如何在前端开发中构建可扩展的mock服务器
在前端开发中,联调接口是一项必备技能,实际的开发中,经常是前后端并行开发,所以在后端接口还未完成环境部署的情况下,前端解决 API Mock 就显的尤为重要。
在基于 webpack 的打包架构中,webpack 提供了一个 devServer.before
字段,用来在启动开发服务器的时候执行一段代码。一种思路,便是在 before 中赋值一个 mock server ,这样一来,随着项目本地调试的开启,mock server 也会同步建立起来,本篇博文将介绍基于此方式的一种 mock server 方案。
本地服务
本地服务可以使用 express 建立,实现起来也比较简单,创建一个 http-server.js 文件,写入服务代码。
webpack 配置项中的 devServer.before 会传递一个 app 对象,这个对象也是一个 express 的实例。
function HttpServer() {
this.version = '1.0.0';
this.app = express();
this.server = null;
}
HttpServer.prototype.start = function (port = 8080) {
try {
this.server = server = this.app.listen(port, () => {
logger.success('Listening localhost on port ', this.server.address().port);
});
}
catch (e) {
logger.error(e);
}
}
// 注册路由
HttpServer.prototype.route = function (method, uri, response) {
this.app[method](uri, function (req, res) {
let responseType = helper.getTypeOf(response);
if (['Number', 'String', 'Array', 'Object'].includes(responseType)) {
res.send(response);
return;
}
if (responseType === 'Function') {
res.send(response(req));
return;
}
});
}
module.exports = HttpServer;
其中的 logger 和 helper 是辅助函数,logger 辅助日志输出,helper 提供工具方法。
API 模块化
API部分是可变的,每一个项目都会有所区别,也就是当前项目的API定义,不一定符合下一个项目的定义,所以API部分需要模块化处理。
我将 API 部分放到 services 目录下,services 目录下的每个目录都当做一个微服务来处理,这样一来,只需要遍历 services 目录,然后注册 services 下每个微服务的路由即可。
假设有一个服务叫 app,现处理如下:
- app 下的 mock 文件夹,处理自定义的 mock 注册,返回值和返回方式由 mock 规则决定;
- app 下的 yaml 文件夹,处理 swagger 定义的路由,mock 数据由 yaml 的解析决定;
- 如果 mock 下的路由与 yaml 下的路由重名,则优先匹配 mock 下的路由;
这样一来,API 的部分同时执行了自定义和 swagger ,这便对基于 swagger 的后端非常有益,开发前 SE 如果确定了接口文档,那么前端基于 swagger 就可以马上生成一个虚拟接口服务器,前后端并行开发。
API 模块的加载
基于上述模块化的思路,API 的加载就是在加载 services 下的微服务,那么遍历 services 的每个目录即可。
不过,这并不是全部,为了可以让 API 在模块化的同时也规范化,我们可以定义 每一个API 模块必须有一个入库函数,我将次函数定义为 main 函数,main 函数是执行入口,必须返回一个路由表,再有加载器去向 http server 注册路由;
所以,API 模块的编写就变成了这样:
/**
* 微服务入口函数
*
* @param {object} random mockjs 的 Random 对象
* @param {object} Logger logger 对象,需实例化
* @param {object} helper 辅助函数库
* @returns
*/
function main(random, Logger, helper) {
const logger = new Logger('MyTag');
const hello = {
method: 'get',
uri: '/hello',
response: function (req) {
logger.log(req.route.path);
return random.sentence();
}
}
return {
basePath: '/mytag/v1',
routes: [
hello,
],
}
}
module.exports = {
main,
}
basePath
是该服务的基础地址,routes 用来导出当前服务下的路由列表,即接口列表。路由表中的 response 则用来处理接口响应。
外部,则通过遍历 services 来注册服务接口。
/**
* 加载 services 目录下的微服务
*
* @param {object} app express 实例
* @returns
*/
function loadServices(app) {
const services = fs.readdirSync(servicesPath);
if (!services || !services.length) return;
// 加载每一个微服务
services.forEach(el => {
// yaml
const yamlHandleFile = helper.pathJoin(servicesPath, el, 'yaml');
regYamlRoutes(app, yamlHandleFile);
// mock
const mockHandleFile = helper.pathJoin(servicesPath, el, 'mock/handle.js');
regMockRoutes(app, mockHandleFile);
});
}
其中,regYamlRoutes
方法用于加载 yaml
文件,以便注册 API 接口,regMockRoutes
方法则处理自定义 mock 的注册。
加载 mock 模块
直接看代码:
/**
* 注册 mock 路由
*
* @param {object} app server 对象
* @param {string} mockFile mock入口文件
*/
function regMockRoutes(app, mockFile) {
if (!fs.existsSync(mockFile)) {
logger.warn(`mock handle file '{mockFile}' is not exist`);
return;
}
let handlerRouteDefine = null;
try {
handlerRouteDefine = require(mockFile).main(Random, Logger, helper);
}
catch (e) {
logger.error(e.toString());
return;
}
if (!handlerRouteDefine.routes || !handlerRouteDefine.routes.length) {
logger.warn(`{mockFile} undefine routes`);
return;
}
// 遍历定义,注册路由
handlerRouteDefine.routes.forEach(el => {
if (!config.http.enabledMethods.includes(el.method)) {
logger.warn(`request method '${el.method}' is invalid`);
return;
}
const uri = helper.urlJoin(handlerRouteDefine.basePath, el.uri);
app.route(el.method, uri, el.response);
logger.log('Reg router =>', el.method.toUpperCase().bold().cyan(), uri);
});
}
简单介绍下加载过程:
- 基于传入参数判断入口文件是否存在,不存在则直接跳出;
- 尝试 require 入口文件,并执行入口函数;
- 入口函数执行完成后会返回一个路由表,对路由表进行校验;
- 根据路由表注册路由;
加载 yaml 模块
直接上代码:
/**
* 注册 yaml 路由
*
* @param {object} app server 对象
* @param {string} yamlPath yaml文件路径
*/
function regYamlRoutes(app, yamlPath) {
if (!fs.existsSync(yamlPath)) {
logger.warn(`path '{yamlPath}' is not exist`);
return;
}
const yamls = fs.readdirSync(yamlPath);
if (!yamls || !yamls.length) return;
yamls.forEach(el => {
if (!/\.yaml/.test(el)) return;
// 读取 swagger 的 yaml 文档
const jsonObj = yamlLoader.load(helper.pathJoin(yamlPath, el));
const requestDefine = yamlLoader.translate(jsonObj, config.http.enabledMethods);
// 遍历接口,注册路由
logger.info(`Reg Yaml router from ${el}`);
requestDefine.forEach(req => {
if (req.mock && req.mock.response) {
app.route(req.method, req.uri, decorator(req.mock.response));
logger.log('Reg router =>', req.method.toUpperCase().bold().cyan(), req.uri);
}
});
});
}
执行思路:
- 基于传入参数遍历目录下的文件;
- 过滤
.yaml
文件,解析.yaml
文件; - 对
.yaml
解析结果做判断,以确定该文件是可用的路由表; - 根据解析出的路由表注册路由;
那么这里就涉及到了 yaml 的解析,我是通过编写一个 yaml-loader
来实现的,大体上分两步:
- 使用
yamljs
将 yaml 解析为 json 对象,以便分析; - 然后通过
yaml-loader
的translate
方法将 json 对象转换成路由表结构;
以下是 translate
的处理过程:
/**
* 将 yaml 对象转换成请求定义
*
* @param {object} yamlobj yaml 对象
* @param {array} enabledMethods 支持的 HTTP 请求类型
* @returns
*/
function translate(yamlobj = {}, enabledMethods = []) {
const basePath = yamlobj.basePath || '';
const paths = yamlobj.paths;
let routes = [];
for (let uri in paths) {
let reqObj = yamlobj.paths[uri];
enabledMethods.forEach(type => {
if (!reqObj[type]) return;
const reguri = `${helper.urlJoin(basePath, uri)}`;
const regtype = reqObj[type];
// logger.log(reguri)
routes.push({
method: type,
uri: reguri,
mock: _createRequestDefine(regtype, yamlobj)
});
});
}
return routes;
}
Response 处理
response 是指向前台返回数据的处理,这里做了兼容,可以是函数类型,或者非函数类型。
总结之下,response 可以有以下几种返回状态:
- 纯静态返回;
- 基于 mockjs 的伪静态返回;
- 响应 request 的动态返回;
返回来看注册路由的 route
方法:
// 注册路由
HttpServer.prototype.route = function (method, uri, response) {
this.app[method](uri, function (req, res) {
let responseType = helper.getTypeOf(response);
if (['Number', 'String', 'Array', 'Object'].includes(responseType)) {
res.send(response);
return;
}
if (responseType === 'Function') {
res.send(response(req));
return;
}
});
}
上面对 response
做了判断,非函数类型则直接返回,函数类型则执行后返回,这便带来了非常灵活的处理能力。
除此之外,还需要注意,上文中对 yaml
文件处理 response 时,使用了一个 decorator
函数,其主要目的是用于解析 yaml 的返回类型。
decorator
的简要实现如下:
/**
* 将返回对象重新包装成 mock 函数
*
* @param {object} response 响应定义
* @returns 返回一个闭包函数,每次请求时调用闭包函数实现数据mock
*/
function decorator(response = {}) {
return function () {
return _responseDecorator(response);
}
}
_responseDecorator
内部会调用 _mockResponse
方法,而 _mockResponse
是这样定义的:
/**
* 获取根类型的mock数据
*
* @param {object} repdefine 根返回值定义 {type: string|number|boolean...}
* @returns
*/
function _mockResponse(repdefine = {}) {
if (!repdefine.type) {
return mockMap.string();
}
// 如果有默认值,直接返回默认值
if (repdefine.default) {
return repdefine.default;
}
// 处理枚举类型
if (repdefine.enum) {
return mockMap.enum(repdefine.enum);
}
// 其他类型
return mockMap[repdefine.type]();
}
其中的 mockMap
是一个类型处理的映射,mock-map.js 文件定义如下:
const { Random } = require("mockjs");
/**
* 返回枚举数组中某个随机索引对应的值
*
* @param {array} enumbase 枚举值数组
* @param {any} defaultValue 默认值,非索引
* @returns 默认值,或枚举值中的任意一个
*/
Random.oneOf = function (enumbase = [], defaultValue = null) {
if (defaultValue !== null) return defaultValue;
let i = Random.integer(0, enumbase.length - 1);
return enumbase[i];
}
module.exports = {
'string': Random.string.bind(Random),
'integer': Random.integer.bind(Random),
'number': Random.float.bind(Random),
'date': Random.date.bind(Random),
'boolean': Random.boolean.bind(Random),
'enum': Random.oneOf.bind(Random),
}
对 webpack 兼容
对 webpack 的兼容,还是体现在 devServer.before
字段上,webpack 在启动时会传一个 app
参数进来,这个参数本身就是一个 express 的实例,不过为了能让 mock server 不依赖 webpack ,mock server 内部也定义了一个 HttpServer 类,所以 mock server 被用于 webpack 时,需要对这个实例做兼容。
这里使用适配器模式来兼容这两个实例。
先看我的启动入口:
/**
* 启动 mock 服务器
*
* @param {object} app server 实例,可由 webpack 传入
*/
function startMockServer(app = httpServer) {
const adapterApp = serverAdapter(app);
loadServices(adapterApp);
adapterApp.start(config.http.port);
}
其中的 serverAdapter
是一个适配器,用于适配 webpack 的 app 对象和 mock server 本身的 httpServer 对象,实现原理如下:
/**
* 适配传入的 server 对象,生成一个适配后的对象
*
* @param {object} app server 实例
* @returns
*/
function serverAdapter(app = httpServer) {
// 如果入参本身就是 httpServer 的实例,则原样返回
if (app instanceof HttpServer) {
return app;
}
// 如果不是 httpServer 实例,说明 webpack 启动时传入了 app 参数
// 则将 app 适配到 httpServer
return {
route(method, uri, response) {
if (!config.http.enabledMethods.includes(method)) {
logger.warn(`request method '${method}' is invalid`);
return;
}
const adApp = { app };
HttpServer.prototype.route.call(adApp, method, uri, response);
},
start() {
logger.success('Mock server is running...');
}
};
}
这样一来,在后续使用 app 的地方都可以只有一种写法,无需再进行过多的判断,同时也达到了兼容 webpack 的目的。
在 Vue 项目中使用
写到这里,应该很容易理解如何在项目中引用 mock server 了。
我这里也做一下简要的介绍:
- 将项目目录整体拷贝到 Vue 项目的根目录 mock 文件夹下(文件夹名可自定义);
- 在
vue.config.js
中引入 mock server ,并将入口函数startMockServer
赋值到devServer.before
上; - 在 mock server 的 services 中定义自己的微服务;
- 使用 npm run start 启动 Vue 本地调试,mock server 也将同步启动;
来一段代码示意一下,修改 vue.config.js
如下:
const startMockServer = require("./mock");
module.exports = {
// ...
devServer: {
before: startMockServer
},
// ...
}
是不是很简单。