PHP 8 值对象进阶之路

admin 2023-12-16 150 阅读 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 类型和联合类型都是有价值的工具,可以灵活地定制您的策略。关键是选择一种与您的项目理念无缝契合的方法。

发表评论

快捷回复: 表情:
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 条评论, 150人围观)