Generate model, repository, dao sources for Go application
eevee
はアプリケーション開発時に必要となる
キャッシュやデータベースといったミドルウェアとの効率的なデータのやりとりや
開発時に生じる冗長な作業を自動化するための仕組みを提供します。
データをいかに簡単かつ効率的に参照し書き込めるかということにフォーカスしているため、
ルーティングなどの機能は提供していません。
そのため、 echo や chi や goji といったアプリケーションフレームワークと同時に利用することを想定しています。
goa が提供しているような APIリクエスト・レスポンス を自動生成する機能等も存在しますが、
プロジェクトにあわせて導入するしないを判断することができます。
eevee
が提供する機能は主に次のようなものです。
- スキーマ駆動開発によるモデル・リポジトリ層の自動生成
- モデル間の依存関係の自動解決
Eager Loading
/Lazy Loading
を利用した効率的なデータ参照- テスト開発を支援する mock インスタンス作成機能
- モデルからJSON文字列への高速な変換
- API リクエスト・レスポンスとそのドキュメントの自動生成
- プラグインを用いた柔軟なカスタマイズ
eevee
は 600 を超えるテーブル、150万行を超える規模のアプリケーション開発を日々支えており、
小規模開発から大規模開発まで様々な用途で利用することができます。
- 使い方
- 設定ファイル
- 各機能について
- eevee による実践的な開発方法
- Committers
- License
まずは実際に eevee を利用することで何ができるようになるのかを見ていきます。
ここで紹介しているコードは _example/01_simple 配下に置かれています。
$ go get go.knocknote.io/eevee/cmd/eevee
無事インストールできていれば、 $GOPATH/bin/eevee
があるはずです。
eevee --help
が実行できれば、インストールは完了です
アプリケーション開発のための作業用ディレクトリを作成してください
いつものように go.mod
ファイルを作成してください
$ go mod init simple
今回は echo の https://echo.labstack.com/cookbook/crud をベースに
eevee
を利用したいと思います。
リンク先にあるコードは以下のようになっています。
package main
import (
"net/http"
"strconv"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
)
type (
user struct {
ID int `json:"id"`
Name string `json:"name"`
}
)
var (
users = map[int]*user{}
seq = 1
)
func createUser(c echo.Context) error {
u := &user{
ID: seq,
}
if err := c.Bind(u); err != nil {
return err
}
users[u.ID] = u
seq++
return c.JSON(http.StatusCreated, u)
}
func getUser(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
return c.JSON(http.StatusOK, users[id])
}
func updateUser(c echo.Context) error {
u := new(user)
if err := c.Bind(u); err != nil {
return err
}
id, _ := strconv.Atoi(c.Param("id"))
users[id].Name = u.Name
return c.JSON(http.StatusOK, users[id])
}
func deleteUser(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
delete(users, id)
return c.NoContent(http.StatusNoContent)
}
func main() {
e := echo.New()
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Routes
e.POST("/users", createUser)
e.GET("/users/:id", getUser)
e.PUT("/users/:id", updateUser)
e.DELETE("/users/:id", deleteUser)
// Start server
e.Logger.Fatal(e.Start(":1323"))
}
このサンプルでは user
というリソースに対して CRUD 操作を行っていますが、
サンプルのためデータはサーバのメモリ上に置かれています。
これを eevee を用いて データベース(MySQL) 上への操作に変更することを行ってみます。
まずは、上記のコードを server.go
として保存します。
user
に関するデータを MySQL 上に保存することにしたので、まずはそのスキーマを定義します。
次のようなコマンドで、 id
と name
というカラムをもった users
テーブルを作るDDLが書かれたファイルを作成します。
$ mkdir schema
$ cat <<'EOS' >> schema/users.sql
CREATE TABLE `users` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(30) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
EOS
eevee の init
コマンドを実行します。
$ eevee init --schema schema --class config
--schema
オプションでスキーマファイルが置かれているディレクトリを指定します ( 今回は schema
)
--class
オプションでクラスファイルが生成されるディレクトリを指定します ( 今回は config
)
※ eevee で Go のソースコードを自動生成する際、上述の クラスファイル というものを参照します。
これについては後で詳しく説明します。
うまくいくと .eevee.yml
というファイルが作成されているはずです。
この状態で、以下のコマンドを実行してください
$ eevee run
先ほど作成した .eevee.yml
を読み込み、定義に従ってソースコードの自動生成を行います。
自動生成がうまくいくと、作業ディレクトリ配下は以下のようになるはずです。
├── config
│ └── user.yml
├── dao
│ └── user.go
├── entity
│ └── user.go
├── go.mod
├── go.sum
├── model
│ ├── model.go
│ └── user.go
├── repository
│ ├── repository.go
│ └── user.go
├── schema
└── users.sql
新しく、 config
entity
dao
model
repository
というディレクトリが作られています。
それでは、自動生成されたコードを使って server.go
を修正します。
修正した後のコードは以下です。
package main
import (
"context"
"database/sql"
"io/ioutil"
"net/http"
"simple/entity"
"simple/repository"
"strconv"
_ "github.com/go-sql-driver/mysql"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
)
var (
db *sql.DB
)
func createUser(c echo.Context) error {
tx, err := db.Begin()
if err != nil {
return err
}
ctx := context.Background()
repo := repository.New(ctx, tx)
reqUser := new(entity.User)
if err := c.Bind(reqUser); err != nil {
return err
}
user, err := repo.User().Create(ctx, reqUser)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return c.JSON(http.StatusCreated, user)
}
func getUser(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
tx, err := db.Begin()
if err != nil {
return err
}
ctx := context.Background()
repo := repository.New(ctx, tx)
user, err := repo.User().FindByID(ctx, uint64(id))
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return c.JSON(http.StatusOK, user)
}
func updateUser(c echo.Context) error {
reqUser := new(entity.User)
if err := c.Bind(reqUser); err != nil {
return err
}
id, _ := strconv.Atoi(c.Param("id"))
tx, err := db.Begin()
if err != nil {
return err
}
ctx := context.Background()
repo := repository.New(ctx, tx)
user, err := repo.User().FindByID(ctx, uint64(id))
if err != nil {
return err
}
user.Name = reqUser.Name
if err := user.Save(ctx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return c.JSON(http.StatusOK, user)
}
func deleteUser(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
tx, err := db.Begin()
if err != nil {
return err
}
ctx := context.Background()
repo := repository.New(ctx, tx)
if err := repo.User().DeleteByID(ctx, uint64(id)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return c.NoContent(http.StatusNoContent)
}
func init() {
{
conn, err := sql.Open("mysql", "root:@tcp(localhost:3306)/?parseTime=true")
if err != nil {
panic(err)
}
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS eevee"); err != nil {
panic(err)
}
}
{
conn, err := sql.Open("mysql", "root:@tcp(localhost:3306)/eevee?parseTime=true")
if _, err := conn.Exec("DROP TABLE IF EXISTS users"); err != nil {
panic(err)
}
sql, err := ioutil.ReadFile("schema/users.sql")
if err != nil {
panic(err)
}
if _, err := conn.Exec(string(sql)); err != nil {
panic(err)
}
}
}
func main() {
e := echo.New()
conn, err := sql.Open("mysql", "root:@tcp(localhost:3306)/eevee?parseTime=true")
if err != nil {
panic(err)
}
db = conn
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Routes
e.POST("/users", createUser)
e.GET("/users/:id", getUser)
e.PUT("/users/:id", updateUser)
e.DELETE("/users/:id", deleteUser)
// Start server
e.Logger.Fatal(e.Start(":1323"))
}
ひとつずつ見ていきます。
func init() {
{
conn, err := sql.Open("mysql", "root:@tcp(localhost:3306)/?parseTime=true")
if err != nil {
panic(err)
}
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS eevee"); err != nil {
panic(err)
}
}
{
conn, err := sql.Open("mysql", "root:@tcp(localhost:3306)/eevee?parseTime=true")
if _, err := conn.Exec("DROP TABLE IF EXISTS users"); err != nil {
panic(err)
}
sql, err := ioutil.ReadFile("schema/users.sql")
if err != nil {
panic(err)
}
if _, err := conn.Exec(string(sql)); err != nil {
panic(err)
}
}
}
func init()
で行っている処理は、このサンプルコードを動かすために
データベースや users
テーブルを作成している処理です。
ここでは、ローカルの MySQL サーバにつないで、 eevee
という名前のデータベースを作成し、
そこに users
テーブルを作成しています ( もしすでに存在してたら削除します )。
func main() {
e := echo.New()
conn, err := sql.Open("mysql", "root:@tcp(localhost:3306)/eevee?parseTime=true")
if err != nil {
panic(err)
}
db = conn
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Routes
e.POST("/users", createUser)
e.GET("/users/:id", getUser)
e.PUT("/users/:id", updateUser)
e.DELETE("/users/:id", deleteUser)
// Start server
e.Logger.Fatal(e.Start(":1323"))
}
func main()
で行っている処理で変更があるのは
conn, err := sql.Open("mysql", "root:@tcp(localhost:3306)/eevee?parseTime=true")
if err != nil {
panic(err)
}
db = conn
の部分です。 func init()
で作ったデータベースに対応するコネクションを作っています。
あわせて
var (
db *sql.DB
)
で、 db
インスタンスをグローバルに定義し、 CRUD
操作のいずれからも同じインスタンスを参照するようにします。
CRUD のうち、 CREATE は以下のように変わりました。
func createUser(c echo.Context) error {
tx, err := db.Begin()
if err != nil {
return err
}
ctx := context.Background()
repo := repository.New(ctx, tx)
reqUser := new(entity.User)
if err := c.Bind(reqUser); err != nil {
return err
}
user, err := repo.User().Create(ctx, reqUser)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return c.JSON(http.StatusCreated, user)
}
eevee は、あるリソースに対する CRUD 操作を repository
パッケージを通して行うように設計されています。
今回は user
リソースを操作するため、 repository.User
にアクセスするのですが、
その方法は repository.Repository
という共通のインスタンスを用いて行います。
共通インスタンスを作るためには、 context.Context
と *sql.Tx
が必要なので、それらを作ってから初期化します。
ctx := context.Background()
tx, err := db.Begin()
...
repo := repository.New(ctx, tx)
次に user
を作ります。 repo.User()
で user
リソースへアクセスすることができるようになり、
リソース作成の場合は Create(context.Context, *entity.User) (*model.User, error)
を実行します。
user, err := repo.User().Create(ctx, reqUser)
if err != nil {
return err
}
entity.User
はロジックをもたない、シリアライズ可能なデータ構造です。
まずは、リクエスト内容をこのインスタンスにマッピングすることで、作成したいデータ構造を表現します。
データの作成と同時に、 *model.User
が返却されます。
model
パッケージにはアプリケーション開発に役立つ API が豊富に存在します ( 詳細は後述 )。
ここではデータベース上に作成されたレコードと対応するインスタンスが返却されたと考えてください。
CRUD のうち、 READ は以下のように変わりました。
func getUser(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
tx, err := db.Begin()
if err != nil {
return err
}
ctx := context.Background()
repo := repository.New(ctx, tx)
user, err := repo.User().FindByID(ctx, uint64(id))
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return c.JSON(http.StatusOK, user)
}
まずは CREATE のときと同様に repository.Repository
を作成することから行います。
ここで、読み込み操作にも関わらず db.Begin()
と tx.Commit()
を実行していることが奇妙に思えるかもしれません。
このような設計にしている背景として
- CRUDのどの操作を行うかに関係なく、統一されたインターフェース (
repository.New()
) を通してリポジトリを作成し、操作してほしい - 読み出しのみであってもキャッシュの作成など書き込み操作が含まれる場合もあり、トランザクションを前提としても良いケースもある(※
repository.New()
には設定によってsql.Tx
以外のトランザクションインスタンスを渡すことができます )
のようなものがあります。
ですが、それでも読み出しのみのAPIに関してはトランザクションを作りたくない場合があるかもしれません。
そのため、現在の eevee では上記のような考え方に対する解は持っていませんが、
声が多くある場合は、インターフェースを見直すことも検討しています。
user, err := repo.User().FindByID(ctx, uint64(id))
if err != nil {
return err
}
で user
インスタンスを取得します。このとき得られるのは *model.User
インスタンスです。
モデルインスタンスは JSON 文字列への高速変換をサポートしているため、
そのまま以下のように JSON として出力することができます。
return c.JSON(http.StatusOK, user)
CRUD のうち、 UPDATE は以下のように変わりました。
func updateUser(c echo.Context) error {
reqUser := new(entity.User)
if err := c.Bind(reqUser); err != nil {
return err
}
id, _ := strconv.Atoi(c.Param("id"))
tx, err := db.Begin()
if err != nil {
return err
}
ctx := context.Background()
repo := repository.New(ctx, tx)
user, err := repo.User().FindByID(ctx, uint64(id))
if err != nil {
return err
}
user.Name = reqUser.Name
if err := user.Save(ctx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return c.JSON(http.StatusOK, user)
}
repository.Repository
を作成する流れは同じです。
特筆すべきは、 user.Save(ctx)
で更新処理を実現していることでしょう。
eevee が自動生成したコードを用いてリソースを更新するには、2通りの方法があります。
一つは、 repo.User().UpdateByID(context.Context, uint64, map[string]interface{}) error
を用いた方法です。
repository
パッケージを通して、更新したいレコードの ID と 更新したいカラム名とその値を map にしたものを渡します。
この方法は直感的ではありますが map[string]interface{}
を手動で作成する場合などは、
値が正しいかをコンパイラで検査できないため、誤りを実行時にしか気づけないというデメリットもあります。
もう一つは Save(context.Context)
です。
これは書き込み可能なモデルがもつ機能のうちのひとつで、
モデルインスタンスをどういった手段で作ったかによって Save(context.Context)
を呼んだ際に
適切にリソースの作成または更新処理が走ります。
この Save(context.Context
をリソースの作成・更新手段として利用することによって、
レコードがあるとき・ないときといった場合分けや、どの値を更新すべきかといったことを意識する必要がありません。
今回のケースでは、 FindByID()
で取得したインスタンスであることから、すでに存在するレコードを引いたことが自明なため、
Save()
を呼んだ際にレコードの更新処理が走ります。
CRUD のうち、 DELETE は以下のように変わりました。
func deleteUser(c echo.Context) error {
id, _ := strconv.Atoi(c.Param("id"))
tx, err := db.Begin()
if err != nil {
return err
}
ctx := context.Background()
repo := repository.New(ctx, tx)
if err := repo.User().DeleteByID(ctx, uint64(id)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return c.NoContent(http.StatusNoContent)
}
repository.Repository
を作って以下のように
if err := repo.User().DeleteByID(ctx, uint64(id)); err != nil {
return err
}
を呼べば、レコードを削除することができます。
ここまでで大まかな使い方の流れを理解してもらったところで、
次は eevee
が自動生成を行う際に参照しているファイルについて説明します。
eevee
を用いたアプリケーション開発を行う場合は、基本的にこれから説明する
二種類のファイルに設定を記述していきます。
eevee init
をおこなうと、実行ディレクトリ配下にある go.mod
を読み込んで
アプリケーション名を取得し、 .eevee.yml
というファイルを作成します。
init
コマンド実行時にオプションを与えることで、あらかじめ設定値を .eevee.yml
に反映させた上で生成することが可能ですが、あとから YAML
ファイルを編集しても同じです。
設定ファイルは一番簡素な状態だと以下のようなものです
.eevee.yml
module: module_name
module
には go.mod
から読み込んだモジュール名が入ります ( もちろん変更可能です )。
これでも良いのですが、もう少しここに書き足してみます。
eevee
はまずこの設定ファイルを読み込んで、後述する クラスファイル をどこに生成するかを判断します。
デフォルトでは eevee run
を実行したディレクトリ配下に生成するため、この挙動を変えたい場合は以下のように設定します。
module: module_name
class: config
これで クラスファイル が config
ディレクトリ配下に生成されるようになります。
あわせて、 クラスファイル を生成するための元となるスキーマファイルを格納しているディレクトリも指定してみます。ここでは、 schema
ディレクトリ配下にスキーマファイルを配置していることを前提として
module: module_name
class: config
schema: schema
と設定します。このように書くと、 eevee
は eevee run
を実行した際にスキーマファイルがどこにあるのかを把握して読み込み、その結果を クラスファイル として指定されたディレクトリ配下に自動生成し、さらにその内容から Go のソースコードを自動生成します。
.eevee.yml
には他にも多くのパラメータを設定することができ、自動生成全体に関わる挙動を変更することができます。
module: module_name
graph: graph
class: config
schema: schema
plugins:
plugin-name:
repo: github.com/path/to/plugin-repo
dao:
default: db
datastore:
db:
before-create:
- request-time
before-update:
- request-time
entity:
plugins:
- plugin-name
Go のソースコードを自動生成する際に利用するモジュール名を指定します。
go.mod
が存在する場合は自動的にモジュール名を読み取って書き込みます。
スキーマファイルを配置する場所を指定することができます
クラスファイルを生成するパスを指定することができます
クラスファイルの依存関係を可視化したウェブページを表示する機能です。
graph: path/to/graph
と指定することで、指定先に index.html
や viz.js
といったページの表示に必要なファイルが生成されます。
また、 eevee serve
コマンドを実行すると、生成したファイルをサーブするウェブサーバが立ち上がります。
entity
dao
model
repository
などのソースコードを自動生成する際の起点となるパスを指定することができます。デフォルトは .
( eevee run
実行時のディレクトリ ) です
API定義が書かれた YAML
ファイルが格納されているパスを指定することができます
APIリクエスト・レスポンスを自動生成する機能を利用した際に、 同時に自動生成する APIドキュメント の生成場所を指定することができます
dao
というパッケージ名を変更するために使用します。
dao
の役割はそのままに、名前だけを変更したい場合に利用します
クラスファイルを自動生成する際に利用する、デフォルトの datastore
を変更できます。
何も指定しない場合は db
が使用されます
datastore
にはリリース時点では db
の他に rapidash
が利用できます
datastore
の種類ごとにどのタイミングでどんなプラグインを使用して自動生成を行うかを指定することができます。
以下の例では、 db
では create
と update
実行前のタイミングで、 request-time
というプラグインを利用することを指定しています。同様に、 datastore
として rapidash
が指定された場合は create
実行前のタイミングで other-plugin
というプラグインが使用されることを示しています。
dao:
datastore:
db:
before-create:
- request-time
before-update:
- request-time
rapidash:
before-create:
- other-plugin
entity
というパッケージ名を変更するために使用します。
entity
の役割はそのままに、名前だけを変更したい場合に利用します
entity
のファイルを自動生成する際に使用するプラグインのリストを指定します
model
というパッケージ名を変更するために使用します。
model
の役割はそのままに、名前だけを変更したい場合に利用します
repository
というパッケージ名を変更するために使用します。
repository
の役割はそのままに、名前だけを変更したい場合に利用します
eevee
では、アプリケーション外部とやりとりする場面では常にAPIのインターフェースに context.Context
を利用します。
ですがアプリケーションによっては、独自の context
パッケージを使用したい場合もあるでしょう。
そういったケースに対応できるよう、 context
パッケージのインポート先をカスタマイズすることができます。
デフォルトのインポート先 ( context
) を指定したパスでのインポートに置き換えます。
インポート先のパッケージで type Context interface {}
を定義し、 context.Context
とコンパチのインターフェースを実装することで、アプリケーション独自の context
に差し替えることができます。
自動生成コードに複数形の名前を用いたい場合、クラスファイルで指定された名前を利用して自動的に変換しています。
ですが、すべての英単語を正しく変換できるわけではないため、
自動生成された名前が間違っている場合は、正しい名前を個別に指定する必要があります。
複数形にした場合の名前を書きます
単数形の場合の名前を書きます
クラスファイルのメンバごとにレンダリング時の挙動を指定するのが面倒な場合は、 このパラメータを指定することで一括で挙動を変更することができます。
- lower-camel : 出力時に lowerCamelCase を利用する
- upper-camel : 出力時に UpperCamelCase を利用する
- lower-snake : 出力時に lower_snake_case を利用する
通常、クラスファイルのメンバには Go のプリミティブ型のみを指定するのですが、 アプリケーションによってはプリミティブ型を拡張した型を用いたい場合もあるでしょう。
(例)
type ID uint64
func (id ID) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprint(id)) // bigint をデコードできないクライアントのために、文字列として出力
}
上記のようなケースでは、以下のように .eevee.yml
に記述しておくことでクラスファイル内で ID
型が利用できるようになります。
primitive_types:
- name: ID
package_name: entity
default: 1
as: uint64
型の名前を記述します
対象の型がどのパッケージに属するかを指定します
その型に値する値を出力する際のデフォルト値を指定します ( テストデータの自動生成に使用します )
関連するプリミティブ型を指定します
クラスファイルは、eevee が Go のソースコードを自動生成する際に読み込むファイルです。
基本的にはスキーマとクラスファイルは 1:1
の関係になります。
ただし、クラスファイルを作るために必ずスキーマを書かないといけないわけではなく、
手でゼロから書くことも可能です。
これは例えば、クラスファイルを作りたいが、そのクラスに対応するデータの保存先が RDBMS でない場合などに用います ( 例えば KVS に保存するなど )
ここでは、説明のためにスキーマファイルがある前提で、
そこからクラスファイルを自動生成する流れを説明します。
まず、例として以下のようなスキーマを持つテーブルからクラスファイルを生成します。
CREATE TABLE `users` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(30) DEFAULT NULL,
`sex` enum('man','woman') NOT NULL,
`age` int NOT NULL,
`skill_id` bigint(20) unsigned NOT NULL,
`skill_rank` int NOT NULL,
`group_id` bigint(20) unsigned NOT NULL,
`world_id` bigint(20) unsigned NOT NULL,
`field_id` bigint(20) unsigned NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_users_01` (`name`),
UNIQUE KEY `uq_users_02` (`skill_id`, `skill_rank`),
KEY `idx_users_03` (`group_id`),
KEY `idx_users_04` (`world_id`, `field_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
このスキーマを schema/users.sql
として保存したあと、
eevee init -s schema -c config
として .eevee.yml
を生成し、 eevee run
して作成された config/user.yml
は以下のようになります。
name: user
datastore: db
index:
primary_key: id
unique_keys:
- - name
- - skill_id
- skill_rank
keys:
- - group_id
- - world_id
- field_id
members:
- name: id
type: uint64
- name: name
type: string
- name: sex
type: string
- name: age
type: int
- name: skill_id
type: uint64
- name: skill_rank
type: int
- name: group_id
type: uint64
- name: world_id
type: uint64
- name: field_id
type: uint64
この内容をもとに、基本的なパラメータの説明をします。
クラス名を記載します。スキーマから生成した場合は、スキーマ名の単数形になります
dao
でやりとりする外部のミドルウェアの種類を記述します。
クラスファイルを自動生成した場合、 .eevee.yml
で設定したデフォルトの datastore
が書き込まれます。 ( デフォルトは db
です )
datastore: none
とすると、 eevee
で用意している自動生成コードが何も使用されない状態で dao
のソースコードが生成されます ( 初回は空のファイルになります )。
この機能は、 eevee
の自動生成系には乗りたいが自分でファイルの内容をすべてカスタマイズしたい場合などに有効です。
スキーマに記述した PRIMARY KEY
, UNIQUE KEY
, KEY
の設定を反映したものになります。
基本的にこの部分を手動で編集する必要はありません
スキーマの各カラムに対応する定義を記述します。
基本的には name
と type
の組み合わせとなり、 name
はカラム名、 type
は SQL での型を Go
の型に変換したものが利用されます。
以上がベースのパラメータになります。このままでも利用できますが、 eevee
がもつテーブル間の参照解決機能を利用するために、いくつかのパラメータを追加します。
以下の YAML
定義を参照してください。
name: user
members:
... ( 省略 ) ...
- name: user_fields
extend: true
has_many: true
relation:
to: user_field
internal: id
external: user_id
- name: skill
extend: true
render:
inline: true
relation:
to: skill
internal: skill_id
external: id
- name: group
extend: true
render:
json: group
relation:
custom: true
to: group
- name: world
extend: true
render: false
relation:
to: world
internal: world_id
external: id
members
に新しく user_fields
, skill
, group
, world
を追加しました。
それぞれの member
で利用されているパラメータは以下のようなものです。
extend: true
として定義したメンバは、 entity
のメンバ変数には現れず、 model
のメンバ変数にのみ追加されることを意味します。
eevee
は自動生成時に entity
や model
といったパッケージを生成しますが、
entity
にはシリアライズ対象のメンバ変数のみをもたせ、アプリケーションロジックを記述する上で必要な状態変数などは model
のメンバ変数として定義することを推奨しています。
model
として定義されたオブジェクトを JSON
などにエンコードする際のふるまいをカスタマイズするために使用します。
member.render
には複数の書き方が存在します。
render: name
: 指定した名前をkey
にして値を出力しますrender: false
:false
を指定するとエンコード・デコードの対象になりませんrender: inline
:inline
を指定すると、後述するrelation
が存在する場合にrelation
先のオブジェクトをエンコードした結果 ( key: value のペア )を自身の出力結果にマージします
render:
json: lowerCamelName
yaml: lower_snake_name
のように、レンダリングプロトコルごとにエンコード・デコード時の名前を変更することができます。 json
や msgpack
など、複数のプロトコルに対応したい場合はこの記法を利用してください。 ( 何も設定しない場合は、 lowerCamelCase
が使用されます )
スキーマ間の依存関係を定義するためのパラメータです。
このパラメータを定義することによって、
依存先のインスタンスを取得するためのアクセサが自動生成されるようになります。
依存先のクラス名を書きます。ここでのクラス名とは、クラスファイルの name
パラメータです。
依存先のインスタンスを取得するために利用する自クラスのメンバーの名前を指定します。
依存先のインスタンスを取得するために利用する依存先クラスのメンバーの名前を指定します。
クラス間の紐付けルールが複雑な場合など、 internal
, external
の枠にとらわれずに依存先のインスタンスを取得したい場合に利用します。
このパラメータと internal
, external
パラメータを併用することはできません。
依存先クラスの値をすべて取得したい場合に利用します。internal
, external
パラメータと併用することはできません。
その他にも、以下のパラメータが利用できます。
そのメンバの役割を把握するためのドキュメントを記述します。
このパラメータは APIドキュメントを自動生成する際に利用されます。
そのメンバがとる値の例を記述します。ここで指定した値は、APIドキュメントの自動生成に利用される他、テスト時のモックオブジェクト作成用データとしても利用されます。
read_only: true
と書くと、そのクラスは読み込み専用と解釈され、
CRUD のうち READ 操作を行う API のみ自動生成されます。
利用例としては、アプリケーション開発でよく行われる、
管理者側であらかじめ用意したデータセット(マスターデータ)に用いることができます。
アプリケーションの利用者側から API を通して変更できるデータでない場合は read_only: true
を指定すると安全に開発することができます。
member.type
は複数の記述方法があります。
もっともシンプルなのは、型をそのまま書く方法です。例えば以下のように書くことができます。
members:
- name: a
type: interface{}
- name: b
type: map[string]interface{}
- name: c
type: *time.Time
しかし、上記の time
パッケージは Go
標準の time
パッケージでしょうか。
アプリケーションによっては、別の time
パッケージを参照したいかもしれません。
そこで eevee
では次のような記述方法もサポートしています。
members:
- name: d
type:
import: time
package_name: time
name: Time
is_pointer: true
このように書くと、パッケージのインポートパスは time
でパッケージの名前は time
、型名は Time
でポインタであるということを意味します。
この記法はアプリケーションで定義した型情報やサードパーティ製のライブラリで
利用されている構造体を定義したい場合に有効です。
API リクエストのパラメータを Go の構造体へマッピングする処理や、レスポンスに用いる JSON 文字列の作成支援を行う機能を利用するための設定方法について説明します。
はじめに、API定義は YAML
を用いて記述しますが、そのファイルを格納するディレクトリを
eevee
に教えるために、 .eevee.yml
に次のように指定します。
.eevee.yml
api: config/api
上記のように設定すると、 config/api
配下の YAML
ファイルを読みに行き、
自動生成が走るようになります。
次に、ユーザー情報を返す API を例に YAML
の書き方について説明します。
APIの名前を user_getter
とし、次のように定義しました。
- name: user_getter
desc: return user status
uri: /users/:user_id
method: get
response:
subtypes:
- name: user_getter_subtype
members:
- name: user
type: user
render:
inline: true
- name: param1
type: string
- name: param2
type: int
include:
- name: user
only:
- name
- param1
- param2
include:
- name: user_fields
only:
- field_id
include:
- name: field
only:
- name
type:
members:
- name: users
type: user
has_many: true
- name: sub
type: user_getter_subtype
include:
- name: user
only:
- id
- name
include:
- name: user_fields
only:
- field_id
説明のために、あえて複雑なレスポンスを定義してみました。
大事なのは response
の部分で、ここに出力したいレスポンスの構造を記述していきます。
APIはリストで記述します。つまり、1つのファイルに複数のAPI定義を書くことができます。
API名を記述します。この名前を利用してリクエストやレスポンスを処理するための構造体を作成します
API の説明を記述します。ドキュメントに反映されます
API にアクセスするための URI を記述します。ドキュメントに反映されます。
HTTP メソッド ( get
post
put
delete
など ) を記載してください。
指定したメソッドにあわせて、リクエストパラメータのデコード処理が変化します。
response
には subtypes
と type
を記述することができます
レスポンス用の構造体の定義を記述します。
記述方法は、クラスファイルと同じように、 members
を定義して行います。
response.type
を表現する際に階層構造を作りたい場合や、
他のAPIとレスポンス構造をシェアしたい場合など、より複雑な構造を記述したい場合に利用します。
記述方法は、クラスファイルと同じように、 members
を定義して行います。
subtypes
にはリスト構造で複数の subtype
を定義することができます。
type
, subtype
には、 members
の他に include
プロパティがあります。
include
を適切に利用することで、依存関係にあるすべてのクラスを取得・レンダリングせずに、
必要な部分だけをレスポンスに含めることができます。
レスポンスに含めたいメンバのうち
- クラスファイルに定義されているもの
- subtype として定義されているもの
の名前を記載します。
include.name
で指定された定義のうち、 members
の中でレスポンスに含めたいものを指定します。
ここで指定できるのは、リレーション定義のないメンバのみです。
※ include.except
と併用することはできません
include.name
で指定された定義のうち、 members
の中でレスポンスに含めたくないものを指定します。
ここで指定できるのは、リレーション定義のないメンバのみです。
※ include.only
と併用することはできません
include.name
で指定された定義のうち、 members
の中でリレーション定義をもつメンバに対して、
レスポンスに含めたいものの定義を記述します。
再帰的に記述していくことが可能です。
include_all: true
と指定すると、すべての依存先メンバを含めます。
( ただし、クラスファイル側で render: false
が指定されている場合は出力されません )
eevee
はスキーマ駆動開発を前提としています。
はじめにスキーマを定義してそれを読み込むことで クラスファイル を生成し、
必要であればテーブル間の依存関係などを書き足した上で Go のソースコードを自動生成します。
こうしてデータを扱いやすくするAPIを多く持ったモデルと、
そのモデルを取得するためのリポジトリレイヤーを自動生成することでアプリケーション開発を効率的に行うことができます。
自動生成されたパッケージは大まかに以下の図のような依存関係を持ちます
図は一番左から右へ、または一番右から左へ矢印の向きに沿って見ます。
ビジネスロジックから eevee
の機能を利用する場合、 repository
と model
パッケージを利用します。
repository
, model
は裏で dao
パッケージを利用します。 dao
は DataAccessObject
の略で、アプリケーション外部のプロセスとデータをやりとりするためのローレベルなAPIを提供します。
dao
が外部とやりとりする場合、必ず entity
パッケージを利用します。
entity
にはシリアライズ可能なデータ構造が定義されており、
このデータ構造を通して dao
が適切にシリアライズ・デシリアライズすることで外部とのやりとりを実現します。
データの読み込み方向に注目すると以下の図のようになります。
大きく、 1 と 2 の 2通りの読み込み方法があります。
まずアプリケーションからデータを取得したいと思った場合は、 repository
を利用します。 repository
が提供する CRUD
API を通して dao
を経由しつつデータを取得します。このとき、 dao
が扱うデータ構造は entity
のため、アプリケーションから利用しやすいように model
に変換することも行います。
もうひとつは、 model
を通して行う読み込み操作です。
後述しますが、 model
にはリレーション関係にある別のテーブルデータを効率的に取得する機能があります。こういった機能を利用する場合は、 model
が裏で repository
を経由してデータを取得します。
一方、データの書き込み方向に注目すると以下の図のようになります。
こちらも 2 通りの方法があり、シンプルなのは repository
を使ったものです。
repository
にはそのまま CRUD
ができる API があるので、そちらを通して Create
, Update
, Delete
を実行すれば、 dao
を通して書き込み操作が反映されます。
一方、 model
を通して書き込むことも可能です。
その場合は、モデルの内容を作成・または更新したいものに書き換えた後に Save()
を呼ぶことで行うことができます。
あわせて、 Create
Update
Delete
といった直感的な API も用意しているので、用途によって使い分けることも可能です。
クラスファイル にデータの依存関係を適切に記述することで開発を効率的に進めることができるようになります。
例えば、 users
テーブルの id
カラムの値に対応する user_id
というカラムを持った user_fields
テーブルを考えてみます。
ここで、 users
テーブルのレコードを取得してから user_fields
のレコードを取得するには、通常次のように書くと思います。
user, _ := repo.User().FindByID(ctx, 1)
userFields, _ := repo.UserField().FindByUserID(ctx, user.ID)
これを、両者の依存関係を クラスファイル に落とすと次のように書けます
name: user
members:
... ( 省略 ) ...
- name: user_fields
extend: true
has_many: true
relation:
to: user_field
internal: id
external: user_id
上記は
user
クラスはuser_field
クラスと依存関係にあり、user_fields
という名前のメンバでその依存関係が表現されているuser
=>user_field
の参照は、user
クラスのid
メンバとuser_field
クラスのuser_id
メンバの値を見て行われるuser
=>user_field
の関係はhas_many
関係にあるので、複数のuser_field
インスタンスを取得する- このメンバは
extend: true
がついているのでシリアライズ対象ではない (entity
には反映されない )
といったことを表しています。
この状態で eevee run
を実行して Go のソースコードを自動生成すると、はじめに書いた Go
のコードは次のように書くことができるようになります。
user, _ := repo.User().FindByID(ctx, 1)
userFields, _ := user.UserFields(ctx)
この機能を用いることで、依存関係にあるクラスの値を簡単かつ安全に取得することができるようになります。
またこのアクセスは非常に効率よく行われるので、例えば次のようなコレクションインスタンスに対して行われる際に特に有効です。 この例では通常 N + 1
回クエリが発行されてしまうように思われますが、実際には Eager Loading
が行われ、2度のクエリ発行のみになります。このあたりの詳細は次の項目で説明します。
user, _ := repo.User().FindByID(ctx, 1)
userFields, _ := user.UserFields(ctx)
userFields.Each(func(userField *model.UserField)) {
// 普通はここで SELECT * FROM fields WHERE id = { user_field.field_id } のようなクエリが発行されるため効率が悪いが、
// eevee では N+1 クエリを回避できる ( 後述 )
field, _ := userField.Field(ctx)
}
この機能の重要な点は、あるクラスが関連するデータをすべてそのクラスのインスタンスから取得することができるということです。これによって、( エラー処理が入るので実際には利用感は異なりますが ) チェーンアクセスで依存データを取得することができたり、API レスポンスにあるインスタンスの関連データをすべて反映したりすることができるようになります。
前項で触れましたが、 eevee
にはモデル間の依存関係を解決する機能があります。
この機能を提供する上で大切にしたのは次の2点です。
N+1
問題が起こらないこと- 不必要なデータを読まないこと
それぞれどのように解決しているのかを説明します。
モデルをインスタンス化する際、 eevee
では複数のインスタンスをまとめる場合、
スライスではなくコレクション構造体を利用します。
例えば複数の user
インスタンスを取得する場合は []*model.User
ではなく、 *model.Users
が返却されます
users, _ := repo.User().FindByIDs(ctx, []uint64{1, 2, 3})
// users は []*model.User ではなく *model.Users
スライスではなく構造体を用いることで、
このコレクションインスタンス自体がクエリ結果のキャッシュを持つことを可能にしています。
例えば _example/02_relation
を例にとると https://github.com/blastrain/eevee/blob/master/_example/02_relation/model/user.go#L44-L52 に書かれている通り Users
構造体として定義され、 skills
や userFields
といったキャッシュ用のメンバを持っていることが確認できると思います。
ではこの skills
や userFields
に値が入るのはいつかというと、
例えば skills
は FindSkill
を呼んだときです。 ( つまり user
がもつ skillID
を使って 1:1
対応する skill
を引くタイミング )
https://github.com/blastrain/eevee/blob/master/_example/02_relation/model/user.go#L1396-L1409
このメソッドの処理を読むと、 skillID
を利用して skill
を取得する際に、 finder.FindByID(skillID)
とするのではなく、 finder.FindByIDs(skillIDs)
と複数の skillID
を使って取得しているのがわかると思います。
つまり、 *model.Users
はあらかじめ自身が管理する *model.User
それぞれに対応する skillID
の集合を skillIDs
として保持しておき、どれかひとつでもその中の skillID
を使って skill
を検索する場合は、保持しておいた集合値を使ってすべての skill
を取得しておき、その中から指定された skillID
でフィルタするような挙動をとります。
これによって、毎回 skillID
でクエリを投げることを防いでいるのがわかると思います。しかし、以下のような例ではコレクションインスタンスの FindSkill()
が呼ばれるようなイメージが沸かないかもしれません。
users, _ := repo.User().FindByIDs(ctx, []uint64{1, 2, 3})
users.Each(func(user *model.User)) {
// *model.User から skill をとっているが、
// 本当にコレクションインスタンスにアクセスしているのか
skill, _ := user.Skill(ctx)
}
この答えは、 repository
パッケージ内の次の箇所にあります。
https://github.com/blastrain/eevee/blob/master/_example/02_relation/repository/user.go#L411-L435
各 *model.User
の他インスタンスを取得するためのアクセサは関数オブジェクトになっており、それをこの部分で作っています。
このとき、コレクションインスタンスの参照(とそれを使ったメソッドコール)をクロージャを使って閉じ込めているため、各 *model.User
インスタンスが *model.Users
のメソッドを呼び出すことが可能になっているのです。
なので上記のコードは実は次のような処理になっています。
users, _ := repo.User().FindByIDs(ctx, []uint64{1, 2, 3})
users.Each(func(user *model.User)) {
// 1度目のアクセス時に SELECT * FROM skills WHERE id IN (1, 2, 3) 相当のことを行う
// 取得した結果を users に保持しておく
// users が保持している取得結果から該当の skill を検索する
skill, _ := user.Skill(ctx)
}
前項で、他インスタンスへのアクセサが関数オブジェクトになっていることを説明しました。
そのため、データを取得しにいくのは関数を読んときだけになります。
あるインスタンスに紐づくデータを一番最初にすべて引きに行ってしまうと、無駄なデータを多く引いてしまう可能性がありますが、 eevee
ではそのようなことはありません。
必要なときに、必要なぶんだけデータを参照することができます。
eevee
は model
や repository
といったパッケージを自動生成するのと同時に、 mock/repository
と mock/model/factory
というパッケージも生成します。
これらはテスト開発時に repository
層をモックすることを支援してくれます。
アプリケーション開発におけるテスト手法についてここで多くは触れませんが、
データベースなどのミドルウェアにアクセスするようなテストケースを書く際、実際にアクセスして検証する方法と、モックを利用して擬似アクセスを行う方法があると思います。
eevee
では 後者のモックを用いた開発を支援しており、次のように repository.Repository
インターフェースを実装した *repository.RepositoryMock
インスタンスを返すことで、 repository
層のモックを簡単に行えるようにしています。
あわせて、 model
インスタンスのファクトリパッケージも提供しており、簡単に repository
の API を置き換え可能です。
import (
"app/mock/repository"
"app/mock/model/factory"
)
repo := repository.NewMock()
ctx := context.Background()
repo.UserMock().EXPECT().FindByID(ctx, 1).Return(factory.DefaultUser(), nil)
mock 時のインターフェースは https://github.com/golang/mock を参考にしています。 gomock
と違い型安全なコードを自動生成しているので、例えば上記の FindByID
に与える引数の型に合わせるために 1
を int64(1)
などとする必要はありません。 gomock
は引数を interface{}
で受けているためランタイム中に型エラーが発生する場合がありますが ( しかもわかりづらい )、そういった心配はありません。
factory.DefaultUser()
の中身は、 testdata/seeds
配下の YAML
ファイルを読み込んで自動生成しています。
基本的にはインスタンスの初期値を YAML
ファイルに名前付きで書いておくと、
その名前で factory.XXX
という形でモデル初期化用の API を用意してくれるので、それを使います。
model
パッケージを自動生成しているという設計上のメリットを使って、
JSON
文字列へエンコードする処理を静的に生成しています。これによって reflect
要らずとなっているため、 encoding/json
を利用した JSON
文字列への変換よりも大分高速になっています。
なぜ API リクエスト・レスポンスの作成までも支援しているか。
それは eevee
がもつメリットを最大限活かすために、レスポンス作成まで支援する必要があったからです。リクエストの方はレスポンス開発と同じ仕組みで開発できた方が便利だろうという理由からサポートしています。
前に説明していますが、eevee
が自動生成するモデルインスタンスは、リレーション関係にある依存先のインスタンスも数珠つなぎでとってくることができます。
モデルはそれぞれ MarshalJSON
を実装しており、 json.Marshal(user)
といったようにそのまま引数に与えると JSON
文字列に変換できます。
ここで、デフォルトでは user
に紐づく依存先のインスタンスも全て対象にして JSON
文字列へ変換しようとします。
こうすることで、例えば user
に紐づくモデルのどこかに依存関係を追加した場合 ( クラスファイルにメンバを追加する )、Go のコードを一切変更することなく JSON
に結果を反映させることができます。
一度この仕組みを利用すると、これがどれだけ開発を楽にしているかを実感できると思いますが、メリットばかりではありません。
すべての依存先インスタンスを取得、エンコードしようとするということは、それだけ時間もかかるし JSON
のデータ量も大きくなります。
API によっては、依存先の特定の部分だけ必要ないという場合もあるでしょう。
そこで、 eevee
では、各モデルに ToJSON
と ToJSONWithOption
という JSON
文字列化のための 2種類の手段を用意しています。
ToJSON
は MarshalJSON
の内側で呼ばれる API で、依存先をすべて含めた JSON
を作成しようと試みます。
一方 ToJSONWithOption
は、引数で与えたオプションによって、自身や依存先メンバの取捨選択ができるようになっています。この機能を用いることによって、「この API ではこのメンバだけ返す」といったことが容易にできるようになります。
とはいえ、このオプションを API 作成のたびに Go
で記述するのは大変です。
そこで、 Swagger
などの YAML
を用いた API 開発を参考に、
API レスポンスに必要なメンバの取捨選択もできるようにした上で YAML
で記述できるようにし、それを読み込んで レスポンス作成に必要なソースコードを自動生成する機能をサポートしています。
eevee
が自動生成するパッケージ ( entity
, dao
, repository
, model
) のうち、アプリケーションごとに設定が必要な部分は主に dao
の部分です。
eevee
を用いて数百を超えるクラスファイルを記述していくと、
徐々に eevee run
の時間が気になるようになっていきます。
また、 クラスファイルや dao
のソースコードを修正した場合に、
eevee run
を実行するのを忘れてしまい、
自動生成対象を更新せずにコミットしてしまうようなことも起きるかもしれません。
こういった問題を解決するために、
eevee
には -w
オプションをつけて起動することで、
ファイル変更イベントを監視して変更があったファイルに関連するファイルだけ
自動生成を走らせる機能 ( watch
モード ) があります。
- クラスファイルに依存先の定義を適切に記述
- そのクラスをレスポンスに含める
eevee -w
(watch
モードでファイル監視 )
の3つを組み合わせることで、カラム追加など既存のデータ構造が変わるような場面で 「 クラスファイル ( YAML
ファイル ) を変更した瞬間にレスポンス内容が変わる」という開発体験を得ることができます。
この体験は今までのアプリケーション開発を変えるほどに良いため、 watch
モードの利用を強く勧めています。
repository
を用いてアクセスできる API は、スキーマファイルで定義したインデックス情報に基づいています。 ( PRIMARY KEY
や UNIQUE KEY
, KEY
を適切に設定することで、それらのインデックスを用いた API を自動生成し、 repository
を用いてアクセスできるようになります )
このため、まずはインデックスを見直して生成するAPIを調整していただきたいですが、
自動生成対象になっているのは Equal
で比較できるものだけになっています。
それでは範囲検索や複雑なクエリを発行したい場合に困るので、
eevee
には好きな API を repository
に追加できる機能も存在します。
repository
に存在する API は、 dao
に存在する公開 API をもとに自動生成しています。
( repository
は完全自動生成のパッケージです。基本的に手動で何か処理を書き足すことはありません )
例えば、以下のような dao
パッケージのファイルがある場合 ( _example/01_simple/dao/user.go
)
package dao
import (
...
)
type User interface {
Count(context.Context) (int64, error)
Create(context.Context, *entity.User) error
Delete(context.Context, *entity.User) error
DeleteByID(context.Context, uint64) error
DeleteByIDs(context.Context, []uint64) error
FindAll(context.Context) (entity.Users, error)
FindByID(context.Context, uint64) (*entity.User, error)
FindByIDs(context.Context, []uint64) (entity.Users, error)
Update(context.Context, *entity.User) error
UpdateByID(context.Context, uint64, map[string]interface{}) error
UpdateByIDs(context.Context, []uint64, map[string]interface{}) error
}
( 実装は省略 )
repository
パッケージは次のようになります。
// Code generated by eevee. DO NOT EDIT!
package repository
import (
...
)
type User interface {
ToModel(*entity.User) *model.User
ToModels(entity.Users) *model.Users
Create(context.Context, *entity.User) (*model.User, error)
Creates(context.Context, entity.Users) (*model.Users, error)
FindAll(context.Context) (*model.Users, error)
FindByID(context.Context, uint64) (*model.User, error)
FindByIDs(context.Context, []uint64) (*model.Users, error)
UpdateByID(context.Context, uint64, map[string]interface{}) error
UpdateByIDs(context.Context, []uint64, map[string]interface{}) error
DeleteByID(context.Context, uint64) error
DeleteByIDs(context.Context, []uint64) error
Count(context.Context) (int64, error)
Delete(context.Context, *entity.User) error
Update(context.Context, *entity.User) error
}
( 実装は省略 )
つまり、 dao
に定義されている同名のクラスの interface
の内容を解析して、
entity
の返り値を model
のものに変換した内容が repository
の interface
に反映されます。
このルールを利用すると、何か API を追加したい場合は以下のような手順で行うことができます。
dao
に API を追加する
package dao
import (
...
)
type User interface {
( 省略 )
FindByRange(startAt time.Time, endAt time.Time) (entity.Users, error)
}
func (*UserImpl) FindByRange(startAt time.Time, endAt time.Time) (entity.Users, error) {
....
}
-
eevee run
を実行 -
repository.User
に API が追加される
package repository
import (
...
)
type User interface {
( 省略 )
FindByRange(startAt time.Time, endAt time.Time) (*model.Users, error)
}
「repository に API を追加する」で述べた機能を実現するため、
dao
パッケージのファイルは同一ファイル内で自動生成と手動編集が混在することを許容しており、
自動生成マーカー によって実現しています。
dao
で自動生成されたAPIは、API毎に // generated by eevee
というコメントが付与されています。
このコメントは、対象APIが eevee
によって自動生成されたものなのかを見分けるために使用されており、コメントがない API には自動生成の仕組みが適応されないようになっています。
このため、自動生成された処理そのものを修正したい場合や自作のAPIなどは、
コメントが付いていない状況にしていただければ eevee
側で上書きなどはしません。
model
の構造体自体にメンバ変数を追加したい場合は、
クラスファイルの説明の中で触れた extend
パラメータを利用してください。
extend: true
を追加することで、モデルだけに任意のメンバ変数を追加することができます。
それとは別に、レシーバメソッドを追加したい場合もあるかと思います。
そういった場合は、自動生成されたファイルとは別のファイルで ( 例えば model/user_api.go
など )
以下のように好きな API を追加してください。
model/user_api.go
package model
func (u *User) Hoge() {
...
}
クラス間の依存関係を解決する際に、依存するパラメータの同値性以外で判断したいケースもあります。
モデルに付与した状態変数によって A と B のクラスを切り替えたい場合もあるかもしれません。
そういった場合は、依存解決を自分で実装できる relation.custom
を用います。
relation:
to: user_field
custom: true
などと書けば、 「UserField
クラスを参照するためのメンバ変数だが、依存解決方法は自作する」
といった意味になります。
自動生成する際は
func (u *User) UserField(ctx context.Context) (*UserField, error) {
...
}
上記のような API が実装されることを前提として 自動生成コードを生成します。
つまり、上記の API を実装しなければコンパイルエラーになります。
そのため、実装し忘れを防ぎつつ、 UserField
を返すための処理を自作することができます。
もうひとつ relation.custom
の利用方法例として、ショートカットの実装があります。
例えば A => B => C というクラスの依存関係がある場合、
A のインスタンスである a
から C のインスタンスを取得する流れは次のようになります。
b, _ := a.B(ctx)
c, _ := b.C(ctx)
これを
c, _ := a.C(ctx)
と書けるようにするのがここでのショートカットです。
実装方法は A のクラスファイルに以下のようなメンバを追加するだけです
name: c
relation:
to: c
custom: true
こうすると、 func (a *A) C(ctx context.Context) (*C, error)
という API が
実装されることを期待するので、実装します。
func (a *A) C(ctx context.Context) (*C, error) {
b, err := a.B(ctx)
if err != nil {
return nil, err
}
c, err := b.C(ctx)
if err != nil {
return nil, err
}
return c, nil
}
最初に示したコードを再利用できるように実装しただけですが、 A => C の取得を B を意識せずに行いたい場合は重宝します。
- Masaaki Goshima ( goccy )
MIT