こんにちは、極予測やりとりAI というプロダクトの開発責任者をしている しゅん(@MxShun)です。

この記事では、疎結合アーキテクチャにおけるプロデューサ側でリクエストをバリデーションするという考え方と、ogen-go/ogen を利用した具体的な実装方法について詳しく説明します。

目次

疎結合アーキテクチャ

AWS Well-Architected Framework の「信頼性」を実現する手段の一つに 疎結合アーキテクチャ(以下、疎結合)があります。疎結合はコンポーネント間の結合度を下げることで、単一障害点をなくした可用性向上やスケーラビリティ向上による信頼性を実現します。そして、AWS では疎結合を可能にするサービスとして Elastic Load Balancing や Amazon SQS(以下、SQS)などのサービスを展開しています。

今回のワークロード

複数システムがバケツリレーでデータを渡し合うワークロードがあり、SQS を介したプロデューサ/コンシューマパターンでの疎結合を採用しました。

arch

そのなかで私が開発責任者をしている「極予測やりとりAI」は、データの起点となるプロデューサの役割を担います。

疎結合における難点

さて、上の図を見て「なぜ Amazon API Gateway が入っているのだろう?」と思われた方、鋭いです。
一般的にプロデューサが直接 SQS にエンキューする、もしくは ファンアウト を考慮して Amazon SNS トピックをサブスクライブするのがよくあるパターンだと思います。そして、Amazon API Gateway(以下、API Gateway)を入れた背景に疎結合における難点があります。

それはズバリ、データが一方通行 であるという点です。

arch-without-apigetwway

コンシューマからプロデューサへ伸びる線がないため、リクエスト(プロデューサがコンシューマに渡すデータ)に不正があるような場合もコンシューマ側で何かしら対処する必要があります。

そこで、リクエストバリデーションを担う 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.yamlproperties/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 になったばかりではありますが、今後の発展に期待です。