PHP 8中的值对象:构建更优雅、更可靠的代码

admin 2023-12-13 35 阅读 0评论

在编码世界中,保持代码整洁和强大是至关重要的。值对象模式是一种可以显着提高代码质量的设计模式,使其更加健壮和可维护。

在本文中,我将解释如何实现该模式,以及如何使用 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 中使用值对象模式可以显著提高代码质量,使其更加健壮和可维护。通过将类视为类型,并关注不变性和始终有效的数据,开发人员可以创建更稳定、更有弹性的应用程序。值对象还可以改善代码的外观、简化开发过程,并为更具可扩展性和防错性的代码库奠定基础。

发表评论

快捷回复: 表情:
Addoil Applause Badlaugh Bomb Coffee Fabulous Facepalm Feces Frown Heyha Insidious KeepFighting NoProb PigHead Shocked Sinistersmile Slap Social Sweat Tolaugh Watermelon Witty Wow Yeah Yellowdog
提交
评论列表 (有 0 条评论, 35人围观)