PHP领域驱动设计(DDD):实体、值对象、聚合根在Laravel/Symfony中的落地
大家好!今天我们来聊聊领域驱动设计(DDD)在PHP,特别是Laravel和Symfony框架中的落地实践。DDD 是一种软件开发方法,它强调以业务领域为中心,通过对业务领域的深入理解,构建出更贴近业务、更易于维护和扩展的软件系统。
DDD 的核心概念包括实体(Entity)、值对象(Value Object)和聚合根(Aggregate Root)。理解这些概念并正确地应用它们,是实践 DDD 的关键。
1. 实体(Entity)
实体是具有唯一标识的对象,它的生命周期与其标识相关。即使实体的属性发生变化,它仍然是同一个实体。例如,一个用户(User)、一个订单(Order)等。
特点:
- 唯一标识: 实体必须具有一个唯一标识,通常是 ID。
- 可变性: 实体的状态可以改变。
- 生命周期: 实体的生命周期与其唯一标识相关。
Laravel/Symfony 中的落地:
在 Laravel 和 Symfony 中,实体通常对应于数据库中的一条记录,并且使用 Eloquent ORM (Laravel) 或 Doctrine ORM (Symfony) 进行持久化。
示例(Laravel):
<?php
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
class User extends Model
{
use HasFactory;
protected $table = 'users';
protected $primaryKey = 'id';
public $timestamps = true; // 启用时间戳 created_at 和 updated_at
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
];
public function changePassword(string $newPassword): void
{
// 业务逻辑:修改密码
$this->password = bcrypt($newPassword);
$this->save();
}
}
说明:
User类继承自IlluminateDatabaseEloquentModel,表示一个实体。$primaryKey属性定义了实体的唯一标识。changePassword()方法封装了修改密码的业务逻辑。 注意,这里直接在实体内部修改密码并保存,体现了实体负责维护自身状态的原则。
示例(Symfony):
<?php
namespace AppEntity;
use DoctrineORMMapping as ORM;
use SymfonyComponentSecurityCoreUserPasswordAuthenticatedUserInterface;
use SymfonyComponentSecurityCoreUserUserInterface;
#[ORMEntity(repositoryClass: UserRepository::class)]
#[ORMTable(name: 'users')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORMId]
#[ORMGeneratedValue]
#[ORMColumn]
private ?int $id = null;
#[ORMColumn(length: 180, unique: true)]
private ?string $email = null;
#[ORMColumn]
private ?string $password = null;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
return ['ROLE_USER']; // 默认角色
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
}
说明:
User类使用 Doctrine ORM 的注解进行映射。#[ORMId]、#[ORMGeneratedValue]和#[ORMColumn]等注解定义了数据库字段和属性之间的映射关系。setPassword()方法修改密码的业务逻辑。同样,实体负责自身状态的维护。
2. 值对象(Value Object)
值对象是没有唯一标识的对象,它的相等性完全由它的属性值决定。如果两个值对象的所有属性值都相同,则认为它们是相等的。例如,一个地址(Address)、一个货币金额(Money)等。
特点:
- 没有唯一标识: 值对象没有 ID。
- 不可变性: 值对象一旦创建,其状态就不能改变。如果需要改变值对象的状态,应该创建一个新的值对象。
- 相等性: 值对象的相等性由其属性值决定。
Laravel/Symfony 中的落地:
在 Laravel 和 Symfony 中,值对象通常被实现为一个简单的类,并且使用 __construct() 方法来初始化其属性。
示例(Laravel):
<?php
namespace AppValueObjects;
class Address
{
private string $street;
private string $city;
private string $state;
private string $zipCode;
public function __construct(string $street, string $city, string $state, string $zipCode)
{
$this->street = $street;
$this->city = $city;
$this->state = $state;
$this->zipCode = $zipCode;
}
public function getStreet(): string
{
return $this->street;
}
public function getCity(): string
{
return $this->city;
}
public function getState(): string
{
return $this->state;
}
public function getZipCode(): string
{
return $this->zipCode;
}
public function equals(Address $other): bool
{
return $this->street === $other->street &&
$this->city === $other->city &&
$this->state === $other->state &&
$this->zipCode === $other->zipCode;
}
public function __toString(): string
{
return $this->street . ', ' . $this->city . ', ' . $this->state . ' ' . $this->zipCode;
}
}
说明:
Address类表示一个地址值对象。- 所有的属性都是
private的,并且没有setter方法,保证了值对象的不可变性。 equals()方法用于比较两个Address对象是否相等。__toString()方法用于将Address对象转换为字符串。
示例(Symfony):
<?php
namespace AppValueObject;
use InvalidArgumentException;
class Money
{
private int $amount;
private string $currency;
public function __construct(int $amount, string $currency)
{
if ($amount < 0) {
throw new InvalidArgumentException('Amount cannot be negative.');
}
if (strlen($currency) !== 3) {
throw new InvalidArgumentException('Currency must be a 3-letter code.');
}
$this->amount = $amount;
$this->currency = strtoupper($currency);
}
public function getAmount(): int
{
return $this->amount;
}
public function getCurrency(): string
{
return $this->currency;
}
public function add(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Cannot add Money objects with different currencies.');
}
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;
}
}
说明:
Money类表示一个货币金额值对象。- 构造函数进行了参数验证,保证了数据的有效性。
add()方法返回一个新的Money对象,而不是修改当前对象,保证了不可变性。
3. 聚合根(Aggregate Root)
聚合根是一个特殊的实体,它是聚合的根节点,负责维护聚合内部的一致性。聚合根是外部访问聚合的唯一入口,所有对聚合的操作都必须通过聚合根进行。例如,一个订单(Order)可能包含多个订单项(OrderItem),那么订单就可以作为聚合根,而订单项则是聚合内部的实体或值对象。
特点:
- 聚合的根节点: 聚合根是聚合的入口点。
- 维护聚合一致性: 聚合根负责维护聚合内部的一致性。
- 唯一访问入口: 外部只能通过聚合根访问聚合。
Laravel/Symfony 中的落地:
在 Laravel 和 Symfony 中,聚合根通常对应于一个主要的业务实体,例如订单、用户等。聚合根负责控制对聚合内部其他实体和值对象的访问和修改。
示例(Laravel):
假设我们有一个 Order 聚合,包含 Order 实体(聚合根)、OrderItem 实体和 Address 值对象。
<?php
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
use AppValueObjectsAddress;
use AppExceptionsInvalidOrderStateException;
class Order extends Model
{
use HasFactory;
protected $table = 'orders';
protected $primaryKey = 'id';
public $timestamps = true;
protected $fillable = [
'customer_id',
'shipping_address_street', // 为了持久化方便,直接存储地址信息
'shipping_address_city',
'shipping_address_state',
'shipping_address_zip_code',
'status', // 订单状态,例如:pending, processing, shipped, completed, cancelled
];
// 定义订单状态常量
const STATUS_PENDING = 'pending';
const STATUS_PROCESSING = 'processing';
const STATUS_SHIPPED = 'shipped';
const STATUS_COMPLETED = 'completed';
const STATUS_CANCELLED = 'cancelled';
public function orderItems()
{
return $this->hasMany(OrderItem::class);
}
public function getShippingAddress(): Address
{
return new Address(
$this->shipping_address_street,
$this->shipping_address_city,
$this->shipping_address_state,
$this->shipping_address_zip_code
);
}
public function setShippingAddress(Address $address): void
{
$this->shipping_address_street = $address->getStreet();
$this->shipping_address_city = $address->getCity();
$this->shipping_address_state = $address->getState();
$this->shipping_address_zip_code = $address->getZipCode();
}
public function addItem(Product $product, int $quantity): void
{
// 业务逻辑:添加订单项
$existingItem = $this->orderItems()->where('product_id', $product->id)->first();
if ($existingItem) {
$existingItem->quantity += $quantity;
$existingItem->save();
} else {
$orderItem = new OrderItem([
'product_id' => $product->id,
'quantity' => $quantity,
]);
$this->orderItems()->save($orderItem);
}
// 维护订单状态 (例如,如果订单之前是草稿状态)
if ($this->status === self::STATUS_PENDING) {
$this->status = self::STATUS_PROCESSING; // 添加商品后,修改为 processing
$this->save();
}
}
public function cancel(): void
{
// 业务逻辑:取消订单
if ($this->status !== self::STATUS_PENDING && $this->status !== self::STATUS_PROCESSING) {
throw new InvalidOrderStateException("Can only cancel orders that are pending or processing.");
}
$this->status = self::STATUS_CANCELLED;
$this->save();
}
// 其他业务逻辑方法,例如:ship(), complete() 等
}
<?php
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
class OrderItem extends Model
{
use HasFactory;
protected $table = 'order_items';
protected $fillable = [
'product_id',
'quantity',
];
public function product()
{
return $this->belongsTo(Product::class);
}
}
说明:
Order类是聚合根,负责维护订单的状态和一致性。getShippingAddress()和setShippingAddress()方法用于获取和设置订单的收货地址,使用了Address值对象。addItem()方法用于添加订单项,并维护订单的状态。cancel()方法用于取消订单,并检查订单状态是否允许取消。OrderItem实体是Order聚合内部的实体,不能直接从外部访问。 所有对 OrderItem 的操作都必须通过 Order 聚合根进行。
示例(Symfony):
<?php
namespace AppEntity;
use DoctrineCommonCollectionsArrayCollection;
use DoctrineCommonCollectionsCollection;
use DoctrineORMMapping as ORM;
use AppValueObjectAddress;
use AppExceptionInvalidOrderStateException;
#[ORMEntity(repositoryClass: OrderRepository::class)]
#[ORMTable(name: 'orders')]
class Order
{
#[ORMId]
#[ORMGeneratedValue]
#[ORMColumn]
private ?int $id = null;
#[ORMManyToOne(targetEntity: Customer::class)]
#[ORMJoinColumn(nullable: false)]
private ?Customer $customer = null;
#[ORMEmbedded(class: "AppValueObjectAddress", columnPrefix: "shipping_")]
private Address $shippingAddress;
#[ORMOneToMany(mappedBy: 'order', targetEntity: OrderItem::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $orderItems;
#[ORMColumn(length: 20)]
private string $status = self::STATUS_PENDING;
// 定义订单状态常量
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_SHIPPED = 'shipped';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
public function __construct(Customer $customer, Address $shippingAddress)
{
$this->customer = $customer;
$this->shippingAddress = $shippingAddress;
$this->orderItems = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function getShippingAddress(): Address
{
return $this->shippingAddress;
}
public function setShippingAddress(Address $shippingAddress): self
{
$this->shippingAddress = $shippingAddress;
return $this;
}
/**
* @return Collection<int, OrderItem>
*/
public function getOrderItems(): Collection
{
return $this->orderItems;
}
public function addItem(Product $product, int $quantity): self
{
$existingItem = null;
foreach ($this->orderItems as $item) {
if ($item->getProduct() === $product) {
$existingItem = $item;
break;
}
}
if ($existingItem) {
$existingItem->setQuantity($existingItem->getQuantity() + $quantity);
} else {
$orderItem = new OrderItem($this, $product, $quantity);
$this->orderItems->add($orderItem);
}
if ($this->status === self::STATUS_PENDING) {
$this->status = self::STATUS_PROCESSING;
}
return $this;
}
public function removeOrderItem(OrderItem $orderItem): self
{
if ($this->orderItems->removeElement($orderItem)) {
// set the owning side to null (unless already changed)
if ($orderItem->getOrder() === $this) {
$orderItem->setOrder(null);
}
}
return $this;
}
public function cancel(): void
{
if ($this->status !== self::STATUS_PENDING && $this->status !== self::STATUS_PROCESSING) {
throw new InvalidOrderStateException("Can only cancel orders that are pending or processing.");
}
$this->status = self::STATUS_CANCELLED;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
}
<?php
namespace AppEntity;
use DoctrineORMMapping as ORM;
#[ORMEntity]
#[ORMTable(name: 'order_items')]
class OrderItem
{
#[ORMId]
#[ORMGeneratedValue]
#[ORMColumn]
private ?int $id = null;
#[ORMManyToOne(targetEntity: Order::class, inversedBy: 'orderItems')]
#[ORMJoinColumn(nullable: false)]
private ?Order $order = null;
#[ORMManyToOne(targetEntity: Product::class)]
#[ORMJoinColumn(nullable: false)]
private ?Product $product = null;
#[ORMColumn]
private int $quantity;
public function __construct(Order $order, Product $product, int $quantity)
{
$this->order = $order;
$this->product = $product;
$this->quantity = $quantity;
}
public function getId(): ?int
{
return $this->id;
}
public function getOrder(): ?Order
{
return $this->order;
}
public function setOrder(?Order $order): self
{
$this->order = $order;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): self
{
$this->product = $product;
return $this;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): self
{
$this->quantity = $quantity;
return $this;
}
}
说明:
Order类是聚合根,负责维护订单的状态和一致性。#[ORMEmbedded]注解用于将Address值对象嵌入到Order实体中。addItem()方法用于添加订单项,并维护订单的状态。cancel()方法用于取消订单,并检查订单状态是否允许取消。OrderItem实体是Order聚合内部的实体,不能直接从外部访问。所有对 OrderItem 的操作都必须通过 Order 聚合根进行。 注意OrderItem的构造函数必须接受Order实例,确保每个OrderItem都属于一个Order聚合。
4. 在实际应用中落地 DDD 的一些建议
- 从领域建模开始: 在开始编写代码之前,花时间与领域专家沟通,深入了解业务领域,并创建领域模型。
- 识别实体、值对象和聚合根: 仔细分析领域模型,识别出实体、值对象和聚合根。
- 设计聚合边界: 合理地设计聚合边界,确保聚合内部的一致性,并尽量避免跨聚合的事务。
- 使用贫血模型还是充血模型: 关于是否应该使用充血模型(即实体包含业务逻辑),存在一些争议。 虽然充血模型更符合 DDD 的原则,但在实际项目中,可以根据项目的复杂度和团队的经验进行选择。 如果项目比较简单,可以使用贫血模型,将业务逻辑放在服务层。 如果项目比较复杂,建议使用充血模型,将业务逻辑封装在实体内部。
- 使用领域事件: 领域事件用于在聚合之间进行异步通信,可以提高系统的可扩展性和灵活性。
- 编写测试: 编写单元测试和集成测试,确保领域模型的正确性。
表格对比:
| 特性 | 实体 (Entity) | 值对象 (Value Object) | 聚合根 (Aggregate Root) |
|---|---|---|---|
| 唯一标识 | 有 | 无 | 有 |
| 可变性 | 可变 | 不可变 | 可变 |
| 相等性 | 由唯一标识决定 | 由属性值决定 | 由唯一标识决定 |
| 生命周期 | 与唯一标识相关 | 与属性值相关 | 与唯一标识相关 |
| 作用 | 表示领域中的一个事物 | 表示领域中的一个概念或属性 | 维护聚合内部的一致性,是外部访问聚合的唯一入口 |
| 示例 | 用户(User)、订单(Order) | 地址(Address)、货币金额(Money) | 订单(Order)、客户(Customer) |
| ORM 映射 | 通常映射到数据库表,使用主键作为唯一标识 | 通常嵌入到实体中,或者映射到数据库表的多个字段 | 通常映射到数据库表,是聚合的根 |
| Laravel/Symfony 实现 | Eloquent Model (Laravel), Doctrine Entity (Symfony) | 普通 PHP 类,通常包含私有属性和 getter 方法 | Eloquent Model (Laravel), Doctrine Entity (Symfony), 负责控制聚合内部实体和值对象的访问和修改 |
5. 示例:领域事件的应用
假设在订单支付成功后,我们需要发送通知给用户,并更新库存。可以使用领域事件来实现这个功能。
Laravel 示例:
首先,定义一个 OrderPaid 领域事件:
<?php
namespace AppEvents;
use AppModelsOrder;
use IlluminateBroadcastingInteractsWithSockets;
use IlluminateFoundationEventsDispatchable;
use IlluminateQueueSerializesModels;
class OrderPaid
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $order;
public function __construct(Order $order)
{
$this->order = $order;
}
}
然后,在 Order 实体中,当订单支付成功时,触发 OrderPaid 事件:
<?php
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
use AppEventsOrderPaid;
class Order extends Model
{
use HasFactory;
// ...
public function pay(): void
{
// 业务逻辑:支付订单
$this->status = self::STATUS_COMPLETED;
$this->save();
// 触发 OrderPaid 事件
event(new OrderPaid($this));
}
}
最后,创建事件监听器来处理 OrderPaid 事件:
<?php
namespace AppListeners;
use AppEventsOrderPaid;
use IlluminateContractsQueueShouldQueue;
class SendOrderPaidNotification implements ShouldQueue
{
public function handle(OrderPaid $event)
{
// 发送通知给用户
// ...
}
}
<?php
namespace AppListeners;
use AppEventsOrderPaid;
use IlluminateContractsQueueShouldQueue;
class UpdateInventory implements ShouldQueue
{
public function handle(OrderPaid $event)
{
// 更新库存
// ...
}
}
在 EventServiceProvider 中注册事件和监听器:
<?php
namespace AppProviders;
use IlluminateFoundationSupportProvidersEventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
OrderPaid::class => [
SendOrderPaidNotification::class,
UpdateInventory::class,
],
];
}
Symfony 示例:
首先,定义一个 OrderPaid 领域事件:
<?php
namespace AppEvent;
use AppEntityOrder;
use SymfonyContractsEventDispatcherEvent;
class OrderPaid extends Event
{
public const NAME = 'order.paid';
protected Order $order;
public function __construct(Order $order)
{
$this->order = $order;
}
public function getOrder(): Order
{
return $this->order;
}
}
然后,在 Order 实体中,当订单支付成功时,触发 OrderPaid 事件:
<?php
namespace AppEntity;
use DoctrineORMMapping as ORM;
use AppEventOrderPaid;
use SymfonyComponentEventDispatcherEventDispatcherInterface;
#[ORMEntity(repositoryClass: OrderRepository::class)]
#[ORMTable(name: 'orders')]
class Order
{
// ...
public function pay(EventDispatcherInterface $eventDispatcher): void
{
// 业务逻辑:支付订单
$this->status = self::STATUS_COMPLETED;
// 触发 OrderPaid 事件
$event = new OrderPaid($this);
$eventDispatcher->dispatch($event, OrderPaid::NAME);
}
}
最后,创建事件监听器来处理 OrderPaid 事件:
<?php
namespace AppEventListener;
use AppEventOrderPaid;
use SymfonyComponentMailerMailerInterface;
use SymfonyComponentMimeEmail;
use SymfonyComponentEventDispatcherAttributeAsEventListener;
#[AsEventListener(event: OrderPaid::NAME, method: 'onOrderPaid')]
class SendOrderPaidNotification
{
private MailerInterface $mailer;
public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}
public function onOrderPaid(OrderPaid $event): void
{
$order = $event->getOrder();
// 发送通知给用户
$email = (new Email())
->from('[email protected]')
->to($order->getCustomer()->getEmail())
->subject('Your order has been paid')
->text('Your order has been paid. Thank you!');
$this->mailer->send($email);
}
}
<?php
namespace AppEventListener;
use AppEventOrderPaid;
use AppRepositoryProductRepository;
use DoctrineORMEntityManagerInterface;
use SymfonyComponentEventDispatcherAttributeAsEventListener;
#[AsEventListener(event: OrderPaid::NAME, method: 'onOrderPaid')]
class UpdateInventory
{
private ProductRepository $productRepository;
private EntityManagerInterface $entityManager;
public function __construct(ProductRepository $productRepository, EntityManagerInterface $entityManager)
{
$this->productRepository = $productRepository;
$this->entityManager = $entityManager;
}
public function onOrderPaid(OrderPaid $event): void
{
$order = $event->getOrder();
foreach ($order->getOrderItems() as $orderItem) {
$product = $orderItem->getProduct();
$product->setStock($product->getStock() - $orderItem->getQuantity()); // 假设 Product 实体有 setStock 方法
$this->entityManager->persist($product);
}
$this->entityManager->flush();
// 更新库存
// ...
}
}
通过使用领域事件,我们可以将订单支付成功后的逻辑解耦,提高系统的可扩展性和灵活性。
6. 关于贫血模型和充血模型
贫血模型和充血模型是两种不同的领域模型设计方式。
贫血模型:
- 实体只包含数据和简单的 getter/setter 方法。
- 所有的业务逻辑都放在服务层。
- 优点:简单易懂,易于测试。
- 缺点:实体缺乏行为,可能会导致服务层过于臃肿。
充血模型:
- 实体包含数据和业务逻辑。
- 服务层只负责协调实体之间的交互。
- 优点:实体职责明确,易于维护和扩展。
- 缺点:实现起来比较复杂,需要对 DDD 有深入的理解。
在实际项目中,可以根据项目的复杂度和团队的经验进行选择。如果项目比较简单,可以使用贫血模型。如果项目比较复杂,建议使用充血模型。
总的来说,在 Laravel/Symfony 中落地 DDD,需要理解实体、值对象和聚合根的概念,并合理地应用它们。同时,还需要注意领域建模、聚合边界设计、领域事件和测试等方面。 通过实践,我们可以构建出更贴近业务、更易于维护和扩展的软件系统。
核心概念回顾与要点
本文探讨了DDD的核心元素,展示了如何在Laravel和Symfony框架中实现实体、值对象和聚合根。着重强调了聚合根在维护数据一致性方面的作用,以及领域事件在实现松耦合系统架构方面的优势。