はじめに
これから新しくPHPで何かを作る場合、どういう構造が良いだろうか。
確認と使い勝手を見るために小さなサンプルアプリを作りリファクタリングで少しずつ変更していくことで違いを体感する。
まずベタ書きで作る
DB定義
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)
);
最初は全て一つのファイルに書くこととする。
<?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>
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
を使う。
{
"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
に移動した。
<?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
は次のようにして、各画面の処理は各クラスに分散させる。
<?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 は最小限のものにする。
<?php
ini_set('display_errors', true);
error_reporting(-1);
require_once dirname(__DIR__) . '/vendor/autoload.php';
Bbs\AppMain::run();
<?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/
以下に置くことにする。
<?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
に触らないためと、「取得したら削除」という機能だけある。
<?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
parameters:
level: max
./vendor/bin/phpstan analyze src/
で大量のエラーが出るので一つずつ修正する。
データの受け渡しに配列を使っていると型チェックがしづらいので、値オブジェクト簡易版を作る。
<?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']
のようにアクセスしているのが気になるので分ける。
コントローラーで行う処理は各処理を呼び出して繋げるだけにする。
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
に分けた。
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)という関係。
設定ファイルはこんな感じ。
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'],
])
いくつもエラーが出るので、修正しつつディレクトリとクラスを分ける。
入力チェックモデルに分離する。
依存関係を厳密にしたので、データを受け渡すクラスがどのレイヤーに属するのかも考える必要がある。そして必要に応じて詰め替える必要が出てきた。
<?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)
// ・・・
castList
でBbs\Dao\Article
をBbs\Model\ArticleDto
に変換している。Page
はModel
に依存するけれど、Dao
には依存しないとさっき決めたので。
インターフェースを使っていないので、各所に詰め替えが発生するようになってしまった。(インターフェースを使っていたとしても、レイヤーを行き来すると詰め替える必要はありそうだが。)
// (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
クラスは次のようになる。
<?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行になった。
モデルがフォームに依存しているのがちょっとイマイチ。でもPHPの主な処理といえばDBとフォームなので仕方ないという感じでもある。
DDDっぽい構造にする
依存関係を逆転させる
モデルからDBとフォームへの依存をなくし、インターフェースに依存するように依存関係を逆転させる。
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)
モデルに移した処理が再びコントローラーに戻ってきた・・・?
<?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);
}
}
モデルはシンプルになったけど、ほとんど詰め替えて移譲しているだけのコードになった。
これは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
も整理する。
<?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)
// ・・・
サービスクラスでドメイン(旧モデル)を操作する。
<?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)
// ・・・
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行になった。