为什么我不使用DI容器 | Node.js w/ TypeScript

105 阅读8分钟

我收到很多关于使用哪个DI容器的邮件。InversifyJS?Awilix?

"Angular和NestJS使用他们自己的反转控制容器,那么你为什么不呢?"

因为我真的还不需要。

我一直在等待我的依赖性失去控制的那一刻,这样我就能诚实地、真诚地看到利用DI容器的必要性。

但我还没到那一步。

我还需要补充的是,我的主要代码库有超过15万行的代码,并且已经存活并积极开发了大约3年。

虽然,在我了解到清洁架构和如何将逻辑组织成层之前

先决条件。

你必须有这么高才能坐这个车。

这里有一些你应该熟悉的东西,以便加入到对话中来。

建立一个博客

让我们想象一下,我们正在建立一个网站,该网站有一个博客,你可以注册成为author ,创建posts ,并在posts ,写comments

同时,假设其他users ,也可以在你的postscomment

第一件要做的事是什么?弄清楚角色和用例,以便按组件打包

第1步:按组件打包

前端开发者已经在用Angular做这件事了,他们甚至可能不知道。

按组件打包是Bob Martin在Clean Architecture中写的,我在我的免费电子书"Name, Construct & Structure | Organizing Readable Codebases "中也写到了这一点。

在书中,他说我们应该 "围绕应用程序的用例组织我们的代码"。这样做将创建一个项目,其中文件夹的名称实际上是对我们正在解决的问题的领域的尖叫。他称之为 "尖叫的架构"。

请看一个能让人们交易黑胶唱片的前端应用程序的文件夹结构。它可能看起来像这样。

src
  modules 
    admin             # Admin "actor" (all admin modules/components below)
      analytics         # Analytics module
        components/     # Dumb components
        containers      # Smart components
        pages/          # Pages
        redux/          # State management for this module
        services/       # API adapters
        styles/         # Styles
        index.ts        
      dashboard/        # Admin Dashboard module...
      users/            # Admin view of users module...
    shared/           # Shared (admins, traders, etc)
      login/            # Login module...
    traders           # Trader "actor" (all trader modules/components below)
      dashboard/        # Trader Dashboard module...
      register/         # Trader registration module...
      trades/           # Trades module...
  models/
  utils/

黑胶交易前端应用程序的项目结构

注意到Admin 有一个analyticsdashboardusers 模块吗?在Angular中,你不得不导出模块并使用这些模块来连接所有的东西。

Angular迫使你思考如何将组件凝聚在一起

在这种情况下,组件这个词也可以指模块(有些人称之为Package by Module而不是Package by Component)。

这也是我在后端开发中所做的。

将代码组织成有凝聚力的模块......以用例为中心。


围绕用例构建项目

回到博客上,如果我不得不围绕用例来组织项目,文件夹结构会是这样的。

src
  modules
    blog                    # Blog module
      authors               # Authors component
        domain                # Author module entities
          Author.ts
        repos                 # Author module repos
          AuthorRepo.ts
        useCases              # Author module use cases
          createAuthor                # <== Let's look at this one
            CreateAuthorController.ts
            CreateAuthorErrors.ts
            CreateAuthorUseCase.ts
            CreateAuthorUseCase.spec.ts
            index.ts
          getAuthorById/
          getAllAuthors/
          editAuthor/
          deleteAuthor/       
        index.ts               # Exports dependencies from the Authors component
      comments/              # Comments module
      posts/                 # Posts module
    users                    # Users module

注意到这个结构是相似的吗?我们只是简单地遵循Package By Component,将功能分组为有凝聚力的单元。

现在让我们特别关注Authors 组件中的CreateUser 用例。

为什么要这样做?

2个原因。

  1. 它使人们更容易看到你的代码能做什么。
  2. 人是软件最终需要被改变的原因。通过围绕人和他们的用例来组织代码,它使得找到修改代码的地方变得非常简单。不仅如此,将功能隔离开来,还可以减少连锁反应的可能性。

用例里有什么?

在任何一个用例文件夹中,我都会通过把与该用例相关的所有东西放在那里来努力实现凝聚力。这意味着有一个contoller用例本身,用例的测试,以及用例可能产生的所有错误。我将使用文件夹index.ts ,为这个模块之外的世界导出必要的东西。

authors
  domain/
  repos/
  useCases              # Author module use cases
    createAuthor                
      CreateAuthorController.ts     # Application controller
      CreateAuthorErrors.ts         # Errors namespace
      CreateAuthorUseCase.ts        # Use case
      CreateAuthorUseCase.spec.ts   # Test for the use case
      index.ts                      # Compose and export use case + controller
    getAuthorById/
    getAllAuthors/
    editAuthor/
    deleteAuthor/  

错误

使用TypeScript命名空间,我可以表示这个用例的所有错误。

CreateAuthorErrors.ts

import { Result } from "core/Result";
import { DomainError } from "core/DomainErrror";

export namespace CreateAuthorErrors {

  export class AuthorExistsError extends Result<DomainError> {
    constructor () {
      super(false, {
        message: `Author already exists`
      } as DomainError)
    }
  }

  export class UserNotYetCreatedError extends Result<DomainError> {
    constructor () {
      super(false, {
        message: `Need to create the user account first`
      } as DomainError)
    }
  }

}

用例

该用例包含实际的功能。

对于这个特殊的用例,CreateAuthorUseCase ,它依赖于两个外部依赖

其中一个,IAuthorRepo ,来自authors 模块。

另一个,IUserRepo ,是来自于users 模块。

它还利用了我们在以前的文章中探讨过的功能性错误处理技术

看看吧。

CreateAuthorUseCase.ts

import { UseCase } from "core/UseCase";
import { Either, Result, left, right } from "core/Result";
import { CreateAuthorErrors } from "./CreateAuthorErrors";
import { AppError } from "core/AppError";
import { IUserRepo } from "modules/users/repos/UserRepo";
import { IAuthorRepo } from "../../repos/AuthorRepo";
import { Author } from "../../domain/Author";

// All we need to execute this is a userId: string.

interface Request {
  userId: string;
}

// The response is going to be either one of these
// failure states, or a Result<void> if successful.

type Response = Either<
  CreateAuthorErrors.AuthorExistsError |
  CreateAuthorErrors.UserNotYetCreatedError |
  AppError.UnexpectedError,
  Result<any>
>

export class CreateAuthorUseCase implements UseCase<Request, Promise<Response>> {

  // This use case relies on an IUserRepo and an IAuthorRepo to work
  private userRepo: IUserRepo;
  private authorRepo: IAuthorRepo;

  public constructor (userRepo: IUserRepo, authorRepo: IAuthorRepo) {
    this.userRepo = userRepo;
    this.authorRepo = authorRepo;
  }

  public async execute (req: Request): Promise<Response> {
    const { userId } = req;

    const user = await this.userRepo.getUserById(userId);
    const userExists = !!user;

    // If the user doesn't exist yet, we can't make them an author
    if (!userExists) {
      return left(
        new CreateAuthorErrors.UserNotYetCreatedError()
      ) as Response;
    }

    // If the user was already made an author, we can return a failed result.
    const alreadyCreatedAuthor = await this.authorRepo
      .getAuthorByUserId(user.userId);

    if (alreadyCreatedAuthor) {
      return left(
        new CreateAuthorErrors.AuthorExistsError()
      ) as Response;
    }

    // If validation logic fails to create an author, we can return a failed result
    const authorOrError: Result<Author> = Author
      .create({ userId: user.userId });

    if (authorOrError.isFailure) {
      return left(
        new AppError.UnexpectedError(authorOrError.error)
      ) as Response;
    }


    // Save the author to the repo
    const author = authorOrError.getValue();
    await this.authorRepo.save(author);

    // Successfully created the author
    return right(Result.ok<void>()) as Response;
  }
}

控制器

虽然控制器可能是一个基础设施适配器,但我们仍然想把它和用例结合起来,以保持这个模块的凝聚力。

控制器有一个依赖性,就是这个模块的CreateAuthorUseCase

我们需要把控制器和它作为一个依赖关系,以便创建一个控制器的实例。

基础控制器。我们正在使用本指南中的BaseController

CreateAuthorController.ts

import { CreateAuthorUseCase } from "./CreateAuthorUseCase";
import { AppError } from "core/AppError";
import { CreateAuthorErrors } from "./CreateAuthorErrors";

export class CreateAuthorController extends BaseController {
  private useCase: CreateAuthorUseCase;

  constructor (useCase: CreateAuthorUseCase) {
    super();
    this.useCase = useCase;
  }

  public async executeImpl (): Promise<any> {
    const req = this.req as DecodedExpressRequest;
    const { userId } = req.decoded;
    try {
      const result = await this.useCase.execute({ userId });

      if (result.isLeft()) {
        const error = result.value;

        // Based on the error, map to the appropriate HTTP code
        switch (error.constructor) {
          case CreateAuthorErrors.AuthorExistsError:
            return this.notFound(error.errorValue().message)
          case CreateAuthorErrors.UserNotYetCreatedError:
            return this.fail(error.errorValue().message)
          case AppError.UnexpectedError:
          default:
            return this.fail(error.errorValue().message);
        }
      } else {
        return this.ok<void>(this.res);
      }
    } catch (err) {
      console.log(err);
      return this.fail("Something went wrong on our end.")
    }
  }
}

我们已经有了执行这个功能所需要的一切。

我们只需要把它连接起来。

第2步:创建和导出模块的特征

回顾一下,文件夹结构是这样的。

authors
  domain/
  repos/
  useCases              # Author module use cases
    createAuthor                
      CreateAuthorController.ts     # Application controller
      CreateAuthorErrors.ts         # Errors namespace
      CreateAuthorUseCase.ts        # Use case
      CreateAuthorUseCase.spec.ts   # Test for the use case
      index.ts                      # Compose and export use case + controller
    getAuthorById/
    getAllAuthors/
    editAuthor/
    deleteAuthor/  
...
users/
  ...

首先要创建的是CreateAuthorUseCase

但是CreateAuthorUseCase 依赖于一些依赖关系(IUserRepoIAuthorRepo)。

author/useCases/createAuthor/index.ts

import { CreateAuthorUseCase } from './CreateAuthorUseCase';

const createAuthorUseCase = new CreateAuthorUseCase() /* An argument 
for 'userRepo' and 'authorRepo' was not provided. */

我导出依赖项的风格指南是这样的。

  • 总是使用index.ts 来导出你的模块中需要被他人使用的东西。
  • 总是使用小写的名字来表示导出的依赖关系是一个实例,而不是一个类。
  • 只从直接的父文件夹导出依赖关系。只有users/repos/index.ts 允许导出UserRepo ,因为它驻留在users/repos/UserRepo.tsusers/index.ts (或其他地方)是不允许的。

users 模块中,我会像这样把一个IUserRepo 的实例导出为userRepo

modules/users/repos/index.ts

import { models } from '../../../core/infra/models' 
import { UserRepo } from './UserRepo';

const userRepo = new UserRepo(models);

export { 
  userRepo
}

然后我就会把它导入到authors/useCases/index.ts ,这通常是使用Visual Studio Code的intellisense的一个很好的经验

使用Intellisense自动按名称追踪依赖关系。

不错。现在用例已经创建,我需要将注入到我的控制器中。

一旦完成,我将从这个模块导出它们。

author/useCases/createAuthor/index.ts

import { CreateAuthorUseCase } from "./CreateAuthorUseCase";
import { userRepo } from "modules/users/repos";
import { authorRepo } from "../../repos";
import { CreateAuthorController } from "./CreateAuthorController";

const createAuthorUseCase = new CreateAuthorUseCase(
  userRepo, authorRepo
)

// Inject the use case into the controller to create it
const createAuthorController = new CreateAuthorController(
  createAuthorUseCase
)

export {
  // Export instances as lowercase to signify they're instances
  createAuthorUseCase,
  createAuthorController
}

使用用例特性

现在,如果我想使用CreateAuthorUseCase ,我所要做的就是开始输入createAuthor... ,除了CreateAuthorUseCaseController ,我还会看到CreateAuthorUseCase

import { 
  createAuthorUseCaseController 
} from './modules/author/useCases/createAuthor`

...

const authorRouter = express.Router();

authorRouter.post('/',
  (req, res) => createAuthorUseCaseController.execute(req, res)
)

...

不使用DI容器的好处

更少的复杂性

只要使用模块系统!Node.js会在第一次导入时自动将所有东西变成单子,如果这对你很重要的话。

更好的软件组合

不使用DI容器迫使你更好地理解你是如何组成你的类的,而不是仅仅创建一个新的CatsService ,上面有一个可注入的装饰器。

这正是Eric Elliot在他的《Composing Software》一书中所写的那种类型的东西。

更少的循环依赖性

不使用DI容器使你很难引入循环依赖关系,因为你对输出和输入的内容以及顺序有更好的理解。你被迫将软件组件组合在一起。

这就是东西保持可测试性的方法。

更少的装饰器噪音

装饰器是低级别的细节,它可以进入你的应用级代码。我认为这不是很干净。

劣势

在团队中工作

我认为在团队中工作有时比自己工作更有挑战性。

像Angular和NestJS这样的框架比React和Vue这样的工具更经常被点头用于企业软件,原因是框架是有意见的

框架告诉你如何做事情--他们的方式,也只有他们的方式。

在Angular中,有一种方法可以创建Route Guard。

在React中,这是一个不同的故事。

当与团队中的其他几个人一起工作时,要让每个人都遵守风格指南并以某种方式做事,比如将功能打包成有凝聚力的组件,这可能是非常具有挑战性的。

框架在这里是成功的,因为它们减少了你可以做的事情的总表面积。

但是,如果团队规模小,纪律严明,经验水平相似,我认为它可以发挥作用。

每个人都需要遵循风格指南

这就是对我有用的东西!像任何项目风格指南一样,在代码库中工作的其他开发者也需要采用它。