PHP 8 值对象进阶之路

admin 2023-12-16 479 阅读 0评论

之前的文章中,我们探讨了值对象在提高代码质量、系统稳健性和最大限度地减少广泛验证的需求方面的强大功能。现在,让我们更深入地了解和使用这个重要工具。

不同类型的值对象

在处理值对象时,根据其复杂性将它们分为不同的类型。根据我的经验,我确定了三种主要类型:

  • 简单值对象
  • 复杂值对象
  • 复合值对象

简单值对象

简单值对象封装单个值,通常表示域内的原始值或基本概念。这些对象非常适合简单的属性或测量。

我们以之前那篇文章中介绍的值对象 Age 为例:

readonly final class Age 
{
     public function __construct(public int $value)
     {
          $this->validate();
     }

     public function validate(): void
     {
          ($this->value >= 18) 
              or throw InvalidAge::adultRequired($this->value);

          ($this->value <= 120) 
              or throw InvalidAge::matusalem($this->value);
     }

     public function __toString(): string 
     {
          return (string)$this->value;
     }

     public function equals(Age $age): bool 
     {
         return $age->value === $this->value;
     }
}

我们可以看到,Age 是一个简单的值对象,表示一个人的年龄。它封装了单个整数值,并包含验证机制以确保年龄落在合理的范围内。

Age 的构造函数接受一个整数值作为参数,并将其存储在 value 属性中。validate() 方法验证 age 是否在 18 岁以上和 120 岁以下。

__toString() 方法将 age 转换为字符串。equals() 方法比较两个 Age 对象是否相等。

创建简单值对象时,应遵循以下准则:

  • 单一职责:值对象应专注于表示单个概念或属性,通常对应于原始值。
  • 不变性:简单值对象一旦创建,就应保持不变。任何更改都应导致创建新实例。
  • 验证:在构造函数中包含验证逻辑,以确保对象始终处于有效状态。
  • 字符串表示:实现__toString方法,以便在需要时方便地将对象转换为字符串。
  • 相等性检查:提供equals方法,以比较两个实例是否相等。 遵循这些准则可以帮助您创建简单值对象,从而提高代码的清晰度、稳定性和可靠性。

复杂值对象

简单值对象封装单个值,而复杂值对象则封装更复杂的结构或多个属性,在您的领域模型中形成更丰富的表示。这些对象非常适合对复杂概念或数据聚合进行建模。

例如坐标值对象:

readonly final class Coordinates 
{
     public function __construct(
          public float $latitude,
          public float $longitude
     )
     {
          $this->validate();
     }

     private function validate(): void
     {
          ($this->latitude >= -90 && $this->latitude <= 90) 
              or throw InvalidCoordinates::invalidLatitude($this->latitude);

          ($this->longitude >= -180 && $this->longitude <= 180) 
               or throw InvalidCoordinates::invalidLongitude($this->longitude);
     }

     public function __toString(): string 
     {
          return "Latitude: {$this->latitude}, Longitude: {$this->longitude}";
     }

     public function equals(Coordinates $coordinates): bool 
     {
          return $coordinates->latitude === $this->latitude
              && $coordinates->longitude === $this->longitude;
     }
}

坐标值对象是一个复杂值对象,它表示具有纬度和经度的地理坐标。构造函数确保对象的有效性,验证纬度在 [-90, 90] 范围内,经度在 [-180, 180] 范围内。该__toString方法提供了可读的字符串表示形式,并且该equals方法比较两个 Coords 对象是否相等。

创建复杂值对象时,应遵循以下准则:

  • 结构化表示:对对象进行建模以反映相应领域概念的复杂性和结构。例如,地理坐标对象可以包含纬度和经度属性。
  • 验证:在构造函数中实现验证逻辑,以确保对象始终处于有效状态。例如,地理坐标对象可以验证纬度和经度值是否在有效范围内。
  • 字符串表示:提供一个有意义的__toString方法,以提高可读性和调试性。例如,地理坐标对象可以将纬度和经度值转换为字符串。
  • 相等性检查:提供一种equals方法比较两个实例是否相等。例如,地理坐标对象可以比较两个对象的纬度和经度值是否相等。 遵循这些准则可以帮助您创建复杂值对象,这些对象可以有效地表示应用程序中的复杂概念。

复杂值对象通常需要更详细的检查,不仅涉及对象的属性,还涉及属性之间的关系。例如,地理坐标对象可以验证纬度和经度值是否在同一半球中。

一个价格范围类的示例:

readonly final class PriceRange
{
    public function __construct(
        public int $priceFrom,
        public int $priceTo
    ) {
        $this->validate();
    }

    private function validate(): void
    {
        ($this->priceTo >= 0) 
            or throw InvalidPriceRange::positivePriceTo($this->priceTo);

        ($this->priceFrom >= 0) 
            or throw InvalidPriceRange::positivePriceFrom($this->priceFrom);

        ($this->priceTo >= $this->priceFrom) 
            or throw InvalidPriceRange::endBeforeStart($this->priceFrom, $this->priceTo);
    }

    // ...(rest of the methods)
}

在这种情况下,即使每个价格本身都有效,我们也需要确保 priceTo 必须在 priceFrom 之后或与其相同。

复合值对象

复合值对象是一种强大的结构,它将多个简单或复杂的值对象组合成一个有凝聚力的单元,以表示域中更复杂的概念。这使您可以构建丰富且有意义的抽象。

例如,一个地址值对象可以由街道、城市和邮政编码等简单值对象组成。这些子对象一起形成更全面的地址表示。

以下是一个使用地址值对象的示例:

readonly final class Address 
{
    public function __construct(
        public Street $street,
        public City $city,
        public PostalCode $postalCode
    ) {}

    public function __toString(): string 
    {
        return "{$this->street}, {$this->city}, {$this->postalCode}";
    }

    public function equals(Address $address): bool 
    {
        return $address->street->equals($this->street)
            && $address->city->equals($this->city)
            && $address->postalCode->equals($this->postalCode);
    }
}

在这个示例中,街道、城市和邮政编码属性分别封装了街道、城市和邮政编码值。这些属性一起形成了一个更全面的地址表示。

__toString方法提供了可读的字符串表示形式,并且该equals方法比较两个地址对象是否相等。

在创建复合值对象时,应遵循以下准则:

  • 组合:将多个简单或复杂的值对象组合成一个更复杂的结构。
  • 抽象:使用复合结构表示领域内的复杂概念。
  • 字符串表示:提供一个有意义的__toString方法,以提高可读性和调试性。
  • 相等性检查:提供一种equals方法比较两个实例是否相等。 在许多情况下,验证复合值对象是不需要的,因为其有效性已由其组件确保。但是,在某些情况下,可能需要跨对象的不同属性进行验证。在这种情况下,验证是必要的。

工厂方法和私有构造函数

在前面的示例中,我们探讨了相对简单的值对象。但是,在日常开发中,价值对象的内部表示与其外部表示不同时,会带来挑战。

例如,日期“2023 年 12 月 24 日,下午 4:09:53,罗马时区”可以用多种方式表示,例如自 1970 年 1 月 1 日以来的秒数,或作为 RFC3339 字符串。

由于 PHP 缺乏构造函数重载,不像 Java 或 C# 等语言那样灵活,采用工厂方法模式就变得非常有价值。这种模式通过静态方法,以受控的方式创建 DateTimeValueObject 实例,确保其内部状态 zawsze 始终有效。

让我们以 DateTimeValueObject 为例进行详细探讨:

class DateTimeValueObject
{
  private DateTimeImmutable $dateTime;

  private function __construct(DateTimeImmutable $dateTime)
  {
    $this->dateTime = $dateTime;
  }

  // 静态工厂方法:从时间戳创建
  public static function createFromTimestamp(int $timestamp): self
  {
    if ($timestamp < 0) {
      throw new InvalidDateTime::invalidTimestamp($timestamp);
    }

    $dateTime = new DateTimeImmutable();
    $dateTime = $dateTime->setTimestamp($timestamp);

    return new self($dateTime);
  }

  // 静态工厂方法:从 RFC3339 字符串创建
  public static function createFromRFC3339(string $dateTimeString): self
  {
    $dateTime = DateTimeImmutable::createFromFormat(DateTime::RFC3339, $dateTimeString);

    if ($dateTime === false) {
      throw new InvalidDateTime::invalidRFC3339String($dateTimeString);
    }

    return new self($dateTime);
  }

  // 静态工厂方法:从各个部分创建
  public static function createFromParts(
    int $year,
    int $month,
    int $day,
    int $hour,
    int $minute,
    int $second,
    string $timezone
  ): self
  {
    if (!checkdate($month$day$year) || !self::isValidTime($hour$minute$second)) {
      throw new InvalidDateTime::invalidDateParts(...); // 省略参数列表
    }

    $dateTime = new DateTimeImmutable();
    $dateTime = $dateTime
      ->setDate($year$month$day)
      ->setTime($hour$minute$second)
      ->setTimezone(new DateTimeZone($timezone));

    return new self($dateTime);
  }

  // 私有方法:验证时间合法性
  private static function isValidTime(int $hour, int $minute, int $second): bool
  {
    return (0 <= $hour && $hour <= 23) && (0 <= $minute && $minute <= 59) && (0 <= $second && $second <= 59);
  }

  // 静态工厂方法:获取当前时刻
  public static function now(): self
  {
    return new self(new DateTimeImmutable());
  }

  // 获取内部 DateTimeImmutable 对象
  public function getDateTime(): DateTimeImmutable
  {
    return $this->dateTime;
  }

  // 其他方法:__toString、equals 等

  // 使用示例
  $dateTime1 = DateTimeValueObject::createFromTimestamp(1703430593);
  $dateTime2 = DateTimeValueObject::createFromRFC3339('2023-12-24T16:09:53+01:00');
  $dateTime3 = DateTimeValueObject::createFromParts(2023, 12, 24, 16, 9, 53, 'Europe/Rome');
  $dateTime4 = DateTimeValueObject::now();
}

让我们看看一些细节:

DateTimeValueObject 类用于表示日期和时间值。它提供了一系列静态工厂方法来创建实例,以及用于获取内部 DateTimeImmutable 对象的方法。

DateTimeValueObject 的构造函数是私有的,这意味着只能在类内部使用。这是为了确保实例只能通过工厂方法创建。

DateTimeValueObject 提供了四个静态工厂方法来创建实例:

  • createFromTimestamp() 方法从时间戳创建实例。
  • createFromRFC3339() 方法从 RFC3339 字符串创建实例。
  • createFromParts() 方法从各个部分创建实例。
  • now() 方法创建表示当前时刻的实例。 所有工厂方法都包含输入验证,以确保创建的日期和时间值有效。

所有工厂方法都包含输入验证,以确保创建的日期和时间值有效。具体来说:

  • createFromTimestamp() 方法会验证时间戳是否为非负数。
  • createFromRFC3339() 方法会验证 RFC3339 字符串是否有效。
  • createFromParts() 方法会验证日期和时间的各个部分是否有效。
  • now() 方法会使用 DateTimeImmutable::now() 方法获取当前时刻。 输入验证有助于确保 DateTimeValueObject 实例的一致性和可靠性。

除了工厂方法之外,DateTimeValueObject 类还提供了以下方法:

  • getDateTime():获取内部 DateTimeImmutable 对象。
  • __toString():返回日期和时间的字符串表示形式。
  • equals():比较两个 DateTimeValueObject 对象是否相等。

工厂方法是一种创建对象的常见方法,它可以用于简化复合值对象的实例化。复合值对象是指由多个值对象组成的对象。

例如,以下代码定义了一个地址值对象:

readonly final class Address 
{
    private function __construct(
        public Street $street,
        public City $city,
        public PostalCode $postalCode
    ) {}

    public static function create(
        string $street
        string $city
        string $postalCode
    ): Address 
    {
        return new Address(
            new Street($street),
            new City($city),
            new PostalCode($postalCode)
        );
    }

    // ... (rest of the methods)
}

该类定义了三个属性:StreetCity 和 PostalCodecreate() 工厂方法用于创建新的地址对象。该方法接受三个参数,分别用于设置每个属性的值。

在 PHP 8 中,我们可以使用一个非常酷的技巧来简化复合值对象的实例化。例如,以下代码使用 ... 运算符来传递一个数组作为 create() 方法的参数:

$data = [
   'street'     => 'Via del Colosseo, 10',
   'city'       => 'Rome',
   'postalCode' => '12345'
];

$address = Address::create(...$data);

这段代码使用 data 数组中的值来设置 street、city 和 postalCode 属性的值。使用 ... 运算符可以使复合值对象的实例化更加简洁和富有表现力。

异常的替代方案

对于那些对使用异常持保留态度的人,有几种替代方案可供选择。一种方法是使用返回值。例如,如果某个函数可能会失败,则可以返回一个 布尔值 或 枚举值 来指示成功或失败。

另一种方法是使用Either 类型。Either 类型是一种可以包含两种值的类型:正确值 或 错误值。如果某个函数可能会失败,则可以返回一个 Either 类型的值。

在简化的实现中,Either 类型可以如下所示:

/**
 * @template L
 * @template R
 */
final class Either
{
    /**
     * @param bool $isRight
     * @param L|R $value
     */
    private function __construct(private bool $isRight, private mixed $value)
    {
    }

    /**
     * @param L $value
     * @return Either<L, R>
     */
    public static function left(mixed $value): Either
    {
        return new self(false$value);
    }

    /**
     * @param R $value
     * @return Either<L, R>
     */
    public static function right(mixed $value): Either
    {
        return new self(true$value);
    }

    /**
     * @return bool
     */
    public function isRight(): bool
    {
        return $this->isRight;
    }

    /**
     * @return L|R
     */
    public function getValue(): mixed
    {
        return $this->value;
    }
}

我们可以将 Either 类型应用到值对象的创建中,如下所示:

readonly final class Address 
{
   // ... (rest of the methods)

    /**
     * @returns Either<InvalidValue,Address>
     */
    public static function create(
        string $street
        string $city
        string $postalCode
    ): Either
    {
        try {
            return Either::right(new Address(
                new Street($street),
                new City($city),
                new PostalCode($postalCode)
            ));
        } catch (InvalidValue $error) {
           return Either::left($error);
        } 
    }

    // __toString & equals methods
}

我们可以使用以下代码来处理 Either 类型的结果:

$address = Address::create('''''');

if ($address->isRight()) {
   // do stuff in case of success

else {
   // do stuff in case of error

   /** @var InvalidValue $error */
   $error = $address->getValue();

   echo "Error: {$error->getMessage()}";
}

这种方法提供了一种灵活的方法来管理结果,为成功和错误场景提供不同的处理路径。

虽然 Either 类型提供了一种灵活的方法来管理结果,但它也有一些缺点:

  • 由于 PHP 缺乏泛型,因此 Either 类型的实现需要使用大量的静态分析工具,例如 PSalm 或 PHPStan。这可能会使代码变得复杂且难以维护。
  • Either 类型的实现不支持类型推断,这可能会导致错误。

联合类型

PHP 8.0 引入了联合类型的概念,允许一个变量或参数可以是多种类型之一。例如,以下代码定义了一个 Address 类,其 create() 方法返回一个 InvalidValue 或 Address 类型的值:

readonly final class Address 
{
   // ... (rest of the methods)

    public static function create(
        string $street,
        string $city
        string $postalCode
    ): InvalidValue|Address
    {
        try {
            return new Address(
                new Street($street),
                new City($city),
                new PostalCode($postalCode)
            );
        } catch (InvalidValue $error) {
           return $error;
        } 
    }

    // __toString & equals methods
}

我们可以使用以下代码来处理联合类型的值:

$address = Address::create('''''');

if ($address instanceof InvalidValue) {
   // do stuff in case of error

   echo "Error: {$address->getMessage()}";

else {
   // do stuff in case of success
}

该代码使用 instanceof 运算符来检查 address 变量的类型。如果 address 变量是 InvalidValue 类型,则代码执行错误处理逻辑。如果 address 变量是 Address 类型,则代码执行成功处理逻辑。

当谈到处理 PHP 中的错误时,没有一种万能的解决方案。使用 Either 类型和联合类型之间的决定取决于您项目的具体需求。

Either 类型提供了一种精细的方法,使您能够明确管理不同的结果。它强调结构化且明确的错误处理策略。联合类型利用语言的内置功能并简化语法。这种方法可能更符合“让它快速失败”的理念,直接在错误发生的地方进行处理。

在 PHP 中选择正确的错误处理方法需要仔细考虑项目的上下文和需求。Either 类型和联合类型都是有价值的工具,可以灵活地定制您的策略。关键是选择一种与您的项目理念无缝契合的方法。

喜欢就支持以下吧
点赞 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 条评论, 479人围观)

最近发表

热门文章

最新留言

热门推荐

标签列表