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

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

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

## モチベーション

[Split Testing | Netlify](https://docs.netlify.com/site-deploys/split-testing/) を 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](https://nextjs.org/docs/api-reference/next.config.js/rewrites) の Cookieベースのルート書き換えと、外部ドメイン参照の機能を組み合わせることで、先のA/Bテスト手法を実現する事ができました。
また、この模索した方法をnpmパッケージにして公開しましたので、そのパッケージの紹介も兼ねています。

## サンプル

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

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

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

|original|challenger|
|---|---|
|![スクリーンショット 2021-06-28 0.26.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46467/d098f821-6c50-b299-312f-144e01f45b62.png)|![スクリーンショット 2021-06-28 0.27.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46467/2c0e640c-1b2f-5240-3b5b-84d93567b196.png)|

## 仕組み

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

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

![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46467/5ab7a2fe-2dc9-55a1-aaf8-709bbb5ca738.png)

#### フローチャート

![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46467/9604022d-7879-3cb4-b86e-c1590a60f339.png)

まずはじめに、ユーザはオリジナル(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](https://nextjs.org/docs/api-reference/next.config.js/rewrites#rewriting-to-an-external-url))によってプレビューURLを参照し、ユーザにチャレンジャーのコンテンツを返却します。

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

## 実際の設定

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

##### Rewrite rules

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

<details><summary>`rewrite.js`</summary><div>

```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
```
</div></details>

##### pages/_split-challenge.ts

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

```ts
// 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](https://github.com/aiji42/next-with-split#readme)

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

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

### インストール

```
npm install --save next-with-split
```
or

```
yarn add next-with-split
```

### 使用手順

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

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

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

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

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

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

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

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

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

![スクリーンショット 2021-06-28 10.15.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46467/ca7b969c-6d30-5e3c-9515-64e73378411e.png)

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

```js
// 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テストが停止されます。

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

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

### 設定

#### `withSplit`

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

```js
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 } &#124; undefined |チェレンジャーブランチ名をキーに、対応するVercel上のURLを値としたマップを入力。<br />複数入力することで、A/B/Cテストのような2つ以上の出し分けも可能。 |
|splits.rootPage|string &#124; undefined|**default: 'top'**<br />ルートページのファイル名(拡張子なし)を指定。<br />ルートページ名をindexにできないためリネームする必要がる。詳しくは後述|
|splits.mainBranch|string &#124; undefined|**default: 'main'**<br />メインブランチ名を指定|
|splits.active|boolean &#124; 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.active` を `true` に設定してください。
ただし、チャレンジャーブランチで `splits.active` が `true` に設定されていると、リダイレクトループなどが発生してしまいます。デプロイの際には設定を外すよう注意してください。

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

---

以上が、[next-with-split](https://github.com/aiji42/next-with-split#readme)の紹介・説明です。

## おわりに

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

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

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