各位好,坐稳了。今天我们不聊“Hello World”,也不聊“怎么用PHP最快写出Hello World”。今天我们聊点重口味的。
如果你们的代码库现在长这样:一个 src 文件夹,里面有1000个类文件,每个类都有500行代码,Controller里混杂了数据库查询、缓存逻辑、业务规则、甚至还有“判断一下今天是周五再发货”这种代码……如果你看着这坨东西不觉得反胃,那说明你的胃大概是钛合金做的。
欢迎来到“PHP屎山现场”。
我们要解决的问题是:在PHP这个被误读为“低端脚本语言”的领域,如何用DDD(领域驱动设计)和优雅的模块拆分,把这坨屎山变成瑞士奶酪,或者说,变成一座精密的瑞士钟表。
准备好了吗?让我们开始解剖。
第一章:限界上下文——你是谁?你的边界在哪里?
很多PHP开发者对DDD的理解非常肤浅。他们认为DDD就是把类名改得更花哨一点,比如把 User 改成 UserEntity,把 OrderService 改成 OrderApplicationService。这就好比给一只穿着西装的猴子贴了个标签,它还是猴子。
DDD的核心在于限界上下文。
想象一下你的公司。有销售部、财务部、客服部、IT部。销售部有他们的语言(比如“单子”、“提成”、“客户”),财务部也有他们的语言(比如“发票”、“报销”、“审计”)。IT部把销售部和财务部的代码混在一个命名空间 AppService 里,这就是灾难的开始。
限界上下文就是那个“地域”概念。
在代码里,它表现为一个命名空间。
代码示例:错误的开始
// App/Service/User.php
class User {
private $name;
private $email;
private $isAdmin = false; // 这里的业务逻辑太脏了
public function updateProfile($name, $email) {
// 还可能混杂了发送邮件的逻辑
// 甚至混杂了缓存刷新逻辑
}
}
// App/Service/Order.php
class Order {
private $items = [];
private $total;
public function addItem($productId) {
// 这里试图去调用数据库查询库存
}
}
在这个世界里,User类知道订单的事,Order类想操作User,User想查库存。这叫什么?这叫耦合的绞肉机。大家都在一个大缸里搅和,谁也救不了谁。
代码示例:优雅的开始
我们要把“用户”和“订单”切分开。
// App/Domain/User/Identity.php
namespace AppDomainUser;
class Identity {
private string $id;
public function __construct(string $id)
{
if (empty($id)) {
throw new InvalidArgumentException("ID不能为空");
}
$this->id = $id;
}
}
// App/Domain/User/Profile.php
namespace AppDomainUser;
class Profile {
private string $name;
private string $email;
// 这是纯粹的领域逻辑,没有任何HTTP请求或数据库连接
public function changeEmail(string $newEmail): void {
if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) {
throw new DomainException("邮箱格式错误");
}
$this->email = $newEmail;
}
}
// App/Domain/User/User.php
namespace AppDomainUser;
class User {
private Identity $id;
private Profile $profile;
// ... 其他领域属性
public function __construct(Identity $id, Profile $profile)
{
$this->id = $id;
$this->profile = $profile;
}
public function changeEmail(string $newEmail): void {
$this->profile->changeEmail($newEmail);
}
}
看懂了吗?User 类现在非常干净。它不知道数据库长什么样,不知道HTTP请求长什么样,甚至不知道外面还有个叫 Order 的东西。它只关心“我是谁,我的资料是什么”。这就是单一职责原则的极致体现。
第二章:整洁架构——洋葱模型
PHP项目怎么组织?很多人喜欢 app/Controller, app/Model, app/View, app/Service, app/Entity, app/Utils, app/Config, app/Database……文件夹多到打开IDE都要转个圈。
在DDD里,我们用洋葱模型。或者更学术一点,叫整洁架构。
核心原则:依赖方向。
你要记住,依赖方向必须是单向的。内层不依赖外层,外层依赖内层。
- 最外层:Interface(接口层):负责HTTP请求、CLI命令、序列化JSON。也就是你的Controller和CommandBus。
- 第二层:Application(应用层):负责用例编排。这里不写业务规则,只写“发生了什么事”。比如“创建订单”、“修改用户”。
- 第三层:Domain(领域层):业务规则的核心。这是代码的灵魂。这里定义了实体、值对象、领域事件、仓储接口。
- 最内层:Infrastructure(基础设施层):数据库、第三方API、日志、邮件发送。这里实现了Domain层定义的接口。
目录结构规划
不要搞那些花里胡哨的。一个清晰的目录结构比什么都强。
/project-root
/config
/src
/Interface
/Http // Controllers, Middleware
/Console // Commands
/Application // Use Cases, DTOs
/Domain // The Meat!
/User
Entity
ValueObject
Repository
/Order
Entity
Service
Repository
/Infrastructure // The Support Staff
/Persistence // Doctrine, PDO implementations
/ExternalApi // 3rd party SDKs
/tests
第三章:深入Domain层——实体与聚合根
很多人问:“我在Domain层里能不能写 echo 'Hello World'?”
不能。Domain层里绝对不能有任何I/O操作(数据库、文件系统、网络)。如果你在一个Entity里写了 file_put_contents,那你就是对DDD的侮辱。
聚合根
聚合根是聚合的守护神。它定义了内部其他实体(如OrderItem)的生命周期。
假设我们在做一个电商系统。Order 是聚合根。OrderItem 是实体。Money 是值对象。
错误的代码:
// Domain/Order/Order.php
class Order {
public $items = [];
public function addItem($product, $quantity) {
// 这里的逻辑是反的!你把数据映射的逻辑放在了Domain层!
// 而且直接操作了数据库?
$stmt = $this->pdo->prepare("SELECT price FROM products WHERE id = ?");
$stmt->execute([$product->id]);
$price = $stmt->fetchColumn();
$this->items[] = new OrderItem($product, $quantity, $price);
}
}
正确的代码:
// Domain/Order/Order.php
namespace AppDomainOrder;
use AppDomainUserProfile;
class Order {
// 聚合根包含其他实体
private $items = [];
private $status;
public function __construct() {
$this->status = OrderStatus::CREATED;
}
/**
* 领域行为:添加商品
* 这里只进行逻辑校验,不涉及数据库操作
*/
public function addLineItem(OrderItem $item): void {
if ($this->status->isShipped()) {
throw new DomainException("订单已发货,无法添加商品");
}
// 这里仅仅是引用,真正的数据持久化交给Infrastructure层
$this->items[] = $item;
}
// 计算总价
public function getTotalAmount(): Money {
$total = Money::zero();
foreach ($this->items as $item) {
$total = $total->add($item->getTotal());
}
return $total;
}
}
注意看 Order 类。它没有 $pdo 属性,没有 Repository 属性。它只关心业务逻辑。如果我要添加一个商品,系统先检查“能不能添加”,能的话就添加,不能就抛异常。
第四章:Application层——编排的艺术
现在Domain层很干净了,但是谁来调用它呢?谁来创建 Order 对象?谁来把HTTP请求的数据转换成 Order 对象?
这就是Application层(也叫用例层)的活儿。
在PHP里,最流行的方式是 CQRS(命令查询职责分离),或者更简单的 Command/Query 模式。
我们来看一个“创建订单”的流程。
代码示例:Command Handler
// Application/Order/CreateOrderHandler.php
namespace AppApplicationOrder;
use AppDomainOrderOrder;
use AppDomainOrderOrderRepository;
use AppDomainOrderCommandCreateOrderCommand;
class CreateOrderHandler {
private $orderRepository;
private $productService; // 这里的依赖是接口,不是具体实现
public function __construct(OrderRepository $orderRepository, ProductServiceInterface $productService)
{
$this->orderRepository = $orderRepository;
$this->productService = $productService;
}
public function handle(CreateOrderCommand $command): string {
// 1. 加载聚合根(这里简化处理,实际可能需要从DB Load)
// 注意:这里我们不直接查数据库,而是查领域服务的内存缓存或内存状态
// 因为Domain层不应该知道如何从DB拿数据,它只知道Product是个什么概念
// 假设我们根据Command里的User ID找到了对应的User
// $user = $this->userService->getById($command->userId);
// 2. 构建聚合根
$order = new Order();
// 3. 编排业务流程
foreach ($command->items as $itemInput) {
// 去领域服务里查查这个商品存不存在,库存够不够
$product = $this->productService->getById($itemInput->productId);
// 转换成领域实体
$orderItem = new OrderItem(
$product->getId(),
$product->getName(),
$product->getPrice(),
$itemInput->quantity
);
$order->addLineItem($orderItem);
}
// 4. 保存
// 这里依然不写SQL!OrderRepository是一个接口,它的实现可能是PDO,也可能是Redis
$this->orderRepository->save($order);
// 5. 触发领域事件(后续章节细讲)
// $order->record(new OrderCreatedEvent($order->getId()));
return $order->getId();
}
}
Application层很轻量。它就像个导演。它指挥 User 和 Order 这两个演员去演戏,它不写剧本(业务规则),它只负责把道具搬上舞台。
第五章:Infrastructure层——数据持久化的替罪羊
这是最“脏”的一层,但也是PHP项目最常见的一层。它负责把Domain层的抽象变成具体的数据库操作。
这里我们需要用到 Repository Pattern(仓储模式)。
代码示例:Eloquent/Doctrine 实现
我们要实现刚才 OrderRepository 接口。
// Infrastructure/Persistence/OrderRepository.php
namespace AppInfrastructurePersistence;
use AppDomainOrderOrder;
use AppDomainOrderOrderRepository as OrderRepositoryInterface;
class OrderRepository implements OrderRepositoryInterface {
private $db; // PDO或者Doctrine EntityManager
public function __construct(PDO $db)
{
$this->db = $db;
}
public function save(Order $order): void {
// 1. 开始事务
$this->db->beginTransaction();
try {
// 2. 插入主表
$stmt = $this->db->prepare("INSERT INTO orders (status) VALUES (?)");
$stmt->execute([$order->getStatus()->getValue()]);
$orderId = $this->db->lastInsertId();
// 3. 插入明细表
foreach ($order->getItems() as $item) {
$stmt = $this->db->prepare("INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)");
$stmt->execute([
$orderId,
$item->getProductId(),
$item->getQuantity(),
$item->getUnitPrice()->getAmount() // Money是值对象,要取值
]);
}
$this->db->commit();
} catch (Exception $e) {
$this->db->rollBack();
throw $e; // 或者抛出更具体的仓储异常
}
}
public function findById(string $id): ?Order {
// 复杂的JOIN查询,甚至可能用到Query Builder
// 返回的必须是领域对象 Order,而不是 DB Row
}
}
看到了吗?在 Infrastructure 层,你可以尽情地写 SELECT * FROM ...,你可以用 file_get_contents,你可以用 curl。这是你的自由,因为外层根本看不见这些“脏活累活”。
第六章:值对象——不可变的宝石
在PHP中,对象通常是引用传递的。如果你修改了一个对象,所有持有这个对象引用的地方都会变。这是巨大的隐患。
DDD引入了 Value Object (VO)。值对象是不可变的。一旦创建,它就是死的。
例如:钱。Money 是一个值对象。
// Domain/Money/Money.php
namespace AppDomainMoney;
class Money {
private int $amount;
private string $currency; // 'CNY', 'USD'
private function __construct(int $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}
public static function fromInt(int $amount, string $currency): self {
return new self($amount, $currency);
}
// 关键点:修改钱的方法,必须返回一个新的Money对象
// 原对象保持不变
public function add(Money $other): self {
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException("货币单位不一致");
}
return new self($this->amount + $other->amount, $this->currency);
}
public function equals(Money $other): bool {
return $this->amount === $other->amount && $this->currency === $other->currency;
}
// 为了打印,提供一个getter
public function getAmount(): int {
return $this->amount;
}
}
在 Order 里,我们怎么用?
// Domain/Order/Order.php
class Order {
private $total; // Money 类型
public function addItem(OrderItem $item): void {
$currentTotal = $this->total !== null ? $this->total : Money::zero();
$this->total = $currentTotal->add($item->getTotal()); // 这里返回了新的 Money
}
}
这种“不可变性”大大降低了Bug率。你不用到处去 unset($order->total),也不用担心一个地方改了总价,整个系统都乱套了。
第七章:领域事件——不要大喊大叫,发个消息
在传统PHP MVC里,Controller往往是“上帝”。Controller里充满了:
$this->userService->create();
$this->emailService->send();
$this->inventoryService->deduct();
$this->logService->record();
这叫上帝控制器。它什么都管,什么都耦合。
DDD推崇 领域事件。业务流程发生变化时,发布一个事件。
比如:订单已创建。
- Domain层创建Order后,发布
OrderCreated事件。 - Domain层不关心谁监听了这个事件。
- Application层或者Infrastructure层监听这个事件,去干“发送邮件”、“扣库存”这种杂事。
代码示例:领域事件
// Domain/Order/Order.php
namespace AppDomainOrder;
use AppDomainEvent DomainEvent;
class Order implements DomainEvent {
private $createdAt;
private $orderId;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getOrderId(): string {
return $this->orderId;
}
// 发布事件
public function publishCreatedEvent(): OrderCreatedEvent {
return new OrderCreatedEvent($this->orderId, $this->createdAt);
}
}
// Domain/Event/OrderCreatedEvent.php
namespace AppDomainEvent;
use AppDomainEventDomainEvent;
class OrderCreatedEvent implements DomainEvent {
private $orderId;
private $occurredOn;
public function __construct(string $orderId, DateTimeImmutable $occurredOn)
{
$this->orderId = $orderId;
$this->occurredOn = $occurredOn;
}
public function getOrderId(): string {
return $this->orderId;
}
public function getOccurredOn(): DateTimeImmutable {
return $this->occurredOn;
}
}
代码示例:事件监听器
// Infrastructure/Event/OrderCreatedListener.php
namespace AppInfrastructureEvent;
use AppDomainEventOrderCreatedEvent;
use AppDomainEventListenerInterface;
class OrderCreatedListener implements ListenerInterface {
private $emailService;
public function __construct(EmailServiceInterface $emailService)
{
$this->emailService = $emailService;
}
public function handle(OrderCreatedEvent $event): void {
// 像个烧锅炉的,不管上面造什么,收到信号就烧锅炉
$this->emailService->sendWelcomeEmail($event->getOrderId());
}
}
通过事件,你的 Order 类变得非常纯粹。它只管“我发生了什么事”,而不管“谁来通知”。这样,以后如果你想加一个“发短信通知”,不需要修改 Order 类,只需要加一个监听器。这就是开闭原则。
第八章:循环依赖——死结与解药
在拆分模块时,最容易遇到的坑就是循环依赖。
比如:OrderModule 需要查 UserModule 的资料,而 UserModule 也想给 User 对象打上“最近购买过Order”的标记。
在PHP里,这会编译报错。
解决方案:接口隔离。
不要互相依赖。如果 Order 需要 User 的信息,Order 只需要依赖 UserInterface。具体的实现细节(比如Profile、Avatar)由 Order 去问。
或者,更狠一点,把这两个上下文合并。如果它们必须强耦合,说明它们本来就应该在一个限界上下文里,别强行拆分了。
第九章:测试策略——别指望手动测试
用了DDD,测试就变得简单了。因为你的Domain层是纯函数式的,没有I/O。
写单元测试时,你不需要Mock数据库,不需要Mock HTTP请求。
代码示例:Domain测试
// Tests/Domain/Money/MoneyTest.php
namespace TestsDomainMoney;
use AppDomainMoneyMoney;
use PHPUnitFrameworkTestCase;
class MoneyTest extends TestCase {
public function testCanAddMoney() {
$money1 = Money::fromInt(10, 'CNY');
$money2 = Money::fromInt(20, 'CNY');
$result = $money1->add($money2);
$this->assertEquals(30, $result->getAmount());
}
public function testThrowExceptionOnDifferentCurrency() {
$money1 = Money::fromInt(10, 'CNY');
$money2 = Money::fromInt(20, 'USD');
$this->expectException(InvalidArgumentException::class);
$money1->add($money2);
}
}
这就是测试的优雅。闭着眼睛写,点一下按钮,全绿。这就是DDD的魅力。
第十章:实战场景——一个完整的数据流
为了让你彻底明白,我们来走一遍完整的流程。假设用户在Web端下单。
-
用户点击提交:
src/Interface/Http/Controllers/OrderController.php- 接收
POST /orders,解析JSON。
-
转换与校验:
OrderController验证请求参数。- 创建
CreateOrderCommand对象。
-
分发Command:
src/Application/CommandBus.php(使用 Symfony Messenger 或类似库)bus->dispatch($command)
-
执行Handler:
CreateOrderHandler被调用。CreateOrderHandler注入了OrderRepository。CreateOrderHandler创建Order聚合根。CreateOrderHandler调用Order->addLineItem(...)。- Domain层生效:
Order检查逻辑,抛出异常(如果库存为0)。
-
持久化:
- 如果成功,
OrderRepository->save($order)。 Infrastructure/Persistence/OrderRepository把Order对象序列化成SQLINSERT。
- 如果成功,
-
事件触发:
Order->publishCreatedEvent()。- 事件总线监听到
OrderCreated。 - 监听器收到通知。
- Infrastructure层生效:
EmailService发送邮件。
整个过程中,Order 对象从来没见过 PDO,也没见过 SMTP。它只在纯粹的逻辑世界里跳舞。而 OrderController 只是个传话的,不知道业务细节。
总结:不要为了DDD而DDD
最后,我要泼一盆冷水。
DDD不是万灵药。不要为了用DDD而把一个简单的“Hello World”项目搞成几百个文件夹。那样你会累死的。
DDD的适用场景:
- 业务逻辑极其复杂。
- 业务规则多变。
- 需要支持多端(Web、App、小程序)接入同一个业务核心。
什么情况下别用DDD:
- 传统的CRUD增删改查项目。
- 周期性执行的脚本(CLI)。
- 你只有一个人,且代码量小于1000行。
优雅拆分的秘诀:
就是“尊重业务”。先画出你的业务流程图,找到那些变化频繁的地方,把它们圈起来,这就是你的限界上下文。然后,把代码的“脏活累活”扔到最外层,把“纯粹的业务规则”锁在最里层。
记住,代码是写给人看的,顺便给机器运行。如果你的代码结构清晰,同事们读起来像读小说一样顺畅,那你就成功了。
好了,今天的讲座就到这里。现在,去把你那个 lib 文件夹里的500个类删了吧。相信我,你会感觉像是在戒毒一样爽快。