ランサーズ Advent Calender 2021 7日目の記事です。
ランサーズのオンラインマッチング事業部所属の川原です。
元々CakePHPで作られている一つの大きなプロダクトに対しAPI(CakePHP2)+SPA(React)の構成で機能を追加することがあったので実例を元に、ディレクトリ構成と実装の流れについてsampleとして紹介します。元のプロダクトに/project
というページを追加し、機能を作成する想定とします。
構成
CakePHPとReactのディレクトリ構成はそれぞれ以下のようになっています。固有のドメイン名を含むディレクトリ、ファイル、その他設定ファイル等は一部省略しています。省略箇所は雰囲気で読み取ってください。
フロントエンド
フロントエンドのコンポーネント実装において関連する使用ライブラリは主にReact、Storybook、CSS modules、unstated-next等です。
root
├ .storybook/
├ config/
├ src/
| ├ components/
| | └ pages/
| | └ Index/
| | ├ index.module.css
| | ├ index.module.css.d.ts
| | ├ index.stories.mdx
| | └ index.tsx
| ├ constants/
| ├ images/
| ├ unstated/
| ├ App.tsx
| ├ index.tsx
| └ models.ts
├ package.json
├ .babelrc
├ tsconfig.json
└ README.md
.storybook
storybookの設定ファイルを配置しています。
config
webpackやpath等の設定ファイルを配置しています。
src/components配下
アトミックデザインでコンポーネントを作成します。そのため、pagesディレクトリ以外にもatomsやmolecules等のディレクトリも必要に応じて切っていきます。
さらにその下にコンポーネント名でディレクトリ切って以下ファイルを追加します。
- index.module.css
- index.module.css.d.ts
- index.stories.mdx
- index.tsx
src/constants
宣言する定数を配置します。主にプロジェクトで必要な path や API のエンドポイント等を定義します。
src/images
画像データ(主にSVGファイル)を配置します。
src/unstated
unstated-nextを用いてコンテナを定義します。APIとの通信はここで行います。
型定義は ../models.ts
にしてもよさそうです。
import React, { useState } from 'react';
import { createContainer } from 'unstated-next';
import axios, { AxiosRequestConfig } from 'axios';
import PATH from '../constants/path';
type ApiResponse = {
projects: {
id: number;
project_name: string;
description: string;
}
}
type ApiError = {
projectName: string;
description: string;
}
const useApiDataHook = () => {
const [projectName, setProjectName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [error, setError] = useState<ApiError>({projectName: '', description: ''});
const onChangeProjectName = (e: React.ChangeEvent<HTMLInputElement>) => {
setProjectName(e.currentTarget.value);
};
const save = async () => {
const params = {
project_name: projectName,
description: description,
};
const axiosConfig: AxiosRequestConfig = {
method: 'POST',
url: PATH.API.PROJECT,
data: params,
};
try {
await axios.request<ApiResponse>(axiosConfig);
} catch (error: any) {
if (error.response.status === 400) {
setError({
projectName: error.response.data.project_name,
description: error.response.data.description,
});
} else {
alert('エラーが発生しました');
}
}
};
return {
projectName,
description,
error,
onChangeProjectName,
save,
};
};
const ApiContainer = createContainer(useApiDataHook);
export default ApiContainer;
src/App.tsx
主にデータフェッチとルーティング定義を行います。
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import Foo from './components/pages/Foo';
import Bar from './components/pages/Bar';
import Piyo from './components/pages/Piyo';
import Loading from './components/organisms/Loading';
import PATH from './constants/path';
import LoadingContainer from './unstated/LoadingContainer';
const useInitialize = () => {
const { fetchData } = ApiContainer.useContainer();
useEffect(() => {
fetchData();
}, []);
};
function App() {
const { isLoading } = LoadingContainer.useContainer();
useInitialize();
return (
<Router>
<Loading isLoading={isLoading} />
<Switch>
<Route path={PATH.FOO} exact>
<Foo />
</Route>
<Route path={PATH.BAR} exact>
<Bar />
</Route>
<Route path={PATH.PIYO} exact>
<Piyo />
</Route>
</Switch>
</Router>
);
}
export default App;
src/index.tsx
unstated-nextで定義したコンテナを挟みます。変更が多いstateはネストが深いところにあるのが良いです。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import ApiContainer from './unstated/ApiConfigListContainer';
import LoadingContainer from './unstated/LoadingContainer';
import FlushMessageContainer from './unstated/FlushMessageContainer';
// 以下例
ReactDOM.render(
<React.StrictMode>
<LoadingContainer.Provider>
<FlashMessageContainer.Provider>
<ApiContainer.Provider>
<App />
</ApiContainer.Provider>
</FlashMessageContainer.Provider>
</LoadingContainer.Provider>
</React.StrictMode>,
document.getElementById('project-root')
)
サーバーサイド
CakePHP2.xのものです
app
├ Config
| └ routes.php
├ Lib
| └ Project
| ├ Exception
| ├ Factory
| ├ Object
| ├ Repository
| └ Service
├ Controller
| ├ ProjectApiController.php
| └ ProjectController.php
└ View
└ Project
└ index.ctp
App\Config\routes.php
APIと後述するProjectControllerのアクションのルーティングを定義します。CakePHPの機能をそのまま使うだけです。
Router::connect('/api', ['controller' => 'ProjectApi', 'action' => 'index', '[method]' => 'GET']);
Router::connect('/api', ['controller' => 'ProjectApi', 'action' => 'add', '[method]' => 'POST']);
Router::connect('/api/:project_id', ['controller' => 'ProjectApi', 'action' => 'view', '[method]' => 'GET'], ['project_id' => '[0-9]+']);
Router::connect('/api/:project_id', ['controller' => 'ProjectApi', 'action' => 'edit', '[method]' => 'PUT'], ['project_id' => '[0-9]+']);
Router::connect('/api/:project_id', ['controller' => 'ProjectApi', 'action' => 'delete', '[method]' => 'DELETE'], ['project_id' => '[0-9]+']);
App\Controller\ProjectApiController.php
Requestを受け取ってJSONを返す層
<?php
use App\Lib\Project\Repository\ProjectRepository;
use App\Lib\Project\Object\Project;
use App\Lib\Project\Service\ProjectService;
use App\Lib\Project\Exception\ApiErrorInterface;
class ProjectApiController extends AppController
{
public $uses = [
'Project',
];
/**
* Projectの追加
*/
public function add()
{
try {
$project = ProjectService::create($this->request->data);
} catch (ApiErrorInterface $e) {
return $this->renderErrorJson($e);
}
return $this->renderJson([$project]);
}
/**
* application/jsonで結果を返す
* @param array $data
* @return CakeResponse
*/
public function renderJson(array $data): CakeResponse
{
$this->response->type('json');
$this->response->charset('UTF-8');
$this->response->body(json_encode($data));
return $this->response;
}
/**
* 例外時の結果を返す
* @param ApiErrorInterface $error
* @return CakeResponse
*/
public function renderErrorJson(ApiErrorInterface $data): CakeResponse
{
$this->response->statusCode($data->getStatus());
return $this->renderJson([$data]);
}
}
App\Controller\ProjectController.php
React DOMによって描画するためのルートDOMを返すアクションのみ定義します
<?php
class ProjectController extends AppController
{
/**
* プロジェクト一覧画面
*/
public function index()
{
return $this->render('index');
}
}
App\View\Project\index.ctp
<?php
/**
* Project画面
*/
$this->Html->headCss("/project.css");
?>
<div id="project-root">
<?= $this->Html->script('/project.js') ?>
App\Lib\Project\Exception
エラー時のレスポンス用の挙動を定義します
<?php
namespace App\Lib\Project\Exception;
interface ApiErrorInterface
{
/**
* レスポンスステータスを返す
* @return int
*/
public function getStatus(): int;
/**
* エラーメッセージを返す
* @return string
*/
public function getErrorMessage(): string;
}
例えばですが、Service層で以下の例外を投げてControllerで上記例外インターフェースでキャッチするイメージです
<?php
namespace App\Lib\Project\Exception;
class NotFoundException extends Exception implements ApiErrorInterface
{
/**
* {@inheritdoc}
*/
public function getStatus(): int
{
return 404;
}
/**
* {@inheritdoc}
*/
public function getErrorMessage(): string
{
return 'データが存在しません';
}
}
App\Lib\Project\Factory
Repositoryの型を変換する層としています。
App\Lib\Project\Object
ValueObjectやEntityのような責務を持たせています。
App\Lib\Project\Repository
データの取得、削除等を行う層
App\Lib\Project\Service
ビジネスロジック層
今後の課題
フロントエンドにおいて一部適切にアトミックデザインとしてコンポーネントを分割し切れてなかったところがある感じがするので最初の時点でコンポーネント同士の関係性を考慮しきっておくべきでした。
所感
サーバーサイドからフロントエンドまで触ってみて、SPA+APIはチーム開発において有用そうな構成だと感じました。フロントエンドはStorybookとCSS Modulesによってコンポーネント単位でレビューが可能なためレビュイ、レビュア双方にとってよさそうです。APIの方も基本的にフロントのことを気にせずに開発に注力できるため、より変更に強い設計を考えられるはずだと思います。
フロントエンドとサーバーサイドとで分離されただけそれぞれの担当領域が狭まるため、それぞれの実装担当者はより深い知識が求められると感じました。
個人的に同時に両面見ることで設計、実装面で成長と反省がありました。
アドベントカレンダー、明日は @m-tsuchiya さんです。
よろしくお願いします。
参考文献