ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

NestJS 依赖注入中的里氏替换原则(LSP)实战:以 Comp AI CRM 为例

NestJS 依赖注入中的里氏替换原则(LSP)实战:以 Comp AI CRM 为例 后端前端CRM人工智能AI Agent【免费下载链接】crmComp 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 的 CRMNestJS 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-segregation、di-prefer-constructor-injection、di-use-interfaces-tokens、di-scope-awareness、di-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 cant 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(): Promisevoid { 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.tsPrisma 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): PromisePaymentResult; }生产实现遵守契约Injectable() export class StripeService implements PaymentGateway { async charge(amount: number, currency: string): PromisePaymentResult { 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): PromisePaymentResult { // 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! } }三种违规类型值得单独拆解对合法输入抛错契约声明charge对合法参数返回PromisePaymentResultmock 却对amount 1000抛出普通Error。若调用方仅按契约捕获PaymentFailedException这个错误会直接冒泡成 500。返回null契约返回PaymentResultmock 对非 USD 货币返回null as any。调用方sendReceipt(result)会收到null导致 NPE/TypeError。缺少必需字段{ 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): Promisevoid { 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): PromisePaymentResult; /** * Refunds a previous charge. * throws TransactionNotFoundException if transactionId is invalid */ refund(transactionId: string, amount?: number): PromiseRefundResult; }注意这里的契约升级接口注释里显式写明了参数单位amount以最小货币单位「分」计、支持的货币码、返回结构、以及每个方法的异常类型。把行为写入接口文档是 LSP 能被遵守的前提——实现者与调用方基于同一份行为规格编程。生产实现忠实履行Injectable() export class StripeService implements PaymentGateway { async charge(amount: number, currency: string): PromisePaymentResult { 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契约事务不存在时抛TransactionNotFoundExceptionInjectable() export class MockPaymentService implements PaymentGateway { private transactions new Mapstring, PaymentResult(); async charge(amount: number, currency: string): PromisePaymentResult { // 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): PromiseRefundResult { // 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): PromiseOrder { 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 MailboxResultT | { 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 → unauthorized、404/410 → cursor-invalid、403命中 rate/quota 关键词与429 → rate-limited并附带retryAfterMs退避时间、其余状态 →failedstatus 500时retryable: true。无论底层是 Gmail 还是 Google Calendar无论网络超时还是 HTTP 错误返回结构永远一致——这就是「实现可替换而不破坏调用方」的工程化表达。对应测试 mailbox-api-client.spec.ts 用globalThis.fetch打桩逐条验证契约行为200→{ outcome: ok, data: { ok: true } }Gmail 的404→cursor-invalidCalendar 的410→cursor-invalid401→unauthorized以便调用方标记行需要重新连接quota403→rate-limited可重试、带退避时间权限403→failed终态、不可重试这些断言本质上就是上面「共享契约测试」思想的单实现落地契约被写成测试行为形状被锁定。当调用方如同步服务拿到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-segregationISP接口越窄、行为约定越少实现越容易完全履约宽接口是 LSP 违约的高发区di-interface-segregation.mddi-scope-awareness若实现方与注入方作用域singleton/request/transient不一致替换后状态行为会漂移di-scope-awareness.mdtest-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 依赖注入中的落地可以浓缩为四条可执行准则先定义契约再写实现接口/抽象类的 JSDoc 必须写明参数单位、返回结构、异常类型没有文档化行为约定的接口实现方必然各写各的。测试替身必须与生产实现同形状返回相同的字段集、抛出相同的异常类型、对非法输入做相同的校验。mock 不是「简化版」而是「行为一致版」。用共享契约测试锁定行为让所有实现跑同一份断言套件任何新增/修改实现都必须先通过契约测试。用令牌与工厂装配实现可替换性Symbol/字符串令牌 useClass/useFactory动态选择实现让「替换」发生在容器装配处调用方零改动。从 di-liskov-substitution.md 的支付网关示例到 Comp AI CRM 中InjectDatabase()的 Symbol 令牌、MailboxApiClient的可判别联合返回、以及AppCacheModule的useFactory双存储切换LSP 的本质始终如一让「实现可以互换」成为系统的默认属性而不是一次冒险的替换操作。赞分享后端前端CRM人工智能AI Agent【免费下载链接】crmComp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.项目地址https://gitcode.com/gh_mirrors/crm48/crm点击查看免费下载相关推荐终极指南macOS上如何彻底阻止iTunes和Apple Music自动启动终极指南macOS上如何彻底阻止iTunes和Apple Music自动启动 你是否经常被macOS上iTunes或Apple Music的自动启动打断工作流桌面应用如何掌握PHP里氏替换原则clean-code-php中的LSP实践指南如何掌握PHP里氏替换原则clean code php中的LSP实践指南 clean code php 是一个专注于将Clean Code概念应用于PHP开发教程rsschool-app 后端中的 Liskov 替换原则NestJS 依赖注入下的可替换实现设计rsschool app 后端中的 Liskov 替换原则NestJS 依赖注入下的可替换实现设计 本文基于本仓库 .agents/skills/nestjs教育后端前端上一篇Backstage v1.33.0 发布解读目录性能优化与面包屑导航、只读文件系统配置注入、Scaffolder Node.js 22 支持等关键更新下一篇Semantic Kernel 中的 AWS Bedrock Agent 集成从环境配置到多 Agent 编排实战指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表