1. 项目概述:从“特征”到“服务”的重构之旅

最近在重构一个内部项目时,我遇到了一个典型的“历史包袱”问题:一个核心的业务逻辑类,随着功能的不断堆叠,已经膨胀到了近两千行代码。它最初被设计为一个“上帝类”(God Class),内部充斥着各种静态方法、私有状态和复杂的继承链。最要命的是,它几乎无法进行单元测试——任何试图测试它的行为都会牵扯到数据库连接、外部API调用和复杂的全局状态,写测试比写业务逻辑本身还痛苦。同时,团队开始探索引入智能体(Agents)来辅助处理一些决策流程,但现有代码的紧耦合结构让智能体根本无法“理解”和“调用”离散的业务能力。这迫使我们开始一场名为“Traits to Services”的重构。

简单来说,这次重构的核心目标有两个: 提升可测试性 为智能体架构铺路 。我们将把那些混杂在类内部、以“特征”(Traits)或混合(Mixins)形式存在的、具有明确职责的代码块,剥离、重组为独立的、可注入的“服务”(Services)。这不仅仅是代码组织形式的变化,更是一种架构思维的转变——从“我能做什么”的面向对象思维,转向“我需要什么服务”的面向服务思维。无论你是正在为祖传代码的测试而头疼,还是计划引入AI智能体来增强系统能力,相信这次从“特征”到“服务”的实战拆解,都能给你带来直接的参考价值。

2. 重构动因与核心设计思路拆解

2.1 为什么“特征”会成为测试的噩梦?

在项目早期,为了快速实现功能复用,“特征”是一个非常方便的工具。例如,我们可能有一个 OrderProcessor 类,它通过 use NotifiableTrait, LoggableTrait, TaxCalculatorTrait 的方式,混入了通知、日志和税费计算的能力。表面上看,代码复用率很高,类文件也很整洁。但问题隐藏在背后:

  1. 隐式依赖与紧耦合 :特征中的方法直接操作 $this 上下文,它们假设宿主类一定拥有某些属性(如 $this->user $this->amount )或方法。这种依赖关系是隐式的、强制的,没有通过构造函数或方法参数显式声明。这使得 OrderProcessor 与这些特质深度绑定,无法单独测试税费计算逻辑,因为你必须实例化一个完整的、可能依赖数据库的 OrderProcessor 对象。
  2. 状态共享与副作用 :特征方法通常会修改宿主类的内部状态。测试一个调用了特征方法的功能时,你不仅要验证该功能的输出,还要担心特征方法是否无意中改变了其他状态,导致测试难以断言且不稳定。
  3. 难以模拟(Mock)和桩(Stub) :由于特征代码是编译时复制到宿主类中的,你无法在测试中轻松地将一个特征实现替换为模拟对象。例如, NotifiableTrait 里可能直接调用了 Mail::send(...) ,在单元测试中我们并不想真发邮件,但替换这个调用点异常困难。

注意 :这里说的“特征”是广义的,不仅指PHP的Trait,也包括其他语言中类似的Mixin、模块混入等模式,其核心问题是 将实现细节通过编译/运行时混合,破坏了类的接口边界和依赖清晰度

2.2 智能体(Agents)需要什么样的接口?

智能体,无论是基于规则的引擎还是大语言模型驱动的AI,它们与系统交互的基本模式是“感知-决策-执行”。为了让智能体能有效“执行”,系统需要提供一套清晰、稳定、语义化的“工具”或“能力”接口。

  1. 原子性与描述性 :智能体调用的服务应该是原子的、功能单一的。一个名为 calculateTax(orderDetails) 的服务,比一个庞大的 processOrder(orderId) 方法更容易被智能体理解和正确调用。服务需要有清晰的输入输出定义。
  2. 无状态与幂等性 :理想的服务应该是无状态的或至少是幂等的。给定相同的输入,总是返回相同的输出,不依赖于隐藏的会话或全局状态。这使得智能体的决策过程更可预测,也便于回滚和重试。
  3. 显式契约 :服务通过接口(Interface)明确约定其行为。智能体框架(如LangChain、AutoGPT的插件系统)可以通过这些接口自动发现、描述和调用服务。而隐藏在特征内部的方法,对智能体来说是不可见、不可用的。

因此,将特征重构为服务,实质上是为人类开发者和AI智能体同时创建一套统一的、可编程的API层。

2.3 重构的核心设计原则

我们的重构遵循以下几个核心原则:

  1. 单一职责原则(SRP) :每个服务只做一件事,并把它做好。将原来特征中混杂的多个职责拆分开。
  2. 依赖倒置原则(DIP) :高层模块(业务逻辑)不应依赖低层模块(如邮件发送、日志记录),二者都应依赖其抽象(接口)。通过构造函数注入依赖。
  3. 面向接口编程 :为每个服务定义接口。业务类依赖接口,而非具体实现。这为测试(注入Mock)和未来替换实现提供了终极灵活性。
  4. 领域驱动设计(DDD)启发 :识别出核心的“领域服务”,如 TaxCalculationService NotificationService ShippingService 。这些服务封装了核心业务规则。

3. 实操步骤:从识别到落地的完整流程

3.1 第一步:代码分析与特征识别

不要试图一次性重构整个类。我们采用“外科手术式”的渐进重构。

  1. 定位“上帝类” :找到那个代码行数多、职责多、测试覆盖率低或测试运行缓慢的类。
  2. 扫描特征引用 :在类定义中,找到所有的 use ...Trait 语句。列出所有被混入的特征。
  3. 分析特征内容 :打开每个特征文件,逐一分析其中的每个公共(public)和受保护(protected)方法。为每个方法贴上标签:
    • 独立工具方法 :如 calculatePercentage($value, $percent) , 它不依赖或很少依赖宿主类状态,可以轻松静态化或移入工具类。
    • 依赖外部服务的功能 :如 sendEmail($subject, $body) , 内部直接调用了邮件发送库。这应该被提取为一个通知服务。
    • 核心业务逻辑 :如 applyDiscount($user) , 内部有复杂的折扣规则计算。这应该被提取为一个折扣计算服务。
    • 数据访问或持久化 :如 saveWithLog($data) , 混合了业务逻辑和数据库操作。这通常需要拆解,将持久化职责分离到仓储(Repository)中。
  4. 绘制依赖关系图 :在白板或绘图工具上,画出原类与各个特征方法之间的调用关系和数据流动。这能清晰揭示隐藏的耦合点。

3.2 第二步:定义服务接口与实现

这是重构的核心设计环节。

  1. 为每个职责定义接口 :根据上一步的分析,为每一组相关的方法定义一个接口。接口命名应清晰反映其职责,通常以 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);
        }
    }
    
  2. 实现具体服务 :创建实现上述接口的具体类。将原特征中的代码逻辑迁移过来。 关键点 :移除对原宿主类 ( $this ) 状态的直接访问,所有需要的数据都通过方法参数传入。
  3. 处理工具方法 :对于那些纯粹的、无状态的工具方法,可以考虑将其放入一个静态工具类 MathHelper StringFormatter 中,或者如果它们有状态但可配置,也可以做成一个小的服务(如 CurrencyConverterService )。

3.3 第三步:改造宿主类与依赖注入

现在,我们来改造原来的“上帝类”。

  1. 移除特征引用 :从类定义中删除 use ...Trait 语句。
  2. 声明服务依赖 :在类的构造函数中,通过接口类型声明它所需要的服务。
    class OrderProcessor
    {
        public function __construct(
            private TaxCalculationServiceInterface $taxCalculator,
            private NotificationServiceInterface $notifier,
            private DiscountServiceInterface $discounter,
            // ... 其他服务
        ) {}
    }
    
  3. 重构方法实现 :遍历宿主类的所有方法,找到其中调用了原特征方法的地方。将这些调用替换为对注入服务的调用,并调整参数传递。
    // 重构前
    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 第四步:更新工厂、容器配置与测试

  1. 依赖注入容器配置 :在你的框架(如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();
    });
    
  2. 编写单元测试 :现在,测试变得非常简单。你可以为 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());
    }
    
  3. 服务本身的测试 :同时,你也可以为每个独立的服务(如 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?

解决方案

  1. ** characterization Tests(表征测试)**:在动手重构前,先为要修改的类编写一组高层次的、基于输入输出的集成测试。这些测试不关心内部实现,只保证给定特定输入,输出符合当前(可能是错误的)行为。它们是你的“安全网”。
  2. 小步快跑,随时可回滚 :一次只提取一个特征或一组相关方法。每完成一个小步骤,就运行完整的测试套件(包括表征测试)。
  3. 并行模式 :在极端复杂的场景下,可以暂时保留旧的特征方法,但将其标记为 @deprecated ,同时在新服务中实现新逻辑。在新代码经过充分测试后,再逐步将调用点从旧特征迁移到新服务,最后删除旧特征。

6. 重构后的收益与度量

完成“Traits to Services”的重构后,你将会收获一个截然不同的代码库:

  1. 可测试性飞跃 :单元测试编写速度提升,运行速度更快(因为不再启动数据库、邮件服务器等)。测试覆盖率可以轻松提高。
  2. 代码清晰度提升 :每个类的职责一目了然。 OrderProcessor 的代码量可能从2000行缩减到200行,它现在只是一个“协调者”,负责调用各种服务来完成订单处理流程。
  3. 架构灵活性增强 :替换实现变得容易。想换一个短信通知服务?只需实现新的 NotificationServiceInterface 并在容器中切换绑定。想为智能体提供能力?直接暴露服务接口。
  4. 团队协作改善 :不同的服务可以由不同的开发者或团队并行开发和维护,只要接口契约不变。
  5. 性能优化有入口 :可以对单个服务(如 TaxCalculationService )进行性能剖析和优化,甚至为其添加缓存层,而不会影响其他部分。

如何度量成功?

  • 静态指标 :单个类的代码行数/圈复杂度下降;单元测试数量/覆盖率上升;集成测试运行时间缩短。
  • 动态指标 :构建失败次数减少(因测试不稳定);新功能开发速度提升;定位和修复生产环境bug的平均时间缩短。
  • 业务指标 :为智能体开发新工具的速度;系统因依赖清晰而减少的线上事故。

从“特征”到“服务”的重构,远不止是一次代码整理。它是一次将系统从“怎么做”的泥潭中拉出,转向“做什么”的清晰契约的思维升级。这个过程虽然初期有成本,但带来的可测试性、可维护性和面向未来的扩展性,是支撑一个项目走得更远的关键架构投资。当你下次面对一个臃肿的、难以测试的类时,不妨思考一下:这里面隐藏了多少个等待被解放的“服务”?

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐