Node.js 动态插件架构 - 构建易于扩展的程序

两年前我做了一个名叫302go的不为人知的self-hosted玩具项目, 这个项目是一个重定向搜索引擎. 什么叫重定向搜索引擎呢? 就是它本身不具备任何搜索引擎应该具有的抓取和索引功能, 仅仅是把搜索用的关键字用HTTP 302状态重定向到指定的URL. 在实际使用中, 它的效果是这样的:

当你在浏览器的搜索栏(鉴于现在所有主流浏览器的地址栏和搜索栏都合并在了一起, 所以当成地址栏也没问题)里输入"Google 你好"或者"你好 Google"并按下回车进行搜索, 这个搜索引擎会直接302重定向到Google关于"你好"的搜索结果页面. 大部分人了解浏览器的用户会认为这是一个相当多余的功能, 因为Chrome等现代浏览器已经内建了搜索引擎自动收录和在地址栏快速使用特定搜索引擎进行搜索的功能, 所以实际上这个玩具项目为了有一点特色还添加了一些其他的独特功能, 不过这个项目毕竟已经在两年前就被我坑掉了, 而且这只是作为本篇文章一个引子, 所以关于这些独特之处就不展开来细讲了, 如果未来有一天我有幸填坑, 那么会有专门的文章去介绍这些功能.

要实现一个像302go这样的项目, 大规模支持各种搜索引擎是很有必要的, 在我坑掉的这个项目里, 已经内建支持了几十种搜索引擎, 每个搜索引擎支持都是一个独立的JavaScript函数. 如何让这样的项目支持许多搜索引擎并能够不断扩展, 有很多种方法, 比如我们把这些独立的项目都按照固定的格式写进数据库或缓存, 当需要调用的时候直接从数据库里读取再使用, 这些都是不错的方法, 虽然不知道哪种方法会是最好的, 但我相信对于大部分人来说, 最愚蠢的方法无疑是把每个函数硬编码进程序的HTTP路由或者其他表示映射关系的数据结构里面——我在一些项目里经常看到这种傻得很彻底的代码, 当后来的程序员为了给一个项目的这类功能增加一个新的支持项的时候, 他们需要直接去修改这个项目的路由代码, 然后在它们更新代码到服务器时还需要因此去重新启动一次服务器程序.

利用require函数实现动态插件

回忆一下美好的PHP时代, 如果有人接触过像WordPress这样使用代码进行插件扩展的程序的话, 你就会发现像JavaScript和PHP这样的动态语言, 在支持类似"插件"的内容时其实有着得天独厚的优势: 我们可以简单优雅地动态读取和执行代码. 具体到302go项目里是这样的——我建立一个文件夹专门放置相应的代码, 比如plugins文件夹, 再在里面为每个搜索引擎支持建立单独的JavaScript文件, 例如Google搜索的文件名就是"google.js", 文件的内容是这样的:

module.exports = keyword => `//www.google.com/search?q=${ keyword }`

非常简单和清晰, 一个纯函数, 接受keyword作为唯一参数, 将返回一个拼接好的URL字符串.

如何在程序代码里得到这个纯函数模块, 也很简单:

const plugin = require('./plugins/google')

对于任何编写过Node.js代码的开发者来说, 这些都非常好理解.

接下来的问题是怎么让程序意识到这里有一个名为"google.js"的插件, 对于我的项目来说, 其实完全可以不用让程序知道这里有一个名为"google.js"的插件存在, 它是响应式的: 当用户进行搜索时, 程序会解析搜索时的关键字, 对于"Google 你好"或者"你好 Google", 它会将关键字以空格为分隔进行拆分, 然后得到"Google"和"你好"这对关键字, 程序此时可以不需要知道"Google"和"你好"究竟哪个才是搜索引擎的名字, 它只需要拼接字符串尝试调用require()函数进行模块导入, 最多两次就能确定哪个是需要调用的模块(当然, 在这之前需要你让关键字变得干净, 以防止被恶意代码攻击):

function tryRequire(moduleName) {
  try {
    return require(moduleName)
  } catch(e) {
    return null
  }
}

function getPlugin(...maybePluginNames) {
  for (const pluginName of maybePluginNames) {
    const plugin = tryRequire(`./plugins/${ pluginName }`)
    if (plugin) {
      return plugin
    }
  }
  return null
}

const plugin = getPlugin('Google'.toLowerCase(), '你好'.toLowerCase()) || defaultPlugin

由于Node.js的require函数只会执行一次代码, 所以你不必担心它会一次又一次读取硬盘文件, 只要被调用一次, 它就进入了内存, 而这样一个纯函数对于内存的负担是非常小的, 所以实际上像这样使用require函数相当于你拥有了一个内置在Node.js里自带缓存的插件功能.

更新插件缓存

由于require函数自带缓存的设定, 当你更新插件的代码后, 程序依然会使用之前缓存的插件代码. 删除require函数的缓存很简单, 只要删除require.cache里对应的键就行了:

function removePluginCache(pluginName) {
  return delete require.cache[require.resolve(`./plugin/${ pluginName }`)]
}

removePluginCache('google') // true

作为一个开发人员, 你真正会遇到的问题是如何触发这个removePluginCache函数. 通常我们会在编辑完这个插件的代码后就要求更新缓存, 所以监视插件目录下的文件改动是必要的, 而Node.js内建的API在这方面并不好用, 所以这里我们借助一下第三方模块chokidar, 可以很简单的实现自动更新插件缓存的需求:

const chokidar = require('chokidar')

function autoRemovePluginCache() {
  const watcher = chokidar.watch('./plugins/*.js')
  watcher.on('change', filename => removePluginCache(require.resolve(`./plugins/${ filename }`)))
  return watcher
}

autoRemovePluginCache()

当你注意到require自带缓存, 并且在更新插件缓存前修改本地插件文件是安全的这件事时, 可能会不禁感叹这套方法的强大.

获取全部动态插件

当然, 我们的程序不可能总是像这样响应式运行的, 当我们想知道这个项目里到底有哪些插件的时候, 这样需要主动输入模块名的方式就不再适用了. 不过要解决这个问题也很简单——我们只需要获得这个文件夹下的所有以"js"作为扩展名的文件即可:

const util = require('util')
const fs = require('fs')
const readdir = util.promisify(fs.readdir)

async function getAllPluginNames() {
  const filenames = await readdir('./plugins')
  return filenames
    .filter(x => x.endsWith('.js'))
    .map(x => x.replace(/\.js$/, ''))
}

getAllPluginNames().then(console.log) // ['google']

由于这么做确实会读取硬盘(即使这种程度的I/O在大部分SSD硬盘都是非常快的), 所以你大可为getAllPluginNames函数设置一个缓存, 或者...维护一个插件数组.

在维护插件数组的时候监视插件变动

当你维护一个插件数组的时候, 你的程序需要能够响应插件的添加和删除, 这和上文的更新插件缓存是非常相似的, 我们只需要继续使用chokidar模块, 监视事件addunlink就可以了.

如果单个插件有多个JavaScript文件怎么办?

成熟的程序所使用的插件可能会很复杂, 依赖各种不同的模块和文件, 所以文中举例的单文件式插件就不再好用了, 在Node.js里, 我们也不太可能用Browserify、Webpack和Rollup这样的打包工具强行把它们打包成单个文件来使用(在Node.js项目里这样干很蠢).

实际上, 多文件的解决方案已经内置在Node.js里了, 你只需要对代码进行一些小改动就能进行支持. 在最近一两年里, 越来越多的人回忆起了这种代码组织形式:

plugins/
  p1/
    depA.js
    depB.js
    index.js
  p2/
    depA.js
    depB.js
    depC.js
    index.js
  ...

在这种代码结构下, require('./plugins/p1')的执行结果相当于require('./plugins/p1/index'), 在require函数的第一个参数为目录时, 它会使用该目录下的"index.js"文件作为模块入口, 这样我们就可以在目录结构上把插件p1和插件p2的代码进行隔离.

再深入一点, 你还可以为插件目录创建"package.json"文件, 让它成为一个真正独立的模块目录, 并在"package.json"文件里保存各种有用的信息, 不要忘了require函数也可以用来加载JSON文件, 在这种情况会变得很有用.

对于大部分程序员来说, 真正限制他们的, 还是想象力.