MIS.W 公式ブログ

早稲田大学公認、情報系創作サークル「早稲田大学経営情報学会」(MIS.W)の公式ブログです!

Go言語でメタプログラミングをやってみた【カウントダウンカレンダー2021冬10日目】

はじめに

ご無沙汰しております。54代プログラミング研究会会長を務めているシロフクロウHarrisonKawagoeです。

シンカンセンスゴイカタイアイスを召し上がる季節になりましたね。

いつも頭おかしいことをしていますが今回もだいぶ意味不明な話をしたいと思います。

ところでみなさん、メタプログラミングってご存知ですか?

メタプログラミング?何それおいしいの?

自分は昔、C#API通信を行う為にとあるcurlコマンドに相当するコードを生成しようと思いましたが、当時は.NETでアプリを作るのが初めてだった為、中々うまく行きませんでした。

その後、このcurlコマンドに相当するC#のコードを探してみましたが、下記のサイトが検索結果に出てきました。

curl.olsh.me

こちらはcurlコマンドをC#のコードに変換してくれるサイトです。当時はC#を使い始めたばかりなので色々苦労しましたが、このサイトがあったおかげでネット通信関連の実装がかなり楽になりました。

このように、ソースコードあるいは設定ファイルを自動で生成するプログラムを開発することが、メタプログラミングと呼ばれています。

メタプログラミングは普通のプログラミングよりハードルが若干高くて、あんまり広く使われていませんが、役立つ場面は多くあります。例えば、Web開発の現場では、案件によって違うプログラミング言語を使ったりしますが、似たような処理をするコードを書くことは結構あります。もちろんフレームワークやライブラリの依存関係にもよりますが、メタプログラミングして似たようなコードを自動で生成してくれるツールがあったら、より効率的に開発を進められるのでは無いかと思われます。

Go言語でメタプログラミングをやる方法

Go言語ではメタプログラミングに使えるパッケージが標準で搭載されているので、下記の方法で行うことができます:

  • reflectによるプログラム実行時の変数の解析
  • go/parserによるソースコードの解析

今回はreflectを使った実行時の解析について説明したいと思います。

reflectionとは

プログラム実行時に、動的にプログラムの構造や変数などの情報を取得したり、値を書き換えたりする手法がリフレクションと呼ばれています。Go言語の場合、標準パッケージに含まれているreflectを使うと、プログラム実行時に変数や構造体の型情報の取得と値の変更を行うことができます。

go言語は静的型付け言語ですが、interface{}型というどんな型でも受け入れることができる万能な型があります。この型使うと、リフレクションによる型チェックや値の更新が可能になります。ただしinterface型とは全く違うものなので注意が必要です。

go.dev

リフレクションを実際の開発に使うと、コードの可読性が下がったり、バグの温床になったりするのでプロダクトの開発には基本使われませんが、これによって型の解析など、普通のプログラミングでは実現できないことができたりするので、メタプログラミングの手法の一つとして使われています。

何を作ったのか

まだ制作途中ですが、構造体の型と値に基づいてSQLのQueryを自動で生成してくれるORMっぽいものを作りました。(リポジトリ名なんとかしろよ)

github.com

Go言語は標準でDBとやりとりする為のdatabaseパッケージが標準で備えられています。それを使うとSQL文を実行してデータの取得と変更が行えるようになりますが、database/sqlはDBとやりとりする為の最低限の機能しかついていないので、gormなどのORMを使わない限りは、SQL文の生成や、構造体の値の取得と変更はすべて自力で実装しなければなりません。自分は鳥企画でActionRecordを使うノリでgormを使ったら痛い目にあったので、ORMを自分で作ろうとしていましたが、普段Raw Queryを書いてるけど値の取得や変更で楽したい場合は、sqlxを使うと良いかもしれません。

今回自分が作ったORMは、DBへの接続とSQL文の実行はdatabaseパッケージで行っており、構造体への代入とSQL Queryの自動生成はreflectパッケージで行うようにしています。

SELECT

SELECT文は、指定されたDBのテーブルからデータを取り出す為のSQL文です。

SELECT (カラム1,カラム2) FROM テーブル名 WHERE 条件文

SELECT文は基本このような構文になっていますが、条件を指定しない場合はSELECT...FROM...だけになります。

ORMからselectが呼び出された場合、「構造体の情報の取得→SQL文の生成と実行→フィールドへの代入」の流れで実行されます。今回自分が作ったORMもこれらの機能が実装されています。

型情報とテーブル名の取得

まず、渡された構造体をinterface{}型に変換します。こうすると構造体がreflectによって解析できるようになります。

reflectには、型情報と値の情報をそれぞれ返してくれるTypeOf関数とValueOf関数が備えられています。それ以外に、構造体のフィールド一覧を返してくれるField関数も実装されています。 フィールド名はTypeに含まれているので、それを使って下記のように実装すると、構造体の変数一覧が指定された文字列に書き込まれます。

func extractColumnsFromStruct(model interface{},column *string) {
    typeinf := reflect.TypeOf(model)
    for i := 0; i < typeinf.NumField(); i++ {
        if *column != "" {
            *column += ","
        }
        *column += getColumnName(typeinf.Field(i).Name)
    }
}

また、構造体の名前はValue.Name()から抜き出すことができます。

valuePtr := reflect.ValueOf(models)
value := valuePtr.Elem()
typeinf := value.Type().Elem()
tableName := getTableName(typeinf.Name())

テーブル名とカラム名はsnakecaseで定義されていることが多いので、構造体名と変数名がCamelCaseになっている場合、getTableNamegetColumnNameでそれぞれsnakecaseに変換するようにしています。

埋め込み構造体への対応

Go言語の場合、構造体に他の構造体を埋め込むことで同じ変数を複数回定義することを回避できます。 例えば、idcreated_atupdated_atはどのテーブルにも含まれていることが多いので、gormの場合、これらのフィールドを含んでいるModel構造体はデフォルトで定義されています。

type Model struct {
    ID        uint `gorm:"primarykey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt DeletedAt `gorm:"index"`
}

Model構造体を下記のように他の構造体に埋め込むことで、コード量を減らすことができます。

type User struct {
    gorm.Model
    Name                string              `gorm:"not null"`
}

しかし、goの仕様上、gorm.Modelの中の変数がそのままUserに展開される訳ではないので、こういうケースに対応させるには、gorm.Modelの中身もextractColumnsFromStructで解析する必要があります。Type.Kind()を使うと、型チェックを行うことができるので、これを使ってフィールドが構造体であるかどうかを判断します。構造体であれば、変数名を抜き出すのではなく、再帰処理によって埋め込み構造体の中身を解析するようにしました。

func extractColumnsFromStruct(model interface{},column *string) {
    typeinf := reflect.TypeOf(model)
    valueinf := reflect.ValueOf(model)
    for i := 0; i < typeinf.NumField(); i++ {
        fieldkind := typeinf.Field(i).Type.Kind()
        if fieldkind == reflect.Struct {
            extractColumnsFromStruct(valueinf.FieldByName(typeinf.Field(i).Name).Interface(), column)
        } else {
            if *column != "" {
                *column += ","
            }
            *column += getColumnName(typeinf.Field(i).Name)
        }
    }
}

実際自分が公開したextractColumnsFromStructは、time型とslice型も対応できるように、他の条件分岐も挟んでいますが、仕様に関する説明が長くなりそうのでそこは割愛させていただきます。

SQL文の生成

上記のステップによって、構造体からテーブル名やカラム一覧を取得することができました。 テーブル名、カラム一覧及び条件文はそれぞれcolumnstableNameconditionsに保存されているので、文字列を組み合わせることでSELECT文を生成することができます。

query := fmt.Sprintf("SELECT %s FROM %s", columns, tableName)
if conditions != ""{
   query += (" WHERE "+conditions)
}

これを実行すると、下記のようなSELECT文が生成されます。

SELECT id,created_at,updated_at,name FROM users WHERE id = 4 or id = 5

すべてのカラムを選択する場合は、カラム一覧を*に置き換えても問題無いですが、今回自分が作ったORMのMigration機能はまだ実装されていないので、カラム名を全列挙するようにしています。

フィールドへの代入

select文はdatabase/sqlで実行しました。SQLの実行結果はScan機能で構造体のフィールドに代入していますが、仕様上、変更するフィールドのポインタを全部Scanに渡す必要があるので、フィールドのポインタが収納されたSliceを作成します。

これも同じく埋め込み構造体に対応させる必要があるので、Structである場合は再帰処理を行うようにしています。また、timeとSliceの場合は他の処理が必要になるので、下記のように条件分岐させています。

func AssignFromArgs(model interface{},columns *[]interface{})  {
    valueinf := reflect.ValueOf(model).Elem()
    typeinf := valueinf.Type()
    for i := 0; i < valueinf.NumField(); i++ {
        fieldkind := typeinf.Field(i).Type.Kind()
        if fieldkind==reflect.Slice {
            continue
        }
        if typeinf.Field(i).Type.String() == "time.Time" {
            *columns = append(*columns,valueinf.Field(i).Addr().Interface())
        }else if fieldkind==reflect.Struct {
            AssignFromArgs(valueinf.FieldByName(typeinf.Field(i).Name).Addr().Interface(),columns)
        } else {
            *columns = append(*columns,valueinf.Field(i).Addr().Interface())
        }
    }
}

Scanの引数はargsになっていますが、Sliceの後ろに...を追加するとSliceが展開されるのでargsとして使うことができます。

AssignFromArgs(result,&columns)
err := rows.Scan(columns...)

これで、SELECT文が実行された後の結果が構造体に書き込まれました。

INSERT

ORMからinsert,delete,updateが呼び出された場合、フィールドへの代入が無い以外、裏側で行う処理はselectとほぼ同じ流れになりますが、insertとupdateにはフィールドの値の情報が必要なので、値を取得する方法について軽く説明します。

値の取得

SELECT文のパートでも説明しましたが、ValueOfを呼び出すことで、各フィールドの値一覧を取得することができます。ただし、SQL上では数値型と文字列型を区別する必要があるので、フィールドの型によって違う処理をするようにしました。

func extractValuesFromStruct(model interface{},value *string,isUpdate bool,queries *[]QueryInf)  {
    typeinf := reflect.TypeOf(model)
    valueinf := reflect.ValueOf(model)
    for i := 0; i < typeinf.NumField(); i++ {
        fieldkind := typeinf.Field(i).Type.Kind()
        if fieldkind==reflect.Struct {
            extractValuesFromStruct(valueinf.FieldByName(typeinf.Field(i).Name).Interface(),value,isUpdate,queries)
        } else {
            if *value != ""{
                *value += ","
            }
            if fieldkind == reflect.String{
                *value += "'"+valueinf.Field(i).String()+"'"
            }else{
                *value += fmt.Sprintf("%v",valueinf.Field(i))
            }
        }
    }
}

実際は、データの新規作成と更新に合わせて自動でtimeを生成する必要があるのと、PrimaryKeyの処理もあるので、結構複雑の条件分岐になっています。

終わりに

という感じでメタプログラミングをやってみました。見ての通りコードが絶望的に汚いものが生成されてしまいましたね...

とはいえメタプログラミングの目的は普通のプログラミングと結構違っていて、うまく使えば開発体験がかなり改善されます。実際、自分もWebアプリの開発でActionRecordなどのORMの恩恵を受けていました。SQLが自動で生成してくれると開発者がだいぶ楽になりますよね。(ただし、ORMの仕様をちゃんと理解しないとやばいことになります)

開発支援やコードの自動生成に興味ある方はぜひメタプログラミングを試してみてはいかがでしょうか。

明日は幹事長のひらめ天氏の記事ですね。どういう記事が生成されるんでしょうか。楽しみですね。