コントラクトファーストアプローチの実践「API仕様書の作成から実装に至る開発プロセスの具体的な方法」 - Natic | Application Modernization Platform – 日商エレクトロニクス
コントラクトファーストアプローチの実践「API仕様書の作成から実装に至る開発プロセスの具体的な方法」
本記事で紹介したいこと:
本記事では、コントラクトファーストアプローチにしたがった>開発プロセスの具体的なやり方についてご紹介いたします。
目次(Table of Contents)
- はじめに
- 体験環境の準備
- 体験1:
1-1.OpenAPI仕様書とソースコードとの間のずれを防ぐ
1-2. API仕様書を読み解きながら動かしてみる
1-3.GitHub Action(のローカルツールであるact)で自動化する - 体験2: OpenAPI仕様書をチームで共有する
- 所感
はじめに
前回、 【NADP】Fuse Onlineに見るコントラクトファーストの考えにてAPI仕様書を先に作成してから、API仕様書に従った実装を行うという開発プロセス「コントラクトファーストアプローチ」を紹介しました。
今回は、このコントラクトファーストアプローチにしたがった開発プロセスの具体的なやり方について、どんな風に進めていけばいいのか追体験していきましょう。
コントラクトファーストアプローチ:体験環境の準備
このブログのために、Dockerとact(ローカル環境でGitHub Actionと呼ばれるCI/CDツールを動かすツール)を用意するだけで、Go言語で書かれたサーバーのビルドやAPI仕様書の更新を行える環境を用意しました。
上記ソースコードならびに docker
, act
, git
の三つのツールでコントラクトファーストアプローチを体験します。
このブログのshell操作はLinuxやWSLを想定しています。
また、Go言語プラグインを入れたVisualStudio Codeがあるとより体感しやすくなります。
最初にソースコードをローカル環境に準備しておきましょう。
$ git clone https://github.com/nelco-abm/fuse-handson-api-lifecycle.git $ cd fuse-handson-api-lifecycle
今回のブログのコンセプトは、先ほど挙げたツール以外はローカル環境に何もインストールしないことでクリーンな実験環境を目指しています。その代わりdockerコマンドを多用するため、若干視認性に欠けるところがございますが、ご容赦願えれば幸いです。
体験1: OpenAPI仕様書とソースコードとの間のずれを防ぐ
プログラムの開発を行う上で、長期間保守や機能改修を繰り返し行っているとソースコードとドキュメント(仕様書)がずれていくという課題は誰もが経験したことがあるでしょう。
特にAPI仕様書は、機能改修と同時に真っ先に更新し、他のチームにも連携するチームの顔ともいえるドキュメントです。公開しているAPI仕様書は、APIサービスの顔であり、その内容が実際の処理とずれると誰もが困ります。そういったずれを防ぐにはどうすればよいでしょうか?その解決策の一つが、OpenAPI仕様書をベースにソースコードを自動生成するコードジェネレーターです。
今回、API仕様書からアプリケーションサーバーのインターフェース(スタブ部分)を自動生成する oapi-codegen
というツールを利用して、自動生成部分には一切手作業による修正を加えないように簡単なAPIサーバーを構築してみました。
OpenAPI仕様書からGo言語で自動生成します
oapi-codegen
ツールは、OpenAPI仕様書を読み込むと、自動的にGo言語で書かれたアプリケーションサーバー(echoサーバー)の一部、例えばhttpリクエストを受信して処理を振り分けするhandlerまでを生成します。
それだけでは単にリクエストが振り分けられるだけなので、どんなデータを返すか処理を書いたり、echoサーバーの起動停止やポートの受付部分をプログラマーが実装することで、APIサーバーは完成します。
もしもここで、API仕様書だけを修正し、サーバー側の修正をうっかり忘れてしまうとどうなるでしょうか?コンパイルが通らないこともあれば、変なデータを返してしまってこのAPI仕様書間違っているんだけど…という困った事態になります。
しかし、Go言語でテストコードをしっかり書いていれば、そのようなミスが発生してもテスト実行時にわかるので本番環境にリリースされる前に未然に防ぐことができるでしょう。それでは体験してみます。
体験1の流れ
1. oapi-codegenでサーバーのIFを作成する
2. API仕様書を読み解きながら動かしてみる
3. GitHub Action(のローカルツールであるact)で自動化する
4. わざとAPI仕様書の一部を変更して、うっかりサーバー側の修正を忘れてみる
1. oapi-codegenでサーバーのIFを作成する
さっそくですが、ソースコードを git clone
した直後のプロジェクトフォルダの内容を見てみましょう。
例えば、golangプラグインが入っているvs codeでプロジェクトがあるフォルダを開くと左のようにいくつかのgoファイルがエラーになっていることがわかります。インターフェースとしてあるべきファイルがないため、コンパイルに失敗しているからです。
ビルドをするために必要なインターフェース部分は、 oapi-codegen
というOpenAPIからGo言語のソースコードを生成できるツールで補うことができます。実際にコマンドを実行してみます。
# oapi-codegenを実行します $ docker run --rm -v ${PWD}/openapi-spec.yaml:/target.yml tomoyamachi/oapi-codegen -generate "server,types" -package api target.yml > ./api/server.gen.go # oapi-codegenでソースコードを自動生成後、関連するライブラリを整えるために `go mod tidy` コマンドを実行します $ docker container run -v ${PWD}:/data -w=/data --rm golang go mod tidy
OpenAPI仕様書は、APIサーバーのインターフェースとして自動的に生成されます。
一番わかりやすい例としてAPIのレスポンスに使用する EstimateItem
(見積り業務情報)についてAPI仕様がどのようにgolangのソースコードに変換されるのか見てみましょう。
- openapi-spec.yaml の一部
// 中略 // リクエストやレスポンスのコアとなるEstimateItem(見積り業務情報) // の詳細について説明しています。 components: schemas: EstimateItem: description: 見積り業務用 type: object properties: quote_no: description: 見積番号 type: string quote_date: format: date description: 見積り日 type: string cust_code: description: 得意先企業コード type: string cust_name: description: 得意先名 type: string example: quote_no: 11A0000001-00 quote_date: '2020-01-01' cust_code: X001 cust_name: NELCO商事 // 以下略
この仕様書から下記の通り、サーバーのインターフェースが生成されます
- oapi-codegen で自動生成したサーバー情報
// EstimateItem defines model for EstimateItem. type EstimateItem struct { // 得意先企業コード CustCode *string `json:"cust_code,omitempty"` // 得意先名 CustName *string `json:"cust_name,omitempty"` // 見積り日 QuoteDate *openapi_types.Date `json:"quote_date,omitempty"` // 見積番号 QuoteNo *string `json:"quote_no,omitempty"` }
得意先企業コードを示す下記の行は、三つの意味でできています。
CustCode *string `json:"cust_code,omitempty"`
CustCode
得意先企業コードです。サーバー内にて、入力された変数として使用します。
*string
文字列型のポインタを表します。
json:"cust_code,omitempty"
Struct Tag(構造体タグ)と呼ばれる注釈です。
このStructTagは、Structのインスタンスをjsonに変換するときに指示に従って名前などをbindすることができます。サーバーからjsonレスポンスを返すときに使用します。
ocapi-codegen
により、インターフェース部分の実装ができたのでコンパイルが通るようになりました。vs codeで同じプロジェクトをみるとエラーが解消されていることがわかります。
2. API仕様書を読み解きながら動かしてみる
せっかくインターフェースを作成し、動けるようになったのでOpenAPIの仕様にしたがってどのように動くのか確認してみましょう。
2.1. API仕様書を読み解く
まずAPI仕様書を見て、サーバーにどんなリクエストを送るとどんなデータが取得できるのか見てみます。
openapi-spec.yaml
の全体図
openapi: "3.0.0" info: title: 見積り業務API version: 0.0.1 description: 見積り業務API contact: name: 日商エレクトロニクス-アプリケーション事業推進部-NADPチーム url: 'https://natic.nissho-ele.co.jp/' email: nadp@nissho-ele.co.jp license: name: MIT License url: 'https://opensource.org/licenses/MIT' paths: /estimateitems: summary: 見積もり情報のリストを管理する description: このエンドポイントは 0個以上の `EstimateItem` エンティティをリストアップするために使用します。 get: responses: '200': content: application/json: schema: type: array items: $ref: '#/components/schemas/EstimateItem' description: 成功時 - `EstimateItem` エンティティの配列を返す operationId: getestimateitems summary: 見積り情報をすべて取得する description: すべての `EstimateItem` エンティティの一覧を取得する parameters: - examples: 得意先企業コード: value: '"X001"' name: cust_code description: 得意先企業コード schema: type: string in: query '/estimateitems/{estimateitemId}': summary: 個別の見積り情報を管理する。 description: このエンドポイントは 個別の `EstimateItem` のシングルインスタンスを取得するために使用します。 get: responses: '200': content: application/json: schema: $ref: '#/components/schemas/EstimateItem' description: 成功時 - `EstimateItem`を返す operationId: getEstimateItem summary: 見積り情報を取得する description: 見積番号に紐づく `EstimateItem` の詳細情報を取得する parameters: - examples: 見積番号: value: '"11A0000001-00"' name: estimateitemId description: 個別の `EstimateItem`. schema: type: string in: path required: true components: schemas: EstimateItem: description: 見積り業務用 type: object properties: quote_no: description: 見積番号 type: string quote_date: format: date description: 見積り日 type: string cust_code: description: 得意先企業コード type: string cust_name: description: 得意先名 type: string example: quote_no: 11A0000001-00 quote_date: '2020-01-01' cust_code: X001 cust_name: NELCO商事
着目すべきは14行目からになります。ここからは複数行ずつ、個別に読み解きます。
paths: /estimateitems: summary: 見積もり情報のリストを管理する description: このエンドポイントは 0個以上の `EstimateItem` エンティティをリストアップするために使用します。 get:
このAPI仕様書によると /estimateitems
エンドポイントに対して GET コマンドを投げることができるようです。そして、複数個の EstimateItem
、つまり見積り情報を取得するAPIであると説明しています。
responses: '200': content: application/json: schema: type: array items: $ref: '#/components/schemas/EstimateItem' description: 成功時 - `EstimateItem` エンティティの配列を返す operationId: getestimateitems summary: 見積り情報をすべて取得する description: すべての `EstimateItem` エンティティの一覧を取得する
サーバーの処理が成功した場合、 200 というHTTPレスポンスコードと、EstimateItem
という見積もり情報が取得できます。配列に入って返ることが読み取れます
parameters: - examples: 得意先企業コード: value: '"X001"' name: cust_code description: 得意先企業コード schema: type: string in: query
見積り情報が取得したい場合、パラメーターが必要です。具体的には /estimateitems?cust_code=X001
のようにしてGETクエリを投げるとよさそうですね。
2.2. サーバーを起動する
APIサーバーへのリクエストのやり方が想像できたので実際に起動してみます。サーバーを起動するためにはビルドし、バイナリを直接実行するだけです。
# APIサーバーをビルドする $ docker container run -v ${PWD}:/data -w=/data --rm golang go build # ビルドした結果、サーバーバイナリができるので直接実行する # (そうすると自動的にport8080がbindされて、localhost:8080がサーバーの口になります) $ ./fuse-handson-api-lifecycle ____ __ / __/___/ / ___ / _// __/ _ \/ _ \ /___/\__/_//_/\___/ v4.3.0 High performance, minimalist Go web framework https://echo.labstack.com ____________________________________O/_______ O\ ⇨ http server started on [::]:8080
ターミナル画面をもう一画面開いて、 curl localhost:8080/estimateitems?cust_code=X001 を投げてみましょう。
$ curl localhost:8080/estimateitems?cust_code=X001 | jq -r '.' [ { "cust_code": "X001", "cust_name": "NELCO商事", "quote_date": "2021-01-01", "quote_no": "11A0000001-02" }, { "cust_code": "X001", "cust_name": "NELCO商事", "quote_date": "2021-01-01", "quote_no": "11A0000001-03" }, { "cust_code": "X001", "cust_name": "NELCO商事", "quote_date": "2021-01-01", "quote_no": "11A0000001-04" }, { "cust_code": "X001", "cust_name": "NELCO商事", "quote_date": "2021-01-01", "quote_no": "11A0000001-01" } ]
見積り情報を取得することができました。
3. GitHub Action(のローカルツールであるact)で自動化する
前章にて、
①API仕様書を元にまずサーバーのインターフェースにあたるソースコードが自動生成されること
②自動生成されたソースコードをビルドし、サーバーとして動かせること
を確認しました。この手順をマニュアルで行っていましたが実際のプロダクション環境ではGitHub ActionなどのCI/CDツールを使用して自動化します。
具体的には
- ソースコードがコミットされたときに、自動的にビルドしてテストを実行する
- gitにリリースタグをつけた時、成果物をArtifact Registry(成果物管理サービス)にデプロイする
といったことが可能です。
GitHub Actionは本来GitHub上でしか動かすことができないツールなのですが、有志がローカル環境でもGitHub Actionを回せるように actというツールを開発しています。今回はこちらを使用してCI/CDを回していきます。
act
でできる処理の内容は .github/workflows
の中のyamlファイルで設定します。
build-api-server.yaml
の内容を確認してみましょう。コメントで処理内容に補足説明を追加しています。
name: build-api-server # GitHub Actionのサンプルではここで[push]が指定してあるなど # gitでコミットしたりPRをマージしたことをトリガーとして動かすケースが多いですが # 今回はGitHubは使わずに手動で実行できるようにしています。 on: workflow_dispatch: # 処理の詳細はjobという単位で記述します。基本的に上から下へ処理が流れます jobs: build: # 動作環境のVMの種類です。GitHub上では払い出されたVMが使用されますが # actの場合は正確には独自のubuntuコンテナイメージ上で動きます。 runs-on: ubuntu-latest # jobの下にさらにstepという単位で処理が分けられています。 steps: # go言語のセットアップをしています - name: set up uses: actions/setup-go@v2 with: go-version: ^1.16 id: go # ソースコードをgit cloneしています - 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- # oapi-codegenでecho serverの一部を自動生成しています - name: build an api stub run: | GO111MODULE=on go get github.com/deepmap/oapi-codegen/cmd/oapi-codegen oapi-codegen -generate "server,types,spec" -package api openapi-spec.yaml > api/server.gen.go # ビルドしています - name: build an api server run: go build ./... # テストをしています - name: test run: go test -v
こちらの内容は次のコマンドで実行できます。
$ act -j build [build-api-server/build] 🚀 Start image=catthehacker/ubuntu:act-latest [build-api-server/build] 🐳 docker run image=catthehacker/ubuntu:act-latest platform= entrypoint=["/usr/bin/tail" "-f" "/dev/null"] cmd=[] // 中略 [build-api-server/build] ✅ Success - set up // 中略 [build-api-server/build] ✅ Success - check out // 中略 [build-api-server/build] ✅ Success - Cache // 中略 [build-api-server/build] ✅ Success - build an api stub [build-api-server/build] 🐳 docker exec cmd= <pre data-pm-slice="1 1 []"><code>user= | go: downloading github.com/dgrijalva/jwt-go v3.2.0+incompatible | go: downloading golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba [build-api-server/build] ✅ Success - build an api server [build-api-server/build] ⭐ Run test [build-api-server/build] 🐳 docker exec cmd=</code></pre> <pre data-pm-slice="1 1 []"><code>user= | go: downloading github.com/stretchr/testify v1.5.1 | go: downloading github.com/pmezard/go-difflib v1.0.0 | go: downloading github.com/davecgh/go-spew v1.1.1 | === RUN TestEstimateItemStore | {"time":"2021-07-14T05:38:55.502407Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/estimateitems/11A0000001-01","user_agent":"","status":404,"error":"code=404, message=Not Found","latency":50100,"latency_human":"50.1µs","bytes_in":0,"bytes_out":24} | estimate_store_test.go:28: | Error Trace: estimate_store_test.go:28 | Error: Not equal: | expected: 200 | actual : 404 | Test: TestEstimateItemStore | --- FAIL: TestEstimateItemStore (0.00s) | panic: runtime error: invalid memory address or nil pointer dereference [recovered] | panic: runtime error: invalid memory address or nil pointer dereference | [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x6e215a] | | goroutine 6 [running]: | testing.tRunner.func1.2(0x71fce0, 0x98caa0) | /opt/hostedtoolcache/go/1.16.6/x64/src/testing/testing.go:1143 +0x332 | testing.tRunner.func1(0xc000001c80) | /opt/hostedtoolcache/go/1.16.6/x64/src/testing/testing.go:1146 +0x4b6 | panic(0x71fce0, 0x98caa0) | /opt/hostedtoolcache/go/1.16.6/x64/src/runtime/panic.go:965 +0x1b9 | github.com/nelco-abm/fuse-handson-api-lifecycle.TestEstimateItemStore(0xc000001c80) | /home/soharaki/work/fuse-handson-api-lifecycle/estimate_store_test.go:34 +0x43a | testing.tRunner(0xc000001c80, 0x793450) | /opt/hostedtoolcache/go/1.16.6/x64/src/testing/testing.go:1193 +0xef | created by testing.(*T).Run | /opt/hostedtoolcache/go/1.16.6/x64/src/testing/testing.go:1238 +0x2b3 | exit status 2 | FAIL github.com/nelco-abm/fuse-handson-api-lifecycle 0.006s [build-api-server/build] ❌ Failure - test Error: exit with `FAILURE`: 1 </code></pre>
先ほど実行した act -j build
の実行結果と異なり、❌ Failure - test
となっていることがわかります。 Run test というjobにて、Go言語のテストに失敗したためです。
ここで着目すべきはその手前のjobである Run build an api server がうまくいっていることです。インターフェース部分の自動生成だけではビルドは普通に通ってしまうのですね。しかし、httpリクエストを呼び出すことはできてもHandlerと呼ばれる呼び出しに応じた具体的な処理が実装されていないため、 404 Not Foundとして処理に失敗します。
冒頭の図を再掲します。このプロジェクトで開発しているAPIサーバーはAPI仕様書をベースとして自動生成されたインターフェースと自分で実装が必要なechoサーバーの両輪がそろって初めて動きます。
GitHub Actionでは、インターフェース部分は自動生成されます。しかし、まだechoサーバー側の修正(エンドポイント名を /items に変更する修正)はcommitしていませんでした。バグを抱えたまま、ビルドが通ってしまいましたが、integration testでコケて、バグを未然に防ぐことができた次第です。
もしもAPI仕様書とechoサーバーが連携されておらず、サーバーとOpenAPI仕様書をそれぞれ独自に手修正するといった場合、差異が仮に発生してもなかなか気づかないものです。API仕様書をまず書いて、それを元にソースコードジェネレーターで自動生成したスタブを実装するというコントラクトファーストアプローチならば、自動的に差異を検出することができます。
体験2: OpenAPI仕様書をチームで共有する
この章では、OpenAPI仕様書をApicurio Registryというデータスキーマに関する成果物管理ツールで共有したいと思います。
データスキーマとはデータレコードの構造と形式を決めるための仕様のことです。つまり、Apicurio Registryは仕様書は管理しますが、その仕様書に基づいて作成したデータそのもの、つまりバイナリファイル等は共有しません。そこが大きな違いです。
Apicurio RegistryはRed Hat Integrationの中の一つのOSS製品で、API仕様書やイベント・スキーマ(Apache Avro等)を、APIを使用するチームやサービス間で共有するためのデータスキーマストアです。
OSSなので、一般の開発者も利用することが可能です。今回はDockerコンテナとして起動してみたいと思います。
docker pull apicurio/apicurio-registry-mem docker run -it -d -p 8080:8080 apicurio/apicurio-registry-mem:latest
起動後、ブラウザでhttp://localhost:8080/ui/artifacts
にアクセスすることでApicurio Registryを利用することができます。
このまま、手動でAPI仕様書をアップロードすることもできますが、せっかくなので本番と同様にGitHub Actionを用いて、API仕様書を自動でApicurio Registryに更新したいと思います。
コンソール画面を開いて、 actを呼び出します。GitHub Actionの処理内容は .github/workflows/deploy-api-spec.yaml にありますので興味がある方は覗いてみてください。
act -j deploy
ブラウザで実行後、再度http://localhost:8080/ui/artifacts
にアクセスしてみます。
画面が変わりました。先ほどGitHub Actionにより、送信されたAPI仕様書が更新されたからです。右側にある”View artifact” ボタンを押して、真ん中のタブであるDocumentationを覗いてみましょう。わかりやすく可視化されたAPI仕様書を見たり、左上のボタンにあるように過去バージョンのAPIを参照することができます。
なぜApicurio Registryが必要なのでしょうか?
ちょっと詳しい方なら、API仕様書”だけ”ならばReDocで変換かけた後、AzureのAzure Static Web Appsあたりに社内の人しか参照できない認証付きページとして共有するといった方法をとられる方もいらっしゃると思います。
Apicurio Registryは残念ながらReDoc等の専用のビジュアライズツールと比較して、見た目の良さにはまだまだ足りません。
Apicurio Registryを含むApicurioの真価は、システム間で流れるデータ、特に構造や役割を仕様書のような形ではっきり可視化し、共同で管理することでシステム間インテグレーションにおけるシステム間の流れをシステムに関わるひとがわかるようにすることです。
ReDocなどのビジュアライズツールの強みは見た目の良さでしたが、APICURIOの強みはいまだ増え続けている、成果物管理ツールとしての対象データスキーマの豊富さにあります。
例えば、APICURIO Registryでは次のデータを取り扱うことができます。
Storage/management of several types of artifacts
The registry supports adding, removing, and updating the following types of artifacts: OpenAPI, AsyncAPI, GraphQL, Apache Avro, Google protocol buffers, JSON Schema, Kafka Connect schema, WSDL, XML Schema (XSD)
アーキテクチャを問わず、様々なデータスキーマに対応していることがわかります。インスタンスとしての保存するわけではなく、データレコードの構造と形式のみに特化していることがわかります。
APICURIOはシステム間を流れるデータを設計するために必要なドキュメントやソースコード、それらに特化した成果物管理ツールなのです。
✽ 小ネタ: APICURIOにはArtifactIDと呼ばれる成果物管理のために一意となるIDが必要なのですが、新規登録時に何も指定されないとAPICURIO側で採番されたUUID値になります。
例えばここで、APICURIOにID作成を任せずに明示的にGitのコミットハッシュ値を指定することで後からGit側においてもどのようにAPI仕様書が修正されたかトレースすることが可能です。
所感
今回は、コントラクトファーストアプローチにしたがった開発プロセスの具体的なやり方について、
- API仕様書のコードジェネレーターをCI/CDと組み合わせることで、設計と実装の漏れを防ぐ方法
- OpenAPI仕様書をApicurio Registryという成果物管理ツールに連携することでドキュメントを共有する仕組み
の二つについて手順に従って実践致しました。
昨今はシステム間連携の構築依頼や引き合いが増えておりますが、システムが二つ以上ある現場において、下記の理由からAPI仕様書やスキーマレジストリは常に重要なデータです。
- どんな認証(アクセス権限)が必要か
- スキーマ構造はどうなっているのか
- どのような手段で取得できるか
これらの管理を自動化して、次のビジネスの開発に生かすためにコントラクトファーストアプローチという開発プロセスの導入をご検討してみてはいかがでしょうか。
Red Hat Open Shipt Container PlatformはNissho-Naticのソリューションです。Nissho-Electronicsは、ビジネスに俊敏性と柔軟性を与えるコンテナ技術をベースに、お客様毎に異なるデジタル変革の行程をはじめる最初のステップである「OCPディスカバリーセッション」を提供します。(Red Hat OpenShift Container Platformについてはこちら)
記事担当者:アプリケーション企画開発部 原木
投稿日:2021/07/27