PHP 8中的值对象:构建更优雅、更可靠的代码
在编码世界中,保持代码整洁和强大是至关重要的。值对象模式是一种可以显着提高代码质量的设计模式,使其更加健壮和可维护。
在本文中,我将解释如何实现该模式,以及如何使用 PHP 8.1 和 PHP 8.2 引入的最新功能向您的代码添加一些“糖分”。
在我们开始讨论值对象之前,让我们先讨论一下基本数据类型的问题。
以下是三个常见问题:
1. 无效值
简单数据类型没有内置的值验证机制,这可能会导致我们的代码出现意外。
例如,年龄可以用整数表示,但它不能是负数或大于 120(或更少)。在某些领域,年龄必须大于或等于 18 岁。
电子邮件可以用字符串表示,但它并非所有字符串都是有效的电子邮件地址。电子邮件地址需要满足特定的格式要求,例如包含一个 @ 符号和一个域名。
我们的代码中可能有很多不同的地方使用这些值,但我们不能假设用户会输入有效的数据。因此,我们必须在每次使用这些值之前进行验证。
这会导致验证逻辑重复的问题。这些重复的逻辑中的每一个都可能彼此不同,从而导致不一致。
function logic1(int $age): void
{
($age >= 18) or throw InvalidAge::adultRequired($age);
// Do stuff
}
function logic2(int $age): void
{
($age >= 0) or throw InvalidAge::lessThanZero($age);
($age <= 120) or throw InvalidAge::matusalem($age);
// Do stuff
}
使用值对象应该可以解决问题。它将大大简化您的代码并确保数据的一致性。
readonly final class Age
{
public function __construct(public int $value)
{
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
}
}
function logic1(Age $age): void
{
// Do stuff
}
function logic2(Age $age): void
{
// Do stuff
}
通过这种方式,您可以确定如果 Age 实例存在,那么它在代码中的任何地方都是有效且一致的,而无需每次都进行检查。
2. 参数顺序混淆
当处理采用相似类型数据的函数时,很容易混淆参数的顺序。这可能会导致难以发现的错误。
例如,下面的代码中,logic1()
函数需要传入姓名和姓氏。logic2()
函数需要传入姓氏和姓名。如果我们不注意参数的顺序,很容易在调用 logic2()
函数时将姓名和姓氏传错。
function logic1(string $name, string $surname): void
{
// Logic error,
// $name is switched with $surname, unintentionally
logic2($name, $surname);
}
function logic2(string $surname, string $name): void {
// Do stuff
}
这种类型的错误特别棘手,PHP 中没有内置检查可以帮助我们预防这种类型的错误。
解决这个问题只有两种方法:
使用值对象:值对象是一种自包含的数据结构,它封装了数据和行为。在这种情况下,我们可以将姓名和姓氏封装到 Name
和Surname
值对象中。
function logic1(Name $name, Surname $surname): void
{
// Static analysis error
// Expected Surname, found Name
logic2($name, $surname);
}
function logic2(Surname $surname, Name $name): void {
// Do stuff
}
使用命名参数:PHP 8.0 引入了命名参数,这可以帮助我们解决参数顺序混淆的问题。命名参数允许我们通过参数名称而不是参数顺序来传递参数。
function logic1(string $name, string $surname): void
{
logic2(name: $name, surname: $surname);
}
3. 意外修改
简单数据类型在传递给函数时,可能会被意外修改。
例如,下面的代码中,logic1()
函数需要传入一个年龄。如果我们将一个整数值传递给 logic1()
函数,那么 logic1()
函数可能会意外修改该整数值。
function logic1(int &$age): void
{
if ($age = 42) { // BUGS alert
echo "That's the answer\n";
}
echo "Your age is $age\n"; // It will print always 42
}
由于 &
通常用于引用传递参数,因此这个示例有两个错误:
$age = 42
是赋值操作,而不是比较操作。这意味着age
变量的值将被设置为 42,即使age
变量之前有其他值。这可能会导致意外的结果,因为之后使用age
变量的所有代码都将看到新的值。&
符号表示参数是引用传递。这意味着logic1()
函数可以直接修改age
变量的值。这可能是故意的,但有时并非如此。例如,如果age
变量用于表示用户的当前年龄,那么logic1()
函数不应该修改该值。
使用值对象可以解决这个问题,因为值对象是不可变的。
final readonly class Age
{
public function __construct(public int $value)
{ // 验证
}
}
function logic1(Age $age): void
{
// 解释器错误:无法写入只读属性
// BUGS 警告
if ($age->value = 42) {
echo "That's the answer\n";
}
echo "您的年龄是 $age\n"; // 将打印原始值
}
类作为类型
值对象通过将类作为类型来解决这些问题。与简单数据类型不同,值对象将其数据封装在类中,并提供对数据的访问和修改方法。这有助于我们更好地控制和验证我们的数据。
值对象的关键特性
为了充分利用值对象,我们需要关注一些重要的特性:
1. 无法改变(不变性)
值对象一旦创建,其内部数据就应保持不变。这有助于避免意外变化。在 PHP 8.1 之前,这通常通过仅使用私有或受保护的属性和 getter
来实现,而禁止使用 setter
。
如果内部数据确实需要更改,则应创建一个新的值对象,而不是修改现有的实例。
PHP 8.1 通过引入 readonly
关键字,大大简化了只读属性的声明和属性的提升。
class Age // PHP 8.1
{
public function __construct(public readonly int $value)
{
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
}
}
PHP 8.1 之前的版本
class Age // PHP < 8.1
{
private int $value;
public function __construct(int $value)
{
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
$this->value = $value;
}
public function value():int { return $this->value; }
}
以下示例展示了如何处理值对象的更改。
如您所见,值对象的内部数据是不可变的。因此,要更改值对象的状态,需要创建一个新的值对象。
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency
) {
if ($amount <= 0) {
throw InvalidMoney::cannotBeZeroOrLess($amount);
}
}
public function sum(Money $money): Money
{
if ($money->currency !== $this->currency) {
throw InvalidMoney::cannotSumPearsWithApples($this->currency, $money->currency);
}
$newAmount = $this->amount + $money->amount;
return new Money($newAmount, $this->currency);
}
}
2. 易于比较(可比性)
使值对象具有可比性意味着我们可以轻松检查它们是否相同或不同。这在排序或搜索时非常有用。
// Money
public function equals(Money $money): bool
{
// 比较金额和货币是否相等
return $this->amount === $money->amount
&& $this->currency === $money->currency;
}
// code
$thousandYen = new Money(1000, Currency::YEN);
$thousandEuro = new Money(1000, Currency::EURO);
$thousandYen->equals($thousandEuro); // false
当然,1000 日元和 1000 欧元不一样。
3.始终保持良好的数据(一致性)
值对象应始终代表有效的值。通过验证对象内部的属性和方法,我们可以确保其始终处于良好的状态。
因此,验证应在构造函数内部完成,以确保实例在创建时始终是有效的。
注意:
反序列化器可能会在不调用构造函数的情况下构建对象,因此应谨慎使用。
一种解决方案是使用 validate
方法,该方法在构造函数内部调用,并在反序列化后再次调用。
例如,以下代码使用 validate
方法进行验证:
public function __construct(public string $value)
{
// 验证值
$this->validate();
}
private function validate(): void
{
// 执行具体的验证操作
}
使用 Serde PHP 反序列化器时,您可以添加 #[PostLoad]
属性来在实例化后调用 validate
方法。
这是因为反序列化器通常使用 ReflectionClass::newInstanceWithoutConstructor()
方法来创建对象,该方法不会调用构造函数。
4. 易于调试(可调试性)
为值对象提供一种简单的自我调试方法是良好的实践。对于简单值对象,__toString()
方法通常就足够了。对于复合值对象(具有大量属性或内部其他值对象)的情况,toArray()
方法是一个不错的选择。否则,可以使用序列化器。
final readonly class Name
{
public function __construct(public string $value) {}
public function __toString(): string
{
return $this->value;
}
}
final readonly class Surname
{
public function __construct(public string $value) {}
public function __toString(): string
{
return $this->value;
}
}
final readonly class Person
{
public function __construct(
public Name $name,
public Surname $surname
){}
public function __toString(): string
{
return "{$this->name} {$this->surname}";
}
public function toArray(): array
{
return [
'name' => (string)$this->name,
'surname' => (string)$this->surname
];
}
}
在 PHP 8.2 中使用值对象模式可以显著提高代码质量,使其更加健壮和可维护。通过将类视为类型,并关注不变性和始终有效的数据,开发人员可以创建更稳定、更有弹性的应用程序。值对象还可以改善代码的外观、简化开发过程,并为更具可扩展性和防错性的代码库奠定基础。
发表评论