14年間継ぎ足し続けられた課金ページを分離して整理した話

こんにちは。

(ニコニコ)プレミアム課金開発チームに所属しております @ingen084 です。

去年は [ニコニコで13年運用された決済システムが移行されてからのその後の改善](https://qiita.com/ingen084/items/17cc2be96727271061a8) を投稿させていただきましたが、この投稿は前座で、実際に環境が整ったところでシステム上でどのような変更を加えていったかなどを書いてみたいと思います。

## (改めて)ニコニコプレミアムについて

|入会ページ|退会ページ|
|:-:|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/a918b144-3c4e-fbff-d6f9-01c95079f71c.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/9c0cd971-d2af-bbdd-a86e-eddfdbba7986.png)|

ご存じの方は多いと思いますが、ニコニコプレミアムは月額500円(税別)のニコニコのプレミアム会員サービスです。
これらのシステムは当時ニコニコ動画本体のコードに組み込まれていましたが、[一昨年分離され](https://qiita.com/chiyoppy/items/2571e8e3ae675cc7a991)身軽になり去年、今年と機能追加などと並行して改善を続けています。

## 決済の流れと課題

ニコニコプレミアムでは主に以下のフローで入会時の決済を行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/8169156a-dfac-3e76-86cc-f6682fb82196.png)

これらのページ構成は2007年のプレミアム会員サービスの開始当時から殆ど変わらずに拡張され続けており、苦し紛れに追加された処理が更に複雑度を増していくという悪循環が続いていました。

### 1ページに複数の決済手段が詰め込まれている

決済手段選択から確認画面･解約時の確認画面･決済代行サービスへのリダイレクトなど、かなりの割合の画面が1つのレガシーPHPで動作するエンドポイントに実装され、ユーザーの状態やパラメータを使用して制御が行われていました。
同じようなフローにも関わらず各決済手段の処理やテンプレートがバラバラに実装されており、他の決済手段の知識を応用できないといったこともよくありました。

### 処理を追うことがほぼ不可能になっていた

レガシーなPHPのため巨大なif文や変数の上書きなど多数存在しており、処理を追うことがとても難しく変更に対する副作用が大きいコードになっていました。
また、決済関連のパラメータを巨大な入れ子の連想配列にまとめた上で参照を渡し加工したり、そのままテンプレートにセットしている箇所があり、参照が追いきれず全体像がほぼ理解できない物になっていました。

## ひとまずのゴールの設定

まとめると、主に2つの課題が改善を妨げていることがわかりました。

1. 複数の決済手段が1つのページ上で絡まっている
1. ほぼブラックボックスと化している連想配列がテンプレートから利用されている

もちろんすべてを完璧なコードにするのがゴールではありますが、この2つさえなんとかなれば今後継続的に改善ができるようになると考え、集中的に改良することとしました。

紹介順と実際の順序は一致していない(キャリア決済を一番初めに実装したり、テンプレート周りは時系列的には少しあとに行った)点についてはご了承下さい。

## 決済遷移の抽象化

各決済をページから切り離していくにあたり、決済遷移を抽象化して扱えるようにしました。
決済遷移は基本的に入会ページと確認ページと完了ページが存在するというシンプルなものであるため、これらの遷移を汎用的に扱うエンドポイントを1つ用意し、処理を切り離していくことにしました。

また、入会時のレコードの更新なども新たに汎用的に扱えるクラスを用意し、モダンなコードからは必ずこのクラスを利用する形になりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/0d20d795-13ef-93c1-461a-0d73e6b6c556.png)
(エンドポイントへのアクセスからDBリポジトリまでの各クラスの参照イメージ図)

これは擬似的なコードですが以下のような interface をもとに各決済遷移を実装しています。

```php
interface RegistrarInterface {
    /**
     * 指定されたユーザーがこの決済手段を利用可能かどうか判断する
     * @param User $user 対象のユーザー
     * @return bool 利用可能か
     */
    public function isPurchasable(User $user): bool;
    /**
     * 購入前の確認画面に表示する内容を返す
     * @param User $user 対象のユーザー
     * @return ConfirmDetail|null 表示内容 null の場合は表示しない
     */
    public function getConfirmDetail(User $user): ?ConfirmDetail;
    /**
     * 決済代行サービス等の決済遷移先を取得する
     * @param User $user 対象のユーザー
     * @param string $successUri 決済完了時の遷移先
     * @param string $errorUri 決済エラー時の遷移先
     * @param string $cancelUri 決済キャンセル時の遷移先
     * @return Destination 遷移先の詳細
     */
    public function getLink(
        User $user,
        string $successUri,
        string $errorUri,
        string $cancelUri
    ): Destination;

    /**
     * 決済を確定させ、決済完了ページへリダイレクトする
     * @param User $user 対象のユーザー
     * @param Request $request 完了時のリクエスト
     * @param string $completeUri 完了ページのURI
     * @return Destination
     */
    public function complete(
        User $user,
        Request $request,
        string $completeUri
    ): Destination;
}
```

各遷移は RegistrarID と呼ぶ文字列で管理され、このIDを元に Controller 側において使用する Registrar を切り替えます。
Controller は同意等のパラメータなどをもとにこの interface のメソッドを実行し、結果に応じてリダイレクトなどのレスポンスを返します。
決済を確定させる際は決済ページからの遷移の仕様がバラバラのため Request クラスをそのまま受け渡す形になっていますが、1つのファイルでごちゃごちゃしているよりもずっと保守性が高くなるはずです。

遷移を interface にして制約を設けることで、**ある程度自由度を保ちつつ、基本の遷移のフローを守った上で各決済手段を実装する**ことが可能になりました。

```php:決済代行会社を経由する決済手段の例
class AgencyRegistrarInterface implements RegistrarInterface {
	public function isPurchasable(User $user): bool {
        return !$user->isPremiumMember(); // すでにプレミアムでない場合入会可能
	}
	
	public function getConfirmDetail(User $user): ?ConfirmDetail {
        return new ConfirmDetail(
            '決済手段A',
            500
        );
	}
	
	public function getLink(User $user, string $successUri, string $errorUri, string $cancelUri): Destination {
        決済開始処理;
        return 遷移先生成;
	}
	
	public function complete(User $user, Request $request, string $completeUri): Destination {
        決済確定処理;
        return リクエストの値を元に完了ページのURLを生成する;
	}
}
```

## テンプレートの書き直し

デザインは全く同じですが、現在ニコニコプレミアムシステム上のフロントエンドのテンプレートはすべて Laravel の Blade を使用して書き直されています。
過去のテンプレートはカスタマイズされた Smatry2 が使用されており、 PHP 本体の更新や環境の構築などの妨げとなっていました。

これらすべてのエンドポイントをモダンなPHPへ持っていくのは困難なので、レガシーPHP上から Laravel のクラスを呼び実装したのですが、それ自体はそこまで難しいものではありません。
レガシーPHPから Laravel を呼び出す仕組み自体は[僕の上司が記事にしていますので](https://www.utakata.work/entry/laravel/for-legacy)参考にしてみてください。

```php
$laravelApp = LaravelAppForLegacy::get(); // laravelの App クラスを取得
/**
 * @var Illuminate\View\Factory
 */
$viewFactory = $laravelApp->make('view'); // 'view'(エイリアス) クラスを取得する
// あとは見覚えのある感じで出力、描画
echo $viewFactory->make('template.file', [ 'view' => 'param' ])->render();
```

この作業によりブラックボックスと化していた配列への依存をすべて削除し、改めてテンプレートに必要なパラメータを精査することができました。

## キャリア決済

キャリア決済についてはキャリアの認証が挟まる都合もともとページが分離していたのですが、形だけ汎用化されており、遷移を図に起こすとこのようなひどいものが出来上がる状態でした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/c0504826-9e0c-04e3-9bd5-6355daf3bb36.png)

今回はキャリア決済も上記の interface を実装し他の決済手段と同様に扱えるようにしたのですが、認証できるようにするため拡張を行い、その決済遷移に必要な認証情報(+認可フロー)を定義できるようにしました。

```php:キャリアの決済遷移
class CarrierRegistrarInterface implements RegistrarInterface {
    private $authenticator = new CarrierAAuthenticator();

	public function getAuthenticator(): ?Authenticator {
        return $this->authenticator;
	}
	
	public function isPurchasable(User $user): bool {
        return !$user->isPremiumMember();
	}
	
	public function getConfirmDetail(User $user): ?ConfirmDetail {
        return new ConfirmDetail(
            'キャリアA',
            500
        );
	}
	
	public function getLink(User $user, string $successUri, string $errorUri, string $cancelUri): Destination {
        $this->authenticator->get認可情報();
        決済開始処理;
        return 遷移先生成;
	}
	
	public function complete(User $user, Request $request, string $completeUri): Destination {
        $this->authenticator->get認可情報();
        決済確定処理;
        return リクエストの値を元に完了ページのURLを生成する;
	}
}
```

詳細は割愛しますが Controller は有効な認証情報を確認できない場合にフローを開始するような実装になっています。

あまり本筋とは関係ありませんが今回キャリアの認証に使用する OpenID はRFCとにらめっこしながら自分で実装しました。
(利用している範囲は限定的なのでそこまで難しいものでもないですが。)

## ブラックボックスの削除

一連の作業により各決済手段の定義はすべて別のページに移すことができたため、決済手段の選択画面は会員状態に応じた `決済遷移を選択する` だけの画面になり決済に関する処理を全て削除することができるようになりました。

これによりゴールとしていた2つの課題を達成することができました。

## レビュー･マージ

まずはモダンPHPの部分に各決済手段のクラスを作成した PullRequest を作成し、メンバーにレビューしてもらいました。
しかしレビューの速度よりも実装速度のほうが早くなってしまい、差分が大きくなっていく問題が発生したため PlantUML を使用してPRの依存関係を作り、リンク付きの図として作成･メンバーへ共有することにしました。SVG出力にするとリンクを含んだ状態でそのまま出力できるため、おすすめです。

結果として十数個のPRになりましたが、小さい差分を継続的にマージできたので、うまく行ったのではないかなと思います。

## 良かった点･良くなかった点

処理の分割を行ったことで新しく決済手段を追加する時などに他の決済手段を考慮しなくても良くなったため、大きく工数削減に寄与することができました。
また汎用化したことによりページ遷移の代わりにAPIを使用して離脱の低減を図ったりなど、以前では困難だったことが実現ができるようになりました。
しかし投入後に小さくはないミスが発覚し、ホットフィックスを入れたりリカバリを行うなどの対応に追われました。投入の規模からするとあまり重大ではなかったかもしれませんが、普通はミスがないものなので今後の動作確認やテストの充実は課題に感じています。

## さいごに

今回の改修ではシステムのメインとなる部分を大きく改変したため、いろいろ過去のものが掘り返されました。
いずれも改修済みである程度合理的な形になっているのですが、そのうち個人的に気に入っているものをリストアップしてみます。

- [15年前に廃止(変更)された機能](https://blog.nicovideo.jp/developers_diary/2007/08/post_149.html)の一部が未使用のコードとともに放置され、ほとんど形骸化した状態でごく一部の遷移に組み込まれていた
- 何故か同じWebサーバー上に存在するAPIに(NAT+LB経由で)リクエストを投げ情報を取得している箇所があった

15年の重みとともに自分が好きなサービスの歴史を感じることができ、個人的にはとても楽しかったです。

### 退会ページについて

ここまで退会ページには触れてきませんでしたが、退会ページも同様の構造で分離を行っています。
わかりやすく退会できるようになるといいんですけどね…(個人の意見)。

### 今後について

2年前から継続して勧めている一連の改善において、これほど大規模作業はもうないだろうな、とは思っています。
しかしレガシーなコードはまだまだたくさん残っており、デザインやUXの面でも多数問題を抱えているため、引き続きコードの改修に合わせそういった面を改善できればいいなと思っています。

### そのほか

年々アドベントカレンダーを書くのが大変になっている実感があります。
書きたい気持ちは山々なのですが文章にするとなると手が止まってしまいますね…。
社内のドキュメントはよく書いており流石に慣れてきたのですがこういった文章をかけるように精進していきたいと思います。
あと時間がないです。なんでだろう…。

かなりニッチなお話でしたが一人でも参考になる方がいらっしゃれば幸いです。
お付き合いいただきありがとうございました。