Protocol Buffersの一元管理

ogp

この記事はenechain Advent Calendar 2024の20日目の記事です。

はじめに

こんにちは、エンジニアの青戸です。

enechainでは複数のプロダクトが利用する機能をマイクロサービスとして共通化する取り組みを進めており、APIの定義にはProtocol Buffersを利用しています。本記事では、proto定義の管理や公開を効率化するための取り組みについて紹介します。

背景

enechainではエネルギー取引にまつわる様々な課題を多方面から解決するために次々と新たなプロダクトが立ち上がっています。プロダクト数が増えるにつれて、認証認可や外部サービスとの連携等、複数のサービスで同じような機能が必要となるケースが増えてきました。 アプリケーション基盤チームを中心として、利用頻度の高い共通機能を順次マイクロサービスとして切り出す取り組みを行っており、マイクロサービスを量産する基盤開発も進んでいます。 新規に立ち上げるサービスのAPIプロトコルとしてはgRPCが社内標準となっており、Protocol Buffersを利用してAPIを定義しています。

共通サービスはproto定義をそれぞれのリポジトリが個別に持つ形で立ち上がっていきました。 そのため、プロダクトから共通サービスを利用するためにはproto定義からのコード生成を自前で行う必要があったり、定義の変更に追従するためにはgit submodule等でコードを同期させる必要があり、導入の負荷が高いという課題がありました。 定義が複数のリポジトリに分散していることによって、現在どのような機能が共通サービスとして提供されているのかを把握しづらかったり、共通サービスの開発は複数チームで行われているためコード生成やlintの設定等にばらつきがありガバナンスを効かせるのが難しいといった問題もありました。

proto定義の一元管理

プロダクトへの導入や変更への追従のしやすさ、ガバナンスを効かせられる状態にしておくことは今後の機能共通化の推進において重要度の高い課題でしたので改善に取り組みました。 ソリューションとしては、各サービスのproto定義を1つのリポジトリに集約し、生成コードの管理やパッケージの公開を自動化する仕組みを提供することです。

ディレクトリ構成

proto定義リポジトリのディレクトリ構成は以下のようになりました。

.
├── buf.yaml
├── buf.gen.yaml
├── package.json
├── pnpm-workspace.yaml
├── doc
│   ├── service1
│   └── service2
├── gen
│   ├── go
│   │   ├── service1
│   │   │   ├── v1 
│   │   │   │   └── model.pb.go
│   │   │   ├── go.mod
│   │   │   └── go.sum
│   │   └── service2
│   └── npm
│       ├── service1
│       │   ├── package.json
│       │   └── tsconfig.json
│       └── service2
└── services
    ├── service1
    │   └── service1
    │       └── v1
    │           └── model.proto
    └── service2
  • services: proto定義を格納するディレクトリ。サービスごとにディレクトリを切る
  • doc: proto定義のドキュメントを格納するディレクトリ
  • gen: proto定義から生成されたコードを格納するディレクトリ。enechainのプロダクトは主にGoとTypeScriptで開発されているため、Goとnpm向けにパッケージを公開します。通常生成コードはバージョン管理の対象となりませんが、GoはGitHubリポジトリをプライベートレジストリとしてパッケージを公開するため、生成コードもバージョン管理の対象としています。npmはpackage.jsonやパッケージビルドに必要な設定ファイル等をバージョン管理の対象としています。

コード生成にはBufを利用しているため、buf.yamlbuf.gen.yamlを配置しています。 buf.yaml は下記のように各サービスのディレクトリをmodulesに指定します。 ガバナンスの強化という観点で、BufのLintやBreaking Changeの設定を有効化し、社内ルールからの逸脱や破壊的変更が含まれていないかをCIで自動的にチェックできるようにしています。

version: v2
modules:
  - path: services/service1
  - path: services/service2
lint:
   use:
      - STANDARD
breaking:
   use:
      - FILE

詳細は後述しますが、パッケージ公開のワークフローにおいてサブパッケージを識別するためにpnpmのワークスペース機能を用いるため、pnpm-workspace.yamlを配置しています。

packages:
  - 'gen/npm/*'

パッケージ公開のワークフロー

proto定義からコードを生成し、パッケージを公開するためのワークフローについて紹介します。 ワークフローの責務は、入力としてリリース対象のサービス名セマンティックバージョン(以下、SemVer)与えられると、各言語向けのコード生成とパッケージ化を行い、公開することです。

ワークフローの構築にはchangesetsを利用しました。changesetsはモノレポ構造のパッケージを公開するためのツールであり、パッケージのバージョン管理やリリースノートの生成を支援してくれます。採用した主な理由は下記のとおりです。

  • SemVerをファイルに保存できるため、バージョン情報がコードに残る
  • モノレポ構造のリポジトリでパッケージを公開するための機能を提供しており、今回のユースケースに適している
  • npmパッケージビルドのためにはgen/npm/serviceディレクトリにpackage.jsonを配置する必要があり、changesetsのサブパッケージ管理の入力としてそのまま利用できる

proto定義変更からパッケージ公開までの流れは下記のとおりです。

  1. protoファイルを変更する
  2. changeset コマンドでバージョンを変更したいサービスと、メジャー、マイナー、パッチバージョンのうち上げたい箇所を指定し、生成されたchangesetファイルをコミットする。mainブランチ向けのプルリクエストを作成する。 Changesets
  3. 変更内容をレビューし、プルリクエストをマージする
  4. マージされたプルリクエストにchangesetファイルが含まれている場合は、CIによって対象サービスのpackage.jsonに記載されているパッケージバージョンを変更するプルリクエストが自動作成される。
  5. バージョンの変更内容をレビューし、プルリクエストをマージする
  6. CIによって5.の差分から対象サービスと新しいバージョン情報を取得し、サポート言語ごとにコード生成・パッケージビルド・レジストリへの公開が行われる

Workflow

具体的なGitHub Actionsのワークフローは下記のような内容になりました。リリースの制御にはchangesets/actionを利用しています。

name: Release
on:
  push:
    branches:
      - main
...
jobs:  
  version:
    steps:
      ...
      # changesets/action は追加された変更にchangesetファイルが含まれている場合はバージョンアップ用のPRを作成し、hasChangesets=trueを出力。ワークフローの図では④のタイミングにあたる。changesetファイルが含まれていない場合、hasChangesets=falseを出力。ワークフローの図では⑥のタイミングにあたる。
      - name: Create Release Pull Request
        id: changesets
        uses: changesets/action@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    outputs:
      hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}
 
  generate-code:
    needs: [version]
    steps:
      ...
      # リリースの制御を行う。⑥のタイミングなら、should_release=trueを出力し、後続のリリース対象サービスの特定とコード生成、パッケージ公開のステップに進む。
      - name: Check if release process should be run
        id: check-should-release
        run: |
          last_merged_branch=...

          # 直近マージされたブランチが changesets の version up の場合はリリースする
          if [[ "$last_merged_branch" == *"changeset-release"* ]] && [[ "${{ needs.version.outputs.hasChangesets }}" == "false" ]]; then
            echo "should_release=true" >> $GITHUB_OUTPUT
          else
            echo "should_release=false" >> $GITHUB_OUTPUT
          fi

      # パッケージバージョンアップの内容を解析し、リリース対象のサービス名とバージョンを抽出する
      - name: Extract service names and versions to be released
        id: extract-releases
        if: steps.check-should-release.outputs.should_release == 'true'
        run: |      
          # Gitの履歴を解析し、変数releasess に ``service_name:version`` の形式でリリース情報を格納する
          releases=...
          
          echo "releases<<EOF" >> $GITHUB_OUTPUT
          cat $releases >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      # buf generate でコードを生成する
      - name: Generate code
        if: steps.check-should-release.outputs.should_release == 'true'
        ...

    outputs:
      should_release: ${{ steps.check-should-release.outputs.should_release }}
      releases: ${{ steps.extract-releases.outputs.releases }}

  # 生成コードをコミットしタグ打ち。前ステップで抽出したバージョン情報を利用してタグを打つ
  publish-go:
    needs: generate-code
    if: needs.generate-code.outputs.should_release == 'true'    
    steps:
      ...

  # npmパッケージをビルドし、プライベートレジストリに公開
  publish-npm:
    needs: generate-code
    if: needs.generate-code.outputs.should_release == 'true'    
    steps:
      ...

利用推進のための工夫

リリースワークフローの整備と合わせて、 組織内での利用が進むための工夫をいくつか行いました。

開発用ワークフローの整備

上記で紹介したパッケージ公開のワークフローは本番リリースを想定したものでした。proto定義の管理を各サービスのリポジトリから一元管理リポジトリに移行すると、開発途中の生成コードを用いたテストがしにくくなり開発体験を損なってしまいます。そのため、リリース用のワークフローとは別に、手動実行可能な下記のようなワークフローを準備することによって、 任意のタイミングでパッケージを公開できるようにしました。

  1. 公開対象のブランチとサービスを指定してワークフローを実行する
  2. 指定されたブランチの内容で対象サービスのコードを生成する
  3. サポート言語ごとにパッケージ化する
  4. ブランチ名、コミットハッシュ、タイムスタンプから一時的なバージョンを生成し、パッケージを公開する

Dispatch dev workflow

利用側のコードでは、一時的なバージョンを指定することによって開発中のパッケージを簡単にテストできるようになりました。

初期セットアップの自動化

新しいサービスを追加するには、コード生成の設定や言語ごとのパッケージ定義などのボイラープレートコードを多く追加する必要があり手間がかかります。そこで、 下記の作業を自動化するスクリプトを用意し、オンボーディングをセルフサービスで行ってもらいやすくしました。

  • サービス用のproto定義ディレクトリを作成
  • buf.yamlにサービスのproto定義ディレクトリを追加
  • renovateやCODEOWNERSの設定を追加
  • Go, npmのモジュール定義に必要なファイルをテンプレートからコピー

テンプレートファイルは下記のように配置されています。

scripts
├── add_service.sh
└── template
    ├── go
    │  └── go.mod
    └── npm
        ├── package.json
        └── tsconfig.json

go.modにはモジュール名とgoのバージョンを指定しています。サービス名のプレースホルダーはテンプレートからコピーされたあとに置き換えられます。

module github.com/enechain/service-proto/gen/go/SERVICE_NAME

go x.x.x

package.jsonにはパッケージ名やパッケージをビルドするために必要なスクリプトが定義されています。

{
  "name": "@enechain/service-proto-SERVICE_NAME",
  "version": "0.0.0",
  "main": "index.js",
  "module": "index.mjs",
  "types": "index.d.ts",
  "scripts": {
    "barrel": "bash ../../../scripts/barrel.sh",
    "build": "rm -rf dist && pnpm barrel && tsup && tsc"
  },
  "dependencies": {
    ...
  }
}

コードメンテナンスの役割分担

依存パッケージのバージョンアップ等を含めたコードのメンテナンスが各チームによってセルフサービスで行われるように、下記のようにコードの役割分担を明確にしました。

  • 特定のサービスに関するコードのメンテナンスは、そのサービスの開発チームが行う
  • その他の基盤に関するコードのメンテナンスは、基盤チームが行う

上記のルールを、下記に示すようにコードオーナーやrenovateの設定に反映しました。 これらのファイルは上記で紹介した初期セットアップのスクリプトで自動的に更新することによって設定漏れを防いでいます。

CODEOWNERSは下記のような内容です。

# 基盤に関するコード
/* @enechain/application-platform-team
/.github/ @enechain/application-platform

# 特定サービスのコード
service1/ @enechain/service1-team
/.changeset/service1*.md @enechain/service1

service2/ @enechain/service2-team
/.changeset/service2*.md @enechain/service2

...

renovate.jsonは下記のような内容です。

{
  ...
  "packageRules": [
    {
      "packagePatterns": ["*"],
      "groupName": "common",
      "matchFileNames": [".github/**", "scripts/**", "./*"],
      "reviewersFromCodeOwners": true
    },
    {
      "packagePatterns": ["*"],
      "groupName": "service1",
      "matchFileNames": ["**/service1/**"],
      "reviewersFromCodeOwners": true
    },  
    {
      "packagePatterns": ["*"],
      "groupName": "service2",
      "matchFileNames": ["**/service2/**"],
      "reviewersFromCodeOwners": true
    },      
  ]
  ...
}

おわりに

本記事ではproto定義の一元管理に関する取り組みについて紹介しました。 運用開始から半年程度が経ちましたが、既存の共通サービスのproto定義の移行が完了し、新サービスの立ち上げも本仕組みを使って行われるようになりました。 当初の狙いであった、共通サービスの利用開始にまつわる負荷の軽減や発見容易性の向上、ガバナンス強化を実現でき、今後共通機能を効率的に提供していくための土台を整備できました。


enechainでは、100兆円規模のエネルギー市場を作り上げるべく、サービス開発だけではなくそれらを支えるためにPlatform Engineering観点で様々な取り組みを行っています。 興味を持っていただけた方は下記のリンクからぜひお声がけください。

tech.enechain.com

herp.careers