こんにちは、HRBrainでバックエンドエンジニアをしている鈴木(善)です。
今回は、OpenAPIのスキーマからGoのコードを生成する際に、任意の型を指定できるよう拡張した話を紹介したいと思います。
なお、こちらは HRBrain Advent Calendar 2020 18日目の記事です。
拡張を必要とした背景
弊社では、現在スキーマ駆動での開発を進めています。まずスキーマを定義し、そこからインターフェースとなるコードを自動生成するアプローチです。
私の担当するサービスでは、RESTfulなAPIではあるもののスキーマ駆動にはできていなかったため、ここを改善していくこととしました。
(歴史の長いサービスなどでは、あとからスキーマ駆動に対応させる今回のようなケースはあるかなと思います)
スキーマの仕様は OpenAPI Specification(以下OpenAPI)を使い、サーバー(Go製)向けのコードジェネレーターについては oapi-codegen を採用しました。 github.com
詳細は割愛しますが、このジェネレーターを採用したポイントとしては:
- chi 向けのコードが生成でき、実装済みの既存のchiのルーティングと共存できたこと。これにより既存のものを全置き換えしなくても、部分的なところからスキーマ駆動の開発に切り替られる。
- Go向けのジェネレータということもあり、Goで実装しやすいインターフェースや型のコードが生成される。
- 生成されるコードが比較的薄く把握しやすい。
などがありました。
今回検討したサービスでは、IDを表す型としてuint64
を使っていました。ただ、OpenAPIのData Typesではこれは仕様の範囲外です。そのため、oapi-codegenでもuint64
型でコードを生成することはできません。
この問題の対応としては、「性能面も考慮しつつstring⇔uint64で変換する」や「桁あふれのリスクを考慮しつつint64を使う」なども検討しましたが、「素直にuint64でコード生成できれば余計なこと考えなくていいよね」ということで、その可能性を探っていきました。
(個人的には、自分たちが本当に嬉しいこと(楽になること)を考え、追求していける雰囲気や文化があるのは、弊社のよいところだなぁと感じています)
どのように拡張したか
OpenAPIにあるSpecification Extensionsという仕様を利用しました。x-
から始まる独自のフィールドを追加できる拡張用の仕様です。
oapi-codegenにPRを出し、x-go-type
というフィールドに型名を指定しておくと、oapi-codegenがその型名でコードを生成するようにしました。
github.com
OpenAPIの拡張仕様に則っているため、OpenAPIのlinterからも怒られることもありません。
どのように使うのか
基本編
実際に、IDを示すフィールドにuint64
型を指定したものがこちらです。
openapi: 3.0.0 # ...(略)... components: schemas: News: title: お知らせ type: object properties: id: type: integer x-go-type: uint64 # ←ここに型名を指定 #...(略)...
oapi-codegenで生成すると次のようなGoのコードが生成されます。
//...(略)... // News defines model for News. type News struct { Id uint64 `json:"id"` // ←指定したuint64型で自動生成されている Text string `json:"text"` }
さらに、これをコンポーネントとしてスキーマ内で切り出しておくと、x-go-type
の記述は1箇所に留められるため、拡張の記述が散らばることも避けられます。
下記の例ではID
というコンポーネントに切り出しています。
openapi: 3.0.0 # ...(略)... components: schemas: ID: type: integer title: ID x-go-type: uint64 #←拡張についてはここ1箇所に記述 News: title: お知らせ type: object properties: id: $ref: '#/components/schemas/ID' #←他からはIDを参照するだけ text: type: string # ...(略)... parameters: NewsIDPathParam: in: path name: newsID required: true schema: $ref: '#/components/schemas/ID' #←他からはIDを参照するだけ
切り出したID
は Defined Typeとして生成されます。
// ...(略)... // ID defines model for ID. type ID uint64 // News defines model for News. type News struct { Id ID `json:"id"` // ←ここはuint64ではなくID型となる Text string `json:"text"` } // ...(略)...
応用編
json.Marshaler、json.Unmarshalerを実装した独自の型を指定することも可能です。 (ただし制約として、独自に定義する構造体は自動生成されるコードと同じにパッケージ入れておく必要があります)
components: schemas: News: title: お知らせ type: object properties: id: $ref: '#/components/schemas/ID' text: type: string custom_date: type: string x-go-type: CustomDate #←日時を表す独自の型をスキーマとして定義
独自の構造体の例:
package api import ( "encoding/json" "time" ) type CustomDate struct { time time.Time } func (d *CustomDate) UnmarshalJSON(b []byte) error { // your unmarshal logic... d.time = time.Now() return nil } func (d CustomDate) MarshalJSON() ([]byte, error) { // your marshal logic... return json.Marshal(d.time.Format(time.ANSIC)) }
連携先の都合など個別の事情で、独自フォーマットを受け付けないといけないような場合でも、スキーマ駆動にしつつ対応することができます。
まとめ
OpenAPIのスキーマ定義に型を指定できるようになったことで、既存のコードベースを大改修することなく、緩やかにOpenAPIベースのスキーマ駆動開発に移行していけるようになりました。スキーマ駆動開発を検討されている方の参考になれば幸いです!