🌐

Connect RPC + Protocol Buffers で少人数チームの開発効率を劇的に向上させた話

に公開

こんにちは。PortalKey の植森です。

前回、PortalKey の主要技術に関してざっくりと解説をしました。
今回は、その中のひとつである Protocol Buffers について掘り下げていきます。

プロジェクト概要と Protocol Buffers 採用の背景

PortalKey は、リモートワーク環境でのコミュニケーションをより自然で効率的にするためのプラットフォームです。
このプロジェクトでは、Protocol Buffers を中心としたスキーマ駆動開発を採用しています。

Protocol Buffers と手に馴染む道具の話

Protocol Buffers は前職で使っていた経験もあり、非常に手に馴染む道具だと感じています。
*「手に馴染む道具」の話については、yuguiさんが素晴らしい記事を書いてくださっているのでこちらを見るのがおすすめです
https://qiita.com/yugui/items/160737021d25d761b353

Protocol Buffers 自体はバイナリシリアライゼーションのフォーマットですが、IDL が非常に優秀であり、表現力が高いことが特徴として挙げられます。

  • IDL 自体は proto 定義のパーサであり、単体でバリデーションやドキュメンテーションが可能
  • IDL のプラグインシステムが拡張性が高く、IDL の定義から様々なコードを生成することが出来る
  • OpenAPI のような YAML 形式よりも可読性が高い

これらの特徴により、例えば以下のような利用シーンがあります。

このように、単なるバイナリシリアライゼーションの枠を超えて「スキーマを定義する」というコンテキストにおいて高い汎用性と拡張性を持っています。

なぜ Protocol Buffers を採用したのか

プロジェクト開始時、私たちは以下のような状況に直面していました:

  • 少人数チームでの効率的な開発の必要性: プロジェクトメンバーが 3 人で、Web 開発経験のあるメンバーが自分 1 人だけ
  • 型安全性の確保: フロントエンド(TypeScript)とバックエンド(Go)間での型の整合性を保つ必要
  • 学習コストの最小化: Web 開発経験の少ないメンバーでも重要でない部分に躓かずに開発できるようにしたい

これらの課題を解決するために、IDL(Interface Definition Language)として Protocol Buffers を採用しました。

Protocol Buffers は以下の理由で私たちの要件に最適と判断しました。

  1. スキーマ駆動開発: 一度スキーマを定義すれば、複数の言語で型安全なコードを自動生成できる
  2. 豊富なエコシステム: Connect RPC、gRPC、WebSocket など様々な通信方式に対応
  3. 拡張性: カスタムプラグインによる独自のコード生成が可能

Connect RPC による API 設計

PortalKey では、HTTP API の通信に Connect RPC を採用しています。
Connect RPC は Protocol Buffers ベースの RPC フレームワークで、HTTP/1.1 と HTTP/2 の両方に対応し、gRPC との互換性も持っています。

Protocol Buffers と Connect RPC を活用することで、少ない実装コードで型安全な実装が行えています。
以下は認証サービスの proto 定義と、それから生成されるコードの例です。

サンプルの proto 定義

proto/portalkey/v1/auth_service.proto
syntax = "proto3";

package portalkey.v1;

message AuthServiceLoginRequest {
  string email = 1;
  string password = 2;
}

message AuthServiceLoginResponse {
  string access_token = 1;
  string refresh_token = 2;
  User user = 3;
}

service AuthService {
  rpc Login(AuthServiceLoginRequest) returns (AuthServiceLoginResponse) {}
  rpc Refresh(AuthServiceRefreshRequest) returns (AuthServiceRefreshResponse) {}
}

生成されるコードの活用

Connect RPC により以下のようなコードが生成されます。

frontend/src/libs/portalkey-api-types/portalkey/v1/auth_service_pb.ts
// Protocol Buffers が生成する Message クラス
export class AuthServiceLoginRequest extends Message<AuthServiceLoginRequest> {
  constructor(data?: PartialMessage<AuthServiceLoginRequest>) {
    super();
    proto3.util.initPartial(data, this);
  }

  static readonly runtime: typeof proto3 = proto3;
  static readonly typeName = "portalkey.v1.AuthServiceLoginRequest";
  static readonly fields: FieldList = proto3.util.newFieldList(() => [
    { no: 1, name: "email", kind: "scalar", T: 9 },
    { no: 2, name: "password", kind: "scalar", T: 9 },
  ]);

  email: string = "";
  password: string = "";
}
frontend/src/libs/portalkey-api-types/portalkey/v1/auth_service_connect.ts
// Connect RPC が生成するクライアント
export interface AuthServiceClient {
  login(request: ConnectRequest<AuthServiceLoginRequest>): Promise<ConnectResponse<AuthServiceLoginResponse>>;
  refresh(request: ConnectRequest<AuthServiceRefreshRequest>): Promise<ConnectResponse<AuthServiceRefreshResponse>>;
}

また、後述するカスタムプラグインによって、Connect が要求する interface を満たすクラスを実装するコードを自動生成しています。

frontend/src/libs/portalkey-api/portalkey/v1/auth_service_ext.ts
// プロジェクト固有のラッパークラス
export class AuthServiceAPI {
  constructor(private readonly connect: ConnectClient<typeof AuthService>) {}

  public async login(body: AuthServiceLoginRequest): Promise<AuthServiceLoginResponse> {
    return await this.connect.login(body, { headers: this.headers }).then((res) => toPlainMessage(res))
  }

  public async refresh(body: AuthServiceRefreshRequest): Promise<AuthServiceRefreshResponse> {
    return await this.connect.refresh(body, { headers: this.headers }).then((res) => toPlainMessage(res))
  }
}
frontend/src/libs/portalkey-api/index.ts
export class PortalKeyAPI {
  public readonly auth: AuthServiceAPI
  public readonly workspace: WorkspaceServiceAPI
  // ... 他のサービス

  constructor(connect: Connect) {
    this.auth = new AuthServiceAPI(connect(AuthService))
    this.workspace = new WorkspaceServiceAPI(connect(WorkspaceService))
    // ...
  }
}

フロントエンドでの型安全な API 呼び出し

フロントエンドでは、生成された型定義を活用して型安全な API 呼び出しが可能です:

// 型安全なリクエスト
const response = await api.auth.login({
  email: "user@example.com",
  password: "password",
});

// responseは AuthServiceLoginResponse 型で型安全
console.log(response.access_token);
console.log(response.user.name);

WebSocket メッセージングシステムの設計

また、PortalKey では、リアルタイム通信に WebSocket を採用しています。
Connect RPC では必要に応じて WebSocket にフォールバックされる gRPC をベースとした Streaming 通信の実装が存在しますが、WebSocket が枯れていて安定した技術でありプラットフォームを選ばないこと、Connect の採用自体が新しい挑戦でありスタートアップの実装において技術的負債へのリスクを負いすぎないようにすることを理由に WebSocket を選択しました。

その WebSocket の実装においても Protocol Buffers を活用しています。

  • WebSocket でやり取りするメッセージの型定義
  • クライアント・サーバ間でやり取りされるメッセージの種類およびコードの定義
  • カスタムプラグインを実装して周辺コードを自動生成

これらの Protocol Buffers を活用した実装により、WebSocket の実装コストを大幅に削減しています。

以下は、WebSocket メッセージの型定義と、それから生成されるコードの例です。

Protocol Buffers を使った WebSocket メッセージの型定義

WebSocket メッセージは以下のような定義を行っています。

proto/portalkey/v1/gateway.proto
syntax = "proto3";

package portalkey.v1;

// クライアントがGatewayに対して送信するメッセージコード
enum ClientMessageCode {
  CLIENT_MESSAGE_CODE_UNSPECIFIED = 0;
  CLIENT_MESSAGE_CODE_HEARTBEAT = 100;
  CLIENT_MESSAGE_CODE_IDENTIFY = 101;
  CLIENT_MESSAGE_CODE_JOIN_VOICE = 102;
  CLIENT_MESSAGE_CODE_RESUME = 104;
  // ... その他のメッセージコード
}

// クライアントがGatewayに送信するメッセージ
message ClientMessage {
  ClientMessageCode code = 1;

  // oneofはメッセージ毎に固有のペイロードがそれぞれ定義されている
  oneof payload {
    HeartbeatPayload heartbeat = 100;
    IdentifyPayload identify = 101;
    JoinVoicePayload join_voice = 102;
    ResumePayload resume = 104;
    // ... その他のペイロード
  }
}

// Gatewayがクライアントに対して送信するメッセージコード
enum GatewayMessageCode {
  GATEWAY_MESSAGE_CODE_UNSPECIFIED = 0;
  GATEWAY_MESSAGE_CODE_HELLO = 100;
  GATEWAY_MESSAGE_CODE_READY = 101;
  GATEWAY_MESSAGE_CODE_RESUMED = 102;
  // ... その他のメッセージコード
}

// Gatewayがクライアントに送信するメッセージ
message GatewayMessage {
  GatewayMessageCode code = 1;
  oneof payload {
    HelloPayload hello = 100;
    ReadyPayload ready = 101;
    ResumedPayload resumed = 102;
    // ... その他のペイロード
  }
}

サーバー側でのメッセージハンドラの実装

サーバー側では、生成されたメッセージコードを使用してハンドラを登録します:

pkg/handlers/gateway/handler.go
func RegisterHandler(app app.Application) *gateway.Gateway {
	gtw := gateway.New()
	handler := NewHandler(app)

	// メッセージコードに対して処理するハンドラを登録
	gtw.RegisterHandler(portalkeyv1.ClientMessageCode_CLIENT_MESSAGE_CODE_HEARTBEAT, handler.Heartbeat, true)
	gtw.RegisterHandler(portalkeyv1.ClientMessageCode_CLIENT_MESSAGE_CODE_IDENTIFY, handler.Identify, true)
	gtw.RegisterHandler(portalkeyv1.ClientMessageCode_CLIENT_MESSAGE_CODE_JOIN_VOICE, handler.JoinVoice, false)
	// ... その他のハンドラ

	return gtw
}

以下は実際のハンドラの実装例です。

pkg/handlers/gateway/heartbeat.go
// Heartbeatハンドラの処理
func (h *Handler) Heartbeat(ctx gateway.Context, payload *portalkeyv1.HeartbeatPayload) error {
    // Msg()でハンドラに対応したメッセージを取得し、取得できなければクライアントの送信したメッセージが誤っているためエラーを返す
    payload := ctx.Msg().GetHeartbeat()

	if payload == nil {
		return status.ErrUnsupportedData
	}
	// クライアントの接続状態を更新
	ctx.Conn().UpdateLastHeartbeat(time.Now())
	return nil
}

クライアント・サーバー間の通信フロー

WebSocket メッセージングシステムでは、以下のような流れで通信が行われます。ここでは、proto 定義とハンドラ、メッセージ型の関連性について詳しく説明します。

proto 定義との関連性

まず、通信の基盤となる proto 定義です。

proto/portalkey/v1/gateway.proto
// メッセージコードの定義
enum ClientMessageCode {
  CLIENT_MESSAGE_CODE_HEARTBEAT = 100;
  // ... その他のメッセージコード
}

// クライアントメッセージの定義
message ClientMessage {
  ClientMessageCode code = 1;
  oneof payload {
    HeartbeatPayload heartbeat = 100;
    // ... その他のペイロード
  }
}
proto/portalkey/v1/message_payload.proto
// ハートビートのペイロード定義
message HeartbeatPayload {
  int32 sequence = 1;
}

これらの proto 定義から、以下のコードが自動生成されます:

  1. メッセージコードの定数: ClientMessageCode.HEARTBEAT
  2. メッセージ型: ClientMessageHeartbeatPayload
  3. ペイロード取得メソッド: GetHeartbeat()

通信フローの詳細

  1. クライアント側でのメッセージ送信:
frontend/src/libs/portalkey-client/gateway/PortalKeyWebSocket.ts
// クライアントがハートビートを送信
// ClientMessageCode.HEARTBEAT は proto 定義から生成された定数
this.send({
  code: ClientMessageCode.HEARTBEAT,  // ← proto の enum から生成
  payload: { sequence: this.sequence } // ← HeartbeatPayload の型に基づく
})
  1. 自動生成されたエンコード関数によるバイナリ変換:
frontend/src/libs/portalkey-api/portalkey/v1/gateway_pb_ext.ts
// 自動生成されたエンコード関数
// proto 定義の ClientMessage と oneof payload から生成される
export function encodeClientMessage(message: ClientMessage): gatewayPb.ClientMessage {
  switch (message.code) {
    case gatewayPb.ClientMessageCode.HEARTBEAT: // ← proto の enum から生成
      return new gatewayPb.ClientMessage({
        code: gatewayPb.ClientMessageCode.HEARTBEAT,
        payload: { case: "heartbeat", value: message.payload } // ← proto の oneof から生成
      })
    // ... その他のケース
  }
}
  1. サーバー側でのメッセージ受信とルーティング:
pkg/server/gateway/server.go
// サーバー側でメッセージを受信し、メッセージコードに基づいてハンドラに振り分け
func (s *Server) handleMessage(conn *Client, data []byte) error {
    var msg portalkeyv1.ClientMessage // ← proto から生成された型
    if err := proto.Unmarshal(data, &msg); err != nil {
        return err
    }

    // メッセージコードに基づいてハンドラを呼び出し
    // msg.Code は proto の ClientMessageCode enum から生成されたフィールド
    handler, exists := s.handlers[msg.Code]
    if !exists {
        return fmt.Errorf("unknown message code: %v", msg.Code)
    }

    return handler(conn, &msg)
}
  1. ハンドラでのペイロード取得:
pkg/handlers/gateway/heartbeat.go
func (h *Handler) Heartbeat(ctx gateway.Context, msg *portalkeyv1.ClientMessage) error {
    // GetHeartbeat() は proto の oneof payload から自動生成されたメソッド
    // HeartbeatPayload の型で返される
    payload := msg.GetHeartbeat()
    if payload == nil {
        return status.ErrUnsupportedData
    }

    // payload.Sequence は proto の HeartbeatPayload.sequence フィールドから生成
    // ハートビートの処理
    ctx.Conn().UpdateLastHeartbeat(time.Now())
    return nil
}

ハンドラ登録との関連性

メッセージに対応したハンドラーは以下のように登録します。
ここで登録することで、クライアントから受信したメッセージコードに対応したハンドラーが自動的に呼び出されるようになります。

pkg/handlers/gateway/handler.go
func RegisterHandler(app app.Application) *gateway.Gateway {
	gtw := gateway.New(app.IsDebug())
	handler := NewHandler(app)

	// メッセージコードに基づいてハンドラを登録
	// portalkeyv1.ClientMessageCode_CLIENT_MESSAGE_CODE_HEARTBEAT は
	// proto の ClientMessageCode enum から生成された定数
	gtw.RegisterHandler(portalkeyv1.ClientMessageCode_CLIENT_MESSAGE_CODE_HEARTBEAT, handler.Heartbeat, true)
	// ... その他のハンドラ

	return gtw
}

自動生成コードが担うボイラープレートの削減

この通信フローにおいて、以下のボイラープレートコードが自動生成により削減されています:

  1. メッセージコードの定数定義: ClientMessageCode.HEARTBEAT などの定数(proto の enum から生成)
  2. エンコード/デコード関数: encodeClientMessagedecodeGatewayMessage など(proto の message 定義から生成)
  3. ペイロード取得メソッド: GetHeartbeat() などの型安全なアクセサ(proto の oneof から生成)
  4. 型定義: ClientMessageGatewayMessage などの型(proto の message 定義から生成)

これらを手動で実装すると、メッセージの種類が増えるたびに大量のボイラープレートコードを書く必要がありますが、Protocol Buffers の自動生成により、proto ファイルの定義から一貫したコードが生成されます。

クライアント側の WebSocket 実装

クライアント側では、ProtocolBuffers のバイナリシリアライゼーションを活用して効率的な通信を実現しています:

frontend/src/libs/portalkey-client/gateway/PortalKeyWebSocket.ts
export class PortalKeyWebSocket extends AsyncEventEmitter<WebSocketEventMap> {
  private ws: WebSocketConnection

  constructor(url: string, connector: Connector = defaultConnector) {
    super()
    this.ws = connector(url)
    this.ws.binaryType = "arraybuffer" // バイナリ通信を有効化
    this.ws.onmessage = this._handleMessage
  }

  // メッセージ送信(バイナリ形式)
  send(message: ClientMessage) {
    if (this.ws == null) {
      throw new Error("Socket is not connected")
    }

    // ProtocolBuffersのバイナリ形式で送信
    this.ws.send(encodeClientMessage(message).toBinary().buffer)
    this.emit(WebSocketEvent.Send, { data: message })
  }

  // メッセージ受信時の処理
  private _unpackMessage = (e: MessageEvent<number>): GatewayMessage | undefined => {
    try {
      const data = new Uint8Array(e.data)
      const raw = decodeGatewayMessage(data)
      return decodeGatewayPayload(raw.code, raw)
    } catch (e) {
      if (e instanceof Error) {
        this.emit(WebSocketEvent.Error, e)
      }
      return undefined
    }
  }

  private _handleMessage = (e: MessageEvent<number>) => {
    if (this.disconnectRequested) {
      return
    }
    const message = this._unpackMessage(e)

    if (message == null) {
      return
    }

    this.emit(WebSocketEvent.Dispatch, message)
  }
}

カスタム protoc plugin の開発と活用

Connect RPC の型拡張(PlainMessage wrapper)

Connect RPC が生成する型はMessageクラスベースで、以下の理由からそのままでは扱いにくい場合があります。

  1. Redux との互換性: Redux が class ベースのオブジェクトではなくプリミティブなオブジェクトを要求すること
  2. 開発効率: PlainMessageでラップした型を高頻度で利用するため、手動での変換が煩雑になること
  3. 防腐レイヤー: Protocol Buffers のクラスに依存しすぎないようにするための防腐レイヤーとしての役割
  4. ボイラープレート: AuthServiceAPIのような通信部分のコードは毎回同じパターンを実装する必要があるため作業が煩雑になりがちなこと

そこで、プロジェクトで実装した Connect RPC の拡張プラグインでは、Connect RPC で生成される型をPlainMessageでラップした wrapper 型やクライアントコードから利用する API Service クラスを生成します。

frontend/src/libs/portalkey-api/portalkey/v1/auth_service_ext.ts
// @generated by protoc-gen-ts_ext

import { PlainMessage, toPlainMessage } from "@bufbuild/protobuf"
import { AuthService } from "@/libs/portalkey-api-types/portalkey/v1/auth_service_connect"
import * as pb from "@/libs/portalkey-api-types/portalkey/v1/auth_service_pb"

// PlainMessageでラップされた型定義
export type AuthServiceLoginRequest = PlainMessage<pb.AuthServiceLoginRequest>
export type AuthServiceLoginResponse = PlainMessage<pb.AuthServiceLoginResponse>

export class AuthServiceAPI {
  constructor(private readonly connect: ConnectClient<typeof AuthService>) {}

  public async login(body: AuthServiceLoginRequest): Promise<AuthServiceLoginResponse> {
    // toPlainMessageでレスポンスをプレーンオブジェクトに変換
    return await this.connect.login(body, { headers: this.headers }).then((res) => toPlainMessage(res))
  }
}

WebSocket 実装コードの自動生成

メッセージコードに応じた decode や encode といった処理は毎回手で書く必要がないコードであり、間違うと問題になりやすいコードです。これらも自動生成コードを利用することで実装やレビューコスト削減を図っています。

以下のようなファイルを生成することで、WebSocket メッセージ用の型安全なエンコード/デコード関数や、メッセージの種類ごとの型定義を自動化しています。

frontend/src/libs/portalkey-api/portalkey/v1/gateway_pb_ext.ts
// @generated by protoc-gen-ts_ext

import * as gatewayPb from "@/libs/portalkey-api-types/portalkey/v1/gateway_pb"
import * as messagePb from "@/libs/portalkey-api-types/portalkey/v1/message_payload_pb"
import { PlainMessage, toPlainMessage } from "@bufbuild/protobuf"

// 型安全なメッセージ型定義
export type ClientMessagePayload = HeartbeatPayload | IdentifyPayload | JoinVoicePayload | ResumePayload

export interface ClientMessageHeartbeat {
  code: gatewayPb.ClientMessageCode.HEARTBEAT
  payload: HeartbeatPayload
}

export type ClientMessage = ClientMessageHeartbeat | ClientMessageIdentify | ClientMessageJoinVoice | ClientMessageResume

// エンコード関数
export function encodeClientMessage(message: ClientMessage): gatewayPb.ClientMessage {
  switch (message.code) {
    case gatewayPb.ClientMessageCode.HEARTBEAT:
      return new gatewayPb.ClientMessage({
        code: gatewayPb.ClientMessageCode.HEARTBEAT,
        payload: { case: "heartbeat", value: message.payload }
      })
    // ... その他のケース
    default:
      throw new Error("Unknown ClientMessage code")
  }
}

// デコード関数
export function decodeGatewayPayload<T extends keyof GatewayMessageMap>(code: T, message: gatewayPb.GatewayMessage): GatewayMessageMap[T][0] | undefined {
  if (code !== message.code) {
    return undefined
  }
  switch (code) {
    case gatewayPb.GatewayMessageCode.HELLO:
      if (message.payload.case !== "hello") {
        return undefined
      }
      return {
        code: gatewayPb.GatewayMessageCode.HELLO,
        payload: toPlainMessage(message.payload.value) as HelloPayload
      } as GatewayMessageMap[T][0]
    // ... その他のケース
    default:
      return undefined
  }
}

プラグイン実装の基本

protoc プラグインの実装については、こちらもyugui さんの記事で詳しく解説されているのでそちらを読んでもらうのが良いと思います。
https://qiita.com/yugui/items/87d00d77dee159e74886

Protocol Buffers のプラグインは非常にシンプルな設計思想と仕様になっており、プラグインが実装しやすいのも特徴です。

  • プラグインへは標準入力として CodeGeneratorRequest が渡される
  • プラグインからは標準出力として CodeGeneratorResponse を返す

標準入力・標準出力という汎用的なインターフェース経由でやり取りが行われるためプラグイン側の実装は任意の言語で行うことが可能で、また CodeGeneratorResponse で出力対象の情報を返せばいいというシンプルな構造になっています。
あえて難しい点を挙げるとすれば CodeGeneratorRequestCodeGeneratorResponse の構造を理解するのに protoc と proto ファイルのパース結果のデータ構造を理解する必要がある点でしょうか。

以下は実際のプラグインのテンプレートおよびコードのサンプルです。

tools/protoc-gen-ts_ext/templates/gateway_pb_ext.ts.tpl
// @generated by protoc-gen-ts_ext
// @generated from file {{.FileName}} (package {{.Package}})

import * as gatewayPb from "@/libs/portalkey-api-types/portalkey/v1/gateway_pb"
import * as messagePb from "@/libs/portalkey-api-types/portalkey/v1/message_payload_pb"
import { PlainMessage, toPlainMessage } from "@bufbuild/protobuf"

export const RawGatewayMessage = gatewayPb.GatewayMessage
export const RawClientMessage = gatewayPb.ClientMessage

// Define ClientMessagePayload types
{{- range .ClientMessagePayloadTypes }}
export type {{.}}Payload = PlainMessage<messagePb.{{- .}}Payload>
{{- end }}

export type ClientMessagePayload =
{{- range $index, $elem := .ClientMessagePayloadTypes }}
  | {{.}}Payload
{{- end }}

// Define ClientMessage types
{{- range .ClientMessagePayloadTypes }}
export interface ClientMessage{{.}} {
  code: gatewayPb.ClientMessageCode.{{toScreamingSnakeCase .}}
  payload: {{.}}Payload
}
{{ end }}
export type ClientMessage = {{ range $index, $elem := .ClientMessagePayloadTypes }}{{if $index}} | {{end}}ClientMessage{{.}}{{end}}

export function encodeClientMessage(message: ClientMessage): gatewayPb.ClientMessage {
  switch (message.code) {
{{- range .ClientMessagePayloadTypes }}
    case gatewayPb.ClientMessageCode.{{toScreamingSnakeCase .}}:
      return new gatewayPb.ClientMessage({
        code: gatewayPb.ClientMessageCode.{{toScreamingSnakeCase .}},
        payload: { case: "{{pascalToCamel . }}", value: message.payload }
      })
{{- end }}
    default:
      throw new Error("Unknown ClientMessage code")
  }
}
tools/protoc-gen-ts_ext/main.go
package main

import (
	"io"
	"log"
	"os"
	"strings"

	plugin_go "github.com/golang/protobuf/protoc-gen-go/plugin"
	"google.golang.org/protobuf/proto"
)

func main() {
	data, err := io.ReadAll(os.Stdin)
	if err != nil {
		panic(err)
	}

    // 標準入力をCodeGeneratorRequestとしてUnmarshalする
	req := &plugin_go.CodeGeneratorRequest{}
	if err := proto.Unmarshal(data, req); err != nil {
		panic(err)
	}

	res := generateCode(req)

	output, err := proto.Marshal(res)
	if err != nil {
		panic(err)
	}

    // CodeGeneratorResponseをMarshalした結果を標準出力へ書き込む
	os.Stdout.Write(output)
}

// CodeGeneratorRequestから任意のコードを生成し、CodeGeneratorResponseへ格納して返す
func generateCode(req *plugin_go.CodeGeneratorRequest) *plugin_go.CodeGeneratorResponse {
	res := &plugin_go.CodeGeneratorResponse{}
	for _, protoFile := range req.ProtoFile {
		if !strings.HasPrefix(protoFile.GetName(), "portalkey/v1") {
			continue
		}

		if strings.HasSuffix(protoFile.GetName(), "gateway.proto") {
			if file := generateGatewayCode(protoFile); file != nil {
				res.File = append(res.File, file)
			}
		} else {
			if file := generateServiceCode(protoFile); file != nil {
				res.File = append(res.File, file)
			}
		}
	}
	return res
}

生成されるコードと開発効率の向上

プラグインにより生成されるコードは、以下のようなメリットとともに開発効率の向上をもたらします。

  • 型安全性: コンパイル時にメッセージの型エラーを検出
  • 開発速度: ボイラープレートコードの自動生成
  • 保守性: スキーマ変更時の自動的な型更新
  • 一貫性: フロントエンド・バックエンド間での型の整合性保証

しかし、一方で自動生成コードには以下のようなデメリットもあります。

  • 差分の複雑化: 実装コードの差分、特に pull request の変更量が多くなりがちで実際の実装量がわかりづらい
  • コンフリクトの発生: 自動生成コードがコンフリクトしやすい

差分の複雑化への対策の Tips としては、GitHub では自動生成コードに .gitattributes を追記することでレビューの複雑性を下げることができます。

.gitattributes
# 自動生成ファイルは差分を表示しない
frontend/src/libs/portalkey-api-types/** linguist-generated=true
frontend/src/libs/portalkey-api/** linguist-generated=true
pkg/gen/** linguist-generated=true

コンフリクトに関しては、コードを再生成することで解決することが出来るため、実際の運用に関してはそこまで気になりません。

まとめ

Protocol Buffers は、少人数チームでの開発において、型安全性と開発効率の両方を実現できる強力なツールです。
また拡張性が高く、周辺ライブラリやエコシステムも豊富なため使い込むことで様々な恩恵を得ることが出来ます。

例えば、protoc-go-templateなどを使えば Go テンプレートを使って好きなコードを生成することが出来ます。
ボイラープレートコードに疲れた方、スキーマ定義のためのツールに困っている方は是非採用を検討してみてください。

それでは。

PortalKey Tech Blog

Discussion