##追記
1.一意な識別子を生成するnextIdentity()
メソッドの実装を追記しました
2.ReservationServiceクラスを修正しました
(リファクタリング、Customerクラス追加)
3.続編では無いですが、その②を書きました。
PHPでDDD実装事例その②リポジトリ&ファクトリで永続化・生成処理をカプセル化(Laravel)(図あり)
#背景
自分が運営しているハウススタジオの予約受付業務の自動化システムを、今学習しているDDD風に設計して、YYPHPにてコードレビューしてもらったので、
・前提となる業務の内容
・業務ルール
・クラス構成(クラス図)
・設計の考え方
・実装コード
・レビューでの指摘ポイント
あたりを共有しようと思います。
未熟な部分もあると思います。
この記事を読んで改善点などあれば是非是非コメント下さい!
言語はPHP、フレームワークはLaravelです。
が、ドメインモデルのコードしか書いて無いので、あまりLaravelは関係無いです。。
#前提となる業務の内容
DDDの考え方で設計したので、前提となる業務に関する知識が無いとコードを読みづらいと思いますので、簡単にですが対象となる業務について説明します。
(ちょっと前段が長いですがご容赦下さい。コードだけ見れれば良いという方はこちらのGitHubをご参照下さい)
https://github.com/Yorinton/asobiba101/tree/master/packages/Asobiba/Domain/Models/Reservation
この中のファイルが作成したドメインモデルになります。
前述しましたが、今回は自分が運営しているハウススタジオ運営における予約受付の業務の自動化を目的にドメインモデルを作成しました。
こちらが運営しているハウススタジオのホームページの予約申込画面です。
ハウススタジオ予約申込画面:http://asobiba101.com/reserve.php
お客さんがこの画面から予約を申し込んだ段階で管理者(自分)とお客さんに自動でメールが行くようになっています。
こちらに僕がいつもやっているハウススタジオ運営の業務内容と自動化したい範囲を示します。
今回コードレビューで見てもらったのは、「管理者」の業務の2番目にある「予約内容のチェック」の部分を自動化したコードです。それ以外の部分はまだ未実装の部分が多いです。
後ほど業務ルールについても記述しますが、僕が運営しているハウススタジオのルールが多く、例えば「お昼5時間プランの場合は深夜利用オプションはつけられない」や「2時間プランの場合は16時〜17時に被ってはならない」等、複雑です。
#業務ルール
具体的な業務ルールはGitHubのreadmeに記載しています。
業務ルールは多く全部見てると大変なのですが、ざっと流すくらいでいいので見て頂いた方がコードも理解しやすいと思います。
https://github.com/Yorinton/asobiba101/blob/master/readme.md
以下からもコードレビュー会にてレビュー会参加の背景や業務内容・業務ルールについて説明したスライドを見れます。
コードレビュー会で背景説明したスライド
#クラス図
こちらがコードレビューのために作ったクラス図になります。
元々手書きざっくりしかクラス図を作っていなかったのですが、コードレビューに向けてよりレビューしやすいようにと作成しました。
こちらのGitHubのreadmeの下の方にもクラス図を載せています。
フォルダ構成もこちらをご参照下さい。(
https://github.com/Yorinton/asobiba101/blob/tests/readme.md
#設計の考え方
一番左のReservationService
クラスからReservation
クラスをnewするだけで、他の全てのクラスで業務ルールに沿ったチェックが行われ、「Reservation
クラスがnew出来た = 業務ルールに適した予約内容である」ということが保証されるような状態にしようという考えで設計しました。
また、Plan
クラスやOptions
クラス、Number
クラス等予約内容の項目毎にクラスを作成しており、業務ルールに必要なクラスに依存している関係になっています。
(例)
「お昼2hプランの場合深夜利用オプションは利用出来ない」というルールはOptionsクラスのルールですが、これをチェックするためにはどのプランを選択したかの情報が無いとチェック出来ないため、Planクラスに依存しています。
コードレビューではこのクラス構成の考え方については問題無いとのことでした。
今回使う側のReservationService
クラスをクライアントと考えると、クライアントが知っていなければならない知識を出来るだけ減らした方がいい、ということで、ReservationService
クラスからReservation
クラスのみを参照すれば良いという形は正しいようです。
ある意味ここが一番自信が無いというかレビューが欲しい部分でもあったので安心しました。
#コード
ここまでで、前提となる業務知識やルール、クラス構成について紹介しました。
ここから具体的なコードとその意図を紹介していきたいと思います。
全部書いていると大量になるので、コードレビューで指摘をもらったクラスを中心に記載します。
他のクラスのコードも見たい場合は、Githubをご確認下さい。
https://github.com/Yorinton/asobiba101/tree/master/packages/Asobiba
[フォルダ]
・サービスクラス・・Application/Services
・ドメインモデル・・Domain/Models
##サービスクラス(ドメインモデルを使う側)
このクラスではUI層のコントローラー等に対して、ドメインモデルを使った予約受付の機能(予約の保存 + 自動返信メールの送信)を提供します。
コードはこちら。
https://github.com/Yorinton/asobiba101/blob/master/packages/Asobiba/Application/Services/AcceptanceReservationService.php
####コードレビューでの指摘ポイント
・一意な識別子の作成方法をuuidにするかDBのシーケンスを使うか相談したところ、サーバー(DBサーバー?)が複数ある場合はuuid(アプリケーション側で作る)、そうでない場合はDBのシーケンスを使うのがいいという意見を頂きました。ただ、MySQLの場合uuidの処理に時間がかかるのでシーケンスを使った方がパフォーマンス的には良いようです。
//asobiba101/packages/Asobiba/Application/Services/AcceptanceReservationService.php
//まだ実装途中なので処理が中途半端になっちゃってます><
namespace Asobiba\Application\Service;
use Asobiba\Domain\Models\Reservation\ReservationId;
use Asobiba\Domain\Models\User\Customer;
use Illuminate\Http\Request;
use Asobiba\Domain\Models\Reservation\Reservation;
use Asobiba\Infrastructure\Repositories\EloquentReservationRepository;
use Infrastructure\Notification\ReservationMailNotification;
class AcceptanceReservationService
{
//カスタマーからのリクエストを受け取ってDBに保存 + 自動返信メール送信
public static function reserve(Request $req)
{
//ReservationIdの生成
$repository = new EloquentReservationRepository();
$id = $repository->nextIdentity();
//CustomerIdの生成処理をここに書く
//ReservationエンティティとCustomerエンティティの生成(自クラス内に分割)
$reservation = self::createReservation($id,$req);
$customer = self::createCustomer($req);
//永続化処理
$repository->add($customer, $reservation);
//自動メール送信
self::sendAutoReply($customer,$reservation);
}
private static function createReservation(ReservationId $id,Request $req): Reservation
{
return new Reservation(
$id,
$req->options,
$req->plan,
$req->number,
$req->date,
$req->start_time,
$req->end_time,
$req->purpose,
$req->question
);
}
private static function createCustomer(Request $req): Customer
{
return new Customer($req->name, $req->email);
}
//自動返信メールをカスタマー・マネージャー両方に送信
private static function sendAutoReply(Customer $customer,Reservation $reservation)
{
return true;//テスト通す用
$notification = new ReservationMailNotification();
$notification->notifyToCustomer($customer,$reservation);
$notification->notifyToManager($customer,$reservation);
}
}
##[追記]リポジトリ
上記のコードの $id = $repository->nextIdentity();
の部分で一意な識別子を生成していますが、このnextIdentity()
メソッドを実装したのでここに追記します。
一意な識別子の生成は早期生成でDBのシーケンスで(MySQLの場合は採番テーブルを作って)生成します。
(参考)
実践ドメイン駆動設計:https://goo.gl/dx1GGx
LAST_INSERT_IDを使って採番テーブルを扱う :https://goo.gl/hi9qbb
reservations
テーブルとは別にreservation_seqs
テーブルを作成しそこに一意な識別子を保存・取得します。
テーブル構造はこちらのマイグレーションファイルを参照下さい。
https://goo.gl/YrTdia
準備としてnextval
カラムには1行だけ値が0となるレコードを挿入しておきます。
//asobiba101/packages/Asobiba/Infrastructure/Repositories/EloquentReservationRepository.php
namespace Asobiba\Infrastructure\Repositories;
use App\Eloquents\Reservation\EloquentReservation;
use App\Eloquents\Reservation\EloquentOption;
use Asobiba\Domain\Models\Repositories\Reservation\ReservationRepositoryInterface;
use Asobiba\Domain\Models\Reservation\Reservation;
use Asobiba\Domain\Models\Reservation\ReservationId;
use DB;
class EloquentReservationRepository implements ReservationRepositoryInterface
{
public function nextIdentity(): ReservationId
{
//nextvalカラムにnextval(現在の値) + 1を挿入
DB::table('reservation_seqs')->update(["nextval" => DB::raw("LAST_INSERT_ID(nextval + 1)")]);
//LAST_INSERT_IDに記憶された値を取得(first()の時点でstdClassが返ってくる)
$reservationId = DB::table('reservation_seqs')->selectRaw("LAST_INSERT_ID() as id")->first()->id;
return new ReservationId($reservationId);
}
}
##ドメインモデル
コードはこちら。
https://github.com/Yorinton/asobiba101/tree/master/packages/Asobiba/Domain/Models/Reservation
###Reservationクラス
このクラスをnewすることで、プランや、オプション、利用人数等に関する様々なルールを自動でチェックするようにしています。
このReservation
クラスがインスタンス化出来る = ルールに即した予約内容であることを保証しています。
####コードレビューでの指摘ポイント
・引数の型がスカラー型になっているため、引数の順番を間違えたり等エラーになりやすい。Plan
やOptions
等の独自型を引数にした方がいい。
namespace Asobiba\Domain\Models\Reservation;
class Reservation
{
/** @var ReservationId */
private $id;
/** @var Options */
private $options;
/** @var Plan */
private $plan;
/** @var Capacity */
private $capacity;
/** @var Number */
private $number;
/** @var DateOfUse */
private $dateOfUse;
/** @var Question */
private $question;
/** @var Status */
private $status;
/**
* Reservation constructor.
* @param array $options
* @param string $plan
* @param int $number
* @param string $date
* @param int $start_time
* @param int $end_time
* @param string|null $question
*/
public function __construct(
ReservationId $id,
array $options,
string $plan,
int $number,
string $date,
int $start_time,
int $end_time,
string $question = null
)
{
//コンストラクタ内でReservationに紐づく各クラスをnewしています
//各クラス内のコンストラクタ内でルールのチェックを行っています。
$this->plan = new Plan($plan);
$this->options = new Options($options, $this->plan, $end_time);
$this->dateOfUse = new DateOfUse($date, $start_time, $end_time, $this->plan, $this->options);
$this->capacity = new Capacity($this->plan, $this->options);
$this->number = new Number($number, $this->capacity);
$this->question = new Question($question);
//質問がある場合は`Contact`(問い合わせ)、無い場合は`Confirmation`(予約確定)
if ($this->hasQuestion()) {
$this->status = new Status('Contact');
} else {
$this->status = new Status('Confirmation');
}
}
//予約内容に関する情報をReservationクラスを通して取得出来る
/**
* @return ReservationId
*/
public function getId(): ReservationId
{
return $this->id;
}
/**
* get total price of this reservation
*
* @return int
*/
public function getTotalPrice(): int
{
return $this->options->getTotalPrice() + $this->plan->getPrice();
}
/**
* get price of plan of this reservation
*
* @return int
*/
public function getPriceOfPlan(): int
{
return $this->plan->getPrice();
}
/**
* get options and price set of this reservation.
*
* @return array
*/
public function getOptionAndPriceSet(): array
{
return $this->options->getOptionAndPriceSet();
}
/**
* get plan name.
*
* @return string
*/
public function getPlanName(): string
{
return $this->plan->getPlan();
}
/**
* get number of guests.
*
* @return string
*/
public function getNumber(): int
{
return $this->number->getNumber();
}
/**
* get question of this reservation
*
* @return string
*/
public function getQuestion(): string
{
return $this->question->getQuestion();
}
/**
* @return string
*/
public function getStatus(): string
{
return (string)$this->status;//__toStringメソッドが定義されているため
}
/**
* change status
* @param string $status
*/
public function changeStatus(string $status)
{
$method = 'to' . $status;
$this->status = $this->status->$method();
}
/**
* get Date
*
* @return string
*/
public function getDate(): string
{
return $this->dateOfUse->getDate();
}
/**
* get StartTime
*
* @return int
*/
public function getStartTime(): int
{
return $this->dateOfUse->getStartTime();
}
/**
* get EndTime
*
* @return int
*/
public function getEndTime(): int
{
return $this->dateOfUse->getEndTime();
}
/**
* check if this reservation has question
*
* @return bool
*/
public function hasQuestion(): bool
{
return $this->question->isQuestion();
}
}
###Planクラス
Plan
クラスでは、
・プランとして正しい文字列が指定されているかのチェック
・2時間or3時間プランを選択しているか、お昼プランを選択しているか等のチェック
(Options
クラスやDateOfUse
クラス等で使います)
を行っています。
以前書いたこちらの記事を参考に、基底クラスとしてEnumクラスを作成して、それを継承する形でPlanクラスを作っています。
https://qiita.com/Yorinton/items/f6138f2bca7664162ca3
####コードレビューでの指摘ポイント
・プラン名の表示方法の部分はドメインの責務ではなく、Viewに近い側に責務を持たせるべき。ドメイン側ではプランコードのようなものを持たせてViewに近い側のクラスで文字列に変換するような処理にした方がいい。(DBにも文字列を保存するのではなく、プランコードを保存する)
→あとから考えて思ったのだが、「プラン名」はドメインの知識になるのでは?と思って、ドメイン側に持たせるかView側に持たせるか悩み中。次のYYPHPで相談してみようと思います。
namespace Asobiba\Domain\Models\Reservation;
use Asobiba\Domain\Models\Enum;
final class Plan extends Enum
{
/** @var String */
// protected $value;
protected const ENUM = [
'【非商用】基本プラン(平日)' => '【非商用】基本プラン(平日)',
'【非商用】基本プラン(休日)' => '【非商用】基本プラン(休日)',
'【非商用】お昼5時間パック' => '【非商用】お昼5時間パック',
'【非商用】夜5時間パック' => '【非商用】夜5時間パック',
'【商用】基本1日プラン' => '【商用】基本1日プラン',
'【商用】お昼5時間パック' => '【商用】お昼5時間パック',
'【商用】夜5時間パック' => '【商用】夜5時間パック',
'【商用】3時間パック' => '【商用】3時間パック',
'【商用】2時間パック' => '【商用】2時間パック',
];
private const PriceOfPlanSet = [
'【非商用】基本プラン(平日)' => 19500,
'【非商用】基本プラン(休日)' => 20500,
'【非商用】お昼5時間パック' => 17000,
'【非商用】夜5時間パック' => 18000,
'【商用】基本1日プラン' => 28500,
'【商用】お昼5時間パック' => 24000,
'【商用】夜5時間パック' => 25000,
'【商用】3時間パック' => 20000,
'【商用】2時間パック' => 17000,
];
/**
* @return String
*/
public function getPlan(): String
{
return $this->value;
}
//選択したプランの価格を返します。このメソッドはReservationクラスから使われています
public function getPrice(): int
{
return $this::PriceOfPlanSet[$this->value];
}
//2時間プランor3時間プランを選択しているかを返します。Optionsクラスでのチェック等に使われます。
public function hasShortTimePlan(): bool
{
return strpos($this->value, '2時間') || strpos($this->value, '3時間');
}
public function hasTwoHourPlan()
{
return strpos($this->value, '2時間');
}
public function hasThreeHourPlan()
{
return strpos($this->value, '3時間');
}
public function hasDayTimePlan()
{
return strpos($this->value, '昼');
###PriceOfPlanクラス
各プランの価格を返すだけのクラス。
PriceOfPlanSet
というプラン名と価格のセットをPlan
クラスに持たせると長くなりそう、、という理由でPlan
クラスから分けていました。
####コードレビューでの指摘ポイント
・Planに関する修正が入るときに、Plan
クラスとPriceOfPlan
クラスの2つの対象クラスがあると、変更に手間がかかりやすいため、一つにまとめた方が良い。
→確かに!と思うとともに、一つのクラスにまとめすぎると逆に変更点を探しにくくなることもあるんだろうなぁと思い、クラスをどこで分けるか、というのもちゃんと考えないとな、と思いました。
namespace Asobiba\Domain\Models\Reservation;
class PriceOfPlan
{
private const PriceOfPlanSet = [
'【非商用】基本プラン(平日)' => 19500,
'【非商用】基本プラン(休日)' => 20500,
'【非商用】お昼5時間パック' => 17000,
'【非商用】夜5時間パック' => 18000,
'【商用】基本1日プラン' => 28500,
'【商用】お昼5時間パック' => 24000,
'【商用】夜5時間パック' => 25000,
'【商用】3時間パック' => 20000,
'【商用】2時間パック' => 17000,
];
public static function getPrice(Plan $plan): int
{
return self::PriceOfPlanSet[$plan->getPlan()];
}
}
###Capacityクラス
Plan
インスタンスとOptions
インスタンスを使って予約のキャパシティ(最大利用人数)を判別して返します。Number
クラス(利用人数クラス)にてキャパシティに収まる利用人数かどうかのチェックに使われます。
namespace Asobiba\Domain\Models\Reservation;
class Capacity
{
private $plan;
private $options;
private const capacityOfPlanSet = [
'【非商用】基本プラン(平日)' => 11,
'【非商用】基本プラン(休日)' => 11,
'【非商用】お昼5時間パック' => 11,
'【非商用】夜5時間パック' => 11,
'【商用】基本1日プラン' => 15,
'【商用】お昼5時間パック' => 15,
'【商用】夜5時間パック' => 15,
'【商用】3時間パック' => 15,
'【商用】2時間パック' => 15,
];
public function __construct(Plan $plan, Options $options)
{
$this->plan = $plan;
$this->options = $options;
}
public function getCapacity()
{
if ($this->options->hasLargeGroupOption()) {
return 15;
}
return self::capacityOfPlanSet[$this->plan->getPlan()];
}
###DateOfUseクラス
利用日、利用時間に関するクラスです。
空き状況のチェック、利用時間に関するルールのチェック、を行っています。
namespace Asobiba\Domain\Models\Reservation;
class DateOfUse
{
private $date;
private $start_time;
private $end_time;
private $cleaningTime = [
'start' => 16,
'end' => 17,
];
public function __construct($date,$start_time,$end_time,Plan $plan,Options $options)
{
if(!$this->isAvailable($date,$start_time,$end_time)){
throw new \InvalidArgumentException('ご希望の時間帯は別の方が予約済みです');
}
if(!$this->isAcceptableStartAndEndTime($start_time,$end_time,$plan)){
throw new \InvalidArgumentException('不正な開始時刻又は終了時刻が入力されています');
}
if(!$this->isAcceptableMaxTime($start_time,$end_time,$plan)){
throw new \InvalidArgumentException('プランで指定された利用時間をオーバーしています');
}
if(!$this->notCleaningTime($plan,$start_time,$end_time)){
throw new \InvalidArgumentException('2or3時間パックの場合16時~17時以外で指定して下さい');
}
$this->date = $date;
$this->start_time = $start_time;
$this->end_time = $this->optimizeEndTime($options,$end_time);
}
private function isAvailable($date,$start_time,$end_time)
{
//Googleカレンダーに問い合わせる
return true;
}
private function isAcceptableStartAndEndTime($start_time,$end_time,$plan)
{
//AcceptableTimeクラスを使って適切な開始時間,終了時間かをチェックしている
return $start_time >= AcceptableTime::acceptableStartTime($plan) && $end_time <= AcceptableTime::acceptableEndTime($plan);
}
private function isAcceptableMaxTime($start_time,$end_time,$plan)
{
if($plan->hasTwoHourPlan()){
return $end_time - $start_time <= 2;
}
if($plan->hasThreeHourPlan()){
return $end_time - $start_time <= 3;
}
return true;
}
private function optimizeEndTime($options,$end_time)
{
if($options->hasMidnightOption()){
return 24;
}
if($options->hasStayOption()){
return 9;
}
return $end_time;
}
public function getDate(): string
{
return $this->date;
}
public function getStartTime():int
{
return $this->start_time;
}
public function getEndTime():int
{
return $this->end_time;
}
public function notCleaningTime($plan,$start_time,$end_time):bool
{
if($plan->hasShortTimePlan()) {
return $start_time >= $this->cleaningTime['end'] || $end_time <= $this->cleaningTime['start'];
}
return true;
}
上記のような形で予約に関する各クラスにて、紐づくルールをチェックしています。
他のクラスのコードも見たい場合は、Githubをご確認下さい。
https://github.com/Yorinton/asobiba101/tree/master/packages/Asobiba
[フォルダ]
・サービスクラス・・Application/Services
・ドメインモデル・・Domain/Models
#学んだポイント・気づきまとめ
##コードレビューで指摘されたポイント
・引数の型を独自の型にする・・外でインスタンス化する
・ドメインモデルではプランコード(アスキー)だけ持たせて、表示するプラン名はViewに近いところで変換する
・DBに登録するときもプランコード
・一意な識別子の生成
・・複数サーバの場合はアプリケーション側で作る(uuid)
・・MySQLはuuidのパフォーマンスが悪い
##その他
・インデントの揃え方
phpcsfixer・・インデントを揃えてくれる
php storm・・Reformat Codeで自動で揃えてくれる
・データベース
大規模な場合はMySQLよりPostgreの方がパフォーマンスがいい
・その他
?>は無い方がいい(htmlに入れる時以外)
(?>の後に空白が入ったり改行が入ることがある)
今回コードレビューを受けるに当たって、自分なりに結構考えて書いたコードを持参したせいか、そこまで沢山の指摘があった訳ではありませんでしたが、出た指摘はどれも自分の思いつく範囲外のことで、とても勉強になりました。
また、もっとレビューしてもらうポイントを絞って(機能単位やメソッド単位等)レビューしてもらった方がレビューする側も分かりやすかったと思うので、そこは反省ポイントでした。
コードは改善していくので時間見つけてこちらも編集していければと思います。
PHPでのDDD実践例はあまり無いので、DDD学習中の方の学習材料に少しでもなれれば幸いです!!