引子
文章 《The Single Responsibility Principle》 是从《NodeJS and Good Practices》里面看到的,继续翻译记录。
翻译原文:NodeJS and Good Practices
正文
软件总是处于随时变化中,而有助于衡量代码质量的一个方面是改动代码的容易程度。但为什么会这样呢?
...如果你害怕改变某些东西,那么它显然设计得很糟糕。— Martin Fowler
关注点和责任分离(Separation of concerns and responsibilities)
“将因同样原因而改变的事情集中起来。将因不同原因而改变的事情分开。”
无论是函数、类还是模块,它们都可以应用单一责任原则和关注点分离。从架构开始,基于这些原则进行软件设计。
架构(Architecture)
在软件开发中,责任 是共同致力于实现的任务,例如:在应用程序中表示产品的概念、处理网络请求、数据库中用户持久化等等。
注意到这三项责任不属于同一类别吗?这是因为它们属于不同的层次,这些层次可以划分为不同的概念。根据上述示例,“在数据库中用户持久化”涉及“用户”概念,也涉及与数据通信的层。
一般来说,与上述概念相关的架构往往分为四层:定义域(domain)、应用(application)、基础设施(infrastructure)和输入(input interfaces)。
定义域层
在这一层中,我们可以定义有实体和业务规则作用的单元,它们与我们的定义域有直接的关系。例如,在用户和团队的应用程序中,我们可能有一个 User
实体、一个 Team
实体和一个 JoinTeamPolicy
来回答用户是否能够加入给定的团队。
这是我们软件中最独立和最重要的一层,应用层可以使用它来定义用例。
应用层
应用层定义了我们应用程序的实际行为,因此负责执行定义域层中各单元之间的交互。例如,我们可以有一个 JoinTeam
用例 ,它接收 User
和 Team
的实例并把它们传递给JoinTeamPolicy
;如果用户可以加入,它将持久化的责任委托给基础设施层。
应用层还可以用作基础设施层的适配器 。假设我们的应用程序可以发送电子邮件;负责与电子邮件服务器直接通信的类(让我们称它为 MailChimpService
)属于基础设施层,但实际发送电子邮件的类(EmailService
)属于应用层 ,并且在内部使用 MailChimpService
。因此,我们应用程序的其余部分不知道具体实现细节——它只知道 EmailService
能够发送电子邮件。
基础设施层
这是所有层中最底层,是我们应用程序所有延伸的边界:数据库、电子邮件服务、队列引擎等。
多层 应用程序的一个常见特性,是使用存储库模式与数据库或其它一些外部持久化服务(如 API)通信。存储库对象本质上被视为集合,使用它们的层(定义域层 和应用层 )不需要知道底层是那种持久化技术(类似于我们的电子邮件服务示例)。
这里的想法是,存储库接口属于定义域层,而实现则属于基础设施层,即定义域只知道存储库接受的方法和参数。这使得这两个层更加灵活,即使在测试方面也是如此!由于 JavaScript 没有实现接口的概念,我们可以想象自己的接口,并基于基础设施层创建一个具体的实现。
输入层
这个层包含我们应用程序的所有入口,比如控制器、CLI、websockets、图形用户界面(对于桌面应用程序)等等。
它不应该对业务规则、用例、持久化技术有任何了解,甚至不应该对其它类型的逻辑有任何了解!它应该只接收用户输入(比如URL参数),将其传递给用例,最后返回响应给用户。
NodeJS 与关注点分离
好了,在所有这些理论之后,如何将这些理论应用到 Node 应用程序?老实说,多层架构中使用的一些模式非常适合 JavaScript 世界!
NodeJS 与定义域层
Node 上的定义域层可以由简单的 ES6 类 组成。有许多 ES5 和 ES6+ 模块有助于创建域实体,例如:Structure、Ampersand State、tcomb 和 ObjectModel。
让我们看使用 Structure 的简单示例:
const { attributes } = require('structure');
const User = attributes({
id: Number,
name: {
type: String,
required: true
},
age: Number
})(class User {
isLegal() {
return this.age >= User.MIN_LEGAL_AGE;
}
});
User.MIN_LEGAL_AGE = 21;
请注意,我们列出的不包含 Backbone.Model 或像 Sequelize 和 Mongoose 这样的模块,因为它们在基础设施层,是用来与外部世界进行通信的。因此,我们代码库的其余部分甚至不需要知道它们的存在。
NodeJS 与应用层
用例 属于应用层,与 promise 不同的是,它们可能会产生成功 和失败 之外的结果。对于这种情况,一个好的 Node 模式是 event emitter。要使用它,我们必须扩展 EventEmitter
类,并为每个可能的结果发出一个事件,从而隐藏存储库在内部使用 promise 的事实:
const EventEmitter = require('events');
class CreateUser extends EventEmitter {
constructor({ usersRepository }) {
super();
this.usersRepository = usersRepository;
}
execute(userData) {
const user = new User(userData);
this.usersRepository
.add(user)
.then((newUser) => {
this.emit('SUCCESS', newUser);
})
.catch((error) => {
if(error.message === 'ValidationError') {
return this.emit('VALIDATION_ERROR', error);
}
this.emit('ERROR', error);
});
}
}
这样,我们的入口就可以执行用例 并为每个结果添加一个侦听器,就像这样:
const UsersController = {
create(req, res) {
const createUser = new CreateUser({ usersRepository });
createUser
.on('SUCCESS', (user) => {
res.status(201).json(user);
})
.on('VALIDATION_ERROR', (error) => {
res.status(400).json({
type: 'ValidationError',
details: error.details
});
})
.on('ERROR', (error) => {
res.sendStatus(500);
});
createUser.execute(req.body.user);
}
};
NodeJS 与基础设施层
基础设施层的实现不应很困难,但要注意其逻辑不要泄漏到以上的层!
例如,我们可以使用 Sequelize 模型来实现一个与 SQL 数据库通信的库,并为其提供方法名称,这些名称并不暗示下面存在的 SQL 层——例如我们上一个示例中的常见的 add
方法。
我们可以实例化 SequelizeUsersRepository
并将其作为 usersRepository
变量传递给它的依赖项,这些依赖项可能只是与它的接口交互。
class SequelizeUsersRepository {
add(user) {
const { valid, errors } = user.validate();
if(!valid) {
const error = new Error('ValidationError');
error.details = errors;
return Promise.reject(error);
}
return UserModel
.create(user.attributes)
.then((dbUser) => dbUser.dataValues);
}
}
同样的道理也适用于 NoSQL 数据库、电子邮件服务、队列引擎、外部 api 等等。
NodeJS 和输入层
在 Node 应用程序中实现这一层有很多选择。对于 HTTP 请求,Express 模块是最常用的,但是你也可以使用 Hapi 或 Restify 。尽管对这一层的更改不应影响其它层,也要根据实现细节做出最后的选择。如果从 Express 迁移到 Hapi 意味着要进行某种程度变更时,那么这就是耦合的迹象,你应该密切注意要修复它。
层的通信
让一个层直接与另一个层直接通信可能是一个槽糕的决定,并会导致它们耦合。在面向对象编程中,这个问题的一个常见解决方案是依赖注入(DI)。这种技术使类的依赖项在其构造函数中作为参数接收,而不是要求依赖项并在类本身内部实例化它们——从而创建所谓的控制反转(inversion of control)。
使用这种技术使我们能够以一种非常简洁的方式隔离一个类的依赖关系,从而使它更加灵活和易于测试,因为清除依赖关系成为一项琐碎的任务。
对于 Node 应用程序,有一个很好的 DI 模块 Awilix ,它允许我们利用 DI ,而不必将我们的代码与 DI 模块本身耦合——因此我们不想使用 Angular 1 中奇怪 的依赖注入机制。Awilix 的作者有一系列文章解释使用 Node 进行依赖注入,这些文章值得一读,同时也介绍了如何使用 Awilix 。顺便说一下,如果你计划使用 Express 或 Koa ,还应该看看 Awilix Express 或 Awilix Koa 。
一个实例
即使有了所有这些关于层和概念的例子和解释,我相信没有什么比一个遵循多层架构 的应用程序实例更好的了,它可以简单地使用!
你可以看看这个基于 Node 生产就绪的实例 boilerplate for web APIs。它应用了多层 架构,并且已经为你提供了基础设置(包括文档),因此你可以练习甚至将其用作 Node 应用程序的初始化。
更多信息
如果你想了解有关多层架构以及如何分离关注点的更多信息,请查看以下链接:
- FourLayerArchitecture
- Architecture — The Lost Years
- The Clean Architecture
- Hexagonal Architecture
- Domain-driven design
感谢 Thiago Araújo Silva 。