最近Web APIとしてprotobufというかGRPCを使ってみています。
「複数言語間のインターフェースが欲しい」というWebAPI要求くらいなら、落とし所としてはRESTより良いかなあ、という手応えがありましたので、本記事でつらつら良いところを記載していきます。
REST APIへの若干の不満
ネイティブアプリやSPAの 登場以降、長年REST APIを使ってきた開発者として、その利便性は十分に理解しています。しかし、最近のプロジェクトで感じた「もやもや」した部分をここで書いてみます
エコシステムが充実...?うーん
REST APIの良さは、なんと言ってもその手軽さにあります。HTTPメソッドとURIの組み合わせで直感的にリソースを操作できますし、中身はJSONですので誰にもわかりやすい。
特に、StoplightやSwaggerのようなツールがあると、すごく簡単にAPI仕様を記述できます。ドキュメントを生成するのも、モックサーバーを立てるのも、簡単。
ただ、ある程度枯れてるのかと思いきや、GoやTypeScriptでのコード生成の段になると、世間で使われている割にはあんまり各エコシステムによる生成物がイケてないなあと経験で思っているところはあります。
バリデーション仕様とかもちゃんと批准してる生成物...そんなに多くない。
(お仕事では、Goだとoapi-codegen、TypeScriptだとopenapi-typescriptを使っています。ここにたどり着くまでにもチームで色々使った気がします)
各ライブラリの不満の段はまあけっこうそれぞれ細かすぎる話なので置いておくんですが、
実際にRESTのスキーマ生成に関しては未だにけっこう皆ツール選定で悩んだ記事書いたりモジュールハックしたりライブラリに不満述べたり...とかがちょくちょくWebで見つかると思いますので、なんとなく雰囲気は察していただけるかと思います。
どうしても膨れ上がる仕様とメンテのメンテ
OepnAPIの記述はYAML等で書きますが、ちょっとした構造を書くにもどうしても膨れ上がりがちです。
会社の身の回りのチームでもREST採用は多いのですが、OpenAPIを半年~一年運用したチームはどこも数千~1万行到達していました。
で、エディタ/IDEでうまく動かなくなるので、ある種の境界によるコード分割とリファレンス化とかを皆やり始めるわけです。
ですが、ドキュメント生成ツールの都合のために一度分割したYAMLをCIでまた再度マージしてHTML生成...!みたいな、あんまり本質的でないことが往々にして連鎖的に始まるので、「API仕様を書くための仕様を作る」という、何とも言えない悩みがあります。
結局のところ
これらの不満は、決してREST APIが「悪い」というわけではありません。むしろ、REST APIのシンプルかつ柔軟性や拡張性が高いからこそ生じる問題とも言えるでしょう。
JSON構造決めるだけなのだが...奥が深すぎてこれ逆にToo muchなのでは...?という気分。
GRPC Connect
フューチャーの渋川さんの紹介記事 「gRPCのGo実装の新星、Connect」 あたりで初めて知ったのですが、GRPC Connectというものがあります。
gRPC(gRPC Remote Procedure Call)は、Googleによって開発されたオープンソースの高性能RPCフレームワークです。
GRPCの主な特徴を引用します。
- Protocol Buffersの使用:データのシリアライズにProtocol Buffersを使用し、効率的なデータ転送を実現します。
- HTTP/2の利用:低レイテンシー、多重化されたストリーミング、ヘッダー圧縮などの利点があります。
- 強力な型システム:サービスとメッセージの定義に.protoファイルを使用し、型安全性を確保します。
- 多言語サポート:様々なプログラミング言語で使用可能です。
- 双方向ストリーミング:クライアントとサーバー間で複雑な通信パターンを実現できます。
GRPC ConnectとgRPC-Web
HTTP/2などと言っているように、本来はWebブラウザで動かすようなものでもなく、マイクロサービスなど、サーバー間通信のためのプロトコルです。一方で、GRPC Connectは、従来のgRPCの概念を拡張し、より使いやすくしたプロトコルです。中でもgRPC-Webは、ブラウザ環境でgRPCを使用するためのソリューションとして開発されました。
GRPC Connectの基本とスキーマ
- シンプルなプロトコル:HTTP/1.1とHTTP/2の両方をサポートし、単純なJSON/Protobufエンコーディングを使用できます。
- ブラウザ互換性:追加のプロキシなしで、直接ブラウザからサーバーへの通信が可能です。
- ストリーミングサポート:サーバーサイドストリーミングをサポートし、リアルタイムデータ更新などの機能を実現します。
...まあ基本的にどこでも動くようになったよ、それなりに速いよ、ということだけ確認して、話題の中心であるスキーマやコード生成を中心に見ていきましょう。
syntax = "proto3"; // 使用するProtobufのバージョンを指定します。
package api.v1;
// Go用の生成設定
option go_package = "github.com/xxx/my-playground/project/convert_kicks_video/go/app/grpc_server/api/v1;api_v1";
// example
message Example {
string name = 10; // アーティスト名
string debug_message = 20; // デバッグメッセージ
}
syntax = "proto3"; // 使用するProtobufのバージョンを指定します。
package api.v1;
import "api/v1/model_example.proto";
// Go用の生成設定
option go_package = "github.com/xxx/my-playground/project/convert_video/go/app/grpc_server/api/v1;api_v1";
// example
service ExampleService {
// なんかとる
rpc Example(ExampleRequest) returns (ExampleResponse) {}
}
// なんかとる
message ExampleRequest {
string name = 10;
}
// なんかかえす
message ExampleResponse {
Example example = 10;
}
このようなProtocol Buffersの定義は、RESTful APIの設計と比較しても、Webエンジニアなら直感的で理解しやすい、と思いますがどうでしょうか。初見でなんとなく読めちゃうのでは?string email = 3;
などの数字はシリアライズ用のタグナンバーというやつなので、ユニークであれば他に特に意味はありません。
そして、はじめから.protoファイルは簡単にファイルを分けて宣言することが自然です。RESTで問題になった膨れ上がりようも、そもそも普通のプログラムの構造体宣言と増え方は変わりません。
Buf (Remote) プラグインによる簡潔なコード生成
これだけなら特にRESTと比べてメリットを感じることは薄いかもしれませんが、一気にゲームが変わった感があるのが
Buf は、Protocol Buffers を扱う際のコード生成や管理を強化するためのツールです。gRPCやProtobufを使用する際に発生しがちな複雑さを簡素化し、より効率的なワークフローを提供します。Buf の特徴の一つに、プロトコル定義の検証やフォーマットの自動チェック機能があり、CI/CD パイプラインに統合して静的解析ツールとして活用することもできます。
特に良かったのは「リモートプラグイン」の機能です。もともといろいろなプラグインを別途インストールしなければならなかったのですが、 bufコマンド一個入っていれば、
- APIドキュメント
- スキーマのLint
- NodeJSのコードの自動生成
- Goのコードの自動生成
これらが一切他の依存無しで実現できます。
例を見ていきます。
version: v1
# protoファイルのパスを指定
breaking:
use:
- FILE
# リンターの設定。
lint:
use:
- DEFAULT
- COMMENT_ENUM
- COMMENT_MESSAGE
- COMMENT_RPC
- COMMENT_SERVICE
version: v2
plugins:
# 各protoの型ファイルを生成する (Go)
- remote: buf.build/protocolbuffers/go:v1.34.1
out: ./go/app/grpc_server/
opt: paths=source_relative
# client/server実装(connect仕様)を生成する (Go)
- remote: buf.build/connectrpc/go:v1.16.2
out: ./go/app/grpc_server/
opt: paths=source_relative
# 各protoの型ファイルを生成する (Typescript)
- remote: buf.build/bufbuild/es:v1.9.0
out: ./app/proto/
opt:
- target=ts
# client sdk (connect仕様)を 生成する(Typescript)
- remote: buf.build/connectrpc/es:v1.4.0
out: ./app/proto/
opt:
- target=ts
# API ドキュメント生成
- remote: buf.build/community/pseudomuto-doc:v1.5.1
out: catalogapi
opt:
- markdown,README.md
これらのファイルと実際の.protoファイルさえあれば、
gen_grpc:
echo "Generating gRPC code..."
# lint
buf lint
# 自動フォーマット
buf format -w
# コード生成
buf generate
あとは buf generate
を実行するだけです。
上記例であれば ./go/app/grpc_server/
にGoのコードが、./app/proto/
以下にTypeScriptのコードが生成されているかと思います。
既存のGoサーバー(echo)への組込み
今回はGoでサーバーサイド実装するものとし、Example Service のinterfaceが生成されてるので、実装を書いておきます。
package grpc_server
import (
"connectrpc.com/connect"
"context"
v1 "github.com/xxx/my-playground/project/convert_video/go/app/grpc_server/api/v1"
"github.com/xxx/my-playground/project/convert_video/go/app/grpc_server/api/v1/api_v1connect"
)
func NewExampleHandler() api_v1connect.ExampleServiceHandler {
return &exampleServiceHandler{}
}
func (e exampleServiceHandler) Example(ctx context.Context, c *connect.Request[v1.ExampleRequest]) (*connect.Response[v1.ExampleResponse], error) {
resp := v1.ExampleResponse{Example: &v1.Example{Name: `いいい`, DebugMessage: `あああ`}}
return connect.NewResponse(&resp), nil
}
type exampleServiceHandler struct {
}
また今回は、Webフレームワークとしてhttps://echo.labstack.com/ を使って、それに組み込んでみましょう。
import (
"github.com/labstack/echo/v4"
"net/http"
)
...
func ConnectHandler(path string, handler http.Handler) (string, echo.HandlerFunc) {
path = path + "*"
return path, echo.WrapHandler(handler)
}
上記のようなconnect handlerとの橋渡しメソッドを宣言しておいて、
先程のExampeHandlerとつなぎます。
exampeHandler := NewExampleHandler()
exampleHandlerPath, httpExampleServiceHandler := api_v1connect.NewExampleServiceHandler(exampeHandler)
// echoのルーティングに GRPC-Connect 追加
e.Any(ConnectHandler(exampleHandlerPath, httpExampleServiceHandler))
TypeScript側
サーバーが設定できたので、これを叩くクライアントを書いてみます。
"@connectrpc/connect-node": "^1.4.0",
"@connectrpc/connect-web": "^1.4.0",
生成されたTypeScriptスキーマを使うには、基本的には上記のものが入っていれば、サーバーサイドNodeからでもブラウザからでもconnectrpcが使えるかと思います。
クライアントコードは以下です。
import { createPromiseClient, Interceptor } from "@connectrpc/connect";
import { createGrpcTransport } from "@connectrpc/connect-node";
import { ExampleService } from "~/proto/api/v1/service_example_connect";
import {createGrpcWebTransport} from "@connectrpc/connect-web";
const GRPC_BASE_URL = `https://xxxx.com`;
const BACKEND_API_KEY = `dummy-api-key`;
// GRPCサーバーにリクエストを送る前に、APIキーを付与するInterceptor
const addApiKey: Interceptor = (next) => async (req) => {
req.header.set("x-api-key", BACKEND_API_KEY);
return await next(req);
};
// webブラウザから叩く場合
const transportWeb = createGrpcWebTransport({
baseUrl: GRPC_BASE_URL,
interceptors: [addApiKey],
});
/*
// BFFサーバーから叩く場合
const transport = createGrpcTransport({
baseUrl: GRPC_BASE_URL,
interceptors: [addApiKey],
useBinaryFormat: true, // protobufを使用するように設定
httpVersion: "1.1", // 2が使える環境なら2も可
});
*/
console.log("GRPC_BASE_URL:", GRPC_BASE_URL);
// APIリクエストする側はこれをimportして使う
export const exampleServiceClient = createPromiseClient(
ExampleService,
transportWeb,
);
export type ExampleServiceClientIface = typeof exampleServiceClient;
これでtype safe なクライアントができました! 簡潔!あとはモジュール使うだけって感じですね。
ここまで書いたことと似たようなコードサンプルがクラスメソッドさんの以下の記事とGithubにもありましたので、
https://dev.classmethod.jp/articles/devio2024-golang-backend/
こちらでイメージをつけてもらうのも良いんじゃないかと思います。
まとめ
connectやbufのおかげで、スキーマ駆動APIの選択が増えました。
フロントもバックもある程度のWebエンジニアが触るくらいなら必要十分な機能という感想です。
「WebAPIや言語間でスキーマが欲しい」くらいの要求であれば、一度検討していただくのはアリかなと思います。