@FunC
2017-12-24T21:43:20.000000Z
字数 6275
阅读 2187
Node.js
一个缠成一团的依赖图对项目是非常不利的,以至于到后期可能会牵一发而动全身,甚至要完全重写才能修改。
但这也不意味着我们要从第一个模块开始就过度设计,而是应该找到一个平衡点。
在软件架构中,任何会影响到软件行为或者组件结构的实体、状态或者数据格式都可以成为依赖。
使用模块能带来一些好处:
* 由于模块专注于一个问题,所以可读性📖更高
* 因为模块是一个独立的文件,所以很容易识别👀出来
* 能在不同的应用中复用
模块代表着隐藏信息🤫(information hiding)的一个完美粒度,它提供了一个有限的机制,只暴露出组件的公共接口。
saveProduct()
, saveInvoice()
, saveUser()
,那他就是低内聚的。按理说,由于 Node.js 的模块缓存机制,通过module.exports = new Database(‘myDb’)
导出的应该是一个单例。但由于node_module
中每个包又有自己独立的依赖,同时模块的缓存 ID 是模块路径。所以很有可能导出的❌并不是单例!
尽管将有状态的模块挂载到全局对象上能让其在整个应用中可用:
// DO NOT DO THIS
global.db = new Database('my-app-db');
我们应该极力禁止这样做!很多时候我们并不真的需要一个纯单例,而且还有其他的方式让一个实例在不同的模块中共享(见下文)。
简单来说就是在一个模块中直接通过 require()
加载另一个模块。
假如我们需要实现这样一个验证服务:
如果采用硬编码的方式:
// db.js
module.exports = new DataBase('my-db');
// authService.js
const db = require('./db.js');
module.exports.login = (name) => {
if (name === 'sth') {
return db.get(msg);
}
}
// authController.js
const authService = require('./au thService');
exports.login = (req, res, next) => {
authService.check(req.body.username, (err, result) => {
// ...
});
✅优点显而易见:
* 直观易懂
* 模块的连接不需要外部支持(对比下面几种方法)
🛑然而缺点严重:
* 难以复用。autherService 模块无法使用其他数据库实例(除非改动代码)
* 难以独立测试。因为无法轻易地 mock 出数据库
⚠️需要注意的是,硬编码的缺点主要与有状态的实例有关(上例中的db)。如果均为无状态的模块,则无相关问题。
所谓依赖注入,就是将组件的依赖,以外部输入的形式提供(例如作为函数参数)。
马上来将上述例子重构成 DI 的形式吧(伪代码):
// db.js
module.exports = dbName => new DataBase(dbName);
// authService.js
module.exports = (db) => {
// ...
}
// authController.js
module.exports = (authService) => {
// ...
}
调用方式:
app.js
const dbFactory = require('./db');
const authServiceFactory = require('./authService');
const authControllerFactory = require('./authController');
const db = dbFactory('my-db');
const authService = authServiceFactory(db);
const authController = authControllerFactory(authService);
// use authController
可以发现,在加载模块时,加载进来的全都是无状态的工厂函数。
然后将相应的依赖作为参数传入工厂函数,完成初始化。
除了上述的工厂函数注入(factory injection),还有其他类型的 DI:
* Constructor 注入:将依赖作为构造函数的参数传入
const service = new Service(depA, depB);
const service = new Service();
service.depA = anInstanceOfDepA;
由于创建对象时没有连接相应的依赖,所以有可能是在不一致的状态下创建出对象,不推荐。
但这种方式在解决循环依赖时可能很有用。
✅优点:
* 可复用性⬆️。不需要改动代码就能复用
* 可测试性⬆️。能轻松提供一些 mock 的依赖
🛑新的缺点出现了:
* 需要事前知道各个依赖之间的关系。在编码期间难以获知每个模块的依赖(因为所有依赖都要手动注入)
* 要以一定的顺序注入依赖。相当于需要手动构建依赖图,模块数据增加后难以维护。
一个可行的解决方案:把依赖拆分到不同的组件上,分别初始化,减少复杂度。同时仅在有需要的时候使用 DI。
service locator 的核心原则,是用一个集中的登记处来管理组件,同时为其他组件提供相应的依赖。
万语千言不如直接看代码:
serviceLocator.js
module.exports = function() {
// 准备好的依赖
const dependencies = {};
// 注册的工厂函数
const factories = {};
const serviceLocator = {};
// 用于注册工厂函数
serviceLocator.factory = (name, factory) => {
factories[name] = factory;
}
// 注册准备好的依赖
serviceLocator.register = (name, instance) => {
dependencies[name] = instance;
}
// 返回依赖
serviceLocator.get = (name) => {
if (!dependencies[name]) {
const factory = factories[name];
// 如果没有依赖,找同名的工厂函数,并传入 serviceLocator 作为参数
// 因此工厂函数的写法是关键
dependencies[name] = factory && factory(serviceLocator);
if (!dependencies[name]) {
throw new Error('module not found: ' + name);
}
}
return dependencies[name];
}
return serviceLocator;
}
重点在于获取依赖时,会尝试用同名的工厂函数以 serviceLocator 实例作参数调用。同时 serviceLocator 中又有其他依赖,这样就达到了自动管理依赖的效果。
来看看重构后的模块(以 authService.js 为例):
module.exports = (serviceLocator) => {
const db = serviceLocator.get('db');
const tokenSecret = serviceLocator.get('tokenSecret');
// ...
}
可见,重构后的 authService 通过注入的 serviceLocator 来获取自己所需的依赖。如果所需的依赖还未初始化,就通过 serviceLocator 递归地进行初始化。
由于依赖之间的关系已经在模块中定义好了,我们调用时只需要注册相应的工厂函数,剩下的放心交给 serviceLocator 即可~
app.js
const svcLoc = require('./lib/serviceLocator')();
// 除了可以注册依赖,还能注册参数!
svcLoc.register('dbName', 'example-db');
svcLoc.register('tokenSecret', 'SHHH!');
svcLoc.factory('db', require('./lib/db'));
svcLoc.factory('authService', require('./lib/authService'));
svcLoc.factory('authController', require('./lib/authController'));
const authController = svcLoc.get('authController');
可以看到,其实参数也是依赖的一种。所以其实可以把所需要的参数也注册上去,达到可配置的效果。
其实 Express 的 server 实例也是一个简单的 service locator。可以通过
expressApp.set(name, instance)
来注册服务,然后通过expressApp.get(name)
来获取。关键之处在于, server 实例已经被注入到每一个中间件中了,可以通过request.app
来访问到。
✅优点:
* 方便易用。只需注册工厂函数,无须知道依赖的顺序
🛑缺点
* 模块之间的关系不清晰。相比于 DI 将依赖直接作为参数,service locator 的依赖则需要通过查看文档或者看源码才能得知。
* 可复用性:硬编码 < service locator < DI。因为需要给模块增加一层对 service locator 的依赖
上面提到,service locator 的主要问题,是每个模块都需要依赖 service locator 实例。如果每个模块通过某种方式声明自己的依赖,那么就能在初始化前就识别出其所需依赖了(从而在内部不需要依赖 service locator)
一个模块声明其依赖的方式有以下这些技术:
* 直接读模块的参数名(参数即依赖)。通过 toString()
方法+正则能获取参数名。不过当代码需要压缩混淆的时候就不使用了。
* 以字符串的形式放在一个数组里,和工厂函数一起导出:
module.exports = ['db', 'another/dependency', (a, b) => {}];
下面以读取参数名为例实现 DI Container:
const fnArgs = require('parse-fn-args');
module.exports = () => {
const dependencies = {};
const factories = {};
const diContainer = {};
diContainer.factory = (name, factory) => {
factories[name] = factory;
};
diContainer.register = (name, dep) => {
dependencies[name] = dep;
};
diContainer.get = (name) => {
if (!dependencies[name]) {
const factory = factories[name];
// 从这里开始有差别,以工厂函数作参数调用 .inject() 方法
dependencies[name] = factory &&
diContainer.inject(factory);
if (!dependencies[name]) {
throw new Error('Cannot find module: ' + name);
}
}
return dependencies[name];
};
diContainer.inject = (factory) => {
// inject 方法读取参数,并返回相应的依赖数组
const args = fnArgs(factory)
.map(function(dependency) {
return diContainer.get(dependency);
});
// 将依赖数组作为参数调用工厂函数
return factory.apply(null, args);
};
return diContainer;
};
✅优点:
* 耦合度降低,可测试性提高。
* 模块不使用 DI Container 时也能用
🛑缺点
* 因为其实也是 DI,缺点类似
* 更复杂,依赖在运行时才解析出来
理想的软件工程架构,是拥有一个最小化的核心。然后其余功能按需通过插件来扩展。然而这并不容易实现,因为需要花费不少的时间与资源。
❓本节关注以下两个问题:
* 如何将应用的服务暴露给插件
* 如何将插件整合到应用的执行流中
在 Node.js 应用中,经常能见到将插件以包的形式安装在 node_modules
中。
✅这样做有两个好处:
1. 利用了 npm 对依赖的管理分发能力
2. 每个包都可以拥有自己私有的依赖,减少了冲突的可能性
著名的例子有 express 的 middleware,gulp 的一众插件等。
事实上,除了可以将外部的扩展插件作为包,还可以将整个应用拆分成一个个的包,如同内部插件一般。
包可以是私有的,只要在
package.json
中设置private
即可避免上传到公开的 npm 仓库中。自己可以通过 git 或者私有 npm 仓库来管理分发。
✅采用“内部插件”有这些好处:
1. 利用 requier()
的寻路算法,免去写相对路径的麻烦
2. 提高了可复用性。迫使开发者关注应用的那些部分可以暴露,那些需要保持私有。
一般应用会提供一些钩子给插件挂载。
主要有两种方式来扩展一个应用的组件:
* 明确扩展(Explicit extension),即插件调用组件
const app = express();
require('thePlugin')(app);
const app = express();
const plugin = require('thePlugin')();
app[plugin.method](plugin.route, plugin.hander);
两种方式的差异也很明显:
* 插件控制的扩展能力更强,更灵活,因为能直接访问到应用的内部。但弊大于利,插件必需清楚应用的构造,每次应用作出修改,插件很可能也需要随之修改。
* 应用控制的插件则要求应用能以某种形式提供扩展点(如钩子等)
*