咱们平时在使用express写代码的过程当中,会根据类别,将路由分为多个不一样的文件,而后在项目的入口文件(例如app.js)中将其依次挂载,例如:java
const index = require('./routes/index') const user = require('./routes/user') // ...其余路由文件 app.use('/', index) app.use('/user', user) // ...挂载其余路由
可是当路由文件过多时,这样写会多出不少重复性的代码,并且当我添加一个新的路由模块时,除了编写路由文件自己,还须要到app.js入口文件中将新路由文件挂载上去,不够灵活,所以,咱们须要想一些办法来管理咱们的路由,使其可以自动化,免除频繁修改入口文件的操做。git
咱们的项目目录主要是这样的:github
├─routes ├─index.js ├─user.js ├─sub ├─index.js ├─a.js ├─app.js
首先,咱们来看一下,express的路由管理主要由三部分组成,路由方法(method)、路由路径(path)和路由处理器(handle),通常状况下,路由方法和路由处理器是由路由文件本身来管理,在一个路由文件中,咱们常用这样的写法:express
// routes/user.js const express = require('express') const router = express.Router() // 路由的方法,处理器和部分路径 router.get('/', function (req, res, next) { res.send('respond with a resource') }) module.exports = router
而后在入口文件中添加上共通的路由前缀:npm
app.use('/user', require('./routes/user'))
根据这种思路,咱们主要处理的就是路由路径这个部分。在这个部分咱们有两种处理方式,一种是根据路径和文件名自动生成路由的共通路径前缀,路由文件只编写剩余不共通部分的路径;还有一种则是路径彻底由路由文件本身来管理,在挂载时直接挂载到根路径'/'
上。json
咱们经过扫描项目目录,能够将文件在项目中的路径转化为express的路由路径模式,自动生成路由前缀,例如路由文件routes/sub/a.js
就会为转化成路由前缀/sub/a
,路由文件a.js
中只要编写/sub/a
后面的路径部分便可。数组
项目目录为:bash
├─routes ├─index.js ├─user.js ├─sub ├─index.js ├─a.js ├─app.js ├─helper.js
主要的实现代码为:服务器
// helper.js const fs = require('fs') const path = require('path') /** * 将文件名修正为前缀 * * @param {String} filename * @returns {String} */ function transform (filename) { return filename.slice(0, filename.lastIndexOf('.')) // 分隔符转换 .replace(/\\/g, '/') // index去除 .replace('/index', '/') // 路径头部/修正 .replace(/^[/]*/, '/') // 路径尾部/去除 .replace(/[/]*$/, '') } /** * 文件路径转模块名(去.js后缀) * * @param {any} rootDir 模块入口 * @param {any} excludeFile 要排除的入口文件 * @returns */ exports.scanDirModules = function scanDirModules (rootDir, excludeFile) { if (!excludeFile) { // 默认入口文件为目录下的 index.js excludeFile = path.join(rootDir, 'index.js') } // 模块集合 const modules = {} // 获取目录下的第一级子文件为路由文件队列 let filenames = fs.readdirSync(rootDir) while (filenames.length) { // 路由文件相对路径 const relativeFilePath = filenames.shift() // 路由文件绝对路径 const absFilePath = path.join(rootDir, relativeFilePath) // 排除入口文件 if (absFilePath === excludeFile) { continue } if (fs.statSync(absFilePath).isDirectory()) { // 是文件夹的状况下,读取子目录文件,添加到路由文件队列中 const subFiles = fs.readdirSync(absFilePath).map(v => path.join(absFilePath.replace(rootDir, ''), v)) filenames = filenames.concat(subFiles) } else { // 是文件的状况下,将文件路径转化为路由前缀,添加路由前缀和路由模块到模块集合中 const prefix = transform(relativeFilePath) modules[prefix] = require(absFilePath) } } return modules }
而后,在路由目录的入口index文件下,加入这么一段代码(scanDirModules方法须要从以前编写的helper.js文件中引入):babel
const scanResult = scanDirModules(__dirname, __filename) for (const prefix in scanResult) { if (scanResult.hasOwnProperty(prefix)) { router.use(prefix, scanResult[prefix]) } }
在app.js入口文件中只须要将全部路由相关代码改为一句:
app.use('/', require('./routes'))
这样就完成了路由前缀的自动生成和路由自动挂载了。
效果展现:
咱们将routes/sub/a.js
的内容定为:
// routes/sub/a.js const express = require('express') const router = express.Router() router.get('/', function (req, res) { res.send('sub/a/') }) module.exports = router
挂载效果:
访问结果:
这种自动生成前缀的方法,在路由目录层级不深时,能够起到很好的做用,可是当目录层级较多时,就会暴露出缺点:阅读代码时路由路径不明确,不能直观地看到完整路径,并且生成前缀的灵活性不高。
后者可使用自定义导出对象和挂载方式的方法来解决,可是前者我暂时没有什么好的解决方法,所以咱们来看一下以前提到的另外一种自动化方法。
这种方法的扫描思路和前一种方法类似,不一样之处在于,在编写路由文件的时候,咱们须要写完整路由的路径,例如:
// routes/sub/a.js const express = require('express') const router = express.Router() router.get('/sub/a', function (req, res) { res.send('sub/a/') }) module.exports = router
扫描部分的代码修改成:
exports.scanDirModulesWithoutPrefix = function scanDirModulesWithoutPrefix (rootDir, excludeFile) { if (!excludeFile) { // 默认入口文件为目录下的 index.js excludeFile = path.join(rootDir, 'index.js') } const modules = [] let filenames = fs.readdirSync(rootDir) while (filenames.length) { // 路由文件相对路径 const relativeFilePath = filenames.shift() // 路由文件绝对路径 const absFilePath = path.join(rootDir, relativeFilePath) // 排除入口文件 if (absFilePath === excludeFile) { continue } if (fs.statSync(absFilePath).isDirectory()) { // 是文件夹的状况下,读取子目录文件,添加到路由文件队列中 const subFiles = fs.readdirSync(absFilePath).map(v => path.join(absFilePath.replace(rootDir, ''), v)) filenames = filenames.concat(subFiles) } else { // 是文件的状况下,将模块添加到模块数组中 modules.push(require(absFilePath)) } } return modules }
路由入口文件修改成:
// 获取 routes 目录下全部路由模块,并挂载到一个路由上 const routeModules = scanDirModulesWithoutPrefix(__dirname, __filename) routeModules.forEach(routeModule => { router.use(routeModule) })
挂载效果:
这种方法能够明确的看到路由的完整路径,在阅读代码时不会出现由于层级过深而致使出现阅读困难的状况,可是明显的缺点就是须要编写大量的路径相关代码,路径重用性又过低。
那么有没有一种方法,既能保证共通路径的重用性,又能保证代码的可阅读性呢?
有,咱们能够用JavaScript的装饰器(Decorator)来进行路由的管理。
装饰器的思路来自于Java的MVC框架Spring MVC
,在Spring MVC中,路由的编写方式是这样的:
// 类上的 RequestMapping 注解用来设置共通的路径前缀 @Controller @RequestMapping("/") public class SampleController { // 方法上的 RequestMapping 注解用来设置剩余路径和路由方法 @RequestMapping("/", method=RequestMethod.GET) public String index() { return "Hello World!"; } // GetMapping 注解至关于已经指定了GET访问方法的 RequestMapping @GetMapping("/1") public String index1() { return "Hello World!1"; } }
在ES6以后,在js中编写类已经变得很是容易,咱们也能够仿照 Spring MVC 的路由方式来管理express中的路由。
关于JavaScript的装饰器,能够参考这两篇文章:
在进行实现以前,咱们先简单整理一下实现的思路。个人思路是,为了阅读方便,每个路由文件包括一个类(Controller),每一个类上有两种装饰器。
第一种装饰器是在类上添加的,用来将这个类下面的全部方法绑定到一个共通的路由前缀上;
而第二种装饰器则是添加到类中的方法上的,用来将方法绑定到一个指定的HTTP请求方法和路由路径上。
这两种装饰器也都接收剩余的参数,做为须要绑定的中间件。
除了编写装饰器自己以外,咱们还须要一个注册函数,用来指定须要绑定的express对象和须要扫描的路由目录。
为了使用装饰器这个特性,咱们须要使用一些babel插件:
$ yarn add babel-register babel-preset-env babel-plugin-transform-decorators-legacy
编写.babelrc
文件:
{ "presets": [ "env" ], "plugins": [ "transform-decorators-legacy" ] }
在app.js中注册babel-register
:
require('babel-register')
注册函数的功能较为简单,所以咱们先来编写注册函数:
let app = null /** * 扫描并引入目录下的模块 * * @private * @param {string} routesDir 路由目录 */ function scanDirModules (routesDir) { if (!fs.existsSync(routesDir)) { return } let filenames = fs.readdirSync(routesDir) while (filenames.length) { // 路由文件相对路径 const relativeFilePath = filenames.shift() // 路由文件绝对路径 const absFilePath = path.join(routesDir, relativeFilePath) if (fs.statSync(absFilePath).isDirectory()) { // 是文件夹的状况下,读取子目录文件,添加到路由文件队列中 const subFiles = fs.readdirSync(absFilePath).map(v => path.join(absFilePath.replace(routesDir, ''), v)) filenames = filenames.concat(subFiles) } else { // require路由文件 require(absFilePath) } } } /** * 注册express服务器 * * @param {Object} options 注册选项 * @param {express.Application} options.app express服务器对象 * @param {string|Array<string>} options.routesDir 要扫描的路由目录 */ function register (options) { app = options.app // 支持扫描多个路由目录 const routesDirs = typeof options.routesDir === 'string' ? [options.routesDir] : options.routesDir routesDirs.forEach(dir => { scanDirModules(dir) }) }
经过获取express的app对象,将其注册到文件的顶级变量app,可让其他的装饰器函数访问到app对象从而完成路由注册。
routesDir
能够是字符串也能够是字符串的数组,表明了须要扫描的路由目录,将其转化为字符串数组后依次进行扫描。
scanDirModules
方法与以前的扫描方法相似,只是这里只须要将路由文件require进来就行,不须要返回。
装饰器部分分为两部分,装饰类的路由装饰器Router
和其他装饰方法的请求处理装饰器(Get
, Post
, Put
, Delete
, All
, Custom
)。
在方法装饰器的编写上,因为装饰器的行为类似,所以咱们能够编写一个抽象函数,用来生成不一样HTTP请求方法的不一样装饰器。
抽象函数的具体代码为:
/** * 生成对应HTTP请求方法的装饰器 * * @param {string} httpMethod 请求方法 * @param {string|RegExp} pattern 请求路径 * @param {Array<Function>} middlewares 中间件数组 * @returns {MethodDecorator} */ function generateMethodDecorator (httpMethod, pattern, middlewares) { return function (target, methodName, descriptor) { if (!target._routeMethods) { target._routeMethods = {} } // 为自定义方法生成对应的方法存储对象 if (!target._routeMethods[httpMethod]) { target._routeMethods[httpMethod] = {} } target._routeMethods[httpMethod][pattern] = [...middlewares, target[methodName]] return descriptor } }
这里的target
表示类的原型对象,methodName
则是须要装饰的类方法的名称,咱们将类方法和它的前置中间件组成一个数组,存储到类原型对象上的_routeMethods
属性中,以便类装饰器调用。
要生成一个HTTP请求方法的装饰器,只须要调用这个生成函数便可。
例如生成一个GET方法的装饰器,则只须要:
/** * GET 方法装饰器 * * @param {string|RegExp} pattern 路由路径 * @param {Array<Function>} middlewares 中间件数组 * @returns {MethodDecorator} */ function Get (pattern, ...middlewares) { return generateMethodDecorator('get', pattern, middlewares) }
路由装饰器(类装饰器)的代码为:
/** * Router 类装饰器,使用在 class 上,生成一个带有共通前缀和中间件的路由 * * @param {string|RegExp} prefix 路由前缀 * @param {express.RouterOptions} routerOption 路由选项 * @param {Array<Function>} middlewares 中间件数组 * @returns {ClassDecorator} */ function Router (prefix, routerOption, ...middlewares) { // 判断是否有路由选项,没有则当作中间件来使用 if (typeof routerOption === 'function') { middlewares.unshift(routerOption) routerOption = undefined } /** * 为类生成一个 router, * 该装饰器会在全部方法装饰器执行完后才执行 * * @param {Function} target 路由类对象 */ return function (target) { const router = express.Router(routerOption) const _routeMethods = target.prototype._routeMethods // 遍历挂载路由 for (const method in _routeMethods) { if (_routeMethods.hasOwnProperty(method)) { const methods = _routeMethods[method] for (const path in methods) { if (methods.hasOwnProperty(path)) { router[method](path, ...methods[path]) } } } } delete target.prototype._routeMethods app.use(prefix, ...middlewares, router) } }
这里的target是类对象,当装饰器对类进行处理时,咱们生成一个新的express路由对象,将放置在类对象原型上的_routeMethods属性进行遍历,获取到对应的路由方法、路由路径和路由处理函数,并挂载到这个路由对象上。
须要注意,类装饰器的处理会放在方法装饰器以后进行,所以咱们不能直接在方法装饰器上进行挂载,须要将其存储起来,在类装饰器上完成挂载工做。
咱们的路由文件也须要进行大幅度的改动,将其转化为下面相似的形式:
// routes/sub/a.js // Router 和 Get 装饰器从你的装饰器文件中引入 @Router('/sub/a') class SubAController { @Get('/') index (req, res, next) { res.send('sub/a/') } } module.exports = SubAController
用装饰器编写路由的相关代码我已经单独创建了一个github仓库,并发布成了一个npm包——express-derouter,欢迎各位star。
以上就是我最近所思考的有关于express路由管理自动化的几种方法,其中装饰器挂载的方式因为js自身缘由,在还原Spring MVC的其余功能上有所限制,若是你对更增强大的功能有要求的话,能够看看TypeScript基于express的一个MVC框架——nest,相信它应该更能知足你的需求。
文章博客地址:关于 express 路由管理的几种自动化方法