front-end に Nuxt.js と Laravel が混在するシステムで Cookie を用いてクライアントを識別する

![Nuxt-2.14.4](https://img.shields.io/badge/Nuxt-2.14.4-brightgreen) ![Laravel-6.x](https://img.shields.io/badge/Laravel-6.x-brightgreen)

[前回の記事](https://qiita.com/hamakou108/items/24533eee75b4a9256f3d)の続きですが、 A/B テストのような文脈でクライアント（ブラウザ）ごとに一意なランダムの ID を割り当て、 Cookie を用いてリクエストごとにクライアントを分類できるようにする処理を実装する機会がありました。この記事では front-end に Nuxt.js と Laravel （の view ）が混在するシステムで、両方から ID を生成・利用できるようにする方法について紹介します。

## 方針と注意

基本的な方針は以下の通りです。

1. リクエストの Cookie ヘッダから ID が得られなかった場合（初回訪問）は ID を生成して Cookie に追加 [^set-cookie] する。
2. リクエストの Cookie ヘッダから ID を得られた場合（2回目以降の訪問）は、それを利用する。

今回の実装では Nuxt.js と Laravel のどちら側でも Cookie から ID を読み取れるようにするため、 HttpOnly 属性を外し、暗号化を行いません。このため、**ログインセッション用の Cookie など、第三者による Cookie の盗聴や改ざんがセキュリティ上のリスクとなるような情報に関しては本記事中の実装方法を避け、フレームワークが提供する機能などを利用してください。**

また front-end がすべて Nuxt.js で構成されている場合には Laravel 側の処理は不要なので、 Laravel のセクションを読み飛ばしてください。

## 実装

### Nuxt.js

以下の `useClientId` は ID の生成および Cookie 操作の処理を集約した composition です。

```ts:app/compositions/client-id.ts
import { Store } from 'vuex/types'
import { IncomingMessage } from 'connect'
import Cookie from 'universal-cookie'
import {
  name,
  StoreAbTesting,
  ACTIONS,
  GETTERS
} from '~/store/abTesting'

export function useClientId(store: Store<StoreAbTesting>, req: IncomingMessage) {
  const CLIENT_ID_KEY = 'client_id'

  const cookie = process.server
    ? new Cookie(req.headers?.cookie ?? '')
    : new Cookie()

  const getIdFromStore = (): string => {
    return store.getters[`${name}/${GETTERS.clientId}`]
  }

  const prepareId = async (): Promise<void> => {
    if (process.client) {
      throw new Error('CSR でのメソッド呼び出しは不正です')
    }

    const generateId = (): string => {
      // ランダムな ID を生成して返す
    }

    await store.dispatch(
      `${name}/${ACTIONS.setClientId}`,
      getIdFromCookie() ?? generateId()
    )
  }

  const getIdFromCookie = (): string|undefined => {
    return cookie.get(CLIENT_ID_KEY)
  }

  const isIncludedInCookie = (): boolean => {
    return getIdFromCookie() !== undefined
  }

  const setCookie = (): void => {
    if (process.server) {
      throw new Error('SSR でのメソッド呼び出しは不正です')
    }

    if (isIncludedInCookie()) {
      return
    }

    cookie.set(CLIENT_ID_KEY, getIdFromStore())
  }

  return {
    prepareId,
    setCookie,
    getId: getIdFromStore
  }
}
```

主要な処理について解説します。

まず Cookie の操作に関しては [universal-cookie](https://github.com/reactivestack/cookies) というライブラリを利用しています。

```ts
  const cookie = process.server
    ? new Cookie(req.headers?.cookie ?? '')
    : new Cookie()
```

このライブラリを利用すると Nuxt の SSR モードでもリクエストヘッダから Cookie を取得することができます。使用例に関しては以下の記事が参考になります。

- [Nuxt \+ universal\-cookieでクライアント、サーバーサイド両方でcookieを扱えるようにする \- Qiita](https://qiita.com/igayamaguchi/items/92b473f28b0e548c0da8)

Nuxt は [Context](https://nuxtjs.org/api/context/) から `context.req` のようにしてリクエストの情報が取得できるので、 `useClientId` の利用時はこの変数を引数に入力します。この変数から `req.headers?.cookie` のようにして Cookie ヘッダの値を文字列として取得できるので、これを `Cookie` コンストラクタの引数に渡してインスタンス生成しています。

次に Cookie から ID を取得、または生成を行う `prepareId` メソッドです。

```ts
  const prepareId = async (): Promise<void> => {
    if (process.client) {
      throw new Error('CSR でのメソッド呼び出しは不正です')
    }

    const generateId = (): string => {
      // ランダムな ID を生成して返す
    }

    await store.dispatch(
      `${name}/${ACTIONS.setClientId}`,
      getIdFromCookie() ?? generateId()
    )
  }
```

最初の処理で CSR でのメソッド呼び出しを禁止しています。これは直アクセス時の SSR の時点で ID を用意しておけば以降の処理で ID を使い回せるので、 CSR での呼び出しが不要になるためです。

`getIdFromCookie() ?? generateId()` の部分で Cookie から ID を取得するか、取得できなければ生成を行っています。取得した ID は [Vuex](https://vuex.vuejs.org/ja/) の Store を用いて保存しておきます [^store-implementation]。 Store に関しても Nuxt の Context から `context.store` のように取得できます。何となく `cookie.set(id)` のようにしておけば後から `cookie.get()` で ID を取得できそうな気もしてしまいますが、 SSR で `cookie.set()` を呼び出してもブラウザ上の Cookie には影響がないため、このような方法では実現できません。

次に Cookie を設定する `setCookie` メソッドです。

```ts
  const setCookie = (): void => {
    if (process.server) {
      throw new Error('SSR でのメソッド呼び出しは不正です')
    }

    if (isIncludedInCookie()) {
      return
    }

    cookie.set(CLIENT_ID_KEY, getIdFromStore())
  }
```

前述の通り SSR で `cookie.set()` を呼び出しても意味がないので、 SSR でのメソッド呼び出しを禁止しています。そして ID が Cookie に含まれていない場合のみ Store に保存しておいた ID を取得して Cookie に設定します。

このように実装した `useClientId` の諸々の処理は plugins から呼び出します。

```ts:app/plugins/ab-testing.ts
import { Context } from '@nuxt/types'
import { useClientId } from '~/compositions/libs/ab-testing/client-id'

export default (context: Context) => {
  if (process.server) {
    useClientId(context.store, context.req).prepareId()
  } else {
    useClientId(context.store, context.req).setCookie()
  }
}
```

前述した通り、 `prepareId` は SSR で、 `setCookie` は CSR で呼び出します。これらの処理を Middleware から呼び出しても問題ないように思えてしまいますが、 Middleware の場合は直アクセス時に SSR の処理しか実行されないため Cookie が設定されません。 Nuxt のライフサイクルに関しては以下の記事が参考になります。

- [【Vue\.js】middlewareやplugins, fetchなどの実行順序 \| HAFILOG](https://hafilog.com/execution-order)
- [Nuxt\.jsのSSR/CSR処理について \- 株式会社Japonline](https://www.japon-line.co.jp/tech/nuxt-js%E3%81%AEssr-csr%E5%87%A6%E7%90%86%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6/)

後は `nuxt.config.js` に plugins を追加し、 Vue コンポーネント等から `useClientId(store, req).getId()` のような形で ID を取得する流れになります。

### Laravel

大まかな内容は Nuxt.js と変わりませんが、 Laravel の場合は Store の代わりに単なる singleton インスタンスとしてメモリに保持する方針で実装します [^laravel-singleton]。

まず singleton インスタンスの元となるクラス `AbTestingCookie` を以下のように作成します。

```php:app/Http/Cookie/AbTestingCookie.php
<?php

namespace App\Http\Cookie;

use Illuminate\Http\Request;

class AbTestingCookie
{
    private $id;

    public function __construct(Request $request)
    {
        $key = 'client_id';

        if ($request->hasCookie($key)) {
            $this->id = $request->cookie($key);
            return;
        }

        $this->id = $this->make();
    }

    public function make()
    {
        // ランダムな ID を生成して返す
    }

    public function id()
    {
        return $this->id;
    }
}

```

コンストラクタでは `Request` を引数に取り、 Cookie から ID を取得するか、取得できなければ生成し、 ID をプロパティに保持しておきます。

singleton を生成するための ServiceProvider の実装は以下のとおりです。

```php:app/Providers/AbTestingServiceProvider.php
<?php

namespace App\Providers;

use App\Http\Cookie\AbTestingCookie;
use Illuminate\Http\Request;
use Illuminate\Support\ServiceProvider;

class AbTestingServiceProvider extends ServiceProvider
{
    public function boot(Request $request)
    {
        $this->app->singleton(AbTestingCookie::class, function ($app) use ($request) {
            return new AbTestingCookie($request);
        });
    }
}
```

次にレスポンスの Set-Cookie ヘッダに ID を付与する処理を Middleware で実装します。

```php:app/Http/Middleware/IssueAbTestingClientId.php
<?php

namespace App\Http\Middleware;

use App\Http\Cookie\AbTestingCookie;
use Closure;

class IssueAbTestingClientId
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $key = 'client_id';

        if ($request->hasCookie($key)) {
            return $next($request);
        }

        return $next($request)->withCookie(cookie(
            $key,
            app(AbTestingCookie::class)->id(),
            0,
            null,
            null,
            false,
            false,  // front-end からも利用するため httpOnly にしない
            false,
            null
        ));
    }
}
```

この Middleware は ID の生成・利用が必要なすべてのルートで呼び出されるように Kernel.php に設定しておきます。また `withCookie()` メソッドはデフォルトで HttpOnly 属性を付与しますが、 Nuxt.js からも読み出せるように HttpOnly 属性を外しています。

最後に Nuxt.js から Cookie を読み出せるように EncryptCookies の暗号化の処理対象から除外しておきます。

```php:app/Http/Middleware/EncryptCookies.php
<?php

namespace App\Http\Middleware;

use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;

class EncryptCookies extends Middleware
{
    /**
     * The names of the cookies that should not be encrypted.
     *
     * @var array
     */
    protected $except = [
        'client_id', // front-end からも利用するため暗号化しない
    ];
}
```

後は view 等から `app()->make(AbTestingCookie::class)->id()` のようにして ID を取得する流れになります。



[^set-cookie]: サーバー側ではレスポンスに Set-Cookie ヘッダを、クライアント側では document.cookie を利用します。
[^store-implementation]: 単に State に clientId を保存するだけなので、 Store の実装は割愛します。
[^laravel-singleton]: [Stack Overflow の Q&A](https://stackoverflow.com/questions/41830944/laravel-setting-cookie-in-middleware-so-its-available-on-the-same-request/41831796#41831796) を参考にしています。