こんにちは、プロダクト開発部コアグループの井上です。
コアグループでは、次世代ECの開発を行っています。
現在makeshopでは、決済画面をリニューアルするプロジェクトが進行しており、4月に第一弾がリリースがされました。 今回はその中でのOpenAPI(3.0)を活用した開発を紹介したいと思います。
概要
今回のプロジェクトでは、私の所属するチームはAPI部分を担当しています。 そのBFFではOpenAPIとコード生成によるスキーマ駆動開発を行っています。
- スキーマ定義: OpenAPI(3.0)
- コード生成: oapi-codegen
- フレームワーク: echo
コード生成
コード生成にはoapi-codegenを利用しています。 下記のコマンドをdockerコンテナ上で実行してSchemaの型とechoのinterfaceなどを生成しています。
oapi-codegen \ -package=frontapi \ -generate=types,server \ ./swagger_definition.yaml > ./generated/server.gen.go
生成されたinterfaceの実装をRegisterHandlersで登録することでリクエストが通るようになります。
func NewServer() *echo.Echo { e := echo.New() e.Use(middleware.RootMiddleware()...) apiGroup := e.Group("/api") apiGroup.Use(middleware.ApiMiddleware()...) apiHandler := handler.NewOpenApiHandler() frontapi.RegisterHandlers(apiGroup, apiHandler) return e }
packageの指定
package swaggerDefinition
デフォルトでは生成されるコードのpackageは定義ファイルの名前になってしまいますが、 -package
オプションを使用することで任意のpackage名を指定することができます。
生成対象の指定
-generate
オプションでは、生成する対象を指定することができます。 今回はサーバーサイドのみ必要だったためserver
, types
を指定しました。
server
はデフォルトでechoが利用されます。他にはchi-serverや、アーカイブから復活したgorillaが利用できます。
また、Go1.22以降であれば、新しく追加されたnet/http.ServeMuxが利用できるようです。
フィールドのオプション
OpenAPIでは拡張機能としてx-
から始まる独自のプロパティを記述することができ、追加機能を記述するために使用できます。
oapi-codegen
にも拡張機能のオプションがいくつか用意されています。
x-go-type
x-go-type
でGoの型を指定することができます。OpenAPIの仕様上integer
のフォーマットにはint32
, int64
しか用意されていませんが、こちらのオプションを使うことで、生成されるフィールドの型にuint
などを指定することができます。
Pet: type: object properties: int64Field: type: integer format: int64 uint64Field: type: integer x-go-type: uint64
また、README.mdにも記載がありますが、x-go-type-import
と合わせて利用することで、Go標準以外の型も指定することができます。
Pet: type: object properties: age: x-go-type: myuuid.UUID x-go-type-import: name: myuuid path: github.com/google/uuid
x-oapi-codegen-extra-tags
x-oapi-codegen-extra-tags
を利用することで、下記のように任意の構造体タグを追加することができます。
今回はバリデーションに使用しているgo-playground/validatorのタグを追加するために使用しました。
SenderAddressRequest: type: object properties: name: type: string description: 注文者名 x-oapi-codegen-extra-tags: validate: "required,max=20" required: - name
// SenderAddressRequest defines model for SenderAddressRequest. type SenderAddressRequest struct { // Name 注文者名 Name string `json:"name" validate:"required,max=20"` }
ドキュメント
OpenAPIのみならず、スキーマ駆動開発の良いところですが、スキーマ定義からドキュメントを自動生成できるエコシステムが揃っています。 今回はSwaggerUIをGithub Pagesにデプロイして、リポジトリを見れるメンバーであれば閲覧可能なドキュメント環境を構築しました。
また、servers
には複数のエンドポイントのベースURLを定義することができる為、Github Pages上のSwaggerUIから開発環境でのレスポンスを確認することもできるようにしました。
servers: - url: http://localhost:8080/api description: Local - url: https://<development-host>/api description: dev
今回のプロジェクトでは初めて今まで関わりのなかったチームとも開発をすることになったこともあり、ドキュメントの共有が今まで以上に重要でした。 makeshopではOpenAPIの私が知る限り使用実績はないため、多少のフォローが必要になる場面はありましたが、自動生成できる恩恵は大きかったです。
また、社内の別チームが実施する脆弱性診断実施のための資料・診断環境としても利用され、当初想定していなかった生産性向上の恩恵を受けることができました。
コード生成のカスタマイズ
今回、string
のフィールドはmakeshop内部で利用できる文字コードかのチェックをする必要がありました。
また、利用できない場合はどのフィールドにリクエストされたかをわかる形でエラーレスポンスを返却する必要がありました。
今回利用している、go-playground/validator
では以下のように独自のバリデーションを追加することができ、発生したエラーをフィールドごとに取得できます。
oapi-codegen
の生成したコードの構造体タグをカスタマイズできれば、今回やりたいことは実現できそうです。
package main import ( "context" "fmt" "unicode" "github.com/go-playground/validator" ) var v *validator.Validate func init() { validate := validator.New() if err := validate.RegisterValidation("katakana", validationCustomFuncKatakana); err != nil { panic(err) } v = validate } func validationCustomFuncKatakana(fl validator.FieldLevel) bool { if fl.Field().String() == "" { return true } for _, r := range fl.Field().String() { if !unicode.In(r, unicode.Katakana) { return false } } return true } func main() { type User struct { Name string `validate:"required,max=20"` NameKana string `validate:"required,max=20,katakana"` Age uint8 `validate:"required,max=130"` } user := User{ Name: "aaaaaaaaaaaaaaaaaaaaa", NameKana: "漢字", Age: 200, } if err := v.StructCtx(context.Background(),user); err != nil { fmt.Println(err) // Key: 'User.Name' Error:Field validation for 'Name' failed on the 'max' tag // Key: 'User.NameKana' Error:Field validation for 'NameKana' failed on the 'katakana' tag // Key: 'User.Age' Error:Field validation for 'Age' failed on the 'max' tag } }
コード生成のカスタマイズにあたって、
oapi-codegen
のカスタムテンプレートを利用する- 生成されたgoのコードをいじる
以上の2つの方法を検討しました。
1. カスタムテンプレートを利用する
-templates
オプションを利用することでカスタムテンプレートを渡すことができます。
こちらを利用することで実現できなくはなさそうでしたが、今回は採用しませんでした。
$ oapi-codegen \ -templates my-templates/ \ -generate types,client \ petstore-expanded.yaml
カスタムテンプレートについてはこちらの記事が参考になりました。 qiita.com
2. 生成されたGoのコードをいじる
今回はこちらを採用しました。
やりたいこととしては、特定のフィールドにタグを足すだけなので、
oapi-codegenの挙動自体をを変えるよりも、出来上がっているGoのコードをいじる方がoapi-codegenのアップデートにも左右されずシンプルになると考えました。
生成したgoファイルを静的解析してリクエストの構造体からstring
, *string
, []string
などのフィールドを(structなら再帰的に)拾ってvalidate
タグにcharset
を足して愚直に置換していきます。
func main() { serverGo, err := os.ReadFile("path/to/server.go") if err != nil { panic(err) } serverGo, _ = format.Source(serverGo) fset := token.NewFileSet() parsedFile, err := parser.ParseFile(fset, "", serverGo, parser.ParseComments) if err != nil { panic(err) } var genDecls []*goast.GenDecl typeMap := make(map[string]*goast.StructType) goast.Inspect(parsedFile, func(node goast.Node) bool { switch decl := node.(type) { case *goast.GenDecl: genDecls = append(genDecls, decl) } return true }) goast.Inspect(parsedFile, func(node goast.Node) bool { switch t := node.(type) { case *goast.TypeSpec: if s, ok := t.Type.(*goast.StructType); ok { if strings.HasSuffix(t.Name.Name, "Request") { typeMap[t.Name.Name] = s for name, st := range getStructFields(s, genDecls) { typeMap[name] = st } } } } return true }) for structName, s := range typeMap { //... } } func getStructFields(s *goast.StructType, genDecls []*goast.GenDecl) map[string]*goast.StructType { result := make(map[string]*goast.StructType) for _, f := range s.Fields.List { switch ft := f.Type.(type) { case *goast.Ident: if ft.Obj == nil { continue } if ts, ok := ft.Obj.Decl.(*goast.TypeSpec); ok { if structType, ok := ts.Type.(*goast.StructType); ok { result[ts.Name.Name] = structType for name, st := range getStructFields(structType, genDecls) { result[name] = st } } } case *goast.StarExpr: if ident, ok := ft.X.(*goast.Ident); ok && ident.Obj != nil { if ts, ok := ident.Obj.Decl.(*goast.TypeSpec); ok { if structType, ok := ts.Type.(*goast.StructType); ok { result[ts.Name.Name] = structType for name, st := range getStructFields(structType, genDecls) { result[name] = st } } } } case *goast.ArrayType: if ident, ok := ft.Elt.(*goast.Ident); ok && ident.Obj != nil { for _, decl := range genDecls { for _, spec := range decl.Specs { if typeSpec, ok := spec.(*goast.TypeSpec); ok { if typeSpec.Name.Name == ident.Name { if structType, ok := typeSpec.Type.(*goast.StructType); ok { result[ident.Name] = structType for name, st := range getStructFields(structType, genDecls) { result[name] = st } } break } } } } } } } return result }
ハマったところ
若干ハマったところを紹介します。
下記のようにvalidate
タグがすでにある場合はその最後にタグを追加する必要があった為、
reflect.StructTag.Lookup()で値を取得しようとしましたが、思った通りに取得できませんでした。
type PetRequest struct { Field1 string `json:"field1" validate:"required,max=20"` } // ↓ type PetRequest struct { Field1 string `json:"field1" validate:"required,max=20,charset"` }
func getTargetFields(s *goast.StructType) []*stringField { var fields []*stringField for _, f := range s.Fields.List { switch ft := f.Type.(type) { case *goast.Ident: if ft.Name == "string" { fields = append(fields, &stringField{ Tag: reflect.StructTag(f.Tag.Value), Pos: f.Pos(), End: f.End(), }) } } // fields[0].Tag.Lookup("validate") // "", false return fields }
解決策としては、(*ast.Field).Tag.Value
の値は「`」で囲まれた文字列なので、
reflect.StructTagにキャストする際に除去しておく必要があるという話でした。
`json:"field1" validate:"required,max=20"`
fields = append(fields, &stringField{ Tag: reflect.StructTag(strings.Trim(f.Tag.Value, "`")), Pos: f.Pos(), End: f.End(), })
最後に、oapi-codegenでのコード生成ととvalidateタグ追加のgo実行を1つのスクリプトにまとめて完成です。
無事に文字コードチェックのバリデーションが追加されるようになりました。
type SenderAddressRequest struct { // Name 注文者名 Name string `json:"name" validate:"required,max=20,charset"` }
画面には下記のようなレスポンスを返すことができるようになりました。
{ "code": "missing_or_invalid_parameter_value", "fields": [ { "code": "common.max_len_error", "field": "name", "message": "error: field validation for 'name' failed on the 'max'", "value": "20" }, { "code": "common.invalid_charset", "field": "nameKana", "message": "error: field validation for 'nameKana' failed on the 'charset'" } ] }
まとめ
OpenAPIを採用したことで、新決済画面のAPI開発においても効率的な開発とドキュメント共有を実現できましたが、以下の課題も浮き彫りになりました。
pathを考えるのがしんどい
OpenAPIの問題ではないですが、URL設計に非常に苦労しました。長時間悩んでしまったり、他メンバーへのレビュー時も、これは明らかに違うけど代替案が思い浮かばない... という場面が多々ありました。
namespace, packageに相当するものがない
OpenAPIにはnamespace, packageに相当するものがないため、規模が大きくなることが想定される場合はOperationIdやSchema名はこれを意識した設計が必要だったなと反省しています。
開発当初のswaggerは1,000行程度でしたが5月時点では4,000行を超え、今後もさらに増えていく予定です。
AzureのRESTApiのスキーマ定義
ではApiVersion_
, Environment_
のような機能ごと(?)のプレフィックスがつけられており、今後これを参考に整理していきたいと考えています。
他にもまだまだ課題はありますが、より快適なお買い物体験を実現できるよう、OpenAPIのメリットを最大限に活かし、より効率的なAPI開発を目指していきます。