🦜

Next.js x Vercel でブランチベース・CDNベースのA/Bテストを行う

11 min read

追記(2021/07/17)
この記事で紹介されている next-with-split の設定方法や動作の仕組み等は、最新のバージョンでは若干異なっています。
導入を検討される場合は、実際のリポジトリのREADMEを読んでください。
簡潔に差異をピックアップすると、

  • 設定の記述方法が変更されています
  • pages/_split-challenge.tsの設置は自動的に行われます
  • pages/index.tsxが使用できなくなる問題は解消されました
    • リネームの必要はありません
  • Vercel 以外のプロバイダにも対応しました
  • rewrite rule そのものは使用していますが、実際のコンテンツの出し分けは _split-challenge 内でリバースプロキシサーバを立ち上げてアクセスを分配しています。
    • そのため、独自の rewrite rule の追加が可能です。

この記事は Qiita に掲載した記事のコピーとなっております。

https://qiita.com/aiji42/items/ddac5a049e00032257ca

より多くの Next.js ユーザに共有したいという目的でこちらでも投稿させていただきますが、もし、コピーコンテンツは掲載すべきでない等のご意見ございましたら、ご連絡いただけますと幸いです。

はじめに

この記事は next-with-split を紹介する記事です。
記事を読んでこのプラグインを気に入っていただけた方は、スターをお願いします⭐️ コントリビュートもお待ちしております🙏

https://github.com/aiji42/next-with-split#readme

モチベーション

Split Testing | Netlify を Next.js x Vercel の構成でも実現することを目的としています。

https://docs.netlify.com/site-deploys/split-testing/

https://qiita.com/kiida/items/72db315d9ad595aaea4f

Netlify を利用するとブランチベースのA/Bテストが簡単に行えます。
ブランチを切ってチャレンジャーを開発し、Netlify のコンソールからA/Bテストの登録をするだけで、自動的にCDN側からアクセスを二分してテストが開始されます。
典型的なコンポネントの出し分けやページファイルの出し分けと異なり、この手法ではバンドルサイズが増加しないので、LCPの遅延などのユーザ体験を損ねてしまうような影響はありません。また、チャレンジャーのコードがオリジナルのコードに紛れることがないため、開発体験も良くなります。

このA/Bテストを行うために Netlify を使用したいという気持ちも山々ですが、ことさら Next.js の開発においては Vercel が非常に有力です。ISR の対応を行うとなれば、Vercel 以外に選択肢はないと言っても過言ではないと思います。

なんとか、Next.js x Vercel の構成で、ブランチベース・CDNベースのA/Bテスト手法を実現する方法はないかと模索した結果、Rewrites の Cookieベースのルート書き換えと、外部ドメイン参照の機能を組み合わせることで、先のA/Bテスト手法を実現する事ができました。
また、この模索した方法をnpmパッケージにして公開しましたので、そのパッケージの紹介も兼ねています。

サンプル

https://nextjs-split-test.vercel.app/

こちらで実際にA/Bテストを動かしていますので、アクセスして確認してみてください。
初回アクセス時にオリジナルもしくはチャレンジャーにランダムで振り分けられ、2回目以降は何度アクセスしても、初回に割り分けられた方を参照し続けます。

Cookieを削除してアクセスし直すと、A/Bの再振り分けが行われます。(1/2の確率なので3,4回同じ方を引き当てることもありますが、めげずにトライしてみてください。)

original challenger

仕組み

プロバイダとして Vercel を利用すると、全ブランチ・コミット(チームの場合登録メンバーのコミット)が監視され、プッシュのたびにビルド・デプロイが実行されて、プレビューURLが生成されます。
このプレビューURLは、コミットとブランチそれぞれにユニークなURLを生成してくれます。

今回はこの、ブランチに対して発行されたプレビューURLを使用して、A/Bテストのコンテンツの出し分けを行います。

フローチャート

まずはじめに、ユーザはオリジナル(A)のURLにアクセスします。(チャレンジャーに引き当たっても、エンドポイントはこのオリジナル一つです。)
ルーティングを解決する前に、予め用意しておいた rewrite rule によって、全パスで A/Bテスト用のCookieを保持しているかチェックします。

初回アクセス => No 側

初回アクセスにおいてはCookieは保持していないため、本来のユーザがリクエストしたパスではなく、別のページファイル(pages/_split-challenge.ts)にルーティングさせます。
この pages/_split-challenge.ts では getServerSideProps によって、A/Bテスト用のCookie付与が行われます。
メインブランチ用のコンテンツにアクセスを向けるか、チャレンジャーブランチのコンテンツを向けるかをランダムに決定し、その結果に対応したCookieの発行をします。
その後、再度ユーザがリクエストしたURLにリダイレクトをさせます。

getServerSideProps は、ページコンポネント用のprops生成が主な機能ですが、実はリクエストヘッダーの解釈、や今回のようにレスポンスヘッダーの書き換えを行うこともできます。

2回目以降のアクセス => Yes 側

リダイレクト後および以降のアクセスでは、A/Bテスト用のCookieを保持しているので Yes 側の処理が行われます。
メインブランチ(A)用のCookieを保持していた場合には、そのまま本来のルーティングが行われユーザにオリジナルのコンテンツを返却します。

チャレンジャーブランチ(B)用のCookieを保持していた場合には、 rewrite rule の外部ドメインへのリライト(Rewriting to an external URL)によってプレビューURLを参照し、ユーザにチャレンジャーのコンテンツを返却します。

注意していただきたいのが、ここで行っていることはリダイレクトではなくサーバ内でのリライトです。A/Bどちらに振られてもブラウザ側ではURLは変わりません。
また一度Cookieを振ったあとは、(Cookieの有効期限内である限り)何度アクセスしても、ディストリビューション間をまたいでしまうということもありません(スティッキー)

実際の設定

上記の仕組みを実現するために、実際に使用したコードです。
固有値を保持していたり、冗長な書き方になっていますが、あくまで説明をしやすくするためにそうしています。
npm化の際にリファクタおよび一般化していますので、どうかあしからず。

Rewrite rules

Rewriteの設定はこんな感じです。(長いので折りたたんでいます。)
それぞれA/Bそれぞれのケースで、設定ブロックが重複しているように見えますが、Next.js の仕様でルートパスのアクセスとそれ以外のアクセスの処理が異なるようでしたので、冗長に見えますがこのような記述になっています。
(最終的に npm パッケージを作成する際に一般化したため、ここはあまり深追いしなくてOKです。)

rewrite.js
// rewrite.js
// next.config.js でインポートして使います。
const rewrites = async () => {
  return {
    beforeFiles: [
      // オリジナル(A)用のCookieを持っている時
      {
        source: '/',
        has: [
          {
            type: 'cookie',
            key: 'branch',
            value: 'main'
          }
        ],
        destination: '/top'
      },
      {
        source: '/:path*/',
        has: [
          {
            type: 'cookie',
            key: 'branch',
            value: 'main'
          }
        ],
        destination: '/:path*'
      },
      // オリジナル(A)用のCookieを持っている時 ここまで
      // チャレンジャー(B)用のCookieを持っている時
      {
        source: '/',
        has: [
          {
            type: 'cookie',
            key: 'branch',
            value: 'challenger-branch'
          }
        ],
        destination: 'https://example.vercel.app/'
      },
      {
        source: '/:path*/',
        has: [
          {
            type: 'cookie',
            key: 'branch',
            value: 'challenger-branch'
          }
        ],
        destination: 'https://example.vercel.app/:path*'
      },
      {
        source: '/:path*',
        has: [
          {
            type: 'cookie',
            key: 'branch',
            value: 'challenger-branch'
          }
        ],
        destination: 'https://example.vercel.app/:path*'
      },
      // チャレンジャー(B)用のCookieを持っている時 ここまで
      // Cookieを持っていない時
      {
        source: '/:path*/',
        destination: '/_split-challenge'
      }
    ]
  }
}

module.exports = rewrites

pages/_split-challenge.ts

初回アクセス時にA/B用のCookieを付与するページファイル(pages/_split-challenge.ts)はこのような処理です。
察しの良い方はここでわかるかもしれませんが、A/B/Cテストのような2つ以上のブランチにも対応できます。
また、branches[Math.floor(Math.random() * branches.length)]ここを改良すれば、振り分けの比重もコントロール可能です。

// pages/_split-challenge.ts
import { GetServerSideProps } from 'next'
import { setCookie } from 'nookies'

const branches = ['main', 'challenger-branch']

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  setCookie(
    ctx,
    'branch',
    branches[Math.floor(Math.random() * branches.length)],
    { path: '/' }
  )
  ctx.res.writeHead(302, { Location: ctx.req.url ?? '/' })
  ctx.res.end()

  return {
    props: {}
  }
}

const Page = () => null
export default Page

next-with-split

ここまで、先のA/Bテストを実現する方法を書いてきましたが、プロジェクトで広く導入しやすくするためにnpmパッケージ化を行いました。
ここからは、実際にnext-with-splitを使うための手順などを書いていきます。

https://github.com/aiji42/next-with-split#readme

インストール

npm install --save next-with-split

or

yarn add next-with-split

使用手順

1. メインブランチ(オリジナル)で next.config.js をカスタマイズする

// next.config.js
const { withSplit } = require('next-with-split');

module.export = withSplit({
  // webpackなど個別に設定項目があれば、ここに記載する。
})

2. メインブランチ(オリジナル)で pages/_split-challenge.ts を作成する

このファイルでA/Bのクッキー発行が行われます。

// pages/_split-challenge.ts (.js)
export { getServerSideProps } from 'next-with-split'
const SplitChallenge = () => null
export default SplitChallenge

3. メインブランチからチャレンジャー用のブランチを派生させて開発する

4. チャレンジャー用ブランチをプッシュし、VercelでプレビューURLを取得する

このとき取得するのは、ブランチに対して発行されたURLであることに注意してください。(コミットに対してのURLでもエラーにはなりませんが、チャレンジャー側の変更に追従できません。)
URLにブランチ名が含まれていますのでチェックしましょう。

5. メインブランチ・チャレンジャーブランチともに withSplit にステップ4で取得したURLを記載する

// next.config.js
const { withSplit } = require('next-with-split');

module.export = withSplit({
  splits: {
    branchMappings: {
      abtest_challenger: // チャレンジャーブランチ名
        'https://nextjs-split-test-git-abtestchallenger-aiji42.vercel.app' // ステップ4で取得したURL
    }
  }
  // webpackなど個別に設定項目があれば、ここに記載する。
})

6. メインブランチ・チャレンジャーブランチともにプッシュしてデプロイする

7. 自動的にアクセスがA/Bに分割されてA/Bテストが開始される

アクセスをして確認してみてください。

停止方法

メインブランチでbranchMappings内を空にすると、A/Bテストが停止されます。

// next.config.js
const { withSplit } = require('next-with-split');

module.export = withSplit({
  splits: {
    branchMappings: {}
  }
  // webpackなど個別に設定項目があれば、ここに記載する。
})

設定

withSplit

withSplit で使用できる設定値は次のとおりです。

const { withSplit } = require('next-with-split');

module.export = withSplit({
  splits: {
    branchMappings: { challenger: 'https://example.com' },
    rootPage: 'root',
    mainBranch: 'master',
    active: true
  }
  // webpackなど個別に設定項目があれば、ここに記載する。
})
key type note
splits.branchMappings { [branch: string]: string } | undefined チャレンジャーブランチ名をキーに、対応するVercel上のURLを値としたマップを入力。
複数入力することで、A/B/Cテストのような2つ以上の出し分けも可能。
splits.rootPage string | undefined default: 'top'
ルートページのファイル名(拡張子なし)を指定。
ルートページ名をindexにできないためリネームする必要がる。詳しくは後述
splits.mainBranch string | undefined default: 'main'
メインブランチ名を指定
splits.active boolean | undefined 開発環境などで強制的にA/Bテストをオンにしたい場合に指定。(未指定の場合、自動的に本番のみでアクティブになるようになっています) チャレンジャーブランチで active: true にしてデプロイしてしまうと、リダイレクトループなどが発生するため注意

補足

pages/index.tsx が使用できない

withSplit を使用すると、ルートページ名を index.(tsx|jsx) にすることができません。(Next.js の Rewrite の仕様の問題のようです。もし詳しい方がいればコントリビュートお待ちしています。)
救済措置として、別のファイル名にリネームし splits.rootPage でファイル名を指定することで、変わりなく挙動するようにしています。(A/Bテストが停止していても問題ありません)

強制的にトレイリングスラッシュが有効になってしまう

こちらも先の問題と同じく、Next.js の Rewrite の仕様の問題のようです。
withSplit 側で trailingSlash: true を強制していますので、個別に trailingSlash: false にしても上書きされてしまいます。ご注意を。

A/Bの振り分けが行われるのは VERCEL_ENV === 'production' のみ

開発環境等で強制的にアクセスの振り分けを行いたい場合は splits.activetrue に設定してください。
ただし、チャレンジャーブランチで splits.activetrue に設定されていると、リダイレクトループなどが発生してしまいます。デプロイの際には設定を外すよう注意してください。

理論上、デプロイ先がVercel意外でも上記の仕組みを実現することは可能です。
しかし、この環境変数を見てもわかるように、Vercel 依存の値を使用していますので、現状対応しているのはVercelのみです。
この辺は改善の余地がありますので順次改善していきます。また、コントリビュートお待ちしています。


以上が、next-with-splitの紹介・説明です。

おわりに

「Next.js ABテスト」でググると、Googleオプティマイズを使用する方法が多く解説されています。
確かにオプティマイズはエンジニア以外も使用しやすいため、簡単にA/Bテストを行うには非常に有益なツールだと思います。
しかし、昨今のホットになっている Core Web Vitals の観点から見ると、クライアントサイドでレンダリング後にコンテンツを書き換えるため、どうしてもパフォーマンスのコントロールが難しくなります。

今回紹介した、ブランチベース・CDNベースのA/Bテストは、そういった問題をクリアしています。

多くのプロジェクトで next-with-split を利用していただき、Issue や MR を頂きながら、より良いプラグインにできると良いと考えていますので、ぜひコントリビュートやフィードバックお待ちしています🙇‍♂️