目的
openapi-generatorで生成した、chunked transfer encodingでJSON Linesのフォーマットで返却されるストリーミングAPIのGo/Python/TypeScriptのクライアントコードを管理するだけの新規のレポジトリを作成し、1レポジトリで管理してみました。その際に主に以下の点で考慮することがあったので、このブログで紹介できればなと思います。
- 1レポジトリで複数言語のパッケージを管理する方法
- ストリーミングAPIに適したクライアントコードを生成させる方法
- 生成したコードをリソースリークせずにアプリケーションで適切に利用する方法
それらは別のテーマなので本来は分けて説明した方が良いのかもしれませんが、それもそれでやや面倒でしたのでまとめて説明させてください。
なお、ここで記載している内容を包含する形で https://github.com/KeiichiHirobe/package-test にコードをあげているので、動くものを見る方が早い方はそちらを読んだ方が早いかもしれません。
1つのレポジトリで複数言語のパッケージを管理する
なぜしたいか
ローカルパッケージ、もしくはただのモジュールではダメなのかという観点では、もちろんそれでも平気なケースもありますし、そのように運用したこともあります。特にモノリス、モノレポであればそちらを検討する価値もあるでしょう。一方問題になりそうな部分を列挙します。
パッケージではなくてただのモジュールにするのは基本的には悪手だと思います。生成されたコードの依存関係は生成されたコード群で管理すべきでしょう。Pythonを例にすると、生成されたpyproject.tomlをメインのpyproject.tomlに取り込んでファイル自体を消すといった運用です。ただ、今のlatestのGoは実はgo.modの中身が空であり、依存がありません。そのような場合はアリかもしれませんが、将来、versionを上げた際に依存が追加されていてもおかしくはないので自己責任の範囲内でとなります。
ローカルパッケージはというと、そもそもやや扱いづらいという問題があります。例えばdockerで環境を作る際にmulti stagebuildで依存モジュールだけ先にインストールするのは一般的な方法だと思いますが、その際にレポジトリのローカルコードをコピーしておく必要があります。
別レポジトリに切り出さない場合、機能追加のPRに生成コードのdiffが混ざり込んで見づらいです。もちろんPRを分けることもできますが分けられないことが多くないですかね笑。
また別レポジトリに分けた場合、バージョン管理できるのは強みです。APIが互換性を維持している限りは最新の生成コードを常に利用していれば問題ないのですが、互換性のない変更ではバージョンを指定したくなるケースがあるかもしれません。また、API仕様をAPI実装の前に更新するのは一般的ですが、実装はデプロイされていないが最新の生成コードでは呼べてしまう、もしくはまだ削除されていないAPIであるが仕様からは消えてしまったなどの場合は最新のバージョン以外の生成コードを使いたいでしょう。
言語ごとに新しいレポジトリを立ててはダメなのか?という点で言えば、全然良いと思いますが、やや大袈裟に感じるのと、API Specとクライアント生成コードが1つのレポジトリになっている方が管理しやすいというメリットはあると思います。
どうするか
Python
前提として、私の環境ではPythonは主に利用されている言語ではなく、保守面を考慮して新しい構成要素を増やしたくないため、PyPIを使わず直接GitHubのレポジトリを指定させる運用にします。Pythonはパッケージ周りが煩雑で整理されていない印象をうけ、なおかつGitHub Packagesのサポートは当面実現されないなどの事情もあります。
Poetryでは、パッケージを使いたい側が以下のようにサブディレクトリを指定することができます。この方法では、提供側は何もしなくても問題ないです。
poetry add "git+https://github.com/KeiichiHirobe/package-test#subdirectory=python/gen"
Go
Goの場合はトップディレクトリにgo.mod/go.sumが存在していることを前提とします(厳密にはそうでなくても動きますが適切なバージョン管理ができないらしい)のでやや格好悪いですが生成後にトップディレクトリへ移動します。import時にサブディレクトリを指定すれば良いだけなのでモジュールのコードの配置場所は特に気にしなくてもかまいません。
mv ${repo_root}/go/gen/go.mod ${repo_root}/go.mod && \
mv ${repo_root}/go/gen/go.sum ${repo_root}/go.sum
TypeScript
GitHub Packagesを使います。package.jsonに以下のようにdirectoryを指定します。生成されたファイルに編集を加える必要がありますが、custom templateで編集するのが良いでしょう。custom templareteは次の章で説明します。
"repository": {
"type": "git",
"url": "https://github.com/KeiichiHirobe/package-test.git",
"directory": "typescript-fetch/gen"
},
ストリーミングAPIに適したクライアントコードを生成させる
経緯はここでは説明しませんが、クライアントコードがレスポンスを漸進的に処理する必要があり、ストリーミングAPIをサーバが提供することになりました。ここでは、chunked transfer encodingでJSON Linesのフォーマットで返却されるストリーミングAPIを想定したいと思います。イメージしやすいように動作検証で利用した最小限のサーバ実装を以下に貼っておきます。
サーバ実装の例
import asyncio
from aiohttp import web
import json
async def stream_response(request):
response = web.StreamResponse()
response.headers["Content-Type"] = "text/plain"
# send header
# you should not change any header data after calling this
await response.prepare(request)
for i in range(4):
chunk = f"Chunk {i}"
payload = {"body": chunk, "event": "message"}
text = json.dumps(payload) + "\n"
await response.write(text.encode("utf-8"))
await asyncio.sleep(1)
# send finish
payload = {"body": "finish!!!!", "event": "message"}
text = json.dumps(payload) + "\n"
await response.write(text.encode("utf-8"))
# eof
await response.write_eof()
return response
app = web.Application()
app.router.add_get("/", stream_response)
if __name__ == "__main__":
web.run_app(app, port=8888)
ストリーミングAPIのクライアント生成コードとして期待したいのはresponse bodyを処理せずにそのまま返却することです。生成コード側でreadすると漸進的にアプリケーションが処理できません。openapi-generatorで生成したところ、Pythonのみが期待した生成コードをはいてくれました。Go,TypeScriptでは一連の処理の中でresponse bodyをreadしてしまう関数のみ作成されました。
そもそもAPI specの記述を工夫をすることでストリーミングAPIであることをopenapi-generatorに意識させ、生成コードをコントロールできないかと思いましたが、確実な方法は存在しないようで、OpenAPI-Specificationのissueに議論されている良いspec例を真似しても解決しませんでした。
では、どうやって対応したかというと、templateファイルをcustomizeすることで解決しました。Go/TypeScript間で解決方法は全く一緒でしたので、ここではGoの生成コードのカスタマイズにフォーカスしたいと思います。
生成されたファイルを載せた方がわかりやすいと思うので、GitHubにもあるコードを載せてしまうと、以下が最終的な生成ファイルです。
カスタマイズを元に生成されたコード
/*
Test Streaming API
Test Streaming API
API version: 1.0.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package streamingapitest
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
)
// ChatAPIService ChatAPI service
type ChatAPIService service
type ApiTestStreamGetRequest struct {
ctx context.Context
ApiService *ChatAPIService
}
func (r ApiTestStreamGetRequest) Execute() (string, *http.Response, error) {
return r.ApiService.TestStreamGetExecute(r)
}
/*
TestStreamGet get streaming data
get streaming data
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@return ApiTestStreamGetRequest
*/
func (a *ChatAPIService) TestStreamGet(ctx context.Context) ApiTestStreamGetRequest {
return ApiTestStreamGetRequest{
ApiService: a,
ctx: ctx,
}
}
// Execute executes the request
// @return string
func (a *ChatAPIService) TestStreamGetExecute(r ApiTestStreamGetRequest) (string, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue string
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "ChatAPIService.TestStreamGet")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/test/stream"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"text/plain"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
// Execute executes the request
// @return string
// This function doesn't check the HTTP StatusCode, checking it is a caller's responsibility.
func (a *ChatAPIService) TestStreamGetExecuteWithoutPreloadContent(r ApiTestStreamGetRequest) (string, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue string
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "ChatAPIService.TestStreamGet")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/test/stream"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"text/plain"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
return localVarReturnValue, localVarHTTPResponse, nil
}
TestStreamGetExecute, TestStreamGetExecuteWithoutPreloadContentの2つの関数が定義してありますが、デフォルトではTestStreamGetExecuteのみ定義されていました。TestStreamGetExecuteWithoutPreloadContentはカスタマイズにより追加された関数です。両者の差は一目瞭然です。前者はbodyをreadし、その後もいくつかの処理を行ってますが、後者ではbodyをreadせずにすぐにreturnしています。そして、returnするまでのコードは全く一緒です。
templateファイルをcustomizeと聞くと一見複雑に聞こえるかもしれませんが、今回のようなシンプルな変更であればとても簡単に対応することができました。実際、私はドキュメントを全くといっていいほど読んでません笑(なので、より良い方法があるかもしれません)。
templateファイルはいくつかのファイル(Goのlatestで22個)から構成されており、上書きたいtemplateファイルのみを修正し、そのディレクトリを -tで指定するだけです。
より具体的には、Goのカスタマイズでは以下のような手順を踏みました。
1 Goのtemplateファイル群 からapi.mustacheを手元にダウンロード。この時、バージョン指定を忘れずに
2 TestStreamGetExecuteを生成したと思われる箇所をコピーして関数名のみ変えて関数を新規作成
3 responseをparseするコード以降を単純に削除。実際のcommitはこちら
4 ファイルが置かれたディレクトリを -t で指定
です。また、このカスタマイズを編集する機会は、openapi-generatorでコード生成するときにopenapi-generatorのバージョンを指定していれば、使用するopenapi-generatorのバージョンを何らかの理由により上げたい時だけです。API specの変更時はカスタマイズを意識する必要はありません。
クライアントアプリケーションで適切に生成コードを利用する方法
ストリーミングAPIに対応した生成コードをはけるようになったのはいいのですが、それを利用するクライアントアプリケーションコードの実装も注意する点があると思います。
一般的に、標準ライブラリやライブラリの実装として、response bodyをアプリケーションが全て読み終わったら、ライブラリ内部で、正常であればコネクションを再利用し、エラーが起きた場合はcloseするなどしていることが多いと思います。通常、これはクライアントのリクエスト処理の最初でのみ行われますが、ストリーミングAPIの場合は異なります。例えば、クライアントアプリケーションが最初のメッセージを正常に受け取った後、その処理で例外が発生して処理を終了したとします。このコネクションにはサーバがまだメッセージを送るかもしれません。この状況で実装によって、コネクションは a.利用中とみなされる b.closeされる c. コネクションプールに戻され、再利用される の状態になりえます。aはリソースがleakしていることを意味するので避けなければいけません。
各言語の標準ライブラリやライブラリの実装を実際に読んで、debuggerを走らせ理解したことを簡単にまとめ、さらにクライアントのサンプルコードをGitHubにおきましたので、以下興味ある方は参照ください。結果だけ簡潔に書いてしまうと、Goはお約束の defer r.Body.Close()をよんでいれば問題ありません。Python/TypeScriptでは標準ライブラリやライブラリが提供するAsync Iteratorを使っていれば問題ありません。
Makefile
最後にGo/Python/TypeScript全てのコードを生成するMakefileを貼って終わりにしたいと思います。
repo_root := $(shell git rev-parse --show-toplevel)
ver := v7.8.0
git_repo_id := package-test
git_user_id := KeiichiHirobe
# should be lower-case
npm_scope := keiichihirobe
all: go-gen python-gen typescript-fetch-gen
.PHONY: go-clean
go-clean:
rm -rf go/gen
.PHONY: go-gen
go-gen: go-clean
docker run --rm -v "${repo_root}:/local" openapitools/openapi-generator-cli:${ver} generate \
-i local/openapi-spec/chatbots_api.yml \
-g go \
-o local/go/gen \
-t local/go/custom_template \
--git-repo-id=${git_repo_id} I am running a few minutes late; my previous meeting is running over.
--git-user-id=${git_user_id} \
--global-property "modelTests=false,apiTests=false" \
--additional-properties packageName=streamingapitest && \
mv ${repo_root}/go/gen/go.mod ${repo_root}/go.mod && \
mv ${repo_root}/go/gen/go.sum ${repo_root}/go.sum
.PHONY: python-clean
python-clean:
rm -rf python/gen
.PHONY: python-gen
python-gen: python-clean
docker run --rm -v "${repo_root}:/local" openapitools/openapi-generator-cli:${ver} generate \
-i local/openapi-spec/chatbots_api.yml \
-g python \
-o local/python/gen \
--git-repo-id=${git_repo_id} \
--git-user-id=${git_user_id} \
--library asyncio \
--global-property "modelTests=false,apiTests=false" \
--additional-properties packageName=streamingapitest
.PHONY: typescript-fetch-clean
typescript-fetch-clean:
rm -rf typescript-fetch/gen
.PHONY: typescript-fetch-gen
typescript-fetch-gen: typescript-fetch-clean
docker run --rm -v "${repo_root}:/local" openapitools/openapi-generator-cli:${ver} generate \
-i local/openapi-spec/chatbots_api.yml \
-g typescript-fetch \
-o local/typescript-fetch/gen \
-t local/typescript-fetch/custom_template \
--git-repo-id=${git_repo_id} \
--git-user-id=${git_user_id} \
--additional-properties npmName=@${npm_scope}/streaming-api-test