PHP Readonly Properties与Domain Model:构建不可变核心业务对象的实践
大家好,今天我们来深入探讨PHP的readonly properties与Domain Model结合,构建不可变核心业务对象的实践。在软件开发中,尤其是涉及复杂的业务逻辑时,保持数据的完整性和一致性至关重要。不可变对象为我们提供了一种强大的工具,可以有效地防止数据在生命周期中被意外修改,从而提高代码的可靠性和可维护性。
1. 什么是Domain Model?
首先,我们需要理解什么是Domain Model。简单来说,Domain Model是软件系统对现实世界业务领域的抽象表示。它包含了一系列的对象(实体、值对象等),以及它们之间的关系,这些对象共同描述了业务规则和流程。
例如,在一个电商系统中,Product(商品)、Order(订单)、Customer(顾客)等都可以被认为是Domain Model中的对象。每个对象都具有自己的属性和行为,这些属性和行为共同构成了该对象在业务领域中的含义。
Domain Model 的重要性:
- 业务逻辑集中化: 将业务逻辑封装在模型内部,避免逻辑分散在Controller或其他地方,提高代码的可读性和可维护性。
- 领域驱动设计 (DDD): Domain Model是DDD的核心,通过清晰地定义领域模型,可以更好地理解业务需求,并将其转化为高质量的代码。
- 可测试性: 结构良好的Domain Model更容易进行单元测试,因为每个对象都相对独立,易于隔离和验证。
2. 为什么需要不可变对象?
不可变对象是指一旦创建,其状态(属性值)就不能被修改的对象。 这种不变性带来了诸多好处:
- 线程安全: 不可变对象天生就是线程安全的,因为它们的状态不会被多个线程同时修改,避免了并发问题。
- 简化推理: 当我们使用不可变对象时,可以更容易地推理代码的行为。由于对象的状态不会改变,因此我们可以确定对象在任何时刻都具有特定的值。
- 缓存友好: 不可变对象更容易进行缓存,因为它们的值不会改变,可以放心地将它们存储在缓存中,而无需担心缓存失效。
- 防御性编程: 通过使用不可变对象,可以防止代码中其他部分意外地修改对象的状态,从而提高代码的健壮性。
- 更清晰的副作用: 不可变性使得函数和方法更容易理解,因为它们不会修改输入对象的状态。
3. PHP Readonly Properties 的作用
PHP 8.1 引入了 readonly 关键字,用于声明类的只读属性。 一旦 readonly 属性在构造函数中或属性声明时被赋值,之后就不能再被修改。
ReadOnly Properties 的优点:
- 强制不变性:
readonly关键字强制属性的不可变性,编译器会在运行时检查是否尝试修改只读属性,从而提供更强的保证。 - 代码清晰性: 明确地声明属性为只读,可以提高代码的可读性,让开发者更容易理解代码的意图。
- 减少错误: 避免意外修改属性值,减少程序出错的可能性。
示例:
<?php
class Product
{
public readonly int $id;
public readonly string $name;
public readonly float $price;
public function __construct(int $id, string $name, float $price)
{
$this->id = $id;
$this->name = $name;
$this->price = $price;
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getPrice(): float
{
return $this->price;
}
}
$product = new Product(123, 'Example Product', 99.99);
echo "Product ID: " . $product->id . "n";
echo "Product Name: " . $product->name . "n";
echo "Product Price: " . $product->price . "n";
// 尝试修改 readonly 属性会导致错误:
// Fatal error: Uncaught Error: Cannot modify readonly property Product::$id
// $product->id = 456;
在这个例子中,Product 类的 id、name 和 price 属性都被声明为 readonly。这意味着一旦 Product 对象被创建,这些属性的值就不能再被修改。 任何尝试修改这些属性的操作都会导致一个致命错误。
4. 将 Readonly Properties 应用于 Domain Model
将 readonly properties 应用于 Domain Model 可以帮助我们构建不可变的核心业务对象,从而提高代码的可靠性和可维护性。
示例:Order 类
<?php
class Order
{
public readonly int $id;
public readonly Customer $customer;
public readonly array $items; // array of OrderItem
public readonly DateTimeImmutable $orderDate;
public readonly string $status; // "pending", "processing", "shipped", "delivered", "cancelled"
public function __construct(int $id, Customer $customer, array $items, DateTimeImmutable $orderDate, string $status = "pending")
{
$this->id = $id;
$this->customer = $customer;
$this->items = $items;
$this->orderDate = $orderDate;
$this->status = $status;
}
public function getId(): int
{
return $this->id;
}
public function getCustomer(): Customer
{
return $this->customer;
}
public function getItems(): array
{
return $this->items;
}
public function getOrderDate(): DateTimeImmutable
{
return $this->orderDate;
}
public function getStatus(): string
{
return $this->status;
}
// 订单状态改变的方法,返回一个新的Order对象
public function withStatus(string $newStatus): Order
{
return new Order(
$this->id,
$this->customer,
$this->items,
$this->orderDate,
$newStatus
);
}
// 添加订单项,返回新的Order对象
public function withAddedItem(OrderItem $newItem): Order
{
$newItems = $this->items;
$newItems[] = $newItem;
return new Order(
$this->id,
$this->customer,
$newItems,
$this->orderDate,
$this->status
);
}
}
class OrderItem
{
public readonly int $productId;
public readonly int $quantity;
public readonly float $price;
public function __construct(int $productId, int $quantity, float $price)
{
$this->productId = $productId;
$this->quantity = $quantity;
$this->price = $price;
}
public function getProductId(): int
{
return $this->productId;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function getPrice(): float
{
return $this->price;
}
}
class Customer
{
public readonly int $id;
public readonly string $name;
public readonly string $email;
public function __construct(int $id, string $name, string $email)
{
$this->id = $id;
$this->name = $name;
$this->email = $email;
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getEmail(): string
{
return $this->email;
}
}
// 使用示例
$customer = new Customer(1, 'John Doe', '[email protected]');
$item1 = new OrderItem(101, 2, 25.00);
$item2 = new OrderItem(102, 1, 50.00);
$order = new Order(1, $customer, [$item1, $item2], new DateTimeImmutable());
echo "Order ID: " . $order->getId() . "n";
echo "Customer Name: " . $order->getCustomer()->getName() . "n";
echo "Order Date: " . $order->getOrderDate()->format('Y-m-d H:i:s') . "n";
echo "Order Status: " . $order->getStatus() . "n";
// 修改订单状态 (创建新的 Order 对象)
$updatedOrder = $order->withStatus("shipped");
echo "Original Order Status: " . $order->getStatus() . "n"; // 输出: pending
echo "Updated Order Status: " . $updatedOrder->getStatus() . "n"; // 输出: shipped
// 添加新的订单项 (创建新的 Order 对象)
$item3 = new OrderItem(103, 3, 10.00);
$updatedOrderWithItem = $order->withAddedItem($item3);
echo "Original Order Item Count: " . count($order->getItems()) . "n"; // 输出: 2
echo "Updated Order Item Count: " . count($updatedOrderWithItem->getItems()) . "n"; // 输出: 3
?>
在这个例子中,Order 类的所有属性都被声明为 readonly。 这意味着一旦 Order 对象被创建,它的 id、customer、items、orderDate 和 status 属性就不能再被修改。
需要注意的是,虽然 Order 对象本身是不可变的,但如果 items 属性包含可变对象(例如,没有使用 readonly properties 的 OrderItem 类),那么 Order 对象的状态仍然可能被间接修改。 为了确保完全的不可变性,我们需要确保所有相关的对象都是不可变的。
另外,修改订单状态或者添加订单项,我们并没有直接修改原有的 Order 对象,而是创建了一个新的 Order 对象,并将新的状态或订单项赋值给新的对象。 这种方式遵循了不可变对象的原则,保证了原有对象的状态不会被改变。 withStatus() 和 withAddedItem() 方法返回新的 Order 对象,保持了原始 Order 对象的不可变性。
关键点:
- 构造函数赋值:
readonly属性只能在构造函数或属性声明时赋值。 - 深拷贝: 当属性包含对象时,需要考虑深拷贝,确保内部对象也是不可变的。 在上面的例子中,
Customer和DateTimeImmutable对象也是不可变的。 如果Customer是可变的,那么需要创建Customer的一个副本,以保证Order的不可变性。 - 集合处理: 当属性是集合(例如,数组)时,需要返回新的集合,而不是修改原始集合。 上面的
withAddedItem方法创建了一个新的$newItems数组,并将新的OrderItem添加到新的数组中,然后用新的数组创建一个新的Order对象。
5. 值对象 (Value Object) 与不可变性
值对象是 Domain Model 中另一个重要的概念。 值对象是根据其属性值来定义的,而不是根据其身份来定义的。 例如,Address(地址)、Money(金额)等都可以被认为是值对象。
值对象通常是不可变的,因为它们的值一旦确定,就不应该再被修改。
示例:Money 类
<?php
class Money
{
public readonly float $amount;
public readonly string $currency;
public function __construct(float $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}
public function getAmount(): float
{
return $this->amount;
}
public function getCurrency(): string
{
return $this->currency;
}
public function add(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException("Currencies must match.");
}
return new Money($this->amount + $other->amount, $this->currency);
}
public function subtract(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException("Currencies must match.");
}
return new Money($this->amount - $other->amount, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
public function __toString(): string
{
return $this->amount . ' ' . $this->currency;
}
}
// 使用示例
$amount1 = new Money(100.00, 'USD');
$amount2 = new Money(50.00, 'USD');
$sum = $amount1->add($amount2);
echo "Sum: " . $sum . "n"; // 输出: Sum: 150 USD
$difference = $amount1->subtract($amount2);
echo "Difference: " . $difference . "n"; // 输出: Difference: 50 USD
$amount3 = new Money(100.00, 'USD');
echo "Amount1 equals Amount3: " . ($amount1->equals($amount3) ? 'true' : 'false') . "n"; // 输出: true
// 尝试修改 amount 属性会导致错误:
// Fatal error: Uncaught Error: Cannot modify readonly property Money::$amount
// $amount1->amount = 200.00;
?>
在这个例子中,Money 类的 amount 和 currency 属性都被声明为 readonly。 这确保了 Money 对象一旦创建,其值就不能再被修改。 add() 和 subtract() 方法返回新的 Money 对象,而不是修改原始对象。
6. 使用 Data Transfer Objects (DTOs) 初始化 Domain Model
在实际应用中,Domain Model 对象的数据通常来自外部,例如数据库、API 等。 为了将数据从外部源传递到 Domain Model 对象,我们可以使用 Data Transfer Objects (DTOs)。
DTOs 是一些简单的对象,用于携带数据。 它们通常只包含一些公共属性,用于存储数据,而没有复杂的业务逻辑。
示例:
<?php
// DTO
class ProductData
{
public int $id;
public string $name;
public float $price;
}
// Domain Model
class Product
{
public readonly int $id;
public readonly string $name;
public readonly float $price;
public function __construct(int $id, string $name, float $price)
{
$this->id = $id;
$this->name = $name;
$this->price = $price;
}
public static function fromData(ProductData $data): Product
{
return new Product($data->id, $data->name, $data->price);
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getPrice(): float
{
return $this->price;
}
}
// 使用示例
$productData = new ProductData();
$productData->id = 123;
$productData->name = 'Example Product';
$productData->price = 99.99;
$product = Product::fromData($productData);
echo "Product ID: " . $product->getId() . "n";
echo "Product Name: " . $product->getName() . "n";
echo "Product Price: " . $product->getPrice() . "n";
?>
在这个例子中,ProductData 是一个 DTO,用于携带从外部源获取的商品数据。 Product 类有一个静态方法 fromData(),用于从 ProductData 对象创建一个 Product 对象。 这样可以将数据转换逻辑封装在 Product 类中,并保持 Product 对象的不可变性。
使用 DTO 的好处:
- 解耦: 将数据传输逻辑与 Domain Model 解耦,使得 Domain Model 更加独立和可测试。
- 数据验证: 可以在 DTO 中进行数据验证,确保传递到 Domain Model 的数据是有效的。
- 灵活性: 可以根据需要修改 DTO 的结构,而无需修改 Domain Model。
7. 总结
通过结合 PHP 的 readonly properties 和 Domain Model,我们可以构建不可变的核心业务对象,从而提高代码的可靠性、可维护性和可测试性。 不可变对象可以简化推理、提高线程安全性和缓存友好性。 在设计 Domain Model 时,应该尽可能地将对象设计为不可变的,并使用值对象来表示不可变的数据。 使用 DTOs 可以将数据从外部源传递到 Domain Model 对象,并保持 Domain Model 的独立性。
8. 采用不可变对象构建可靠系统
利用 PHP 的 Readonly Properties,结合领域模型的设计原则,我们可以更好地构建健壮、可维护、可测试的应用程序。在核心业务逻辑中使用不可变对象,可以有效降低bug出现的概率,提升代码的整体质量。