こんにちは、Web チームの井手です。
この度 NIKKEI Professional Media(通称 Promedia) という新媒体をリリースしました。各トピックに特化したメディアで、現在は 日経モビリティ、日経GX、日経テックフォーサイトが展開されています。
これまで日経 Web チームでは特定のFWを利用せず、長年JSXをテンプレートエンジンとした独自FWを開発して、モノレポとして運用していました。これはチューニングの余地を自分で確保することや、自分たちのチームにあった規約を作りやすくするための選択です。しかし Promedia の開発は電子版本体のリリースサイクルと外れるためにモノレポの中に入れたくないことや、長年の開発の負債を引き継ぎたくないこと、なによりNextJSエコシステムの発達によって僕たちの要求をカバーできつつあることから、試験的にNextJSを採用して開発してみました。
他にも次世代基盤の技術選定の参考にすべく promedia 開発で様々な実験をしたので、その評価を報告します。
日経のメディア開発における制約
SSR が必須
まず我々はメディア企業であり、SEOとOGPの要件は必須です。 そのためSSRするかテンプレートエンジンの利用が前提となります。
なるべく依存を持たない
私たちのチームは週次の担当制で依存パッケージの更新をしています。 そのため更新コストは下げたく、なるべく依存するライブラリは減らしています。
既存の CDN を使わなければいけない
今回私たちは新媒体を開発しましたが、電子版の本体が https://www.nikkei.com/ 配下に展開される一方で promedia は https://www.nikkei.com/prime/mobility や https://www.nikkei.com/prime/gx に展開します。これは SEO やマーケティングの都合でこのようにしています。サービスごとに新しいサブドメインを切らない方針により、電子版本体の開発ですでに用意している既存 CDN に乗らざるを得なくなります。
複数サービス展開
このプロジェクトでは日経モビリティ、日経GX、日経テックフォーサイトなどの複数サービスを展開しますが、全サービスをこの1アプリで開発します。前基盤であれば monorepo 構成を生かして、複数サービスを複数フォルダに分けて開発していましたが、今回はモノレポにしないので dynamic routing で実現します。
画像の最適化や crop に imgix を使う
promedia は 社内共通のCMSから入稿されます。 このCMSでは画像の加工ができますが、その加工機能は imgix を前提としています。 そのため promedia でも imgix の利用は必須となります。
API やCMSは既存のものを使う
共通のCMSを使うので、APIから見たデータソースも同じとなります。 そのためクライアントとしては既存のAPIを使ってデータを取る必要があります。 promedia 用のエンドポイントが増えないことは、over fetching の対策やバリデーションの要件などに関係してきます。
技術選定
上記のような要件や制約を踏まえ、次のような技術選定をしました。
NextJS
宣言的UIを使いたく、社内の既存資産も使いたく、SSRさせたいと考えた結果、NextJS 一択になりました。
もちろん既存のモノレポに乗せる案もありましたが、電子版本体とはリリースサイクルが異なることや、要所要所で電子版開発に最適化されてしまっていたため採用を見送りました。
ちなみに既存のモノレポでは Fastify 上で JSX から HTML を生成して自前で SSR しています。
CSS Modules
スタイリングの選択肢は、
- CSS in JS
- 0 runtime CSS in JS
- CSS Modules
です。電子版では 0 runtime CSS in JS を実現できる linaria を利用しています。
しかし linaria はビルド環境に根深く依存し、storybook や jest などにも影響があります。そのためこれらのツールを入れ替えようとした時にボトルネックになります。またビルド時間が伸びる原因でもあります。
そこで promedia ではこの手のライブラリを使うことをやめました。代わりに CSS Modules を採用しました。それは、
- CSS ファイルとして配布できてCDNとも相性が良い
- CSS そのものであれば将来の移植も簡単
- NextJS が標準サポートしている
という理由によるものです。
また Sass は使っていません。これはネスト記法などが多用されると grep が難しくなるからという理由です。Sass は便利な機能がたくさんありますが、いまは CSS そのものにも様々な機能が追加されたので、CSSそのものを使ってスタイリングしていくことにしました。
バリデーション、スキーマ
APIから返す値をクライアントで検証する仕組みを導入しました。
Ajv
バリデータには Ajv を利用しています。入力のスキーマが JSON Schema であることが選定理由です。 TypeScriptには便利なスキーマライブラリがたくさんありますが、独自スキーマだと将来の移植が大変になるので今回はJSON Schema ベースのものを採用しました。
TypeBox
ただし、JSON Schema を手で書いたり、TSの型と紐づけるのは大変なのでその辺りを支援してくれるライブラリを使いました。それが typebox です。独自スキーマを要求されますが、JSON Schema に変換できることと、TSの型を導出する utility が提供されていることから便利です。fastify の公式でも推奨されていて一定の信頼を置けるというのと、最悪の場合に剥がすときも JSON Schema を出力してしまえば剥がせるので採用しました。
エラーハンドリング
promedia は息の長い電子版基盤の上に一部が乗ります。そのため溜まった負債、特に運用でカバーを前提とした設計と向き合わざるをえませんでした。そこでエラーハンドリングをしっかりこなせる仕組みを導入します。
Error 設計
エラーはエラーの種類ごとに custom error を定義しています。
export class NotFoundError extends BaseError {
override readonly name = 'NotFoundError' as const;
}
なるべく細かくエラーは定義しており、これは後述の Result 型ととても相性の良い方法です。
option-t
promedia では例外の代わりに Result 型を使うようにしています。これはエラーハンドリングのし忘れを防ぐために利用しています。
また
export const assertIsNever = (_: never): void => {
// no op
};
のような never 型を使った utility を用意しておくことで、else
や switch(){default: }
でハンドリング漏れに気づくこともできます。特にAPIとの境界ではどんなエラーが飛んでくるのか正直予想できないので、クライアントバリデーションとResult型でなるべく制御しようと努めました。
その Result 型を提供してくれるものが option-t です。これは Rust の OptionやResultを参考に作られており、同等のインターフェースを提供します。そのためただ Ok | Err
な型を提供するだけでなく、map や and_then、flatten なども提供されています。
Result, Option などについて詳しく解説しているドキュメントの一つに Rust の標準ライブラリのドキュメント がありますが、Rust を意識している option-t はRust のドキュメントを参照して利用できるため困った時にすぐ調べられます。この手のライブラリはすでにTSにいくつかあるとは思いますが、私はこのライブラリを一番気に入っています。
Vitest
今回は新規開発でテストを書きやすいように実装できることもあり、テスト方法もしっかりと検討しました。 丁度 vitest の情報が色々出揃っていたこともあり、テストランナーとしては vitest を採用しました。 既存資産を流用している箇所のテストは jest だったのですが、vitest が jest とほぼ互換で作られていることもあって、import を差し替えるだけでほとんど動き、導入は楽でした。使ってみた感想としては噂通りの速さで満足です。
snapshot testing
リリースしたあとはデグレを気にしながら改修しています。デグレに気をつけられる仕組みとして私たちは snapshot testing を採用しました。ここでは getServerSideProps と typebox のスキーマに対して snapshot test をしています。typebox そのものをテストするかのようですが、typebox のバージョンを上げた時にJSON Schemaに影響がないことを確認することが目的です。
ここで普通の expect を使った assert ではなく snapshot test を利用したのには理由があります。それは snapshot test と普通の assert で気づけるバグが、この案件に限っては同じだからです。日経のほとんどのプロダクトにおいて JSX はテンプレートでしかなく、interaction はないです。そのためテスト内容は DOM が同じかです。ここで DOM の全要素に対する assert を書くと、それは結局 snapshot test で生成したものと近しいものになり、テストファイルを更新するタイミングも snapshot test を採用した場合と近いものになります。その上でテストを書く費用対効果を考えると snapshot test が圧倒的に高いので snapshot test を選択しています。
Storybook
コンポーネントカタログとしては storybook を採用しました。しかし依存が多いこと、React18との噛み合わせが悪い(plugin の deps が最近まで v18 ではなかった)こともあり、別チームが採用している ladle に載せ替えようともしました。ただ、依存が多い問題はモノレポ化してstorybook周りを切り出すことで和できること、v18対応は最近v18対応されていたこともあり今は storybook を利用しています。
ただ storybook がビルドシステムや日経独特の要件と相性が悪いことはこれまでにも経験しており、剥がす方法も同時に検討はしています。実際、別チームのメンバーに依頼して storybook 相応のものを試験的に内製してもらいました。
Firebase
ブランチごとに storybook の成果物をデプロイして確認するために firebase を利用しました。firebase hosting には channel という機能が beta 版で提供されており、いわゆる preview 環境を作れます。これを利用してブランチごとの storybook 環境を作りました。firebase を選択した理由は、preview 環境を提供する他のホスティングサービスは、チーム利用のためにはメンバー数に応じた課金を要求され、かなりな額になることが分かったためです。ただ firebase hosting にも欠点があり、7人体制で開発していた都合で作られるブランチのスピードがあまりにも早く、channel 数上限を簡単に超えてしまいました。そのためGitHub Actions経由で expires date を縮めてデプロイしたり、毎朝コンソールに入ってchannelを手で消していました。
jobs:
build_and_preview:
steps:
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
expires: 2d
また開発中の資源が見えては行けないので前段に Firebase Functions を置いてbasic 認証もしています。Firebase Functions を使って Firebase Hosting 上の storybook を basic 認証するには少しコツが必要ですが、
- express を使うと静的配信と認証をプラグインの仕組みに乗っかれる
- storybook 資産を functons の上に載せる
- hosting へのアクセスを functions 側へ流す
で実現できます。
import express from 'express';
import basicAuth from 'express-basic-auth';
import * as functions from 'firebase-functions';
const app = express();
app.use(
basicAuth({
challenge: true,
unauthorizedResponse: () => {
return 'Unauthorized';
},
authorizer: (username: string, password: string) => {
const userMatch = basicAuth.safeCompare(username, 'hoge');
const passMatch = basicAuth.safeCompare(password, 'fugafuga');
return userMatch && passMatch;
},
})
);
app.use(express.static(__dirname + '/storybook-static/'));
exports.getProMediaStorybook = functions.https.onRequest(app);
{
"hosting": {
"rewrites": [
{
"source": "**",
"function": "storybook"
}
],
}
}
実装で気をつけたこと
Cache-Control ヘッダーのテスト
私たちはサーバープロセスが返すレスポンスの Cache-Control ヘッダーに大きな関心があります。それは promedia は日経のCDN上に展開されるためです。日経のサービスは会員向けコンテンツもキャッシュするため cache control header を間違えると、本来見えては行けないものが見えるといった事故にもつながります。
そこで私たちは Cache-Control ヘッダー の内容をテストしています。NextJS では Cache-Control ヘッダーは getServerSideProps の context を通じて付与できます。そこでこの context を mock して getServerSideProps にテストを書くことでテストしています。
import { rest } from 'msw';
import type { GetServerSidePropsContext } from 'next';
import { createRequest, createResponse } from 'node-mocks-http';
import { describe, expect, test } from 'vitest';
describe('page test', () => {
describe('top', () => {
test('Set vary is correct.', async () => {
const req = createRequest();
const ctx: GetServerSidePropsContext = {
res: createResponse(),
req: {
...req,
headers: {
'xxx': 'hoge',
'yyy':'fuga',
},
},
query: { category: 'mobility', aid: 'xxx' },
resolvedUrl: '',
};
await getServerSideProps(ctx);
expect(ctx.res.getHeaders()).toEqual({
'cache-control': '',
vary: 'aaa, bbb, ccc, ddd',
'surrogate-control': 'hoge=fuga',
'surrogate-key':
'aaa bbb ccc ddd',
});
});
});
});
context はその中に HTTP Response を持ちますが、それを node-mocks-http で作っています。私たちのアプリケーションは ロジックを実行すると HTTP Response に HTTP Header が足されていくので、実行後にどのヘッダがついたかをテストしています。
Cache First で思考する
CDNを最大限活用するのであれば、サーバープロセス上で認可情報に基づいて情報を出し分けるのではなく、CDNの段階でコンテンツを出し分ける必要があります。そのために vary を利用します。ただし cache hit ratio をなるべく高く保ちたいので、vary の種類は可能な限り低くしたいです。しかし promedia は 1app で複数サービスを提供するため、認可情報の持ち方を工夫しないと vary の種類が増えてしまいます。セキュリティに関わることなので詳しくは書きませんが、なんらかの工夫をして解決しました。
またキャッシュさせる画面には個人情報が乗らないようにも工夫しています。そうすれば最悪キャッシュ情報を取り違えても、権限がない記事を読めてしまうだけという被害に止めることができ、会員情報の漏洩は防げます。少なくとも /prime/ 配下のページでは別ドメインの会員ページへの導線だけを用意して、会員情報は載せないようにしています。
Cache first な設計については CDNを活用した日経電子版のネットワーク最適化とサイト高速化 に詳しくまとまっていますので、是非とも参照してみてください。
参照したヘッダは必ず Vary に追加する
ヘッダを見て処理を分けたのであれば、それは別のキャッシュに載せることを期待するはずです。 そうでなければキャッシュの取り違え事故が起きるからです。 そのため「参照したヘッダは必ず vary に追加する」ということを実現するために、見たヘッダを vary に追加する関数を用意しています。
import type { IncomingHttpHeaders, ServerResponse } from 'http';
const VARY_HEADER_FIELD = 'vary';
/**
* NOTE: This functions mutate `res`.
* It return value of a request header in `req`, which specified in `headerField`.
* Then, also append the header field to Vary header in the `res`
* in order to set it as a cache key in CDN front of this application.
*/
export const referReqHeader = ({
headers,
res,
headerField,
}: {
headers: IncomingHttpHeaders;
res: ServerResponse;
headerField: string;
}): string | undefined => {
res.setHeader(VARY_HEADER_FIELD, headerField);
const headerValue = headers[headerField];
if (Array.isArray(headerValue)) {
return headerValue.join(',');
}
return headerValue;
};
そして後は直接 HTTP Request から header を見ようとしたら警告を出す ESLint を用意することで、必ずこの referReqHeader を使わせるようにできます。 既存の基盤では Fastify の plugin を使うことで必ず vary を付けさせることを強制できたのですが、今回はそのような Plugin 機構がないので ESLint で強制力を持たせます。
purge と surrogate key 設計
日経のCDNは Fastly で、これは cache の purge が速度や柔軟性で競合他社と比較して強力という特徴があります。日経でも purge は多用しており、たとえば記事の編集や修正があると purge しています。他にも障害対応時やデバッグにも使っており purge を活用しています。purge は URL を指定してすることもできるのですが、「このサービスのキャッシュを全部」「この画面のキャッシュを全部」といった風に複数選択もできます。そのときに使う key が surrogate keyです。これも HTTP Header なので getServerSideProps の context で付与できます。
// surrogatekey: mobility-root-page
// surrogatekey: mobility-detail-page-1
ctx.res.setHeader(SURROGATE_KEY, surrogateKey);
context は pages/* で使えるので、各ページごとに surrogate key を作成し、page ごとに cache を purge できるようにしています。
テーマ機能の実装
1レポジトリで複数サービスを開発するため、色味を切り替えられるようにテーマ機能が必要です。 しかし今回は Chakra や MUI のようなものに頼っておらず Theme Provider が不在なので、その仕組みを作らなければいけません。
そこで私たちは、CSS上ではカスタムプロパティを使った色指定をして、_app.tsx でプロパティの定義ファイルを差し替える仕組みで実装しました。
import '../client/styles/reset.css';
import '../client/styles/skin.css';
function Root({ Component, pageProps }: AppProps): ReactElement {
const router = useRouter();
useInsertionEffect(() => {
if (router.pathname === '/mail_settings') {
// Import css file for common/footer color definition
import('../client/styles/mail-settings/color.css');
return;
}
const category = router.query['category'];
if (typeof category !== 'string' || !isOneOfCategory(category)) {
return;
}
switch (category) {
case Category.MOBILITY:
import('../client/styles/mobility/theme.css');
return;
case Category.GX:
import('../client/styles/gx/theme.css');
return;
case Category.TECH_FORESIGHT:
import('../client/styles/tech-foresight/theme.css');
return;
default:
assertIsNever(category);
}
}, [router.query['category']]);
return (
<>
<Component {...pageProps} />
</>
);
}
export default Root;
:root {
/* color definition */
--green-primary-light: #0c6f5b;
--green-bg-dark: #00241d;
--copyright: #eeeeee;
--bg-light: #e7e9e9;
--gray-button-text-hover: #e6e6e6;
--gray-button-bg-hover: #434343;
/* theme definition */
--primary: var(--green-primary-light);
--text-color: var(--kite-gray-120);
...
}
また私たちは React18 を利用しているため、useInsertionEffect
も使っています。
これは DOM にスタイルを注入することを意図して作られた API です。
FYI: https://ja.reactjs.org/docs/hooks-reference.html#useinsertioneffect
imgix を適用するタイミング
日経の既存基盤では imgix は Picture コンポーネントのような画像用の独自コンポーネントの中で適用させていました。私たちは imgix の Secure URL を使っていますが、そのURL生成には imgix token が必要なのでサーバーでしか imgix の SDK を実行できませんでした。既存基盤は JSX そのものを renderToString して SSR させていること、navigate が anchor タグで行われていることからコンポーネントの中で imgix を実行しても問題がありませんでした。しかし今回私たちは NextJS 上でさらには next/link も使っていたので、クライアント側でも imgix を利用できるようにする必要がありました。
そこで私たちは getServerSideProps を介してその中で変換処理を実施してクライアントに返すことにしました。getServerSideProps は aタグでの遷移、Linkタグでの遷移の双方に対応しています。
そして変換した画像を持ちやすくするため、もしくは変換をしやすくするため記事データはクラスで表現しています。いわゆる entity を作っています。フロントエンドアプリケーションにおいてはクラスを使わないという意見もあるとは思いますが、データを imgix を通した形式に変換したり、それを上手く表現するためにクラスを使っています。またこのような変換はいたるところで起きるため、そのタイミングや責務に一貫性をも持たせるために、サーバープロセス側のコードはいわゆるDIベースのレイヤードアーキテクチャで設計しています。DIベースで設計するのはテストの都合からしても良いです。
まとめ
promedia 開発を通して NextJS などの採用と評価を行ってみました。既存基盤の上で動かしたり既存資産を活かすことができており、目立った問題も起きていないので、日経の次世代基盤として採用できるかもしれません。そのためにも引き続き利用して評価を繰り返します。