🔥

gRPC - connect - Render でwebサービスを作ってみる:FEで使えるテーブルの型を生成する

に公開

はじめに

gRPCとRemixを使ってサービスを公開したいと考えています。以下でサーバーサイドのDBの操作を実装してみました。今回はFEにテーブルレコードを渡す時の型解決をentprotoを使ってやってみたいと思います。

参照情報

以下の情報を参照していますが、実際の作業はかなり異なっています。

というのは今までの作業をベースに動作確認をしたためです。
ベースとするレポジトリは以下です。

実装後のレポジトリは以下です。

やりたいことの整理

最初におさらいです。

  • proto : gRPCのサービス及びデータ型を定義するための言語。
  • protoc(buf) : protoで定義された型やサービスの実装を生成するツール。
  • ent : GoのORMで型安全なCRUDの実装を生成するツール。DBスキーマの定義は別途必要。
  • entproto : entのスキーマからprotoファイルを生成するツール。生成されたファイルからprotocでコード生成することで、DB操作に関する制御を型安全に行うことができるようになります。

今回はFEでテーブルレコードのデータを簡単に扱うために、entprotoを使ってコード生成してみます。entprotoを使わずとも、protoにテーブル定義と同じ型を定義すれば同じことができます。

スキーマの編集

まず必要なモジュールをインストールします。

$ cd backend && go get entgo.io/contrib/entproto && go mod tidy

entproto を使うには専用のアノテーションをする必要があります。まず必要なモジュールをインポートします。entgo.io/ent/schemaentgo.io/contrib/entproto です。

次に FieldEdge にアノテーションを追加します。

最後に func (Todo) Annotations() []schema.Annotation { でTodoを生成対象にします。

 import (
     "entgo.io/ent"
+    "entgo.io/ent/schema"
+    "entgo.io/contrib/entproto"
     "entgo.io/ent/schema/field"
     "entgo.io/ent/schema/edge"
     "time"
@@ -15,21 +17,43 @@ type Todo struct {
 // Fields of the Todo.
 func (Todo) Fields() []ent.Field {
 	return []ent.Field{
-		field.Text("text").NotEmpty(),
-		field.Time("created_at").Default(time.Now).Immutable(),
+		field.Text("text").NotEmpty().
+		Annotations(entproto.Field(2)),
+		
+		field.Time("created_at").Default(time.Now).Immutable().
+		Annotations(entproto.Field(3)),
+		
 		field.Enum("status").NamedValues(
 			"InProgress", "IN_PROGRESS",
 			"Completed", "COMPLETED",
-		).Default("IN_PROGRESS"),
-		field.Int("priority").Default(0),
+		).Default("IN_PROGRESS").
+		Annotations(
+			entproto.Field(4),
+			entproto.Enum(map[string]int32{
+				"IN_PROGRESS": 0,
+				"COMPLETED": 1,
+			}),
+		),
+		
+		field.Int("priority").Default(0).
+		Annotations(entproto.Field(5)),
 	}
 }
 
 // Edges of the Todo.
 func (Todo) Edges() []ent.Edge {
 	return []ent.Edge{
-		edge.To("parent", Todo.Type). // Todoを親とするエッジを追加
+		// 子から親
+		edge.To("parent", Todo.Type).
 			Unique(). // 親は単一
-			From("children"), //自分自身(今回はTodo)を子として引けるようにする
+			Annotations(entproto.Field(102)).
+			From("children").
+			Annotations(entproto.Field(103)),
+	}
+}
+
+func (Todo) Annotations() []schema.Annotation {
+	return []schema.Annotation{
+		entproto.Message(),
 	}
 }

entprotoでスキーマ定義をprotoファイルとして出力する

サービ及びデータ型の出力の設定が backend/ent/generate.go に実装されています。ここに entproto を実行するコマンドを追加します。しかしこの状態だと2つ問題があります。

  1. パッケージ名を修正する
    自動誠意正されるパッケージ名は example/ent/proto となります。examplebackend/go.mod で定義した module example の部分です。 ent/protoentproto を使うと自動で定義される部分のようです。しかし、本プロジェクトの場合は greet.proto に合わせるために example/gen にしておく必要があります。そしてそれを指定するオプションは無いようです。しょうがないので少々力技ですが、sedで生成されたファイルを書き換えることにします。

    backend/ent/fix_protoc_go_package.sh を作成し、以下の実装をします。

    #!/bin/bash
    
    set -e
    
    find ./proto/entpb -name '*.proto' -exec \
    sed -i '' 's|option go_package = "example/ent/proto/entpb";|option go_package = "example/gen/entpb";|' {} +
    

    実装したファイルを generate.go で実行するように実装します。(実装は後述)

  2. protoファイルをプロジェクトルートのprotoに移動する
    FEから使うためのサービス及びデータ型を出力するためにはプロジェクトルートの proto にprotoファイルを配置する必要があります。そのために generate.go でファイルを移動します。こちらの移動も generate.go に実装します。

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
+//go:generate bash ./fix_protoc_go_package.sh
+//go:generate rm -rf ../../proto/entpb
+//go:generate mv ./proto/entpb ../../proto
  1. protoファイルを生成する
    ここまででentからprotoを生成する準備が整いました。 プロジェクトルートで cd backend && go generate ./ent を実行すると backend/gen/entpb/entpb.pb.go が生成されます。

データベースのデータを取得するサービスの定義をする

今回TODOのデータを取得するサービスを定義します。この時点では entpb.Todo でTODOテーブルの定義の型をそのまま使うことができています。後は今までと同じ感じでサービスのIF、型を生成します cd proto && npx --prefix ../frontend buf generate。後は生成されたIF、型を使ってバックエンド・フロントエンドの実装をするだけです。

 option go_package = "example/gen/greet/v1;greetv1";
 
+import "entpb/entpb.proto"; // entpb.protoをインポート
+
 message GreetRequest {
   string name = 1;
 }
@@ -14,4 +16,15 @@ message GreetResponse {
 
 service GreetService {
   rpc Greet(GreetRequest) returns (GreetResponse) {}
}
+
+/* todo service */
+message ListTodosRequest {}
+
+message ListTodosResponse {
+  repeated entpb.Todo todos = 1;
+}
+
+service TodoService {
+    rpc ListTodos(ListTodosRequest) returns (ListTodosResponse);
+}

バックエンドサービスの実装

ListTodos の実装をしました。実装自体は GreetServer の実装とほぼ同じです。データの取得先と entpb.Todo の型を使っているところが違いでしょうか。

 	greetv1 "example/gen/greet/v1"
 	"example/gen/greet/v1/greetv1connect"
+
+	"example/gen/entpb"
+	"google.golang.org/protobuf/types/known/timestamppb"
 )
 
+/* Greet */
 type GreetServer struct{}
 
 func (s *GreetServer) SaveRequest(
@@ -76,6 +80,52 @@ func (s *GreetServer) Greet(
 	return res, nil
 }
 
+/* Todo */
+type TodoServer struct{}
+
+func (s *TodoServer) ListTodos(
+	ctx context.Context,
+	req *connect.Request[greetv1.ListTodosRequest],
+) (*connect.Response[greetv1.ListTodosResponse], error) {
+	// ファイルベースのSQLiteデータベースを持つent.Clientを作成します。
+	client, err := ent.Open(dialect.SQLite, "file:ent.db?_fk=1")
+
+	if err != nil {
+		log.Fatalf("failed opening connection to sqlite: %v", err)
+	}
+	defer client.Close()
+	// 自動マイグレーションツールを実行して、すべてのスキーマリソースを作成します。
+	if err := client.Schema.Create(ctx); err != nil {
+		log.Fatalf("failed creating schema resources: %v", err)
+	}
+
+	todos, err := client.Todo.Query().All(ctx)
+	if err != nil {
+	return nil, err
+	}
+
+	var pbTodos []*entpb.Todo
+	for _, todo := range todos {
+		pbTodo := &entpb.Todo{
+			Id: int64(todo.ID),
+			Text: todo.Text,
+			CreatedAt: timestamppb.New(todo.CreatedAt),
+			Status: entpb.Todo_Status(entpb.Todo_Status_value[string(todo.Status)]),
+			Priority: int64(todo.Priority),
+		}
+		if err != nil {
+			return nil, err
+		}
+		pbTodos = append(pbTodos, pbTodo)
+	}
+
+	res := connect.NewResponse(&greetv1.ListTodosResponse{
+		Todos: pbTodos,
+	})
+
+	return res, nil
+}
+
 func withCORS(h http.Handler) http.Handler {
 	return cors.New(cors.Options{
 		AllowedOrigins:   []string{"http://localhost:5173"},
 }
 
 func main() {
-	// greeterサービスを生成
-	greeter := &GreetServer{}
 	// マルチプレクサ(ルータ)を生成
 	mux := http.NewServeMux()
-	// サービスハンドラにgreeterサービス登録、
-	// ルーティング用のpathと関数呼び出し用のハンドラを作成
-	path, handler := greetv1connect.NewGreetServiceHandler(greeter)
-	fmt.Println("gRPC endpoint:", path) // pathの値を確認
+
 	// マルチプレクサ(ルータ)にパスとハンドラを追加
-	mux.Handle(path, handler)
+	mux.Handle(greetv1connect.NewGreetServiceHandler(&GreetServer{}))
+	mux.Handle(greetv1connect.NewTodoServiceHandler(&TodoServer{}))
+
 	// httpサーバーを起動
 	http.ListenAndServe(
 		"0.0.0.0:8080",

フロントエンドの実装

こちらもやはり型 Todo を使うことができています。後はバックエンドサーバーに listTodos でデータ取得してデータを使っています。型が定義されているので todo.status のように値を取得できて便利ですね。

 import { createConnectTransport } from "@connectrpc/connect-web";
 
 // 接続したいサービスをインポート
-import { GreetService } from "../gen/greet/v1/greet_pb";
+import { GreetService, TodoService } from "../gen/greet/v1/greet_pb";
+import type { Todo } from "../gen/entpb/entpb_pb"
+
 
 // サービス定義とtransportを組み合わせてクライアントを作ります
 const client = createClient(GreetService, transport)
+const todoClient = createClient(TodoService, transport)
 
 function App() {
     const[requestState, setRequest] = useState("")
     const [responseState, setResponse] = useState<string[]>([]);
+    const [todos, setTodos] = useState<Todo[]>([]);
     return <>
         <form onSubmit={async (e) => {
             e.preventDefault(); // ページリロードを避ける
@@ -39,6 +43,23 @@ function App() {
                 ))
             }
         </>
+
+        <button onClick={async () => {
+            const response = await todoClient.listTodos({})
+            setTodos(response.todos)
+        }}>
+            get todos
+        </button>
+
+        <div>
+            {
+                todos.map( todo => (
+                    <li key={todo.id}>
+                        ID: {todo.id}, タイトル: {todo.text}, 状態: {todo.status}, 優先度: {todo.priority}
+                    </li>
+                ))
+            }
+        </div>
     </>
 }
 export default App

結果

以下のようなFEになりました。formに入れたメッセージはバックエンドで、変換・結合されてformの下に出力されています。get todosボタンを押すと、現在データベースに保存されているデータを全て取得してボタンの下に表示しています。

まとめ

  • ent で定義したスキーマを entprotoproto ファイルとして生成できました。
  • 生成した proto ファイルを使いのテーブルレコードの型を生成することができました。
  • テーブルレコードの型を使って、テーブルの値を取得するサービスが実装できました。
  • 実装したサービスとテーブルレコードの型を使ってFEで結果表示ができました。

以上です!
フロントエンドでデータベースのテーブルの型を定義するのが面倒だったので、entprotoで定義を生成してみました。しかしながら思ったより面倒で、後々何をしているのかを思い出すのがものすごく大変そうだと思いました。単純なサービスを作るうえでは自分で型を実装して使うのが良さそうです。

Discussion