Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Vol.05 Connect によるスキーマ駆動開発のススメ - connect-go バックエンド編

はじめに

こんにちは! 技術本部 Bill One Engineering Unit の市川です。

私の所属するチームでは Bill One の新規マイクロサービス開発に Go 言語と Connect を採用してスキーマ駆動開発を実践しています。 今回は Connect を導入して実感した良いところを、バックエンドの話を中心に実例を交えながらご紹介していきます。

なお、本記事は【Bill One 開発 Unit ブログリレー】という連載記事のひとつです。

目次

Connect とは

Connect は gRPC 互換の HTTP API を実装するためのフレームワークです。 一般的な gRPC Server と同じように Protocol Buffers を用いて API を定義し、 Server と Client のコードを生成することができます。

connect.build

API を定義するための仕組みとしては、他にも OpenAPIGraphQLtRPC などが存在します。 本記事ではそれらと Connect とを厳密に比較することはせず、あくまでも Connect の良さという切り口で話を進めていきます。

REST、gRPC、GraphQL の使い分けについては以下の記事が参考になります。

jp.konghq.com

Connect の良さ・メリット

Protocol Buffers ベースのスキーマ駆動開発

Protocol Buffers は構造化データをシリアライズするためのインタフェース記述言語 (IDL) です。 先にも述べたように、Connect は gRPC 同様 Protocol Buffers を用いて API を定義します。 そのため、開発の流れも gRPC を用いた開発と同様の流れとなります。

Connect を用いた開発の大まかな流れ

  1. API スキーマを Protocol Buffers を用いて定義する
  2. API スキーマから Server と Client のコードを生成する
  3. Server と Client を実装する

API を最初に定義してしまうことで、後続のバックエンド・フロントエンド実装をスムーズに行えるのがスキーマ駆動開発の良いところですね。 当チームでは、API 定義をモブプロにて実施することで、実装タスクに取り掛かる前にチームメンバー全員の認識を合わせるようにしています。

Proxy 不要でブラウザから直接 API が呼べる

gRPC (gRPC over HTTP2) は HTTP/2 ベースの Protocol です。 ブラウザから HTTP/2 のリクエストができることは周知のとおりだと思いますが、 fetch や XHR は HTTP のプロトコルやフレームを直接制御することができないため gRPC API を直接呼び出せません1。 そのため、gRPC-GatewaygRPC-Web を用いて API 呼び出しを Proxy する必要があります。

一方、 Connect は HTTP/1.1 および HTTP/2 上で動作する Protocol であり、ブラウザから直接 API を呼び出すことができます。 Proxy の設定や運用のことを考えなくても gRPC 互換の API をブラウザから呼び出せてしまうことが Connect 最大の特徴です。

Bill One は メインのアプリケーション実行基盤としてフルマネージドの Cloud Run を採用しています。 Cloud Run には Kubernetes の Sidecar Container のような仕組みがまだ存在しないため2、 Proxy を用意するには対象のマイクロサービスとは別に Cloud Run をデプロイするなど、 色々と考えることが増えるので gRPC-Web は選択しませんでした3

connect-go が net/http ベースであること

gRPC が提供する grpc package は HTTP/2 を独自に実装しているため、 Go 標準の net/http package との互換性がありません。

一方、Connect (connect-go) は net/http を用いて実装されているため、各種ライブラリや HTTP Middleware との親和性が高いです。

REST API との共存が可能

Connect が生成する Handler のコードは Multiplexer を用いたシンプルな実装になっています。

func NewInvoiceServiceHandler(svc InvoiceServiceHandler, opts ...connect_go.HandlerOption) (string, http.Handler) {
    mux := http.NewServeMux()
    mux.Handle(InvoiceServiceGetProcedure, connect_go.NewUnaryHandler(
        InvoiceServiceGetProcedure,
        svc.Get,
        opts...,
    ))
    return "/invoice.v1.InvoiceService/", mux
}

そのため、net/http や Web Framework、Routing ライブラリを用いて、同一 Port で REST API と gRPC 互換の Connect API を提供することができます。

func main() {
    http.Handle(invoicev1connect.NewInvoiceServiceHandler(invoiceController))
    http.HandleFunc("/api/invoices", invoiceHandler)
    srv := &http.Server{
        Addr:    ":8080",
        Handler: h2c.NewHandler(http.DefaultServeMux, &http2.Server{}),
    }
    srv.ListenAndServe()
}

REST API との共存が可能であるということは、既存の REST API 群を少しずつ Connect API に置き換えるようなことも可能ですね。

net/http の Middleware が使える

Connect は 独自の Middleware 機能として Interceptor を提供しています。 それとは別に net/http の Middleware を用いることもできます。

Routing ライブラリとして go-chi を用いる場合は、以下のようなコードとなります。 Router に設定された Middleware Chain は Connect の Handler にも適用されるため、Middleware と Interceptor を組み合わせて利用することができます。 既存の Middleware 資産を活用できるのが良いところですね。

func newRouter() http.Handler {
    r := chi.NewRouter()

    r.Use(
        middleware.Logger,
        middleware.Recoverer,
    )

    r.Route(invoicev1connect.NewInvoiceServiceHandler(invoiceController, connect.WithInterceptors(interceptors...)))
    r.Route("/api/invoices", invoiceRouter)

    return r
}

当チームでは、Logger 等の Protocol 共通の処理は net/http の Middleware、Connect 固有の実装を行う場合は Interceptor というように使い分けています。

API のテストに net/http/httptest が使える

net/http ベースということは、API のテストを行う際に net/http/httptest を用いることができます。 connect-demo/main_test.go では生成された Client コードと httptest を用いてテストを行っています。

当チームでは Connect をなるべく意識しないようなテストコードとすることで、学習コストの低減や万一の利用 Protocol 変更に備えています。

func TestInvoiceHandler(t *testing.T) {
    t.Parallel()

    basePath, handler := invoicev1connect.NewInvoiceServiceHandler(invoiceController, connect.WithInterceptors(interceptors...))

    body, err := protojson.Marshal(&invoicev1.InvoiceServiceGetRequest{InvoiceUUID: "a0e9a9e1-9bc2-4cb2-b81f-0172f5dc73f8"})
    assert.NoError(t, err)

    req := httptest.NewRequest(http.MethodPost, basePath+"/Get", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")

    rec := httptest.NewRecorder()
    handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)
    // more assertions...
}

適切な HTTP Status Code を返してくれる

gRPC-Web を用いた場合、HTTP Status Code としては原則として 200 OK が返却されます。 これは gRPC が HTTP Protocol を完全に隠蔽した設計になっており、独自の Status Code 返却の仕組みがあるためです。

zenn.dev

gRPC のサービスとしては適切な挙動だと思いますが、gRPC-Web 経由で REST API として利用する場合は Error Rate の計測が難しいことが課題として挙げられます。

Connect は gRPC 互換であるため Error Codes も gRPC の仕様に合わせたものとなっています。 それだけでなく、Connect Protocol を用いる場合に HTTP Status Code に Mapping の上返却してくれるため、API 呼び出し元は REST API と同じようにエラーハンドリングすることができます。

その他、エラー詳細を返却する仕組みとして grpc-go 同様に Error Details の仕組みが備わっているため、エラー詳細型を Protocol Buffers で定義して活用しています。

Connect の気になるポイント・デメリット

現状、Connect 導入には大きな問題もなくスムーズに開発できているのですが、あえて気になる点やデメリットについても言及しておきます。

Protocol Buffers の学習コストや運用課題

チームメンバーが Protocol Buffers を用いた API 定義の実務経験がない状態で Connect を導入したため、 Protocol Buffers の記述方法や Package 構成について学ぶ必要がありました。 ただ、Buf CLI が Lint 機能として Best Practice を示してくれるため、それに従っていけばおおよそ問題はありません。

運用課題と言っているのは、Protocol Buffers の .proto ファイルや生成コードをどのリポジトリで管理すべきか、更新していくべきかといったところです。 現時点では monorepo 内のマイクロサービスに閉じた場所に配置し手動でコード生成を行っていますが、 本来はスキーマ専用のリポジトリを設けて各言語コードの生成・反映を完全に自動化したいものですね。

既に gPRC のサービスを開発しているチームであれば、これらの話についてはすでに解決済みかと思うので簡単に Connect を導入できるはずです。

その他 Tips

protoc-gen-validate による Request Validation

protoc-gen-validate plugin を用いることで、 Protocol Buffers 生成型に対する Validation コードを出力することができます。

Validation Rule は .proto ファイルに記載する形です。

message InvoiceServiceGetRequest {
    string invoice_uuid = 1 [(validate.rules).string.uuid = true];
}

protoc-gen-validate は記述された Rule に基づいて、 func Validate() error を実装したコードを生成します。

func (m *InvoiceServiceGetRequest) Validate() error {
    return m.validate(false)
}

func (m *InvoiceServiceGetRequest) validate(all bool) error {
    // validation codes...
}

あとは Handler, Controller にて Validate() を呼び出すだけで Request Validation が行えます。 Interceptor の仕組みを使うと、全ての Request に対して暗黙的に Validation を行うことも可能です。

type validator interface {
    Validate() error
}

func NewValidationInterceptor() connect.UnaryInterceptorFunc {
    return func(next connect.UnaryFunc) connect.UnaryFunc {
        return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
            if req.Spec().IsClient {
                // do nothing
                return next(ctx, req)
            }

            msg := req.Any()
            validator, ok := msg.(validator)
            if !ok {
                return next(ctx, req)
            }

            if err := validator.Validate(); err != nil {
                return nil, err
            }

            return next(ctx, req)
        }
    }
}

connect-grpchealth-go を用いた Health Probe

Connect ベースの gRPC Health Checking Protocol 実装である connect-grpchealth-go が提供されています。 これを用いることで、容易に gRPC Health Probe の実装ができます。

type livenessHandler struct {
    db *sqlx.DB
}

var _ grpchealth.Checker = (*livenessHandler)(nil)

func NewLivenessHandler(db *sqlx.DB) (string, http.Handler) {
    return grpchealth.NewHandler(&livenessHandler{db})
}

func (c *livenessHandler) Check(ctx context.Context, _ *grpchealth.CheckRequest) (*grpchealth.CheckResponse, error) {
    if err := c.db.PingContext(ctx); err != nil {
        return &grpchealth.CheckResponse{Status: grpchealth.StatusNotServing}, nil //nolint:nilerr // health probe としては正常 return
    }
    return &grpchealth.CheckResponse{Status: grpchealth.StatusServing}, nil
}

Kubernetes 同様、Cloud Run にも gRPC Liveness Probe の仕組みが存在するため4、 connect-grpchealth-go を用いた Health Probe を設定しています。

connect-opentelemetry-go を用いた Request Tracing

Connect の OpenTelemetry Instrumentation connect-opentelemetry-go が提供されています。 Interceptor として実装されているので、以下のように簡単に Request Tracing と Metrics 収集が可能です。

func main() {
    http.Handle(invoicev1connect.NewInvoiceServiceHandler(invoiceController),
        connect.WithInterceptors(otelconnect.NewInterceptor()))
    srv := &http.Server{
        Addr:    ":8080",
        Handler: h2c.NewHandler(http.DefaultServeMux, &http2.Server{}),
    }
    srv.ListenAndServe()
}

おわりに

Connect は REST API と gRPC API のいいとこ取りをした、とても有用な Protocol であることを紹介してきました。 Go と Browser サポートの他に Node.js, Kotlin, Swift などの言語サポートが追加されるなど開発のペースも早く、 今後も Connect Family の拡大には期待が持てます!

当チームのフロントエンド側での connect-web 活用事例も連載記事として近日公開予定です。ご期待ください!

Bill One では一緒に働く仲間を募集しています! 詳しくはこちらのリンクから採用情報をご確認ください。

open.talentio.com


  1. The state of gRPC in the browser
  2. 最近、Multicontainer support が Pre-GA となっている。
  3. 1 つの Cloud Run Container でメインサービスと Proxy をそれぞれ動かし、Container 内部 Port で通信するということも可能ではある。
  4. https://cloud.google.com/run/docs/configuring/healthchecks?hl=ja#grpc-liveness-probes

© Sansan, Inc.