pythonのクラス定義からUnmarshalJSON()でのvalidation付きのgoのstruct定義を生成する

github.com

pythonのクラス定義からUnmarshalJSON()でのvalidation付きのgoのstruct定義を生成してみる。(より細かなバリエーションは https://github.com/podhmo/egoist/tree/master/examples/e2e/generate-structkit にある)

なぜUnmarshalJSON()付きでの生成?

required/unrequiredのvalidationはしつつも、unmarshal後のstructでポインターのフィールドをなるべく持ちたくない。昔いろいろこのあたりの挙動について考えたりしていた。詳細はこのあたりに。

その時の結論として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は必須(pythonOptionalではない)だったので、空配列であっても渡す必要があった。

$ 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,echo,go-chiを調べた結果go-chiが優勢。

追記

より細かなバリエーションは https://github.com/podhmo/egoist/tree/master/examples/e2e/generate-structkit にある。


  1. もっと良いライブラリがあれば教えてほしい。捨てられるなら捨てたい。