🍣

Protocol BuffersとgRPCを理解するためにTodoアプリを作ってみた

に公開

はじめに

Protocol BuffersとgRPCを理解するために簡単なTodoアプリを作ってみました。

Protocol Buffers(以下、Protobuf)とgRPCの基本に初めて触れる私のような方を対象に、本記事ではGo言語とブラウザJavaScriptを使ってシンプルな「Todoリスト」アプリを実装してみます。Protobufによるインターフェース定義からコードを自動生成し、GoでgRPCサーバーを実装、そしてブラウザからgRPCサービスを呼び出す手順を段階的に紹介します。記事内のコードと解説だけで完結するようにしており、この記事を通して 「サービスのインターフェース定義」「コード生成」「サーバー実装」「クライアントからの呼び出し」 という一連の流れを体験できると思います。

対象の読者はGoとJavaScriptの基本は理解しているが、gRPCやProtobufは初めてという方です。実装には Go (net/http + Connectライブラリ + SQLite + Ent)、フロントエンドにはプレーンなJavaScript (Connect-Web) を使用します。なお、認証やCI/CD、デプロイなど本質から離れるトピックには触れず、開発環境で動くシンプルな動作確認までを扱います。

Connectとは? 本記事ではgRPC実装にBuf社の「Connect」ライブラリを使います。ConnectはHTTP/1.1上でブラウザから直接gRPCサービスにアクセスできる軽量なフレームワークです。ConnectサーバーはgRPCとも互換性があり、例えばcurlでJSONを送信してAPIをデバッグすることも可能です。そのため学習目的の今回のハンズオンにも適しています。

そもそもProtocol Buffers、gRPCってなんなのかという方は↓
https://zenn.dev/shimpei_takeda/articles/c5e52657659cb7

開発準備とプロジェクト構成

まず開発環境の準備を行います。今回はローカル環境(Mac想定)で進めます。Goのセットアップは済んでいるものとし、以下のツールをインストールしてください。

  • Buf CLI:Protobufのコード生成やLintを統合的に行うツール。brew install bufbuild/buf/buf などでインストールできます。Bufは「高性能なProtocol Buffersコンパイラ」であり、Lint機能やプラグイン管理機能を備えているため、protoc単体より便利です。本記事ではBufを使ってコード生成を行いますが、protocを直接使う場合の手順も適宜補足します。

  • Go言語の環境:Go 1.20以上を推奨します。Goプロジェクト用に任意の作業ディレクトリを用意してください。go mod init todoappでGoモジュールを初期化しておきましょう。

  • Ent ORM:Go用のコード生成型ORMライブラリEntを使用します。後ほどTodoエンティティのスキーマ定義に用いますので、まずgo get entgo.io/ent/cmd/entでEntコード生成ツールを準備してください(実行時に自動取得でも可)。Entはスキーマ定義から型安全なDBアクセスコードを生成するORMで、実行時のリフレクションに頼らずコンパイル時にCRUDコードを生成するため高速かつ安全なのが特徴です。

  • SQLite3:Todoデータの保存にはシンプルにSQLiteを使います。GoからSQLiteに接続するドライバgithub.com/mattn/go-sqlite3をインポートします。Entで生成されたコード内から利用されるため、go get github.com/mattn/go-sqlite3し、Goコードで適宜_ "github.com/mattn/go-sqlite3"をインポートしてドライバを有効化してください。

  • Node.js/npm (任意):ブラウザ用クライアントコードのバンドルにnpm経由でConnectのクライアントライブラリを利用します。基本的に手作業でも可能ですが、今回はより簡便な方法として後述のimport mapを用いたCDN経由読み込みやビルド手順を紹介します。

なお、本プロジェクトのディレクトリ構成は以下のようになります(作業の進行に応じて最終的にこのような構造になります)。

todoapp/
├── buf.yaml
├── buf.gen.yaml
├── proto/
│   └── todoapp/
│       └── v1/
│           └── todo.proto
├── gen
│   ├── go
│   │   └── proto
│   │       └── todoapp/v1/... (生成されたGoコード: *.pb.go, *connect.go)
│   └── js
│       └── proto
│           └── todoapp/v1/... (生成されたJS/TSコード: *_pb.js, *_connect.js 等)
├── ent/
│   ├── schema/
│   │   └── todo.go
│   └── ...        (Ent生成コード: client等)
├── server/
│   └── main.go    (サーバーエントリポイント)
├── web/
│   └── index.html (クライアント用シンプルページ)
└── go.mod, go.sum

では、具体的な実装手順に入っていきます。

Step 1: Protobufによるサービス定義 (todo.proto作成)

まずgRPCでやり取りするメッセージ型とサービスRPCをProtobufのインターフェース定義で定義します。proto/todoapp/v1/todo.protoというファイルを作成し、以下の内容を書き込みます。

syntax = "proto3";
package todoapp.v1;

option go_package = "github.com/example/todoapp/gen/go/todoapp/v1;todoappv1";


// サービスの定義
service TodoService {
  // 新しいTodoを作成する
  rpc CreateTodo(CreateTodoRequest) returns (CreateTodoResponse);
  // Todo一覧を取得する
  rpc ListTodos(ListTodosRequest) returns (ListTodosResponse);
}

// CreateTodo RPCのリクエストメッセージ
message CreateTodoRequest {
  string text = 1;        // Todo内容のテキスト
}
// CreateTodo RPCのレスポンスメッセージ
message CreateTodoResponse {
  Todo todo = 1;          // 作成されたTodoエントリ(詳細は後述Todoメッセージ型)
}

// ListTodos RPCのリクエストメッセージ(今回はフィルタ項目なし)
message ListTodosRequest {}
// ListTodos RPCのレスポンスメッセージ
message ListTodosResponse {
  repeated Todo todos = 1;  // 登録されているTodoのリスト
}

// Todoエントリのメッセージ定義
message Todo {
  int32 id = 1;       // 一意なID
  string text = 2;    // 内容テキスト
  bool done = 3;      // 完了フラグ
}

上記ではTodoServiceというサービスに2つのRPCメソッドを定義しました。CreateTodoはクライアントからTodo内容を受け取りサーバーで保存、新しく割り当てられたidなどと共に結果を返すものです。ListTodosは現在のTodo一覧を取得します。各RPCのリクエスト/レスポンス型もそれぞれCreateTodoRequest/ResponseListTodosRequest/Responseとして定義しています。また、Todo項目そのものの構造を表すメッセージTodoにはID、テキスト、完了フラグを持たせました。

補足: Protobufでは各フィールドに= 1, 2, 3...のようなフィールド番号を指定します。これは後から変更しにくい一意の番号なので、実際の開発では慎重に設計しますが、今回はシンプルに1,2,3としています。またpackage todoapp.v1はProtobufのパッケージ名で、GoやJSで生成されるコードの名前空間にも関係します。

Step 2: Bufの設定とコード生成

.protoファイルが書けたら、これを元にGoサーバー用とブラウザ用のコードを生成します。Bufを使うことで、一度のコマンドで複数言語分のコードを生成可能です。まずプロジェクトルート(todoapp/ディレクトリ)にbuf.yamlという設定ファイルを作成します。これはBufのモジュール設定です。

# buf.yaml
version: v1
name: buf.build/example/todoapp # モジュール名(ローカル開発用の仮名)

Bufでは.protoファイルを含むディレクトリ(今回はproto/)をモジュールとして扱います。nameはBufのレジストリに公開しない限りはローカルで一意ならOKです(ここではexample)。次に、どの言語向けコードを生成するかを指定するbuf.gen.yamlを作成します。今回はGo用と、ConnectのWebクライアント用(TypeScript/JavaScript)を生成するため、以下のようにします。

# buf.gen.yaml
version: v2
plugins:
  # Go用のプロトバッファメッセージ / gRPC サービス実装コード
  - remote: buf.build/protocolbuffers/go
    out: gen/go
    opt: paths=source_relative
  # Go用のConnectプラグイン(gRPC互換のHTTPサーバー/クライアントコード)
  - remote: buf.build/connectrpc/go
    out: gen/go
    opt: paths=source_relative
  # ブラウザ/Node.js向けのTypeScriptコード(Protobuf-es + Connect)
  - remote: buf.build/bufbuild/es:v1.6.0
    out: gen/js
    opt: target=js
  # Connect-Web用のTypeScriptクライアントコード
  - remote: buf.build/connectrpc/es:v1.2.0
    out: gen/js
    opt: target=js

上記ではBufのリモートプラグインを指定しています。protocolbuffers/goprotoc-gen-go(メッセージ構造体などを生成)に相当し、connectrpc/goprotoc-gen-connect-go(Connect用のサービスインターフェースとクライアントを生成)に相当します。

JavaScript/TypeScript用では、bufbuild/esがProtobufメッセージ型を生成し、connectrpc/esがConnect-Web用のサービスクライアントコードを生成します。これらは連携して動作し、ブラウザやNode.jsからgRPCサービスを呼び出すためのコード一式を提供します。

paths=source_relativeオプションはGoコードの生成先ディレクトリ構造をソースに合わせる指定です(Goモジュールパスとpackage対応のため)。

Buf設定ができたら、いよいよコード生成を行います。以下のコマンドをプロジェクトルートで実行してください。

$ buf generate

Bufはproto/以下の*.protoを検出し、buf.gen.yamlに従いプラグインを実行してコードを吐き出します。正しく実行できれば、gen/ディレクトリ以下にGoとJSのコードが生成されます。ディレクトリ構成としては以下のようになっているはずです。

gen
├── go
│   └── proto
│       └── todoapp
│           └── v1
│               ├── todo.pb.go
│               └── todoappv1connect
│                   └── todo.connect.go
└── js
    └── proto
        └── todoapp
            └── v1
                ├── todo_connect.d.ts
                ├── todo_connect.js
                ├── todo_pb.d.ts
                └── todo_pb.js
  • todo.pb.go: Go向けのProtobufメッセージ定義コードです。ここにはTodoや各Request/Response構造体、およびシリアライズ/デシリアライズのコードが含まれます(これらは自前で書く必要はなく、自動生成に任せます)。
  • todo.connect.go: Connect用のGoコードです。ここにはサーバーサイドで実装すべきインターフェース(TodoServiceHandler)と、クライアントサイドで使えるstub生成関数(NewTodoServiceClient)が定義されています。ConnectではgRPCサービスをHTTPハンドラとして実装・登録するため、このコードがその橋渡しを担います。
  • todo_pb.js/todo_pb.d.ts: ブラウザ/Node向けのProtobuf実装コードです。TypeScriptで書かれており、Protobufメッセージクラス(Todo,CreateTodoRequest等)とシリアライズ処理が含まれます。.d.tsは型定義ファイルです。
  • todo_connect.js/todo_connect.d.ts: ブラウザ向けのConnectクライアントstubコードです。こちらもTypeScriptで、TodoServiceを呼び出すためのクライアント実装が含まれます(具体的には後述するcreatePromiseClient関数に渡すサービス定義オブジェクトがエクスポートされています)。

ここまでで、gRPCサービスの雛形コードとデータ型定義コードが生成できました。次はこれらを利用して、実際のサーバーロジックとデータ保存処理を実装していきます。

Step 3: EntでTodoスキーマ定義とデータベース準備

gRPC経由で扱うTodoデータを永続化するため、GoのORMであるEntを使ってSQLite上にTodoテーブルを作成します。Entのスキーマ定義を行うことで、データベース操作用のコードも自動生成されます(Protobufがデータ構造のコードを生成するのと同じように、EntもDBアクセスコードを生成します)。

まず、Entの初期化を行います。

$ go mod init todoapp
$ go run entgo.io/ent/cmd/ent new Todo

これらのコマンドを実行すると、ent/schema/todo.goというスキーマ定義ファイルのひな形が作成されます。これを以下のように編集します。

// ent/schema/todo.go
package schema

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

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

// Fields of the Todo.
func (Todo) Fields() []ent.Field {
    return []ent.Field{
        field.String("text").NotEmpty(),
        field.Bool("done").Default(false),
    }
}

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

上記ではTodoエンティティにtext(空文字禁止)とdone(デフォルトfalse)の2つのフィールドを定義しました。IDはEntが自動で主キーIDを付与してくれます。今回はシンプルなのでエッジ(リレーション)やタイムスタンプ等は変更していません。

スキーマを定義したら、Entのコードジェネレーターを実行しましょう。プロジェクトルートで:

$ go generate ./ent

もしくは手動でgo run entgo.io/ent/cmd/ent generate ./ent/schemaを実行します。これにより、ent/ディレクトリ以下にTodoエンティティのモデルやCRUDメソッドを備えたコードが生成されます。ent/client.goent/todo/todo.goなどが生成され、Goからclient.Todo.Createclient.Todo.Queryといった形でデータ操作が可能になります。

次に、サーバー起動時にSQLiteデータベースに接続し、スキーマを適用する処理を追加します。ここでは簡単のためインメモリSQLiteを利用し(ファイルを作らずメモリ上で動かす)、アプリ起動時にテーブル作成まで行います。

Step 4: サーバー実装(Go, Connect, EntによるTodoサービス)

いよいよgRPCサーバーの実装です。Goの標準net/httpサーバーに、ConnectのHTTPハンドラを登録する形で実装します。生成済みのtodo.connect.goには、我々が実装すべきサービスハンドラのインターフェースが定義されています。具体的には todoappv1connect.TodoServiceHandler という interface で、CreateTodoListTodosメソッドが含まれているはずです。まずこのインターフェースを実装する構造体を作り、メソッド内でEntを用いたDB処理を行います。

依存関係をインストールします。

$ go get connectrpc.com/connect \
  golang.org/x/net/http2 \
  github.com/mattn/go-sqlite3 \ 

server/main.goを作成し、以下を記載します。

// server/main.go
package main

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

    connectrpc.com/connect         // ConnectのGoサーバーライブラリ
    "golang.org/x/net/http2"                 // HTTP2設定用
    "golang.org/x/net/http2/h2c"             // h2cサポート用

    "todoapp/ent"                            // Entの生成コード
    todoappv1 "todoapp/gen/go/proto/todoapp/v1"    // Protobufで生成されたメッセージ定義
    "todoapp/gen/go/proto/todoapp/v1/todoappv1connect" // Connectで生成されたサービス定義
    _ "github.com/mattn/go-sqlite3"          // SQLiteドライバ
)

// TodoServer構造体:TodoServiceHandlerインターフェースを実装
type TodoServer struct {
    client *ent.Client
}

// CreateTodoメソッドの実装
func (s *TodoServer) CreateTodo(
    ctx context.Context,
    req *connect.Request[todoappv1.CreateTodoRequest],
) (*connect.Response[todoappv1.CreateTodoResponse], error) {
    // リクエストからテキストを取得
    text := req.Msg.GetText()
    // Entを使って新規Todoレコード作成
    newTodo, err := s.client.Todo.
        Create().
        SetText(text).
        SetDone(false).
        Save(ctx)
    if err != nil {
        return nil, err
    }
    // 作成したレコードをレスポンスメッセージに詰めて返す
    resMsg := &todoappv1.CreateTodoResponse{
        Todo: &todoappv1.Todo{
            Id:   int32(newTodo.ID),
            Text: newTodo.Text,
            Done: newTodo.Done,
        },
    }
    return connect.NewResponse(resMsg), nil
}

// ListTodosメソッドの実装
func (s *TodoServer) ListTodos(
    ctx context.Context,
    req *connect.Request[todoappv1.ListTodosRequest],
) (*connect.Response[todoappv1.ListTodosResponse], error) {
    todos, err := s.client.Todo.Query().All(ctx)
    if err != nil {
        return nil, err
    }
    // エンティティからProtoメッセージへの変換
    var todoList []*todoappv1.Todo
    for _, t := range todos {
        todoList = append(todoList, &todoappv1.Todo{
            Id:   int32(t.ID),
            Text: t.Text,
            Done: t.Done,
        })
    }
    resMsg := &todoappv1.ListTodosResponse{ Todos: todoList }
    return connect.NewResponse(resMsg), nil
}

func main() {
    // 1. SQLiteに接続(インメモリDB使用)しEntクライアント作成
    client, err := ent.Open("sqlite3", "file:todo?mode=memory&cache=shared&_fk=1")
    if err != nil {
        log.Fatalf("failed opening connection to sqlite: %v", err)
    }
    defer client.Close()
    // スキーマ作成(テーブル生成)
    if err := client.Schema.Create(context.Background()); err != nil {
        log.Fatalf("failed creating schema resources: %v", err)
    }

    // 2. TodoServerインスタンス作成(Entクライアントを渡す)
    todoServer := &TodoServer{ client: client }

    // 3. HTTPハンドラにConnectのTodoサービスを登録
    mux := http.NewServeMux()
    // Generateされた関数により、パスとハンドラを取得
    path, handler := todoappv1connect.NewTodoServiceHandler(todoServer)
    mux.Handle(path, handler)

	// 静的ファイルの配信
	mux.Handle("/", http.FileServer(http.Dir(".")))

    // 4. サーバー起動 (h2c設定でHTTP/2をTLS無しで有効化)
    addr := ":8080"
    log.Printf("starting server on %s ...", addr)
    if err := http.ListenAndServe(
        addr,
        h2c.NewHandler(mux, &http2.Server{}),
    ); err != nil {
        log.Fatalf("server error: %v", err)
    }
}

上記がサーバー側のコード全体像です。

  1. データベース接続: Entのent.OpenでSQLiteに接続し、client.Schema.CreateでTodoエンティティ用のテーブルを作成します。file:todo?mode=memory...とすることでオンメモリのSQLiteを使っています(アプリ終了でデータは消えますが、本記事の範囲では問題ありません)。_fk=1は外部キーを有効化するSQLiteのパラメータですが、今回は外部キーは無いので省略可です。

  2. サービス実装用構造体: 生成されたTodoServiceHandlerインターフェースを実装するTodoServer構造体を定義し、Entのクライアントを保持させています。各RPCメソッド(CreateTodo, ListTodos)をレシーバーメソッドとして実装します。

    • CreateTodoでは、req.MsgからProtobufリクエストメッセージCreateTodoRequestが取得できるので(ConnectではRequest[T]から.Msgでデータにアクセス)、そのtextフィールドを取り出し、Entのclient.Todo.Create()を使って新しいTodoレコードを保存します。Entはメソッドチェーンでフィールド値を設定しSave(ctx)でINSERTを実行します。返ってきたnewTodoからIDやTextを取り出し、ProtobufのCreateTodoResponseメッセージを作成して返します。
    • ListTodosでは、Entのclient.Todo.Query().All(ctx)で全Todoを取得し、得られたスライスをProtobufのListTodosResponseメッセージ内のリストに詰め替えています。
    • どちらのメソッドも、Connectの形式でconnect.NewResponse(msg)によりレスポンスを生成してreturnしています。エラーが発生した場合は適宜errorをreturnすることで、ConnectがgRPCステータスコードに変換してくれます。
  3. HTTPハンドラ登録: Connectでは、todoappv1connect.NewTodoServiceHandler(todoServer)という生成済み関数を使って、実装したTodoServerをHTTPハンドラに変換します。この関数は、/todoapp.v1.TodoService/というパスとhttp.Handlerを返すので、それを標準のhttp.ServeMuxに登録します。こうすることで、HTTPサーバーへのリクエストを受け取った際に、該当パスは自動的にConnectが処理し、我々のTodoServerの各メソッドが呼ばれるようになります。

  4. サーバー起動: http.ListenAndServeでサーバーをポート8080で開始します。ここでポイントは、h2c(HTTP/2 Cleartext)を有効にしていることです。h2c.NewHandler(mux, &http2.Server{})とすることで、TLS無しでもHTTP/2での通信を受け付けます。ConnectはUnary RPCであればHTTP/1.1でも通信可能ですが、双方向ストリーミング等を見据えるとHTTP/2サポートが望ましいため、開発環境でもh2cを使っています。ブラウザからのアクセスもHTTP/1.1で問題なく可能であり、Connectプロトコルでは要求に応じて自動的にJSON or Protobufでハンドリングされます。

以上で、Go側のgRPCサーバー実装(正確にはConnectによるHTTP実装)ができました。コード中の主要処理(DB保存やリスト取得)がわずか数行で書けているのは、ProtobufとEntのコード自動生成によるボイラープレート削減の恩恵です。

ここまででサーバーの準備はOKです。次はクライアント側、ブラウザからこのサービスを呼び出してTodoを登録・閲覧する部分を実装します。

Step 5: ブラウザクライアント実装(Connect-Webを使用)

クライアント側では、Connect-WebのJavaScriptライブラリを利用して、先ほどのGoサーバー上のTodoServiceにリクエストを送ります。今回はVueやReactといったフレームワークは使わず、シンプルなHTML+Vanilla JSで実装します。

まず、クライアント画面用にweb/index.htmlを作成しましょう。内容は以下のように、Todo一覧表示と、新規Todo追加フォームだけの簡素なものです。

<!-- web/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>Todo App (gRPC Sample)</title>
  <!-- Connect-Web 利用のための Import Map -->
  <script type="importmap">
    {
      "imports": {
        "@connectrpc/connect": "https://unpkg.com/@connectrpc/connect@1.2.0?module",
        "@connectrpc/connect-web": "https://unpkg.com/@connectrpc/connect-web@1.2.0?module",
        "@bufbuild/protobuf": "https://unpkg.com/@bufbuild/protobuf@1.6.0?module"
      }
    }
  </script>
</head>
<body>
  <h1>Todo List (gRPC)</h1>
  <ul id="todo-list"></ul>
  <form id="todo-form">
    <input type="text" id="new-todo-text" placeholder="Enter todo..." required />
    <button type="submit">Add Todo</button>
  </form>

  <script type="module">
    import { createPromiseClient } from "@connectrpc/connect";
    import { createConnectTransport } from "@connectrpc/connect-web";
    import { TodoService } from "../gen/js/proto/todoapp/v1/todo_connect.js";

    // Connectクライアントの設定
    const transport = createConnectTransport({ baseUrl: "http://localhost:8080" });
    const client = createPromiseClient(TodoService, transport);

    // Todo一覧を取得してUIに表示する関数
    async function refreshTodos() {
      const response = await client.listTodos({});  // ListTodosRequestは空
      const listElem = document.getElementById("todo-list");
      listElem.innerHTML = "";  // 一旦クリア
      for (const todo of response.todos) {
        const li = document.createElement("li");
        li.textContent = todo.done 
          ? `[Done] ${todo.text}` 
          : todo.text;
        listElem.appendChild(li);
      }
    }

    // フォーム送信で新規Todoを追加
    document.getElementById("todo-form").addEventListener("submit", async (e) => {
      e.preventDefault();
      const input = document.getElementById("new-todo-text");
      const text = input.value;
      input.value = "";
      try {
        await client.createTodo({ text });  // CreateTodoRequest(text)を送信
        await refreshTodos();
      } catch (error) {
        console.error("Failed to create todo:", error);
      }
    });

    // ページロード時に現在のTodo一覧取得
    await refreshTodos();
  </script>
</body>
</html>

説明します。

  • ヘッド内で<script type="importmap">を用いてImport Mapを定義しています。これにより、@connectrpc/connect等のモジュール名でCDN(unpkg経由)からライブラリを読み込めるようになります。npmでローカルインストールしてバンドルする代わりに、ここではCDNを利用した手軽な方法を採用しています。この設定により、後続のscript内でimportする際にこれらのモジュールが解決されます。
  • TodoServiceは先ほどBuf生成したクライアントstubコード(gen/js/proto/todoapp/v1/todo_connect.js)からインポートしています。これがサービス定義を表すオブジェクトで、ConnectのcreatePromiseClientに渡してクライアントを生成します。baseUrlには我々のGoサーバーのエンドポイント(今回はhttp://localhost:8080)を指定します 。この時点でclientオブジェクトが、TodoServiceのcreateTodolistTodosメソッドを持つ通信クライアントとなります。
  • refreshTodos()client.listTodos()を呼び出してTodo一覧(レスポンスのtodos配列)を取得し、簡単な<ul>リストに表示しています。createPromiseClientで生成したクライアントはデフォルトでPromiseベースの関数を提供するため、await client.listTodos({})のようにシンプルに呼び出せます。引数には{}(空のListTodosRequest)を渡しています。
  • フォーム送信ハンドラでは、入力されたテキストを使ってclient.createTodo({ text })を呼び出しています。こちらもawaitで非同期実行し、エラー時はキャッチしてコンソールに出す程度にしています。成功したら直後に一覧を再取得して表示を更新しています。
  • ページロード時にもrefreshTodos()を呼んで、初期状態のTodo一覧を読み込むようにしています。

以上でクライアント実装も完了です。Connect-Webのおかげで、XHRやFetchでJSONを手作業でやり取りするコードを書く必要はなく、サーバーと同じProtoで定義した型をそのまま扱う形で実装できています。HTTPの詳細(パスやヘッダー)も意識せず、あたかもローカルのメソッドを呼ぶように通信処理を書ける点がgRPC/Connectの大きなメリットです。

補足: Import Mapを使わずに実装する方法として、Node環境でnpm install @connectrpc/connect @connectrpc/connect-web @bufbuild/protobufし、WebpackやVite等でバンドルしてブラウザ用JSを生成する手段もあります。しかし本記事では環境依存や設定ファイルの複雑さを避けるため、CDN経由ロード+ESModulesの方法を採りました。Fetch APIを直接使ってJSONをPOSTすることも可能ですが、Connectのクライアントを使うことでproto定義どおりの型安全なコードとなります。

Step 6: 動かしてみる

サーバーとクライアントの実装が揃ったので、実際に動作確認をします。

  1. ターミナル1つ目でGoサーバーを起動します。プロジェクトルートで go run ./server/main.go を実行してください。starting server on :8080 ...と表示され待機状態になります。
  2. 今回の構成ではGoサーバーと同じポートで静的ファイルも配信しているため、実は既にindex.htmlにブラウザアクセス可能です。http://localhost:8080/web/index.html にブラウザでアクセスしてください。
  3. ブラウザにTodoアプリの画面が表示されたら、フォームにテキストを入力してAdd Todoボタンを押します。するとページ上部のリストにTodoが追加表示されるはずです。複数入力すればそのたびにリストが増えていきます。これらの操作はバックエンドではgRPCとして処理されており、ネットワークタブでリクエストを見ると/todoapp.v1.TodoService/CreateTodo.../ListTodosへのPOSTが発生しているのが確認できます(Content-Typeはapplication/jsonになっているでしょう。ConnectクライアントはデフォルトでJSONを用いているためです)。

もしブラウザで正しく動作しない場合、以下を確認してください。

  • サーバーコンソールにエラーが出ていないか(SQLエラーやpanicなど)。
  • Import Mapが機能しない場合は、対応ブラウザかどうか(Chrome/Edge/Firefoxの最新あたりなら問題ないはず)を確認してください。

おわりに

以上、Protocol Buffersによるスキーマ定義からgRPCサーバー・クライアント実装まで、シンプルなTodoアプリを題材に一通り体験しました。.protoからコードを生成し、サーバーとクライアント双方でそのコードを活用する流れを理解できたかと思います。ポイントを振り返ります。

  • Protobufを用いたAPI定義: .protoファイルにサービスとメッセージを宣言することで、言語に依存しないインターフェースを設計できます。フィールド番号などの約束事に注意は必要ですが、可読性も高いため扱いやすいです(事実、gRPCは「.protoさえ書けば他は生成に任せられる」点が大きな魅力です)。
  • Bufによるコード生成: Buf CLIを使うことで、Go用、ブラウザJS用といった複数ターゲットへのコード生成をシンプルなコマンドで実行できました。Bufはプラグイン管理やLint機能など開発体験を向上させてくれます。
  • Connectを用いたサーバー実装: Buf社によるConnectフレームワークを使うことで、Goの標準HTTPサーバーに統合する形でgRPCサービスを実装できました。ConnectはgRPC-Webとの互換性も持ちつつ、curlやブラウザからそのままアクセスできるという実用上のメリットがあります。
  • Entによるデータアクセス: GoのEnt ORMを使い、こちらもコード生成によって複雑なSQL操作をシンプルなメソッド呼び出しで記述できました。EntはgRPCとの組み合わせも相性が良く、双方ともスキーマ駆動であるため型安全かつ明快な実装につながりました(スキーマ定義→コード生成という流れが共通しています)。
  • クライアントからのRPC呼び出し: Connect-Webクライアントを用いて、ブラウザから直接gRPCサービスを呼び出しました。従来はgRPC-Web専用クライアントや特殊な設定が必要でしたが、Connectでは普通のFetchに近い感覚で実装でき、さらにprotoの型に沿ったメソッドをそのまま呼べるため実装ミスも減ります。

もし時間に余裕があれば、ぜひ以下もやってみてください。

  • タスクをDoneにする : 記事内では未実装のTodoの完了機能を実装します。(手順:.proto 追記 → buf generate → サーバー実装 → JS 追加)
  • ユニットテスト: gRPCハンドラをテストする際、Connectではclient = TodoServiceClientを直接呼び出す形でテストを書けます。モックサーバーを立てずとも、Goでは生成コードのインターフェースを満たす自作クライアントを用意するなど柔軟なテストが可能です。
  • ストリーミングRPC: Todoアプリでは使いませんでしたが、gRPCのストリーミングにもConnectは対応しています。双方向ストリーミングはWebではWebSocketベースになりますが、ブラウザからも利用可能です。チャットアプリなどに応用できます。
  • エラーハンドリングとステータスコード: ConnectではGoのerrorを返すだけでgRPCステータスに自動変換されました。より細かいエラーコード制御や独自のError Detailを返したい場合、Connectのエラー型やプロトコルメッセージで定義したエラーコードを利用できます。

以上、長くなりましたが、初めてのgRPC実装の取っ掛かりとしてTodoアプリ例を通じた解説を行いました。「プロトコルバッファーで型とサービスを定義し、あとは生成コードを使って実装する」という基本パターンは理解いただけたと思います。

今回作成したTodoアプリは小規模ですが、gRPC/Protobufのエッセンスは詰まっているかと思います。ぜひ、今後はより発展的なサービスや他言語クライアントとの連携にもチャレンジしてみてください。お疲れさまでした!

↓ 実装した後に読むとさらに理解が深まるかもしれません。
https://zenn.dev/shimpei_takeda/articles/c5e52657659cb7

Discussion