LoginSignup
8
4

More than 3 years have passed since last update.

【Go】似た構造体を変換する関数を自動生成するCLIツールを作った話

Posted at

はじめに

サークルにて、DDDライクなレイヤードアーキテクチャでAPIサーバー設計を行っていました。DDDの各層に同じエンティティを示す構造体を定義しましたが、この構造体はものによっては非常に似ていますが、Goの標準で異なる構造体の型を変換する方法はありません。(名前が異なるだけだったり、構造体タグの差の場合はキャスト可能)これに対して、ある一定の変換規約を用意して変換する関数を自動生成しようというのがこのツールの目的です。

生成される関数名は、Convert{{変換元の型名}}To${変換先の型名}です。基本的な変換規約はフィールド名一致かつ型一致ですが、複合型に関しては特別な処理をして妥当な変換を目指します。

使い方

go install github.com/fuji8/gotypeconverter/cmd/gotypeconverter
> gotypeconverter                          
gotypeconverter: gotypeconverter generates a function that converts two different named types.

Usage: gotypeconverter [-flag] [package]


Flags:
  -d string
        destination type
  -o string
        output file; if nil, output stdout
  -pkg string
        output package; if nil, the directoryName and packageName must be same and will be used
  -s string
        source type
  -structTag string
         (default "cvt")

変換例

package main

type Hoge struct {
    foo   int
    bar   float64
    b     string
    m     string
    s     int8
    slice []string
}

type HogeHoge struct {
    foo int
    bar float32
    c   string `cvt:"b"`
    embd
    s struct {
        f      int
        match  int8
        ignore int8
    }
    notSlice string `cvt:"slice"`
}

type embd struct {
    m string
}

HogeHogeHogeに変換する関数を生成させます。

> gotypeconverter -s Hoge -d HogeHoge -pkg main .
// Code generated by gotypeconverter; DO NOT EDIT.
package main

func ConvertHogeToHogeHoge(src Hoge) (dst HogeHoge) {
        dst.foo = src.foo
        dst.a = src.a
        dst.c = src.b
        dst.embd.m = src.m
        dst.s.match = src.s
        if len(src.slice) > 0 {
                dst.notSlice = src.slice[0]
        }
        return
}

それぞれの説明

フィールド名の一致かつ型一致で、代入します。dst.foo = src.foo

フィールド名が一致していても、型が不一致の場合は代入しません。bar (キャストは行われません)

該当する構造体タグがある場合は、フィールド名の代わりに構造体タグを見ます。HogeHogecbとして解釈されます。構造体タグの詳細は、readmeを参照してください。

構造体の埋め込みは、その場で展開して解釈します。つまりHogeHogeは直接m stringのフィールドを持つものとして解釈します。ただし生成される式では、名前付き型の名前(embd)を省略しません。

基底型と構造体では型は不一致ですが、構造体側に一致する基底型があれば、代入します。dst.s.match = src.s 一致する基底型のフィールドが複数ある場合は、一番上にあるものを優先します。

基底型とスライスでも型は不一致ですが、スライスの0番目の要素で代入します。dst.notSlice = src.slice[0]

許可される型

タイトルには似た構造体と書きましたが、変換する型の種類自体は構造体に留まりません。一部不十分ケースも存在しますが、基本的にgo/typestypes.Named, types.Basic, types.Slice, types.Struct, types.Pointerで構成される型について動作します。(types.PointerはWIP)

以下はあまり有用な例ではありませんが、仕様上はこのようなことも可能です。

package main

type foo struct {
    bar  int
    fake int
}

type baz struct {
    bar int
}

このような構造体の定義があったときに、以下のように実行します。

> gotypeconverter -s \*\[\]\[\]\*foo -d \*\[\]\[\]\*baz -pkg main .
// Code generated by gotypeconverter; DO NOT EDIT.
package main

func ConvertPointerSliceSlicePointerfooToPointerSliceSlicePointerbaz(src *[][]*foo) (dst *[][]*baz) {
        dst = new([][]*baz)
        (*dst) = make([][]*baz, len((*src)))
        for i := range *src {
                (*dst)[i] = make([]*baz, len((*src)[i]))
                for j := range (*src)[i] {
                        (*dst)[i][j] = new(baz)
                        (*(*dst)[i][j]) = ConvertfooTobaz((*(*src)[i][j]))
                }
        }
        return
}

func ConvertfooTobaz(src foo) (dst baz) {
        dst.bar = src.bar
        return
}

冗談みたいに長い名前の関数が生成されますが、これでも正常に動作します。

func main() {
    f := &foo{bar: 100, fake: 2}
    src := &[][]*foo{{f}}
    dst := ConvertPointerSliceSlicePointerfooToPointerSliceSlicePointerbaz(src)
    fmt.Println((*dst)[0][0].bar) // 100
}

その他の機能

出力するファイルを関数名でソートして並び変えています。これによって出力するファイルが同じであっても実行順序に左右されず、同一の結果が得られます。

比較

少し調べたところ、これを静的に行うものは見つかりませんでした。(あったら教えてください)ただ動的に変換するのであれば、jinzhu/copierで可能です。

動的には動的でしか出来ないことがありますが、静的に生成するメリットもあります。まず変換で実行時にエラーが返ってくる余地がありません。(パニックは起こりうる)変換する関数を生成するわけなので、どのように変換されるかは明白です。

また静的に生成できるということは、もちろん手で変換する関数を書くことも出来ます。手で書くこととの比較は正直なところ、「楽できる」が主な利点です。(デメリットは割と多い)その他には、//go:generateディレクティブを使うことによって意味のある形で、型の変換の推移をソースコード中に書くことが出来ます。

既知のバグ

-pkgが必要

パッケージ名とディレクトリ名が一致しない場合は、-pkgで該当するパッケージ名を指定してください。

mainパッケージでは、パッケージ名とディレクトリ名が一致しないことが多々あると思いますが、-pkg mainを追加して実行してください。

tmp*.goが残る

解析が失敗した時に、tmp*.goのようなファイルが残る可能性があります。 解析に使用される一時ファイルで、終了時に残ってしまった場合は消してください。

キャストエラー

package main

type foo struct{bar int}
type baz struct{bar int}

これらの型で生成すると、以下のように生成されます。

> gotypeconverter -s foo -d baz -pkg main . 
// Code generated by gotypeconverter; DO NOT EDIT.
package main

func ConvertfooTobaz(src foo) (dst baz) {
        dst = src
        return
}

このコードは正しくありません。

正しくはキャストをする必要があるのですが、現在キャストには対応してません。

おわりに

作成した「型を変換する関数を生成するツール」を紹介させていただきました。

開発の経緯は上で書いた通り、開発中に欲しくなったからでした。しかし、そこで使われる変換関数はそこまで多くないことに加えて、生成する度に正しく変換できているか確認する必要があるので、素直に手で関数を書いたほうが楽だったと思います。(バグったりしていたらツールの修正しないといけない)ただ静的解析は前々から興味があって、なんか書いてみたいと思っていたのでいい機会でした。

現在の実装で、対象としているAPIサーバーのユースケースはおおよそ満たしていると思っています。しかしまだまだ機能的には十分とは言えません。既知のバグも数多く残っていますし、実装していない型の種類も残っています。

また、開発中に想定していた用途は設計的な用途でした。これの意味は、構造体の宣言のそばに//go:generateで記述して型の遷移も併せて定義することで、可読性の向上に役立つのではと考えていました。実際にその用途で使ったところ、上手くいかないケースが存在しました。少し構造が違った型の変換の場合は、構造体タグでの付加情報なしでは思った通りの変換を生成できませんでした。1対1、1対多の変換であれば構造体タグを用いても、まだ人間が読めるレベルなのですが、多対多の場合では構造体タグが複雑になってしまうことがあり、むしろ可読性が下がりそうな気がしています。

これは、構造体タグという付加情報に頼っている変換規約が原因です。付加情報を用いないで妥当なコードを生成する変換規約を考える必要があります。別の対処法としては、設計的な用途を諦めて(//go:generateを使わない)fillstructのような補完的な用途を目指すというものがあると思っています。

変換規約自体は生成されるコードを見ながら割と適当に決めてきたので、確かな確証がありません。ですので、これからは変換規約は少しずつ改善しながら、用途としては補完を目指していこうと考えています。(あとgoplsで遊んでみたい)

コードも本当に長々書いているので、コード設計からやり直さないといけないと思っています。(影響が明確という理由で再帰関数の引数を増やしすぎた)ここら辺の開発の話はどこかで書けたらいいなと思います。

最後になりますが、生成されたコードのこれ変じゃない?や、もっといいやり方あるよ等あればよろしくお願いします。

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4