メルペイのキャンペーンを支えるフィルタリング機能

Merpay Advent Calendar 2021の3日目はGrowth Platform Teamのバックエンドエンジニアの @yukinasu がお送りします。 Growth Platform Teamはキャンペーンやクーポンを通してメルペイの成長を促進していくチームです。 今回はキャンペーンに焦点を当て、メルペイのキャンペーンを支えるフィルタリング機能について紹介したいと思います。

フィルタリング機能とは

メルペイでは日々多くのキャンペーンが開催されています。例をあげると下記のようなキャンペーンがあります。

  • 特定の加盟店さまにてメルペイを使って支払いをしたお客さまに対してポイント還元するキャンペーン
  • バーチャルカードを利用して支払いをしたお客さまに対してポイント還元するキャンペーン
  • 初めて定額払いを利用したお客さまに対して手数料を全額ポイント還元するキャンペーン

これらのキャンペーンのバックエンドとしてお客さまへのポイント付与を担っているのが私達Growth Platform Teamが管理するSanta Serviceというマイクロサービスです。 Santa ServiceはPubSubをトリガーとしたイベントドリブンの形式をとっており、メルペイを使った支払いなどのお客さまのアクションをトリガーとして即時でポイントバックを可能としています。 Santa Serviceについては Merpay Tech Fest 2021 での @yo-gawa さんの 発表 で紹介していますのでよろしければこちらを御覧ください。

本題のフィルタリング機能とはお客さまがメルペイのキャンペーンの参加条件を満たしているかどうかを判定する機能です。 この機能は前述のSanta Serviceの一機能として実装されており、この機能によってキャンペーン毎にシステムに改修を入れることなく、フレキシブルにキャンペーンの参加条件を設定することが可能となっています。

フィルタ

キャンペーンを作成するときの参加条件は、複数の条件から成り立つ場合が多いです。分解した個別の条件をフィルタと呼び、それぞれ対象か対象外かを判定できます。

たとえば、「特定の加盟店さまでメルペイスマート払いを利用し、かつ200円以上の決済でポイント還元」というキャンペーンの条件を分解すると下記のようになります。

  • 特定の加盟店さまでの決済であること
  • メルペイスマート払いで支払いした決済であること
  • 200円以上の決済であること

このそれぞれの条件に対して対応するフィルタが準備されており、上記のキャンペーンならばこのようなフィルタ構成になります。

  • 加盟店フィルタ
設定項目 設定値
対象加盟店ID 特定加盟店のID
  • 支払い方法フィルタ
設定項目 設定値
支払い方法 メルペイスマート払い
  • 決済金額フィルタ
設定項目 設定値
最低決済金額 200円

このフィルタの組み合わせによって、追加の実装なしに決済がキャンペーン参加条件を満たすかどうかを判定しています。

フィルタリング機能の仕組み

フィルタリング機能がどういう実装になっているのかをコードを交えて説明していきます。 フィルタリング機能は下記の3点に重点を置いて実装されています。

  • メルペイの成長に対応できる拡張性があること
  • 依存先マイクロサービスに対する負荷を最低限に抑えること
  • フィルタの設定が理解しやすい形式で保存されていること

この3点に注目してどのような実装になっているのかを説明していきます。

メルペイの成長に対応できる拡張性があること

Growth Platform Teamの目的はメルペイの成長を促進させることです。新サービスがリリースされたときにはそのサービスに関連したキャンペーンが企画されます。 新サービスに関連したキャンペーン参加条件となりますので、新サービスに対応したフィルタが必要になります。 ですが、フィルタの追加が必要になる毎にメインの処理(ポイント付与機能)に改修を入れていては、同時並行で開催されている既存キャンペーンへ影響がないかの検証が非常に大変になります。

上記の課題を解決するためにフィルタリング機能はメイン処理から独立し、任意に追加可能な処理として実装されています。

ではここからはコードを交えて説明していきます。 フィルタは下記の Filter interfaceを実装した構造体として定義されます。

const (
    FilterTypeImmediate FilterType = iota // Filter to get results from resources in Santa Service.
    FilterTypeExternal                    // Filter to access other microservices with gRPC API and get the results.
)

type Filter interface {
    // Apply filters to match the condition based on the argument.
    Apply(ctx context.Context, arg *Argument) (match, skip bool, err error)
    // Type returns one of the following FilterType.
    // - FilterTypeImmediate
    // - FilterTypeExternal
    Type() FilterType
    // Name returns the name of the filter.
    Name() string
}

Filter interfaceは3つのメソッドを保持しており、後述するフィルタリングのチェック処理でこれらのメソッドを利用します。

  • Apply(ctx context.Context, arg *Argument) (match, skip bool, err error)
    • フィルタに定義された条件にマッチするかの判定を行うメソッド
    • 引数の arg は判定の対象となる情報を保持する
      • 例えば決済に対してフィルタリングを行う場合は決済の情報を保持する
        • 決済したお客さまのID
        • 決済金額
        • 決済した店舗のID
        • etc…
    • 返り値の match は判定の結果を返す
    • 返り値の skiparg の内容によって判定の結果を他のフィルタの結果に反映させないようにスキップする場合はtrueを返す
      • 例: メルカリでの取引を対象としたフィルタだが、 arg の情報はメルカリ外での決済だった場合にフィルタの判定をスキップする
  • Type()
    • フィルタがSanta Service内部のリソースで判定できるものなのか、外部リソースの取得を必要とするものなのかを返すメソッド
  • Name()
    • フィルタに定義されたユニークな名称を返すメソッド

下記は実際に Filter interface を実装した加盟店フィルタです。

const FilterNameMerchant = "merchant"

func init() {
    RegisterFilterInitFunc(FilterNameMerchant, func(ctx context.Context, data []byte) (Filter, error) {
        var filter Merchant
        if err := json.Unmarshal(data, &filter); err != nil {
            return nil, fmt.Errorf("failed to unmarshal filter data: %w", err)
        }

        client, _, err := merchant.InitializeMerchantClient(ctx)
        if err != nil {
            return nil, fmt.Errorf("failed to initialize merchant_client")
        }

        filter.merchantGetter = client

        return &filter, nil
    })
}

type Merchant struct {
    MerchantIDs []string `json:"merchant_ids"`

    merchantGetter MerchantGetter
}

// Apply filters to match the condition based on the argument.
func (f *Merchant) Apply(ctx context.Context, arg *Argument) (match, skip bool, err error) {
    shopID, err := arg.ShopID()
    if err != nil {
        return false, false, fmt.Errorf("failed to get shop_id: %w", err)
    }

    merchantID, err := f.merchantGetter.GetMerchant(ctx, shopID)
    if err != nil {
        return false, false, fmt.Errorf("failed to get merchant_id: %w", err)
    }

    for _, id := range f.MerchantIDs {
        if id == merchantID {
            return true, false, nil
        }
    }

    return false, false, nil
}

// Type returns one of the following FilterType.
// - FilterTypeImmediate
// - FilterTypeExternal
func (f *Merchant) Type() FilterType {
    return FilterTypeExternal
}

// Name returns the name of the filter.
func (f *Merchant) Name() string {
    return FilterNameMerchant
}

MerchantFilter interfaceを実装した構造体です。 Merchant は大きく分けて2種類の変数を持ちます。

  • フィルタ単位で保持する条件項目
    • Merchant では MerchantIDs が該当し、 MerchantIDs に一致する加盟店さまでの決済の場合に条件達成となる
    • 必ずJSONタグを付ける必要がある
  • DB, 外部マイクロサービスのclient
    • Merchant では merchantGetter が該当し、取得に必要なメソッドが定義されたinterface型となる
    • init() メソッドで初期化が行われ、実体となるclientが設定される

上記の変数は Apply() メソッドでキャンペーン条件の判定に利用されます。

依存先マイクロサービスに対する負荷を最低限に抑えること

フィルタリング機能が実装されているSanta Serviceはお客さまや加盟店さまの情報は保持していません。 しかしキャンペーンの参加条件によってはお客さまや加盟店さまの情報を参照する必要があるため、1次情報を保持するマイクロサービスに対してAPI経由で問い合わせを行う必要があります。

しかし、ここで一つ問題があります。メルペイでは複数のキャンペーンが同時並行で開催されており、1つの決済に対してキャンペーン毎に複数回のフィルタリングチェックが発生します。 その度に外部マイクロサービスへ問い合わせが必要なフィルタはAPIを叩くことになるのですが、お客さまのステータスのように別のキャンペーンでも取得するためのリクエストパラメータと結果が全くおなじになる場合も存在します。 そのような場合は2回目以降の問い合わせは完全に無駄であり、問い合わせ先のマイクロサービスの負荷を高める結果となるため、フィルタリング機能では下記の3つの方法で無駄な問い合わせの軽減を図っています。

  • Early returnによる軽減
  • フィルタの処理順序による軽減
  • キャッシュによる軽減

それぞれについて下記のフィルタリングチェックのメソッドをベースに説明していきます。

func CheckFilters(ctx context.Context, arg *Argument, filterJSON string, cache *Cache) (bool, error) {
    loader, err := NewLoaderFromFilterJSON(ctx, filterJSON)
    if err != nil {
        return false, fmt.Errorf("failed to create filter loader: %w", err)
    }

    filterMatch := true

    for _, filter := range loader.Filters() {
        key := cacheKey(filter, arg)

        match, exist := cache.Get(key)
        if !exist {
            skip := false
            match, skip, err = filter.Apply(ctx, arg)
            if err != nil {
                return false, fmt.Errorf("failed to apply filter %s: %w", filter.Name(), err)
            }

            // If skip is true,
            // the result of the filter is not considered and does not affect the result of the CheckFilters method.
            // It is also unaffected by the content of the options.
            if skip {
                cache.Set(key, true)
                continue
            }

            // Caches Filter.Apply() results.
            cache.Set(key, match)
        }

        // If a non-matching filter exists, the filter check is ended at that stage and no further filter checks are applied.
        // This style is used to reduce unnecessary gRPC API requests to external microservices.
        if !match {
            filterMatch = false
            break
        }
    }

    return filterMatch, nil
}

Early returnによる軽減

フィルタリングチェックにおいて各フィルタはAND条件となっており、キャンペーンに設定された全てのフィルタの判定がTrueの場合にのみキャンペーン参加条件を満たしたと判断されます。 逆に言えば、複数あるフィルタの判定の中で一つでもFalseとなればキャンペーン参加条件を満たしていないと判断されます。 よってフィルタリングチェックで判定がFalseとなった時点で以降のフィルタの判定を行う必要はなく、Early returnすることで無駄な問い合わせは発生しなくなります。

フィルタの処理順序による軽減

Filter interfaceの説明で少し触れましたが、Filterには FilterTypeImmediateFilterTypeExternal の2種類が存在します。 FilterTypeImmediate はSanta Serviceの内部リソースから判定可能なフィルタ、 FilterTypeExternal は外部リソースの取得を必要とするフィルタです。 下記の Filters() メソッドでフィルタを取得するときに必ず FilterTypeImmediate のフィルタから取得されるようになっています。 そして前述のEarly returnの処理により、 FilterTypeImmediate のフィルタで判定がFalseの場合は以降に存在する FilterTypeExternal の判定は行われず、無駄な問い合わせは発生しなくなります。

func (l *Loader) Filters() []Filter {
    immediate := make([]Filter, 0, len(l.filters))
    external := make([]Filter, 0, len(l.filters))

    for _, filter := range l.filters {
        switch filter.Type() {
        case FilterTypeImmediate:
            immediate = append(immediate, filter)
        case FilterTypeExternal:
            external = append(external, filter)
        }
    }

    return append(immediate, external...)
}

キャッシュによる軽減

filterarg からkeyを生成し、同じ内容のフィルタだった場合はキャッシュから結果を返すようになっています。 フィルタの判定結果そのものをキャッシュに保存することで FilterTypeExternal フィルタの判定結果もキャッシュから取得することができ、外部マイクロサービスに対する同一の問い合わせは発生しなくなります。

func cacheKey(filter Filter, arg *argument) CacheKey {
    return CacheKey(fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%s:%s:%#v", filter.Name(), filter.data(), arg)))))
}

キャッシュのスコープは1 PubSub Messageのフィルタリングチェックの間のみになっており、1決済に対して複数のキャンペーンを処理する場合は複数キャンペーン間でキャッシュを利用することができます。

フィルタの設定が理解しやすい形式で保存されていること

キャンペーン参加条件は当然キャンペーン毎に設定されるため、フィルタの設定もキャンペーンデータに紐づく形で保存する必要があります。 フィルタリング機能ではフィルタの設定をJSON文字列に変換し、キャンペーンデータにString型のカラムとして保持するようにしています。

例えば前述の「特定の加盟店さまでメルペイスマート払いを利用し、かつ200円以上の決済でポイント還元」というキャンペーンのフィルタ設定をJSON形式で表現すると下記のようになります。 普段からJSONを扱うエンジニアの方なら設定内容が読み取れると思います。

[
  {
    "name": "merchant",
    "filter_data": {
      "merchant_ids": [
        "merchant_id_1",
        "merchant_id_2",
        "merchant_id_3"
      ]
    }
  },
  {
    "name": "payment_method",
    "filter_data": {
      "payment_method_types": [
        "merpay_smart_pay"
      ]
    }
  },
  {
    "name": "payment_amount",
    "filter_data": {
      "min": 200
    }
  }
]

フィルタ設定の保存方法に関しては実装時に2つの案がありました。

  • キャンペーンデータとは別にフィルタのテーブルを作成する案
    • Pros
      • フィルタの設定に対してDBのカラムによる固い制約をかけられる
    • Cons
      • フィルタの種類が増えるに従いテーブルのカラムに対してもマイグレーションが必要となり、拡張コストが高い
      • 別テーブルにしてフィルタ毎にレコードを作成すると専用の管理UIがなければメンテナンスが難しい
  • キャンペーンデータの1カラムにJSON形式でフィルタの設定を保存する案
    • Pros
      • キャンペーンデータの1レコード内でフィルタ設定の全ての情報を保存できる
      • JSON形式の場合、展開すれば一定の可読性があり、フィルタの設定内容をJSONだけで読み取れる
      • 管理UIを実装する場合などSanta Service以外でフィルタの設定が必要となった場合でもJSON形式ならば容易に扱える
    • Cons
      • データ保存時にJSON文字列に対する制約が存在せず、フィルタ側で未定義のフィールドを保存することも可能

どちらの案もPros, Consがありましたが、最終的にフィルタの設定のメンテナンス性を優先してJSON形式で保存する案を採用しました。 JSON文字列に対する制約に関しては次項で説明する方法でJSON文字列を出力するように統一することで担保しています。

JSON文字列の出力方法

ここからはどのようにして Filter からJSON文字列を出力してるかをコードを交えて説明します。

Filter をJSONに変換するために一度 FilterEnvelope という構造体に変換されます。 FilterEnvelope は個々に保持するフィールドが異なるフィルタをJSONに変換する上で共通化するための構造体です。

  • Name
    • フィルタの名称を保持する
  • FilterData
    • フィルタに設定されたフィールドの内容を保持する
    • 直接JSONに変換できるように map[string]interface{} 型で保持する
// FilterEnvelope is a struct for converting filter contents to JSON.
//  - Name is the name of the filter.
//  - FilterData is the data held by the filter.
//    It is of type map[string]interface{} so that it can be converted directly into JSON format.
type FilterEnvelope struct {
    Name       string                 `json:"name"`
    FilterData map[string]interface{} `json:"filter_data"`
}

func EnvelopeFilter(filter Filter) (*FilterEnvelope, error) {
    // The Filter is converted to map[string]interface{} by marshaling it to JSON once and then unmarshaling it again.
    filterData, err := json.Marshal(filter)
    if err != nil {
        return nil, fmt.Errorf("failed to marshal filter: %w", err)
    }

    var mapFilterData map[string]interface{}
    if err := unmarshalJSON(filterData, &mapFilterData); err != nil {
        return nil, fmt.Errorf("failed to unmarshal filter_data to map[string]interface{}: %w", err)
    }

    envelope := &FilterEnvelope{
        Name:       filter.Name(),
        FilterData: mapFilterData,
    }

    return envelope, nil
}

Filter をJSON文字列に変換する場合、または逆にJSON文字列を Filter に変換する場合は Loader という構造体を利用します。

Loader 自体は filters, filterJSON の変数を持ったただの構造体ですが、 Loader を生成する NewLoaderFromFilters() メソッドで Filter からJSON文字列に対する変換処理を行っています。 今回の記事には記載していませんがJSON文字列から Filter へ変換を行う NewLoaderFromFilterJSON() というメソッドも存在します。

このように Loader を経由することで Filter とJSON文字列の相互変換が可能となっており、Goのコードで利用したいフィルタを配列で設定して渡すことでJSON文字列を出力し、キャンペーンデータに設定するようにしています。

type Loader struct {
    filters    []Filter
    filterJSON string
}

func NewLoaderFromFilters(ctx context.Context, filters ...Filter) (*Loader, error) {
    loader := &Loader{
        filters: filters,
    }

    if err := loader.dumpFilterJSON(); err != nil {
        return nil, fmt.Errorf("failed to dump filter json: %w", err)
    }

    envelopes := make([]*FilterEnvelope, 0, len(l.filters))
    for _, filter := range l.filters {
        envelope, err := EnvelopeFilter(filter)
        if err != nil {
            return fmt.Errorf("failed to envelope filter: %w", err)
        }

        envelopes = append(envelopes, envelope)
    }

    data, err := json.Marshal(envelopes)
    if err != nil {
        return fmt.Errorf("failed to marshal envelopes")
    }

    l.filterJSON = string(data)

    return nil
}

最後に

以上がメルペイのキャンペーンを支えるフィルタリング機能の仕組みとなります。

実は今回紹介したフィルタリング機能ですが、メルペイローンチ初期から存在するv1とv1のペインポイントの改善を目的として作り直したv2が存在します。 今回の記事はv2をベースに紹介させてもらいましたが、いつかv1とv2を比較してどこが良くなったのかの紹介もできれば良いなと思います。

またフィルタリング機能のJSON形式で設定値を保持し拡張性が高いという点を継承し、middleware patternの考え方を導入してメイン処理に任意の追加機能を差し込めるアタッチメント機能の設計・実装を現在進めています。 こちらに関しても無事実装できましたらいつか紹介できれば良いなと思っています。

最後となりましたが、メルペイではミッション・バリューに共感できるBackend Engineerを募集しています。 もし今回の記事で少しでも興味を持っていただけたのでしたら、下記のリンクから応募していただけると嬉しいです。

明日の Merpay Advent Calendar 2021 は、 @ysk24ok さんより、「モブプログラミングを導入し、チーム一丸となってタスクに取り組むようになった話」です。引き続きお楽しみください。