概要
Next.jsはgetServerSideProps
が推奨となり、もはやgetInitialProps
(以下gIP)は使っていないかもしれません。しかし2022年現在でも、クライアントサイドでのフェッチとサーバサイドでのフェッチを両立したい場合などでは、gIPが必須になると考えています。
サーバ用のコードとクライアント用のコードを用意し、typeof window === "undefined"
で切り分けるようにしたとしても、gIPにサーバサイドモジュールを含めることはできません。そのため、もしgIPにサーバ用のコードを含めたい場合、クライアント用のコードとサーバ用のコードに分離した上でうまくimportする必要があります。
また、Next.jsは異なるページコンポーネントは別々にバンドルされるという特徴があり、異なるモジュールから1つのモジュールでexport
した変数の共有はできません。ページ共通で必要な機能を毎回インスタンス生成していてはパフォーマンスが悪いです。
これらの問題は、API RoutesでglobalThis
にインスタンスを注入しておき、gIPではglobalThis
経由で呼び出すことにより解決はするのですが、globalThis
を汚すことが懸念されます。また、そのままglobalThis
に突っ込んだのではHot Module Replacementも効きません。本記事では、このグローバルなインスタンスをService Locatorパターンにより管理できないかを考えました。
なお、本記事はRecruit Engineers Advent Calendar 2022、16日目の記事です。
課題
どういうパターンで問題になるかを考えます。以下はfs
の例で示します。
Next.jsプロジェクトを作ります。
npx create-next-app -- typescript
page/index.tsxを以下に書き換えてください。
// page/index.tsx
import type { NextPage } from 'next'
import fs from "fs"
const Home: NextPage = ({file}: {file?: string}) => {
// 省略
return <div>{file}</div>
}
Home.getInitialProps = async () => {
// クライアントでの実行時は、コンポーネント側でhttp経由でファイルを取得したい
if(typeof window !== "undefined") {
return {
props: {}
}
}
// サーバでの実行時は、InitialPropsにファイルを詰めて返したい
const file = fs.readFileSync("./README.md")
return {
props: { file }
}
}
export default Home
これでnext build
を実行してみてください。その結果はエラーになるはずで、fs
はサーバサイドモジュールであるにも関わらず、クライアント向けバンドルに含まれるgIPから使われようとしているからです。
解決策1
gIPでは、globalThis
を経由して呼び出すようにしてみます。
// Home.tsx
Home.getInitialProps = async () => {
// ...
// サーバでの実行時は、InitialPropsにファイルを詰めて返したい
// const file = fs.readFileSync("./README.md") // これを
const file = (globalThis as any).readFile("./README.md") // こうじゃ!
//...
}
これにより、fs
依存は消えたのでコンパイルは通るようにはなりますが、
globalThis.readFile
の実体はありませんので、どこからか注入する必要があります。
そこで、API Routes側でglobalThis
に注入します。
// /api/startup.ts
import { readFileSync } from 'fs'
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
(globalThis as any).readFile = (file: string) => readFileSync(file)
res.status(200)
}
早速起動してみましょう。
gIPでglobalThis
のreadFile
を呼ぶためには先に/startupが呼ばれていないといけませんので、
/api/startup をたたくことを忘れないようにする必要があります。
next build && next start
curl http://localhost:3000/api/startup
その後、ブラウザで http://localhost:3000/ にアクセスすると、gIPで注入されたファイルの内容が確認できると思います。
これでも動きはしますが、globalを汚しますし、globalThis
に入れた関数を更新してもHot Module Replacementが効かないという問題が発生するので、これはよくないです。
解決策2
readFile
程度であれば構わないですが、実際はもう少し複雑なサービスを複数置いたり、サービスがサービスに依存するようなものを導入したり、gIP用とサーバ専用といったように可視性を持たせたりといった使い方をしたいはずです。
そこで、サービスの依存関係を整理するために、Service Locatorパターンを導入します。サービスの依存関係を解決するための手法としてよくDependency Injectionが用いられると思いますが、それよりも古から存在するのがService Locatorパターン(別名Dependency Lookupパターン)です。詳細な説明はほかの文献に譲りますが、端的に言うと各サービス自身が自身の依存するサービスを能動的に取りに行く仕組みです。なお、Dependency Injectionでも今回やりたいことは可能です。
まずは、可視性をもたせるために、ServiceLocator
とGipServiceLocator
を定義し、globalThis
からアクセスできるようにしましょう。
// services/types.ts
// 直接はapiRoutesでしか使用しないもの
type ServiceLocator = {
serviceA: ServiceA
serviceB: ServiceB
} & GipServiceLocator
// gIPでしか使用しないもの
type GipServiceLocator = {
serviceX: ServiceX
}
declare global {
var serviceLocator: ServiceLocator | null
}
続いて中身を定義していきます。まずはAPI側から。
// services/server.ts
let serviceLocator: ServiceLocator | null
const createServiceLocator = (): ServiceLocator => {
const serviceA = createServiceA()
const serviceB = createServiceB()
const serviceX = createServiceX(serviceA)
return {serviceA, serviceB, serviceC}
}
const getServiceLocator = (): ServiceLocator => {
if(serviceLocator) return serviceLocator
serviceLocator = createServiceLocator()
return serviceLocator
}
続いて、gIP側を定義します。
// services/gip.ts
const getGipServiceLocator = async (): GipServiceLocator => {
// MEMO: HMR対応のためにfetchする、本番環境では不要
await fetch("http://localhost:3000/api/startup")
return globalThis.gipServiceLocator
}
HMRに対応するために/api/startupをたたいていますが、
HMRが不要でしたらfetchもasyncも不要です。
最後に、startupでgetServiceLocator
を呼ぶようにしましょう。
// api/startup.api.ts
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const serviceLocator = getServiceLocator()
globalThis.gipServiceLocator = serviceLocator
res.status(200)
}
ここまでで準備完了です。APIでは、以下のようにして使用します。
// api/index.api.ts
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { serviceA, serviceB } = getServiceLocator()
const resultA = serviceA.getResult()
const resultB = serviceB.getResult()
res.status(200).json({resultA, resultB})
}
ページでは、以下のようにしてあげればOKです。これでインスタンスが各ページ間で共有されます。
// page1.page.tsx
Page1.getInitialProps = () => {
const { serviceX } = getGipServiceLocator()
const props = serviceX.getProps()
return {
message: props
}
}
// page2.page.tsx
Page2.getInitialProps = () => {
const { serviceX } = getGipServiceLocator()
const props = serviceX.getProps()
return {
message: props
}
}
最後に起動確認です。
next dev
curl http://localhost:3000/api/startup
その後、
- http://localhost:3000/page1
- http://localhost:3000/page2
-
http://localhost:3000/api
にアクセスし、想定通りの値が帰ってくればOKです。
リポジトリ
リポジトリはこちらです。