pythonのクラス定義からUnmarshalJSON()でのvalidation付きのgoのstruct定義を生成する
pythonのクラス定義からUnmarshalJSON()でのvalidation付きのgoのstruct定義を生成してみる。(より細かなバリエーションは https://github.com/podhmo/egoist/tree/master/examples/e2e/generate-structkit にある)
なぜUnmarshalJSON()付きでの生成?
required/unrequiredのvalidationはしつつも、unmarshal後のstructでポインターのフィールドをなるべく持ちたくない。昔いろいろこのあたりの挙動について考えたりしていた。詳細はこのあたりに。
- goでJSONのunmarshal時のrequiredなfieldの扱いについて
- goでrequired/unrequiredに対応しつつpointerを使わないunmarshalJSONのエラーメッセージについて
その時の結論としてvalidation付きのUnmarshalJSON()を実装するのがマシだろうということになった。
しかし真面目に実装しようとすると手が止まる程度には辛い。自分で手書きするのはつらすぎたので、生成することにした。本当は依存ゼロが嬉しいが、validation errorを扱うのにmapで情報を保持するmulti errorが欲しくなった。既存のライブラリが見つかればそちらを使いたかったがなかったので書いた1。
結局欲しいものは
なぜUnmarshalJSON()付きのstruct生成をegoistに設けようと思ったかというと、結局欲しいものは以下の様なLoad関数だけなのでは?と言う発想を持ったため。名前に関してはLoad()ではなくBind()という名前でも良いかもしれない。
func Load(io.Reader, interface{}) error
例えば、設定ファイルの読み込みに関してもファイルIOを受け取り、この関数でvalidation付きで読み込めば良いし。あるいはREST APIのことを考えても、net/httpのHandlerの中で、RequestのBodyを受け取り、この関数でvalidation付きで読み込めば良い。あるいは内部的なAPIだけを考えるならフォームデータでのPOSTなどは考えなくて良い。するとio.Readerに対する対応だけである程度は十分なのではという考えになった。
出力のことを考えるなら、validation済みのものだけを内部では保持しているはずなので、何も気にしなくて良い。というわけで以下があれば良いという結論になった。
- struct定義
- UnmarshalJSON()
hello world的なtutorial
ここからはegoistの利用方法の話。使い方の説明も兼ねてhello world的なtutorialをしてみる。
structの生成
egoist init structkit
で雛形が生成される。
$ egoist init structkit level:INFO message:create definitions.py name:egoist.cliL23
生成されたdefinitions.pyで生成してあげると、利用例のコードが出力される。
$ python definitions.py generate [D] create ./model [F] create ./model/objects.go
自動で生成されたdefinitions.pyに含まれるのは以下の様なクラス定義。
definitions.py (の一部)
class Author: name: str # createdAt: datetime.date class Article: title: str author: t.Optional[Author] content: str comments: t.List[Comment] class Comment: author: Author content: str
クラス定義以外の部分のコードは後で貼るが、app.include("egoist.generators.define_struct_set")
をしていることで、@app.define_struct_set(...)
が使える様になっている。もちろんこのあたりの名前は暫定的なものでAPIは変わる可能性がある。関数名のmodels__objects.go
を利用して出力するファイル名はmodels/objects.go
になる。
definitions.py (の一部)
from __future__ import annotations import typing as t from egoist.app import App, SettingsDict, parse_args settings: SettingsDict = {"rootdir": "", "here": __file__} app = App(settings) app.include("egoist.directives.define_struct_set") # (このあたりにクラス定義。実際の利用ではimportしてきても良い) @app.define_struct_set("egoist.generators.structkit:walk") def model__objects() -> None: from egoist.generators.structkit import runtime, structkit with runtime.generate(structkit, classes=[Article]) as m: m.package("model") if __name__ == "__main__": for argv in parse_args(sep="-"): app.run(argv)
structの利用
生成されたstructを使ってみる。loadパッケージという名前のパッケージを作り、これを呼び出すmain.goを作って生成されたstructのloadを確認してみることにする。
loadパッケージの作成
テキトーに作る。
$ go mod init m $ mkdir -p load $ edit load/load.go # editorで編集
load/load.go
package load import ( "encoding/json" "io" "github.com/k0kubun/pp" ) func Load(r io.Reader, ob interface{}) error { decoder := json.NewDecoder(r) return decoder.Decode(ob) } func LoadAndPrint(r io.Reader, ob interface{}) error { if err := Load(r, ob); err != nil { return err } pp.Println(ob) return nil }
cmd/load/main.goの作成
この辺でrepl的なものがあればそれで動作チェックがしたいかもしれない。あるいはテストを書いてそれで動作チェックとするか。面倒なので今回はCLIを作成して試すことにする。
CLIの作成方法については以前書いたので工程の詳細は省略する。
テキトーにcmd/load/main.go
を作る。
# 手抜きのためにclikitを使って生成したdefinitions.pyからコピーしてくる $ (cd /tmp && egoist init clikit) $ edit definitions.py # editorで編集 $ python definitions.py generate [F] no change ./model/objects.go [D] create ./cmd/load [F] create ./cmd/load/main.go
空のデータを与えてみて試してみる。エラーが出た。
$ go run cmd/load/main.go --filename <(echo '{}') 2020/05/16 07:05:26 !!Error -- { "summary": "title, required", "messages": { "comments": [ { "text": "required" } ], "content": [ { "text": "required" } ], "title": [ { "text": "required" } ] } } exit status 1
- comments
- content
- title
がなかった。
今度は正しそうなデータを与えてみる。loadできた。細かい話をすると、commentsは必須(pythonでOptional
ではない)だったので、空配列であっても渡す必要があった。
$ go run cmd/load/main.go --filename <(echo '{"title": "hello world", "content": "Hello worlld, this is my first article. ...", "comments": []}') &model.Article{ Title: "hello world", Author: (*model.Author)(nil), Content: "Hello worlld, this is my first article. ...", Comments: []model.Comment{}, }
今度は子オブジェクトで間違えてみる。commentsに空オブジェクトを渡してみる。今度は以下のフィールドが不足している。
- comments.author
- comments.content
何番目のどの要素が間違えたなどの情報が無いので不親切かもしれない。あるいは、今回自分で定義したLoad()
の部分で、エラーメッセージと共に渡されたio.Readerの値を表示するエラーでwrapしてあげても良かったかもしれない。
$ go run cmd/load/main.go --filename <(echo '{"comments": [{}], "title": "hello world", "content": "Hello worlld, this is my first article. ..."}') 2020/05/16 07:11:52 !!Error -- { "summary": "comments, author, required", "messages": { "comments.author": [ { "text": "required" } ], "comments.content": [ { "text": "required" } ] } }
structの改変 -- 明示的なunrequired
先程のcommentsフィールドの扱いを変えてみる。基本的には以下の様なルールで変換される。
- 通常のフィールド -> 値扱いでrequired
- Optionalなフィールド -> ポインター扱いでunrequired
しかし、配列に関しては、値扱いでunrequiredになってほしい。このような場合にはmetadataでunrequiredを直接指定してあげる。
+from egoist.generators.structkit.runtime import metadata, field settings: SettingsDict = {"rootdir": "", "here": __file__} app = App(settings) @@ -18,7 +19,7 @@ class Article: title: str author: t.Optional[Author] content: str - comments: t.List[Comment] + comments: t.List[Comment] = field(metadata=metadata(required=False))
生成し直した後に、commentsを省略した値を渡してみる。今度は大丈夫。
$ python definitions.py generate [F] update ./model/objects.go [F] no change ./cmd/load/main.go $ go run cmd/load/main.go --filename <(echo '{"title": "hello world", "content": "Hello worlld, this is my first article. ..."}') &model.Article{ Title: "hello world", Author: (*model.Author)(nil), Content: "Hello worlld, this is my first article. ...", Comments: []model.Comment{}, }
structの改変 -- tagsの追加
出力されるstruct定義はのコードは以下の様になっている(unmarshalJSON()を除いたもの)。タグ部分を触りたいこともあると思う。今度はjsonだけではなくyamlのフィールドもつけてみる。
// this file is generated by egoist.generators.structkit type Article struct { Title string `json:"title"` Author *Author `json:"author"` Content string `json:"content"` Comments []Comment `json:"comments"` } type Author struct { Name string `json:"name"` } type Comment struct { Author Author `json:"author"` Content string `json:"content"` }
set_metadata_handler()
でtagsの取扱を変える。デフォルトではjsonタグだけを設定していた。
+from egoist.generators.structkit.runtime import set_metadata_handler, Metadata settings: SettingsDict = {"rootdir": "", "here": __file__} app = App(settings) @@ -31,6 +32,13 @@ class Comment: def model__objects() -> None: from egoist.generators.structkit import runtime, structkit + @runtime.set_metadata_handler + def metadata_handler( + cls: t.Type[t.Any], *, name: str, info: t.Any, metadata: runtime.Metadata + ) -> None: + """Yaml also added""" + metadata["tags"] = {"json": [name.rstrip("_")], "yaml": [name.rstrip("_")]} + with runtime.generate(structkit, classes=[Article]) as m: m.package("model")
生成結果はこのように変わる。yamlタグが追加された。
type Article struct { - Title string `json:"title"` - Author *Author `json:"author"` - Content string `json:"content"` - Comments []Comment `json:"comments"` + Title string `json:"title" yaml:"title"` + Author *Author `json:"author" yaml:"author"` + Content string `json:"content" yaml:"content"` + Comments []Comment `json:"comments" yaml:"comments"` } func (a *Article) UnmarshalJSON(b []byte) error { @@ -58,7 +58,7 @@ func (a *Article) UnmarshalJSON(b []byte) error { } type Author struct { - Name string `json:"name"` + Name string `json:"name" yaml:"name"` } func (a *Author) UnmarshalJSON(b []byte) error { @@ -85,8 +85,8 @@ func (a *Author) UnmarshalJSON(b []byte) error { } type Comment struct { - Author Author `json:"author"` - Content string `json:"content"` + Author Author `json:"author" yaml:"author"` + Content string `json:"content" yaml:"content"` } func (c *Comment) UnmarshalJSON(b []byte) error {
今回利用したdefinitions.py
今回利用したdefinitions.pyは以下。
definitions.py
from __future__ import annotations import typing as t from egoist.app import App, SettingsDict, parse_args from egoist.generators.structkit.runtime import metadata, field from egoist.generators.structkit.runtime import set_metadata_handler, Metadata settings: SettingsDict = {"rootdir": "", "here": __file__} app = App(settings) app.include("egoist.directives.define_struct_set") app.include("egoist.directives.define_cli") class Author: name: str # createdAt: datetime.date class Article: title: str author: t.Optional[Author] content: str comments: t.List[Comment] = field(metadata=metadata(required=False)) class Comment: author: Author content: str @app.define_struct_set("egoist.generators.structkit:walk") def model__objects() -> None: from egoist.generators.structkit import runtime, structkit @runtime.set_metadata_handler def metadata_handler( cls: t.Type[t.Any], *, name: str, info: t.Any, metadata: runtime.Metadata ) -> None: """Yaml also added""" metadata["tags"] = {"json": [name.rstrip("_")], "yaml": [name.rstrip("_")]} with runtime.generate(structkit, classes=[Article]) as m: m.package("model") # cli @app.define_cli("egoist.generators.clikit:walk") def cmd__load(*, filename: str) -> None: from egoist.generators.clikit import runtime, clikit with runtime.generate(clikit) as m: os_pkg = m.import_("os") load_pkg = m.import_("m/load") model_pkg = m.import_("m/model") f = m.symbol("f") ob = m.symbol("ob") err = m.symbol("err") m.stmt(f"{f}, {err} := {os_pkg.Open(filename)}") m.stmt(f"if {err} != nil {{") with m.scope(): m.return_(err) m.stmt("}") m.stmt(f"defer {f}.Close()") m.stmt(f"var {ob} {model_pkg.Article}") p = m.let("p", f"&{ob}") m.return_(load_pkg.LoadAndPrint(f, p)) if __name__ == "__main__": for argv in parse_args(sep="-"): app.run(argv)
出力されたファイルたち
長くなってしまうのでこれまで頑なに貼っていなかったが、生成されたgoのファイルは以下のようなもの。
その前に全体のファイル構造を貼っておく。
$ tree . . ├── Makefile ├── cmd │ └── load │ └── main.go ├── definitions.py ├── go.mod ├── go.sum ├── load │ └── load.go └── model └── objects.go 4 directories, 7 files
structの出力部分は後々ファイルを分割して出力する機能もつけるかもしれない。しかし、ファイルを分割して出力する機能をつけようとすると、削除されたクラスや参照への対応がめんどくさい。不要になったファイルを消すことができない。そのあたりをどうするかは考える必要がありそう。
models/objects.go
package model import ( "github.com/podhmo/maperr" "encoding/json" ) // this file is generated by egoist.generators.structkit type Article struct { Title string `json:"title" yaml:"title"` Author *Author `json:"author" yaml:"author"` Content string `json:"content" yaml:"content"` Comments []Comment `json:"comments" yaml:"comments"` } func (a *Article) UnmarshalJSON(b []byte) error { var err *maperr.Error // loading internal data var inner struct { Title *string `json:"title"`// required Author *json.RawMessage `json:"author"` Content *string `json:"content"`// required Comments *json.RawMessage `json:"comments"` } if rawErr := json.Unmarshal(b, &inner); rawErr != nil { return err.AddSummary(rawErr.Error()) } // binding field value and required check { if inner.Title != nil { a.Title = *inner.Title } else { err = err.Add("title", maperr.Message{Text: "required"}) } if inner.Author != nil { a.Author = &Author{} if rawerr := json.Unmarshal(*inner.Author, a.Author); rawerr != nil { err = err.Add("author", maperr.Message{Error: rawerr}) } } if inner.Content != nil { a.Content = *inner.Content } else { err = err.Add("content", maperr.Message{Text: "required"}) } if inner.Comments != nil { a.Comments = []Comment{} if rawerr := json.Unmarshal(*inner.Comments, &a.Comments); rawerr != nil { err = err.Add("comments", maperr.Message{Error: rawerr}) } } } return err.Untyped() } type Author struct { Name string `json:"name" yaml:"name"` } func (a *Author) UnmarshalJSON(b []byte) error { var err *maperr.Error // loading internal data var inner struct { Name *string `json:"name"`// required } if rawErr := json.Unmarshal(b, &inner); rawErr != nil { return err.AddSummary(rawErr.Error()) } // binding field value and required check { if inner.Name != nil { a.Name = *inner.Name } else { err = err.Add("name", maperr.Message{Text: "required"}) } } return err.Untyped() } type Comment struct { Author Author `json:"author" yaml:"author"` Content string `json:"content" yaml:"content"` } func (c *Comment) UnmarshalJSON(b []byte) error { var err *maperr.Error // loading internal data var inner struct { Author *json.RawMessage `json:"author"`// required Content *string `json:"content"`// required } if rawErr := json.Unmarshal(b, &inner); rawErr != nil { return err.AddSummary(rawErr.Error()) } // binding field value and required check { if inner.Author != nil { if rawerr := json.Unmarshal(*inner.Author, &c.Author); rawerr != nil { err = err.Add("author", maperr.Message{Error: rawerr}) } } else { err = err.Add("author", maperr.Message{Text: "required"}) } if inner.Content != nil { c.Content = *inner.Content } else { err = err.Add("content", maperr.Message{Text: "required"}) } } return err.Untyped() }
cmd/load/main.goの方も載せる。名前がcmd__load
扱いなのは後で変えると思う。
cmd/load/main.go
package main import ( "flag" "os" "log" "m/load" "m/model" ) // this file is generated by egoist.generators.clikit // Option ... type Option struct { Filename string // for `-filename` Args []string // cmd.Args } func main() { opt := &Option{} cmd := flag.NewFlagSet("cmd__load", flag.ContinueOnError) cmd.StringVar(&opt.Filename, "filename", "", "-") if err := cmd.Parse(os.Args[1:]); err != nil { if err != flag.ErrHelp { cmd.Usage() } os.Exit(1) } opt.Args = cmd.Args() if err := run(opt); err != nil { log.Fatalf("!!%+v", err) } } func run(opt *Option) error { f, err := os.Open(opt.Filename) if err != nil { return err } defer f.Close() var ob model.Article p := &ob return load.LoadAndPrint(f, p) return nil }
おわり
おわり。gistはこちら
future works
おそらくまだまだ先になるがwebAPIを作るときに何に乗っかろうかを調べてみたりした。
- gin https://gist.github.com/podhmo/8f6cac73528a1acaa28c348ab57829d0
- go-chi https://gist.github.com/podhmo/58d8f6d52adfba41e349a529d08210a0
- echo https://gist.github.com/podhmo/6c551832f564bdde306e9dd724e8546c
現状gin,echo,go-chiを調べた結果go-chiが優勢。
追記
より細かなバリエーションは https://github.com/podhmo/egoist/tree/master/examples/e2e/generate-structkit にある。
-
もっと良いライブラリがあれば教えてほしい。捨てられるなら捨てたい。↩