LoginSignup
1
1

More than 3 years have passed since last update.

PHP初心者っぽい書き方の掲示板型メモ帳をリファクタリングしてDDDっぽくする

Posted at

はじめに

これから新しくPHPで何かを作る場合、どういう構造が良いだろうか。
確認と使い勝手を見るために小さなサンプルアプリを作りリファクタリングで少しずつ変更していくことで違いを体感する。

まずベタ書きで作る

DB定義

db/tables.sql
create table article(
  article_id integer primary key autoincrement,
  title text not null,
  body text not null,
  created_at timestamp not null default current_timestamp,
  updated_at timestamp
);

create table tag(
  article_id integer not null,
  tag text not null,
  primary key(article_id, tag)
);

最初は全て一つのファイルに書くこととする。

public/index.php
<?php

// (snip)

$article = (object)[
    'article_id' => null,
    'title' => null,
    'body' => null,
    'created_at' => null,
    'updated_at' => null,
    'tags' => null,
];

$pdo = getPdo();
$message = null;
if (($title = maybe_string($_POST['title'])) &&
    ($body = maybe_string($_POST['body']))){

    if ($articleId = maybe_int($_POST['article_id'])){
        $sql = 'update article set title = ?, body = ?, updated_at = current_timestamp where article_id = ?';
        $stmt = $pdo->prepare($sql);
        $stmt->execute([$title, $body, $articleId]);

        $pdo->exec(sprintf('delete from tag where article_id = %d', $articleId));

        $message = '更新しました。';

    }else{
        $sql = 'insert into article(title, body) values(?,?)';
        $stmt = $pdo->prepare($sql);
        $stmt->execute([$title, $body]);
        $articleId = $pdo->lastInsertId();

        $message = '登録しました。';
    }

    $tags = explode(' ', $_POST['tags'] ?? []);
    if ($tags){
        $tagSql = 'insert into tag values(?, ?)';
        $stmt = $pdo->prepare($tagSql);
        foreach ($tags as $tag){
            $stmt->execute([$articleId, $tag]);
        }
    }

}

if ($id = maybe_int($_POST['delete_id'])){
    $pdo->exec(sprintf('delete from tag where article_id = %d', $id));
    $pdo->exec(sprintf('delete from article where article_id = %d', $id));
    $message = '削除しました。';
}

if ($id = maybe_int($_GET['id'])){
    $sql = "
select article.*, group_concat(tag, ' ') as tags
from article
left outer join tag using(article_id)
where article_id = ?
    ";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$id]);
    $rows = $stmt->fetchAll();

    if ($rows){
        $article = $rows[0];
    }
}

$tagWhere = '';
$params = [];
if ($search = maybe_string($_GET['tag'])){
    $tagWhere = '
where exists (
  select 1
  from tag
  where article_id = article.article_id
    and tag = ?
)';
    $params = [$search];
}
$stmt = $pdo->prepare(sprintf("
select article.*, tags
from article
left outer join (
  select article_id, group_concat(tag, ' ') as tags
  from tag
  group by article_id
) tag using(article_id)
%s
order by created_at desc
    ", $tagWhere));
$stmt->execute($params);
$rows = $stmt->fetchAll();

?>
<!DOCTYPE html>
<meta charset="UTF-8">
<title>メモ帳</title>
<style type="text/css">
* { vertical-align: middle; }
input, textarea { width: 20em; }
textarea { height: 5em; }
.message { color: red; }
.memo { padding: 1em; margin: 1em; background-color: #FFE; border: 1px solid #DDD; }
form { display: inline-block; }
</style>
<h1>メモ帳</h1>

<?php if ($message): ?>
<section class="message">
  <?= h($message) ?>
</section>
<?php endif; ?>

<section>
  <h2>新規投稿</h2>
  <form action="" method="POST">
    <input type="hidden" name="article_id" value="<?= $article->article_id ?>">
    タイトル: <input type="text" name="title" value="<?= h($article->title) ?>"><br>
    本  文: <textarea name="body" value=""><?= h($article->body) ?></textarea><br>
    タ  グ: <input type="text" name="tags" value="<?= h($article->tags) ?>"><br>
    <button type="submit">投稿</button>

  </form>

  <?php if ($article->article_id): ?>
  <div>
    <br>
    <a href="/">キャンセル</a>
  </div>
  <?php endif; ?>
</section>

<hr>

<?php if ($rows): ?>
  <section>
  <h2>メモ一覧</h2>
  <?php foreach ($rows as $row): ?>
    <section class="memo">
      <h3><?= h($row->title) ?></h3>
      <div>
        <?= br($row->body) ?>
      </div>
      <div>
        <time>created: <?= date('Y-m-d H:i:s', strtotime($row->created_at)) ?></time>
        <?php if ($row->updated_at): ?>
          <br>
          <time>updated: <?= date('Y-m-d H:i:s', strtotime($row->updated_at)) ?></time>
        <?php endif; ?>
      </div>
      <div>
        <?php foreach (explode(' ', $row->tags) as $tag): ?>
          <a href="?tag=<?= rawurlencode($tag) ?>"><?= h($tag) ?></a>
        <?php endforeach ?>
      </div>

      <hr>
      <form action="" method="GET">
        <input type="hidden" name="id" value="<?= $row->article_id ?>">
        <button type="submit">編集</button>
      </form>
      <form action="" method="POST" style="margin-left: 1em;">
        <input type="hidden" name="delete_id" value="<?= $row->article_id ?>">
        <button type="submit" onclick="return confirm('削除しますか?')">削除</button>
      </form>
    </section>
  <?php endforeach; ?>
<?php else: ?>
<section>
  <h2>メモ一覧</h2>
  <p>メモはまだありません</p>
</section>
<?php endif; ?>

<hr>

localhost_1.png

cp data/db_init.sqlite data/db.sqlite; cd public; php -S localhost:8080 で一通り動くことを確認する。
基本的なCRUDの機能は備えているつもり。
ユーティリティ的な処理(h, maybe_int, getPdo 等)だけ関数に分けた。

if文で記事作成か、削除か、タグ検索か、などを判別している。
ユーティリティ的な関数は省略。
ちょっとした処理しかないけれど、既にメンテが辛くなってきた。

ここではデータが無い場合のエラーメッセージは書いていない。単に登録されないだけにしている。
削除しようとしたIDのデータが無い、DBに繋がらない、等のエラー処理もまだない。

.
├── data
│   ├── db.sqlite
│   └── db_init.sqlite
├── db
│   └── tables.sql
└── public
    └── index.php

ファイル構成はとてもシンプルである。
index.phpの行数は226行だった。

PHPとHTMLを分ける

Composerを利用する

まずはPHPとHTMLを分けよう。

require_onceと書くよりautoloadの方が楽なので、ここでcomposerを使う。

composer.json
{
    "autoload": {
        "psr-4": { "Bbs\\": "src" },
        "files": [
            "src/functions.php"
        ]
    }
}

index.phpの上部はこうなった。

<?php

ini_set('display_errors', true);
error_reporting(-1);

require_once dirname(__DIR__) . '/vendor/autoload.php';

$ret = Bbs\AppMain::run();
$message = $ret['message'];
$article = $ret['article'];
$rows = $ret['rows'];

?>
<!DOCTYPE html>
・・・

hのような関数はfunctions.php、メインロジックはAppMainに移動した。

src/AppMain
<?php

namespace Bbs;

use PDO;

class AppMain
{
    public static function run()
    {
        $message = '';
        $message .= self::insertOrUpdate();
        $message .= self::delete();

        $article = self::getArticle();
        $rows = self::getList();

        return [
            'message' => $message,
            'article' => $article,
            'rows' => $rows,
        ];
    }

    private static function insertOrUpdate(): ?string
    {
        $message = null;

        $title = maybe_string($_POST['title']);
        $body = maybe_string($_POST['body']);
        if ($title === null ||
            $body === null)
            return null;


        $pdo = getPdo();
        if ($articleId = maybe_int($_POST['article_id'])){
            $sql = 'update article set title = ?, body = ?, updated_at = current_timestamp where article_id = ?';
            $stmt = $pdo->prepare($sql);
            $stmt->execute([$title, $body, $articleId]);

            $pdo->exec(sprintf('delete from tag where article_id = %d', $articleId));

            $message = '更新しました。';

        }else{
            $sql = 'insert into article(title, body) values(?,?)';
            $stmt = $pdo->prepare($sql);
            $stmt->execute([$title, $body]);
            $articleId = $pdo->lastInsertId();

            $message = '登録しました。';
        }

        $tags = explode(' ', $_POST['tags'] ?? []);
        if ($tags){
            $tagSql = 'insert into tag values(?, ?)';
            $stmt = $pdo->prepare($tagSql);
            foreach ($tags as $tag){
                $stmt->execute([$articleId, $tag]);
            }
        }

        return $message;
    }

    private static function delete(): ?string
    {
        $id = maybe_int($_POST['delete_id']);
        if ($id === null)
            return null;

        $pdo = getPdo();
        $pdo->exec(sprintf('delete from tag where article_id = %d', $id));
        $pdo->exec(sprintf('delete from article where article_id = %d', $id));
        $message = '削除しました。';

        return $message;
    }

    private static function getArticle()
    {
        $id = maybe_int($_GET['id']);
        $article = (object)[
            'article_id' => null,
            'title' => null,
            'body' => null,
            'created_at' => null,
            'updated_at' => null,
            'tags' => null,
        ];
        if ($id === null)
            return $article;

        $pdo = getPdo();
        $sql = "
select article.*, group_concat(tag, ' ') as tags
from article
left outer join tag using(article_id)
where article_id = ?
    ";
        $stmt = $pdo->prepare($sql);
        $stmt->execute([$id]);
        $rows = $stmt->fetchAll();

        if ($rows){
            $article = $rows[0];
        }
        return $article;
    }

    private static function getList()
    {
        $pdo = getPdo();
        $tagWhere = '';
        $params = [];
        if ($search = maybe_string($_GET['tag'])){
            $tagWhere = '
where exists (
  select 1
  from tag
  where article_id = article.article_id
    and tag = ?
)';
            $params = [$search];
        }
        $stmt = $pdo->prepare(sprintf("
select article.*, tags
from article
left outer join (
  select article_id, group_concat(tag, ' ') as tags
  from tag
  group by article_id
) tag using(article_id)
%s
order by created_at desc
    ", $tagWhere));
        $stmt->execute($params);
        return $stmt->fetchAll();
    }
}

多少整理された。
SQLがややこしいのは今の段階ではどうしようもないのでスルーで。ORMを使わない場合、const や別ファイルにすると分かりやすいかどうかは一長一短。

├── public
│   └── index.php
├── src
│   ├── AppMain.php
│   └── functions.php

(DBデータやvendorは除いて)3ファイルに分かれた。

フロントコントローラー

昔はフロントコントローラーやアクションコントローラーという言葉が使われていた。もう死語かな?
今はURLとクラスを対応付けるルーターと言ったりディスパッチャーと言ったりする?まあよくわからんが各ページ単位のコントローラーを呼び出す処理に分けてそれぞれのURLを分ける。

みんな大好きnikicのFastRouteを使う。

composer require nikic/fast-route

AppMain は次のようにして、各画面の処理は各クラスに分散させる。

src/AppMain.php
<?php

namespace Bbs;

use FastRoute;

class AppMain
{
    public static function run()
    {
        $dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
            $r->get('/[{id:\d+}]', [Page\MemoPage::class, 'index']);
            $r->post('/', [Page\MemoPage::class, 'create']);
            $r->post('/{id:\d+}', [Page\MemoPage::class, 'update']);
            $r->post('/{id:\d+}/delete', [Page\MemoPage::class, 'delete']);
        });

// (snip)
        $routeInfo = $dispatcher->dispatch($httpMethod, $uri);
        switch ($routeInfo[0]) {
// (snip)
        case FastRoute\Dispatcher::FOUND:
            $handler = $routeInfo[1];
            $vars = $routeInfo[2];
            if (!is_callable($handler)){
                http_response_code(404);
                echo "404 Not Found";
                break;
            }

            $ret = $handler($vars);
            if ($ret instanceof Response\Response){
                $ret->respond();
            }
            break;
        }
    }
}

Htmlも移動して、public.php は最小限のものにする。

public/index.php
<?php

ini_set('display_errors', true);
error_reporting(-1);

require_once dirname(__DIR__) . '/vendor/autoload.php';

Bbs\AppMain::run();
src/MemoHtml.php
<?php

namespace Bbs\Page;

use Bbs\Response\HtmlResponse;

class MemoHtml extends HtmlResponse
{
    private $vars;
    public function __construct($vars)
    {
        $this->vars = $vars;
    }

    public function showHtml(): void
    {

$message = $this->vars['message'];
$article = $this->vars['article'];
$rows = $this->vars['rows'];

?>

<!DOCTYPE html>
<meta charset="UTF-8">

(snip)


<?php
    }
}

namespaceを使うために「HTMLの中にPHP」から「PHPの中にHTML」に変更した。

各画面の処理はsrc/Page/以下に置くことにする。

src/Page/MemoPage.php
<?php

namespace Bbs\Page;

use PDO;
use Bbs\Response\FoundResponse;
use Bbs\Session\Session;

class MemoPage
{
    public static function index($query)
    {
        $pdo = getPdo();
        $tagWhere = '';
        $params = [];
        if ($search = maybe_string($_GET['tag'])){
            $tagWhere = '
where exists (
  select 1
  from tag
  where article_id = article.article_id
    and tag = ?
)';
            $params = [$search];
        }
        $stmt = $pdo->prepare(sprintf("
select article.*, tags
from article
left outer join (
  select article_id, group_concat(tag, ' ') as tags
  from tag
  group by article_id
) tag using(article_id)
%s
order by created_at desc
    ", $tagWhere));
        $stmt->execute($params);

        return new MemoHtml([
            'rows' => $stmt->fetchAll(),
            'message' => Session::pop('message'),
            'article' => self::getArticle($query),
        ]);
    }

    // ・・・(snip)・・・

    public static function create()
    {
        $message = null;

        $title = maybe_string($_POST['title']);
        $body = maybe_string($_POST['body']);
        if ($title === null ||
            $body === null)
            return new FoundResponse('/');

        $pdo = getPdo();
        $sql = 'insert into article(title, body) values(?,?)';
        $stmt = $pdo->prepare($sql);
        $stmt->execute([$title, $body]);
        $articleId = $pdo->lastInsertId();

        self::updateTags($pdo, $articleId);

        return new FoundResponse('/', '登録しました。');
    }

    // ・・・(snip)・・・

ここで、URLを分けたことによって、「トップ画面」=>「データ登録処理」=>(リダイレクト)=>「トップ画面」という遷移に変わった。
「登録しました」というデータはセッションに保存している。
普通はフレームワークがブラックボックス的にやるような処理を全て書いているので、ちょっと複雑になったかもしれない。

├── public
│   └── index.php
├── src
│   ├── AppMain.php
│   ├── Page
│   │   ├── MemoHtml.php
│   │   └── MemoPage.php
│   ├── Response
│   │   ├── FoundResponse.php
│   │   ├── HtmlResponse.php
│   │   └── Response.php
│   ├── Session
│   │   └── Session.php
│   └── functions.php

Session.php は、とりあえず生の$_SESSIONに触らないためと、「取得したら削除」という機能だけある。

src/Session/Session.php
<?php

namespace Bbs\Session;

class Session
{
    public static function start()
    {
        if (session_status() === PHP_SESSION_NONE)
            session_start();
    }

    public static function get($key)
    {
        self::start();

        return $_SESSION[$key] ?? null;
    }

    public static function pop($key)
    {
        self::start();
        $ret = self::get($key);
        if ($ret !== null)
            self::remove($key);
        return $ret;
    }

    public static function add($key, $value)
    {
        self::start();
        $_SESSION[$key] = $value;
    }

    public static function remove($key)
    {
        self::start();
        unset($_SESSION[$key]);
    }
}

レイヤーを分ける

型を分かりやすくする

元々のindex.phpひとつのファイルだったときから、分かりやすくなった?逆に複雑になった?ちょっと微妙だ。
変更はしやすくなったはず。分かりやすくするためにPHPStanを入れる。

composer require phpstan/phpstan
phpstan.neon
parameters:
  level: max

./vendor/bin/phpstan analyze src/ で大量のエラーが出るので一つずつ修正する。

データの受け渡しに配列を使っていると型チェックがしづらいので、値オブジェクト簡易版を作る。

src/Page/ArticleDto.php
<?php

namespace Bbs\Page;

class ArticleDto
{
    /** @var int */
    public $article_id;
    /** @var string */
    public $title;
    /** @var string */
    public $body;
    /** @var string */
    public $created_at;
    /** @var ?string */
    public $updated_at;
    /** @var ?string */
    public $tags;
}

PDOの機能で $stmt->setFetchMode(PDO::FETCH_CLASS, ArticleDto::class, []) を実行すれば配列でもstdClassでもなくArticleDtoのオブジェクトとして結果を受け取れる。
その他、配列を受け渡していたところも適当なクラスに変更する。

├── src
│   ├── AppMain.php
│   ├── Page
│   │   ├── ArticleDto.php
│   │   ├── FormArticleDto.php
│   │   ├── MemoDto.php
│   │   ├── MemoHtml.php
│   │   └── MemoPage.php
│   ├── Response
│   │   ├── FoundResponse.php
│   │   ├── HtmlResponse.php
│   │   └── Response.php
│   ├── Session
│   │   └── Session.php
│   └── functions.php

フォーム処理とDB処理を分ける

コントローラー(Bbs\Page\MemoPage)から直接DBを操作するのは良くないと聞く。
DB処理を行うプログラムのレイヤーを分けることにする。
DB共通処理と個別の処理を分けつつ、コントローラーから分離する。

それからコントローラーで$_GET['tag']のようにアクセスしているのが気になるので分ける。
コントローラーで行う処理は各処理を呼び出して繋げるだけにする。

src/Page/MemoPage.php
class MemoPage
{
    /** @param array<string,mixed> $query */
    public static function index($query): Response
    {
        $tagWhere = '';
        $params = [];
        $articleDao = new Db\ArticleDao(getDb());

        $id = maybe_int($query['id']);
        if ($id === null){
            $form = new FormArticleDto(null);
        }else{
            $article = $articleDao->get($id);
            $form = new FormArticleDto($article);
        }

        return new MemoHtml(new MemoDto(
            self::getArticleList($articleDao)
            , Session::pop('message')
            , $form
        ));
    }

    public static function create(): Response
    {
        $articleDao = new Db\ArticleDao(getDb());
        $form = new FormArticleDto(null);
        if ($form->title === null ||
            $form->body === null){
            return new MemoHtml(new MemoDto(
                self::getArticleList($articleDao)
                , '未入力項目があります。'
                , $form
            ));
        }

        $articleDao->create($form->title, $form->body, $form->tags);

        return new FoundResponse('/', '登録しました。');
    }

// (snip)

DB処理をBbs\Db\ArticleDao に、フォームの値クラスをBbs\Page\FormArticleDtoに分けた。

src/Db/ArticleDao.php
class ArticleDao
{
// (snip)
    /** @return array<int,ArticleDto> */
    public function getAll(?string $tag)
    {
        $params = [];
        if ($tag === null){
            $tagWhere = '';
        }else{
            $tagWhere = '
where exists (
  select 1
  from tag
  where article_id = article.article_id
    and tag = ?
)';
            $params = [$tag];
        }

        $sql = sprintf("
select article.*, tags
from article
left outer join (
  select article_id, group_concat(tag, ' ') as tags
  from tag
  group by article_id
) tag using(article_id)
%s
order by created_at desc
    ", $tagWhere);
        return $this->db->getAll($sql, $params, ArticleDto::class);
    }

    public function create(string $title, string $body, ?string $tags): void
    {
        $sql = 'insert into article(title, body) values(?,?)';
        $this->db->execOne($sql, [$title, $body]);
        $articleId = $this->db->lastInsertId();
        if ($articleId === null)
            throw new \Exception('insert id is null');

        $article = $this->get($articleId);
        if (!$article)
            throw new \Exception('insert failed');

        $article->tags = $tags;
        $this->updateTags($article);
    }

// (snip)

ついでに未入力のエラーメッセージを出すようにして、フォームの値も保持できるようにした。

src/
├── AppMain.php
├── Db
│   ├── ArticleDao.php
│   ├── ArticleDto.php
│   └── Db.php
├── Page
│   ├── FormArticleDto.php
│   ├── MemoDto.php
│   ├── MemoHtml.php
│   └── MemoPage.php
├── Response
│   ├── FoundResponse.php
│   ├── HtmlResponse.php
│   └── Response.php
├── Session
│   └── Session.php
└── functions.php

src/Db が増えた。
データ詰め込み用のクラスを src/Dto に分けるか、それを利用するnamespaceに置いたままにするか、悩みどころ。
これは前のコードで配列を使っていた箇所をクラスに変えただけなので、元々のクラスの役割というか所属が明確でなかったのが原因かもしれない。
クラスの責任があいまいなまま分割してしまった。それでも分割しないよりは分けた方が良いとも言えるし、コードが複数のファイルにまたがって読みづらいとも言える。

依存関係を明確にする

コードを書きながら何となく分割するというやり方でここまで来たので、この辺で一度階層を整理して明確にする。

依存関係をチェックするPHPStan Extension を入れる

依存関係がチェックできるツールがあれば何でも良いけれど、ここではとりあえず自作したツールを入れる。

composer require --dev nish/phpstan-namespace-dependency

とりあえず最初の方針はページコントローラーが各クラスを組み立てて、利用される側のクラスは他のレイヤーに依存しないようにする。

・Page => モデル
・Page => Html
・Page => Form
・Html => Form
・モデル => Dao
・モデル => Form

DBに依存するコードから、モデルを分ける。(ロジックはほとんど無いけれど)
M(モデル)V(Html)C(Page)という関係。

設定ファイルはこんな感じ。

phpstan.neon
includes:
  - vendor/nish/phpstan-namespace-dependency/rules.neon

services:
  -
    factory: Nish\PHPStan\NsDepends\DependencyChecker([
      'PDO': ['Bbs\Db'],
      'Bbs\Db': ['Bbs\Dao'],
      'Bbs\Dao': ['Bbs\Model'],
      'Bbs\Form': ['Bbs\Model', 'Bbs\Html', 'Bbs\Page'],
      'Bbs\Model': ['Bbs\Page'],
      'Bbs\Html': ['Bbs\Page'],
      'Bbs\Page': ['Bbs\AppMain'],
      'Bbs\Session': ['Bbs\Response', 'Bbs\Page'],
    ], [
      'Bbs\Db': ['Exception'],
    ])

いくつもエラーが出るので、修正しつつディレクトリとクラスを分ける。
入力チェックモデルに分離する。

依存関係を厳密にしたので、データを受け渡すクラスがどのレイヤーに属するのかも考える必要がある。そして必要に応じて詰め替える必要が出てきた。

src/Model/ArticleModel.php
<?php

namespace Bbs\Model;

use Bbs\Form\FormArticleDto;
use Bbs\Dao\ArticleDao;
use Bbs\Dao\Article;

class ArticleModel
{
// (snip)
    /** @return array<int,ArticleDto> */
    public static function getArticleList()
    {
        $articleDao = new ArticleDao(getDb());
        $rows = $articleDao->getAll(maybe_string($_GET['tag']));
        return castList($rows, ArticleDto::class);
    }

// (snip)
// ・・・

castListBbs\Dao\ArticleBbs\Model\ArticleDtoに変換している。PageModelに依存するけれど、Daoには依存しないとさっき決めたので。
インターフェースを使っていないので、各所に詰め替えが発生するようになってしまった。(インターフェースを使っていたとしても、レイヤーを行き来すると詰め替える必要はありそうだが。)

src/Model/ArticleModel.php
// (snip)
    public static function create(FormArticleDto $form): void
    {
        if ($form->title === null ||
            $form->body === null)
            throw new ValidateException('未入力項目があります。');

        $articleDao = new ArticleDao(getDb());
        $articleDao->create($form->title, $form->body, $form->tags);
    }
// (snip)

入力チェックはModelで行い、エラーなら例外にする。
ViewModelのようなレイヤーを分けても良かったかもしれない。

Pageクラスは次のようになる。

src/Page/MemoPage.php
<?php

namespace Bbs\Page;

use PDO;
use Bbs\Response\FoundResponse;
use Bbs\Response\Response;
use Bbs\Session\Session;
use Bbs\Model\ArticleModel;
use Bbs\Model\ValidateException;
use Bbs\Html\MemoHtml;
use Bbs\Html\MemoDto;
use Bbs\Html\ArticleDto;

class MemoPage
{
    /** @param array<string,mixed> $query */
    public static function index($query): Response
    {
        $form = ArticleModel::getForm(maybe_int($query['id']));

        return new MemoHtml(new MemoDto(
            castList(ArticleModel::getArticleList(), ArticleDto::class)
            , Session::pop('message')
            , $form
        ));
    }

    public static function create(): Response
    {
        $form = ArticleModel::getForm(null);
        try {
            ArticleModel::create($form);

        }catch (ValidateException $e){
            return new MemoHtml(new MemoDto(
                castList(ArticleModel::getArticleList(), ArticleDto::class)
                , $e->getMessage()
                , $form
            ));
        }

        return new FoundResponse('/', '登録しました。');
    }

// (snip)
// ・・・

かなりシンプルになった。
ここでもBbs\Model\ArticleDtoからBbs\Html\ArticleDtoへの詰替えが発生している。HtmlからModelへの依存は無いとさっき決めたので。
それ以外の処理は、モデルを呼び出して例外を処理したりリダイレクトしたりというコントローラーらしい処理になった。

src/
├── AppMain.php
├── Dao
│   ├── Article.php
│   └── ArticleDao.php
├── Db
│   └── Db.php
├── Form
│   └── FormArticleDto.php
├── Html
│   ├── ArticleDto.php
│   ├── MemoDto.php
│   └── MemoHtml.php
├── Model
│   ├── ArticleDto.php
│   ├── ArticleModel.php
│   └── ValidateException.php
├── Page
│   └── MemoPage.php
├── Response
│   ├── FoundResponse.php
│   ├── HtmlResponse.php
│   └── Response.php
├── Session
│   └── Session.php
└── functions.php

ファイルは結構増えてきた。

ArticleとTagの処理を分ける

タグの処理をBbs\Dao\ArticleDaoでまとめてやっていたり、DB側でgroup_concat(tag, ' ') as tagsをしていたりするので、これを分離する。

各レイヤーにTag用クラスを作ると、array<int,Tag>を各レイヤーに合わせて詰め替える必要が出てきた。
詰め替えのコードが多くなってきた。

データ保持用クラスをどこに置くか、どのレイヤーからでも扱えるようにするかは悩みどころだ。

src/
├── AppMain.php
├── Dao
│   ├── Article.php
│   ├── ArticleDao.php
│   ├── Tag.php
│   └── TagDao.php
├── Db
│   └── Db.php
├── Form
│   └── FormArticleDto.php
├── Html
│   ├── ArticleDto.php
│   ├── MemoDto.php
│   ├── MemoHtml.php
│   └── TagDto.php
├── Model
│   ├── ArticleDto.php
│   ├── ArticleModel.php
│   ├── TagDto.php
│   ├── TagModel.php
│   └── ValidateException.php
├── Page
│   └── MemoPage.php
├── Response
│   ├── FoundResponse.php
│   ├── HtmlResponse.php
│   └── Response.php
├── Session
│   └── Session.php
├── Type
│   └── CastProperty.php
└── functions.php

src以下のPHPの行数は957行になった。

depends.png

モデルがフォームに依存しているのがちょっとイマイチ。でもPHPの主な処理といえばDBとフォームなので仕方ないという感じでもある。

DDDっぽい構造にする

依存関係を逆転させる

モデルからDBとフォームへの依存をなくし、インターフェースに依存するように依存関係を逆転させる。

src/Page/MemoPage.php
class MemoPage
{
    private static function getForm(?int $id): FormArticleDto
    {
        $form = new FormArticleDto();
        if ($id !== null){
            $model = new Model\ArticleModel(new Dao\ArticleDao(getDb()));
            $article = $model->getArticle(new GetArticleRequestImpl($id));
            if ($article){
                $form->article_id = $article->article_id;
                $form->title = $article->title;
                $form->body = $article->body;
                $form->tags = Model\TagModel::toString($article->tags);
            }
        }

        if ($_POST){
            $form->title = maybe_string($_POST['title']);
            $form->body = maybe_string($_POST['body']);
            $form->tags = maybe_string($_POST['tags']);
        }
        return $form;
    }

    private static function responseIndex(
        Model\ArticleModel $model
        , FormArticleDto $form
        , ?string $message
    ): Response
    {
        $model = new Model\ArticleModel(new Dao\ArticleDao(getDb()));
        return new Html\MemoHtml(new Html\MemoDto(
            castList($model->getArticleList(new GetArticleListRequestImpl())
                     , Html\ArticleDto::class)
            , $message
            , $form
        ));
    }

    /** @param array<string,mixed> $query */
    public static function index($query): Response
    {
        $model = new Model\ArticleModel(new Dao\ArticleDao(getDb()));

        $form = self::getForm(maybe_int($query['id']));

        $articles = $model->getArticleList(new GetArticleListRequestImpl());
        return self::responseIndex($model, $form, Session::pop('message'));
    }

    public static function create(): Response
    {
        $form = self::getForm(null);
        $articleModel = new Model\ArticleModel(new Dao\ArticleDao(getDb()));

        if ($form->title === null ||
            $form->body === null)
            return self::responseIndex($articleModel, $form, '未入力項目があります。');

        $tagModel = new Model\TagModel(new Dao\TagDao(getDb()));
        try {
            $article = $articleModel->create(new CreateArticleRequestImpl($form->title, $form->body));
            $tagModel->update($article->article_id, new UpdateTagsRequestImpl($form->tags));

        }catch (Model\ValidateException $e){
            return self::responseIndex($articleModel, $form, $e->getMessage());
        }

        return new FoundResponse('/', '登録しました。');
    }


// (snip)

モデルに移した処理が再びコントローラーに戻ってきた・・・?

src/Model/ArticleModel.php
<?php

namespace Bbs\Model;

class ArticleModel
{
    /** @var ArticleRepository */
    private $repository;
    public function __construct(ArticleRepository $repository)
    {
        $this->repository = $repository;
    }
    /** @return array<int,ArticleDto> */
    public function getArticleList(GetArticleListRequest $request)
    {
        return $this->repository->findArticleList($request->getSearchTag());
    }
    public function getArticle(GetArticleRequest $request): ?ArticleDto
    {
        return $this->repository->findById($request->getId());
    }

    public function create(CreateArticleRequest $request): ArticleDto
    {
        return $this->repository->create($request->getTitle(), $request->getBody());
    }

    public function update(int $id, UpdateArticleRequest $request): void
    {
        $article = $this->repository->findById($request->getId());
        if (!$article)
            throw new ValidateException('データがありません。');

        $article->title = $request->getTitle();
        $article->body = $request->getBody();
        $this->repository->update($article);
    }

    public function delete(GetArticleRequest $request): void
    {
        $article = $this->getArticle($request);
        if (!$article)
            throw new ValidateException('データがありません。');
        $this->repository->delete($article);
    }
}

モデルはシンプルになったけど、ほとんど詰め替えて移譲しているだけのコードになった。

depends.png

これはDDDで言うところのDomainServiceかUseCaseの役割をコントローラーが全部やってるということになるのかな。

src/
├── AppMain.php
├── Dao
│   ├── Article.php
│   ├── ArticleDao.php
│   ├── Tag.php
│   └── TagDao.php
├── Db
│   └── Db.php
├── Form
│   └── FormArticleDto.php
├── Html
│   ├── ArticleDto.php
│   ├── MemoDto.php
│   ├── MemoHtml.php
│   └── TagDto.php
├── Model
│   ├── ArticleDto.php
│   ├── ArticleModel.php
│   ├── ArticleRepository.php
│   ├── CreateArticleRequest.php
│   ├── GetArticleListRequest.php
│   ├── GetArticleRequest.php
│   ├── TagDto.php
│   ├── TagModel.php
│   ├── TagRepository.php
│   ├── UpdateArticleRequest.php
│   ├── UpdateTagsRequest.php
│   └── ValidateException.php
├── Page
│   ├── CreateArticleRequestImpl.php
│   ├── GetArticleListRequestImpl.php
│   ├── GetArticleRequestImpl.php
│   ├── MemoPage.php
│   ├── UpdateArticleRequestImpl.php
│   └── UpdateTagsRequestImpl.php
├── Response
│   ├── FoundResponse.php
│   ├── HtmlResponse.php
│   └── Response.php
├── Session
│   └── Session.php
├── Type
│   └── CastProperty.php
└── functions.php

引数をスカラー値からクラス変えたところが多いので、それに伴ってクラスが大幅に増えた。

更にDDDっぽく整理する

サービス的なクラスを導入して、コントローラーが直接モデルを触らないようにする。
namespace も整理する。

src/Io/Page/MemoPage.php
<?php

namespace Bbs\Io\Page;

use Bbs\Io\Infrastructure\Response\FoundResponse;
use Bbs\Io\Infrastructure\Response\Response;
use Bbs\Io\Infrastructure\Session\Session;
use Bbs\Io\Infrastructure\Db;
use Bbs\Io\Presentation\Html;
use Bbs\Io\Presentation\Form;
use Bbs\Application;
use Exception;

class MemoPage
{
    private static function getService(): Application\ArticleService
    {
        return new Application\ArticleService(new Db\ArticleDao(getDb()), new Db\TagDao(getDb()));
    }
    private static function getForm(?int $id): Form\FormArticleDto
    {
        $request = null;
        if ($id !== null){
            $service = self::getService();
            $article = $service->getArticle($id);
            if ($article)
                $request = new ArticleFormRequestImpl($article);
        }
        return Form\ArticleFormService::getForm($request);
    }

    private static function responseIndex(
        Form\FormArticleDto $form
        , ?string $message
    ): Response
    {
        $listService = new Application\ArticleListService(new Db\ArticleDao(getDb()));

        $articles = $listService->getArticleList(maybe_string($_GET['tag']));
        return new Html\MemoHtml(new Html\MemoDto(
            castList($articles, Html\ArticleDto::class)
            , $message
            , $form
        ));
    }

    /** @param array<string,mixed> $query */
    public static function index($query): Response
    {
        $form = self::getForm(maybe_int($query['id']));
        return self::responseIndex($form, Session::pop('message'));
    }

    public static function create(): Response
    {
        $form = self::getForm(null);

        if ($form->title === null ||
            $form->body === null)
            return self::responseIndex($form, '未入力項目があります。');

        $service = self::getService();
        try {
            $service->create($form->title, $form->body, $form->tags);

        }catch (Exception $e){
            return self::responseIndex($form, $e->getMessage());
        }

        return new FoundResponse('/', '登録しました。');
    }

// (snip)
// ・・・

サービスクラスでドメイン(旧モデル)を操作する。

src/Application/ArticleService.php
<?php

namespace Bbs\Application;

use Bbs\Domain\Article\ArticleRepository;
use Bbs\Domain\Article\ArticleModel;
use Bbs\Domain\Tag\TagRepository;
use Bbs\Domain\Tag\TagModel;

class ArticleService
{
    /** @var ArticleRepository */
    private $articleRepository;
    /** @var TagRepository */
    private $tagRepository;
    public function __construct(
        ArticleRepository $articleRepository
        , TagRepository $tagRepository
    ){
        $this->articleRepository = $articleRepository;
        $this->tagRepository = $tagRepository;
    }
    public function getArticle(int $id): ?ArticleDto
    {
        $model = new ArticleModel($this->articleRepository);
        $article = $model->getArticle(new GetArticleRequestImpl($id));
        if ($article === null)
            return null;
        return ArticleDto::fromModel($article);
    }

    public function create(string $title, string $body, ?string $tags): void
    {
        $articleModel = new ArticleModel($this->articleRepository);
        $article = $articleModel->create(new CreateArticleRequestImpl(
            $title, $body));
        $tagModel = new TagModel($this->tagRepository);
        $tagModel->update($article->article_id, new UpdateTagsRequestImpl($tags));
    }

// (snip)
// ・・・

depends.png

src/
├── AppMain.php
├── Application
│   ├── ArticleDto.php
│   ├── ArticleListService.php
│   ├── ArticleService.php
│   ├── CreateArticleRequestImpl.php
│   ├── GetArticleListRequestImpl.php
│   ├── GetArticleRequestImpl.php
│   ├── UpdateArticleRequestImpl.php
│   └── UpdateTagsRequestImpl.php
├── Domain
│   ├── Article
│   │   ├── ArticleDto.php
│   │   ├── ArticleModel.php
│   │   ├── ArticleRepository.php
│   │   ├── CreateArticleRequest.php
│   │   ├── GetArticleListRequest.php
│   │   ├── GetArticleRequest.php
│   │   └── UpdateArticleRequest.php
│   ├── Tag
│   │   ├── TagDto.php
│   │   ├── TagModel.php
│   │   ├── TagRepository.php
│   │   └── UpdateTagsRequest.php
│   └── ValidateException.php
├── Io
│   ├── Infrastructure
│   │   ├── Db
│   │   │   ├── Article.php
│   │   │   ├── ArticleDao.php
│   │   │   ├── Db.php
│   │   │   ├── Tag.php
│   │   │   └── TagDao.php
│   │   ├── Response
│   │   │   ├── FoundResponse.php
│   │   │   ├── HtmlResponse.php
│   │   │   └── Response.php
│   │   └── Session
│   │       └── Session.php
│   ├── Page
│   │   ├── ArticleFormRequestImpl.php
│   │   └── MemoPage.php
│   └── Presentation
│       ├── Form
│       │   ├── ArticleFormRequest.php
│       │   ├── ArticleFormService.php
│       │   └── FormArticleDto.php
│       └── Html
│           ├── ArticleDto.php
│           ├── MemoDto.php
│           ├── MemoHtml.php
│           └── TagDto.php
├── Type
│   └── CastProperty.php
└── functions.php

うーん、Javaっぽい。
JavaっぽいというかFizzBuzz Enterprise Editionの方向性のような。

ここから更に値オブジェクトっぽいクラスを作るとか、もう少し細かくクラスを分けるとか、細かく分けられることはまだまだありそうだけれど、とりあえずここまで。
https://github.com/nishimura/php-refactoring-sample

おわりに

クラスを分けて依存関係を整理するという方向でやっていったけれど、どうだろう。
CRUDだから単純にややこしくなっただけで、もっとドメインモデルのロジックがある場合は分かりやすくなるのだろうか?
タグを空白で分割したり結合したりエラーチェックしたりといったコードがまだ散らかっているので、整理が足りていないというのはある。
でも結局何らかのツールで書く場所を強制しないと、いつの間にか散らかったりするんだよね。
IDEではないのでクラスの移動がつらい。イマドキのIDEならJavaのようにクラスを移動すると同時に関連する箇所を全て書き換えてくれたりするんだろうか?

あとテストがない。というかテストするようなロジックがない。もしテストするとしても、タグ検索がちゃんと動いているかといったDB込みの結合テストになりそう。

次回やるとすれば(?)もう少しロジックがあるようなものでもう一度試したい。数日で書けるサンプルプログラム程度ならそんなにややこしいロジックを含んだプログラムは書けないかもしれないが。

ちなみに、最初226行だったものがほとんど同じ機能とUIで最終的に1308行になった。

1
1
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
1
1