从特征到服务:重构代码提升可测试性并为AI智能体铺路
1. 项目概述:从“特征”到“服务”的重构之旅
最近在重构一个内部项目时,我遇到了一个典型的“历史包袱”问题:一个核心的业务逻辑类,随着功能的不断堆叠,已经膨胀到了近两千行代码。它最初被设计为一个“上帝类”(God Class),内部充斥着各种静态方法、私有状态和复杂的继承链。最要命的是,它几乎无法进行单元测试——任何试图测试它的行为都会牵扯到数据库连接、外部API调用和复杂的全局状态,写测试比写业务逻辑本身还痛苦。同时,团队开始探索引入智能体(Agents)来辅助处理一些决策流程,但现有代码的紧耦合结构让智能体根本无法“理解”和“调用”离散的业务能力。这迫使我们开始一场名为“Traits to Services”的重构。
简单来说,这次重构的核心目标有两个: 提升可测试性 和 为智能体架构铺路 。我们将把那些混杂在类内部、以“特征”(Traits)或混合(Mixins)形式存在的、具有明确职责的代码块,剥离、重组为独立的、可注入的“服务”(Services)。这不仅仅是代码组织形式的变化,更是一种架构思维的转变——从“我能做什么”的面向对象思维,转向“我需要什么服务”的面向服务思维。无论你是正在为祖传代码的测试而头疼,还是计划引入AI智能体来增强系统能力,相信这次从“特征”到“服务”的实战拆解,都能给你带来直接的参考价值。
2. 重构动因与核心设计思路拆解
2.1 为什么“特征”会成为测试的噩梦?
在项目早期,为了快速实现功能复用,“特征”是一个非常方便的工具。例如,我们可能有一个 OrderProcessor 类,它通过 use NotifiableTrait, LoggableTrait, TaxCalculatorTrait 的方式,混入了通知、日志和税费计算的能力。表面上看,代码复用率很高,类文件也很整洁。但问题隐藏在背后:
- 隐式依赖与紧耦合 :特征中的方法直接操作
$this上下文,它们假设宿主类一定拥有某些属性(如$this->user,$this->amount)或方法。这种依赖关系是隐式的、强制的,没有通过构造函数或方法参数显式声明。这使得OrderProcessor与这些特质深度绑定,无法单独测试税费计算逻辑,因为你必须实例化一个完整的、可能依赖数据库的OrderProcessor对象。 - 状态共享与副作用 :特征方法通常会修改宿主类的内部状态。测试一个调用了特征方法的功能时,你不仅要验证该功能的输出,还要担心特征方法是否无意中改变了其他状态,导致测试难以断言且不稳定。
- 难以模拟(Mock)和桩(Stub) :由于特征代码是编译时复制到宿主类中的,你无法在测试中轻松地将一个特征实现替换为模拟对象。例如,
NotifiableTrait里可能直接调用了Mail::send(...),在单元测试中我们并不想真发邮件,但替换这个调用点异常困难。
注意 :这里说的“特征”是广义的,不仅指PHP的Trait,也包括其他语言中类似的Mixin、模块混入等模式,其核心问题是 将实现细节通过编译/运行时混合,破坏了类的接口边界和依赖清晰度 。
2.2 智能体(Agents)需要什么样的接口?
智能体,无论是基于规则的引擎还是大语言模型驱动的AI,它们与系统交互的基本模式是“感知-决策-执行”。为了让智能体能有效“执行”,系统需要提供一套清晰、稳定、语义化的“工具”或“能力”接口。
- 原子性与描述性 :智能体调用的服务应该是原子的、功能单一的。一个名为
calculateTax(orderDetails)的服务,比一个庞大的processOrder(orderId)方法更容易被智能体理解和正确调用。服务需要有清晰的输入输出定义。 - 无状态与幂等性 :理想的服务应该是无状态的或至少是幂等的。给定相同的输入,总是返回相同的输出,不依赖于隐藏的会话或全局状态。这使得智能体的决策过程更可预测,也便于回滚和重试。
- 显式契约 :服务通过接口(Interface)明确约定其行为。智能体框架(如LangChain、AutoGPT的插件系统)可以通过这些接口自动发现、描述和调用服务。而隐藏在特征内部的方法,对智能体来说是不可见、不可用的。
因此,将特征重构为服务,实质上是为人类开发者和AI智能体同时创建一套统一的、可编程的API层。
2.3 重构的核心设计原则
我们的重构遵循以下几个核心原则:
- 单一职责原则(SRP) :每个服务只做一件事,并把它做好。将原来特征中混杂的多个职责拆分开。
- 依赖倒置原则(DIP) :高层模块(业务逻辑)不应依赖低层模块(如邮件发送、日志记录),二者都应依赖其抽象(接口)。通过构造函数注入依赖。
- 面向接口编程 :为每个服务定义接口。业务类依赖接口,而非具体实现。这为测试(注入Mock)和未来替换实现提供了终极灵活性。
- 领域驱动设计(DDD)启发 :识别出核心的“领域服务”,如
TaxCalculationService、NotificationService、ShippingService。这些服务封装了核心业务规则。
3. 实操步骤:从识别到落地的完整流程
3.1 第一步:代码分析与特征识别
不要试图一次性重构整个类。我们采用“外科手术式”的渐进重构。
- 定位“上帝类” :找到那个代码行数多、职责多、测试覆盖率低或测试运行缓慢的类。
- 扫描特征引用 :在类定义中,找到所有的
use ...Trait语句。列出所有被混入的特征。 - 分析特征内容 :打开每个特征文件,逐一分析其中的每个公共(public)和受保护(protected)方法。为每个方法贴上标签:
- 独立工具方法 :如
calculatePercentage($value, $percent), 它不依赖或很少依赖宿主类状态,可以轻松静态化或移入工具类。 - 依赖外部服务的功能 :如
sendEmail($subject, $body), 内部直接调用了邮件发送库。这应该被提取为一个通知服务。 - 核心业务逻辑 :如
applyDiscount($user), 内部有复杂的折扣规则计算。这应该被提取为一个折扣计算服务。 - 数据访问或持久化 :如
saveWithLog($data), 混合了业务逻辑和数据库操作。这通常需要拆解,将持久化职责分离到仓储(Repository)中。
- 独立工具方法 :如
- 绘制依赖关系图 :在白板或绘图工具上,画出原类与各个特征方法之间的调用关系和数据流动。这能清晰揭示隐藏的耦合点。
3.2 第二步:定义服务接口与实现
这是重构的核心设计环节。
- 为每个职责定义接口 :根据上一步的分析,为每一组相关的方法定义一个接口。接口命名应清晰反映其职责,通常以
Service或Provider结尾。// 之前:在 NotifiableTrait 中 // trait NotifiableTrait { // public function notifyUser($message) { ... } // } // 之后:定义接口和实现 interface NotificationServiceInterface { public function notifyUser(User $user, Message $message): void; } class EmailNotificationService implements NotificationServiceInterface { public function __construct(private Mailer $mailer) {} public function notifyUser(User $user, Message $message): void { $this->mailer->send($user->email, $message); } } - 实现具体服务 :创建实现上述接口的具体类。将原特征中的代码逻辑迁移过来。 关键点 :移除对原宿主类 (
$this) 状态的直接访问,所有需要的数据都通过方法参数传入。 - 处理工具方法 :对于那些纯粹的、无状态的工具方法,可以考虑将其放入一个静态工具类
MathHelper、StringFormatter中,或者如果它们有状态但可配置,也可以做成一个小的服务(如CurrencyConverterService)。
3.3 第三步:改造宿主类与依赖注入
现在,我们来改造原来的“上帝类”。
- 移除特征引用 :从类定义中删除
use ...Trait语句。 - 声明服务依赖 :在类的构造函数中,通过接口类型声明它所需要的服务。
class OrderProcessor { public function __construct( private TaxCalculationServiceInterface $taxCalculator, private NotificationServiceInterface $notifier, private DiscountServiceInterface $discounter, // ... 其他服务 ) {} } - 重构方法实现 :遍历宿主类的所有方法,找到其中调用了原特征方法的地方。将这些调用替换为对注入服务的调用,并调整参数传递。
注意 :这里的一个微妙但重要的变化是,服务通常返回计算结果(如// 重构前 public function process(Order $order) { $this->validate($order); $this->calculateTax($order); // 来自 TaxCalculatorTrait $this->applyDiscount($order); // 来自 DiscountTrait $this->notifyCustomer($order); // 来自 NotifiableTrait $order->save(); } // 重构后 public function process(Order $order) { $this->validate($order); // 调用服务,传递所需数据,而非操作 $this 或 $order 的内部状态 $tax = $this->taxCalculator->calculateForOrder($order); $order->setTax($tax); $discount = $this->discounter->applyDiscount($order, $order->getUser()); $order->applyDiscount($discount); $order->save(); $this->notifier->notifyUser($order->getUser(), new OrderConfirmedMessage($order)); }$tax),由宿主类(或订单实体)来决定如何应用这个结果。这保持了服务的不变性(不直接修改输入参数),使逻辑更清晰。
3.4 第四步:更新工厂、容器配置与测试
- 依赖注入容器配置 :在你的框架(如Laravel的服务容器、Spring的ApplicationContext)中,注册接口与具体实现的绑定。这确保了当
OrderProcessor被实例化时,容器能自动注入正确的服务实例。// Laravel 示例,在 ServiceProvider 中 $this->app->bind(NotificationServiceInterface::class, EmailNotificationService::class); // 或者,根据环境注入不同实现(如测试时注入Mock) $this->app->bind(TaxCalculationServiceInterface::class, function ($app) { if ($app->environment('testing')) { return new MockTaxCalculator(); } return new RealTaxCalculator(); }); - 编写单元测试 :现在,测试变得非常简单。你可以为
OrderProcessor编写真正的单元测试,通过注入模拟对象来隔离测试其核心流程。public function testProcessOrderAppliesTaxAndDiscount() { // 创建Mock $mockTaxCalculator = $this->createMock(TaxCalculationServiceInterface::class); $mockTaxCalculator->method('calculateForOrder')->willReturn(10.00); $mockDiscounter = $this->createMock(DiscountServiceInterface::class); $mockDiscounter->method('applyDiscount')->willReturn(5.00); $mockNotifier = $this->createMock(NotificationServiceInterface::class); // 期望notifyUser被调用一次 $mockNotifier->expects($this->once())->method('notifyUser'); // 注入Mock,创建被测对象 $processor = new OrderProcessor($mockTaxCalculator, $mockNotifier, $mockDiscounter); $order = new Order(...); $processor->process($order); $this->assertEquals(10.00, $order->getTax()); $this->assertEquals(5.00, $order->getDiscount()); } - 服务本身的测试 :同时,你也可以为每个独立的服务(如
RealTaxCalculator)编写集成测试或单元测试,因为它们现在也是独立的、可测试的单元。
4. 为智能体(Agents)暴露服务
当服务层建立好后,将其暴露给智能体就水到渠成了。这里有两种主流模式:
4.1 模式一:适配器模式(Adapter Pattern)创建Agent Tools
为智能体框架(如 OpenAI Function Calling, LangChain Tools)创建专用的适配器。这个适配器将我们的领域服务方法,包装成符合智能体框架要求的工具描述和调用格式。
# 伪代码示例 (LangChain风格)
from langchain.tools import BaseTool
from my_app.services import TaxCalculationService
class CalculateTaxTool(BaseTool):
name = "calculate_order_tax"
description = "Calculates the tax amount for a given order."
tax_service: TaxCalculationService # 注入我们的领域服务
def _run(self, order_details: dict) -> str:
# 将智能体传递的参数,转换为服务所需的参数格式
tax_amount = self.tax_service.calculate_for_order_details(order_details)
return f"The calculated tax is ${tax_amount:.2f}"
# 在智能体初始化时,将这些工具注入
agent = initialize_agent(
tools=[CalculateTaxTool(tax_service=my_tax_service), ...],
llm=my_llm,
agent_type=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION
)
优点 :隔离性好,可以在适配器中处理格式转换、错误处理和日志记录,不影响核心服务。 缺点 :需要为每个服务或方法编写适配器,有一定工作量。
4.2 模式二:自动化包装与发现
利用反射和元编程,自动扫描实现了特定接口的服务类,并为其方法生成工具描述。一些现代框架已经开始支持这种能力。
// 伪代码示例:一个简单的自动化注册思路
class AgentToolRegistry {
private array $tools = [];
public function registerService(object $service): void {
$reflection = new ReflectionClass($service);
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($this->isAgentToolMethod($method)) {
$toolDefinition = $this->createToolDefinition($service, $method);
$this->tools[] = $toolDefinition;
}
}
}
private function createToolDefinition($service, ReflectionMethod $method): array {
// 解析方法名、参数、注释,生成符合OpenAI Function Calling规范的JSON Schema
return [
'type' => 'function',
'function' => [
'name' => $method->getName(),
'description' => $this->extractDescriptionFromDocComment($method),
'parameters' => $this->generateJsonSchema($method),
'callable' => [$service, $method->getName()] // 实际调用入口
]
];
}
// ... 获取所有工具定义,提供给智能体前端
public function getToolsDefinition(): array { return $this->tools; }
}
优点 :几乎零成本将现有服务暴露为智能体工具,维护方便。 缺点 :自动化生成的描述可能不够精准,需要依赖规范的注释(如PHPDoc, OpenAPI注解),对方法签名设计有约束(如不能使用复杂对象参数)。
实操心得 :在项目初期,建议从**模式一(适配器模式) 开始,手动为最关键的几个服务创建高质量的工具。这让你能更精细地控制工具的交互逻辑和错误处理。当服务层稳定且模式固定后,可以逐步探索 模式二(自动化)**来提升效率。永远记住,暴露给智能体的接口是系统新的、重要的API边界,其稳定性和清晰度至关重要。
5. 重构过程中的挑战与应对策略
5.1 循环依赖与上帝服务
有时,从特征中提取出的服务A,可能依赖于另一个服务B,而B又是在另一个特征中,这个特征可能也混在原来的上帝类里,甚至B可能反过来依赖A。这就形成了循环依赖或“上帝服务”。
解决方案 :
- 重新审视职责 :循环依赖通常意味着职责划分不清。尝试寻找一个更小的、更内聚的职责单元,将其提取为第三个服务C,让A和B都依赖C,或者将A和B合并为一个内聚的服务。
- 依赖接口而非具体类 :确保服务之间通过接口交互,这本身不会解决循环依赖,但能让问题在编译/容器初始化时更早暴露。
- 使用事件驱动 :如果A做完某事后B需要知道,可以考虑使用领域事件(Domain Events)。A发布一个事件,B监听并处理该事件,从而解耦直接的调用关系。
5.2 遗留代码的数据库事务管理
原特征方法可能和宿主类的方法在同一个数据库事务中运行。拆分为服务后,事务边界需要重新设计。
解决方案 :
- 事务放在服务外层 :将事务的控制权上移到应用层(如控制器、命令处理器)。在调用一系列服务之前开启事务,全部成功后再提交。这要求服务方法不能自己提交或回滚事务。
DB::beginTransaction(); try { $this->orderService->create($data); $this->inventoryService->deduct($items); $this->paymentService->charge($amount); DB::commit(); } catch (Exception $e) { DB::rollBack(); throw $e; } - 每个服务方法管理自己的事务(谨慎使用) :对于非常独立、可以补偿的操作,可以在服务方法内部管理小事务。但这增加了分布式事务的复杂度,需谨慎评估。
- 使用工作单元(Unit of Work)模式 :让一个“工作单元”对象跟踪所有变更,在最后统一提交。许多ORM(如Hibernate, Doctrine)内置了此模式。
5.3 测试策略的过渡期
在重构完成前,系统处于新旧代码共存的混合状态。如何保证重构期间不引入bug?
解决方案 :
- ** characterization Tests(表征测试)**:在动手重构前,先为要修改的类编写一组高层次的、基于输入输出的集成测试。这些测试不关心内部实现,只保证给定特定输入,输出符合当前(可能是错误的)行为。它们是你的“安全网”。
- 小步快跑,随时可回滚 :一次只提取一个特征或一组相关方法。每完成一个小步骤,就运行完整的测试套件(包括表征测试)。
- 并行模式 :在极端复杂的场景下,可以暂时保留旧的特征方法,但将其标记为
@deprecated,同时在新服务中实现新逻辑。在新代码经过充分测试后,再逐步将调用点从旧特征迁移到新服务,最后删除旧特征。
6. 重构后的收益与度量
完成“Traits to Services”的重构后,你将会收获一个截然不同的代码库:
- 可测试性飞跃 :单元测试编写速度提升,运行速度更快(因为不再启动数据库、邮件服务器等)。测试覆盖率可以轻松提高。
- 代码清晰度提升 :每个类的职责一目了然。
OrderProcessor的代码量可能从2000行缩减到200行,它现在只是一个“协调者”,负责调用各种服务来完成订单处理流程。 - 架构灵活性增强 :替换实现变得容易。想换一个短信通知服务?只需实现新的
NotificationServiceInterface并在容器中切换绑定。想为智能体提供能力?直接暴露服务接口。 - 团队协作改善 :不同的服务可以由不同的开发者或团队并行开发和维护,只要接口契约不变。
- 性能优化有入口 :可以对单个服务(如
TaxCalculationService)进行性能剖析和优化,甚至为其添加缓存层,而不会影响其他部分。
如何度量成功?
- 静态指标 :单个类的代码行数/圈复杂度下降;单元测试数量/覆盖率上升;集成测试运行时间缩短。
- 动态指标 :构建失败次数减少(因测试不稳定);新功能开发速度提升;定位和修复生产环境bug的平均时间缩短。
- 业务指标 :为智能体开发新工具的速度;系统因依赖清晰而减少的线上事故。
从“特征”到“服务”的重构,远不止是一次代码整理。它是一次将系统从“怎么做”的泥潭中拉出,转向“做什么”的清晰契约的思维升级。这个过程虽然初期有成本,但带来的可测试性、可维护性和面向未来的扩展性,是支撑一个项目走得更远的关键架构投资。当你下次面对一个臃肿的、难以测试的类时,不妨思考一下:这里面隐藏了多少个等待被解放的“服务”?
更多推荐


所有评论(0)