使用 TypeScript 和 InversifyJS 在 Node.js 中实现 SOLID 和洋葱架构的先决条件

2025-05-26

使用 TypeScript 和 InversifyJS 在 Node.js 中实现 SOLID 和洋葱架构

先决条件

在本文中,我们将描述一种称为洋葱架构的架构。洋葱架构是一种遵循 SOLID 原则的软件应用程序架构。它广泛使用依赖注入原则,并深受领域驱动设计 (DDD) 原则和一些函数式编程原则的影响。

先决条件

以下部分描述了我们必须学习的一些软件设计原则和设计模式,以便能够理解洋葱架构。

关注点分离(SoC)原则

关注点是指软件功能的不同方面。例如,软件的“业务逻辑”是一个关注点,而用户使用该逻辑的界面又是另一个关注点。

关注点分离是指将每个关注点的代码分离。更改接口不应该需要更改业务逻辑代码,反之亦然。

SOLID 原则

SOLID 是以下五项原则的首字母缩写:

单一职责原则

一个类应该只有一个职责

破解应用程序最有效的方法是创建 GOD 类。

上帝类是指知道太多或做太多事情的类。上帝对象就是反模式的一个例子。

上帝类会跟踪大量信息,并承担多项职责。一次代码更改很可能会影响该类的其他部分,从而间接影响所有使用它的类。这反过来会导致更大的维护混乱,因为除了添加新功能外,没有人敢对其进行任何更改。

以下示例是定义 Person 的 TypeScript 类;此类不应包含电子邮件验证,因为这与人员行为无关:

class Person {
    public name : string;
    public surname : string;
    public email : string;
    constructor(name : string, surname : string, email : string){
        this.surname = surname;
        this.name = name;
        if(this.validateEmail(email)) {
          this.email = email;
        }
        else {
            throw new Error("Invalid email!");
        }
    }
    validateEmail(email : string) {
        var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
        return re.test(email);
    }
    greet() {
        alert("Hi!");
    }
}
Enter fullscreen mode Exit fullscreen mode

我们可以通过从 Person 类中删除电子邮件验证的责任并创建一个新的 Email 类来改进上面的类,以承担该责任:

class Email {
    public email : string;
    constructor(email : string){
        if(this.validateEmail(email)) {
          this.email = email;
        }
        else {
            throw new Error("Invalid email!");
        }        
    }
    validateEmail(email : string) {
        var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
        return re.test(email);
    }
}

class Person {
    public name : string;
    public surname : string;
    public email : Email;
    constructor(name : string, surname : string, email : Email){
        this.email = email;
        this.name = name;
        this.surname = surname;
    }
    greet() {
        alert("Hi!");
    }
}
Enter fullscreen mode Exit fullscreen mode

确保一个类具有单一职责,默认情况下也更容易看到它的作用以及如何扩展/改进它。

开闭原则

软件实体应该对扩展开放,但对修改关闭。

下面的代码片段是一段不遵守开放/关闭原则的代码示例:

class Rectangle {
    public width: number;
    public height: number;
}

class Circle {
    public radius: number;
}

function getArea(shapes: (Rectangle|Circle)[]) {
    return shapes.reduce(
        (previous, current) => {
            if (current instanceof Rectangle) {
                return current.width * current.height;
            } else if (current instanceof Circle) {
                return current.radius * current.radius * Math.PI;
            } else {
                throw new Error("Unknown shape!")
            }
        },
        0
    );
}
Enter fullscreen mode Exit fullscreen mode

上面的代码片段使我们能够计算两种形状(矩形和圆形)的面积。如果我们尝试添加对新形状的支持,则需要扩展我们的程序。当然,我们可以添加对新形状的支持(我们的应用程序是开放扩展的),但问题在于,我们需要修改 getArea 函数,这意味着我们的应用程序也同样开放修改。

解决这个问题的方法是利用面向对象编程中的多态性,如下面的代码片段所示:

interface Shape {
    area(): number;
}

class Rectangle implements Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }
}

class Circle implements Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }
}

function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}
Enter fullscreen mode Exit fullscreen mode

新的解决方案允许我们添加对新形状的支持(开放扩展),而无需修改现有的源代码(关闭修改)。

里氏替代原则

程序中的对象应该可以用其子类型的实例替换,而不会改变该程序的正确性。

里氏替换原则也鼓励我们在面向对象编程中利用多态性。在上面的例子中:

function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}
Enter fullscreen mode Exit fullscreen mode

我们使用接口Shape来确保程序对扩展开放,但对修改关闭。里氏替换原则告诉我们,我们应该能够将 的任何子类型传递Shape给函数,getArea而不会改变程序的正确性。在像 TypeScript 这样的静态编程语言中,编译器会帮我们检查子类型的正确实现(例如,如果 的实现Shape缺少area方法,就会出现编译错误)。这意味着我们无需手动操作即可确保应用程序遵循里氏替换原则。

接口隔离原则

许多特定于客户端的接口比一个通用接口更好。

接口隔离原则有助于我们避免违反单一职责原则和关注点分离原则。
假设您有两个领域实体:Rectangle 和 Circle。您一直在领域服务中使用这些实体来计算它们的面积,并且运行良好,但现在您需要能够在某个基础架构层中序列化它们。我们可以通过在 Shape 接口中添加一个额外的方法来解决这个问题:

interface Shape {
    area(): number;
    serialize(): string;
}

class Rectangle implements Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }

    public serialize() {
        return JSON.stringify(this);
    }
}

class Circle implements  Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }

    public serialize() {
        return JSON.stringify(this);
    }

}
Enter fullscreen mode Exit fullscreen mode

我们的领域层需要区域方法(来自Shape接口),但它不需要了解任何有关序列化的知识:

function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}
Enter fullscreen mode Exit fullscreen mode

我们的基础设施层需要序列化方法(来自Shape接口),但它不需要了解该区域的任何信息:

// ...
return rectangle.serialize();
Enter fullscreen mode Exit fullscreen mode

问题在于,在 Shape 接口中添加一个名为 serialize 的方法违反了 SoC 原则和单一职责原则。Shape 接口关注的是业务方面,而可序列化则关注的是基础架构方面。我们不应该在同一个接口中混合考虑这两个方面。

接口隔离原则告诉我们,多个客户端特定接口比一个通用接口更好,这意味着我们应该拆分接口:

interface RectangleInterface {
    width: number;
    height: number;
}

interface CircleInterface {
    radius: number;
}

interface Shape {
    area(): number;
}

interface Serializable {
    serialize(): string;
}
Enter fullscreen mode Exit fullscreen mode

使用新的接口,我们以一种与序列化等基础设施问题完全隔离的方式实现了我们的领域层:

class Rectangle implements RectangleInterface, Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }
}

class Circle implements CircleInterface, Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }
}

function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}
Enter fullscreen mode Exit fullscreen mode

在基础设施层,我们可以使用一组处理序列化的新实体:

class RectangleDTO implements RectangleInterface, Serializable {

    public width: number;
    public height: number;

    public serialize() {
        return JSON.stringify(this);
    }
}

class CircleDTO implements CircleInterface, Serializable {

    public radius: number;

    public serialize() {
        return JSON.stringify(this);
    }
}
Enter fullscreen mode Exit fullscreen mode

使用多个接口而不是一个通用接口帮助我们避免违反SoC原则(业务层对序列化一无所知)和单一责任原则(我们没有一个既知道序列化又知道面积计算的上帝类)。

我们可以认为RectangleDTO和 矩形Rectangle几乎完全相同,并且它们违反了“不要重复自己”(DRY)原则。但我不这么认为,因为虽然它们看起来一样,但它们涉及两个不同的关注点。两段代码看起来相似,并不总是意味着它们是同一件事。

而且,即使它们违反了 DRY 原则,我们也必须在违反 DRY 原则和 SOLID 原则之间做出选择。我认为 DRY 原则不如 SOLID 原则重要,因此,在这个特定情况下,我宁愿“重复自己”。

依赖倒置原则

人们应该依赖抽象,而不是具体。

依赖倒置原则告诉我们,我们应该始终尝试依赖接口,而不是类。需要注意的是,依赖倒置和依赖注入不是一回事。

不幸的是,依赖倒置原则在 SOLID 中用字母 D 来表示。它总是最后解释的原则,但它却是 SOLID 中最重要的原则。如果没有依赖倒置原则,大多数其他 SOLID 原则都无法实现。如果我们回过头来重新审视之前解释过的所有原则,就会发现接口的使用是每个原则中最基本的元素之一:

  • 依赖遵循接口隔离原则的接口可以让我们将一层与另一层的实现细节(SoC 原则)隔离开来,并帮助我们防止违反单一责任原则。

  • 依赖接口还允许我们用另一个实现替换一个实现(里氏替换原则)。

  • 依靠接口,我们可以编写对扩展开放但对修改关闭的应用程序(开放/关闭原则)。

在不支持接口的编程语言或不支持多态性的编程范式中实现 SOLID 原则是非常不自然的。例如,在 JavaScript ES5 甚至 ES6 中实现 SOLID 原则感觉非常不自然。然而,在 TypeScript 中,它感觉尽可能自然。

模型-视图-控制器 (MVC) 设计模式

MVC 设计模式将应用程序分为三个主要组件:模型、视图和控制器。

模型

模型对象是应用程序的一部分,用于实现应用程序数据域的逻辑。通常,模型对象会检索模型状态并将其存储在数据库中。例如,Product 对象可能会从数据库中检索信息,对其进行操作,然后将更新后的信息写回到 SQL Server 数据库中的 Products 表中。

在小型应用程序中,模型通常是概念上的分离,而不是物理上的分离。例如,如果应用程序仅读取数据集并将其发送到视图,则该应用程序没有物理模型层和关联的类。在这种情况下,数据集将充当模型对象的角色。

看法

视图是显示应用程序用户界面 (UI) 的组件。通常,此 UI 是根据模型数据创建的。例如,Products 表的编辑视图会根据 Product 对象的当前状态显示文本框、下拉列表和复选框。

控制器

控制器是处理用户交互、与模型协作并最终选择要渲染的视图来显示 UI 的组件。在 MVC 应用程序中,视图仅显示信息;控制器处理并响应用户输入和交互。例如,控制器处理查询字符串值并将这些值传递给模型,而模型又可能使用这些值来查询数据库。

MVC 模式可帮助您创建分离应用程序不同方面(输入逻辑、业务逻辑和 UI 逻辑)的应用程序,同时在这些元素之间提供松散耦合。该模式指定了每种逻辑在应用程序中的位置。UI 逻辑位于视图中。输入逻辑位于控制器中。业务逻辑位于模型中。这种分离有助于您在构建应用程序时管理复杂性,因为它使您能够一次专注于实现的一个方面。例如,您可以专注于视图而不依赖于业务逻辑。

MVC 应用程序三个主要组件之间的松散耦合也促进了并行开发。例如,一位开发人员可以处理视图,另一位开发人员可以处理控制器逻辑,第三位开发人员可以专注于模型中的业务逻辑。模型-视图-控制器 (MVC) 设计模式是分离这些关注点以提高软件可维护性的绝佳示例。

存储库和数据映射器设计模式

MVC 模式帮助我们解耦输入逻辑、业务逻辑和 UI 逻辑。然而,模型负责的事情太多了。我们可以使用存储库模式,将检索数据并将其映射到实体模型的逻辑与作用于模型的业务逻辑分离。业务逻辑应该与构成数据源层的数据类型无关。例如,数据源层可以是数据库、静态文件或 Web 服务。

存储库在应用程序的数据源层和业务层之间充当中介。它查询数据源中的数据,将数据从数据源映射到业务实体,并将业务实体中的更改持久化到数据源。存储库将业务逻辑与底层数据源的交互分离开来。数据层和业务层的分离有三个好处:

  • 它集中了数据逻辑或 Web 服务访问逻辑。
  • 它为单元测试提供了一个替代点。
  • 它提供了一种灵活的架构,可以随着应用程序的整体设计的发展而进行调整。

存储库代表客户端创建查询。存储库返回一组满足查询的匹配实体。存储库还会持久保存新的或更改的实体。下图展示了存储库与客户端和数据源的交互。

存储库是不同域中的数据和操作之间的桥梁。一种常见的情况是从数据为弱类型的域(例如数据库)映射到对象为强类型的域(例如域实体模型)。

存储库向数据源发出适当的查询,然后将结果集映射到外部公开的业务实体。存储库通常使用数据映射器模式在不同表示形式之间进行转换。

存储库消除了调用客户端对特定技术的依赖。例如,如果客户端调用目录存储库来检索某些产品数据,则只需使用目录存储库接口即可。例如,客户端无需知道产品信息是通过对数据库的 SQL 查询还是对 SharePoint 列表的协作应用程序标记语言 (CAML) 查询来检索的。隔离这些类型的依赖关系可以为改进实现提供灵活性。

洋葱架构

洋葱架构将应用程序划分为圆形层(像洋葱一样):

中心层是领域模型。随着我们向外层移动,我们可以看到领域服务、应用服务,最后是测试层、基础设施层和 UI 层。

在 DDD 中,一切的中心就是所谓的“领域”。领域由两个主要部分组成:

  • 领域模型
  • 领域服务

在函数式编程中,主要的架构原则之一是将副作用推到应用程序的边界。洋葱架构也遵循这一原则。应用程序核心(领域服务和领域模型)应该没有副作用和实现细节,这意味着不应该引用诸如数据持久化(例如 SQL)或数据传输(例如 HTTP)之类的实现细节。

领域模型和领域服务对数据库、协议、缓存或任何其他特定于实现的问题一无所知。应用程序核心只关注业务的特性和规则。外部层(基础设施、测试和用户界面)是与系统资源(网络、存储等)交互的层,副作用被隔离并远离应用程序核心。

层与层之间的分离是通过使用接口和依赖倒置原则来实现的:组件应该依赖于抽象(接口)而不是具体(类)。例如,基础设施层之一是 HTTP 层,它主要由控制器组成。名为 的控制器AircraftController可以依赖于名为 AircraftRepository 的接口:

import { inject } from "inversify";
import { response, controller, httpGet } from "inversify-express-utils";
import * as express from "express";
import { AircraftRepository } from "@domain/interfaces";
import { Aircraft } from "@domain/entitites/aircraft";
import { TYPE } from "@domain/types";

@controller("/api/v1/aircraft")
export class AircraftController {

    @inject(TYPE.AircraftRepository) private readonly _aircraftRepository: AircraftRepository;

    @httpGet("/")
    public async get(@response() res: express.Response) {
        try {
            return await this._aircraftRepository.readAll();
        } catch (e) {
            res.status(500).send({ error: "Internal server error" });
        }

    }

    // ...

}
Enter fullscreen mode Exit fullscreen mode

AircraftController是基础架构层的一部分,其主要职责是处理与 HTTP 相关的问题,并将工作委托给 。AircraftRepository实现AircraftRepository应该完全不了解任何 HTTP 问题。此时,我们的依赖图如下所示:

图中的箭头含义各异,“comp”箭头表示AircraftRepository是 (组合) 的一个属性AircraftController。“ref”箭头表示 对其AircraftController具有引用或依赖关系Aircraft

接口AircraftRepository是领域服务的一部分,而AircraftControllerAircraftRepository实现是基础设施层的一部分:

这意味着我们有一个从外层(基础设施)到内层(领域服务)的引用。在洋葱架构中,我们只允许从外层引用内层,而不允许反过来:

我们AircraftRepository在设计时使用接口将领域层与基础设施层解耦。然而,在运行时,这两层必须以某种方式连接起来。接口和实现之间的这种“连接”由 InversifyJS 管理。InversifyJS 允许我们使用@inject装饰器声明要注入的依赖项。在设计时,我们可以声明希望注入接口的实现:

@inject(TYPE.AircraftRepository) private readonly _aircraftRepository: AircraftRepository;
Enter fullscreen mode Exit fullscreen mode

在运行时,InversifyJS 将使用其配置来注入实际的实现:

container.bind<AircraftRepository>(TYPE.AircraftRepository).to(AircraftRepositoryImpl);
Enter fullscreen mode Exit fullscreen mode

我们现在将看一下领域服务层的一部分AircratRepository和接口。Repository<T>

import { Aircraft } from "@domain/entitites/aircraft";

export interface Repository<T> {
    readAll(): Promise<T[]>;
    readOneById(id: string): Promise<T>;
    // ...
}

export interface AircraftRepository extends Repository<Aircraft> {
    // Add custom methods here ...
}
Enter fullscreen mode Exit fullscreen mode

此时,我们的依赖图如下所示:

我们现在需要实现Repository<T>接口和AircraftRepository接口:

  • Repository<T>将由名为GenericRepositoryImpl<D, E>

  • AircraftRepository将由名为的类来实现AircraftRepositoryImpl

让我们开始实现Repository<T>

import { injectable, unmanaged } from "inversify";
import { Repository } from "@domain/interfaces";
import { EntityDataMapper } from "@dal/interfaces";
import { Repository as TypeOrmRepository } from "typeorm";

@injectable()
export class GenericRepositoryImpl<TDomainEntity, TDalEntity> implements Repository<TDomainEntity> {

    private readonly _repository: TypeOrmRepository<TDalEntity>;
    private readonly _dataMapper: EntityDataMapper<TDomainEntity, TDalEntity>;

    public constructor(
        @unmanaged() repository: TypeOrmRepository<TDalEntity>,
        @unmanaged() dataMapper: EntityDataMapper<TDomainEntity, TDalEntity>
    ) {
        this._repository = repository;
        this._dataMapper = dataMapper;
    }

    public async readAll() {
        const entities = await this._repository.readAll();
        return entities.map((e) => this._dataMapper.toDomain(e));
    }

    public async readOneById(id: string) {
        const entity = await this._repository.readOne({ id });
        return this._dataMapper.toDomain(entity);
    }

    // ...

}
Enter fullscreen mode Exit fullscreen mode

此特定Repository<T>实现需要通过其构造函数注入anEntityDataMapper和 a 。然后,它使用这两个依赖项从数据库中读取数据,并将结果映射到域实体。TypeOrmRepository

我们还需要EntityDataMapper接口:

export interface EntityDataMapper<Domain, Entity> {

    toDomain(entity: Entity): Domain;
    toDalEntity(domain: Domain): Entity;
}
Enter fullscreen mode Exit fullscreen mode

实施如下EntityDataMapper

import { toDateOrNull, toLocalDateOrNull } from "@lib/universal/utils/date_utils";
import { Aircraft } from "@domain/entitites/aircraft";
import { AircraftEntity } from "@dal/entities/aircraft";
import { EntityDataMapper } from "@dal/interfaces";

export class AircraftDataMapper implements EntityDataMapper<Aircraft, AircraftEntity> {

    public toDomain(entity: AircraftEntity): Aircraft {
        // ...
    }

    public toDalEntity(mortgage: Aircraft): AircraftEntity {
        // ...
    }
}

Enter fullscreen mode Exit fullscreen mode

我们使用EntityDataMapper将 返回的实体映射TypeOrmRepository到我们的域实体。此时,我们的依赖关系图如下所示:

我们最终可以实现AircraftRepository


import { inject, injectable } from "inversify";
import { Repository as TypeOrmRepository } from "typeorm";
import { AircraftRepository } from "@domain/interfaces";
import { Aircraft } from "@domain/entitites/aircraft";
import { GenericRepositoryImpl } from "@dal/generic_repository";
import { AircraftEntity } from "@dal/entities/aircraft";
import { AircraftDataMapper } from "@dal/data_mappers/aircraft";
import { TYPE } from "@dal/types";

@injectable()
export class AircraftRepositoryImpl
    extends GenericRepositoryImpl<Aircraft, AircraftEntity>
    implements AircraftRepository {

    public constructor(
        @inject(TYPE.TypeOrmRepositoryOfAircraftEntity) repository: TypeOrmRepository<AircraftEntity>
    ) {
        super(repository, new AircraftDataMapper())
    }

    // Add custom methods here ...

}

Enter fullscreen mode Exit fullscreen mode

至此,我们已经完成了,我们的依赖图如下所示:

上图使用颜色来标识具体内容(类,蓝色)和抽象内容(接口,橙色):

下图使用颜色来标识属于领域层的组件(绿色)和属于基础设施层的组件(蓝色):

在过去十年的大型企业软件项目中,这种架构对我来说非常有效。我还将一些庞大的单体“洋葱”拆分成遵循相同架构的微服务。我喜欢说,当我们拥有实现洋葱架构的微服务时,我们就拥有了一袋“洋葱”。

希望你喜欢这篇文章!欢迎在评论区留言,或者在@RemoHJansen留言告诉我你的想法。

文章来源:https://dev.to/remojansen/implementing-the-onion-architecture-in-nodejs-with-typescript-and-inversifyjs-10ad
PREV
开发人员认为理所当然的 4 件事,过去却非常困难
NEXT
React 设计模式总结