13
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React(Storybook, unstated-next)とCakePHPでSPA+API開発

Last updated at Posted at 2021-12-07

ランサーズ 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 にしてもよさそうです。

root/src/unstated/ApiContainer.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

主にデータフェッチとルーティング定義を行います。

root/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はネストが深いところにあるのが良いです。

root/src/index.tsx
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を返す層

App\Controller\ProjectApiController.php
<?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を返すアクションのみ定義します

App\Controller\ProjectController.php
<?php

class ProjectController extends AppController
{
  /**
   * プロジェクト一覧画面
   */
  public function index()
  {
    return $this->render('index');
  }
}

App\View\Project\index.ctp

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

エラー時のレスポンス用の挙動を定義します

App\Lib\Project\Exception\ApiErrorInterface.php
<?php

namespace App\Lib\Project\Exception;

interface ApiErrorInterface
{
  /**
   * レスポンスステータスを返す
   * @return int
   */
  public function getStatus(): int;

  /**
   * エラーメッセージを返す
   * @return string
   */
  public function getErrorMessage(): string;
}

例えばですが、Service層で以下の例外を投げてControllerで上記例外インターフェースでキャッチするイメージです

App\Lib\Project\Exception\NotFoundException.php
<?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 さんです。
よろしくお願いします。

参考文献

13
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?