[关闭]
@FunC 2017-12-24T13:43:20.000000Z 字数 6275 阅读 2003

Node.js Design Patterns | CH07

Node.js


编写模块📦

模块与依赖

一个缠成一团的依赖图对项目是非常不利的,以至于到后期可能会牵一发而动全身,甚至要完全重写才能修改。
但这也不意味着我们要从第一个模块开始就过度设计,而是应该找到一个平衡点。

Node.js 中最常见的依赖

在软件架构中,任何会影响到软件行为或者组件结构的实体、状态或者数据格式都可以成为依赖
使用模块能带来一些好处:
* 由于模块专注于一个问题,所以可读性📖更高
* 因为模块是一个独立的文件,所以很容易识别👀出来
* 能在不同的应用中复用

模块代表着隐藏信息🤫(information hiding)的一个完美粒度,它提供了一个有限的机制,只暴露出组件的公共接口。

内聚和耦合(Cohension and coupling)🔗

有状态的模块

按理说,由于 Node.js 的模块缓存机制,通过module.exports = new Database(‘myDb’) 导出的应该是一个单例。但由于node_module中每个包又有自己独立的依赖,同时模块的缓存 ID 是模块路径。所以很有可能导出的❌并不是单例!
尽管将有状态的模块挂载到全局对象上能让其在整个应用中可用:

  1. // DO NOT DO THIS
  2. global.db = new Database('my-app-db');

我们应该极力禁止这样做!很多时候我们并不真的需要一个纯单例,而且还有其他的方式让一个实例在不同的模块中共享(见下文)。

组合模块的模式(Patterns for wiring modules)

硬编码的依赖(Hardcoded dependency)

简单来说就是在一个模块中直接通过 require() 加载另一个模块。

以一个验证服务为例

假如我们需要实现这样一个验证服务:

如果采用硬编码的方式:

  1. // db.js
  2. module.exports = new DataBase('my-db');
  3. // authService.js
  4. const db = require('./db.js');
  5. module.exports.login = (name) => {
  6. if (name === 'sth') {
  7. return db.get(msg);
  8. }
  9. }
  10. // authController.js
  11. const authService = require('./au thService');
  12. exports.login = (req, res, next) => {
  13. authService.check(req.body.username, (err, result) => {
  14. // ...
  15. });

✅优点显而易见:
* 直观易懂
* 模块的连接不需要外部支持(对比下面几种方法)

🛑然而缺点严重:
* 难以复用。autherService 模块无法使用其他数据库实例(除非改动代码)
* 难以独立测试。因为无法轻易地 mock 出数据库

⚠️需要注意的是,硬编码的缺点主要与有状态的实例有关(上例中的db)。如果均为无状态的模块,则无相关问题。

依赖注入(DI, Dependicy Injection)

所谓依赖注入,就是将组件的依赖,以外部输入的形式提供(例如作为函数参数)。

马上来将上述例子重构成 DI 的形式吧(伪代码):

  1. // db.js
  2. module.exports = dbName => new DataBase(dbName);
  3. // authService.js
  4. module.exports = (db) => {
  5. // ...
  6. }
  7. // authController.js
  8. module.exports = (authService) => {
  9. // ...
  10. }

调用方式:
app.js

  1. const dbFactory = require('./db');
  2. const authServiceFactory = require('./authService');
  3. const authControllerFactory = require('./authController');
  4. const db = dbFactory('my-db');
  5. const authService = authServiceFactory(db);
  6. const authController = authControllerFactory(authService);
  7. // use authController

可以发现,在加载模块时,加载进来的全都是无状态的工厂函数。
然后将相应的依赖作为参数传入工厂函数,完成初始化。

其他类型的 DI

除了上述的工厂函数注入(factory injection),还有其他类型的 DI:
* Constructor 注入:将依赖作为构造函数的参数传入

  1. const service = new Service(depA, depB);
  1. const service = new Service();
  2. service.depA = anInstanceOfDepA;

由于创建对象时没有连接相应的依赖,所以有可能是在不一致的状态下创建出对象,不推荐。
但这种方式在解决循环依赖时可能很有用。

✅优点:
* 可复用性⬆️。不需要改动代码就能复用
* 可测试性⬆️。能轻松提供一些 mock 的依赖

🛑新的缺点出现了:
* 需要事前知道各个依赖之间的关系。在编码期间难以获知每个模块的依赖(因为所有依赖都要手动注入)
* 要以一定的顺序注入依赖。相当于需要手动构建依赖图,模块数据增加后难以维护。

一个可行的解决方案:把依赖拆分到不同的组件上,分别初始化,减少复杂度。同时仅在有需要的时候使用 DI。

服务定位器(Service locator)

service locator 的核心原则,是用一个集中的登记处来管理组件,同时为其他组件提供相应的依赖。

万语千言不如直接看代码:
serviceLocator.js

  1. module.exports = function() {
  2. // 准备好的依赖
  3. const dependencies = {};
  4. // 注册的工厂函数
  5. const factories = {};
  6. const serviceLocator = {};
  7. // 用于注册工厂函数
  8. serviceLocator.factory = (name, factory) => {
  9. factories[name] = factory;
  10. }
  11. // 注册准备好的依赖
  12. serviceLocator.register = (name, instance) => {
  13. dependencies[name] = instance;
  14. }
  15. // 返回依赖
  16. serviceLocator.get = (name) => {
  17. if (!dependencies[name]) {
  18. const factory = factories[name];
  19. // 如果没有依赖,找同名的工厂函数,并传入 serviceLocator 作为参数
  20. // 因此工厂函数的写法是关键
  21. dependencies[name] = factory && factory(serviceLocator);
  22. if (!dependencies[name]) {
  23. throw new Error('module not found: ' + name);
  24. }
  25. }
  26. return dependencies[name];
  27. }
  28. return serviceLocator;
  29. }

重点在于获取依赖时,会尝试用同名的工厂函数以 serviceLocator 实例作参数调用。同时 serviceLocator 中又有其他依赖,这样就达到了自动管理依赖的效果。

来看看重构后的模块(以 authService.js 为例):

  1. module.exports = (serviceLocator) => {
  2. const db = serviceLocator.get('db');
  3. const tokenSecret = serviceLocator.get('tokenSecret');
  4. // ...
  5. }

可见,重构后的 authService 通过注入的 serviceLocator 来获取自己所需的依赖。如果所需的依赖还未初始化,就通过 serviceLocator 递归地进行初始化。

由于依赖之间的关系已经在模块中定义好了,我们调用时只需要注册相应的工厂函数,剩下的放心交给 serviceLocator 即可~
app.js

  1. const svcLoc = require('./lib/serviceLocator')();
  2. // 除了可以注册依赖,还能注册参数!
  3. svcLoc.register('dbName', 'example-db');
  4. svcLoc.register('tokenSecret', 'SHHH!');
  5. svcLoc.factory('db', require('./lib/db'));
  6. svcLoc.factory('authService', require('./lib/authService'));
  7. svcLoc.factory('authController', require('./lib/authController'));
  8. 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 的依赖

依赖注入容器(Dependency Injection container)

上面提到,service locator 的主要问题,是每个模块都需要依赖 service locator 实例。如果每个模块通过某种方式声明自己的依赖,那么就能在初始化前就识别出其所需依赖了(从而在内部不需要依赖 service locator)
一个模块声明其依赖的方式有以下这些技术:
* 直接读模块的参数名(参数即依赖)。通过 toString()方法+正则能获取参数名。不过当代码需要压缩混淆的时候就不使用了。
* 以字符串的形式放在一个数组里,和工厂函数一起导出:

  1. module.exports = ['db', 'another/dependency', (a, b) => {}];

下面以读取参数名为例实现 DI Container:

  1. const fnArgs = require('parse-fn-args');
  2. module.exports = () => {
  3. const dependencies = {};
  4. const factories = {};
  5. const diContainer = {};
  6. diContainer.factory = (name, factory) => {
  7. factories[name] = factory;
  8. };
  9. diContainer.register = (name, dep) => {
  10. dependencies[name] = dep;
  11. };
  12. diContainer.get = (name) => {
  13. if (!dependencies[name]) {
  14. const factory = factories[name];
  15. // 从这里开始有差别,以工厂函数作参数调用 .inject() 方法
  16. dependencies[name] = factory &&
  17. diContainer.inject(factory);
  18. if (!dependencies[name]) {
  19. throw new Error('Cannot find module: ' + name);
  20. }
  21. }
  22. return dependencies[name];
  23. };
  24. diContainer.inject = (factory) => {
  25. // inject 方法读取参数,并返回相应的依赖数组
  26. const args = fnArgs(factory)
  27. .map(function(dependency) {
  28. return diContainer.get(dependency);
  29. });
  30. // 将依赖数组作为参数调用工厂函数
  31. return factory.apply(null, args);
  32. };
  33. return diContainer;
  34. };

✅优点:
* 耦合度降低,可测试性提高。
* 模块不使用 DI Container 时也能用

🛑缺点
* 因为其实也是 DI,缺点类似
* 更复杂,依赖在运行时才解析出来

来编写插件吧✍️

理想的软件工程架构,是拥有一个最小化的核心。然后其余功能按需通过插件来扩展。然而这并不容易实现,因为需要花费不少的时间与资源。
❓本节关注以下两个问题:
* 如何将应用的服务暴露给插件
* 如何将插件整合到应用的执行流中

以包的形式安装插件(Plugins as packages)

在 Node.js 应用中,经常能见到将插件以包的形式安装在 node_modules 中。
✅这样做有两个好处:
1. 利用了 npm 对依赖的管理分发能力
2. 每个包都可以拥有自己私有的依赖,减少了冲突的可能性

著名的例子有 express 的 middleware,gulp 的一众插件等。

事实上,除了可以将外部的扩展插件作为包,还可以将整个应用拆分成一个个的包,如同内部插件一般。

包可以是私有的,只要在 package.json 中设置 private 即可避免上传到公开的 npm 仓库中。自己可以通过 git 或者私有 npm 仓库来管理分发。

✅采用“内部插件”有这些好处:
1. 利用 requier() 的寻路算法,免去写相对路径的麻烦
2. 提高了可复用性。迫使开发者关注应用的那些部分可以暴露,那些需要保持私有。

扩展点

一般应用会提供一些钩子给插件挂载。

由插件控制 Vs 由应用控制扩展(Plugin-controlled vs application-controlled extension)

主要有两种方式来扩展一个应用的组件:
* 明确扩展(Explicit extension),即插件调用组件

  1. const app = express();
  2. require('thePlugin')(app);
  1. const app = express();
  2. const plugin = require('thePlugin')();
  3. app[plugin.method](plugin.route, plugin.hander);

两种方式的差异也很明显:
* 插件控制的扩展能力更强,更灵活,因为能直接访问到应用的内部。但弊大于利,插件必需清楚应用的构造,每次应用作出修改,插件很可能也需要随之修改。
* 应用控制的插件则要求应用能以某种形式提供扩展点(如钩子等)
*

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注