9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

朝日新聞社Advent Calendar 2023

Day 21

朝日新聞デジタルの有料コース申し込みページのフロントエンドシステムリプレースについて

Last updated at Posted at 2023-12-20

はじめに

この記事は朝日新聞社 Advent Calendar 2023の21日目の記事です:newspaper:

この記事について

朝日新聞社が運営する朝日新聞デジタルというサービスでは、月額課金のサブスクリプションコースを展開しています。
お申し込みいただくコースによって、有料記事が読み放題になったり、Web/スマホアプリで有料会員限定の機能が使えるようになったり、より便利に朝デジをご利用いただけます。

スクリーンショット 2023-12-19 22.26.20.png

有料コースをお申し込みいただくためには、コースと決済手段を選択し、各種情報を入力する手順を踏んでいただく必要があります。

スクリーンショット 2023-12-19 22.27.00.png
スクリーンショット 2023-12-19 22.28.10.png

2023年9月、よりスムーズなお申し込みを実現するために、Webのお申し込みフローとUIを全面的にリニューアルしました:tada:

この記事ではそのWeb申し込みページのリニューアルに伴う、フロントエンドシステム全体のリプレースについてご説明し、技術的に工夫した点をご紹介します。

経緯

これまでのWebの申し込みページは、フレームワークレスのPHPで動いており、サーバサイドで生成したHTMLをブラウザに返すような一般的な作りでした。
しかし、コードベースが古く、システムの全容を知っている開発者も限られている状態で、いわゆるレガシーシステムと言える状態です。

そのため、申し込み途中でのお客さまの離脱を防ぐためのフローの大規模な見直しや、ABテストを伴うような各種導線の出し分けなどの気軽な改修が困難で、また決済関連の外部システムのアップデートに伴う対応についても極めて対応が難しい状態であり、ビジネス・システム面の両方で多くの課題を抱えている状態でした。

そこで、申し込みページに関係するフロントエンドシステム全体を作り直し、同時に申し込みフローとUIを見直すプロジェクトが2021年末ごろからスタートしました。
構想から2年近くの時間がかかってしまいましたが、晴れて2023年9月にリプレースが完了しました。

2023年12月現在、申し込みフローや各種導線を整理した結果、旧ページと比較すると

  • 有意に申し込み途中での離脱が改善している
  • 申し込み完了後のオンボーディング機能を持たせたことにより申し込み完了後のアプリ利用率が上がった
旧ページ 新ページ
スクリーンショット 2023-12-20 20.47.41.png スクリーンショット 2023-12-20 20.48.02.png

等、ビジネス面では効果が数値に表れています。
また、システム面でも、モダンな開発体制に移ったことで、保守・運用がしやすくなりました。
特に、定期的に行うキャンペーンに伴う対応では大きな効果を発揮し、ビジネス・開発チームの連携も以前よりもスムーズになったように思えます。
(【宣伝】朝日新聞デジタルの「初トクキャンペーン」が本日12月21日から開始しました!!)

構成・設計

それではリプレース後のアプリケーションの構成と設計についてご紹介します。
まず、申し込みフローを実現するために必要なアプリケーションの概念図は以下のようになります。

スクリーンショット 2023-12-17 15.45.44.png

名前 役割 言語・フレームワーク
フロントエンドアプリケーション 申し込みページを表示して、バックエンドサーバーとのやりとりを行うシステム。SPA(=シングルページアプリケーション)として構成。 React+TypeScript+ReactRouter(+Node.js)
バックエンドサーバー フロントエンドアプリケーションがリクエストしやすい形で内製課金システムをラップした、腐敗防止層としての役割を持つシステム。 Go
内製課金システム 会員申し込みや月額課金などの、朝日新聞デジタルの課金に関する全ての役割を持つシステム。 PHP
決済代行サービス クレカやキャリア決済・AmazonPay等の外部の決済代行システム。

今回新規作成したのは、図中にもある通り、フロントエンドアプリケーションとバックエンドサーバーです。
フロントエンドアプリケーションは、React+TS+ReactRouterで構成した、一般的なSPAアプリケーションです。MPA(=マルチページアプリケーション)として設計しなかったのは、詳細は後述しますが、サーバーサイドでユーザーごとに異なる中間データを持たない構成にしたかったのが理由です。
状態管理としては、グローバルなStateオブジェクトをインメモリで持ち、状態の参照と更新が非同期で行えるようなreduxライクな薄い状態管理フレームワークを自作しています。

バックエンドサーバーは、内製課金システムのラッパーとして、使いにくい部分を吸収する腐敗防止層として置いています。

また、図中の内製課金システムは、朝日新聞デジタルの会員管理と課金管理を司るモノリシックな内製のシステム基盤です。月額課金を実行するためのAPIが用意されているため、それらを利用して申し込みフローを実現できます。
リソース等の社内的な事情もあり、今回のリプレースでは課金システムの領域には一切手を入れずに行いました。

SPAとセッションについて

リプレース前の申し込みページは、実は、お客さまが申し込みフローの途中で入力した内容を、ページリロードやブラウザバック・フォワードで復元することができない作りになっていました。

これはどういうことかというと、例えば名前や住所などのお客さま情報を入力して次の画面に遷移した後に、入力内容に誤りがあることに気がついて、一つ前の画面に戻ろうと思っても、正しい入力内容が復元できなかったり、またページをリロードすると、エラー画面に落ちて、申し込みフローの始めからに戻されてしまいます。
これはユーザビリティーを大きく損ね、申し込み途中の離脱の原因になっていると判断し、画面を行き来しても状態が復元できるような作りにする必要がありました。
また、決済手段によっては、一度決済代行サービスの外部ページに遷移してから、申し込みページに戻ってくるフローがあり、遷移前後で状態を復元できるようにする必要があります。

こういった要件を実現するためには、お客さまが入力した情報を、どこかで誰かが一時的に保存しておく必要があります。

誰が保存するのか

考えられる方法として、サーバサイドでデータを保存するというのは一つの手段でしょう。

申し込みのためのセッションを払い出し、セッションごとに途中状態をデータベースに記録しておき、リクエストのたびにデータベースからデータを読み出して、フロントエンドアプリケーションに返す、というような作りになるかと思います。
こうすればMPAとして、サーバサイドで入力済みの内容をHTMLとしてレンダリングして、ブラウザに返すという作りにできそうです。

内製課金システム側では申し込みのためのセッションを払い出しているため、内製課金システム側でセッションごとにデータを一時保存するような作りにするのがシンプルそうです。
しかし、まず既存の仕組みとして存在しておらず、前述したように今回のリプレースでは内製課金システムには手を入れられない制約があったため、これは却下です。

課金システム側に保存できないのであれば、今回新規作成したバックエンドサーバーに保存することが考えられます。
しかし、課金システム側とは異なるライフサイクルでデータを管理する必要が出てきてしまい、シーケンスとして複雑になる上、一時的とはいえお客さまの個人情報を保存することになるため、セキュリティの観点でも考慮しなくてはならないことが多くなります。

そこで、サーバサイドに一時保存はせず、ブラウザのセッションストレージに一時保存するという方法を選択しました。
SPAであれば、状態の一時保存と復元がブラウザ上だけで完結するため、シンプルな構成になります。
サーバサイドでお客さまの個人情報を一元管理する必要がないため、セキュリティ的にも考慮することが減ります。

セッションストレージについて

そういった経緯から、フロントアプリケーションではお客さまが入力した内容の一部をセッションストレージに一時保存しています。
アプリケーションのアーキテクチャとして特徴的な部分であり、いくつか技術的に工夫した点があるのでご紹介します。

暗号化

セッションストレージは、異なるドメインからは、アクセスできない仕様があります。
そのため、基本的にはお客さまの入力した内容が他サービスのWebページや他システムから参照されることはないのですが、同一ドメイン内にXSS(クロスサイトスクリプティング)のような脆弱性があった場合にはその限りではありません。

そのため、セキュリティリスクを考慮し、入力したデータを暗号化した上でセッションストレージに保存することにしました。
(暗号化に関する詳細なアルゴリズムの説明は省きます)

セッションストレージのライフサイクル管理

セッションストレージはタブやウィンドウが開いている間はセッションが割り当てられ、タブやウィンドウを閉じた場合にはセッションが終了し、保存していた値が消えます。
逆に言えば、タブやウィンドウを閉じずに同一ドメインのWebサイトを利用している間は、値が保存されたままになるということです。

Webの申し込みページで、朝日新聞デジタルの有料コースに申し込んだお客さまが、そのままタブやウィンドウを閉じずに朝日新聞デジタルサイト内を回遊すると、その間は入力したデータが消えずに残ってしまいます。
暗号化しているとはいえ、データが残り続けることはあまり行儀が良いものではないため、申し込み完了画面に遷移したタイミングで、セッションストレージの値を明示的に消去しています。

これによる副作用として、申し込み完了画面に遷移した時点でページリロードすると、それまで引き継いできた情報が復元できずにエラー状態に遷移してしまうのですが、こちらはトレードオフとして妥協したポイントです。

保存するオブジェクトを厳密にチェックする

突然ですが、TypeScriptは以下のようにオブジェクトの型に互換性があれば、型エラーにならない仕様があります(Playground)

type User = {
  name: string;
};
type UserWithAddress = User & { address: string };

const user: UserWithAddress = {name: "朝日太郎", address: "東京都中央区築地5-3-2"};

const save = (user: User) => {
  // 何らかの永続化する処理
}

save(user); // <- address プロパティ はUser型には存在しないのに引数に指定できてしまう

上記の例だと、save関数内では、User型に存在しないプロパティは参照できないため、通常は問題になるケースは少ないように思えます。
しかし、引数として受け取ったオブジェクトを、そのままAPIリクエストに使用したり、あるいはWebストレージに保存して永続化するような場合は、注意が必要です。

特に今回、react-hook-formで、アプリケーションのStateとして取り回すオブジェクトを、Reactのformとinput要素にバインドすることで、画面上のStateとアプリケーションのStateを同期できるような設計にしているのですが、そこで以下のような問題が起きました。

import { useForm } from 'react-hook-form';

// これはグローバルでState管理しているオブジェクトの型
type User = {
 name: string;
 address: string;
}


const UserInput = () => {
  // User型を拡張する
  const {register, handleSubmit} = useForm<
        User & { isChecked: boolean }
    >({name: "朝日太郎"});

  const save = (user: User) => {
    // 何らかの永続化する処理
  }
    
  return (
      <form onSubmit=(handleSubmit((data) => {
        /* ここのdataは以下のようになる 
        {
            name: string;
            address: string;
            isChecked: boolean;
        }
        */
        save(data);
      })>
          <input {...register("name")}/>
          <input {...register("address")}/>
          <input {...register("isChecked")}/>
          <button disabled={isChecked} />次に進む</button>
      </form>
  );
}

この例は、お客さまの名前や住所などの個人情報を入力する画面と考えてください。

この画面では、「利用規約を読んだかどうか」というチェックボックスにチェックが入っている場合は、次の画面に進むためのボタンが押せるようになるという仕様とします。

この時、名前や住所を入力するinputと、Userオブジェクトのそれぞれのプロパティをbindします。
また「利用規約を読んだかどうか」の状態をisCheckedとして表現し、こちらもinputとbindします。

この時、handleSubmit関数のコールバックで得られるオブジェクトには、isCheckedプロパティが含まれるようになってしまいます。
isChekcedプロパティはこの画面でのみ必要な情報で、グローバルなStateとして取り回す必要がないのですが、前述したように型の互換性がある場合に型チェックができないため、この意図しない余計な情報が付与されたオブジェクトがそのままセッションストレージに保存されてしまう可能性があります。

意図しない情報が含まれている場合、リロード時等にセッションストレージから情報を復元した時が厄介です。
本来永続化すべきではない情報が永続化されてしまうということは、思わぬ不具合の温床になりえます。

この例でいうと「そもそもisCheckedをuseFormに含めずに、別途useStateで管理して手動でbindすればいいのでは」という意見もあると思います。
それは尤もなのですが、新たにチームに参画した開発者が、そういった事情を知らない間に、上記のようなコードを書いてしまう可能性もあります。

そこで根本的に意図しない情報がセッションストレージに保存されないような策を講じました。

まず、型チェックで気づけるようなアプローチを試しました。
TypeScriptで強制的に余剰プロパティチェックする型を作るを参考に、以下のようなStrictPropertyCheck型を作成します。

export type StrictPropertyCheck<T, TExpected> = Exclude<keyof T, keyof TExpected> extends never ? T : never;

先の例だとこのような使い方になります。
Playground


const save = <T extends User>(user: StrictPropertyCheck<T, User>) => {  
}
const user: UserWithAddress = {
    name: "朝日太郎",
    address: "東京都中央区築地5-3-2",
}
// Argument of type 'UserWithAddress' is not assignable to parameter of type 'never'.(2345)
save(user);

これで、余計なプロパティが含まれる場合は型エラーになります。
おそらくこのアプローチで大体のケースは防げると思います。

ただ、詳細の説明は省きますが、既存コードの構成上、Stateを更新するための関数にそれぞれStrictPropertyCheckによる型チェックを実装する必要があって、大規模なリファクタリングが必要になる上、原理的にその実装を強制することができず、万が一コードレビューで気がつけなかった場合にすり抜けてしまう危険もありました。

次に、型チェックで行うのを諦めて、以下のように、再帰処理を用いたgetStrictObject関数を作成しました。
Playground


type Primitive = boolean | number | string | undefined | null | Array<unknown>;

const isObject = (input: Record<string, unknown> | Primitive): input is Record<string, unknown> =>
    typeof input === 'object' && !Array.isArray(input) && input !== null;
    
const getStrictObject = (
    inputObject: Record<string, unknown> | Primitive,
    baseObject: Record<string, unknown> | Primitive
): Record<string, unknown> | Primitive => {
    if (!isObject(baseObject) || !isObject(inputObject)) {
        return inputObject;
    }
    const expectedKeys = Object.keys(baseObject);
    return expectedKeys.reduce<Record<string, unknown>>((res, key) => {
        res[key] = getStrictObject(
            inputObject[key] as Record<string, unknown>,
            baseObject[key] as Record<string, unknown>
        );
        return res;
    }, {});
};

第一引数に検査対象のオブジェクト、第二引数にベースとなるオブジェクトを渡すと、検査対象のオブジェクトから、ベースオブジェクトに含まれないキーは除いたオブジェクトが返ってきます。

const inputObject = {
    Key1: 'InputValue1',
    Key2: {
        Key1: 'InputValue1',
        Key2: 'InputValue2',
        Key3: 'InputValue3',
    },
    Key3: 'InputValue3',
    Key4: {
        Key1: 'InputValue1',
        Key2: 'InputValue2',
        Key3: 'InputValue3',
    },
};
const baseObject = {
    Key1: 'BaseValue1',
    Key2: {
        Key1: 'BaseValue1',
        Key2: 'BaseValue2',
        Key3: 'BaseValue3',
    },
    Key3: 'BaseValue3',
};
console.log(getStrictObject(inputObject, baseObject));
/*
  "Key1": "InputValue1",
  "Key2": {
    "Key1": "InputValue1",
    "Key2": "InputValue2",
    "Key3": "InputValue3"
  },
  "Key3": "InputValue3"
  // Key4は含まれない
*/

セッションストレージに保存する前にgetStrictObject関数を通せば、意図しないキーを削ぎ落とすことができます。
この実装だと、必ずセッションストレージに保存する前にランタイムでのチェックが走るので、確実に動作させることができます。

まとめ

この記事では朝日新聞デジタルの有料コースWeb申し込みページのリニューアルに伴う、フロントエンドシステム全体のリプレースについてご説明し、技術的に工夫した点をご紹介しました。
引き続き、より使いやすいニュースサービスを目指し、日々改善を進めていきます。

9
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?