こんにちは、極予測やりとりAI というプロダクトの開発責任者をしている しゅん(@MxShun)です。
この記事では、疎結合アーキテクチャにおけるプロデューサ側でリクエストをバリデーションするという考え方と、ogen-go/ogen を利用した具体的な実装方法について詳しく説明します。
目次
疎結合アーキテクチャ
AWS Well-Architected Framework の「信頼性」を実現する手段の一つに 疎結合アーキテクチャ(以下、疎結合)があります。疎結合はコンポーネント間の結合度を下げることで、単一障害点をなくした可用性向上やスケーラビリティ向上による信頼性を実現します。そして、AWS では疎結合を可能にするサービスとして Elastic Load Balancing や Amazon SQS(以下、SQS)などのサービスを展開しています。
今回のワークロード
複数システムがバケツリレーでデータを渡し合うワークロードがあり、SQS を介したプロデューサ/コンシューマパターンでの疎結合を採用しました。
そのなかで私が開発責任者をしている「極予測やりとりAI」は、データの起点となるプロデューサの役割を担います。
疎結合における難点
さて、上の図を見て「なぜ Amazon API Gateway が入っているのだろう?」と思われた方、鋭いです。
一般的にプロデューサが直接 SQS にエンキューする、もしくは ファンアウト を考慮して Amazon SNS トピックをサブスクライブするのがよくあるパターンだと思います。そして、Amazon API Gateway(以下、API Gateway)を入れた背景に疎結合における難点があります。
それはズバリ、データが一方通行 であるという点です。
コンシューマからプロデューサへ伸びる線がないため、リクエスト(プロデューサがコンシューマに渡すデータ)に不正があるような場合もコンシューマ側で何かしら対処する必要があります。
そこで、リクエストバリデーションを担う AWS Lambda を挟められるよう API Gateway を入れることにしました。
とは言え、今回のワークロードでデータを渡し合うのはいずれも社内システムです。AWS Lambda を挟められる余地は残しつつ、プロデューサ側が正しいリクエストを渡すだろうという信頼ベースでのデータの渡し合いを原則としました。
OpenAPI によるシステム間インタフェース定義
データの起点である極予測やりとりAIは 正しいリクエストを渡すことが重要 になります。
そこでまず準備したのが OpenAPI によるインタフェース定義です。
SQS を介したプロデューサ/コンシューマパターンの場合はチャネル・メッセージベースの AsyncAPI を利用することが多いでしょう。しかし、前段に API Gateway があるためパス・オペレーションペースの OpenAPI を利用することにしました。プロデューサ/コンシューマパターンでありながら、慣れ親しんだ REST API のように振る舞いを定義できる訳です。
また、前述の通りバケツリレーでデータを渡し合うことから、システム間のリクエストは共通したプロパティが多いです。そこで、プロパティ定義郡を外出ししてシステム毎のインタフェース定義を 1 つのリポジトリで管理する方式をとりました。
.
├── properties
│ ├── kiwami-yaritori.yaml # 極予測やりとりAIが出すプロパティ定義郡
│ └── system-a.yaml # 社内システムAが出すプロパティ定義郡
├── system-a
│ └── openapi.yaml # 社内システムAのインタフェース定義
└── system-b
└── openapi.yaml # 社内システムBのインタフェース定義
system-a/openapi.yaml
では、properties/kiwami-yaritori.yaml
のプロパティ定義を使ってインタフェースを定義します。
openapi: "3.0.0"
info:
version: 1.0.0
title: Swagger Petstore
paths:
/pets:
post:
summary: Create a pet
operationId: createPets
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
required: true
responses:
"200":
description: OK
# API Gateway と SQS が返すであろうエラー
components:
schemas:
Pet:
type: object
required:
- id
- name
properties:
id:
$ref: "../properties/kiwami-yaritori.yaml#/id" # 極予測やりとりAIが出すプロパティ
name:
$ref: "../properties/kiwami-yaritori.yaml#/name" # 極予測やりとりAIが出すプロパティ
同様に system-b/openapi.yaml
では、properties/kiwami-yaritori.yaml
と properties/system-a.yaml
のプロパティ定義を使ってインタフェースを定義します。
# system-a/openapi.yaml に同上
components:
schemas:
Pet:
type: object
required:
- id
- name
properties:
id:
$ref: "../properties/kiwami-yaritori.yaml#/id" # 極予測やりとりAIが出すプロパティ
name:
$ref: "../properties/kiwami-yaritori.yaml#/name" # 極予測やりとりAIが出すプロパティ
tag:
$ref: "../properties/system-a.yaml#/tag" # 社内システムAが出すプロパティ
ogen-go/ogen によるプロデューササイドリクエストバリデーション
OpenAPI でインタフェースを定義したことで、副次的に OpenAPI の豊富な資産を利用できるようになりました。
そこで利用したのが ogen-go/ogen(以下、ogen)によるスキーマとリクエストバリデーションの生成です。OpenAPI からスキーマを生成するツールはいくつもありますが、OpenAPI 3 に対応しプロデューサ(クライアント)サイドリクエストバリデーションを生成できるのは ogen くらいでした(執筆時点)。
先の system-b/openapi.yaml
からプロデューササイドコードを生成してみましょう。
generator:
features:
enable:
- 'client/request/validation'
disable_all: true
設定ファイルを example を参考に用意しておきます。
.
|--oas_cfg_gen.go # 空の設定ファイル
|--oas_faker_gen.go # ダミーデータを作る faker
|--oas_json_gen.go # JSON エンコーダー/デコーダー
|--oas_schemas_gen.go # インタフェース定義されたスキーマ
|--oas_validators_gen.go # リクエストバリデーター
このようなコード郡が生成されました、
// Code generated by ogen, DO NOT EDIT.
package ogen
// CreatePetsOK is response for CreatePets operation.
type CreatePetsOK struct{}
// NewOptString returns new OptString with value set to v.
func NewOptString(v string) OptString {
return OptString{
Value: v,
Set: true,
}
}
// OptString is optional string.
type OptString struct {
Value string
Set bool
}
// IsSet returns true if OptString was set.
func (o OptString) IsSet() bool { return o.Set }
// Reset unsets value.
func (o *OptString) Reset() {
var v string
o.Value = v
o.Set = false
}
// SetTo sets value to v.
func (o *OptString) SetTo(v string) {
o.Set = true
o.Value = v
}
// Get returns value and boolean that denotes whether value was set.
func (o OptString) Get() (v string, ok bool) {
if !o.Set {
return v, false
}
return o.Value, true
}
// Or returns value if set, or given parameter if does not.
func (o OptString) Or(d string) string {
if v, ok := o.Get(); ok {
return v
}
return d
}
// Ref: #/components/schemas/Pet
type Pet struct {
ID int64 `json:"id"`
Name string `json:"name"`
Tag OptString `json:"tag"`
}
// GetID returns the value of ID.
func (s *Pet) GetID() int64 {
return s.ID
}
// GetName returns the value of Name.
func (s *Pet) GetName() string {
return s.Name
}
// GetTag returns the value of Tag.
func (s *Pet) GetTag() OptString {
return s.Tag
}
// SetID sets the value of ID.
func (s *Pet) SetID(val int64) {
s.ID = val
}
// SetName sets the value of Name.
func (s *Pet) SetName(val string) {
s.Name = val
}
// SetTag sets the value of Tag.
func (s *Pet) SetTag(val OptString) {
s.Tag = val
}
oas_schemas_gen.go
にスキーマが生成されます。
特筆すべきは任意プロパティ tag
の型が OptString
であるという点です。ogen では任意・nullable なプロパティをポインタではなく、いわゆる Optional type でラップしてくれます。この Optional type が非常に使いやすく、他のツールと比較しても優位な点だと思います。
では、oas_validators_gen.go
はどうでしょう。
name:
type: string
minLength: 3
maxLength: 20
プロパティ name
に 3 文字以上 20 文字以下のデータ制限を付けてみます。
// Code generated by ogen, DO NOT EDIT.
package ogen
import (
"github.com/go-faster/errors"
"github.com/ogen-go/ogen/validate"
)
func (s *Pet) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if err := (validate.String{
MinLength: 3,
MinLengthSet: true,
MaxLength: 20,
MaxLengthSet: true,
Email: false,
Hostname: false,
Regex: nil,
}).Validate(string(s.Name)); err != nil {
return errors.Wrap(err, "string")
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "name",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
このように、データ制限をバリデーションする Validate
メソッドが生成されました。ogen/validate/string.go から分かる通り、メールフォーマットや正規表現による制限にも対応しています。
type Pet struct {
ID int
Name string
Tag *string
}
func PostPet(p Pet) error {
// 極予測やりとりAIの Pet モデルを ogen で生成された Pet スキーマに変換
r := ogen.Pet{
ID: int64(p.ID),
Name: p.Name,
Tag: toOptString(p.Tag),
}
// プロデューササイドリクエストバリデーション
if err := r.Validate(); err != nil {
return err
}
// リクエストを JSON エンコードして渡す処理
}
func toOptString(s *string) ogen.OptString {
if s == nil {
return ogen.OptString{
Value: "",
Set: false,
}
}
return ogen.NewOptString(*s)
}
Validate
メソッドを利用することで、リクエストをコンシューマに渡す前にプロデューサ側でバリデーションできます。これにより、データの起点として正しいリクエストを渡すことを担保できました。しかも、スキーマ駆動 で。
まとめ
プロデューサ/コンシューマパターンにおけるプロデューサ側でリクエストをバリデーションするという考え方を紹介しました。
データ設計の段階で不正なデータが入らないよう制限するのがベストです。プロデューササイドリクエストバリデーションはあくまで保険であり、一長一短があることを理解のうえワークロードに応じて利用を選択するのがよいと思います。
加えて、疎結合アーキテクチャにおけるプロデューササイドリクエストバリデーションするコード生成ツールとして、ogen-go/ogen を紹介しました。
今回は触れませんでしたが、HTTP サーバ・クライアントコードや OpenTelemetry トレーシング・メトリクスにも対応しています。2024年3月に v1 になったばかりではありますが、今後の発展に期待です。