はじめに
こんにちは、森茂です。
先日公開したmicroCMSのWebフロントエンドにクリーンアーキテクチャを採用した話【前編】に引き続き、今回は後編として前編で紹介させていただいた構成にあわせて、実際にどのような実装とチームへの浸透を行っていったかについて紹介いたします。
なお、記事内に記載している仕様、ソースコードは説明用として省略や変更、部分的な引用を行っています、実際のサービスとは異なる箇所もある点をあらかじめご了承ください。
前編より
前編ではmicroCMSのフロントエンドアーキテクチャをどういった背景、構成で採用したかを紹介しました。実装の段階に進むにあたっては、その中でもとくに責務(関心)の分離と負担なく開発を進めことができるかを意識することにしました。
また、前編でも紹介の通り、クリーンアーキテクチャという概念に振り回されないことも重要と考えています。完璧な設計を求めることはせず、microCMSにとって実現、運用しやすいように解釈し改変することも是としています。microCMSの開発チームはまだ小規模ですが、徐々に成長を続けている状態です。重要なのはチームの規模にあわせてスケールできるmicroCMSのアーキテクチャをつくることでした。
責務(関心)の分離について
Reactで作られるフロントエンドはそのほとんどが関数への入力と出力によって形成されています。値を渡すとその値の場合の結果を返してくれるコンポーネント、そしてその集まりを役割ごとにレイヤー分けすることで冗長な構成になったとしても管理、把握しやすい構成を目指しています。
アーキテクチャの刷新にあたり、開発チームには導入として「おいしいおにぎりのつくりかた」という題目で、役割を分けることのメリットを再認識してもらうためのレクチャー(小話?)も実施しています。
とりあえずこの3つを再認識
- 境界を意識する
- それぞれがブラックボックスであることを意識する
- 入力、出力をテストで担保することを意識する
少々稚拙で強引なたとえではありますが、責務(関心)の分離について再認識してもらうためには役に立った、かもしれません:)
このほか社内での共有会や、サンプルコードの読みあわせを行ったり、開発中に疑問がでたときにはSlackやGatherなどコミュニケーションツールを利用して意識のすりあわせを行うようにしています。
負担(不安)のない開発
完璧な設計をおこなうことは容易でありません。完璧を求め、例外を排除すればするほど開発は退屈で窮屈なものとなってしまいます。microCMSのフロントエンドアーキテクチャは、開発者がレールへ乗る、また時に乗らないという選択肢もとれることを念頭に試行錯誤をつづけています。最終的にはリリースに対しての開発者の負担、不安を0にすることを目標としています。
負担を減らすための施策一例
Makefileを利用したCLIユーティリティ
- 開発環境用Containerの起動・管理・再構築、インフラとの連携など
Hygenを利用した対話型のテンプレート生成
- コンポーネント、Custom Hooks、テストコード、Storybook、CSSなど
ESLintルールの改善
- 各種ルールの調整(主にimport/sortに関わる部分の自動化)
新設計によるレイヤー分けされたアーキテクチャ
- 開発時の最初の一歩の負担を軽減
不安を減らすための施策一例
テストの拡充
- テストしやすい新設計の普及
FeatureFlagを利用したリリース
- 本来のFeatureFlagとしての利用方法だけでなく、リリースの即時巻き戻しの手段としても有益
- リリースに対しての不安を取り除く
コミュニケーションツールを利用した開発手法の相談体制
- 正解はひとつではない
- 挑戦とともに壊すことのできる構成をこころがける
microCMSのディレクトリ構成
前編での紹介のとおり、microCMSではView、Presenter、UseCase、Repository、Entityの5つのレイヤー(層)を定義しています。今まではcomponents
やhooks
という抽象的なディレクトリにまとまって配置されており、それぞれのディレクトリに多くのファイルが混在している状態となっていました。そこで新しい構成では、下記のような構成をとることにしました。
まず主となる5つのレイヤーは下記の通り。なおView、Presenterについては後述しますがviewディレクトリにまとめています。
- src/view
- src/usecase
- src/repository
- src/entity
他にもここに記載のないディレクトリが複数ありますが、主に利用されるディレクトリは下記のとおりです。(改修前より存在するディレクトリやこちらに記載のないものもほかに多数あります)
- src/lib …外部ライブラリーを利用するためのコード(TanStack Queryのラッパー、microCMSのクライアントなど)
- src/test …テストコード用のユーティリティなど
- src/utils …usecaseレイヤーに属していない便利関数など(配列操作などビジネスロジックを持たないもの)
- src/providers …Contextやライブラリの各種Providerなど
- src/openapi …OpenApiに関連するコード
- src/graphql …GraphQLに関連するコード
- src/constants …アプリケーション内で利用する定数など
- src/styles …グローバルで利用するCSSやCSS変数など
5つのレイヤー
それではここからは5つのレイヤーについてmicroCMSではどのような形となっているか実際の画面(通知の言語設定)を例にして紹介します。
Entity
microCMSではEntityを下記のように定義しています。
- アプリケーション全体で使うようなオブジェクトを定義するレイヤー
- User、Media、Contentなどのinterface(type)やclassを書く
Entityは主にinterface(type/型)、型ガードが記載されているレイヤーとなっています。
src/entity/
├── serviceLanguage.ts
...
// serviceLanguage.ts
export type ServiceLanguage = 'ja' | 'en';
export type LanguageSetting = ServiceLanguage | null;
export const isServiceLanguage = (value: unknown): value is ServiceLanguage => {
if (typeof value !== 'string') {
return false;
}
const serviceLanguage = value as ServiceLanguage;
if (serviceLanguage === 'ja' || serviceLanguage === 'en') {
return true;
}
return false;
};
通知の言語設定ページではサービスで設定できる言語ServiceLanguage
とユーザーの言語設定LanguageSetting
として型を定義しています。またサービスで設定できる言語を判別するための型ガードisServiceLanguage
もEntityレイヤーに配置しています。
Repository
microCMSではRepositoryを下記のように定義しています。
- 外部に依存する処理を書くレイヤー
- DB、API、ブラウザ(CookieやLocalStorageなど)と接続しデータを取得する、保存する
外部データの取得、キャッシュの管理にTanStack Queryを利用しており、RepositoryにはQuery Functions
となるロジックが多く配置されています。
src/repository/
├── languageSettingsRepository
│ ├── __tests__
│ │ └── languageSettingsRepository.test.ts
│ ├── languageSettingsRepository.ts
│ └── index.ts
└── ...
通知の言語設定ページでは現在の言語設定の取得、言語設定の更新に関わるロジックを利用しています。下記はAppSync(GraphQL)を利用したサンプルコードとなっています。
TanStack QueryではonError
の中でthrowされたErrorのハンドリングができます。microCMSのフロントエンドではQuery Functions
からをthrowしたErrorを、onError
で受け取りToastなどで通知を行うようにしています。エラーの処理についてはそれぞれのレイヤー内で完結すべきとも考えましたが例外的にTanStack Queryに依存する形としています。
// languageSettingsRepository.ts
// AppSyncの型からzod schemaを用意
const getServiceLanguageSchema = z.object({
getServiceLanguage: z.nativeEnum(Language).nullish(),
}) satisfies z.ZodType<GetServiceLanguageQuery>;
export const languageSettingsRepository = (serviceId: string) => {
// 現在の言語設定の取得
const getServiceLanguage = async (): Promise<ServiceLanguage> => {
try {
// AppSync GraphQLエンドポイントからデータを取得
const result = (await API.graphql(
graphqlOperation(queries.getServiceLanguage, {
serviceId,
})
)) as GraphQLResult<unknown>;
// 値のバリデーションと型付け
const resultData = getServiceLanguageSchema.parse(result.data);
if (resultData.getServiceLanguage != null) {
return resultData.getServiceLanguage;
}
// 未設定や取得できない場合はデフォルト値'ja'を返す
return 'ja';
} catch (error) {
// 問題が起きた場合はErrorをthrowする
if (error instanceof Error) {
throw new Error(error.message, { cause: error });
}
throw new Error('Could not get service language', { cause: error });
}
};
// 言語設定の更新
const updateServiceLanguage = async (
serviceLanguage: ServiceLanguage
): Promise<UpdateResult> => {
//
// 更新処理
//
};
return { getServiceLanguage, updateServiceLanguage };
};
参考までにRepositoryからexportされる関数はテストコード、UseCaseレイヤーから下記のように利用されます。
// languageSettingsRepository.test.ts
//
const mockApi = vi.spyOn(API, 'graphql');
//
test('APIからの返りがnullの場合にはデフォルトのjaを返すこと', async () => {
mockApi.mockResolvedValue({
data: {
getServiceLanguage: null,
},
});
const result = await getServiceLanguage();
expect(result).toBe('ja');
});
UseCase
microCMSではUseCaseを下記のように定義しています。
- microCMSならではのビジネスロジックを書くレイヤー
- 使い回しのできるユーティリティ関数
- 外部(DB、API、ブラウザ、React)に依存する処理を書かない
- ページによっては使わない場合もある
UseCaseレイヤーではReactに依存する処理は記載しないことをルールとしているためCustom Hooksもこのレイヤーには当てはまりません。ビジネスロジックやバリデーションなどが入ります。microCMSではRepositoryとPresenterをつなぐ処理(右から左)のみとなることや、不要となる場合もあります。
src/usecase/
├── languageSettingsUsecase
│ ├── __tests__
│ │ └── languageSettingsUsecase.test.ts
│ ├── languageSettingsUsecase.ts
│ └── index.ts
└── ...
以下は通知の言語設定ページで言語設定を変更する際のサンプルコードですが、以下のようにパラメーターを検証後にRepositoryを呼び出すという形が多く配置されています。
// languageSettingsUsecase.ts
//
export const updateServiceLanguage = async ({
serviceId,
serviceLanguage,
}: UpdateParams): Promise<UpdateResult> => {
// バリデーションなど
// ex) パラメーターを検証、誤りがある場合Errorをthrowする
validateUpdateParams({ serviceId, serviceLanguage });
const { updateServiceLanguage } = languageSettingsRepository(serviceId);
return await updateServiceLanguage(serviceLanguage);
};
//
テストコードについて
各レイヤーのテストコードでは依存するレイヤーを以下のようなモックとして扱うことでテストを行っています。モックに依存することはモックの作り方によってテスト自体が振り回されるというリスクもありますが、まずは上記であげた3つの再認識項目、境界、ブラックボックス、入力・出力をテストで担保することを意識するという指針のもとこの方法を採用しています。またこれには開発チームの中で認識を共通化することで、テストの習熟度による差をできる限りなくしたいという意図もあります。
// useLanguageSettingsUsecase.test.ts
//
const get = vi.fn();
const update = vi.fn();
const mockRepository = vi.spyOn(repository, 'languageSettingsRepository');
mockRepository.mockImplementation(() => ({
getServiceLanguage: get,
updateServiceLanguage: update,
}));
//
describe('getServiceLanguage', () => {
test('受け取った言語の値を返すこと', async () => {
get.mockResolvedValue('ja');
const result = await getServiceLanguage('test');
expect(result).toBe('ja');
});
});
//
Presenter / View
View、Presenterレイヤーは同一のディレクトリsrc/view
以下に配置しています。
Presenter(Custom Hooks)
- Reactコンポーネント(View)と接続し、Viewを操るレイヤー
- 状態管理を行う
- ViewからのUIイベントを一時受けする
- Reactとの接続があるためCustom Hooksになることがほとんど
- 他のHooksに依存するものはここで呼び出す
microCMSではPresenterレイヤーはViewを操るレイヤーでありReactに依存したロジックを描くレイヤーです。そのためPresenterレイヤーはCustom Hooksレイヤーとも言っています。なおViewレイヤー、Presenterレイヤーお互い密接に関係しているためコロケーションの観点から同一ディレクトリに配置しています。
view/ServiceSettings/
├── LanguageSettings/
│ ├── LanguageSettings.stories.tsx
│ ├── LanguageSettings.tsx
│ ├── __tests__
│ │ ├── LanguageSettings.test.tsx
│ │ ├── useLanguageSettings.test.ts
│ │ └── useLanguageSettingsQuery.test.ts
│ ├── index.tsx
│ ├── languageSettings.module.css
│ ├── translations.json
│ ├── useLanguageSettings.ts
│ └── useLanguageSettingsQuery.ts
例にあげたコンポーネントはかなりシンプルなものですが、microCMSではコンポーネントのカタログとしてStorybookを、またi18nの言語設定ファイルとしてtranslations.json
、stylingにはCSS Modulesを利用しています。View、Presenter(Custom Hooks)、テストコードをあわせるとそれなりのファイル数となるため、書き始めるのもそれなりの手間になります。またStoryの書き忘れやテストコードの書き忘れを防ぐためにもコードジェネレーターであるHygenを利用して対話的にコンポーネントセットを作成できるようにしています。
たとえばView、Presenterレイヤーはyarn create:view
とすることで対話形式に一式を生成できるようにしています。
$ hygen create view
✔ What is the name of component? · EditContent
✔ Where is the directory? (No problem in blank) ·
✔ Is it have style? (y/N) · true
✔ Is it have props? (y/N) · true
✔ Is it have hooks? (y/N) · true
Loaded templates: .hygen
added: src/views/EditContent/EditContent.stories.tsx
added: src/views/EditContent/__tests__/EditContent.test.tsx
added: src/views/EditContent/EditContent.tsx
added: src/views/EditContent/__tests__/useEditContent.test.tsx
added: src/views/EditContent/useEditContent.ts
added: src/views/EditContent/index.tsx
added: src/views/EditContent/editcontent.module.css
added: src/views/EditContent/translations.json
通知の言語設定ページではuseLanguageSettings.ts
、useLanguageSettingsQuery.ts
がPresenterレイヤーに該当します。分割するファイル数にルールは設けていませんがひとつのファイルが大きくなることは避けるようにしています。またTanStack Queryを利用する部分は役割が明確なためファイルを分けることを推奨しています。TanStack Queryを利用して取得するデータ、キャッシュはServer stateのため責務を分離したいという意図です。
現在は依存関係の都合上移行できていないのですが、今後はReact Router 6.4で実装されたLoader Functions
を利用して境界を明確にしたいところです。またせっかくレイヤー分けをしているのに境界を超える部分、ランタイムでのバリデーションについても不足しているのも残念なところ。こちらを充実させていくことも今後の課題になりそうです。
ソースコードとしてはPresenter(Custom Hooks)ではViewレイヤーを操るためのイベントハンドラーとなるものが主となってきます。
// useLanguageSettings.ts
//
export const useLanguageSettings = (partitionKey: Service['partitionKey']) => {
// 取得用、更新用のTanStack Queryを読み込む
const { useGetServiceLanguage, useUpdateServiceLanguage } =
useLanguageSettingsQuery();
const {
mutate: updateServiceLanguage,
isLoading: updateServiceLanguageLoading,
} = useUpdateServiceLanguage();
const { data: serviceLanguage, isLoading: serviceLanguageLoading } =
useGetServiceLanguage(partitionKey);
// Viewのセレクトイベントの処理
const [language, setLanguage] = useState<LanguageSetting>(null);
const onChangeLanguage = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
if (isServiceLanguage(e.target?.value)) {
return setLanguage(e.target.value);
}
setLanguage(null);
},
[]
);
// Viewの更新イベントの処理
const onUpdateServiceLanguage = useCallback(() => {
updateServiceLanguage({
serviceId: partitionKey,
serviceLanguage: language === null ? serviceLanguage : language,
});
}, [serviceLanguage, updateServiceLanguage, language, partitionKey]);
return {
language,
serviceLanguage,
serviceLanguageLoading,
updateServiceLanguageLoading,
onChangeLanguage,
onUpdateServiceLanguage,
};
};
APIからのデータ取得に関してはTanStack Queryで、成功時、エラー時を伝えるトースト表示を以下のような処理をしています。
// useLanguageSettingsQuery.ts
// ...
const useUpdateServiceLanguage = () => {
const { t } = useTranslation('hooksService');
const cache = useQueryClient();
const { addToast } = useToasts();
return useMutation(updateServiceLanguage, {
onSuccess({ result }) {
if (result?.result === true) {
cache.invalidateQueries(['serviceLanguage'], { type: 'all' });
addToast(t('Changed language.'), { appearance: 'success' });
} else {
addToast(result?.message, {
appearance: 'error',
});
}
},
onError(error: Error) {
// ...
addToast(
error.message ? error.message : t('Language could not be changed.'),
{
appearance: 'error',
}
);
},
});
};
// ...
View
- 渡されたデータをレンダリングするだけのレイヤー
- Reactコンポーネント
- Presentational/Containerコンポーネントで構成される
Viewに該当するコンポーネントはファイルとしては1つですがPresentational/Containerコンポーネントで構成されています。
一部省略をしていますが、1つのファイルに描画のみを担当するPresentational部分LanguageSettingsView
、ロジックを受け取りPropsを渡す役目のContainer部分LanguageSettings
を用意しています。
// LanguageSettings.tsx
//
export const LanguageSettingsView: React.FC<ViewProps> = ({
language,
serviceLanguage,
serviceLanguageLoading,
updateServiceLanguageLoading,
onChangeLanguage,
onUpdateServiceLanguage,
}) => {
// Presentational側にはロジックは書かない
// 例外的に翻訳文の生成のみPresentational側に設置するようにしている
const { t } = useTranslation('serviceLanguageSettings');
if (serviceLanguageLoading) return <Loading />;
return (
<div>
<Head title={t('Notification Language')} />
<div className={styles.wrapper}>
<h2 className={styles.title}>{t('Notification Language')}</h2>
<p className={styles.description}>
{t(
'Select a language for notifications such as webhooks and user invitations.'
)}
</p>
<div className={styles.select}>
<Selectfield
value={language === null ? serviceLanguage : language}
onChange={onChangeLanguage}
label={t('Notification Language')}
>
<option value="ja">日本語</option>
<option value="en">English</option>
</Selectfield>
</div>
<div className={styles.actions}>
<Button
type="primary"
value={t('Save changes')}
onClick={onUpdateServiceLanguage}
disabled={updateServiceLanguageLoading}
/>
</div>
</div>
</div>
);
};
export const LanguageSettings: React.FC = () => {
const { service } = useGetMyService();
const {
language,
serviceLanguage = 'ja',
serviceLanguageLoading,
updateServiceLanguageLoading,
onChangeLanguage,
onUpdateServiceLanguage,
} = useLanguageSettings(service.partitionKey);
return (
<LanguageSettingsView
language={language}
serviceLanguageLoading={serviceLanguageLoading}
serviceLanguage={serviceLanguage}
updateServiceLanguageLoading={updateServiceLanguageLoading}
onChangeLanguage={onChangeLanguage}
onUpdateServiceLanguage={onUpdateServiceLanguage}
/>
);
};
どちらかというとHooks時代になる前の手法とも言えますが、責務の分離という視点からは境界、役割が明確になるため以下の記事を参考させていただき採用しました。なおこちらもHygenを利用したひな形で開発者の手間を減らすようにしています。
- 経年劣化に耐える ReactComponent の書き方 - Qiita
- Presentational and Container Components | by Dan Abramov | Medium
コンポーネントを分けることによってPropsを渡す手間が増え、俗に言うProp Drillingを感じるかもしれません。不必要なProp Drillingは問題ですが、すべてが悪になるとは考えていません。適切なPropsはひと目で何を必要とし、何を行うソースコードであるか視認性が高め、ソースコード自体がドキュメントとなると考えています。テストコードと適切なPropsをやり取りしているソースコードがあればむしろその役割は明確になるでしょう。
上記の話からは少し脱線しますがコロケーション、PropsやStateをどこに持つかの考え方についてはRemixやReact Testing Libraryの開発者でもあるKent氏のブログも参考になると思います。
Presenter / Viewのディレクトリ構成についての補足
Presenter/Viewレイヤーのディレクトリ構成について、補足的な説明となりますが、親子関係がある場合は階層化することを前提としています。たとえばサービス設定にグルーピングできる場合は、ServiceSettings
の下層に配置しています。
view/ServiceSettings/
├── ServiceSettings.tsx
├── ...
├── EnvironmentSettings/
│ └── ...
├── LanguageSettings/
│ └── ...
また複数のViewで利用するViewレイヤーについてはCommon
というディレクトリに配置しています。
view/Common/
├── Prompt/
│ └── ...
├── Roles/
│ └── ...
├── ...
おわりに
駆け足ですが以上で前編の設計思想に対して、どのような構成で実装を進めていったかについて紹介させていただきました。お伝えきれない細かな部分もたくさんありますが、どのような思想で刷新を行ったかを少しでも共感いただければ幸いです。
microCMSではアーキテクチャの刷新にあわせて、以下のような設計に影響をあたえていたライブラリや実装方法についてもあわせて刷新を行っています。
- TypeScriptへの完全移行
- ReduxからTanStack Queryへ移行
- HOCからHooksへ移行
- CRAからViteへ移行
- CRA依存のJestからVitestへ移行
- などなど
設計を刷新したことで得られたメリットについて前編でも紹介していますが、一番は開発者の迷いが減り、負担・不安が軽減されたことだと考えています。今後もひきつづき考える必要のないことは考えなくていい、必要なことだけに集中できる開発環境を実現していきたいと考えています。
なお今回は設計を刷新した目的のひとつでもあるテストについて深堀りして紹介することができませんでした。刷新にあわせた各種ライブラリの移行やテストについても機会をあらためて紹介したいところです。