マイクロサービスアーキテクチャにおけるProtocolBuffers管理のGitHubActionsフローを実現した

この記事は [株式会社サイバー・バズ Advent calendar 2021](https://qiita.com/advent-calendar/2021/cyberbuzz) の記事です。

## はじめに

「Protocol Buffersでコード生成して、gRPCを実現してみたよ〜」や
「マイクローサービスアーキテクチャにおけるgRPCの使い所はここだ」
などの記事は見かけるのですが、

「「**具体的にどうやってフローを実現するのか**」」
について書かれている記事がなかったので、
1つの方法として捉えて頂ければなーと思います。

## 構成のイメージ図

![MicroServiceArchby.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436088/63b739ef-e65f-fc8b-c8fb-8b4651349187.png)

軽く上図を説明

### 4つのリポジトリがある

1. 全てのProtocol Buffers(以下PBと略す)を集約するリポジトリ([aggregation-pb](https://github.com/mocoog/aggregation-pb))
1. Protocol Buffersによって生成されたGoのコードを集約するリポジトリ([grpc-go-packages](https://github.com/mocoog/grpc-go-packages))
1. Protocol Buffersによって生成されたPythonのコードを集約するリポジトリ
1. ４つのマイクロサービスリポジトリ([例:BFF](https://github.com/mocoog/bff), [例:Gateway](https://github.com/mocoog/gateway), Stock, Preprocess)

※各マイクロサービスは作成途中で未完成
※Pythonのマイクロサービスは未作成

### 図の流れ

1. BFFを開発するエンジニアがBFFサービスにて新たなAPIを生やしたい
1. **Protocol Buffers Repository**で新たな変更をPR→merge
1. mergeをhookにProtocol Buffersによってコードを生成する
1. ↑のコードを**言語毎リポジトリ**にてPRを投げる
1. 各マイクロサービスでGitHubのソースコードをimportして利用する

## 上の流れを実現したコードの紹介

上記の流れを実現するためのコードを書いてみました。
中々にGitHub Actionsしているので、
解説はさみつつ進めていきます。

### aggregation-pb

Protocol Buffersを集約するリポジトリです。
[aggregation-pbのリポジトリはコチラ](https://github.com/mocoog/aggregation-pb)

#### リポジトリ構成

構成としては以下のようになっています。

```
root/
　├ .github/
　│  └ workflows/
　│    └ test.yaml
　│    └ hook.yaml
　├ gateway/
　│  └ proto/
　│    └ gateway.proto
　├ bff/
　│  └ proto/
　│    └ bff.proto
　etc...
```

ルート直下に`.github`と`各マイクロサービス`のディレクトリに
分かれていることが分かります。

では`.github`で設定した設定を見ていきます。
※各マイクロサービスがどうなっているかは今回は割愛します。

#### hook.yaml

主に`生成された[プログラミング言語]のコードを集約するリポジトリ`にて
GitHub Actionsを走らせるために、hookとなる動作をします。

```yaml:hook.yaml
name: generate

on:
  ## なぜpushではなく、pull_requestにしたか？
  ## → ProtocolBuffersからコードを生成する際に、任意のサービスを指定できる
  ## 　　 PRのタイトルを用いて、マイクサービスを判別する
  ##   またタイトル取得には`github.event.pull_request.title`が不可欠
  pull_request:
    branches:
      - main
    ## なぜclosedか？
    ## 承認フローを通るため
    types:
      - closed

jobs:
  pr-hooks:
    runs-on: ubuntu-20.04
    strategy:
      matrix:
        ## ただの環境変数化
        repo: ['mocoog/grpc-go-packages']
    steps:
      - name: checkout
        uses: actions/checkout@v2
      - name: merged check
        ## これをしないと、PRを単にCloseした時(mergeしなかった時)にも起動してしまう
        if: github.event.pull_request.merged != true
        ## アーリーリターン
        run: exit 1
      - name: def checkRunCompile
        uses: actions/github-script@v3
        env:
          ## タイトルを環境変数化。onでpull_requestを指定したから使えるevent
          PR_TITLE: ${{ github.event.pull_request.title }}
        id: checkRunCompile
        with:
          ## 返り値の型
          result-encoding: string
          ## JSのコードを書いている
          ## コロンがあればservice名を指定している or コード生成以外の目的PR
          script: |
            const prTitle = process.env.PR_TITLE
            return prTitle.match(/:/) ? prTitle : ''
      - name: dispatch compile-pb
        ## コード生成以外のPRでhookを発動させない&↑JSの返り値を取得
        if: steps.checkRunCompile.outputs.result != ''
        ## 以下hookのための処理
        uses: peter-evans/repository-dispatch@v1
        with:
          ## tokenが必要　
          token: ${{ secrets.ACCESS_TOKEN }}
          repository: ${{ matrix.repo }}
          event-type: compile-pb
          ## payloadも指定できる
          ## PRのタイトルを`生成された[プログラミング言語]のコードを集約するリポジトリ`のGitHub Actionsへ送ります。
          client-payload: '{"pr_title": "${{ github.event.pull_request.title }}"}'
```

これによって、`aggregation-pb`にて出された**PRのタイトル**によって
生成するProtocolBuffersを決定する。

例文: [bff: add health check](https://github.com/mocoog/aggregation-pb/pull/30)
例Actions: [bff: add health check generate #23](https://github.com/mocoog/aggregation-pb/actions/runs/1569470272)

#### test.yaml

気になる方もいらっしゃったと思いますが、
こんなルールしらねーとmergeしてしまう人がいるのではないかというケースに対応！

自動テストも作っておきましたw

```yaml:test.yaml
name: test

on:
  pull_request:
    ## PRのタイトル変更にも対応
    types: [opened, edited, synchronize]

jobs:
  test-check-directory:
    runs-on: ubuntu-20.04
    steps:
      - name: Check out
        uses: actions/checkout@v2
      - name: def checkColon
        id: checkColon
        uses: actions/github-script@v3
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        with:
          result-encoding: string
          script: |
            const prTitle = process.env.PR_TITLE
            if (prTitle.match(/:/)) {
              return prTitle.split(':')[0] === '' ?
                core.setFailed('Not setup service name!')
                : prTitle.split(':')[0]
            }
            return ''
      - name: test target service
        env:
          ## ↑JSの呼び出し
          SERVICE_NAME: ${{ steps.checkColon.outputs.result }}
        if: env.SERVICE_NAME != ''
        ## PRで指定したタイトルのディレクトリがない時、コード生成できないため
        run: |
          echo ${{ env.SERVICE_NAME }}
          test -d ${{ env.SERVICE_NAME }}
```

これによって不適切なPRタイトルを防ぐことができました。

もし**「「PRタイトルにてディレクトリに無いマイクロサービス名が指定された時」」**に
**事前にテストで落とす**ことができます。

### grpc-go-packages

Protocol Buffersによって生成されたGoのコードを集約するリポジトリです。
[grpc-go-packagesのリポジトリはコチラ](https://github.com/mocoog/grpc-go-packages)

#### リポジトリ構成

構成としては以下のようになっています。

```
root/
　├ .github/
　│  └ workflows/
　│    └ compile.yaml
　├ gateway/
　│  └ proto/
　│    └ gateway.proto
　│    └ gateway.pb.go
　│    └ gateway_grpc.pb.go
　├ bff/
　│  └ proto/
　│    └ bff.proto
　│    └ bff.pb.go
　│    └ bff.pb.go
　├ docs/
　etc...
```

各マイクロサービス`のディレクトリ下にて
それぞれProtocolBuffersにより生成されたコードがあることが分かります。
ついでにドキュメントも生成しています。

では`.github`で設定した設定を見ていきます。

#### compile.yaml

aggregation-pbのactionsによるhookで発火させられ、
値を受け取っています。

その値を用いてProtocolBuffersをコンパイル(コード生成)しています。

```yaml:compile.yaml
name: compile aggregation protocol-buffers
on:
  ## hookにより起動する際のセリフと合言葉
  repository_dispatch:
    types: [compile-pb]

jobs:
  setup:
    runs-on: ubuntu-20.04
    steps:
      - name: Set up
        uses: actions/setup-go@v2
        with:
          go-version: 1.17.1
        id: go
      - name: Check out
        uses: actions/checkout@v2
      - name: Cache
        uses: actions/cache@v2.1.0
        with:
          path: ~/go/pkg/mod
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-go-
  pick-service-name:
    needs: setup
    runs-on: ubuntu-20.04
    outputs:
      ## jobを超えた値を渡すため
      pr-title: ${{ steps.pickPR.outputs.prTitle }}
      service-name: ${{ steps.pickPR.outputs.serviceName }}
    steps:
      - name: Check out
        uses: actions/checkout@v2
      - name: def pickServiceName
        uses: actions/github-script@v3
        env:
          ## aggregation-pbからのpayloadが存在しているか判定する
          PR_TITLE: ${{ github.event.client_payload.pr_title }}
        id: pickServiceName
        with:
          result-encoding: string
          script: |
            const prTitle = process.env.PR_TITLE
            return prTitle.match(/:/) ?
              prTitle.split(':')[0]
              : core.setFailed('Not setup service name!')
      - name: pickup service name from PR title
        id: pickPR
        if: steps.pickServiceName.outputs.result != ''
        ## 三行目: pick-service-nameのlevelからidを元に取り出せるようにセット
        ## 六行目: サービス名だけを取得する関数を発火して、idを元に取り出せるようにセット
        run: |
          echo '== pr title =='
          echo ${{ github.event.client_payload.pr_title }}
          echo '::set-output name=prTitle::${{ github.event.client_payload.pr_title }}'
          echo '== service name =='
          echo ${{ steps.pickServiceName.outputs.result }}
          echo '::set-output name=serviceName::${{ steps.pickServiceName.outputs.result }}'
  compile-pb:
    needs: pick-service-name
    runs-on: ubuntu-20.04
    env:
      ## pick-service-nameのoutputsで指定したものを取り出し(長いので少しでもjobを短くして責務分け)
      PR_TITLE: ${{ needs.pick-service-name.outputs.pr-title }}
      SERVICE_NAME: ${{ needs.pick-service-name.outputs.service-name }}
    steps:
      - name: Check out
        uses: actions/checkout@v2
      - name: clone aggregation-pb
        uses: actions/checkout@v2
        with:
          repository: mocoog/aggregation-pb
          path: aggregation-pb
          token: ${{ secrets.ACCESS_TOKEN }}
      - name: copy target service
        ## aggregation-pbの対象のサービスディレクトリをコピー(バグり防止で削除処理も込み)
        run: |
          echo '== ls aggregation-pb/${{ env.SERVICE_NAME }} =='
          ls aggregation-pb/${{ env.SERVICE_NAME }}
          if [ -d ${{ env.SERVICE_NAME }} ]; then
            rm -rf ${{ env.SERVICE_NAME }}
          fi
          cp -rf aggregation-pb/${{ env.SERVICE_NAME }} ${{ env.SERVICE_NAME }}
      - name: clean aggregation-pb
        run: |
          rm -rf aggregation-pb
      - name: ls test
        run: |
          echo '== ls =='
          ls
          echo '== ls service name =='
          ls ${{ env.SERVICE_NAME }}
      ## Protocol Buffersのコンパイラー準備
      - name: apt-get
        run: |
          sudo apt-get update
          sudo apt-get install -y protobuf-compiler
      ## go.modを生成
      - name: initialize go
        run: go mod init github.com/mocoog/grpc-go-packages
      ## protocするための関連パッケージをゲット
      - name: get protoc
        run: |
          go get -v google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
          go get -v google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
          go get -u github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc
      ## ドキュメント生成用のディレクトリを生成
      - name: make directory docs
        run: mkdir -p docs
      ## compile！!
      - name: compile protocol-buffers
        run: |
          export PATH="$PATH:$(go env GOPATH)/bin"
          protoc \
            --doc_out=./docs --doc_opt=markdown,${{ env.SERVICE_NAME }}.md \
            --go_out=. --go_opt=paths=source_relative \
            --go-grpc_out=. --go-grpc_opt=paths=source_relative \
            ${{ env.SERVICE_NAME }}/proto/${{ env.SERVICE_NAME }}.proto
      ## 不要なものを消去。今思ったけど、消さない方が効率的な気がしてきている。
      - name: rm go mod & sum
        run: rm go.mod go.sum
      ## プルリクを作成
      - name: create PR
        uses: peter-evans/create-pull-request@v3
        with:
          token: ${{ secrets.ACCESS_TOKEN }}
          author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
          commit-message: compile ${{ env.SERVICE_NAME }}
          branch: compile-pb
          branch-suffix: timestamp
          delete-branch: true
          title: 'compile:${{ env.PR_TITLE }}'
```

めちゃめちゃ長いYAMLさんですが、ヤバそうな所はコメントを付けたので、
読む意思が強めな方は読んでみてください〜！

- 取得した値のtest
- split関数
- jobを超えた変数のやり取り
- 対象サービスディレクトリのコピーコマンド
- Goの環境構築
- protoc実行
- プルリク作成

大体↑こんなことしています。

もっとjob分けできるなーとも思ったんですが、また長くなるので、悩みどころです。

### 完成例

最後までフローが進むと以下の感じにプルリクが作成されます。
(以下例はマージ済みのやつです)

![スクリーンショット 2021-12-14 1.21.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/436088/066801e0-8473-135e-7b52-3bbeca01d25f.png)

そして生成されたGoのコードをパッケージとして、
各マイクロサービスで`go get`して`import "github.com/〜"`で呼んであげる訳です。

↑を呼び出す例はまだ作っていないです！

[Python-GoのgRPC実装例](https://github.com/Hirochon/pygo-grpc)も作ったことはありますので、見てみてください＾＾