在进行Web开发的时候,很多前端同学都有等待后端接口联调的经历,一开始的时候,在后端接口没有准备好的时候,很多同学无法开始工作,只能做一些前期准备,感觉有点浪费时间。
可能有些经验比较丰富的同学会使用一些工具库来构建模拟接口,最常用的如 Mock.js。
当然,Mock.js比较强大,有很多强大的功能,而且能够方便地在本地启动服务。但是,它也有一些不足。比如,它配置生成的接口,不能直接生成对应的API文档,这样还需要人为检查和管理,不方便维护和沟通。另外,它有一套自己的数据生成语法,写起来方便,但是也有额外的学习成本,且不够灵活。
针对上面的缺点,我希望设计一个新的工具,有如下特点:
- 它能够用原生JavaScript工具函数灵活地生成各种各样的模拟数据
- 生成模拟API的同时,对应生成API的文档,这样我们就可以直接通过文档了解完整的API,既方便我们的研发,后端也可以根据文档实现真正的业务代码。
好,那我们来看看如何一步步实现我们的目标。
构建数据生成函数
生成模拟数据的最基本原理,就是根据一份描述(我们称为schema),来生成对应的数据。
比如最简单的:
const schema = {
name: 'Akira',
score: '100',
};
const data = generate(schema);
console.log(data);
上面这个schema
对象里面的所有的属性都是常量,所以我们直接生成的数据就是原始输入,最终输出的结果自然是如下:
{
"name":"akira",
"score":100
}
如果我们要生成的数据随机一点,那么我们可以使用随机函数,例如:
function randomFloat(from = 0, to = 1) {
return from + Math.random() * (to - from);
}
function randomInteger(from = 0, to = 1000) {
return Math.floor(randomFloat(from, to));
}
这样,我们修改schema
得到随机的成绩:
const schema = {
name: 'Akira',
score: randomInteger(),
}
...
这个看起来很简单是不是?但是实际上它有缺陷。我们接着往下看。
假如我们要批量生成数据,我们可以设计一个repeat
方法,它根据传入的schema
返回数组:
function repeat(schema, min = 3, max = min) {
const times = min + Math.floor((max - min) * Math.random());
return new Array(times).fill(schema);
}
这样,我们就可以用它来生成多条数据,例如:
const schema = repeat({
name: 'Akira',
score: randomInteger(),
}, 5);
但是这样明显有个问题,注意到我们通过repeat复制数据,虽然我们生成了随机的score,但是在repeat复制前,score的值已经通过randomInteger()
生成好了,所以我们得到的5条记录的score值是完全一样的,这个不符合我们的期望。
那应该怎么办呢?
利用函数延迟求值
我们修改生成函数:
function randomFloat(from = 0, to = 1) {
return () => from + Math.random() * (to - from);
}
function randomInteger(from = 0, to = 1000) {
return () => Math.floor(randomFloat(from, to)());
}
这里最大的改动是让randomInteger
生产函数不直接返回值,而是返回一个函数,这样我们在repeat的时候再去求值,就可以得到不同的随机值。
要做到这一点,我们的生成器需要能够解并和执行函数。
下面是生成器的实现代码:
function generate(schema, extras = {}) {
if(schema == null) return null;
if(Array.isArray(schema)) {
return schema.map((s, i) => generate(s, {...extras, index: i}));
}
if(typeof schema === 'function') {
return generate(schema(extras), extras);
}
if(typeof schema === 'object') {
if(schema instanceof Date) {
return schema.toISOString();
}
if(schema instanceof RegExp) {
return schema.toString();
}
const ret = {};
for(const [k, v] of Object.entries(schema)) {
ret[k] = generate(v, extras);
}
return ret;
}
return schema;
};
生成器是构建数据最核心的部分,你会发现其实它并不复杂,关键是递归地处理不同类型的属性值,当遇到函数的时候,再调用函数执行,返回内容。
function generate(schema, extras = {}) {
if(schema == null) return null;
if(Array.isArray(schema)) {
return schema.map((s, i) => generate(s, {...extras, index: i}));
}
if(typeof schema === 'function') {
return generate(schema(extras), extras);
}
if(typeof schema === 'object') {
if(schema instanceof Date) {
return schema.toISOString();
}
if(schema instanceof RegExp) {
return schema.toString();
}
const ret = {};
for(const [k, v] of Object.entries(schema)) {
ret[k] = generate(v, extras);
}
return ret;
}
return schema;
};
function randomFloat(from = 0, to = 1) {
return () => from + Math.random() * (to - from);
}
function randomInteger(from = 0, to = 1000) {
return () => Math.floor(randomFloat(from, to)());
}
function genName() {
let i = 0;
return () => `student${i++}`;
}
function repeat(schema, min = 3, max = min) {
const times = min + Math.floor((max - min) * Math.random());
return new Array(times).fill(schema);
}
const res = generate(repeat({
name: genName(),
score: randomInteger(0, 100),
}, 5));
console.log(JSON.stringify(res, null, 2));
输出结果如下:
[
{
"name": "student0",
"score": 47
},
{
"name": "student1",
"score": 71
},
{
"name": "student2",
"score": 68
},
{
"name": "student3",
"score": 96
},
{
"name": "student4",
"score": 91
}
]
所以,这里最关键的问题就是利用函数表达式延迟取值,这样能及时取到随机数值,以符合自己的要求。
比如:
生成 API 文档
第二个比较核心的功能是根据schema
生成API文档,这个其实本质上是生成一段 HTML 片段,难度应该不大,细节比较复杂,可选方案也很多。
这里我选择的是根据schema
构建markdown文本,然后通过marked最终解析成HTML的办法。
Marked初始化代码片段如下:
const renderer = new marked.Renderer();
renderer.heading = function(text, level, raw) {
if(level <= 3) {
const anchor = 'mockingjay-' + raw.toLowerCase().replace(/[^\w\\u4e00-\\u9fa5]]+/g, '-');
return `<h${level} id="${anchor}"><a class="anchor" aria-hidden="true" href="#${anchor}"><svg class="octicon octicon-link" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg></a>${text}</h${level}>\n`;
} else {
return `<h${level}>${text}</h${level}>\n`;
}
};
const options = {
renderer,
pedantic: false,
gfm: true,
breaks: false,
sanitize: false,
smartLists: true,
smartypants: false,
xhtml: false,
headerIds: false,
mangle: false,
};
marked.setOptions(options);
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
}));
再准备一个 HTML 模板
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>AirCode Doc</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="description" content="A graphics system born for visualization.">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown-light.css">
<link rel="stylesheet" href="https://unpkg.com/highlight.js@11.8.0/styles/github.css">
<style>
.markdown-body {
padding: 2rem;
}
</style>
</head>
<body>
<div class="markdown-body">
${markdownBody}
</div>
</body>
</html>
最终我们实现一个compile方法:
compile() {
return async (params, context) => {
// console.log(process.env, params, context);
const contentType = context.headers['content-type'];
if(contentType !== 'application/json') {
context.set('content-type', 'text/html');
const markdownBody = marked.parse(this.info());
return await display(path.join(__dirname, 'index.html'), {markdownBody});
}
const method = context.method;
const headers = this.#responseHeaders[method];
if(headers) {
for(const [k, v] of Object.entries(headers)) {
context.set(k, v);
}
}
const schema = this.#schemas[method];
if(schema) {
return generate(schema, {params, context, mock: this});
}
if(typeof context.status === 'function') context.status(403);
else if(context.response) context.response.status = 403;
return {error: 'method not allowed'};
};
}
这个方法返回一个服务端云函数,根据http请求的content-type返回内容,如果是application/json
,返回接口生成的JSON数据,否则返回HTML页面,其中this.info()
是得到Markdown代码,display
将代码通过模板渲染成最后的接口页面。
生成页面类似效果如下:
完整的代码见代码仓库,有兴趣的同学可以自行尝试。
有任何问题欢迎讨论,也欢迎给项目贡献PR。