Growthのオペレーションツールの歩み 〜クーポン編〜

Merpay Advent Calendar 2021 の 15 日目は 2 本立てで、1 本目の記事をメルペイ Growth Platform Team のバックエンドエンジニアの@naoinaがお送りします。

2本目は Growthのオペレーションツールの歩み 〜ダッシュボード編〜 で公開されているので、そちらもよろしくお願いします。

クーポンやキャンペーンなど、Growth に欠かせない機能を実現するシステムはマイクロサービスとして構築していますが、データの登録のような人が介在するオペレーションは定常的に発生します。 その内容を設計するのは、バックエンドエンジニアではなくマーケティング、BI、プロダクトマネージャーなどの職種、部署の方であり、理想的にはそれらの方々だけで作業を完結できるような使いやすいUIを備えたシステムがあることが望ましいでしょう。 しかし、他にも様々な開発が必要になる中でこの開発に投資するのがベストとは限りません。実際に私たちのチームでは、その都度必要な要素を見極めてオペレーションのためのツール、仕組みを用意してきました。

この記事では、Growth Platform Teamが管理するものから、クーポンに関するオペレーションのためのツール化の状況について紹介したいと思います。

クーポンの入稿フローについて

メルペイでは、街のお店で決済する際に割引されたり、後日ポイントが付与されたりする電子クーポンを提供しています。

クーポンの提供には入稿作業とリリースが必要になります。 ここで言う入稿作業とは、加盟店さまと合意できたクーポンの内容をメルペイのデータベースに投入できるデータ形式にするまでの作業全般を指します。リリースは実際に本番環境へデータを反映することを指します。

現在、我々のクーポンの入稿フローはざっくりと下記のようになっています。

  1. クーポン案件確定
  2. クーポン案件を管理するGoogleスプレッドシートに記入
  3. 入稿用スプレッドシートをGoogle Apps Scriptで作成
  4. ChatOpsでYAMLファイルの生成とGitHubへのプルリクエストを作成
  5. プルリクエストをトピックブランチにマージ
  6. ChatOpsでリリース用プルリクエストを作成
  7. プルリクエストをマージして本番環境にリリース

1と2に関しては我々開発陣が関わることがほぼないのでサラッと流していきます。

クーポン案件確定

セールスおよびマーケティングの方々がメルペイ加盟店さまと交渉し、クーポン案件の実施を決定します。

クーポン案件を管理するGoogleスプレッドシートに記入

案件の内容が決まったらそれを管理するGoogleスプレッドシートに記入します。

入稿用スプレッドシートをGoogle Apps Scriptで作成

クーポン案件管理用スプレッドシートには未確定のものも含め、すべてのクーポンの案件が記載されており、セールスおよびマーケティングの方が入力、編集するシートという位置づけになっています。 そのため、このシートから個別に各クーポンの入稿用スプレッドシートを作成して管理しています。

入稿用スプレッドシートの作成にはGoogle Apps Script(以下GAS)を使用しており、GASはClasp + TypeScript + esbuild + で開発、管理しています。 ClaspはGASの管理用CLIで、手元にあるファイルをGASとしてアップロードするのに使っています。ClaspはそのままでもTypeScriptをGASに変換してアップロードしてくれるのですが、import/exportが単純にコメントアウトされるのと、すべて1ファイルにまとめてGASとしてアップロードしてほしかったので、TypeScriptで書いたものをModule bundlerで1ファイルのJavaScriptに変換後、Claspでアップロードするという方針を取りました。

TypeScriptが扱えるModule bundlerとしてはwebpackRollupParcelなどがありますが、

  • GASで実行する関数はトップレベルに定義されている必要があるので、そのような出力をしてくれるもの
  • Tree shakingしてくれるもの
  • 設定が複雑怪奇にならないもの

といった条件下で色々と試した結果、esbuildを使用することにしました。

esbulidはCLIとして使うことも出来ますが、出力されるファイルにコメントを追加したかったので、下記を build として保存してCLIの代わりに使うようにしました。

#!/usr/bin/env node

const path = require('path');
const esbuild = require('esbuild');
const pkg = require('./package.json');

const argv = process.argv.slice(2);

for (const file of argv) {
  esbuild.buildSync({
    entryPoints: [file],
    bundle: true,
    minifySyntax: true,
    outfile: path.join(
      path.dirname(file),
      `${path.basename(file, path.extname(file))}.js`
    ),
    format: 'cjs',
    banner: {
      js: [
        '/*! DO NOT EDIT DIRECTLY. */',
        `/*! This code is generated from ${
          pkg.repository.url
        }/tree/master/${path.basename(__dirname)}/${file} by clasp. */`,
        'const exports = {}',
      ].join('\n'),
    },
  });
}

ポイントは banner の設定で、Claspでどのファイルから生成したか?ということをコメントとしてファイルの先頭に置くようにします。これによって、誰かがスプレッドシートからGASを直接編集しようとしたときに気がつけるようにしています。

上記のスクリプトで下記のTypeScriptをビルドすると

export function onOpen() {
  const ui = SpreadsheetApp.getUi();
    ui.createMenu('入稿用スプレッドシート')
      .addItem('作成する', 'createCouponSheets')
      .addToUi();
}

export function createCouponSheets() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const baseSheet = ss.getActiveSheet();
  if (baseSheet.getSheetId() !== COUPON_DEALS_SHEET_GID) {
    const s = findSheet(ss, COUPON_DEALS_SHEET_GID);
    if (!s) {
      return alert('クーポン案件リストのシートがありません');
    }
    ss.setActiveSheet(s);
    return alert(
      'クーポン案件リストに移動しました。もう一度このシート上でやり直してください'
    );
  }
  ......
}

function findSheet(
  ss: GoogleAppsScript.Spreadsheet.Spreadsheet,
  gid: number
): GoogleAppsScript.Spreadsheet.Sheet | null {
  for (const s of ss.getSheets()) {
    if (s.getSheetId() === gid) {
      return s;
    }
  }
  return null;
}

このように、exportされているものと、その中で使われている関数(ここではfindSheet)がトップレベルに定義されます。コメントもちゃんと先頭に書かれています。 GASを直接見ることは基本的にはないことから、最初の方にあるグルーコードは許容しています。

/*! DO NOT EDIT DIRECTLY. */
/*! This code is generated from https://github.com/kouzoh/repo/tree/master/google-apps-scripts/Code.ts by clasp. */
const exports = {}
var __defProp = Object.defineProperty;
var __markAsModule = (target) => __defProp(target, "__esModule", { value: !0 });
var __export = (target, all) => {
  __markAsModule(target);
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: !0 });
};

// Code.ts
__export(exports, {
  createCouponSheets: () => createCouponSheets,
  onOpen: () => onOpen
});
function onOpen() {
  SpreadsheetApp.getUi().createMenu("\u5165\u7A3F\u30B7\u30FC\u30C8").addItem("\u4F5C\u6210\u3059\u308B", "createCouponSheets").addToUi();
}
function createCouponSheets() {
  var _a, _b;
  let ss = SpreadsheetApp.getActiveSpreadsheet(), baseSheet = ss.getActiveSheet();
  if (baseSheet.getSheetId() !== COUPON_DEALS_SHEET_GID) {
    let s = findSheet(ss, COUPON_DEALS_SHEET_GID);
    return s ? (ss.setActiveSheet(s), alert("\u30AF\u30FC\u30DD\u30F3\u6848\u4EF6\u30EA\u30B9\u30C8\u306B\u79FB\u52D5\u3057\u307E\u3057\u305F\u3002\u3082\u3046\u4E00\u5EA6\u3053\u306E\u30B7\u30FC\u30C8\u4E0A\u3067\u3084\u308A\u76F4\u3057\u3066\u304F\u3060\u3055\u3044")) : alert("\u30AF\u30FC\u30DD\u30F3\u6848\u4EF6\u30EA\u30B9\u30C8\u306E\u30B7\u30FC\u30C8\u304C\u3042\u308A\u307E\u305B\u3093");
  }
  ......
}
function findSheet(ss, gid) {
  for (let s of ss.getSheets())
    if (s.getSheetId() === gid)
      return s;
  return null;
}

GASによる自動作成を導入する前はすべて手動で転記していたので、そのときと比べて導入後は工数やミスを軽減することができました。

ChatOpsでYAMLファイルの生成とGitHubへのプルリクエストを作成

入稿用スプレッドシートを作成したら、そのシートにクーポン案件の管理シートには載っていない、本番データ用の設定を手動で入れていきます。 たとえば下図のような項目の設定をします。

これ以外にも設定する項目は多数ありますが、今回は割愛します。

入稿用スプレッドシートが完成したら、それをデータベースにINSERTするためのYAMLファイルに変換してクーポンデータ用のリポジトリに対してプルリクエストを作成します。これにはBotを使います。

Botを使う理由の一つは、チャットログを簡易的なAudit logとするためというものがあります。

Botでコマンドを実行したときのフローは、以下のようになっています。

  1. Botがコマンドから必要なパラメーターを取得してツールのDockerイメージをrun
  2. 入稿用ツールが入稿用スプレッドシートを読み込み、YAMLファイルへの変換とリポジトリへのcommit/push、プルリクエストの作成を行う

また、Botの作成にはHubotを使っています。詳細は割愛するので、気になる方はHubotについて調べてみてください。

Botがコマンドから必要なパラメーターを取得してツールのDockerイメージをrun

下記のコマンドから、入稿用スプレッドシートが置いてあるGoogle DriveのフォルダID(aaa...の部分)とクーポンデータ用リポジトリのトピックブランチ名(20211215の部分)を取得します。

@coupon-ta submit coupon id:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 20211215

以下Bot実装の抜粋です。

const keyfile = process.env.SUBMITTER_KEY_FILE;
const prefix = `auto-pr/${branch}`;
const baseBranchEnv = `BASE_BRANCH=${branch}`;
const driveEnvName = 'COUPON_DRIVE_ID';
const args = [
  '-e',
  baseBranchEnv,
  process.env.SUBMITTER_IMAGE,
  '--coupon-folder-id',
  id,
  '--branch-prefix',
  prefix,
  '--keyfile',
  keyfile,
];

msg.reply(
  '(*´ェ`*) < 入稿するよ〜 ' +
    [
      `${driveEnvName}=<https://drive.google.com/drive/folders/${id}|${id}>`,
      baseBranchEnv,
    ]
      .filter((s) => !!s)
      .join(' ')
);
cp.execFile(
  'docker',
  ['run', '--net=host', '--rm', '-v', `${keyfile}:${keyfile}`].concat(args),
  (error) => {
    if (error) {
      const err = '```' + `\nerror:${error}\n` + '```';
      const reply = `(´・ω・`) 入稿時にエラーが発生しました...\n${err}`;
      msg.reply(reply);
      return;
    }

    msg.reply('(*´∀`) < 入稿おわったよ');
  }
);

SUBMITTER_IMAGE という環境変数に入稿用ツールのDockerイメージのリポジトリ名が入っており、それに引数を付けて docker run するというシンプルなものとなっています。 各種認証情報などはファイルに保存されていて、それを -v オプションでDockerコンテナへマウントするようにしています。

入稿用ツールが入稿用スプレッドシートを読み込み、YAMLファイルへの変換とリポジトリへのcommit/push、プルリクエストの作成を行う

まず docker run で起動された入稿用ツールが入稿用スプレッドシートを読み込みます。どのスプレッドシートを読むのかは --coupon-folder-id オプションで指定されたGoogle Driveフォルダを元に検索します。 クーポンの入稿にはクーポンデータの他、クーポン券面の画像などが必要になるため、それらをまとめてGoogle Driveフォルダに入れています。 入稿に使うGoogle Driveフォルダの構成は決まっているため、入稿用ツールが必要なファイルをそのフォルダから検索して読み込みます。

入稿用ツールでは入稿用スプレッドシートをCSVとしてエクスポートしたものを扱います。CSVへのエクスポートはGoogle Driveの機能を使っており、下記のようなURLでGETリクエストを投げれば GOOGLE_DRIVE_FILE_ID のスプレッドシートの内容をCSVとして取得できます。

https://docs.google.com/spreadsheets/export?id=[GOOGLE_DRIVE_FILE_ID]&exportFormat=csv

CSVからGo structへのマッピング

入稿用ツールはGoで書かれているのですが、標準ライブラリの encoding/csv そのままだとCSVの各行は []string になって扱いづらいので、下記のようなstructにマッピングするヘルパーを作成します。

type Reader struct {
    Header []string

    r *csv.Reader
}

func newReader(r *csv.Reader) *Reader {
    return &Reader{
        r: r,
    }
}

func (r *Reader) ReadStruct(out interface{}) error {
    if len(r.Header) == 0 {
        h, err := r.r.Read()
        if err != nil {
            return err
        }
        r.Header = make([]string, len(h))
        copy(r.Header, h)
    }
    row, err := r.r.Read()
    if err != nil {
        return err
    }
    m := make(map[string]string, len(r.Header))
    for i, h := range r.Header {
        if h = strings.TrimSpace(h); i < len(row) {
            m[h] = row[i]
        } else {
            m[h] = ""
        }
    }
    decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
        TagName: "row",
        Result:  out,
    })
    if err != nil {
        return fmt.Errorf("failed to create decoder: %w", err)
    }
    if err := decoder.Decode(m); err != nil {
        return fmt.Errorf("failed to decode row: %w", err)
    }
    return nil
}

CSVの1行目をヘッダーとして、その各項目をstructフィールド名としてマッピングしています。実際の処理は github.com/mitchellh/mapstructure で行います。

マッピング先のstructの定義は下記のようにしています。

type couponRow struct {
    CouponKey      string  `row:"CouponKey" validate:"required"`
    CouponType     string  `row:"CouponType" validate:"required"`
    BrandName      string  `row:"BrandName" validate:"required"`
    PaymentType    string  `row:"PaymentType" validate:"required"`
    DiscountAmount *string `row:"DiscountAmount" validate:"required"`
  ......
}

validate タグは github.com/go-playground/validator 用のもので、ポインタではないフィールドは CSVのカラム、値ともに必須、ポインタのフィールドは CSVのカラムは必須、値は任意 となっており、これは github.com/mitchellh/mapstructuregithub.com/go-playground/validator の挙動を利用しています。

CSVのカラムはあるが、値がないものを string にマッピングすると、下記の疑似コードのようになります。

var field string = ""

この場合は field == "" なので validatorrequired バリデーションでエラーになります。つまり値が必須ということになります。

*string にマッピングした場合は

var s = ""
var field *string = &s

となります。この場合は field != nil なので validatorrequired バリデーションは通ります。つまり値は必須ではないということになります。

CSVのカラムがない場合は

var field *string = nil

となるので validatorrequired バリデーションでエラーになる、という挙動になります。これはCSVのカラムが必須であるということになります。

couponRow structはすべて string のフィールドとしています。 これはGoogleスプレッドシートではセルのフォーマットによって値の自動変換が走り、予期しない値が入ってきてしまうことを回避するためで、値はすべてstringとして取り、適切な型と値への変換はすべて入稿用ツールで行うようにしています。

値の変換

CSVからstructへマッピングしたら、YAMLファイルを出力するためにそれを適切な型と値に変換します。 変換はモデルごとに下記のようなメソッドを用意して行っています。

func (r *couponRow) asCoupon() (*model.Coupon, error) {
    c := &model.Coupon{
        CouponKey: r.CouponKey,
      ......
    }
    if c.PaymentType, err = r.extractInt(r.PaymentType, map[string]int64{
        "iD":     model.CouponPaymentTypeID,
        "コード":    model.CouponPaymentTypeCode,
        "iD/コード": model.CouponPaymentTypeIDOrCode,
        "ネット決済":  model.CouponPaymentTypeNetpayment,
    }); err != nil {
        return err
    }
    if c.DiscountAmount, err = r.extractDiscountAmount(); err != nil {
        return err
    }
    ......
    return c, nil
}

各型については共通処理として extractInt parseInt extractDiscountAmount などを用意して変換しています。

func (r *couponRow) extractInt(s string, m map[string]int64) (int64, error) {
    n, ok := m[s]
    if !ok {
        return -1, fmt.Errorf(`"%s"`, s)
    }
    return n, nil
}

func (r *couponRow) parseInt(s string) (int64, error) {
    n, err := strconv.ParseInt(strings.ReplaceAll(s, ",", ""), 10, 64)
    if err != nil {
        return -1, fmt.Errorf(`failed to parse int: %w: "%s"`, err, s)
    }
    return n, nil
}

func (r *couponRow) extractDiscountAmount() (int64, error) {
    ds := *r.DiscountAmount
    if ds == "" {
        return 0, nil
    }
    discountAmount, err := r.parseInt(strings.TrimRight(ds, "%"))
    if err != nil {
        return -1, fmt.Errorf("invalid DiscountAmount: %w", err)
    }
    return discountAmount, nil
}

各モデルには yaml:"CouponKey" といったYAML用のタグが付いているので、ここでモデルへ変換した後はそのモデルをYAMLのライブラリに渡せばOKという寸法です。

YAMLファイルへの変換

YAMLへの変換は github.com/goccy/go-yaml を使っています。これを選定した理由は、モデルが spanner.NullTime などのフィールドを持っており、これらはyamlタグに対応していないため、一旦JSONへの変換を経てYAMLにしたかったからです。

リポジトリへのcommitとpush

これも入稿用ツールで行っていますが、やっていることはgitコマンドを呼んでいるだけです。

プルリクエストの作成

プルリクエストの作成はCircleCIを利用しており、下記の設定により特定のブランチ名にpushされたものに対してプルリクエストを作成するworkflowが走ります。

jobs:
  open-pr:
    <<: *golang
    steps:
      - checkout
      - run:
          name: Open Pull Request
          command: |
            ./script/github-open-pullrequest.sh
workflows:
  version: 2
  build-and-test:
    jobs:
      - open-pr:
          context: org-global
          filters:
            branches:
              only:
                - /auto.*/

script/github-open-pullrequest.sh については、単純にGitHubのAPIを叩いているだけなので割愛します。

ここまで入稿用ツールについて説明してきましたが、入稿用スプレッドシートから直接データベースにINSERTせず、一旦YAMLファイルを生成してプルリクエストを作成するのは以下の観点からです。

  • 差分が簡単に見れる
  • 簡易的なAudit logの代わりになる

差分が簡単に見れる

クーポンの入稿は一度ですべての要件が決まって終わることはまずなく、通常何度か入稿を繰り返します。そのたびにYAMLファイルを生成してプルリクエストを作成するのですが、GitHub上のリポジトリでクーポンデータを管理していることにより、前回入稿したものからの差分が簡単に確認でき、間違いに早期に気がつくことが出来ます。

簡易的なAudit logの代わりになる

クーポンの入稿に関しては、そこまでカチッとしたAudit logは必要ありませんが、それでも誰がいつ何をやったか?を知りたいときがよくあります。たとえば予期せずクーポンの設定を変えてしまった場合です。このとき、お客さまへの補填のために問題がいつからあったのか?というのを提示する必要があるのですが、我々のようなリポジトリ管理の運用であれば、リポジトリの履歴やCloseされたプルリクエストを検索したり、あるいはBlameしたりすれば簡単に必要な情報が得られます。

プルリクエストをトピックブランチにマージ

Botによって作成されたプルリクエストはトピックブランチにマージしていきます。最終的にマージ先のトピックブランチがQA対象となるので、ここでは内容をサッと見るだけでマージします。 前述のとおり、入稿が一度で終わることはまずないので、入稿用スプレッドシートの更新とBotでのYAMLファイル生成、プルリクエストのマージを繰り返して最終形まで持っていきます。

最終形になったらQAを経てトピックブランチの変更を開発用ブランチにマージします。

ChatOpsでリリース用プルリクエストを作成

ここでもBotを使います。

このコマンドによりメインブランチがマージ先となったプルリクエストが作成されます。

プルリクエストをマージして本番環境にリリース

メインブランチにマージすれば、自動でクーポンデータの本番リリースが行われるようになっています。 まずメインブランチにマージされたら、CIが走ります。CIでは各種データバリデーションが走った後、Google Cloud Buildによってリリース用Dockerイメージが作成されます。

次に、新しいDockerイメージが作成されたことをSpinnakerがPubSubによって検知し、Dockerイメージ内のクーポンデータを元にデータベースへのINSERT/UPDATEが走り、リリースが行われます。 Spinnakerについては過去の記事を参照してください。

これをもってクーポンデータの入稿とリリースが完了となります。

課題と今後の展望

簡易的なAudit logのためにGitHubのプルリクエストと通したフローや、Botによるオペレーションを行っていると書きましたが、今回紹介したようなクーポン入稿用ツールの開発当初はちゃんとしたAudit logを備えたシステムを構築する時間がなかったという事情があります。 また、現在のクーポンの入稿フローは複数の仕組みの上で成り立っており、ツールも開発者寄りなものが多く、セールスやマーケティングの方だけでクーポンの入稿から運用までできるまでには相応のトレーニングが必要になってしまいます。

現在はまだ具体的な構想はありませんが、今後関係各所へのヒアリングなどを行い、業務フロー改善も含めてこれらの課題をベストな形で解決していきたいと考えています。

明日の記事は@foostanさんです。引き続きお楽しみください。