我收到很多关于使用哪个DI容器的邮件。InversifyJS?Awilix?
"Angular和NestJS使用他们自己的反转控制容器,那么你为什么不呢?"
因为我真的还不需要。
我一直在等待我的依赖性失去控制的那一刻,这样我就能诚实地、真诚地看到利用DI容器的必要性。
但我还没到那一步。
我还需要补充的是,我的主要代码库有超过15万行的代码,并且已经存活并积极开发了大约3年。
虽然,在我了解到清洁架构和如何将逻辑组织成层之前
先决条件。
你必须有这么高才能坐这个车。
这里有一些你应该熟悉的东西,以便加入到对话中来。
建立一个博客
让我们想象一下,我们正在建立一个网站,该网站有一个博客,你可以注册成为author ,创建posts ,并在posts ,写comments 。
同时,假设其他users ,也可以在你的posts ,comment 。
第一件要做的事是什么?弄清楚角色和用例,以便按组件打包。
第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 有一个analytics 、dashboard 和users 模块吗?在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个原因。
- 它使人们更容易看到你的代码能做什么。
- 人是软件最终需要被改变的原因。通过围绕人和他们的用例来组织代码,它使得找到修改代码的地方变得非常简单。不仅如此,将功能隔离开来,还可以减少连锁反应的可能性。
用例里有什么?
在任何一个用例文件夹中,我都会通过把与该用例相关的所有东西放在那里来努力实现凝聚力。这意味着有一个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 依赖于一些依赖关系(IUserRepo 和IAuthorRepo)。
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.ts。users/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中,这是一个不同的故事。
当与团队中的其他几个人一起工作时,要让每个人都遵守风格指南并以某种方式做事,比如将功能打包成有凝聚力的组件,这可能是非常具有挑战性的。
框架在这里是成功的,因为它们减少了你可以做的事情的总表面积。
但是,如果团队规模小,纪律严明,经验水平相似,我认为它可以发挥作用。
每个人都需要遵循风格指南
这就是对我有用的东西!像任何项目风格指南一样,在代码库中工作的其他开发者也需要采用它。
