LaravelDoctrineでXMLマッピング
これはなに?
LaravelをFWとして採用した際に通常はEloquentをORMとして採用することがほとんどだとおもいます。
しかし、例えばレガシーなDB設計になっているWEBアプリケーションをLaravelで作り直す
などの必要が生じた場合や、リポジトリパターンを活用して重厚なシステムを構築したい場合
Eloquentでは厳しい場面も出てきます。
その際に第二の選択しとしてDoctrineというORMがあげられます。
このORMをxmlマッピングにて使用すると、DBに関する関心事と実際の業務ロジックを限りなく分離させることが可能となります。
この記事では、公式のドキュメントからのみではわかりづらいことが多い
LaravelDoctrineのXMLマッピング
を実現するまでの手順を記載していきます。
尚本記事内で紹介している手法を実際に使用しているリポジトリも紹介しておきます。
↑をバンドルしているrepository
LaravelDoctrineのインストール
composerからインストールします
composer require doctrine/inflector:^1.4 laravel-doctrine/orm laravel-doctrine/migrations
設定ファイル生成
php artisan vendor:publish --tag="config"
設定
Doctrineでは
「どのオブジェクト」に「どのようなテーブルのレコード」を「どのようにマッピング」するかを
- XML
- YAML
- ANNOTATION
と3種類の手法を使って定義できます。
今回の記事ではXMLマッピングの方法を記載していきます
この記事で扱うsampleシチュエーション
- ユーザーマスタテーブルとしてsample.usersが定義されている。
- ユーザー情報を記録するテーブルとしてsample.user_profilesが定義されている。
- アプリケーション側では、ユーザー情報をオブジェクトとして取得して業務ロジックで扱いたい。
データベース定義
sample.usersテーブル
user_id | access_id | password | expires_at | created_at |
---|---|---|---|---|
1 | u1 | xxxx | 2023-01-01 12:00:00 | 2020-01-01 12:00:00 |
2 | u2 | xxxx | 2023-01-01 13:00:00 | 2020-01-01 12:00:00 |
3 | u3 | xxxx | 2023-01-01 14:00:00 | 2020-01-01 12:00:00 |
sample.user_profilesテーブル
user_profile_id | user_id | name | tel | |
---|---|---|---|---|
1 | 1 | user1 | 000-0000-000 | user1@sample.com |
2 | 2 | user2 | 000-0000-001 | user2@sample.com |
3 | 3 | user3 | 000-0000-002 | user2@sample.com |
業務ロジックを扱うためのオブジェクト
jsonで表現した場合
下記のような構造として扱いたい
{
"user":{
"userId":{"value": 1},
"accessId":{"value": "u1"},
"password": "xxxx",
"expiresAt": {"value": "2023-01-01 12:00:00"}
"profile" :{
"userProfileId": {"value": 1},
"name": {"value": "user1"},
"tel": {"value": "000-0000-0000"},
"mail":{"value": "user1@sample.com"}
}
}
}
ユーザー情報のオブジェクト
PHPで上記Jsonを下記オブジェクトで表現します。
ディレクトリ構造のsample
Laravelをインストールした段階ではappという標準のdirectoryがいて、その中にアプリケーションロジックを入れていくと思いますが、このsampleではappとは完全に分離してこのdir内にPHPオブジェクトクラスとXMLファイルを詰め込みます
{LaravelProjectPath}
|-app //Laravel標準dir
|-packages //このsample用に新設したdir
|-domain
| |-model //この中にオブジェクトを定義したphpファイルを追加していく
|-infrastructure
| |- database
| |- xml // この中にXMLファイルを追加していく
|
....
上記
packages/domain/model/
内に、下記のようにオブジェクトを定義していきます。
<?php
namespace packages\domain\model\user;
use packages\domain\model\user\profile\UserProfile;
class User
{
private UserId $userId;
private AccessId $accessId;
private string $password;
private ExpiresAt $expiresAt;
private UserProfile $profile;
/**
* @return int
*/
public function getUserId(): int
{
return $this->userId->getValue();
}
/**
* @return string
*/
public function getName(): string
{
return $this->profile->getName();
}
/**
* @return UserProfile
*/
public function getProfile(): UserProfile
{
return $this->profile;
}
}
namespace packages\domain\model\user;
class UserId
{
private ?int $value;
public function __construct(int $value = null)
{
$this->value = $value;
}
public static function create(string $value): self
{
return new self((int)$value);
}
public function isEmpty(): bool
{
return is_null($this->value);
}
public function toInteger(): int
{
return $this->value;
}
public function getValue(): ?int
{
return $this->value;
}
}
namespace packages\domain\model\user;
class AccessId
{
private ?string $value;
public function __construct(string $value = null)
{
$this->value = $value;
}
public function isEmpty(): bool
{
if (!$this->value) {
return true;
}
return false;
}
public function toString(): string
{
if ($this->isEmpty()) {
return "";
}
return $this->value;
}
public function getValue(): ?string
{
return $this->value;
}
}
namespace packages\domain\model\user;
class ExpiresAt {
private ?DateTime $value;
public function __construct(DateTime $value = null)
{
$this->value = $value;
}
public static function create(string $value = null): self
{
if ($value) {
return new self(DateTime::createFromFormat('Y-m-d H:i:s', $value));
}
return new self();
}
public function isEmpty(): bool
{
return is_null($this->value);
}
public function toLocalDateTime(): DateTime
{
return $this->value;
}
public function getValue(): ?DateTime
{
return $this->value;
}
public function format(): ?string
{
if ($this->isEmpty()) {
return null;
}
return $this->value->format('Y-m-d H:i:s');
}
}
namespace packages\domain\model\user\profile;
class UserProfile
{
private UserProfileId $userProfileId;
private UserName $name;
private UserTel $tel;
private UserMail $mail;
/**
* @return int
*/
public function getName(): string
{
return $this->name->getValue();
}
/**
* @return int
*/
public function getTel(): string
{
return $this->tel->getValue();
}
/**
* 電話番号を配列に分解します。
* @return array
*/
public function getArrayTel(): array
{
return $this->tel->toArrayValue();
}
}
namespace packages\domain\model\user\profile;
class UserProfileId
{
private ?int $value;
public function __construct(int $value = null)
{
$this->value = $value;
}
public static function create(string $value): self
{
return new self((int)$value);
}
public function isEmpty(): bool
{
return is_null($this->value);
}
public function toInteger(): int
{
return $this->value;
}
public function getValue(): ?int
{
return $this->value;
}
}
namespace packages\domain\model\user\profile;
class UserName
{
private ?string $value;
public function __construct(string $value = null)
{
$this->value = $value;
}
public function isEmpty(): bool
{
if (!$this->value) {
return true;
}
return false;
}
public function toString(): string
{
if ($this->isEmpty()) {
return "";
}
return $this->value;
}
public function getValue(): ?string
{
return $this->value;
}
}
namespace packages\domain\model\user\profile;
class UserTel
{
private ?string $value;
public function __construct(string $value = null)
{
$this->value = $value;
}
public function isEmpty(): bool
{
if (!$this->value) {
return true;
}
return false;
}
public function toString(): string
{
if ($this->isEmpty()) {
return "";
}
return $this->value;
}
public function getValue(): ?string
{
return $this->value;
}
public function toArrayValue(): array
{
return explode('-',$this->value);
}
}
namespace packages\domain\model\user\profile;
class UserMail
{
private string $value;
public function __construct(string $value = null)
{
$this->value = $value;
}
public function isEmpty(): bool
{
if (!$this->value) {
return true;
}
return false;
}
public function toString(): string
{
if ($this->isEmpty()) {
return "";
}
return $this->value;
}
public function getValue(): string
{
return $this->value;
}
public function toInternetAddress(): string
{
$valid = new RFCValidation();
try {
$valid->isValid($this->mail->toString(), new EmailLexer());
} catch (Exception $e) {
//$valid->getError();
//$valid->getWarnings();
// TODO ユーザー定義Exception設置
}
$this->address = $this->mail->toString();
}
}
ここまででdir構造は
{LaravelProjectPath}
|-app //この中にLaravel標準のdirectoryとかが入っていると思います。
|-packages //このsampleではappとは完全に分離してこのdir内にPHPオブジェクトクラスとXMLファイルを詰め込みます
|-domain
| |-model
| |-user
| |- User.php
| |- UserId.php
| |- AccessId.php
| |- ExpiresAt.php
| |
| |-profile
| |- UserProfileId.php
| |- UserName.php
| |- UserTel.php
| |- UserMail.php
|
|-infrastructure
|- database
|- xml
となります。
実際にLaravel上で業務ロジックを記述していくときには、上記のうち
packages/domain/models内のクラスのみを意識して実装することができるようにすることを目的とします。
XML用の設定
config/doctrine.phpに
'managers' => [
'default' => [
'dev' => env('APP_DEBUG', false),
'meta' => env('DOCTRINE_METADATA', 'simplified_xml'),
'connection' => env('DB_CONNECTION', 'pgsql'),
'namespaces' => [],
'paths' => [],
'repository' => Doctrine\ORM\EntityRepository::class,
'proxies' => [
'namespace' => false,
'path' => storage_path('proxies'),
'auto_generate' => env('DOCTRINE_PROXY_AUTOGENERATE', false)
],
'events' => [
'listeners' => [],
'subscribers' => []
],
'filters' => [],
'mapping_types' => [
//'enum' => 'string'
]
]
],
//以下略
のように記載していきます。
ポイントは
'meta' => env('DOCTRINE_METADATA', 'simplified_xml')
と
paths => []
です。
となっていますが、このpaths内に
どのXMLファイルと
どのオブジェクトを紐づけるか
を定義していく必要があります。
マッピング定義XML
どのテーブルのどのカラムをどのオブジェクトに紐づけるか。
をXMLに定義していきます。このXMLを定義したのちに、
paths => []
へオブジェクトとXMLのパスを記載していきます。
また、XMLの命名規則として
orm.xml
を拡張子として記載します。
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="{クラスpath}" table="{DBテーブル名}">
...
</entity>
今回注入先のオブジェクトのメンバ変数はそれぞれオブジェクトになっていたりします。
この場合doctrineでは
メンバ変数のオブジェクトに対するXMLに対してentityタグの代わりに
<embeddable name='{メンバ変数名オブジェクトのpath}' table='{対象テーブル名}'>
というタグで定義しつつ、
参照元のXMLにて
<embedded name="{オブジェクトのメンバ変数名}"
class="{メンバ変数名オブジェクトのpath}"
use-column-prefix="false"/>
というタグを定義することでオブジェクトの階層を表現できます。
上記を利用して
infrastructure
|- database
|- xml
|- user
|-profile
ないにそれぞれXML定義を追加していきます。
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="packages\domain\model\user\User" table="sample.users">
<embedded name="userId"
class="packages\domain\model\user\UserId"
use-column-prefix="false"/>
<embedded name="accessId"
class="packages\domain\model\user\AccessId"
use-column-prefix="false"/>
<field name="password" type="string" column="password"/>
<embedded name="expiresAt"
class="packages\domain\model\user\ExpiresAt"
use-column-prefix="false"/>
<embedded name="profile"
class="packages\domain\model\user\profile\UserProfile"
use-column-prefix="false"/>
</embeddable>
</doctrine-mapping>
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="packages\domain\model\user\UserId">
<id name="value" type="integer" column="user_id"/>
</embeddable>
</doctrine-mapping>
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="packages\domain\model\user\AccessId">
<field name="value" type="string" column="access_id"/>
</embeddable>
</doctrine-mapping>
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="packages\domain\model\user\ExpiresAt">
<field name="value" type="datetime" column="expires_at"/>
</embeddable>
</doctrine-mapping>
Userが持つprofileというメンバのオブジェクトマッピング定義
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="packages\domain\model\user\profile\UserProfile" table='sample.user_profiles'>
<embedded name="userProfileId"
class="packages\domain\model\user\profile\UserProfileId"
use-column-prefix="false"/>
<embedded name="name"
class="packages\domain\model\user\profile\UserName"
use-column-prefix="false"/>
<embedded name="tel"
class="packages\domain\model\user\profile\UserTel"
use-column-prefix="false"/>
<embedded name="mail"
class="packages\domain\model\user\profile\UserMail"
use-column-prefix="false"/>
</embeddable>
</doctrine-mapping>
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="packages\domain\model\user\profile\UserProfileId">
<id name="value" type="integer" column="user_profile_id"/>
</embeddable>
</doctrine-mapping>
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="packages\domain\model\user\profile\UserName">
<id name="value" type="string" column="name"/>
</embeddable>
</doctrine-mapping>
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="packages\domain\model\user\profile\UserTel">
<id name="value" type="string" column="tel"/>
</embeddable>
</doctrine-mapping>
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="packages\domain\model\user\profile\UserMail">
<id name="value" type="string" column="mail"/>
</embeddable>
</doctrine-mapping>
ここまででディレクトリ構造は下記のようになります。
{LaravelProjectPath}
|-app //この中にLaravel標準のdirectoryとかが入っていると思います。
|-packages //このsampleではappとは完全に分離してこのdir内にPHPオブジェクトクラスとXMLファイルを詰め込みます
|-domain
| |-model
| |-user
| |- User.php
| |- UserId.php
| |- AccessId.php
| |- ExpiresAt.php
| |
| |-profile
| |- UserProfileId.php
| |- UserName.php
| |- UserTel.php
| |- UserMail.php
|
|-infrastructure
|- database
|- xml
|- user
|- User.orm.xml
|- UserId.orm.xml
|- AccessId.orm.xml
|- ExpiresAt.orm.xml
|
|- profile
|- UserProfile.orm.xml
|- UserName.orm.xml
|- UserTel.orm.xml
|- UserMail.orm.xml
XMLとオブジェクトの関連性をパスに定義する
ここまでで、かなりのファイルを作成してきました。
XML内に、フルパスでクラスの指定をしているのですが、残念ながらDoctrineではさらに同じような紐づけ定義をしていく必要があります。
それが前述した
'paths' => []
です。
この配列にXMLファイルまでのパス、対象ドメインファイルまでのパス
を下記のように定義していく必要があります。
'paths' => [
{XMLファイルまでのパス} => {対象オブジェクトファイルまでのパス}
]
今回のsampleを動作させる場合。下記の設定が必要となります。
'paths' => [
base_path('packages/infrastructure/database/xml/user/')
=>'packages\domain\model\user',
base_path('packages/infrastructure/database/xml/user/profile')
=>'packages\domain\model\user\profile',
使ってみる
ここまでで、XMLマッピングに関する設定が完了しました。
あとは実際にDoctirneからsample.usersテーブルデータを取得し、意図通りとなっていることを確認していくのみです。
本記事ではDoctrineのRepositoryに関する詳細は省略しますが、概要としては下記のような構造にしつつユーザー情報取得処理を記述していきます。
{LaravelProjectPath}
|-app //この中にLaravel標準のdirectoryとかが入っていると思います。
|-packages //このsampleではappとは完全に分離してこのdir内にPHPオブジェクトクラスとXMLファイルを詰め込みます
|-domain
| |-model
| |-user
| |- UserRepository.php
|-infrastructure
| |- database
| |- doctrine
| |- DoctrineUserRepository.php
|
|- service
|- UserGetService.php
のようにUserオブジェクトを扱うためのRepositoryを実装します。
<?php
namespace packages\domain\model\User;
interface UserRepository
{
/** @retrun User */
public function findUser(UserId $userId): User;
/** @retrun User profileにも注入されます */
public function findUserInfo(UserId $userId): User;
/** @retrun Array<User> */
public function findAllUsers(): array;
}
- sample.userを単純にfindしてUserオブジェクトに入れ込むメソッド(profileは空になります)、
- sample.user_profilesの情報も一括で取得するメソッド。※ここではNativeQueryを利用する例も書いておきます
- sample.userをすべて取得する(profileはなし)
の三種類のメソッドをsampleで書いてみました。
<?php
namespace packages\infrastructure\database\doctrine\user;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use packages\domain\model\User\User;
use packages\domain\model\User\UserRepository;
use packages\infrastructure\database\doctrine\DoctrineRepository;
class DoctrineUserRepository extends EntityRepository implements UserRepository
{
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function findUser(UserId $userId): User
{
try {
// User.orm.xml内でidタグを定義しているuser_idに対して検索がかかる。
// 複数idを指定している場合はcriteriaとか使うといいんじゃないかな
return $this->find($userId->getValue());
} catch (NoResultException $e) {
throw $e;
}
}
/**
* ユーザープロファイル情報も同時に取得する。ここではNativeQueryで書いてみる
* User.profileにもSQLで取得した値が格納されていきます
* @return User
*/
public function findUserInfo(): User
{
$rsm = new ResultSetMappingBuilder($this->getEntityManager());
$rsm->addNamedNativeQueryResultClassMapping($this->getClassMetadata(), $this->getClassName());
$sql = 'SELECT
user_id
, password
, access_id
, expires_at
, user_profile_id
, name
, tel
, mail
FROM sample.users
JOIN sample.user_profiles USING (user_id)
WHERE 1 = 1
AND user_id = :userId
';
$query = $this->getEntityManager()->createNativeQuery($sql, $rsm);
$query->setParameters([
'userId' => $userId->toInteger()
]);
try {
return $query->getResult();
} catch (NoResultException $e) {
throw $e;
}
}
/**
* @return Array<User>
*/
public function findAllUsers(): array
{
try {
return $this->findAll();
} catch (NoResultException $e) {
throw $e;
}
}
}
上記リポジトリーをLaravelのDIコンテナを利用しつつ
<?php
namespace App\Providers;
use packages\infrastructure\database\doctrine as Doctrine;
use packages\infrastructure\database as Database;
use packages\domain\model as DomainModel;
use Illuminate\Support\ServiceProvider;
class DatasourceServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->bind(DomainModel\user\UserRepository::class, function ($app) {
return new Doctrine\user\DoctrineUserRepository(
$app['em'],
$app['em']->getClassMetaData(DomainModel\user\User::class)
);
});
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
//
}
}
このようにDIすることでUserGetService.phpでは、DBや、DoctorineRepositoryの実装を意識せずに
ビジネスロジックを書いていくことができます。
<?php
namespace packages\service;
use packages\domain\model\User\UserId;
use packages\domain\model\User\User;
use packages\domain\model\User\UserRepository;
class UserGetService
{
private UserRepository $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function getUser(UserId $userId): User
{
return $this->userRepository->findUser($userId);
}
public function getUserList(): array
{
return $this->userRepository->findAllUsers();
}
}
ここまでできたら、あとは通常のLaravel開発と同様の手順通り。
app/Http/Controllers/Sample.php
等にコントローラを設置し、 UserGetService を呼び出し、Viewに変換していく。
というながれになりますね。
言い訳とかとか
- 色々説明足りないのではないか
この記事ではあくまでXMLマッピングについての言及を主とするので細かなことは省きます。 - embeddableばかりに焦点あてて、doctrine本来のマッピングの例としては弱くないか
マッピングの仕方としてont-to-oneやone-to-manyなどなどの関連付けについては別途記事を起こそうかと思ってます。
が、このあたりはドメイン駆動開発をするにあたって個人的にあまり好きではない部分なのでかなり偏った記事になる気がする。
最後に
以上で、LaravelDoctorineのXMLマッピングを利用するまでの手順が完了します。
この記事内で触れられなかった内容は別途記事を起こし、本記事にリンク貼ってこうと思います。
質問や、ここ間違ってるよーとかいけてないよーとかって部分がありましたら是非コメントお願いいたします。
誰かにとっての何かの助けになりますと幸いです。
Discussion