NestJS 依赖注入中的里氏替换原则(LSP)实战:以 Comp AI CRM 为例
2026/9/24 17:22:00 网站建设 项目流程
  • 后端
  • 前端
  • CRM
  • 人工智能
  • AI Agent

【免费下载链接】crm

Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.

项目地址:https://gitcode.com/gh_mirrors/crm48/crm
点击查看免费下载

本文聚焦 NestJS 依赖注入场景下的里氏替换原则(Liskov Substitution Principle, LSP),讲解为什么子类型(实现类)必须能够在运行时被其基类型(接口/抽象类)无缝替换而不破坏调用方语义。结合 Comp AI CRM(开源、面向 AI Agent 的 CRM,NestJS API 位于 apps/api)的实际代码,你将掌握:如何用注入令牌(injection token)为接口绑定实现、如何识别测试替身(mock/stub)中的 LSP 违规、以及如何编写共享契约测试来验证任意实现都遵守同一份行为契约。

为什么在 NestJS 中 LSP 是 CRITICAL 级实践

Comp AI CRM 的代码规范中,.agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md将「Honor Liskov Substitution Principle」标记为impact: HIGH,与依赖注入(DI)类别下的其余规则(di-interface-segregationdi-prefer-constructor-injectiondi-use-interfaces-tokensdi-scope-awarenessdi-avoid-service-locator)一起,共同构成 NestJS 应用架构中 CRITICAL 级别的质量门槛。

LSP 的经典定义是:子类型必须能够替换其基类型而不改变程序的正确性。映射到 NestJS 依赖注入语境中,规则文件给出了更精确的表述:

Any implementation of an interface or abstract class must honor the contract completely. A mock payment service used in tests must behave like a real payment service (return similar shapes, handle errors the same way). Violating LSP causes subtle bugs when swapping implementations.

也就是说:只要某段代码依赖的是接口PaymentGateway,那么运行时无论容器注入StripeService还是MockPaymentService,调用方的行为都必须一致。测试中使用的 mock 服务必须与生产实现返回相同形状的数据、以相同方式处理错误,否则在「切换实现」的那一刻就会引入难以排查的隐性 bug。

在依赖注入系统中违反 LSP 的典型后果是:单元测试全绿(因为测试替身放水),但一旦把生产实现换进来(或反过来用 mock 做 E2E),立刻出现undefined字段、意外抛错、错误的异常类型等「只在特定环境出现」的问题。

前提:接口无法直接作为注入令牌

讨论 LSP 之前必须建立基础:TypeScript 接口在编译期会被擦除,运行时不存在PaymentGateway这个值,因此接口本身不能作为注入令牌。同类别规则 di-use-interfaces-tokens.md 明确指出:

TypeScript interfaces are erased at compile time and can't be used as injection tokens. Use string tokens, symbols, or abstract classes when you want to inject implementations of interfaces.

Comp AI CRM 正是这么做的。apps/api/src/database/database.constants.ts使用Symbol 令牌暴露数据库连接:

import { Inject } from "@nestjs/common"; export const DATABASE = Symbol("DATABASE"); export const InjectDatabase = () => Inject(DATABASE);

然后在 database.module.ts 中把Db实例绑定到该令牌并导出:

@Global() @Module({ providers: [{ provide: DATABASE, useValue: db }], exports: [DATABASE], }) export class DatabaseModule implements OnModuleInit, OnApplicationShutdown { constructor(@InjectDatabase() private readonly db: Db) {} async onModuleInit(): Promise<void> { try { await this.db.$connect(); this.logger.log({ message: "Database connected" }); } catch (error) { // ... } } // ... }

任意 Service 通过自定义装饰器@InjectDatabase()注入,例如 activities.service.ts、agent-access.service.ts、agent-definitions.service.ts、agent-queue.service.ts、agent-runs.service.ts 等都以@InjectDatabase() private readonly db: Db作为构造参数。Db类型来自 packages/db/src/client.ts(Prisma Client),它就是一个跨实现(SQLite/PostgreSQL 等)保持同一行为契约的典型「实现可替换」对象。

在这个基础上,LSP 的完整落地需要三步:用令牌定义契约 → 所有实现忠实履行契约 → 用共享契约测试验证

违规示例:测试替身悄悄破坏契约

规则文档给出了一个非常经典的违规示例。先看契约:

// Base interface with clear contract interface PaymentGateway { /** * Charges the specified amount. * @returns PaymentResult on success * @throws PaymentFailedException on payment failure */ charge(amount: number, currency: string): Promise<PaymentResult>; }

生产实现遵守契约:

@Injectable() export class StripeService implements PaymentGateway { async charge(amount: number, currency: string): Promise<PaymentResult> { const response = await this.stripe.charges.create({ amount, currency }); return { success: true, transactionId: response.id, amount }; } }

而下面这个 mock 则同时踩中三类 LSP 违规:

@Injectable() export class MockPaymentService implements PaymentGateway { async charge(amount: number, currency: string): Promise<PaymentResult> { // VIOLATION 1: Throws for valid input (contract says return PaymentResult) if (amount > 1000) { throw new Error('Mock does not support large amounts'); } // VIOLATION 2: Returns null instead of PaymentResult if (currency !== 'USD') { return null as any; // Real service would convert or reject properly } // VIOLATION 3: Missing required field return { success: true } as PaymentResult; // Missing transactionId! } }

三种违规类型值得单独拆解:

  1. 对合法输入抛错:契约声明charge对合法参数返回Promise<PaymentResult>,mock 却对amount > 1000抛出普通Error。若调用方仅按契约捕获PaymentFailedException,这个错误会直接冒泡成 500。
  2. 返回null:契约返回PaymentResult,mock 对非 USD 货币返回null as any。调用方sendReceipt(result)会收到null导致 NPE/TypeError。
  3. 缺少必需字段{ success: true } as PaymentResult缺少transactionId。调用方saveTransaction(result.transactionId)拿到undefined,写入数据库时静默产生脏数据——这正是规则文档强调的「subtle bugs」。

调用方完全信任契约:

@Injectable() export class OrdersService { constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} async checkout(order: Order): Promise<void> { const result = await this.payment.charge(order.total, order.currency); // These fail with MockPaymentService: await this.saveTransaction(result.transactionId); // undefined! await this.sendReceipt(result); // might be null! } }

测试里 mock 全绿、生产环境必炸,就是 LSP 被破坏的典型信号。

正确示例:同一契约、同一行为形状

修正方案不是「删掉 mock」,而是让 mock忠实复刻生产实现的行为形状。规则文档给出的正确版本值得逐行对照:

// Well-defined interface with documented behavior interface PaymentGateway { /** * Charges the specified amount. * @param amount - Amount in smallest currency unit (cents) * @param currency - ISO 4217 currency code * @returns PaymentResult with transactionId, success status, and amount * @throws PaymentFailedException if charge is declined * @throws InvalidCurrencyException if currency is not supported */ charge(amount: number, currency: string): Promise<PaymentResult>; /** * Refunds a previous charge. * @throws TransactionNotFoundException if transactionId is invalid */ refund(transactionId: string, amount?: number): Promise<RefundResult>; }

注意这里的契约升级:接口注释里显式写明了参数单位(amount以最小货币单位「分」计)、支持的货币码、返回结构、以及每个方法的异常类型。把行为写入接口文档,是 LSP 能被遵守的前提——实现者与调用方基于同一份行为规格编程。

生产实现忠实履行:

@Injectable() export class StripeService implements PaymentGateway { async charge(amount: number, currency: string): Promise<PaymentResult> { try { const response = await this.stripe.charges.create({ amount, currency }); return { success: true, transactionId: response.id, amount: response.amount, }; } catch (error) { if (error.type === 'card_error') { throw new PaymentFailedException(error.message); } throw error; } } // refund(...) 实现略 }

mock 遵循同一行为形状:校验货币时抛InvalidCurrencyException(与生产一致而非直接null)、模拟特定金额的拒付、返回包含全部必需字段PaymentResult,并且实现了完整的refund契约(事务不存在时抛TransactionNotFoundException):

@Injectable() export class MockPaymentService implements PaymentGateway { private transactions = new Map<string, PaymentResult>(); async charge(amount: number, currency: string): Promise<PaymentResult> { // Honor the contract: validate currency like real service would if (!['USD', 'EUR', 'GBP'].includes(currency)) { throw new InvalidCurrencyException(`Unsupported currency: ${currency}`); } // Simulate decline for specific test scenarios if (amount === 99999) { throw new PaymentFailedException('Card declined (test scenario)'); } // Return same shape as production const result: PaymentResult = { success: true, transactionId: `mock_${Date.now()}_${Math.random().toString(36)}`, amount, }; this.transactions.set(result.transactionId, result); return result; } async refund(transactionId: string, amount?: number): Promise<RefundResult> { // Honor the contract: throw if transaction not found if (!this.transactions.has(transactionId)) { throw new TransactionNotFoundException(transactionId); } return { success: true, refundId: `refund_${transactionId}`, amount: amount ?? this.transactions.get(transactionId)!.amount, }; } }

调用方因此可以安全地在测试与生产之间切换实现:

@Injectable() export class OrdersService { constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} async checkout(order: Order): Promise<Order> { try { const result = await this.payment.charge(order.total, order.currency); // Works with both StripeService and MockPaymentService order.transactionId = result.transactionId; order.status = 'paid'; return order; } catch (error) { if (error instanceof PaymentFailedException) { order.status = 'payment_failed'; return order; } throw error; } } }

注意错误分支只按契约中的异常类型分流(PaymentFailedException→ 标记支付失败;其余异常原样上抛)。mock 若抛出普通Error,就会逃出此分支——这正是上一节违规示例会在真实切换时暴露的原因。

用共享契约测试固化 LSP

规则文档指出,仅靠「自觉」无法长期维持 LSP,正确做法是编写一份所有实现都必须通过的共享测试套件

// Shared test suite that any implementation must pass function testPaymentGatewayContract( createGateway: () => PaymentGateway, ) { describe('PaymentGateway contract', () => { let gateway: PaymentGateway; beforeEach(() => { gateway = createGateway(); }); it('returns PaymentResult with all required fields', async () => { const result = await gateway.charge(1000, 'USD'); expect(result).toHaveProperty('success'); expect(result).toHaveProperty('transactionId'); expect(result).toHaveProperty('amount'); expect(typeof result.transactionId).toBe('string'); }); it('throws InvalidCurrencyException for unsupported currency', async () => { await expect(gateway.charge(1000, 'INVALID')) .rejects.toThrow(InvalidCurrencyException); }); it('throws TransactionNotFoundException for invalid refund', async () => { await expect(gateway.refund('nonexistent')) .rejects.toThrow(TransactionNotFoundException); }); }); } // Run against all implementations describe('StripeService', () => { testPaymentGatewayContract(() => new StripeService(mockStripeClient)); }); describe('MockPaymentService', () => { testPaymentGatewayContract(() => new MockPaymentService()); });

这套「契约测试」的价值在于:任何一个新实现(或对现有实现的改动)都必须先通过这份公共断言,违规会在 PR 阶段而不是生产故障中被拦截。这正是 test-mock-external-services.md 所强调的方向——mock 不是「随便写个假实现」,而是「行为形状与真实服务一致、且覆盖超时与错误边界」的替身。

仓库中的真实印证:以结果联合类型收敛行为形状

Comp AI CRM 里有一个比「示例代码」更能说明 LSP 的实战案例:MailboxApiClient。它不通过抛异常表达失败,而是把所有可能的响应收敛为一个可判别联合(discriminated union),任何调用方拿到的都是同一种行为形状。

mailbox-api.client.ts 定义的返回类型:

export type MailboxResult<T> = | { outcome: "ok"; data: T } | { outcome: "cursor-invalid"; reason: string } | { outcome: "unauthorized"; reason: string } | { outcome: "rate-limited"; reason: string; retryAfterMs: number } | { outcome: "failed"; reason: string; retryable: boolean };

内部对fetch的每个分支都映射到该联合的某个成员:401 → unauthorized404/410 → cursor-invalid403(命中 rate/quota 关键词)与429 → rate-limited(并附带retryAfterMs退避时间)、其余状态 →failedstatus >= 500retryable: true)。无论底层是 Gmail 还是 Google Calendar,无论网络超时还是 HTTP 错误,返回结构永远一致——这就是「实现可替换而不破坏调用方」的工程化表达。

对应测试 mailbox-api-client.spec.ts 用globalThis.fetch打桩,逐条验证契约行为:

  • 200{ outcome: "ok", data: { ok: true } }
  • Gmail 的404cursor-invalid
  • Calendar 的410cursor-invalid
  • 401unauthorized(以便调用方标记行需要重新连接)
  • quota403rate-limited(可重试、带退避时间)
  • 权限403failed(终态、不可重试)

这些断言本质上就是上面「共享契约测试」思想的单实现落地:契约被写成测试,行为形状被锁定。当调用方(如同步服务)拿到MailboxResult时,它可以放心做穷举式switch,而不用担心某个实现偷偷返回null或抛出意想不到的异常类型。

与相邻规则配合:完整落实 LSP 的检查清单

LSP 不是孤立规则。在 NestJS 中,让它真正生效需要与依赖注入类别下的兄弟规则协同:

配套规则与 LSP 的关系仓库证据
di-use-interfaces-tokens接口在编译期被擦除,必须用 Symbol/字符串令牌或抽象类做注入令牌,否则「可替换实现」无从谈起database.constants.ts 的DATABASE = Symbol("DATABASE")InjectDatabase()
di-prefer-constructor-injection构造器注入让「换实现」只发生在容器装配处,调用方代码零改动,LSP 收益最大化仓库各 Service 均以构造器@InjectDatabase()/@Inject(XxxService)注入
di-interface-segregation(ISP)接口越窄、行为约定越少,实现越容易完全履约;宽接口是 LSP 违约的高发区di-interface-segregation.md
di-scope-awareness若实现方与注入方作用域(singleton/request/transient)不一致,替换后状态行为会漂移di-scope-awareness.md
test-mock-external-services测试替身必须与真实服务「同形状、同错误处理」,正是 LSP 在测试层的落地mailbox-api-client.spec.ts 的 fetch 打桩与边界断言

useFactory动态装配是 LSP 在实际 DI 容器中的经典场景:Comp AI CRM 的 cache.module.ts 根据REDIS_URL是否存在,在工厂函数里选择 Redis 存储或进程内内存缓存——两个存储实现对外暴露的CacheOptions行为契约一致,调用方@Inject(CACHE_MANAGER)(见 auth.service.ts)无感知切换。这就是「子类型可替换而不改变程序正确性」在生产代码中的直接体现。

小结

LSP 在 NestJS 依赖注入中的落地可以浓缩为四条可执行准则:

  1. 先定义契约再写实现:接口/抽象类的 JSDoc 必须写明参数单位、返回结构、异常类型;没有文档化行为约定的接口,实现方必然各写各的。
  2. 测试替身必须与生产实现同形状:返回相同的字段集、抛出相同的异常类型、对非法输入做相同的校验。mock 不是「简化版」,而是「行为一致版」。
  3. 用共享契约测试锁定行为:让所有实现跑同一份断言套件,任何新增/修改实现都必须先通过契约测试。
  4. 用令牌与工厂装配实现可替换性:Symbol/字符串令牌 +useClass/useFactory动态选择实现,让「替换」发生在容器装配处,调用方零改动。

从 di-liskov-substitution.md 的支付网关示例,到 Comp AI CRM 中@InjectDatabase()的 Symbol 令牌、MailboxApiClient的可判别联合返回、以及AppCacheModuleuseFactory双存储切换,LSP 的本质始终如一:让「实现可以互换」成为系统的默认属性,而不是一次冒险的替换操作

  • 后端
  • 前端
  • CRM
  • 人工智能
  • AI Agent

【免费下载链接】crm

Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.

项目地址:https://gitcode.com/gh_mirrors/crm48/crm
点击查看免费下载
上一篇:Backstage v1.33.0 发布解读:目录性能优化与面包屑导航、只读文件系统配置注入、Scaffolder Node.js 22 支持等关键更新
下一篇:Semantic Kernel 中的 AWS Bedrock Agent 集成:从环境配置到多 Agent 编排实战指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询