什么叫反向解析
这里所说的Egg路由反向解析指的是,根据浏览器上一个能访问的URL地址定位到Egg工程里面Controller
或者API
代码的位置,为了描述方便,本文把Controller
或者API
统一称为action
。
乍一听起来感觉这个应该很容易啊,直接从router.js找到映射关系不就好了么,例如下面这样的:
如果大家都严格按照一些约定好的规范去写的话,这个确实很容易。比如说,如果按照代码路径关系相应的生成router,那么路由都不用手写了,甚至可以全部都自动生成。但现实是我们现在的代码中路由的写法五花八门,有的文件名是中划线式的,比如customer-info.js
,有的又是驼峰,有的最终生成的路由和代码路径完全不一致,有的是分开在多个文件写的,有的是合并在一个文件里面的,而且所有的路由都是手写的,等等等等。
诸如以上一系列原因导致Egg路由的反向解析并不能通过简单的文件查找或者正则匹配去实现。
要面临的几个问题
1、速度
这里之所以要反向解析路由,主要是用在一些插件跳转场景,比如通过页面快速定位到代码,所以,诸如“把整个Egg工程跑起来不就很容易拿到页面地址么”此类的方法是不可取的。
2、中划线与驼峰问题
我们Egg工程里面action
文件名的写法五花八门,有的是中划线,有的是驼峰:
但是最终在router.js中由于Egg会自动转驼峰,所以又必须按照驼峰的方式来写:
猜猜下面这个js最终生成的action
路径是什么:
答案是:api.fooBar1.fooBar2.fooBar3.fooBar4['foo-bar5']
可以看到,不仅是文件名,文件夹名,也会转驼峰,首字母大写的还会转小写,但是方法名不会转驼峰。
所以可以看到,这样一搞下来,想要写一个通用的反向解析功能,绝对不是简单的解析router.js文件就可以了,还要扫描整个Controller
文件夹来进一步配合,也正是因为这个原因,想要在Chrome插件中通过简单的几行代码就把路由在线解析出来是完全不可行的。
3、 投机取巧正则匹配?
比如我们的ptn-basedata
路由都是写在一个页面的,只要简单对router.js
做一些正则匹配基本上就能拿到真实的代码地址,但是,这种做法肯定不通用,今天这样写能解决明天换一种写法就解析不出来了,所以也不可取。
4、无法直接判断是文件还是方法
比如api.fooBar1.fooBar2.fooBar3
这样一个action,可能是fooBar2.js
这样一个文件里面有一个叫fooBar3
的方法,也有可能fooBar2
只是一个文件夹,fooBar3
才是文件,所以还是要配合文件扫描才能确定结果。
5、action写法不统一
有的action是带引号的,有的是没有,如果是引号的,我们可以很容易模拟一个app对象出来,然后require('router.js')(app)
执行即可,但现在问题是如果是没有引号的话,在我们模拟app对象的时候,必须保证app.controller.api.xxx.ooo.aaa
这样的不定层级的对象一直是存在的,不能报错。
具体实现
通过前面一小节的分析,我们已经知道反向解析路由必须要面临的几个问题,再来梳理一下思路:
- 直接正则匹配?很难做到通用;
- 直接启动整个Egg工程?太慢!
- 自己写语义解析去解析路由文件?太难!
- 模拟一个轻量级app对象然后去执行
router.js
?貌似可取,我们就采用这个方法试试。
初次尝试
router.js
导出的是一个方法,执行需要一个app对象,根据观察,最开始简单写了如下方法先保证满足最普通的场景:
/**
* 反向解析Egg路由
* @param {*} projectPath Egg工程路径
*/
function parseEggRouter(projectPath) {
const routerPath = `${projectPath}/app/router.js`;
if (!fs.existsSync(routerPath)) {
return;
}
// 模拟一个app对象
const app = {
map: {},
controller: {},
jsonp() {},
middlewares: {
interceptor() {}
},
get(url, interceptor, target) {this.map[url] = target || interceptor},
post(url, interceptor, target) {this.map[url] = target || interceptor}
};
// 扫描整个controller文件夹,往app.controller里面填充内容
scanFolderSync(`${projectPath}/app/controller`, filePath => {
if (!/\.js$/g.test(filePath)) return;
// 由于要判断这个文件exports出的是方法还是对象,所以需要require它
const target = require(filePath);
const basePath = filePath.replace(`${projectPath}/app/controller/`, '').replace(/\.js$/, '').replace(/\//g, '.');
if (typeof target == 'function') {
setDeepPathObj(app.controller, basePath, undefined);
} else {
for (let key in target) {
setDeepPathObj(app.controller, basePath + '.' + key, undefined);
}
}
})
require(routerPath)(app);
return app.map;
}
/**
* 给对象设置 a.b.c.d 深层级key
* @param {*} target
* @param {*} key
* @param {*} value
*/
function setDeepPathObj(target, key, value) {
value = value == undefined ? key : value;
var arr = key.split('.').map(key => util.toHump(key, '-').replace(/^([A-Z])/g, (m, $1) => $1.toLowerCase()));
arr.forEach((k, i) => {
if (!target[k]) target[k] = {};
if (i < arr.length - 1) target = target[k];
});
target[arr.pop()] = value;
}
这种方法基本上能满足需要,只需要针对router
写法的各种情况做一下完善兼容即可,但是它有2个致命问题:
- 速度比较慢,为了判断某个文件到底是方法还是对象,需要require每个文件,所以拖慢了速度,整体执行一遍大概需要几百毫秒;
- 不稳定,容易报错,只要有一个action文件出现错误,整个解析都会报错,所以还需要继续完善;
继续完善
换一个思路,先拿到router => action
的映射关系,然后再通过遍历文件夹得到 action => filePath
的映射,如果碰到filePath找不到的就假设action的最后一层是function,向前查找一级,这样就避免require
每一个action文件,速度得到大大提升。
那么如何通过不扫描文件来模拟一个不会报错的app对象呢?答案是利用ES6的Proxy
,例如对于app.controller
:
/**
* 创建一个特殊的对象,这个对象会满足:
* var controller = createSpecialObject('controller');
* `${controller}` => 输出 ''
* `${controller.page.home}` => 输出 'page.home'
* `${controller.api.activity.gmvrank}` => 输出 'api.activity.gmvrank'
* `${controller.basedata.page.home.index}` => 输出 'basedata.page.home.index'
* @param {*} prefix 前缀
*/
function createSpecialObject(prefix = '') {
return new Proxy({}, {
get(target, key) {
if (typeof key === 'string') {
if (key === 'toString') return function(){ return prefix; };
return createSpecialObject(prefix + (prefix ? '.' : '') + key);
}
}
});
}
由于路由的写法还支持interceptor,所以还要再模拟一个万能拦截器:
/**
* 创建一个万能的中间件:
* var middlewares = createSpecialMiddlewares();
* middlewares.xxx 返回一个空function
* middlewares.ooo 返回一个空function
*/
function createSpecialMiddlewares() {
return new Proxy({}, {
get(target, key) {
if (typeof key === 'string') {
return function(){};
}
}
});
}
通过查看egg官网router的api,模拟更完善的方法:
最终实现:
/**
* 反向解析Egg路由
* @param {*} projectPath Egg工程路径
*/
function parseEggRouter(projectPath) {
const routerPath = `${projectPath}/app/router.js`;
if (!fs.existsSync(routerPath)) {
return {code: 1, message: '未找到路由文件:' + routerPath};
}
// 模拟一个Egg的app对象
const app = {
map: {}, // 存放映射好的结果
jsonp() {},
router: {},
controller: createSpecialObject(),
middlewares: createSpecialMiddlewares(),
};
// 模拟所有router下面挂载的http方法
const methods = ['get', 'post', 'all', 'head', 'options', 'put', 'patch', 'delete', 'del', 'redirect'];
methods.forEach(method => app.router[method] = app[method] = (...args) => app.map[args[0]] = `${args.pop()}` );
require(routerPath)(app);
const routerMap = {};
// 扫描app下所有的controller文件,放到app.controller对象里面去
scanFolderSync(`${projectPath}/app/controller`, filePath => {
if (!/\.js$/g.test(filePath)) return;
// 形如:page.xxx.customer-info.js
const router = filePath.replace(`${projectPath}/app/controller/`, '')
.replace(/\.js$/, '')
.replace(/\//g, '.')
.replace(/-(\w)/g, (m, $1) => $1.toUpperCase()); // 中划线转驼峰
routerMap[router] = filePath;
});
const result = {}; // 结果格式:{controller => [absolutePath, selectText]}
for (const i in app.map) {
let router = app.map[i];
// router 形如:page.activity.gmvrank 或者 page.customer.checkInfo.index
// 所以,可能最后一个单词是文件名,也有可能倒数第二个单词是文件名
if (routerMap[router]) {
result[i] = [routerMap[router], null];
} else {
// 如果没有找到,可能是因为最后一层是一个function,往前退一层
const temp = router.split('.');
const methodName = temp.pop();
router = temp.join('.');
if (routerMap[router]) {
result[i] = [routerMap[router], methodName];
}
}
}
return {code: 0, data: result};
}