PHP 8 值对象进阶之路
在之前的文章中,我们探讨了值对象在提高代码质量、系统稳健性和最大限度地减少广泛验证的需求方面的强大功能。现在,让我们更深入地了解和使用这个重要工具。
不同类型的值对象
在处理值对象时,根据其复杂性将它们分为不同的类型。根据我的经验,我确定了三种主要类型:
简单值对象 复杂值对象 复合值对象
简单值对象
简单值对象封装单个值,通常表示域内的原始值或基本概念。这些对象非常适合简单的属性或测量。
我们以之前那篇文章中介绍的值对象 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)
}
该类定义了三个属性:Street
、City
和 PostalCode
。create()
工厂方法用于创建新的地址对象。该方法接受三个参数,分别用于设置每个属性的值。
在 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 类型和联合类型都是有价值的工具,可以灵活地定制您的策略。关键是选择一种与您的项目理念无缝契合的方法。
发表评论