Clutch から学ぶ Go と grpc-gateway のプラガブルアーキテクチャ

2023-08-18

はじめに

本記事では Clutch という OSS の実装を読み解きながら、Go と grpc-gateway のプラガブル (pluggable) アーキテクチャについて考えます。

プラガブルというのはプラグインを追加して機能を拡張できるという意味で、プラガブルアーキテクチャとは、プラグインを追加して機能を拡張しやすいアーキテクチャを指します。
プラガブルであるためには、インターフェースが統一されていること・プラグインが独立して動作できること・プラグインを受け入れる側がプラグインの詳細を知ることなく簡単に追加できることが必要です。

Clutch は異なる目的の機能を同一のサービス(コードベース)でホストできる作りをしており、プラグイン (Clutch 上では service と表現する ) を追加して機能を拡張できるのでプラガブルであることの要件を満たしています。 また、API の構築には Go、 gRPC、 grpc-gateway が用いられ、フロントエンドは React で実装されているので Belong の技術スタックとかなり近いです。

私達と近い技術スタックを用いつつ、実際に解くべき課題を解決している Clutch の実装を読み解くことで自分たちのプロジェクトでも活かせる知見を得ることができると考え、本記事を執筆しました。

Clutch とは

Clutch は、Lyft が開発したオープンソースの Web UI および API プラットフォームで、運用タスクを簡素化しつつ、承認フローの構築や各種タスクや実行するためのツールセットを提供しています。 具体的には、AWS などクラウド環境でのユーザー ID の登録や、 IAM の権限付与を行うときの権限一覧の呼び出しから承認フローの設定、そのための認証・認可のための仕組みを Web UI と API の双方で提供します。

バックエンドの API は Protocol Buffers (Protobuf) で定義し、Go で実装されています。API は grpc-gateway によって gRPC だけでなく JSON ベースでのやり取りを可能にしています。 フロントエンドは React で実装され、Protobuf を用いて生成されたリソースの定義を利用しつつ、Go の API サーバーが static なものとして CSR を行う SPA の提供を行います。

本記事ではこの Clutch の、 API 側の仕組みに着目します。

Clutch のアーキテクチャ

Clutch のアーキテクチャは下記図のようにいくつかのコンポーネントから成り立っています。

  • Middleware
    • 認証・ロギング・監査など各リクエストで共通で行われる処理
  • Services
    • 外部の API 呼び出しやデータベースとのやり取りを行う
  • Modules
    • API を実装し gateway を登録する
  • Resolvers
    • Protobuf で定義したメッセージを 用いてリソースの取得を service を通して行う

https://clutch.sh/docs/about/architecture architecture

Middleware は一般的な HTTP リクエストの実装でも使いますが、 gRPC の場合は interceptor に相当します。リクエスト毎に行う共有の処理を定義します。
Services は外部 API の呼び出しや DB の呼び出しを含めた処理を行うコンポーネントです。一般的なレイヤードアーキテクチャで言うところのアプリケーションからインフラストラクチャの部分を指し、 これはプラグイン自体の実装に近いです。

本記事ではプラグインを追加して機能を拡張しやすくする仕組みについて着目するので、「プラグインを追加・利用する仕組み」であるところの module に関わる部分の実装を読み解きます。 Module 登録の仕組みが理解できると Service や Resolver も同様の仕組みで実現出来ます。
以下では Clutch の Feature Development の流れに従い Clutch 上で amiibo のチュートリアルを行う過程で作られるリソースを例にして説明します。

Clutch コンポーネントのエコシステム

この先 Clutch コンポーネント自体に触れる前に理解したいのが Clutch のコンポーネント登録の仕組みです。

この仕組みはコンポーネントの種類を問わず共通で、以下のステップが必要です。

  1. 設定ファイルへ登録
    1. 定義したコンポーネントの名前を登録する
  2. main.go におけるコンポーネントのファクトリの登録

設定ファイル

Clutch では clutch-config.yaml という設定ファイルを設けており、 この設定ファイルではアプリケーションのプロセスが利用する IP アドレスや logger の設定から、利用する middleware, service, module の登録を行います。

clutch-config.yaml の例

gateway:
  listener:
    tcp:
      address: 0.0.0.0
      port: 8080
      secure: false
  logger:
    level: INFO
  stats:
    flush_interval: 1s
  middleware:
    - name: clutch.middleware.stats
    - name: clutch.middleware.validate
services:
  - name: myapp.service.amiibo
modules:
  - name: clutch.module.healthcheck
  - name: clutch.module.resolver
  - name: clutch.module.assets
  - name: myapp.module.amiibo

コンポーネントのファクトリの登録

コンポーネントを利用するためには、main.go でコンポーネントのファクトリを登録する必要があります。 ファクトリ (New メソッド) を規定のコンポーネントのマップに登録することで、そのコンポーネントを利用できるようになります。

func main() {
    ...
    components := gateway.CoreComponentFactory
    components.Modules[amiibomod.Name] = amiibomod.New // Add Module's factory
    components.Services[amiiboservice.Name] = amiiboservice.New // Add Service's factory
    // Run app.
    gateway.Run(flags, components, assets.VirtualFS)
}

ここで、components.Modules は以下のように string と初期化関数の Map として定義されています。

module/module.go

type Factory map[string]func(*any.Any, *zap.Logger, tally.Scope) (Module, error)

gateway/core.go

var Modules = module.Factory{...}

var CoreComponentFactory = &ComponentFactory{
    Services:   Services,
    Resolvers:  Resolvers,
    Middleware: Middleware,
    Modules:    Modules,
}

これらのコンポーネントに関わるエコシステムの仕組みを前提として Module の実装を読み解いていきます。

Module

Module は grpc-gateway に gRPC のバックエンドを登録する仕組みです。
通常 grpc-gateway では gRPC のバックエンドサービスへのコネクションを確立した後、 gateway のコンポーネントに対してそのコネクションを登録します。
Module は gRPC Server として自身でエンドポイントの実装を行い、gateway コンポーネントへの登録を行うことで疎通を実現します。

まず grpc-gateway の理解のためにチュートリアルを見てみましょう。
以下のコードの通り、チュートリアルでは次のステップで gateway と gRPC バックエンドサービスの疎通を行います。

  1. RegisterGreeterServer による gRPC のサービス Greeter の立ち上げ
  2. 立ち上げたサービスへのコネクションを conn として定義
  3. connRegisterGreeterHandler への登録

https://grpc-ecosystem.github.io/grpc-gateway/docs/tutorials/adding_annotations/

    // Create a listener on TCP port
    lis, err := net.Listen("tcp", ":8080")
    ...
    // Create a gRPC server object
    s := grpc.NewServer()
    // Attach the Greeter service to the server
    helloworldpb.RegisterGreeterServer(s, &server{})
    go func() {
        log.Fatalln(s.Serve(lis)) // Host gRPC server
    }()

    conn, err := grpc.DialContext(...)
    ...

    gwmux := runtime.NewServeMux()
    // Host grpc-gateway server
    err = helloworldpb.RegisterGreeterHandler(context.Background(), gwmux, conn)
    ...
    gwServer := &http.Server{
    Addr:    ":8090",
    Handler: gwmux,
    }

    log.Fatalln(gwServer.ListenAndServe()) // Host gateway's http server

この方法の延長で、gRPC のバックエンドサービスが複数ある場合、main の初期化時に愚直に Register**Handler を各バックエンドに行うと gateway と gRPC バックエンドの疎通を実現できます。 しかし、この方法ではサービスが増えるたびに main の初期化処理を変更する必要があり、main が肥大化してしまいます。 つまり、gRPC バックエンドをプラグインとしてアプリケーションに登録したい場合にアプリケーション側への影響が大きくなってしまいます。

Clutch の Module はこの疎通登録の仕組みをより洗練された形で実現します。

Module の実装

Module は自身が gRPC サーバーの実装です。

下記コードの例にもある通り以下のポイントが特筆するべき点です。

  1. Module ごとに名前を設ける
  2. Module ごとに共通のインターフェースを持つファクトリ (New) を設ける
  3. gRPC バックエンドサービスの生成と gateway への登録を行う
  4. API を実装しつつ Service に実際の処理を委譲する
 // 1. Module's definition
const Name = "gateway.module.amiibo"

// 2. Module's factory
func New(*any.Any, *zap.Logger, tally.Scope) (module.Module, error) {
    svc, ok := service.Registry["gateway.service.amiibo"]
    if !ok {
        return nil, errors.New("no amiibo service was registered")
    }

    client, ok := svc.(amiiboservice.Client)
    if !ok {
        return nil, errors.New("amiibo service in registry was the wrong type")
    }

    return &mod{client: client}, nil
}

type mod struct {
    client amiiboservice.Client
}

// 3. Backend Service registration
func (m *mod) Register(r module.Registrar) error {
    amiibov1.RegisterAmiiboAPIServer(r.GRPCServer(), m) // Registration of gRPC backend service
    return r.RegisterJSONGateway(amiibov1.RegisterAmiiboAPIHandler) // Registration of gateway component
}

// 4. API implementation
func (m *mod) GetAmiibo(ctx context.Context, request *amiibov1.GetAmiiboRequest) (*amiibov1.GetAmiiboResponse, error) {
    a, err := m.client.GetAmiibo(ctx, request.Name)
    if err != nil {
        return nil, err
    }
    return &amiibov1.GetAmiiboResponse{Amiibo: a}, nil
}

1. Module ごとに名前を設ける

Module の名前は設定ファイルにおけるモジュールの登録や、ファクトリの呼び出しにおいて利用されます。 先に示した Clutch の設定ファイルや main.go での呼び出しにおいて引用されるものです。

2. Module ごとに共通のインターフェースを持つファクトリ (New) を設ける

Module はファクトリを通して生成されます。

アプリケーションの立ち上げ時に Clutch 内で、設定ファイルに登録された名前と一致するコンポーネントのファクトリを呼び出しその種類に応じた処理を行うため、 main.go では Module 名と、このファクトリを登録するだけで、Module を利用できるようになります。

3. gRPC バックエンドサービスの生成と gateway への登録を行う

Module では Register メソッドで gRPC バックエンドサービスの生成と gateway への登録を行います。

Module の初期化では、New を通して利用するサービスのインスタンスを生成した後、Register に於いて gRPC サーバーインスタンスの生成と grpc-gateway への登録を行います。
grpc-gateway のチュートリアルでは main で gRPC サーバーのインスタンスを生成し、そのインスタンスを gateway へ登録していましたが、 この形にすることで main では gRPC 周りは触らず、新しく作られるプラグイン (Module) 側で行うためアプリケーション本体の変更が少なくて済みます。

4. API を実装しつつ Service に実際の処理を委譲する

Module は gRPC サーバーとしてエンドポイントを自身で実装します。 これにより Register での gRPC サーバーインスタンスの生成と grpc-gateway への登録をシンプルにしています。

Module 初期化の流れ

Module を初期化する実装を見てみましょう。 以下のコードは main.go からのアプリケーション立ち上げ後に Clutch の gateway パッケージ内において Module の初期化を行う実装です。

このコードでは以下を行っています。

  1. 設定ファイルに登録された Module を一つづつ取り出す
  2. main.go で登録された Module のファクトリを取得し、インスタンスを生成する
  3. Module の Register メソッドを呼び出し、gRPC バックエンドの立ち上げと gateway への登録を行う

gateway/gateway.go#L243

...
for _, modCfg := range cfg.Modules {
    logger := logger.With(zap.String("moduleName", modCfg.Name))

    factory, ok := cf.Modules[modCfg.Name]
    if !ok {
        logger.Fatal("module not found in registry")
    }
    if factory == nil {
        logger.Fatal("module has nil factory")
    }

    if err := validateAny(modCfg.TypedConfig); err != nil {
        logger.Fatal("module config validation failed", zap.Error(err))
    }

    logger.Info("registering module")
    mod, err := factory(modCfg.TypedConfig, logger, scope.SubScope(
        strings.TrimPrefix(modCfg.Name, clutchComponentPrefix)))
    if err != nil {
        logger.Fatal("module instantiation failed", zap.Error(err))
    }

    if err := mod.Register(reg); err != nil {
        logger.Fatal("registration to gateway failed", zap.Error(err))
    }
}
...

この仕組みによりコンポーネント登録の共通処理をアプリケーションのサービス内部へ隠蔽し、Module は自身に関わる処理のみを実装することで、 Module 追加によるアプリケーション本体側の変更を少なく、また Module に関わる変更は外部に影響を与えること無く実装できます。

おわりに

本記事では Clutch の実装を参考にしつつ Go におけるプラガブルアーキテクチャを実現するためのアプローチを学びました。 公式リポジトリには Module の他にも Resolver や Service の実装もあるので、深掘りするとさらなる学びがある思います。
また、Protobuf を用いてサーバーと Web クライアントのインターフェースの共通化1という論点でリポジトリを読み解いても面白いと思います。

Clutch の行う初期化時に登録された Factory からインスタンスを生成し必要箇所へ登録する行為は Dependency Injection (DI) のためであり、 Go で DI を行う方法としては google/wire があります。 wire は Factory を事前に登録し、コンパイル時に関連するリソースを生成することで DI を可能にしており、純粋に DI が欲しいのであれば wire を利用するのが簡単かもしれませんが、 Clutch の場合は gRPC や grpc-gateway の利用を前提としたスマートな DI のアプローチを考える上で参考になると思います。

Go のアーキテクチャを考えるのが得意な方や好きな方は是非 Belong に応募してください。カジュアル面談もお待ちしています。

https://entrancebook.belonginc.dev/

Footnotes

  1. 最近は Connect が主流だと思いますが、Clutch は特に独自の HTTP リクエストの実装時の参考になると思います。