ZOZOTOWNのマーケティングプラットフォームでのフロントエンドの取り組み

ogp

はじめに

こんにちは、MA部の林(@hayash__p)です。

私達のチームでは、メール、LINE、Push通知、サイト内お知らせなどでユーザにZOZOTOWNのセールや新着商品を紹介するといった、マーケティングに関わるシステムを開発しています。これまで、配信チャネルや配信内容ごとに個別最適化したシステムを開発していましたが、それらを一新したマーケティングプラットフォームを作ることになりました。新しいマーケティングプラットフォームであるZOZO Marketing Platform(以下、ZMP)の概要については以下のテックブログをご覧ください。

techblog.zozo.com

本記事では、マーケティングプラットフォームのリプレイスにあたり、フロントエンドエンジニアとして取り組んだことを紹介します。

目次

背景

ZMPでは、マーケターがキャンペーンの管理・運用をより手軽に行うため、新規に管理画面を作ることになりました。ZMPの初期リリース段階(以下、フェーズ1)での管理画面の要件は以下でした。

  • メール、Push通知による配信の設定ができること
  • 拡張を前提とした画面であること(後々、LINE、サイト内お知らせなど、既存システムで利用している配信チャネル全てを網羅する想定のため)
  • 画面には配信の関係者のみがアクセスできること

今まで、管理画面のないマーケティングシステムのバックエンド開発が中心だったこともあり、MA部にはフロントエンドに特化したチームがありませんでした。しかし、ZMPには複数のマーケティングシステムを集約するような画面が必要でした。そのため、フロントエンド、バックエンドそれぞれに主軸を持った開発者同士のチームで、ZMPの管理画面用Webアプリケーションを構築することになりました。

ZMPの管理画面モジュール MPマネージャー

ZMP管理画面のフロントエンドモジュールを、Marketing Platform Managerを一部省略し、MPマネージャーと呼んでいます。ここからは、MPマネージャーで採用した技術、関連システムを含む構成、MPマネージャー自体の構成について紹介します。

技術選定

フロントエンドフレームワークは、ZOZOTOWNのWebホーム画面でも利用されているReact・Next.jsを採用しました1。また、Next.jsのルーティングシステムは、技術選定の時期にbetaとして登場したApp RouterをZOZO社内で初めて採用しました。当時はPage Routerがstableでしたが、Page Routerを選んだ場合、App Routerがstableとなった際に切り替えコストがかかってしまうことを考慮したためです。

私自身は当時の選定には携わっていなかったのですが、実際に手を動かして今回開発をしたところ、App Routerに関する課題がいくつか発生しました。具体的な例を挙げて後述します。

MPマネージャーと関連システムの構成について

MPマネージャーとその関連システムの構成はこのようになっています。

MPマネージャーの構成図

内製のAPI(以降、バックエンドAPI)は配信設定のロジックはもちろん、DBやストレージ、外部APIとの疎通を担当しています。そんなバックエンドAPIから取得したデータを表示したり、フォームで入力したデータをバックエンドAPIに送信したりするのがMPマネージャーの担当領域となっています。

バックエンドAPIは同時並行で開発しており、バックエンドAPIとフロントエンドのインタフェースを共通化し相互連携を強化するために、OpenAPIで定義を作り共用しました。MPマネージャーではその定義を元にPrismでモックサーバーを立てて開発を進めました。

MPマネージャーでのフォルダ構成と役割について

App Routerを使った場合のフォルダ構成の情報が少ないため、App Routerを使って開発する方の参考になればと思い、MPマネージャーでのフォルダ構成を紹介します。

MPマネージャーでは、bulletproofをベースとしつつ、Next.jsのルーティングルールも尊重して、フォルダ構成を設計しました。

下記は、MPマネージャーのフォルダ構成です。このうち、bulletproof・App Router・OpenAPIの組み合わせにより、特殊な実装・構成となった部分をピックアップして紹介します。

.
├── src/
│   ├── api/
│   ├── app/
│   │   ├── _api/
│   │   │   └── usersServer.ts
│   │   ├── _components/
│   │   │   └── UserIcon.tsx
│   │   ├── api/
│   │   │   └── users/
│   │   │       └── route.ts
│   │   ├── users/
│   │   │   ├── _components/
│   │   │   │   └── UserForm.tsx
│   │   │   └── page.ts
│   │   ├── error.tsx
│   │   ├── layout.tsx
│   │   ├── loading.tsx
│   │   └── page.tsx
│   └── _components/
│       └── Button.tsx
...

OpenAPI用のフォルダ

まずは、apiに関連するフォルダについてです。

└── src/
    └── api/

こちらは、OpenAPI Generatorを用いてOpenAPIの定義ファイルをTypeScript用に自動生成したファイルの置き場です。ルーティングに含みたくなかったこと、型定義のファイル群でありアプリケーションそのものと切り離したかったことから、src/app配下ではなく、src直下に専用のディレクトリを設けることにしました。

フロントエンドAPI用のフォルダ

MPマネージャーでは、Route Handlersを用いてフロントエンド側にもAPIを構築しました(以降、フロントエンドAPI)。ここからは、フロントエンドAPI用のフォルダについて紹介します。

└── src/
    └── app/
        └── api/
            └── users/
                └── route.ts

フロントエンドAPIを用意したのは、Client ComponentとServer Component、両方からのアクセスとバックエンドAPIを繋ぐインタフェースを用意したかったためです。フロントエンドAPI(src/app/api/[モデル名]/route.ts)は、OpenAPIを利用してバックエンドAPIにアクセスする実装となっています。

// src/app/api/users/route.ts

import { NextResponse, NextRequest } from 'next/server';
import { UserApi } from '@/api';

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  try {
    const api = new UserApi();
    const users = await api.getUsers();
    return NextResponse.json(users);
  } catch (error) {
    // エラー処理
  }
}

また、そのフロントエンドAPIを叩くためのfunctionをまとめたのが、src/app/_apiフォルダです。

└── src/
    └── app/
        └── _api/
            └── usersServer.ts
'use server';

import { fetchWithJwtIap } from '@/lib/typescript-fetch-on-server-component';

export const getUserList = async () => {
  const res = await fetchWithJwtIap(
    // MEMO: NEXT_PUBLIC_HOST = フロントエンドのホスト
    `${process.env.NEXT_PUBLIC_HOST}/api/users`,
  );
  ...
};

page.tsxにて、下記のように呼び出して利用します。

// src/app/users/page.tsx

import { getUserList } from '@/app/_api/usersServer';
async function Page() {
  const users = await getUserList();
  return <UserList users={users} />;
};

共通コンポーネント用のフォルダ

MPマネージャーでは、以下のルールで共通コンポーネントのフォルダを3通り用意しました。

└── src/
    └── _components/
         └── Button.tsx

bulletproofを参考にしたため、基本的にはこのsrc/_componentsに共通コンポーネントを配置しています。

└─ src/
    └── app/
        └── _components/
             └── UserIcon.tsx

こちらのsrc/app/_componentsは、OpenAPIに依存した共通コンポーネントの置き場です。先述のsrc/apiから何かしらのファイルを読み込み、かつ複数箇所で使われる場合はこちらに配置します。

└── src/
     └── app/
         └── users/
              └── _components/
                   └── UserForm.tsx

最後に、src/app/[モデル名]/_componentsは特定のページでのみ使うコンポーネントの置き場です。使用するページが限定的であれば、近くに配置した方が管理しやすいため、このフォルダも用意しました。

このフォルダ構成にしたことで、OpenAPIひいてはバックエンドAPIに依存するコードかどうかや広範囲で使われているコンポーネントかどうかを意識しやすくなりました。

開発中に出てきたApp Router関連の課題

ここまでは、MPマネージャーを俯瞰的に解説してきました。ここからは、開発中に出てきたApp Router関連の課題とその解決法について説明します。

技術選定時はbetaで始まり、開発中にstableとなったApp Routerでしたが、stableとなった後でも利用事例が少なく、GitHubのコメントだけが頼りになることもしばしばありました。ここからは、開発中に詰まった点を実際の例を用いて紹介していきます。これからApp Routerでの本番アプリケーション開発を検討している方・すでに開発を始めている方の参考になれば幸いです。

Serverからのfetchでは、リクエストオブジェクトにアクセスできない

MPマネージャーでは、「画面には配信の関係者のみがアクセスできる」ように、想定外のユーザーがアクセスできないようIAPによる認証を実施し、バックエンドAPIに認証情報を送っています。このIAPによる認証情報は、カスタムヘッダーに格納されています。

しかしApp Routerでは、Serverからのfetchではリクエストオブジェクトにアクセスできないという仕様により、カスタムヘッダーが取得できませんでした。

GitHub.com

// src/app/users/page.tsx
export const dynamic = 'force-dynamic';

export default async function Page() {
  const users = await fetch(
    `${process.env.NEXT_PUBLIC_HOST}/api/users`,
  );
  ...
}
// src/app/api/users/route.ts
import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  console.log(request.headers.get('X-Goog-IAP-JWT-Assertion'));
  // null
}

こちらは、Discussionsのコメントを参考に、fetchの前にnext/headersを読み込むことで解決しました。

// https://github.com/vercel/next.js/discussions/44270#discussioncomment-6064242 の対策のため、constで定義
const headers = import('next/headers');

export const fetchWithJwtIap = async (
  requestInfo: RequestInfo,
  init?: RequestInit | undefined,
) => {
  const headersList = (await headers).headers();
  const jwtIap = headersList.get('X-Goog-IAP-JWT-Assertion');

  return fetch(requestInfo, {
    ...init,
    headers: {
      ...init?.headers,
      ['X-Goog-IAP-JWT-Assertion']: jwtIap,
    },
  });
};

今回はカスタムヘッダーを取得する実装になりましたが、Cookieなどの他のリクエストオブジェクトにアクセスしたい場合も、同じようにnext/headersを読み込むことで実装できそうです。

Server Actionsを後から導入したため、リファクタリングの余地がある

MPマネージャーでは、フォームデータの送信はClient Component、初回表示用のデータ受信はServer Componentと、両Componentからfetchを使用しています。ただ、前述の通り、Client ComponentからのfetchとServer Componentからのfetchにて、認証・認可のためのコードに若干の違いが発生してしまいました。そこで開発初期は、ファイルを2つ用意してClient Componentでは[モデル名]Client.tsを、Server Componentでは[モデル名]Server.tsを呼び出す実装にしました。

Client Componentでのfetchの例は次のとおりです。

// src/app/_api/usersClient.ts
export const postUser = async (...) => {
  const res = await fetch(
    `/api/users`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: ...
    },
  );
  ...
};
// src/app/users/new/page.tsx
import { UserForm } from '@/app/users/_components/UserForm';

async function Page() {
  return <UserForm />;
};

// src/app/users/_components/UserForm.tsx
'use client';

import { postUser } from '@/app/_api/usersClient';

async function UserForm() {
  return (
    <form>
      ...
      <button type="submit" onClick={postUser(...)} />
    </form>
  );
};

また、Server Componentでのfetchの例は次のとおりです。

// usersServer.ts
'use server';

import { fetchWithJwtIap } from '@/lib/typescript-fetch-on-server-component';

export const getUserList = async () => {
  const res = await fetchWithJwtIap(
    // MEMO: NEXT_PUBLIC_HOST = フロントエンドのホスト
    `${process.env.NEXT_PUBLIC_HOST}/api/users`,
  );
  ...
};
// src/app/users/page.tsx
import { getUserList } from '@/app/_api/usersServer';

async function Page() {
  const users = await getUserList();
  return <UserList users={users} />;
};

後々、Server Actionsがリリースされ、Client ComponentでのfetchをServer側の処理として扱うことができるようになりました。その結果、Client Componentからも[モデル名]Server.tsが呼び出されるようになり、現在は[モデル名]Server.tsだけが残っています。

// usersServer.ts
'use server';

import { fetchWithJwtIap } from '@/lib/typescript-fetch-on-server-component';

export const getUserList = async () => {
  ...
}

export const postUser = async (...) => {
  const res = await fetchWithJwtIap(
    `${process.env.NEXT_PUBLIC_HOST}/api/contents`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: ...,
    }
  );
};
// src/app/users/new/page.tsx
import { UserForm } from '@/app/users/_components/UserForm';

async function Page() {
  return <UserForm />;
};

// src/app/users/_components/UserForm.tsx
'use client';

import { useTransition } from 'react';
import { postUser } from '@/app/_api/usersServer';

async function UserForm() {
  const [, startTransition] = useTransition();
  const handleClick = (...) => {
    startTransition(async () => {
      await postUser(...);
    });
  };
  return (
    <form>
      ...
      <button type="submit" onClick={handleClick(...)} />
    </form>
  );
};

開発途中で追加された機能を導入したこともあり、試行錯誤を経てこの構成に辿りつきました。そのため、バックエンドAPIが求める形に入力値を加工する処理などがServer ActionsとRoute Handlersに点在してしまっています。バックエンドAPIにより近い位置で加工した方が良いと考えているため、将来的にはRoute Handlersに処理を集約できたらと考えています。

エラー画面が描画された場合も、ステータスコードが200で固定になってしまう

Next.jsには本番環境ではServer Componentで起きたエラーはマスキングされてClient Componentに送られるという仕様があります。

nextjs.org

そのため、Server Componentでthrow Errorをするとエラーメッセージの詳細が取得できなくなってしまいました。特にバリデーションエラー時に、何が原因でエラーになったのかをClient Componentで判別できず困りました。

MPマネージャーでは、バリデーションエラーなどの想定したエラーであれば、200系のステータスコードとエラー理由を含んだJSONをレスポンスとして返す実装にして乗り越えました。個人的には、エラー時にエラー系のステータスコードを返す実装が好ましいと思っているため、今後のアップデートを注視していきます。

また、MPマネージャーではSuspenseによるStreamingを利用していたのですが、これがステータスコードに想定外の影響を与えていました。Suspenseを使うと、データのfetch中にローディングマークを表示できます。ですが、画面の描画が始まっている = ルーティングは正常に完了していることから、fetch中にエラーが起こってエラーページが呼び出された場合も、200の正常系のステータスコードが返されていたのです。

nextjs.org

この問題には有効な解決策を打てないままでした。しかし、画面の描画が始まった後のエラーはSentryでキャッチできます。また、MPマネージャーそのものが落ちていないかの死活監視は、ロードバランサのステータス監視で代用できます。以上の設定があれば、MPマネージャーが何かしらエラーで落ちてしまった場合も検知できると判断しました。

現在、GitHubのDiscussionにて議論されているため、こちらもアップデートを注視していきます。

テストライブラリがServer Componentに未対応

MPマネージャーでは、テストライブラリとしてjestやtesting-libraryを採用しました。最初はClient Componentのユニットテストを中心に実装していたため、特に問題なく利用できました。ですが、Server Componentのテストには未対応でした(testing-libraryのIssue)。そのため、Server Componentのユニットテストが実装できずにいました。

Issueでは回避策としてE2Eテストが上げられていました。Next.jsではplaywrightのE2Eテストがサポートされていることから、Server Componentが絡むコードは、E2Eテストで実装することとなりました。これにより、網羅的にテストコードを書くことができ、品質を担保できるようになりました。

まとめ

ZMPの振り返り・今後

2024年1月から、ZMPを利用して設定したデータを元にメール、Push通知の配信ができるようになりました。

画面のスクリーンショット

このように、画面上でメール、Push通知による配信の設定ができるようになっています。

ここまでで、フェーズ1に要件として上がっていた全ての項目が達成されています。

  • メール、Push通知による配信の設定ができること
  • 拡張を前提とした画面であること(後々、LINE、サイト内お知らせなど、既存システムで利用している配信チャネル全てを網羅する想定のため)
  • 画面には配信の関係者のみがアクセスできること

今後は、フェーズ2以降に予定されている機能の画面開発を進めていきます。

最終的に、ZMPには多種多様なマーケティングに関わるシステムが集約されることになるため、マーケターにとって操作しやすい管理画面が提供できるよう、開発を続けていきます。

MA部フロントエンドとしての振り返り・今後

MPマネージャーでは、App Routerという新しく利用例が少ない技術を採用しました。実際に開発を始めてみると、日本語ドキュメントが少ないこと、社内初の利用だったため知見が共有できなかったことから、MA部のフロントエンドとしては挑戦的な技術選定になってしまったように思います。具体的には、リクエストヘッダーを操作したり、テストを拡充したりなど、本番利用では欠かせない機能を開発している時に壁を感じることが多かったです。

しかし、今回出てきた問題点は、GitHubのIssueやDiscussionを参考に対応策を編み出せたため、リリースまで辿り着きました。また、認証など、周辺モジュールと連携する土台が完成しています。以上から、App Routerでの開発体制が軌道に乗り始めたところだと考えており、ZMPでは今後もApp Routerを使い続ける予定です。

今後は、既存システムのZMPへの移行が属人化しないよう、MAのフロントエンドのチーム体制を整えていきたいと考えています。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー