every Tech Blog

株式会社エブリーのTech Blogです。

ネットスーパーリプレイス〜長大なクエリと向きあう編〜

この記事は every Tech Blog Advent Calendar 2024(夏) 5 日目の記事です。

はじめに

こんにちは、TIMELINE 開発部 Service Development をしているほんだです!
初の Go Conference オフライン参戦なので浮かれてる今日この頃です。

今回はスマホ向けネットスーパーアプリの API を Python から Go へ移行する際のデータベース操作の観点での課題と実際にどのような解決策を取ったのか実装をメインに紹介します。
ネットスーパーアプリのリプレイスを行うことにした背景やシステム全体の課題、解決策に関しては前回のブログに記述しているので是非ご一読ください。tech.every.tv

技術スタック

以下は今回の記事に関係のあるリプレイス前後の技術スタックになります。

言語 DB ORM
リプレイス前 Python MySQL PyMySQL
リプレイス後 Go MySQL sqlboiler + sqlx

課題

リプレイスを行うにあたりデータベース操作の観点で以下の 4 点の課題がありました。

テストの不在

既存の実装にテストがないため、リプレイス後のコードが正しく機能するかを検証する手段が限られています。これにより、修正後のコードが期待通りの動作をするかの判断が困難です。

長大な SQL の扱い

200 行を超える長大な SQL クエリを sqlboiler で書き換えることは非常に困難です。これは、sqlboiler が主に CRUD 操作に最適化されており、複雑なクエリの扱いには向いていないためです。

名前付きプレースホルダーの問題

元のクエリでは以下の例のように名前付きプレースホルダー(%(format)s)が多用されていますが、sqlboiler はこの機能をサポートしていません。これにより、プレースホルダー(MySQL では?)で実装されたクエリでは、クエリが長くなるほど可読性と保守性が損なわれます。

WHERE item.item_name like %(search_word)s
    OR item.item_area like %(search_word)s
    OR item.item_spec like %(search_word)s
    OR event_item.event_item_name like %(search_word)s
    OR event_item.event_item_area like %(search_word)s
    OR event_item.event_item_spec like %(search_word)s

型の厳格化

既存の Python 実装ではレスポンスが dict 型で返されるため、柔軟なデータ構造を扱うことができます。しかし、sqlboiler でデータベース操作を行うとレスポンスは tag を元に構造体にバインドされるため厳格な型定義が必要となり、これがリプレイスの際の追加の課題となります。

実装

先に挙げた課題点に対処するため、以下の実装方針を採用しました。

  • 長大なクエリの移行: 長大なクエリは、可能な限りそのまま Go に移行します。これにより、既存のクエリロジックを保持し、移行に伴うリスクを最小限に抑えることができます。
  • 名前付きプレースホルダーの使用: sqlx を使用して、名前付きプレースホルダーを実装します。これにより、クエリの可読性と保守性を向上させることができます。
  • 汎用的な実行関数の作成: 生の SQL クエリを実行し、結果を Go の構造体にバインドする汎用的な関数を作成します。このアプローチにより、異なるタイプのクエリに対しても柔軟に対応することが可能になります。

クエリの移行について

「長大なクエリは可能な限りそのまま Go に移行する」という方針に基づき、sqlboiler で移行可能なクエリと生クエリを明確に区別するために、次のようなディレクトリ構成を採用しました。

repository/
├── models/
│   ├── item.go
│   ├── favorite.go
│   ├── menu.go
│   └── user.go
├── rawquery/
│   ├── util.go
│   ├── item_builder.go
│   └── menu_builder.go
├── item.go
├── favorite.go
├── menu.go
└── user.go

repository ディレクトリ直下には、sqlboiler を用いて移行されたクエリの実装があります。一方で、repository/rawquery ディレクトリには、生クエリを直接扱う実装を配置しています。これらの生クエリは、sqlboiler の Raw 関数をラップしたユーティリティ関数を介して、repository 直下のファイルから呼び出されます。repository/models ディレクトリには、クエリ実行時に結果をバインドするための構造体が定義されています。

この構成により、クエリの種類ごとに責務を分離し、コードの整理と保守性の向上を図っています。

名前付きプレースホルダーを sqlx で実装

次に、名前付きプレースホルダーの実装について説明します。既存の Python 実装では pymysql を使用し、%(format)s形式で名前付きプレースホルダーを実装していました。しかし、sqlboiler にはこの機能がないため、sqlx を採用しました。
名前付きプレースホルダーを使用することで、長大なクエリにおける多数の引数や重複する引数の取り扱いが容易になります。ここでは、名前付きプレースホルダーを含む生クエリ、引数の実装、およびそれらをバインドする関数の実装について順を追って説明します。

以下は、repository/rawquery にある名前付きプレースホルダーに渡される引数をフィールドに持つ構造体、初期化関数、名前付きプレースホルダーを含む生クエリを返すメソッド、および引数を返すメソッドの実装例です。

// repository/rawquery/item_builder.go

package rawquery

type ItemBuilder struct {
    price   int
    janCode string
    tax int
}

func NewItemBuilder(name string, price int, janCode string, tax int) *ItemBuilder {
    return &ItemBuilder{
        price:   price,
        janCode: janCode,
        tax: tax,
    }
}

func (b *ItemBuilder) BuildQueryWithArgs() (ReBindedQueryArgs, error) {
    return buildQueryWithArgsDefault(b.rawQuery(), b.args())
}

func (b *ItemBuilder) rawQuery() string {
    q := `
  SELECT
      name,item_code,price,jan_code,tax_rate
  FROM
      item
  WHERE
        price > :price
        AND
        jan_code = :jan_code`

    if b.tax != nil {
        q += " AND tax_rate = :tax_rate"
    }

    return q
}

func (b *ItemBuilder) args() map[string]interface{} {
    args := map[string]interface{}{
        "price":    b.price,
        "jan_code": b.janCode,
    }
    if b.tax != nil {
        args["tax_rate"] = *b.tax
    }

    return args
}

ItemBuilder構造体は、クエリに必要な引数を保持します。BuildQueryWithArgsメソッドを呼び出すと、sqlx を使用して名前付きプレースホルダーが含まれる生クエリのプレースホルダーを適切な形式に置き換え、引数の順序に準拠した interface{}型のスライスを返します。

以下は、BuildQueryWithArgsメソッドの実行結果の例です。

// repository/rawquery/util.go

type ReBindedQueryArgs struct {
    Query string
    Args  []interface{}
}

func buildQueryWithArgsDefault(rawQuery string, args map[string]interface{}) (ReBindedQueryArgs, error) {
    namedQuery, namedArgs, err := sqlx.Named(rawQuery, args)
    if err != nil {
        return ReBindedQueryArgs{}, err
    }

    return ReBindedQueryArgs{Query: sqlx.Rebind(sqlx.QUESTION, namedQuery), Args: namedArgs}, nil
}

sqlx.Named(rawQuery, args)は、生クエリ(rawQuery)と引数(args)を受け取り、クエリ内の名前付きプレースホルダーを引数の値で置き換えます。置き換えられたクエリ(namedQuery)と引数(namedArgs)を返します。sqlx.Rebind(sqlx.QUESTION, namedQuery)を使用して、名前付きプレースホルダーを?に再バインドします。そして、再バインドされたクエリと引数を含むReBindedQueryArgsを返します。

以下はbuildQueryWithArgsDefaultを実行した結果になります。

sql := `
  SELECT
      name,item_code,price,jan_code,tax_rate
  FROM
      item
  WHERE
        price > :price
        AND
        jan_code = :jan_code`

args := map[string]interface{}{
    "jan_code": 12345,
    "price":    200,
}

queryArgs, _ := buildQueryWithArgsDefault(sql, args)
fmt.Println(queryArgs)
# 実行結果

{
SELECT
    name,item_code,price,jan_code,tax_rate
FROM
    item
WHERE
    price > ?
    AND
    jan_code = ? [200 12345]
}

名前付きプレースホルダー:price, :jan_code?に、引数が名前付きプレースホルダに対応した順序の slice になっていることがわかります。

sqlboiler を用いたクエリの実行関数

次に生クエリを実行し Go の構造体に bind する汎用的な関数について説明します。 以下が具体的な実装になります。

// repository/rawquery/util.go

func Execute[T any](ctx context.Context, exec boil.ContextExecutor, queryArgs ReBindedQueryArgs) (*T, error) {
    var result T
    if err := queries.Raw(queryArgs.Query, queryArgs.Args...).Bind(ctx, exec, &result); err != nil {
        return nil, err
    }

    return &result, nil
}

型引数Tには response に期待する構造体を指定します。
引数に指定されたReBindedQueryArgsQueryArgsを用いてqueries.Rawでクエリを生成、Bindresultにクエリの結果をバインドます。

実行方法

最後に repository 直下のファイルの実装について説明します。
以下のように実装することで生クエリを意識することなくデータベース操作を行えるようにすること、生クエリを廃止し sqlboiler での実装に統一した時の影響が最小限になるようにしています。

// repository/item.go

type ItemRepository struct{}

func NewItemRepository() *ItemRepository {
    return &ItemRepository{}
}

func (r *ItemRepository) ListItem(ctx context.Context, exec boil.ContextExecutor, name string, price int, janCode string, tax int) (*models.Items, error) {
    queryArgs, err := rawquery.NewItemBuilder(name, price, janCode, tax).BuildQueryWithArgs()
    if err != nil {
        return nil, fmt.Errorf("failed to build item query args: %w", err)
    }
    res, err := rawquery.Execute[models.Items](ctx, exec, queryArgs)
    if err != nil {
        return nil, fmt.Errorf("failed to get items: %w", err)
    }

    return res, nil
}

まとめ

この記事では、リプレイスプロジェクトにおけるデータベース操作の課題と、それに対する実装方針について詳しく紹介しました。理想的には、リプレイス前に既存コードにテストを追加し、最低限のリファクタリングを行うことが望ましいです。しかし、今回は迅速な移行と、Go への書き換え後にリファクタリングを進めるという方針のもと、生クエリをそのまま移行することにしました。

sqlboiler と sqlx という二つの異なる ORM を併用することには無理があるように思われるかもしれませんが、結果として責務が適切に分割され、より良いコードへと近づいたと感じています。

Go Conference 2024 まで、あと 3 日! gocon.jp

株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp