从设计原则到依赖注入的简要分析

121 阅读10分钟

在编程过程中常见的代码写法有下面两种,我们通过一个示例去说明这两种写法的优缺点,以及我们更推荐使用那种方式去实现。

方法一:

class AddItemHandler1 {
  constructor(readonly ticket: TicketState, readonly menuItem: MenuItem) {
  }

  handle() {
    const orderItem = new OrderItemFactory(this.menuItem);
    
    this.ticket.items.push();
  }
}

方法二:

class AddItemHandler2 {
    handle(payload: {
        ticket: TicketState;
        menuItem: MenuItem;
    }) {
        const orderItem = new OrderItemFactory(this.menuItem);
        payload.ticket.items.push(orderItem)
    }
}

第一种:类的内部属性的方式

特点

  • 面向对象风格:类通过构造函数接收依赖并存储为内部属性,这种方式符合面向对象编程(OOP)的设计理念。
  • 状态性:类实例可以保存上下文(ticketmenuItem),便于在实例生命周期内多次使用。
  • 封装性:将依赖封装在对象中,不需要在调用方法时显式传入。

优点

  1. 可读性好:调用时更简洁直观,例如:
const handler = new AddItemHandler1(ticket, menuItem);
handler.handle();
  1. 扩展性强:如果后续需要添加更多方法或属性,这种写法较为自然。
  2. 便于维护复杂逻辑:当AddItemHandler1需要管理多个依赖并在多种方法中使用时,内部属性可以减少参数传递。

缺点

  1. 适用范围有限:对于简单的、一次性操作,显得有些重量级。
  2. 测试略麻烦:单元测试需要创建完整的对象实例,可能会显得冗余。
  3. 潜在副作用:类属性的持久化状态可能导致意外的副作用。
  4. 内存占用高:不同的ticket、menu item都需要实例化出新的对象去处理。

第二种:更像是纯函数的方式

特点

  • 函数式风格:类的方法不依赖于内部状态,仅通过参数进行操作。
  • 无状态性:类本身不存储状态,每次调用完全依赖于输入参数。

优点

  1. 灵活性强:无需创建实例就能直接调用,例如:
const handler = new AddItemHandler2();
handler.handle({ ticket, menuItem });

或者完全移除类,仅保留为独立函数:

function addOrderItemToTicket({ ticket, menuItem }: { ticket: TicketState; menuItem: MenuItem }) {
    const orderItem = new OrderItemFactory(this.menuItem);
    payload.ticket.items.push(orderItem)
}
  1. 易测试:无状态特性使得测试只需要传入不同的输入,便于构建测试用例。
  2. 低耦合:方法与上下文解耦,更容易复用。
  3. 内存占用低:因其可以作为纯函数,所以可以只初始化一次。

缺点

  1. 可读性稍差:如果依赖的参数较多,调用时需要显式地组装对象。
  2. 扩展性较差:如果操作逻辑需要访问大量依赖,参数对象可能变得复杂,不如内部属性直观。
适用场景
  • 偏好函数式编程风格的代码库。
  • 当操作是无状态且与上下文解耦的(例如,通用工具函数)。
  • 简单的、一次性的逻辑操作。

3. 选择建议

场景推荐写法
操作逻辑复杂、需要保存状态或依赖第一种
操作简单、无状态,适合工具函数第二种
代码库的整体风格偏函数式编程第二种
更注重封装性和扩展性第一种

1. 为什么第二种方式更合适?

a. 内存占用更低
  • 第一种方式(AddItemHandler1): 每次处理新的 ticketmenuItem 都需要创建一个新的实例,实例会将这两个对象存储为内部属性。如果同时存在大量的 ticketmenuItem,将导致许多无用实例堆积在内存中,尤其当实例生命周期较短但需要频繁创建时,这种开销会更明显。
  • 第二种方式(AddItemHandler2): 只需要实例化一次,甚至可以直接使用静态方法或者纯函数的形式,完全避免实例化的内存开销。
class AddItemHandler2 {
    static handle(payload: { ticket: TicketState; menuItem: MenuItem }) {
        const orderItem = new OrderItemFactory(this.menuItem);
        payload.ticket.items.push(orderItem)
    }
}

// 使用时:
AddItemHandler2.handle({ ticket, menuItem });
b. 函数式思想更简洁
  • 第二种方式与函数式编程的理念一致:无状态、纯输入输出。这种设计简洁、灵活且便于复用,避免了第一种方式中构造函数传递参数和保存内部状态的额外步骤。
c. 线程安全性
  • 第二种方式更容易保证线程安全,因为它没有持久化状态。无论何时调用 handle,只依赖输入数据,彼此之间不会干扰。
  • 第一种方式的状态保存在实例中,如果实例被意外共享或重用,可能会引发状态泄漏或竞争问题。
d. 单一职责原则(SRP)
  • 第二种方式中的 handle 方法单一且清晰:接收 ticketmenuItem,完成处理后即返回。
  • 第一种方式将依赖的构造、保存和处理混杂在一个类中,稍显冗余。

2. 关于内存问题的分析

第一种方式的内存开销
  • 假设每个 AddItemHandler1 保存了一个 ticketmenuItem,随着业务量增长,实例数量成倍增加。
  • 如果 ticketmenuItem 是较大的对象(例如包含大量数据的复杂结构),这些实例的总内存占用会显著上升。
  • 同时,垃圾回收(GC)需要定期回收这些短生命周期的实例,会导致额外的性能开销。
第二种方式的优化
  • 第二种方式完全避免了为每次操作创建独立实例的需求,handle 方法只在运行时处理输入数据,内存占用更可控。
  • 如果将其设计为静态方法或纯函数,甚至可以减少类本身的存储需求。

3. 综合分析:绝大多数场景的推荐实践

特性第一种(AddItemHandler1)第二种(AddItemHandler2)
内存占用高,每个任务需要实例化对象并存储状态低,无状态操作,仅依赖参数
适用场景长生命周期的复杂操作,依赖状态封装短生命周期的无状态操作,或高频简单任务
可扩展性适合扩展复杂方法,但操作代码集中在一个类中灵活扩展,符合函数式思想
调用简洁性较简单,但需要先创建实例直接调用方法(或静态方法),不需要实例化
线程安全性较弱,依赖实例状态强,无状态天然线程安全

因此,如果你的场景主要是处理高频、独立、无状态的任务,第二种方式不仅在性能、内存上更优,还具有更高的简洁性和可维护性。


4. 实践建议

如果确实发现业务场景绝大部分符合第二种方式的特点,可以考虑直接将 AddItemHandler2 设计为一个纯函数工具类,甚至可以移除类的结构,直接用独立函数来实现,例如:

纯函数实现
function handleTicketItem(ticket: TicketState, menuItem: MenuItem): void {
    const orderItem = new OrderItemFactory(this.menuItem);
    ticket.items.push(orderItem)
}

// 使用时:
handleTicketItem(ticket, menuItem);
静态方法实现

如果需要在更大的项目中管理这类函数,可以用一个静态工具类来组织:

class TicketHandler {
    static addItem(ticket: TicketState, menuItem: MenuItem): void {
        const orderItem = new OrderItemFactory(this.menuItem);
        ticket.items.push(orderItem)
    }
}

// 使用时:
TicketHandler.addItem(ticket, menuItem);

这种方式既保留了函数式编程的简洁,又满足了面向对象的组织需求,兼具灵活性和结构性。


5. 总结

大多数场景更适合第二种方式,因为它更高效、简洁且灵活。如果担心第一种方式的内存开销、线程安全或实例管理问题,可以优先选择第二种,甚至进一步优化为纯函数实现。这样不仅代码更符合现代开发的实践,还能在性能和可维护性上获益。 😊


为什么第二种方式更容易实现依赖注入?

第一种方式(AddItemHandler1)的限制
  • 强绑定依赖AddItemHandler1 的依赖(ticketmenuItem)通过构造函数直接传入并存储为类的内部属性。这种设计使得实例在创建时就绑定了具体的依赖,无法轻松替换。
  • 依赖重用性差:如果需要对不同的 ticketmenuItem 进行处理,就不得不重新创建新的实例,这限制了依赖的动态性。
  • 对测试不友好:在测试时,需要实例化整个类并提供所有依赖,即便有些方法并不需要这些依赖。
第二种方式(AddItemHandler2)的优势
  • 无状态设计AddItemHandler2 的方法通过参数显式传递依赖,不绑定到类实例中,这种无状态设计让依赖注入变得简单。
  • 动态注入依赖:只需要在调用方法时提供参数,参数可以来源于不同的上下文或配置,而无需创建新的实例。例如:
const mockTicket = { items: [] };
const mockMenuItem = { id: 1, name: "Test Item" };

handler.handle({ ticket: mockTicket, menuItem: mockMenuItem });

这使得依赖注入的实现非常轻松。

  • 灵活的依赖管理:第二种方式允许你从外部统一管理依赖,通过注入不同的依赖,可以轻松调整逻辑行为。例如,可以通过工厂方法或依赖注入容器动态生成依赖,适配不同场景。

1. 结合依赖注入的改进设计

结合 DI 容器

如果项目中使用了依赖注入框架(如 InversifyJSNestJS),第二种方式更加契合其设计。以下是一个示例:

import { injectable, inject } from "inversify";

@injectable()
class TicketService {
    @injectable()
    readonly orderItemFactory: OrderItemFactory;
  
    handle(ticket: TicketState, menuItem: MenuItem) {
        const orderItem = this.orderItemFactory.create(menuItem);
      
        ticket.items.push(orderItem);
    }
}

// 使用依赖注入容器:
const container = new Container();
container.bind<OrderItemFactory>(OrderItemFactory).toSelf();
container.bind<TicketService>(TicketService).toSelf();

const ticketService = container.get(TicketService);
ticketService.handle(ticket, menuItem);

这种设计完全符合依赖注入的核心思想,依赖通过外部容器管理和传递,而不是由类自己构造或维护。

函数式编程的依赖注入

如果进一步简化为纯函数,可以通过参数实现依赖注入,例如:

function handleTicketItem(ticket: TicketState, menuItem: MenuItem, factory: (menuItem: MenuItem) => OrderItemState): void {
  const orderItem = factory(menuItem);  
  ticket.items.push(orderItem);
}

// 注入依赖:
handleTicketItem(ticket, menuItem, (menuItem: MenuItem): OrderItemState => {
  return {
    id: menuItem.id,
    name: menuItem.name,
  }
});

通过这种设计,你甚至可以轻松为函数添加新的依赖,而不需要修改函数的核心逻辑。


2. 为什么依赖注入如此重要?

a. 提高测试能力
  • 测试代码时,可以传入 mock 对象模拟依赖
  • 第二种方式中,测试只需要组装一个简单的参数对象,而不需要创建复杂的类实例。例如:
const mockTicket = { items: [] };
const mockMenuItem = { id: 1 };

const mockFactory = (menuItem: MenuItem) => {
  return {
    id: menuItem.id
  }
}

handleTicketItem(mockTicket, mockMenuItem, mockFactory);

const mockOrderItem = mockFacoty(mockMenuItem);
expect(mockTicket.items).toContain(mockOrderItem);
b. 提高代码灵活性
  • 可以在运行时动态替换依赖,而不需要修改类的实现。
  • 例如,在不同环境(如开发、测试、生产)下,可以注入不同的依赖,如日志工具、配置等。
c. 避免强耦合
  • 第二种方式避免了对固定依赖的强绑定,更容易调整代码逻辑。
  • 例如,如果业务逻辑从直接修改 **ticket.items** 转变为通过服务类管理,你只需要替换传入的依赖。

3. 结合第一种方式的场景分析

虽然第一种方式在依赖注入上显得笨重,但也有一些适合的场景:

  • 复杂逻辑的封装:当类需要处理多个操作,依赖注入的成本可能更高,而封装到类中可以更好地管理。
  • 需要状态持久化的场景:如果 ticketmenuItem 是需要频繁访问的状态,将它们存储在类的实例中可能更高效。

不过,即便在这种场景中,也可以通过工厂模式或者容器注入优化实例的创建过程。


4. 总结

  • 第二种方式因其无状态设计,更符合现代依赖注入的最佳实践,能够大幅提高代码的灵活性、可维护性和测试能力。
  • 如果项目需要使用依赖注入框架(如 **InversifyJS****NestJS**),第二种方式更容易与这些框架集成。
  • 最佳实践建议
    • 优先使用无状态函数或静态方法;
    • 结合依赖注入容器动态管理依赖;
    • 在需要状态管理的特殊场景下,再考虑第一种方式,但配合工厂模式优化实例管理。