はじめに
ご無沙汰しております。54代プログラミング研究会会長を務めているシロフクロウHarrisonKawagoeです。
シンカンセンスゴイカタイアイスを召し上がる季節になりましたね。
いつも頭おかしいことをしていますが今回もだいぶ意味不明な話をしたいと思います。
ところでみなさん、メタプログラミングってご存知ですか?
メタプログラミング?何それおいしいの?
自分は昔、C#でAPI通信を行う為にとあるcurlコマンドに相当するコードを生成しようと思いましたが、当時は.NETでアプリを作るのが初めてだった為、中々うまく行きませんでした。
その後、このcurlコマンドに相当するC#のコードを探してみましたが、下記のサイトが検索結果に出てきました。
こちらはcurlコマンドをC#のコードに変換してくれるサイトです。当時はC#を使い始めたばかりなので色々苦労しましたが、このサイトがあったおかげでネット通信関連の実装がかなり楽になりました。
このように、ソースコードあるいは設定ファイルを自動で生成するプログラムを開発することが、メタプログラミングと呼ばれています。
メタプログラミングは普通のプログラミングよりハードルが若干高くて、あんまり広く使われていませんが、役立つ場面は多くあります。例えば、Web開発の現場では、案件によって違うプログラミング言語を使ったりしますが、似たような処理をするコードを書くことは結構あります。もちろんフレームワークやライブラリの依存関係にもよりますが、メタプログラミングして似たようなコードを自動で生成してくれるツールがあったら、より効率的に開発を進められるのでは無いかと思われます。
Go言語でメタプログラミングをやる方法
Go言語ではメタプログラミングに使えるパッケージが標準で搭載されているので、下記の方法で行うことができます:
reflect
によるプログラム実行時の変数の解析go/parser
によるソースコードの解析
今回はreflect
を使った実行時の解析について説明したいと思います。
reflectionとは
プログラム実行時に、動的にプログラムの構造や変数などの情報を取得したり、値を書き換えたりする手法がリフレクションと呼ばれています。Go言語の場合、標準パッケージに含まれているreflect
を使うと、プログラム実行時に変数や構造体の型情報の取得と値の変更を行うことができます。
go言語は静的型付け言語ですが、interface{}
型というどんな型でも受け入れることができる万能な型があります。この型使うと、リフレクションによる型チェックや値の更新が可能になります。ただしinterface
型とは全く違うものなので注意が必要です。
リフレクションを実際の開発に使うと、コードの可読性が下がったり、バグの温床になったりするのでプロダクトの開発には基本使われませんが、これによって型の解析など、普通のプログラミングでは実現できないことができたりするので、メタプログラミングの手法の一つとして使われています。
何を作ったのか
まだ制作途中ですが、構造体の型と値に基づいてSQLのQueryを自動で生成してくれるORMっぽいものを作りました。(リポジトリ名なんとかしろよ)
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になっている場合、getTableName
とgetColumnName
でそれぞれsnakecaseに変換するようにしています。
埋め込み構造体への対応
Go言語の場合、構造体に他の構造体を埋め込むことで同じ変数を複数回定義することを回避できます。
例えば、id
、created_at
、updated_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文の生成
上記のステップによって、構造体からテーブル名やカラム一覧を取得することができました。
テーブル名、カラム一覧及び条件文はそれぞれcolumns
、tableName
とconditions
に保存されているので、文字列を組み合わせることで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の仕様をちゃんと理解しないとやばいことになります)
開発支援やコードの自動生成に興味ある方はぜひメタプログラミングを試してみてはいかがでしょうか。
明日は幹事長のひらめ天氏の記事ですね。どういう記事が生成されるんでしょうか。楽しみですね。