導入
Reactを書く時、外部との通信や状態の更新など、ロジックどこに書くか問題というのは常につきまとうかと思います。1ページ分だけの小さなReact applciationであれば、とりあえず外側のコンポーネントに押し込んでおけばいいかと割り切ることもできると思うのですが、機能が増えたり複雑なロジックを持つようになるとつらみが増し、末端のコンポーネントにロジックが漏れて行ったりします。
以前からどうすればいいかといつも悩んでいたのですが、Hexagonal architectureを導入してみたところ、まま快適になったので、まとめます。
サンプルのコードはこちら
https://github.com/eiji03aero/react-hexagonal-sample
概要
Hexagonal architectureとは
本家ブログはこちら。
https://alistair.cockburn.us/hexagonal-architecture/
Hexagonal architectureでは、applicationをコアとなるロジックの部分(application service以下)とそれ以外の外界とのやりとり(adapters: DBのread/writeやhttp endpointなど)を明確にわけることで、以下のようなメリットを得ることを目的とします。今回は主にview層へのビジネスロジック流出の阻止
が目的となります。
高いテスタビリティ
基本的にplain oldなコードのみで記述されるApplication service以下にビジネスロジックが集約されるため、テストを容易に行うことができます。またApplication serviceが依存するportsを実体のないinterfaceとすることでモックの差し込みも容易です。
view層へのビジネスロジック流出の阻止
application service以下にビジネスロジックを配置し、view層はあくまでapplication serviceが提供するapiをadapterとして利用するだけにする、という形に制限することで、ビジネスロジックがview層に流れることを防ぎます。ここは人力です。
同じ機能を複数の入力に対して提供する
application serviceがinportsを提供することにより、複数のadaptersが同じ機能を利用することができます。たとえば、csvレポートを出力するという機能をapplciation serviceが持っていた場合に、その機能を利用するhttp endpointのadapterとcliのadapterを作成することができます。
sampleについて
sampleはtodoを管理するアプリです。todoの作成や完了更新、tagの作成やtodoへの紐づけ、またtodo一覧でfilter表示などができます。
Apollo clientを状態管理に利用することで、ServiceとReact双方から同じデータを参照できるようにしつつ、Serviceが好きなようにデータの更新を行うことができます。
申し訳程度の六角形ですがお許しください。
- Service
- Application serviceとしての責務を持ちます。adapterとしてのreactが利用する、主にデータ更新系のapiを提供します。
- React
- view層を担います。Serviceのinstanceをcontextに保持し、データの作成や更新の際に必要なapiを呼び出します。
- データ(Todoなど)の読み込みをapollo client cacheから行うことにより、Service(Repositories経由で)が状態を変更した時に自動的にviewに反映します。
- Repositories
- Apollo clientに依存し、データのRead/Writeを担います。
- Apollo client cache
- ローカルの状態管理を担います。
- データの変更があった際に、Reactに変更を反映します。
- ①: ReactはServiceが提供するapiを呼び出してデータの更新などを行います。
- ②: ServiceはRepositoriesが提供するapiを呼び出してデータのRead/Writeを行います。
- ③: Repositoriesはapollo clientに直接依存し、データのRead/Writeを行い、実装の詳細をServiceから隠します。
- ④: ReactはApollo clientの提供するapiを利用し、データの読み込みと、変更が会った際のデータの反映を受け取ります。
sampleのコード
コードを追いながら、実際の処理のフローを見てみます。
ユースケース1: Todoを作成する
Todoのタイトルをユーザーが入力して作成の処理を進め、作成されたデータがviewに反映されるまでです。
1. Serviceのapiを呼び出し
まずcomponentのeventListenerをtriggerに、Service#createTodoを呼び出します。
export const Todos: React.FC = () => {
...
const handleCreate = React.useCallback((value: string) => {
ctx.service.createTodo({
title: value,
});
}, [ctx]);
...
};
2. Service#createTodo
受け取った引数で、todosServiceのメソッドを呼び出して、Todoの作成の処理を進めます。
export class Service implements types.IService {
...
async createTodo (params: {
title: string,
}): types.PromisedEither<types.ITodo> {
const r1 = await this._todosService.create(params);
...
}
...
}
3. TodosService#create
受け取った引数を使って、Todo classのインスタンスを作成します。
さらにインスタンスのvalidationを実行し、問題がなければtodosRepository#saveにインスタンスを渡して永続化をします。
export class TodosService implements types.ITodosService {
...
async create (params: {
title: string;
}): types.PromisedEither<types.ITodo> {
const todo = new Todo({
title: params.title,
done: false,
});
const r1 = todo.validate();
if (E.isLeft(r1)) {
return r1;
}
const r2 = await this._todosRepository.save(todo);
if (E.isLeft(r2)) {
return r2;
}
return E.right(todo);
}
...
}
4. TodosService#save
Todo classのインスタンスを受け取り、apollo client cacheに書き込みます。
export class TodosRepository implements types. ITodosRepository {
...
async save (todo: types.ITodo): types.PromisedEither<null> {
const r1 = await this._getSerialized({});
if (E.isLeft(r1)) {
return r1;
}
const stodos = r1.right;
const stodo = todo.serialize();
this._apolloClient.writeQuery({
query: local.GetTodosDocument,
data: {
todos: [stodo, ...stodos]
}
});
return E.right(null);
}
...
}
5. 追加されたデータのviewへの反映
前項目で追加されたデータはuseQueryを通して反映されます。
export const Todos: React.FC = () => {
...
const todosResult = useQuery(local.GetTodosDocument, {
variables: {
keyword: state.keyword,
tagIds: state.tagIds,
sort: state.sort,
}
});
...
};
ユースケース2: 無効なTagを作成しようとしたエラーの通知をviewで購読する
無効な入力でTagを作成しようとして、Tag classのvalidationに引っ掛かり、エラ〜メッセージがEventEmitter経由でviewに通知されるまでです。
1. 無効な値でServiceのapiを呼び出し
空文字を名前としてTagを作成するためのapiを呼び出します。
export const Tags:React.FC = () => {
...
const handleCreate = React.useCallback((value: string) => {
ctx.service.createTag({
name: value,
});
}, [ctx]);
...
};
2. Service#createTag
受け取った引数でTagsService#createを呼び出します。
export class Service implements types.IService {
...
async createTag (params: {
name: string,
color?: string,
}): types.PromisedEither<types.ITag> {
const r1 = await this._tagsService.create(params);
...
}
...
}
3. TagsService#create
受け取った引数でTag classのインスタンスを作成し、validateメソッドを呼び出します。
export class TagsService implements types.ITagsService {
...
async create (params: {
name: string,
color?: string,
}): types.PromisedEither<types.ITag> {
const tag = new Tag({
name: params.name,
color: params.color || colors.random(),
});
const r1 = tag.validate();
...
}
...
}
4. Tag#validate
このインスタンスはname propertyに空文字を持っているため、Errorを返却します。
export class Tag extends BaseEntity implements types.ITag {
...
validate () {
...
if (!this.name) {
return E.left(new Error("name cannot be empty"));
}
...
}
...
}
5. Service#createTag-2
前項目で返却されたエラーはService#createTagまでもどってきます。
Service#notificateを呼び出し、Eventをpublishします。
export class Service implements types.IService {
...
async createTag (params: {
name: string,
color?: string,
}): types.PromisedEither<types.ITag> {
const r1 = await this._tagsService.create(params);
if (E.isLeft(r1)) {
await this.notificate({
type: "error",
message: `Failed to create tag: ${r1.left.message}`,
});
return r1;
}
...
}
...
}
6. 通知の購読
前項目でpublishされた通知は、通知のためのコンポーネントによって購読され、ユーザーへfeedbackされます。
export const NotificationsContainer: React.FC = () => {
...
React.useEffect(() => {
const handler = ((sn: types.SNotification) => {
setState({
open: true,
currentNotification: sn,
});
});
ctx.service.onNotification(handler);
return () => {
ctx.service.offNotification(handler);
};
}, [setState]);
...
return (
<Snackbar
open={state.open}
autoHideDuration={6000}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
onClose={handleClose}
>
<Alert
elevation={6}
variant="filled"
severity={severity}
onClose={handleClose}
children={message}
/>
</Snackbar>
);
};
所感
結局ビジネスロジックがview層に漏れでていないかどうかは開発者が人力でレビューするしかないのですが、明確にcomponentを定義してこの中に収まるように書きましょう、と規約を設定できるのはわかりやすくていいと思います。以下pros cons。
良いところ
- Service以下にロジックを集約できる。Reactからはあくまで必要な引数を作ってServiceのapiに渡すだけにとどめる。きれいになります。
- Apollo clientのおかげで、Service - React間の繋ぎ込みが容易です。
- テストが書きやすい。
- 今回のsampleではテストは書いていないのですが、やはりビジネスロジックが混入しないよう努力されているため、書きやすいはずであります。
悪いところ
- ボイラープレートが多い
- Service class内に直接IO処理(apollo client cacheへのRead/Write)を書かないためにRepositoryというadapterを作ることにしているが、ServiceとRepositoryのapiで似たようなsignatureになりやすいので、冗長な記述が増えてしまった。一つ一つtypeのaliasを定義するのもいかがなものか。。。
- apollo client前提のコードベースになってしまう
- ロジックを明確にreactから分離するために、apollo client(cache)を利用していて、この実装そのものはrepositoryによって抽象化されているものの、react側から同じデータを利用するために直接依存する形にどうしてもなってしまうため、のちのちの柔軟性に乏しいと言える。
- reactのコアチーム側でこの辺のフルスタックなフレームワークを提供してくれたりはしないのかなあ