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

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

喜欢就支持以下吧
点赞 0

发表评论

快捷回复: 表情:
aoman baiyan bishi bizui cahan ciya dabing daku deyi doge fadai fanu fendou ganga guzhang haixiu hanxiao zuohengheng zhuakuang zhouma zhemo zhayanjian zaijian yun youhengheng yiwen yinxian xu xieyanxiao xiaoku xiaojiujie xia wunai wozuimei weixiao weiqu tuosai tu touxiao tiaopi shui se saorao qiudale qinqin qiaoda piezui penxue nanguo liulei liuhan lenghan leiben kun kuaikule ku koubi kelian keai jingya jingxi jingkong jie huaixiao haqian aini OK qiang quantou shengli woshou gouyin baoquan aixin bangbangtang xiaoyanger xigua hexie pijiu lanqiu juhua hecai haobang caidao baojin chi dan kulou shuai shouqiang yangtuo youling
提交
评论列表 (有 0 条评论, 447人围观)

最近发表

热门文章

最新留言

热门推荐

标签列表