在 PHP 中编写值对象
如果你已经听说过领域驱动设计(DDD),那么你可能也听说过价值对象。它是埃里克·埃文斯在“蓝皮书”中引入的基本构建模块之一。
值对象是一种封装数据的类型,它仅通过其属性来区分。与对象不同Entity,它没有唯一标识符。因此,具有相同属性值的两个值对象应被视为相等。
值对象候选对象的典型例子包括:
- 电话号码
- 地址
- 价格
- 提交哈希
- 实体标识符
- 等等。
在设计值对象时,应注意其三个主要特征:不可变性、结构相等性和自我验证性。
举个例子:
<?php declare(strict_types=1);
final class Price
{
const USD = 'USD';
const CAD = 'CAD';
/** @var float */
private $amount;
/** @var string */
private $currency;
public function __construct(float $amount, string $currency = 'USD')
{
if ($amount < 0) {
throw new \InvalidArgumentException("Amount should be a positive value: {$amount}.");
}
if (!in_array($currency, $this->getAvailableCurrencies())) {
throw new \InvalidArgumentException("Currency should be a valid one: {$currency}.");
}
$this->amount = $amount;
$this->currency = $currency;
}
private function getAvailableCurrencies(): array
{
return [self::USD, self::CAD];
}
public function getAmount(): float
{
return $this->amount;
}
public function getCurrency(): string
{
return $this->currency;
}
}
不变性
一旦实例化了一个值对象,它在应用程序的整个生命周期内都应该保持不变。如果需要更改其值,则必须完全替换该对象。
如果仅在局部作用域内使用可变值对象,且仅对该对象持有一个引用,则使用可变值对象是可以接受的。否则,可能会出现问题。
以前面的例子为例,以下是如何更新某种Price类型的数量:
<?php declare(strict_types=1);
final class Price
{
// ...
private function hasSameCurrency(Price $price): bool
{
return $this->currency === $price->currency;
}
public function sum(Price $price): self
{
if (!$this->hasSameCurrency($price)) {
throw \InvalidArgumentException(
"You can only sum values with the same currency: {$this->currency} !== {$price->currency}."
);
}
return new self($this->amount + $price->amount, $this->currency);
}
}
结构平等
值对象没有标识符。换句话说,如果两个值对象具有相同的内部值,则它们必须被视为相等。由于 PHP 没有提供重写相等运算符的方法,因此您需要自行实现此功能。
你可以创建一个专门的方法来实现这一点:
<?php declare(strict_types=1);
final class Price
{
// ...
public function isEqualsTo(Price $price): bool
{
return $this->amount === $price->amount && $this->currency === $price->currency;
}
}
另一种方法是根据其属性创建哈希值:
<?php declare(strict_types=1);
final class Price
{
// ...
private function hash(): string
{
return md5("{$this->amount}{$this->currency}");
}
public function isEqualsTo(Price $price): bool
{
return $this->hash() === $price->hash();
}
}
自我验证
值对象的验证应该在其创建时进行。如果其任何属性无效,则应抛出异常。结合不可变性,一旦创建了值对象,就可以确保它始终有效。
再次以Price类型为例,应用程序的域为负数是没有意义的:
<?php declare(strict_types=1);
final class Price
{
// ...
public function __construct(float $amount, string $currency = 'USD')
{
if ($amount < 0) {
throw new \InvalidArgumentException("Amount should be a positive value: {$amount}.");
}
if (!in_array($currency, $this->getAvailableCurrencies())) {
throw new \InvalidArgumentException("Currency should be a valid one: {$currency}.");
}
$this->amount = $amount;
$this->currency = $currency;
}
}
与 Doctrine 一起使用
使用Doctrine从数据库中存储和检索值对象非常简单,只需使用Embeddable`s` 即可。根据文档,Embeddable`s` 本身并非实体。但是,您可以将它们嵌入到实体中,这使得它们非常适合处理值对象。
假设你有一个Product类,并且你想在这个类中存储价格。最终你会得到以下模型:
<?php declare(strict_types=1);
/** @Embeddable */
final class Price
{
/** @Column(type="float") */
private $amount;
/** @Column(type="string") */
private $currency;
public function __construct(float $amount, string $currency = 'USD')
{
// ...
$this->amount = $amount;
$this->currency = $currency;
}
// ...
}
/** @Entity */
class Product
{
/** @Embedded(class="Price") */
private $price;
public function __construct(Price $price)
{
$this->price = $price;
}
}
Doctrine 会自动将类中的列创建Price到该类的表中Product。默认情况下,它会在类名后添加数据库列名前缀Embeddable,在本例中为:price_amount和price_currency。
结论
值对象有助于编写简洁的代码。例如,与其这样写:
public function addPhoneNumber(string $phone): void {}
你可以这样写:
public function addPhoneNumber(PhoneNumber $phone): void {}
这样一来,阅读和理解起来就很容易了,而且你也不需要弄清楚应该使用哪种手机格式。
由于它们的属性定义了它们,而且你可以与其他不同的实体共享它们,因此它们可以永远缓存。
它们可以帮助你减少重复代码。你可以使用纯类来代替多个amount字段。currencyPrice
当然,就像生活中的其他事情一样,你不应该滥用值对象。想象一下,你为了将大量对象存储到数据库中而将它们转换为原始值,或者在从数据库中检索它们时再将它们转换回值对象。这确实会导致性能问题。此外,拥有大量细粒度的值对象也会使你的代码库变得臃肿。
使用值对象,您可以减少对原始值的执着。使用值对象来表示领域中需要验证或如果使用原始值可能会造成歧义的字段或字段组。
感谢阅读,祝您编程愉快!
延伸阅读
- Martin Fowler写了一篇关于价值对象的文章,点击这里查看:https://martinfowler.com/bliki/ValueObject.html