Go APIの設計
最近になってGo言語を始めた。
先日Udemyのセールがあったのがきっかけで、セール自体は逃したもののウェルカムバックセールなるものでGo言語のコースを購入した。 ビデオの視聴はこれからぼちぼち進めていきたいとは思う。 書籍を購入したり、ビデオを購入するというきっかけが何かを始めなければというモチベーションにつながるのだ。
わざわざビデオを視聴しなくてもChatGPTと壁打ちしたり、ネットで都度調べているだけでもそれっぽいものが作れてしまうのがこの言語の手っ取り早くてよいところだと思う。
ところでGo言語というものはRailsに比べると、あれこれ思考して変更するというものには向いていないと思う。
というのもこの言語は静的型付け言語とか単体テストを書いてエラーが表示されるようになると莫大なコードの山に変更そのものに対して圧倒されがちに感じた。 普段書いているRubyという言語はやはり簡潔な分、あれこれ直すのに向いている。 これは本当に慣れの問題かもしれないのだが、世間のプログラマたちが絶賛するほど私はまだ型の恩恵を理解しきれていない。
前置きが長くなってしまったが、要はGoとRailsを同時並行で書くようになったので 「お互いのクライアントのバージョン管理をしっかりしましょう」 というのが今回の話題である。
Goの代わりにNode.jsで書いている頃は特にそんなことをしようとは思わなかったので、やはりGoの変更にはまだコストがいると感じている。 Node.jsの頃であれば実装そのものはすぐに終わっていた。
今みたいにテストも書こうとしないし、雑に書きなぐったスクリプトを組み合わせるだけだ。 エラーが起これば都度直せばよい。
Goの開発体験もChatGPTのおかげで悪くはないのだが、それでも開発の速度は比べるとどうしても遅くなる気がする。
今回バージョン管理が必要になった背景として最初は単一のエンドポイントに対してコールバックURLを送信して、結果を受け取るというようなシンプルなWebhookのようなものを作っていた。
当然単一のエンドポイントだけではすぐにもの足りなくなる。
ChatGPTの提案を受けてjob_type
を追加して、実行できるジョブの内容を書き換えられるようにしたかったのである。
つまりjob_type
を指定しなければエラーになってしまうという致命的な問題が発生してしまった。
スモールスタートで開始できるのはこの言語の魅力ではあるのだが、スモールすぎる設計だとすぐに壁にぶち当たってしまうようだ。
といってもこの問題自体はそこまで複雑ではない。
例えばGoのhttp
パッケージでRailsにアクセスするときにヘッダーを付与するだけである。
var version = "dev"
req, err := http.NewRequest(http.MethodPost, callbackURL,
bytes.NewReader(payload))
if err != nil {
log.Printf("[CALLBACK] request creation failed: %v", err)
return 0
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Ocelot/"+version)
resp, err := http.DefaultClient.Do(req)
製品トークンは Firefox/4.0.1 のように、名称、スラッシュ ‘/’、バージョン番号で構成されます。ユーザーエージェントは好きなだけこれを入れることができます。
したがってこのGoのAPIがRailsのサーバーへリクエストを送信する際はOcelot/dev
が送信されるようになる。
さすがに手動でこのversion
を書き換えるのは苦痛を伴うのでビルド時に任意の文字列を渡せるようにする:
go build -ldflags "-X main.version=1.0.0" -o dist ./...
私はさらにGitLab CIを使っているのでGitタグから取得できるようにする:
compile:
stage: build
script:
- mkdir -p dist
- VERSION=$(git describe --tags --abbrev=0)
- go build -ldflags "-X main.version=${VERSION}" -o dist ./...
artifacts:
paths:
- dist
これでGoのクライアントにバージョンという概念が付与できるようになった。
Rails側ではこのように記述するとよいだろう。
v1
以前:
class SecureController < ApplicationController
before_action :check_ocelot_header
private
def check_ocelot_header
user_agent = request.headers['User-Agent']
unless user_agent == "Ocelot"
render json: { error: 'Unauthorized client' }, status: :unauthorized
end
end
end
v1
以降:
class V1::SecureController < ApplicationController
before_action :check_ocelot_header
private
def check_ocelot_header
user_agent = request.headers['User-Agent']
unless user_agent&.start_with?('Ocelot/1.')
render json: { error: 'Unauthorized client' }, status: :unauthorized
end
end
end
あくまでこれが適用されるのは既に本番サーバーにアプリケーションがデプロイされていて、APIに破壊的変更が含まれる時に限るべきだろう。
Rails側もクライアントにあわせてAPIバージョニングを行えば万全だろう。 正直なところ個人開発でAPIバージョニングはやりすぎな気がしなくもないのだが、本番を想定した運用法を実践する経験しておくのも悪くはない。
Rails側からGoに対する記述などの諸々は省くけれども、あらためて全く違う言語どうしでAPIを設計するということそのものは楽しい。
あれから無事切り替えは成功した。
順番的にはRails側へ新たに/v1
というパスを開放→破壊的変更を含むGoをリリース→もとのAPIを閉じるといった具合である。
本当はフィーチャーフラグなどを使ったり、ブルーグリーンデプロイを試してみてもよかったが後の祭りである。 APIの切り替えといってやや大げさに表現しているが、Kamalを使っているのでそもそもデプロイ作業もそこまで大した労力ではなかった。 やはり必要以上に複雑な構成にしすぎないことが最も肝要であると思う。
ちなみにAPIは削除してもよかったのだがこのようにしている:
class SecureController < ApplicationController
def callback
head :gone
end
end
普段使わないHTTPヘッダーであるが、こういう時に使いたいものである。
HTTPヘッダーといえばティーポットも使ってみたいのだが、さすがにそちらはイースターエッグ的な使い方以外思いつかないので難しいと思う。(そもそも非公開だし)