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/schema
と entgo.io/contrib/entproto
です。
次に Field
、Edge
にアノテーションを追加します。
最後に 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つ問題があります。
-
パッケージ名を修正する
自動誠意正されるパッケージ名はexample/ent/proto
となります。example
はbackend/go.mod
で定義したmodule example
の部分です。ent/proto
はentproto
を使うと自動で定義される部分のようです。しかし、本プロジェクトの場合は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
で実行するように実装します。(実装は後述) -
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
- 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
で定義したスキーマをentproto
でproto
ファイルとして生成できました。 - 生成した
proto
ファイルを使いのテーブルレコードの型を生成することができました。 - テーブルレコードの型を使って、テーブルの値を取得するサービスが実装できました。
- 実装したサービスとテーブルレコードの型を使ってFEで結果表示ができました。
以上です!
フロントエンドでデータベースのテーブルの型を定義するのが面倒だったので、entproto
で定義を生成してみました。しかしながら思ったより面倒で、後々何をしているのかを思い出すのがものすごく大変そうだと思いました。単純なサービスを作るうえでは自分で型を実装して使うのが良さそうです。
Discussion