gRPCのコード生成をCircleCIで自動化する

# はじめに

gRPCではクライアントとサーバーのインターフェースを一致させる事ができますが
クライアントとスタブのコード生成がずれると通信できなくなります。
また各言語向けのprotocol buffer compilerを環境構築するのは面倒です。
そこで諸々の環境が揃ったDockerイメージの[namely/protoc-all](https://github.com/namely/docker-protoc)とCircleCIを組合せてコード生成を自動化します。

# 構成

以下の図とブランチ構成でコード生成の自動化を行います。

![plantuml.png](https://qiita-image-store.s3.amazonaws.com/0/8995/fb2e9d08-1591-a0c8-87f5-df2de573889f.png)

* masterブランチ
    * .circleci/config.yml
    * docker-compose.yml
    * protos/echo.proto
* Go用のgenerated/goブランチ
* C#用のgenerated/csharpブランチ

また実際の試したものは以下のリポジトリに置いています。

https://github.com/shiena/grpc-gen-circleci
https://github.com/shiena/grpc-gen-circleci/tree/generated/go
https://github.com/shiena/grpc-gen-circleci/tree/generated/csharp

# 各ファイルについて

## docker-compose.yml

```yaml:docker-compose.yml
version: "3"
services:
    lint:
        image: namely/protoc-all:1.16_0
        volumes:
            - .:/defs
        entrypoint: "sh -c"
        command: '"protoc -I/usr/local/include -I. --lint_out=. ./protos/*.proto"'
    go:
        image: namely/protoc-all:1.16_0
        volumes:
            - .:/defs
        command: "-d ./protos -o ./pb-go --with-docs markdown,readme.md --with-gateway -l go"
    csharp:
        image: namely/protoc-all:1.16_0
        volumes:
            - .:/defs
        command: "-d ./protos -o ./pb-csharp --with-docs markdown,readme.md -l csharp"
```

docker-compose.ymlではlintとGoのコード生成とC#のコード生成を例にサービスを定義します。

* lint
    * *.protoをワイルドカード展開したいので敢えてentrypointとcommandを設定しています。
* go
    * クライアント、スタブと一緒にgrpc-gateway用のコード(`--with-gateway`)とドキュメント(`--with-docs`)も一緒に生成します。
* csharp
    * こちらもクライアント、スタブと一緒にドキュメント(`--with-docs`)を生成します。

またentrypointに設定されているコマンドは以下のようなオプションがあります。

```sh
gen-proto generates grpc and protobuf @ Namely

Usage: gen-proto -f my-service.proto -l go

options:
 -h, --help           Show help
 -f FILE              The proto source file to generate
 -d DIR               Scans the given directory for all proto files
 -l LANGUAGE          The language to generate (go ruby csharp java python objc gogo php node)
 -o DIRECTORY         The output directory for generated files. Will be automatically created.
 -i includes          Extra includes
 --lint CHECKS        Enable linting protoc-lint (CHECKS are optional - see https://github.com/ckaznocha/protoc-gen-lint#optional-checks)
 --with-gateway       Generate grpc-gateway files (experimental).
 --with-docs FORMAT   Generate documentation (FORMAT is optional - see https://github.com/pseudomuto/protoc-gen-doc#invoking-the-plugin)
 --go-source-relative Make go import paths 'source_relative' - see https://github.com/golang/protobuf#parameters
```

## .circleci/config.yml

```yml:.circleci/config.yml
version: 2

references:
  defaults: &defaults
    working_directory: ~/grpc-gen
    machine: true

jobs:
  lint:
    <<: *defaults
    steps:
      - checkout
      - run:
          name: Lint
          command: docker-compose run --rm lint
      - persist_to_workspace:
          root: .
          paths:
            - .

  build:
    <<: *defaults
    steps:
      - attach_workspace:
          at: .
      - run:
          name: Clean up
          command: rm -rf pb-go pb-csharp
      - run:
          name: Generate gRPC for go
          command: docker-compose run --rm go
      - run:
          name: Generate gRPC for csharp
          command: docker-compose run --rm csharp
      - persist_to_workspace:
          root: .
          paths:
            - .

  push:
    <<: *defaults
    steps:
      - attach_workspace:
          at: .
      - run:
          name: git config
          command: |
            git config user.email "shiena.jp@gmail.com"
            git config user.name "Generator Bot"
            git remote add upstream https://${GH_TOKEN}@github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}.git
      - run:
          name: git commit go-src
          command: |
            if [ `git branch -r --list origin/generated/go | wc -l` -eq 1 ]; then
              echo "checkout generated/go"
              git fetch origin
              git worktree add -b generated/go ../go-src origin/generated/go
              git -C ../go-src rm `git -C ../go-src ls-files`
            else
              echo "create generated/go"
              git worktree add --detach ../go-src
              git -C ../go-src checkout --orphan generated/go
              git -C ../go-src rm --cached -r .
              git -C ../go-src clean -d -f
            fi
            cp -a pb-go/* ../go-src/
            git -C ../go-src add -A
            git -C ../go-src status
            result=0
            git -C ../go-src commit -m "AUTO GENERATED [ci skip]" || result=$?
            if [ $result -eq 0 ]; then
              git -C ../go-src push upstream generated/go 2> /dev/null
            fi
      - run:
          name: git commit csharp-src
          command: |
            if [ `git branch -r --list origin/generated/csharp | wc -l` -eq 1 ]; then
              echo "checkout generated/csharp"
              git fetch origin
              git worktree add -b generated/csharp ../csharp-src origin/generated/csharp
              git -C ../csharp-src rm `git -C ../csharp-src ls-files`
            else
              echo "create generated/csharp"
              git worktree add --detach ../csharp-src
              git -C ../csharp-src checkout --orphan generated/csharp
              git -C ../csharp-src rm --cached -r .
              git -C ../csharp-src clean -d -f
            fi
            cp -a pb-csharp/* ../csharp-src/
            git -C ../csharp-src add -A
            git -C ../csharp-src status
            result=0
            git -C ../csharp-src commit -m "AUTO GENERATED [ci skip]" || result=$?
            if [ $result -eq 0 ]; then
              git -C ../csharp-src push upstream generated/csharp 2> /dev/null
            fi

workflows:
  version: 2
  build_and_push:
    jobs:
      - lint
      - build:
          requires:
            - lint
          filters:
            branches:
              only:
                - master
      - push:
          requires:
            - lint
            - build
          filters:
            branches:
              only:
                - master
```

CircleCIでは以下の3つのジョブを作っています。

* protoの文法をチェックするlintジョブ
* クライアントとスタブとドキュメントを生成するbuildジョブ
* 各言語毎のブランチに生成物をcommit -> pushするpushジョブ

この中で一番大きなpushジョブは次の処理を行います。

1. git configで必須項目を設定する。`${GH_TOKEN}`はGitHubのPersonal access tokenを設定する
1. commit対象のbranchをgit worktreeでcheckoutする
1. protoのファイル名変更や削除も反映するために一旦全部削除する
1. 生成したファイルをgit commitする。このブランチはCircleCIのジョブを動かしたくないのでコメントに`[ci skip]`を入れる
1. 差分があればgit pushする。ここでエラーになるとPersonal access tokenが出力されてしまうので標準エラーを/dev/nullに捨てる

以上の設定でそれぞれのブランチに生成されたコードがpushされるのでsubmoduleで取り込むことができます。

## gRPCのバージョン

~~namely/protoc-allはgRPCの1.14.xブランチをcloneしているのでどのコミットでビルドされたものか分かりません。
そこでv1.15.1のリリースタグをcloneしてビルドしたDockerイメージを[作りました](https://hub.docker.com/r/shienajp/protoc-all/)。~~
最新版はv1.16.xブランチからビルドされています。

# 参考リンク

* https://hub.docker.com/r/namely/protoc-all/
* https://hub.docker.com/r/shienajp/protoc-all/
* https://github.com/namely/docker-protoc
* https://github.com/shiena/grpc-gen-circleci
* https://github.com/shiena/docker-protoc/tree/shiena