- 投稿日:
Next.jsのディレクトリ構成を、かれこれ3年くらい考えているのだが、ローカルのメモ帳に溜め込んでいても腐るだけなので一旦雑に吐き出してみる。
やりたいこと
大きくは基本の開発ルールを定めて、チームで混乱が生じないようにしたい。
- コードファイルの配置ルールを決め円滑な開発ができるようにする
- ディレクトリごとにスコープを作り、ある機能で使うコードは一か所にまとめる(コロケーション)
- ロジック・状態・ビューを分離し、コードの肥大化を抑制することで、見通しをよくする
- 大まかなルールとしてはロジックは
controller.ts
,util.ts
に置き、状態はstate.ts
に、ビューはview.tsx
に配置する
- 大まかなルールとしてはロジックは
- 共通部品と共通部品でないものを明確に分ける
- 命名規則を単純化して命名で悩んだり属人化することを防ぐ
大きなこと
基本的なデザインパターンはMVCをベースにしていて、controller.ts
にはイベントハンドラを、state.ts
には画面の状態を、view.tsx
にはTSXを持つという設計。controller, state, viewの定義は以下の通り。
- controller.ts
- viewに差し込むイベントハンドラ群
useEffect
はここに書く
- state.ts
- 画面で利用するstateを実装する
- 中には
usePageState()
という関数を一つだけ作り、これで画面の全状態を管理する- この関数の引数は状態の初期値のみを渡す
- この中にTSXや
useEffect
は書かない
- view.tsx
- 基本的にTSXだけが書かれている
- 表示非表示を切り替えるために以下のようなboolean分岐はあってよいが、ネストするなどで複雑になる場合は別コンポーネントに切り出す
props.isHoge ? <Hoge /> : < />
ただ、これだとstateはModelではなくVewModelになるので、MVVCみたいな構成になってしまうので、一般的なデザインパターンから外れてしまうのがやや懸念だ。但しModelがないことで、Modelを修正したら参照している全画面に影響が出てデグレしたみたいなのは回避できると思っている。MVCというよりClean Archtectureとかレイヤーアーキテクチャの方が近いかもしれない。
ディレクトリ・ファイル構成例
└─src
├─adaptors
│ ├─HogeRequest
│ │ ├─index.ts
│ ...
├─components
│ ├─Fields
│ │ ├─Checkbox
│ │ │ ├─controller.spec.ts
│ │ │ ├─controller.ts
│ │ │ ├─view.spec.ts
│ │ │ └─view.ts
│ │ ...
│ ├─Layouts
│ │ ├─HogeLayout
│ │ │ └─index.tsx
│ │ ...
│ └─Pages
│ ├─Dashboard
│ │ ├─ui-parts
│ │ │ ├─HogeSection
│ │ │ │ ├─controller.spec.ts
│ │ │ │ ├─controller.ts
│ │ │ │ ├─view.spec.ts
│ │ │ │ └─view.ts
│ │ │ ...
│ │ ├─controller.spec.ts
│ │ ├─controller.ts
│ │ ├─state.spec.ts
│ │ ├─state.ts
│ │ ├─view.spec.ts
│ │ ├─view.ts
│ │ ├─usecase.spec.ts
│ │ └─usecase.ts
│ ...
├─hooks
│ ├─ValueState
│ │ ├─index.spec.ts
│ │ └─index.ts
│ ...
├─pages
│ ├─Dashboard
│ │ ├─index.page.ts
│ │ ├─server.spec.ts
│ │ └─server.ts
│ ...
├─resources
│ ├─RoutingConfig.ts
│ ...
└─utils
├─HttpClient
│ ├─index.spec.ts
│ └─index.ts
...
各ディレクトリ・ファイルの役割
src/adaptors/**/*
API通信などのリクエストを投げる処理だけを置く場所。投げる処理以外は一切書かない。ここではパースや例外処理を行わなず、必要な処理がある場合は、呼び出し元に委任する。
これは呼び出し元によってハンドリングが変わるケースがあり、機能AとBでは最初同じハンドリングだったが、のちに機能Aだけハンドリングが変わりアダプタ側に手を入れるというケースを防ぐためだ。要するに開放閉鎖の原則を守るためである。
src/components/**/*
Pageコンポーネント以外の全てのUIコンポーネントと、それに付随する処理を配置する場所。MVC一式がここに入る。
Modelをここに入れることに関しては悩みがあるが、MVVMのViewModelとして考える場合はそこまで違和感がないように思う。
src/components/Fields/**/*
<input type="text" />
みたいなフォーム系の入力コンポーネントの部品置き場。
ここに配置されるコンポーネントは原則として状態を持たず、状態は親コンポーネントからprops経由で渡される。このため、viewとcontrollerのみがある。
src/components/Layouts/**/*
ページ全体のレイアウト置き場。大外のレイアウトを入れる想定、そんなに数は生まれないと思う。
例えば以下のコードで~Layout
となっているのがここに入る。基本的にはViewしかない想定だが、必要ならControllerなどを置いてもよい。Stateが存在することはない(props経由で差し込まれることはある)
<CommonLayout>
<FullWideViewLayout>
<ヘッダーコンポーネントとか />
</FullWideViewLayout>
<HalfWideViewLayout>
<ボディコンポーネントとか />
</HalfWideViewLayout>
<FullWideViewLayout>
<フッターコンポーネントとか />
</FullWideViewLayout>
</CommonLayout>
src/components/Pages/**/*
ページコンポーネントの実体置き場。この設計ではNext.jsのページコンポーネントはこのディレクトリにあるUsecaseを参照するための存在なので、ページ本体の実装はここに置く。
src/components/Pages/Hoge/ui-parts/**/*
このページコンポーネント内で使う細かいUI部品置き場。src/components/Fields/**/*
などにある共通UIコンポーネントや、巣のTSXの組み合わせ。stateは持たず、controllerとviewのみを持つ。
src/components/Pages/Hoge/*.{ts, tsx}
このページコンポーネントの本体。
- controller
- viewに差し込むイベントハンドラ群
useEffect
はここに書く
- state
- 画面で利用するstateを実装する
- 中には
usePageState()
という関数を一つだけ作り、これで画面の全状態を管理する- この関数の引数は状態の初期値のみを渡す
- この中にTSXや
useEffect
は書かない
- view
- controllerとstateを受け取るpropsを持つ
- 基本的にTSXだけが書かれている
- 表示非表示を切り替えるために以下のようなboolean分岐はあってよいが、ネストするなどで複雑になる場合はui-parts側に実装することが望ましい
props.isHoge ? <Hoge /> : < />
usecase
getServerSideProps
の結果を受け取るpropsを持つ- controller, state, viewの橋渡しをする場所
この階層でcontrollerにstateを差し込みラップした関数を作り、viewに差し込む
```tsx
import { sendHoge } from './controller';const ps = usePageState();
const onClickHoge = (ev: EventT) => { // これは中でHTTPリクエストを行っており、ローディングの状態を切り替えている
sendHoge(ps.hoge, ev.target.value);
}return
;
```
viewやcontrollerレベルで切り替わる場合は、切り替え先をcontroller, state, view, usecaseの単位で子にして、親側でラップする
src/hooks/**/*
共通的な状態操作用のHooks置き場。
src/pages/**/*
Next.jsのページコンポーネント置き場。*.page.tsx
にはusecaseコンポーネントの呼び出しのみを記述する。SSRする場合はserver.ts
に関数を実装し、*.page.tsx
側から参照して使う。
src/resources/**/*
定数置き場。
src/utils/**/*
共通処理置き場。全体的に利用する共通処理のみ配置する。局所的に使うものは置かない。
一定のドメインの範囲で利用するものをどこに置くかは考え切れていない。
- 投稿日:
この記事では下図のシステム構成を前提に話を進めるものとする。この時、BFFの部分のアーキテクチャをREST APIにするか、GraphQLにするかというのが今回の話。
REST API
長所
- Web標準であるため特別な技術が必要ない
- ファイルアップロードのような特殊な通信プロセスも何も考えずに実装できる
- エラーもエラーコードを返せばいい
- 学習コストが低い
- 設計が単純
- エンドポイントに何かを要求すれば、それに対する応答が帰ってくるという点で非常にシンプル
- CRUDはGET/POST/PUT/DELETEで分かれており、これも単純
- 依存するライブラリがない
- 破壊的変更を受ける可能性がない
- renovateによるアップデート地獄とも無縁
- BFFとフロントエンドで型情報の共有が容易
- BFFをTypeScriptで実装していれば、そのまま型情報が流用できるし、他の言語で実装していてもOpenAPIを作り、そこから型情報を得ることが可能だ
- curlからリクエストを投げやすい
- 基本的にペイロードが単純であるため
短所
- 複数のデータを取得する際に複数回APIを呼ぶ必要があり、オーバヘッドが大きい
- 今回のケースでは呼び出し先のAPIはBFFによって集約されているため、適切にエンドポイントを設計すればこの問題は回避できる
- オーバーフェッチとアンダーフェッチ
- データを取得しすぎたり、取得しきれないといった問題が発生することがある
- 例えば
/api/v1/user
からユーザー名だけを取得したい場合に、ユーザーIDや年齢といった余計なデータが付随してしまうことがあったり、このエンドポイントからそのユーザーのログイン履歴を取得することができなければそれはアンダーフェッチになる - 今回のケースでは呼び出し先のAPIはBFFによって集約されているため、適切にエンドポイントを設計すればこの問題は回避できる
- エンドポイントが増えすぎると何が何かわからなくなる
- GraphQLでもQueryが増えすぎれば同様の問題だと思うのでREST API固有の問題だとは思わない
- レガシーな技術
- レガシーというのはそれだけで悪である
GraphQL
長所
- モダンであり、流行っている
- モダンであり、流行っているということはREST APIより良い選択肢であると言える可能性がある
- クエリを利用した柔軟な要求が可能
- REST APIと異なり特定のエンドポイントに縛られることがない
- またクエリを読むことで、フロントエンド側の実装のみで、どのような要求なのかを知ることができる
- フロントエンド側で取得したいデータを制御できる
- エコシステムが充実している
- キャッシュやエラーハンドリングは勿論のこと、Reactなど特定のフレームワークと統合するための仕組みが存在する
- デバッグ用のブラウザ拡張機能が存在する
- デバッグ用のブラウザ拡張機能が存在し、これがあるとデバッグがしやすい
短所
- Web標準ではない
- ファイルアップロードのような特殊な通信プロセスは追加のライブラリなどが必要になるケースがある
- 学習コストが高い
- 非常に充実したエコシステムが存在するということは反面学ぶことも多いということになる
- Web標準ではないが通信にはHTTPを使っているため、Web標準を知らなくていいわけではない
- GraphQL標準のようなものは存在し、それと別にライブラリの構成についても学ぶ必要がある
- REST APIのノリで利用するのであれば使う意味がない
- 非常に充実したエコシステムが存在するということは反面学ぶことも多いということになる
- 型システムとの相性が悪い
- クエリを利用し柔軟にデータを要求できるということはデータ構造が不定であるため、静的型付け言語との相性が悪い
- 多くの場合、辞書型を使って処理することになると思うが、このままでは不具合を引き起こしやすく、型の正規化を行う場合余計な処理プロセスが入ってしまう
- また型の正規化をするための関数を個別に作っているとやっていることがREST APIと大差なくなる
- 設計が難しい
- スキーマ設計が難しいと言われており、HowTo記事が多くある。AWSにさえある
- 何でもかんでも取得できる魔法のリゾルバみたいなのを作ってしまうとフロントエンド側で取得できてはならないデータまで取得できてしまうためよく考える必要がある
- 例えばクエリに応じた内容をDBから引っこ抜いてくる実装だったりすると、他のユーザーの情報を取得するなどができてしまうので結局BFF側でもある程度の作りこみが必要になる
- 他にもN+1問題が起きるとパフォーマンスが悪くなるどころか、最悪サーバーが落ちることすらあり得るだろう
- 実装が難しい
- 学習コストが高い部分と同じだが、全員がGraphQLとそのライブラリの理念を理解するのはあまり現実的ではなく、実装が難しくなる要因になると思う
- 開発ツールの対応状況があまりよくない
- VSCodeを開発に使おうとしたときにGraphQLの対応状況があまりよくなく、入力補完やLintなどをいい感じにしてくれる存在がいない
- ライブラリの保守作業が必要
- ライブラリはアップデートされ、時として破壊的変更も起きるものなので、それらの対応が必要
- renovate地獄の一翼を担う存在になる
- curlからリクエストを投げづらい
- クエリ文が複雑になればなるほど投げづらくなる
- 下手したらこれだけでオーバーフェッチとかどうでもよくなるレベルだと思う
- 投稿日:
今回は何故単体テスト書くのかというのを個人的な見地から書いていく。TypeScriptとJestで記述するが、他の言語でも応用は可能だと思う。
単体テストとは何か?
関数やクラスのメソッドを単体としてみた場合の振る舞いを確かめるものである。以下はその一例だ。
hoge.ts
export const joinString = (string1: string, string2: string) => {
return `${string1}${string2}`;
};
hoge.spec.ts
import { joinString } from '.';
describe('joinString', () => {
it('引数として渡した文字列が正しく結合されること', () => {
const actual = joinString('123', 'abcd');
expect(actual).toBe('123abcd');
});
it('第一引数が空文字の時に第二引数のみが出力されること(空文字が結合されること)', () => {
const actual = joinString('', 'edfg');
expect(actual).toBe('edfg');
});
it('第二引数が空文字の時に第一引数のみが出力されること(空文字が結合されること)', () => {
const actual = joinString('zxcvb', '');
expect(actual).toBe('zxcvb');
});
});
何のために単体テストを実装するか
- 実装されているコードが実装者の意図通りに動いていることを証明するため
- いわゆるテストコードが仕様書になるってやつです
- 実装コードの改修時に元々の意図と異なる状況(不具合など)が発生した場合に、それを検知するため
- 初回実装時であってもコードを直したときに予期せぬ不具合を防ぐため
- 動作確認した後にちょろっと直すことはよくあると思うが、そういう時に動作を担保するのに使える
- 他にも実装時にコードを思案しながら書いているときに、動作確認をする手間を省くのにも使える
何故単体テストなのか?
結合テストやe2eテストではなく、何故単体テストをするか、これは実務面では結構出てくる疑問だと思うので、以下で説明する。
まず単体テストは一般的に結合テストやe2eテストに比べ、実行速度が速く、コードを修正したときに素早く回し、デグレードしていないかの確認に使うときに有益である。これは単体テストは実際にサーバーを起動したり、ヘッドレスブラウザでUI操作を行うなどしないためだ。他にも同じ処理を何度も見るのでテストのオーバーヘッドが膨らみ、実行時間が落ちるといったこともある。単体テストであれば簡単なロジックの実行しかしないし、結合部分はすべてモックにするため高速に実行できる利点がある。
次に結合テストやe2eテストは共通処理を一個直すと、それを使っている処理のテストをすべて直す必要があり、保守性がよくない。単体テストは基本的に直した箇所のテストコードを直すだけなので保守性が高い。
最後に単体テストでは基本的に関数単体を見るためテストコードが肥大化しづらく、見通しが良いので何をテストしているのかが結合テストやe2eテストと比べて分かりやすい。またスコープが関数単体であるため、カバレッジが取りやすく、いわゆるC0, C1, C2と言われる、命令網羅、分岐網羅、条件網羅の三点を網羅しやすい。結合やe2eでは共通処理の全機能を使うとは限らないため、この網羅がしづらい。
単体テスト向きの設計は何か?
これについてはSOLID原則を使った設計が一番無難だと考える。画面であれば更にMVCモデルを使うのがよい。
SOLIDにする利点
- S:単一責任の原則で関数の責務を切り分けることで、関数の責務を小さくすることができる。責務が小さい関数はテストケースが少なく、テストコードを書きやすく保守しやすい
- O:閉鎖解放の原則により、関数を呼ぶ関数自体を不変にでき、テストの保守性を向上することができる
- L:特にテストには貢献しない
- I:特にテストには貢献しない
- D:依存逆転の法則により、関数内で呼び出す関数をモックやスパイといったテストオブジェクトに置換できるため、テスト容易性が向上する
MVCにする利点
基本的に画面の実装は密結合になりやすい。密結合になっているとテストが書きづらいのでModelとViewとControllerに分けてテストをしやすくするのが目的だ。きちんと分けた場合、Viewは文字列が出ているか、表示項目が出ているかみたいなテストだけでよく、Modelはビジネスロジックのテストをするだけでいいし、Controllerは互いの橋渡しができているかを見ればいい。このため、テストコードをシンプルにできるのが利点だ。
欠点は画面の項目数が多い場合、インターフェースがそれに応じて肥大化するため、画面に仕様変更が入ったときに保守性が悪くなることだ。ここは品質とのトレードオフで許容するしかないと思う。
単体テストで処理の結合部をどう見るか
一つの関数で完結している関数はそれでいいとして、他の関数を呼んでいる関数をどのように単体テストするかという部分。よくあるパターンとしては他の関数ごとテストしてしまうことだが、これでは結合観点になってしまう。
この部分については呼び出す関数をモックすることで単体観点で確認することが出来る。呼び出す先の関数が単体テストで網羅されていれば、その関数の振る舞いは正しいはずなので、呼び出す側としては呼び出す関数をモックして、関数が正常終了したケースと、異常終了したケースでカバレッジが取れるようにテストすればよい。これは例えば次のようなテストを書くことが出来る。
index.ts
import { isEmpty } from './lib/string-util';
export const validate = (hoge: string) => {
if (isEmpty(hoge)) {
throw new Error('空です。');
} else {
// 何もしない
}
};
index.spec.ts
import { validate } from '.';
import * as empty from './lib/string-util';
jest.mock('./lib/string-util');
describe('validate', () => {
it('hogeが空文字の場合、例外がスローされること', () => {
jest.spyOn(empty, 'isEmpty').mockReturnValueOnce(true);
expect(() => {
validate('aaaa');
}).toThrow(new Error('空です。'));
});
it('hogeが空文字でない場合、例外がスローされないこと', () => {
jest.spyOn(empty, 'isEmpty').mockReturnValueOnce(false);
expect(() => {
validate('aaaa');
}).not.toThrow(new Error('空です。'));
});
});
このテストではisEmpty()
の実際の振る舞いを一切見ていないため、validate()
の単体テストということが出来る。但しisEmpty()
のテストが漏れていると機能しないため、これをきちんと機能させる場合、呼び出し先の関数の単体テストも必須だ。
単体テストに出来ない事と、単体テストの意義
単体テストは単体を見るものなので、当たり前だが単体しか見ることが出来ない。結合観点やe2e観点はなく、単体テストが通っているからデグレやバグがないという通りはない。
基本的に単体テストは動作確認が人間がやり、カバレッジが網羅できており、テストしている内容も正しければ以後のテストを工数を削るためのものである。過去に行った動作確認でテストが通っていて、今も通っているのだから恐らく動作しているだろうということを見るためのもので、単体テストが通っているからデグレがないとか、そんなことの保証はない。
では単体テストを何のためにするかだが、複数の確認工程鵜を作ることでバグを作りこむことを防ぐ役割が一つだ。例えば単体テスト+結合テスト+e2eテスト+人による動作確認やテストと言ったように多層の防御があれば、不具合が起きる可能性を減らすことが出来る。もう一つはコードの仕様となることだ。実装を見て意図が読み取れないコードであってもテストコードがあればわかることもあるだろう。つまるところ実装品質を保つための防御機構の一つとみなすことが出来る。
単体テストを書くときにやったほうがいいこと
テストコードの動作確認をする
テストコードを書いても、そのテストコードが有効に動いているかどうかの観点が抜けていると有意なものとならないので、テストコードの動作確認は必要だ。偶にテストコードは動いているが、偶々テストがパスしてそれっぽく動いているだけみたいなこともある。TDDでやってもこれは起きえる。
期待値や結果値をいじって落ちるかどうかや、パラメーターなどの条件をいじって落ちるかは見たほうが良い。何をしてもパスする場合、そのテストコードは無意味である。
境界値テストを書く
文字列の入力パターンを見るテストや組み合わせテストではカバレッジを網羅していてもテストケースが漏れていることがある。例えば次のテストコードはカバレッジ100%だがtrueになる場合のテストが不足している。もしも関数の中身が必ずfalse
を返す実装になっている場合に、これを確かめることが出来ない。実装を知っているからこれでいいとみなすのではなく、基本的には実装を知らない体で書いた方が良い。とはいえ、限度はあるので現実的に起きうる条件でテストするのが無難だろう。この辺りはSIerで堅めの開発をしていると身に付きやすいのだが、個人の感性に左右される部分であり難しいところでもあると思う。
index.ts
export const isEmpty = (str: string) => {
return str === '';
};
index.spec.ts
import { isEmpty } from '.';
describe('isEmpty', () => {
it('空文字でない時にfalseが返る事', () => {
const actual = isEmpty('aa');
expect(actual).toBe(false);
});
});
変数を型で縛る
例えば以下の実装であれば引数は文字列型なので文字列以外が来ることは考えなくていい。
export const isEmpty = (str: string) => {
return str === '';
};
しかし以下の実装では何が来るかわからない。テストとしてはありとあらゆる値が来ることを想定して書かないとリファクタリングに耐えられないだろう。そんなテストは書きたくない。そもそも実装からどれほどテストを書けばいいのか予測し辛いのもある。
export const isEmpty = (str: any) => {
return str === '';
};
もしどうしても何が来るかわからない場合はunknown
にしておくとよいだろう。何故なら以下のコードはエラーになるからだ。
export const isEmpty = (str: unknown) => {
return str === '';
};
また全体的にきちんと型で縛っておくと、改修などで型が変わった時に実装コードやテストで型不整合が発生する機会が増え、型チェックでエラーが発生するため、未然に不具合を防ぎやすくなる筈だ。
- 投稿日:
ここ数年見かける有効期限付きのURLの名前には署名付きURLとか、Private Cache Distributionパターンの様なものがあるらしい。
これはFantiaみたいな課金サービスを利用している人にはもはや当たり前だと思うが、ページを表示してから一定時間たつと画像が表示できなくなるが、一定時間内であればそのURLは誰でも見れるみたいなやつだ。要するにURLに短めの有効期限を付けて認証されたユーザーにしか見えなくする仕組みのことだ。