7月はLayerX エンジニアブログを活発にする期間 です!
今日2つ目の記事はバクラク事業部 Enabling Team エンジニアの @yyoshiki41(中川佳希)が担当します。 バクラクでは開発言語に Go を採用しており、gRPC サーバーとして buf/connect-go を利用しています。このブログでは、既存で動いているアプリケーションにどのようなアプローチで gRPC を導入していったかを紹介します。
導入まで
buf/connect-go 自体は2022年7月頃に新規レポジトリで小さく試すところから行われました。 既存プロダクトで grpc-go のコード資産(開発ツールやミドルウェア、インフラ設定)を持たなかったため、他との比較に時間をかけずにクイックに導入されました。ただ、生成されるクライアント/サーバーコードの簡潔さ、既存の http ミドルウェア実装が再利用出来る点やクライアント/サーバー Intercepters 機構は開発者体験において魅力的でした。 そして、プロキシ不要でシームレスに3つのプロトコル(gRPC, gRPC-web と Connect)がサポートされる点もありました。 サービス間通信時には HTTP/2 上で gRPC を前提に、ローカル環境デバッグにおいてはヒューマンリーダブルな JSON-encoded Protobuf がシームレスに利用できるという具合です。
モノリスのリファクタリング
ここで buf/connect-go から話がそれますが、開発チームの課題の一つに複数サービス間での依存関係、特に他のサービスからモノリスアプリケーションが持つデータの取得・更新を行うという点がありました。
モノリスが決して悪いアイデアではなく、特にサービス初期には将来像を全て描いておいて作り始めることはなく、開発のスタートが求めれることが常だと思います。そして、不確実性が高い状態やドメイン知識が不足した状態でスタートし、後になってから適切な境界づけられたコンテキストが明確になることもあると思います。
バクラクも複数の複雑なビジネスドメインをカバーするようになり、モノリスアプリケーションと新規サービスの連携(特に同期的な通信)をどのように行うかという話がありました。また、モノリスが複数のドメインのデータを包含している場合には、循環依存(circular dependency)を起こさずに相互作用に出来るかという問題も顕在化してきていました。そこでモノリスを適切なドメインカットでリファクタリング(Decoupling)するアプローチを検討していきました。
Decoupling a service
リファクタリング手順
いきなりモノリスの解体をしていくことも可能ですが、開発は継続的に行われています。機能開発を止めることなく、安全な段階的移行を検討することにしました。またどの粒度で境界づけられたコンテキストを設けるかも頭を悩ませるポイントです。(そして、現実の業務を完全にシステム上に落とすことは出来ません。)
段階的な移行のアプローチとして、ストラングラーパターンがあります。
Demo
最初の目標地点は、コアとなるビジネスロジックに手を入れずに gRPC サービスを立ち上げるところです。 Connect の利点は特別なミドルウェアなど不要で、既存の HTTP API に手を加えることなく残したままで、gRPC サービスを新たに立ち上げ、リクエストを gRPC 側に徐々に移行可能な点です。
以下の例では、サンプルページ で利用されているサービスを使って説明していきます。 1.Protocol Buffers でビジネスドメインを定義していきます。
syntax = "proto3"; package greet.v1; option go_package = "example/gen/greet/v1;greetv1"; message GreetRequest { string name = 1; } message GreetResponse { string greeting = 1; } service GreetService { rpc Greet(GreetRequest) returns (GreetResponse) {} }
2.サーバー/クライアントコードの生成を行う
$ buf generate
3.新サービスとなる gRPC サーバーを立ち上げる(ポート番号8081) - リクエスト処理の中身はモノリスアプリケーションに移譲するため、 buf/connect-go が生成するクライアントコードをそのまま呼び出しています
package main import ( "context" "net/http" "github.com/bufbuild/connect-go" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" greetv1 "example/gen/greet/v1" // generated by protoc-gen-go "example/gen/greet/v1/greetv1connect" // generated by protoc-gen-connect-go ) func main() { greeter := &GreetServer{} path, handler := greetv1connect.NewGreetServiceHandler(greeter) mux := http.NewServeMux() mux.Handle(path, handler) http.ListenAndServe( "localhost:8081", // Use h2c so we can serve HTTP/2 without TLS. h2c.NewHandler(mux, &http2.Server{}), ) } type GreetServer struct{} func (s *GreetServer) Greet( ctx context.Context, req *connect.Request[greetv1.GreetRequest], ) (*connect.Response[greetv1.GreetResponse], error) { res, err := greetv1connect.NewGreetServiceClient( http.DefaultClient, "http://localhost:8080", ).Greet(ctx, req) if err != nil { return nil, err } return connect.NewResponse(res.Msg), nil }
4.モノリスアプリケーションで gRPC でのリクエストを受け取れるよう生成されたハンドラー(サーバー)の実装を使う
package main import ( "context" "fmt" "log" "net/http" "github.com/bufbuild/connect-go" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" greetv1 "example/gen/greet/v1" // generated by protoc-gen-go "example/gen/greet/v1/greetv1connect" // generated by protoc-gen-connect-go ) type GreetServer struct{} func (s *GreetServer) Greet( ctx context.Context, req *connect.Request[greetv1.GreetRequest], ) (*connect.Response[greetv1.GreetResponse], error) { log.Println("Request headers: ", req.Header()) res := connect.NewResponse(&greetv1.GreetResponse{ Greeting: fmt.Sprintf("Hello, %s!", req.Msg.Name), }) res.Header().Set("Greet-Version", "v1") return res, nil } func NewGreet() http.Handler { greeter := &GreetServer{} path, handler := greetv1connect.NewGreetServiceHandler(greeter) mux := http.NewServeMux() mux.Handle(path, handler) return h2c.NewHandler(mux, &http2.Server{}) }
5.モノリスアプリケーション内で、リクエストの分岐を実装する(ポート番号8080)
HTTPヘッダーでのルーティングを実装します。
- 識別ヘッダーには、
Connect-Protocol-Version
を用いています。 - これは Connect のプロトコルバージョンを示し(現在のバージョンは 1)、buf/connect-go が生成したクライアントからの Unary RPC コール時には自動で付与されます。(現段階では
Content-Type
ヘッダーが同一な他のリクエストと識別をするために利用されますが、将来的にはプロトコルのリビジョンを表現する機構になる可能性もあります。) ref. https://connect.build/docs/protocol/
- 識別ヘッダーには、
gRPC を実装するハンドラーには、h2c (HTTP/2 without TLS) を利用しますが、それ以外のハンドラーは HTTP/1.1 を前提にしています。
package main import ( "fmt" "net/http" ) func main() { middleware := connectRouter(NewGreet()) mux := http.NewServeMux() mux.Handle("/", middleware(hello())) http.ListenAndServe("localhost:8080", mux) } func hello() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, "hello world") } } func connectRouter(connect http.Handler) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Connect-Protocol-Version") != "" { connect.ServeHTTP(w, r) } else { next.ServeHTTP(w, r) } }) } }
6.それぞれのサーバーを立ち上げて、grpcurl, cURL でリクエストを実行してみます。
- 下記のリクエストでは、入り口は新しい gRPC サービスですが実際の処理はモノリスに移譲されています。
$ grpcurl \ -protoset <(buf build -o -) -plaintext \ -d '{"name": "Jane"}' \ localhost:8081 greet.v1.GreetService/Greet {"greeting": "Hello, Jane!"}
- (もちろん、新しい gRPC サービスのエンドポイントに対して cURL でリクエストを送ることも可能です。)
$ curl \ --header "Content-Type: application/json" \ --data '{"name": "Jane"}' \ http://localhost:8081/greet.v1.GreetService/Greet {"greeting":"Hello, Jane!"}
- モノリス側では従来からあるエンドポイントに対してのリクエストが副作用なく処理可能です。
$ curl \ --header "Content-Type: application/json" \ http://localhost:8080/ {"greeting":"hello world"}
- (直接外部から呼び出すことを想定していませんが、cURL からでも
Connect-Protocol-Version
ヘッダーをつければ、 buf/connect-go で実装されたハンドラーを呼び出す事が可能です。)
$ curl \ --header "Connect-Protocol-Version: 1" \ --header "Content-Type: application/json" \ --data '{"name": "Jane"}' \ http://localhost:8080/greet.v1.GreetService/Greet {"greeting":"Hello, Jane!"}
まとめ
ストラングラーパターンを buf/connect-go を利用することで実現できました。http.Handler を提供している点は、開発者に大きなメリットをもたらします。まだクライアントコードを gRPC に移すこと、ビジネスロジックを完全にモノリスから取り除くリファクタリングは必要ですが、モノリスから Protocol Buffers 上で表現されたドメインをもとに gRPC サービスを立ち上げることが出来ました。
アプリケーション上は多段構成ではありますが、特別なミドルウェア不要で gRPC-Compatible な HTTP API が利用できる点と生成されるコードが汎用的な点は、他に替えが効かない点だと思います。