🦔

entのProtocol BuffersスキーマとgRPCサービスのコード生成機能を試したみた

13 min read

本記事はほとんどentの公式チュートリアルの焼き直しです

はじめに

GoのORMの勉強をしようと思い立ち、今回手にとったのがent。
もぐりの僕でもGoのORMといえばGORMなのは知っていたが[1]、まずentを触ってみようというと思ったのはentの公式ブログでGenerate a fully-working Go gRPC server in two minutes with Entというタイトルの記事を見つけたから。
どうやらentのスキーマからProtocol buffers(以下、protobuf)スキーマ及び本来ならば自前で実装しなくてはいけないgRPCのサービスもコード生成出来るらしい。
GoもgRPCもGoogle産なのだから一緒に使ったらなんか凄そうという偏見をもっているのでこの機能に大いに惹かれた。
本記事の趣旨はentのprotobufスキーマ及びgRPCサービス生成機能を試してみるというものなのでentのORMとしての部分にはあまり触れません。

前準備とスキーマの定義

まずはディレクトリの作成とGo Moduleの初期化、entのCLIを使ってエンティティのスキーマの雛形を生成。

$ mkdir ent-grpc-example
$ cd ent-grpc-example
ent-grpc-example$ go mod init ent-grpc-example
ent-grpc-example$ ent init User

するとディレクトリはこうなるはず。

.
├── ent
│   ├── generate.go
│   └── schema
│       └── user.go //先ほど初期化したUserエンティティのスキーマの雛形。
└── go.mod

次に生成されたuser.goを弄って、Userエンティティのスキーマを定義していく。
Fieldsメソッドにはテーブルでいうところのカラムを、Edgesメソッドにはエンティティ間の関係を定義する。

初期化状態はこんな感じ。
実はFieldsメソッドやEdgesメソッド以外にもいくつかメソッドが存在するが全てを実装する必要はない。

user.go
package schema

import "entgo.io/ent"

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

// Fields of the User.
func (User) Fields() []ent.Field {
	return nil
}

// Edges of the User.
func (User) Edges() []ent.Edge {
	return nil
}

user.goを弄って、Userエンティティ(usersテーブル)にnameとemailフィールド(カラム)を定義する。entでは全てのエンティティに対して暗黙的にidというフィールドが定義されるので実際にはUserエンティティは3つのフィールドをもつことになる。

user.go
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").
            Unique(),
        field.String("email").
            Unique(),
    }
}

せっかくなのでUserエンティティのスキーマからDDLを生成してみる。データベースはdocker-composeで動かす。

ent-grpc-example/main.go
package main

import (
	"context"
	"ent-grpc-example/ent"
	"fmt"
	"log"
	"os"

	_ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", fmt.Sprintf("host=%s port=%s user=%s dbname=%s password=%s sslmode=disable",
		"localhost", "5432", "postgres", "postgres", "password"))
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	ctx := context.Background()
	if err := client.Schema.WriteTo(ctx, os.Stdout); err != nil {
		log.Fatal(err)
	}
}

出力結果を整形したもの。極めて単純。

BEGIN
;
CREATE TABLE IF NOT EXISTS "users"(
    "id" bigint GENERATED BY
    DEFAULT AS IDENTITY NOT NULL,
    "name" varchar UNIQUE NOT NULL,
    "email" varchar UNIQUE NOT NULL,
    PRIMARY KEY("id")
)
;
COMMIT
;

Userエンティティのスキーマ定義が出来たら、再度コード生成。
すると、種々の新規ファイルが大量に生成される。

ent-grpc-example$ go generate ./...
.
├── ent
│   ├── client.go
│   ├── config.go
│   ├── context.go
│   ├── ent.go
│   ├── enttest
│   │   └── enttest.go
│   ├── generate.go
│   ├── hook
│   │   └── hook.go
│   ├── migrate
│   │   ├── migrate.go
│   │   └── schema.go
│   ├── mutation.go
│   ├── predicate
│   │   └── predicate.go
│   ├── runtime
│   │   └── runtime.go
│   ├── runtime.go
│   ├── schema
│   │   └── user.go
│   ├── tx.go
│   ├── user
│   │   ├── user.go
│   │   └── where.go
│   ├── user.go
│   ├── user_create.go
│   ├── user_delete.go
│   ├── user_query.go
│   └── user_update.go
├── go.mod
└── go.sum

protobufスキーマを生成する

続いて、entのスキーマからprotobufのスキーマを生成していく。
まずはスキーマの生成に必要なパッケージをプロジェクトに追加する。

ent-grpc-example$go get -u entgo.io/contrib/entproto

ここで前述の任意実装メソッドのひとつであるAnnotaitonsメソッドをuser.goに追加することでUserエンティティに対応するProtocol Buffersのメッセージの生成が可能になる。
また、単にAnnotaionsメソッドを実装するだけではなく、フィールドを編集してprotobufスキーマ生成の際のメッセージの各フィールド[2]に対して番号の割り当てを指定する必要がある。
上述の通り、各エンティティは暗黙的にidフィールド(1が割当て)をもっているので割当て可能な番号は2からとなる。

user.go
func (User) Fields() []ent.Field {
	return []ent.Field{
		field.String("name").
			Unique().
			Annotations(
				entproto.Field(2),
			),
		field.String("email_address").
			Unique().
			Annotations(
				entproto.Field(3),
			),
	}
}

func (User) Edges() []ent.Edge {
	return nil
}

func (User) Annotations() []schema.Annotation {
	return []schema.Annotation{
		entproto.Message(),
	}
}

protoファイル生成のためのディレクティブを追加して再びコードを生成する。

ent-grpc-example/ent/generate.go
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema
//go:generate go run -mod=mod entgo.io/contrib/entproto/cmd/entproto -path ./schema
ent-grpc-example$ go generate ./...

するとprotoファイルを含む新規ディレクトリが生成される。

ent/proto
└── entpb
    ├── entpb.proto
    └── generate.go

protoファイルの中身はこんな感じ、user.goを見て、直感的に想像するものとほぼ同じだと思う。

// Code generated by entproto. DO NOT EDIT.
syntax = "proto3";

package entpb;

option go_package = "ent-grpc-example/ent/proto/entpb";

message User {
  int32 id = 1;

  string user_name = 2;

  string email = 3;
}

ここから個人的に躓いているところ。
上記のDDLから分かるように、暗黙的なフィールドであるidフィールドは所謂サロゲートキーなのだが実は下記のように上書きが可能。

user.go
func (User) Fields() []ent.Field {
	return []ent.Field{
		field.UUID("id", uuid.UUID{}).
			Default(uuid.New).
			StorageKey("oid").
			Annotations(
				entproto.Field(1),
			),
		field.String("name").
			Unique().
			Annotations(
				entproto.Field(2),
			),
		field.String("email").
			Unique().
			Annotations(
				entproto.Field(3),
			),
	}
}

ただ、このようにしても生成されるprotoファイルおよびこのUserエンティティのスキーマから生成されるDDLはこうなる。
field.UUID関数にチェインしているStorageKeyメソッドはあくまでテーブルのカラム名をフィールド名と異なるものに指定するためのものであってprotoファイルに影響はないらしい。
ググってみても暗黙的なidフィールドに対応するprotobufのメッセージのフィールド名を指定する方法がわからなかったのでご存知の方がいらっしゃったら、是非教えてくださいm(_ _)m

message User {
  bytes id = 1;

  string name = 2;

  string email = 3;
}
BEGIN
;
CREATE TABLE IF NOT EXISTS "users"(
  "oid" uuid NOT NULL,
  "name" varchar UNIQUE NOT NULL,
  "email" varchar UNIQUE NOT NULL,
  PRIMARY KEY("oid")
)
;
COMMIT
;

話を戻す。protoファイルと同時に生成されたgenerate.goにはprotobufのコード生成CLIであるprotocを使用するためのディレクティブが含まれる。

package entpb
//go:generate protoc -I=.. --go_out=.. --go-grpc_out=.. --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative --entgrpc_out=.. --entgrpc_opt=paths=source_relative,schema_path=../../schema entpb/entpb.proto

実際にprotoファイルからコード生成を行うには上述のprotocおよび3つのプラグインが必要となる。

protocのインストール手順
protocからGoのコードを生成するためのプラグインのインストール方法
3つめのprotoc-gen-entgrpcのインストール。

$ go get -u entgo.io/contrib/entproto/cmd/protoc-gen-entgrpc

gRPCサービスの生成

Userエンティティに対するgRPCサービスを生成するには、user.goに1行追加するだけ!

user.go
func (User) Annotations() []schema.Annotation {
    return []schema.Annotation{
        entproto.Message(),
        entproto.Service(),
    }
}

またまた、コード生成。

ent-grpc-example$ go generate ./...

するとprotoファイルを含むディレクトリがちょこっと変化。

ent/entpb
    ├── entpb.pb.go
    ├── entpb.proto
    ├── entpb_grpc.pb.go
    ├── entpb_user_service.go
    └── generate.go

protoファイルにもサービスが追加される。

service UserService {
  rpc Create ( CreateUserRequest ) returns ( User );

  rpc Get ( GetUserRequest ) returns ( User );

  rpc Update ( UpdateUserRequest ) returns ( User );

  rpc Delete ( DeleteUserRequest ) returns ( google.protobuf.Empty );
}

entpb_user_service.goはentpb_grpc.pb.goのインターフェイスを実装したもの。この部分は本来自分で実装する部分なのでめっちゃ楽ちん!

entpb_user_service.go
// Create implements UserServiceServer.Create
func (svc *UserService) Create(ctx context.Context, req *CreateUserRequest) (*User, error) {
	user := req.GetUser()
	m := svc.client.User.Create()
	userEmail := user.GetEmail()
	m.SetEmail(userEmail)
	userName := user.GetName()
	m.SetName(userName)
	res, err := m.Save(ctx)
	switch {
	case err == nil:
		proto, err := toProtoUser(res)
		if err != nil {
			return nil, status.Errorf(codes.Internal, "internal error: %s", err)
		}
		return proto, nil
	case sqlgraph.IsUniqueConstraintError(err):
		return nil, status.Errorf(codes.AlreadyExists, "already exists: %s", err)
	case ent.IsConstraintError(err):
		return nil, status.Errorf(codes.InvalidArgument, "invalid argument: %s", err)
	default:
		return nil, status.Errorf(codes.Internal, "internal error: %s", err)
	}

}

gRPCサーバーの作成とCRUD操作

サービスはコード生成が可能なものの、具体的なサーバーは自前で実装する必要がある。
公式サイトによると使用するミドルウェア(認可やロガー)などは各開発チームに依存する部分が大きいので、本記事執筆時点ではサーバーはコード生成の対象外であるが将来的には変更されるかもしれないとのこと。

gRPCサーバー

ent-grpc-example/server/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"net"

	"ent-grpc-example/ent"
	"ent-grpc-example/ent/proto/entpb"

	_ "github.com/lib/pq"
	"google.golang.org/grpc"
)

func main() {
	log.Print("server is starting...")
	client, err := ent.Open("postgres", fmt.Sprintf("host=%s port=%s user=%s dbname=%s password=%s sslmode=disable",
		"localhost", "5432", "postgres", "postgres", "password"))
	if err != nil {
		log.Fatalf("failed to connect to db: %s", err)
	}
	defer client.Close()

	if err := client.Schema.Create(context.Background()); err != nil {
		log.Fatalf("failed to create schema: %s", err)
	}

	svc := entpb.NewUserService(client)
	server := grpc.NewServer()
	entpb.RegisterUserServiceServer(server, svc)

	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("failed to listen: %s", err)
	}

	if err := server.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %s", err)
	}
}

ちゃんと動く

ent-grpc-example$ docker-compose up -d
ent-grpc-example$ go run server/main.go 
2021/11/03 09:47:58 server is starting...

生成されたサービスを利用してちゃんとCRUD操作が可能か試してみる。

go
package main

import (
	"context"
	"log"

	"ent-grpc-example/ent/proto/entpb"

	"google.golang.org/grpc"
)

func main() {
	conn, err := grpc.Dial(":50051", grpc.WithInsecure())
	if err != nil {
		log.Fatalf("failed connect to server: %s", err)
	}
	defer conn.Close()

	client := entpb.NewUserServiceClient(conn)

	// Create a new User
	ctx := context.Background()
	created, err := client.Create(ctx, &entpb.CreateUserRequest{
		User: &entpb.User{
			Name:  "hoge",
			Email: "hoge@exmaple.com",
		},
	})
	if err != nil {
		log.Fatalf("failed to create user: %s", err)
	}

	log.Printf("created an user:ID: %d, Name: %s, Email: %s", created.Id, created.Name, created.Email)

	// Get an User
	got, err := client.Get(ctx, &entpb.GetUserRequest{
		Id: created.Id,
	})
	if err != nil {
		log.Fatalf("failed to get an user: %s", err)
	}

	log.Printf("got an user:ID: %d, Name: %s, Email: %s", got.Id, got.Name, got.Email)

	// Update an User
	updated, err := client.Update(ctx, &entpb.UpdateUserRequest{
		User: &entpb.User{
			Id:    got.Id,
			Name:  "fuga",
			Email: "fuga@example.com",
		},
	})
	if err != nil {
		log.Fatalf("failed to update an user: %s", err)
	}

	log.Printf("updated an user: ID: %d Name: %s, Email: %s", updated.Id, updated.Name, updated.Email)

	/* Delete an User
	_, err = client.Delete(ctx, &entpb.DeleteUserRequest{
		Id: updated.Id,
	})
	if err != nil {
		log.Fatalf("failed to delete an user: %s", err)
	}

	log.Printf("deleted an user: ID: %d Name: %s Email: %s", updated.Id, updated.Name, updated.Email)
	*/
}

gRPC問い合わせの出力結果。しっかり動いて感動。
サーバー側のロギングはないので、やはりそこは自分でどうにかしろといった感じ。

ent-grpc-example$ go run client/main.go 
2021/11/03 09:48:05 created an user:ID: 1, Name: hoge, Email: hoge@exmaple.com
2021/11/03 09:48:05 got an user:ID: 1, Name: hoge, Email: hoge@exmaple.com
2021/11/03 09:48:05 updated an user: ID: 1 Name: fuga, Email: fuga@example.com

念のために、PostgerSQLのDockerコンテナに潜り込んでちゃんとエンティティが追加されているか確認。

postgres=# \d
              List of relations
 Schema |     Name     |   Type   |  Owner   
--------+--------------+----------+----------
 public | users        | table    | postgres
 public | users_id_seq | sequence | postgres
 
 postgres=# SELECT * FROM users;
 id | name  |   email       
----+-------+------------------
  1 | fuga  | fuga@example.com
(1 row)

おわりに

今回はentの公式チュートリアルを参考にして実際にentスキーマからprotobufスキーマとサービスをコード生成できるか試してみました。
entの公式ドキュメントは大部分が日本語翻訳されているので(感謝m(_ _)m)興味が湧いた方は是非そちらを参考にしてください。
これからは勉強したものをGitHubにあげていこうと思います。
お読みいただきありがとうございました。

本記事のリポジトリ

https://github.com/unm3/ent-grpc-example

参考にしたもの

https://entgo.io/ja/
https://future-architect.github.io/articles/20210728a/
https://zenn.dev/spiegel/books/a-study-in-postgresql
脚注
  1. Goの学習ロードマップでも必修扱いされている。 ↩︎

  2. ここでいうフィールドとはProtocol Buffersの文脈においてのもの。 ↩︎

Discussion

ログインするとコメントできます