浅析Egg路由的反向解析
本文由 小茗同学 发表于 2024-05-15 浏览(45)
最后修改 2024-05-16 标签:

什么叫反向解析

这里所说的Egg路由反向解析指的是,根据浏览器上一个能访问的URL地址定位到Egg工程里面Controller或者API代码的位置,为了描述方便,本文把Controller或者API统一称为action

乍一听起来感觉这个应该很容易啊,直接从router.js找到映射关系不就好了么,例如下面这样的:

image.png

如果大家都严格按照一些约定好的规范去写的话,这个确实很容易。比如说,如果按照代码路径关系相应的生成router,那么路由都不用手写了,甚至可以全部都自动生成。但现实是我们现在的代码中路由的写法五花八门,有的文件名是中划线式的,比如customer-info.js,有的又是驼峰,有的最终生成的路由和代码路径完全不一致,有的是分开在多个文件写的,有的是合并在一个文件里面的,而且所有的路由都是手写的,等等等等。

诸如以上一系列原因导致Egg路由的反向解析并不能通过简单的文件查找或者正则匹配去实现。

要面临的几个问题

1、速度

这里之所以要反向解析路由,主要是用在一些插件跳转场景,比如通过页面快速定位到代码,所以,诸如“把整个Egg工程跑起来不就很容易拿到页面地址么”此类的方法是不可取的。

2、中划线与驼峰问题

我们Egg工程里面action文件名的写法五花八门,有的是中划线,有的是驼峰:

image.png

但是最终在router.js中由于Egg会自动转驼峰,所以又必须按照驼峰的方式来写:

image.png

猜猜下面这个js最终生成的action路径是什么:

image.png

答案是: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这样的不定层级的对象一直是存在的,不能报错。

具体实现

通过前面一小节的分析,我们已经知道反向解析路由必须要面临的几个问题,再来梳理一下思路:

  1. 直接正则匹配?很难做到通用;
  2. 直接启动整个Egg工程?太慢!
  3. 自己写语义解析去解析路由文件?太难!
  4. 模拟一个轻量级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个致命问题:

  1. 速度比较慢,为了判断某个文件到底是方法还是对象,需要require每个文件,所以拖慢了速度,整体执行一遍大概需要几百毫秒;
  2. 不稳定,容易报错,只要有一个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,模拟更完善的方法:

image.png

最终实现:

/**
 * 反向解析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};
}