メインコンテンツへスキップする

Ent で十分に機能する Go CRUD HTTP API を生成する

· 1 分で読む

Entのコアな原則の1つである「Schema as Code」は、「エンティティとそのエッジを定義するEntのDSLは、Goのコードを使う」という以上の意味を持っています。 他の多くのORMと比較した場合、Entのユニークなアプローチは、エンティティに関連するすべてのロジックを、コードとして、直接、スキーマ定義で表現できます。

Entでは、開発者はすべてのauthorization ロジック(EntではPrivacy と呼びます) とすべてのの変更の副作用(EntではHooksと呼びます) をスキーマに直接書き込むことができます。 すべてを同じ場所に置くことができるだけでも非常に便利ですが、その真の力はコード生成と組み合わせたときに発揮されます。

スキーマがこのように定義されていれば、完全に機能するプロダクショングレードのサーバーコードを自動的に生成することが可能になります。 権限決定やカスタム副作用の責任をRPC層からデータ層に移すと、基本的なCRUD(Create、Read、Update、Delete) エンドポイントの実装は、機械的に生成できる程度に汎用的になります。 これこそが、人気のGraphQLやgRPCのEnt拡張機能の背景にある考え方です。

今日は、Entスキーマから十分に機能するRESTfulなAPIエンドポイントを自動的に生成する、elkという新しいEnt拡張をご紹介します。 elkは、グラフに追加するすべてのエンティティの基本的なCRUDエンドポイントを設定するための面倒な作業をすべて自動化することを目指しています。リフレクションを排除し、型安全性を維持しながら、ロギング、リクエストボディの検証、リレーションのイーガーローディング、シリアライズなどを行います。

さあ、始めましょう!

はじめに

このコードの完成系は GitHubにあります

新しいGoプロジェクトを作成することから始めましょう:

mkdir elk-example
cd elk-example
go mod init elk-example

Entのコードジェネレータを呼び出し、2つのスキーマを作成します:User, Pet:

go run -mod=mod entgo.io/ent/cmd/ent init Pet User

プロジェクトはこのようになります:

.
├── ent
│ ├── generate.go
│ └── schema
│ ├── pet.go
│ └── user.go
├── go.mod
└── go.sum

次に、elkパッケージをプロジェクトに追加してみましょう。

go get -u github.com/masseelch/elk

elkは、Entのコード生成と統合するために、Entのextension APIを使用しています。 ここで説明されている通りに、entc(ent codegen) パッケージを使用する必要があります。 次の3つのステップに従って、このパッケージを有効にし、Entがelk拡張機能と連携できるように設定してください:

1. ent/entc.goという名前の新しいGoファイルを作成し、以下の内容を貼り付けます。

// +build ignore

package main

import (
"log"

"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"github.com/masseelch/elk"
)

func main() {
ex, err := elk.NewExtension(
elk.GenerateSpec("openapi.json"),
elk.GenerateHandlers(),
)
if err != nil {
log.Fatalf("creating elk extension: %v", err)
}
err = entc.Generate("./schema", &gen.Config{}, entc.Extensions(ex))
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}

2. ent/generate.goファイルを編集して、ent/entc.goファイルを実行します。

package ent

//go:generate go run -mod=mod entc.go

3/. elk は生成するコードにいくつかの外部パッケージを使用します。 現在、elk のセットアップ時に一度、手動でそれらのパッケージを取得する必要があります。

go get github.com/mailru/easyjson github.com/masseelch/render github.com/go-chi/chi/v5 go.uber.org/zap

これらのステップが完了すると、elkを搭載したentを使用するためのすべてのセットアップが完了します。 Entの詳細、さまざまなデータベースへの接続方法、マイグレーションの実行、エンティティの操作については、セットアップチュートリアルをご覧ください。

elk で HTTP CRUD ハンドラを生成する

完全に動作する HTTP ハンドラを生成するには、最初に Ent スキーマ定義を作成する必要があります。 ent/schema/pet.goを開いて、編集しましょう:

package schema

import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)

// Pet holds the schema definition for the Pet entity.
type Pet struct {
ent.Schema
}

// Fields of the Pet.
func (Pet) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.Int("age"),
}
}

Petのエンティティにnameageの2つのフィールドを追加しました。 ent.Schemaでは、エンティティのフィールドを定義するだけです。 このスキーマから実行可能なコードを生成するには、次のコマンドを実行します:

go generate ./...

Entがいつも生成するファイルに加えて、ent/httpという別のディレクトリが作成されていることに注目してください。 これらのファイルはelk拡張機能によって生成されたもので、生成された HTTP ハンドラのコードが含まれています。 例えば、Pet エンティティに対する読み取り操作のために生成されたコードの一部を以下に示します:

const (
PetCreate Routes = 1 << iota
PetRead
PetUpdate
PetDelete
PetList
PetRoutes = 1<<iota - 1
)

// PetHandler handles http crud operations on ent.Pet.
type PetHandler struct {
handler

client *ent.Client
log *zap.Logger
}

func NewPetHandler(c *ent.Client, l *zap.Logger) *PetHandler {
return &PetHandler{
client: c,
log: l.With(zap.String("handler", "PetHandler")),
}
}

// Read fetches the ent.Pet identified by a given url-parameter from the
// database and renders it to the client.
func (h *PetHandler) Read(w http.ResponseWriter, r *http.Request) {
l := h.log.With(zap.String("method", "Read"))
// ID is URL parameter.
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
l.Error("error getting id from url parameter", zap.String("id", chi.URLParam(r, "id")), zap.Error(err))
render.BadRequest(w, r, "id must be an integer greater zero")
return
}
// Create the query to fetch the Pet
q := h.client.Pet.Query().Where(pet.ID(id))
e, err := q.Only(r.Context())
if err != nil {
switch {
case ent.IsNotFound(err):
msg := stripEntError(err)
l.Info(msg, zap.Error(err), zap.Int("id", id))
render.NotFound(w, r, msg)
case ent.IsNotSingular(err):
msg := stripEntError(err)
l.Error(msg, zap.Error(err), zap.Int("id", id))
render.BadRequest(w, r, msg)
default:
l.Error("could not read pet", zap.Error(err), zap.Int("id", id))
render.InternalServerError(w, r, nil)
}
return
}
l.Info("pet rendered", zap.Int("id", id))
easyjson.MarshalToHTTPResponseWriter(NewPet2657988899View(e), w)
}

次に、Petエンティティを管理するためのRESTful HTTP サーバーを作成する方法を見てみましょう。 ent/main.goという名前の新しいGoファイルを作成し、以下の内容を加えます:

package main

import (
"context"
"fmt"
"log"
"net/http"

"elk-example/ent"
elk "elk-example/ent/http"

"github.com/go-chi/chi/v5"
_ "github.com/mattn/go-sqlite3"
"go.uber.org/zap"
)

func main() {
// Create the ent client.
c, err := ent.Open("sqlite3", "./ent.db?_fk=1")
if err != nil {
log.Fatalf("failed opening connection to sqlite: %v", err)
}
defer c.Close()
// 自動マイグレーションの実行
if err := c.Schema.Create(context.Background()); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
// Router and Logger.
r, l := chi.NewRouter(), zap.NewExample()
// Create the pet handler.
r.Route("/pets", func(r chi.Router) {
elk.NewPetHandler(c, l).Mount(r, elk.PetRoutes)
})
// Start listen to incoming requests.
fmt.Println("Server running")
defer fmt.Println("Server stopped")
if err := http.ListenAndServe(":8080", r); err != nil {
log.Fatal(err)
}
}

次に、サーバーを起動します:

go run -mod=mod main.go

おめでとうございます! これで、Pets APIを提供する実行中のサーバーを手に入れました。 データベース内のすべてのペットの一覧をリクエストできますが、まだ何もありません。 まず一匹作成しましょう:

curl -X 'POST' -H 'Content-Type: application/json' -d '{"name":"Kuro","age":3}' 'localhost:8080/pets'

次のようなレスポンスが返ってくると思います:

{
"age": 3,
"id": 1,
"name": "Kuro"
}

サーバーが起動しているターミナルに移動すると、 elkにロギングが組み込まれていることがわかります

{
"level": "info",
"msg": "pet rendered",
"handler": "PetHandler",
"method": "Create",
"id": 1
}

elkzapをロギングに使用します。 詳細については、zapのドキュメントをご覧ください。

リレーション

elkの機能をもっと説明するために、グラフを拡張しましょう。 ent/schema/user.goent/schema/pet.goを編集しましょう:

ent/schema/pet.go
// Edges of the Pet.
func (Pet) Edges() []ent.Edge {
return []ent.Edge{
edge.From("owner", User.Type).
Ref("pets").
Unique(),
}
}

ent/schema/user.go
package schema

import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)

// User holds the schema definition for the User entity.
type User struct {
ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.Int("age"),
}
}

// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("pets", Pet.Type),
}
}

これで、PetスキーマとUserスキーマの間に一対多の関係ができました。PetはUserに属し、Userは複数のPetを持つことができます。

コードジェネレータを再実行:

go generate ./...

ルーターに UserHandler を登録することを忘れないでください。 main.go に次の行を追加するだけです:

[...]
r.Route("/pets", func(r chi.Router) {
elk.NewPetHandler(c, l, v).Mount(r, elk.PetRoutes)
})
+ // ユーザーハンドラーの作成
+ r.Route("/users", func(r chi.Router) {
+ elk.NewUserHandler(c, l, v).Mount(r, elk.UserRoutes)
+ })
// リクエストの待ち受けを開始
fmt.Println("Server running")
[...]

サーバーを再起動すると、以前に作成したKuroという名前のペットを所有する User を作成できます:

curl -X 'POST' -H 'Content-Type: application/json' -d '{"name":"Elk","age":30,"owner":1}' 'localhost:8080/users'

サーバは以下の応答を返します:

{
"age": 30,
"edges": {},
"id": 1,
"name": "Elk"
}

出力結果から、ユーザーが作成されていることがわかりますが、エッジは空です。 elkは、デフォルトではエッジを出力に含めません。 エッジをレンダリングするようにelkを設定するには、"serialization groups "と呼ばれる機能を使用します。 elk.SchemaAnnotation構造体とelk.Annotation構造体を使用して、スキーマに注釈を付けます。 ent/schema/user.goを編集して、これらを追加してください。

// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("pets", Pet.Type).
Annotations(elk.Groups("user")),
}
}

// Annotations of the User.
func (User) Annotations() []schema.Annotation {
return []schema.Annotation{elk.ReadGroups("user")}
}

フィールドとエッジに追加されたelk.Annotationは、"user"グループがリクエストされた場合、それらをイーガーロードしてペイロードに追加するようelkに指示します。 elk.SchemaAnnotationは、UserHandlerリクエストのRead-Operationを"user"とするために使用されます。 Serialization groupsが添付されていないフィールドは、デフォルトで含まれていることに注意してください。 ただし、特に設定しない限り、 Edgeは除外されます。

次に、もう一度コードを再生成し、サーバーを再起動しましょう。 これで、レンダリングされたユーザーのペットが見えるはずです。

curl 'localhost:8080/users/1'
{
"age": 30,
"edges": {
"pets": [
{
"id": 1,
"name": "Kuro",
"age": 3,
"edges": {}
}
]
},
"id": 1,
"name": "Elk"
}

リクエストの検証

現在のスキーマでは、ペットやユーザーにマイナスの年齢を設定することや、(Kuroで行ったように) オーナーのいないペットを作成することができてしまいます。 Entには、基本的なバリデーションのサポートが組み込まれています。 しかし、場合によっては、Entにペイロードを渡す前に、APIに対するリクエストを検証したいことがあります。 elkこの パッケージを使用して検証ルールを定義し、データを検証します。 elk.Annotation を使用して、作成と更新操作のバリデーションルールを個別に設定できます。 今回の例では、Petスキーマで0以上の年齢のみを許可し、所有者のいないペットの作成を却下するとします。 ent/schema/pet.go を編集しましょう:

// Fields of the Pet.
func (Pet) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.Int("age").
Positive().
Annotations(
elk.CreateValidation("required,gt=0"),
elk.UpdateValidation("gt=0"),
),
}
}

// Edges of the Pet.
func (Pet) Edges() []ent.Edge {
return []ent.Edge{
edge.From("owner", User.Type).
Ref("pets").
Unique().
Required().
Annotations(elk.Validation("required")),
}
}

次に、コードを再生成し、サーバーを再起動します。 新しい検証ルールをテストするために、マイナスの年齢かつ所有者がいないペットを作成してみましょう:

curl -X 'POST' -H 'Content-Type: application/json' -d '{"name":"Bob","age":-2}' 'localhost:8080/pets'

elk は、どの検証に失敗したかについての情報を含む詳細なレスポンスを返します:

{
"code": 400,
"status": "Bad Request",
"errors": {
"Age": "This value failed validation on 'gt:0'.",
"Owner": "This value is required."
}
}

大文字のフィールド名に注目してください。 Validatorパッケージは、構造体のフィールド名を使用して検証エラーを生成しますが、にあるように、これを簡単にオーバーライドできます。

検証ルールを何も定義しなければ、elkは生成するコードに検証コードを含めません。 elksのリクエストバリデーションは、フィールドをまたいだ検証を行いたい場合に特に便利です。

今後追加される機能

elkにはすでにいくつかの便利な機能がありますが、まだまだエキサイティングなことがたくさんあります。 次のバージョンの elk には以下が含まれます:

  • ノードを管理するための十分に機能するflutterのフロントエンド
  • 現在のリクエストバリデーターとEntのバリデーションの統合
  • 他のトランスポートフォーマット(現在はJSONのみ)

まとめ

この記事では、elk ができることのほんの一部を紹介しました。 さらに用例を見たいなら、GitHubのプロジェクトのREADMEをご覧ください。 elk を搭載したEnt で、あなたやあなたの仲間の開発者が、RESTful APIの構築に必要な反復的な作業を自動化し、より意味のある仕事に集中できるようになることを願っています。

elk は開発の初期段階にありますので、ご提案やフィードバックをお待ちしています。ご協力いただける場合は、大変嬉しく思います。 ヘルプ、フィードバック、提案、貢献のために、GitHub Issuesをご活用ください。

著者について

MasseElchは、風が強く、平坦なドイツ北部出身のソフトウェアエンジニアです。 愛犬のKuro(自分のInstagramチャンネルを持っています 😱 ) とハイキングをしたり、息子とかくれんぼをしたりする時間以外は、コーヒーを飲みながらコーディングを楽しんでいます。