背景
前后端分离是一个大家并不陌生的概念。那么哪些事情分离了?简单的讲分离是场景的分离,目标是为了互相之间不阻塞,能够高效的开发。但是终归这前后端是一个整体,前后端是需要进行数据的交流的。不仅是前后端开发,其他端也是需要数据交流的,那么,数据交流应该遵循什么样的规则呢? 答案是: 接口。什么是接口呢?
接口的概念
接口(硬件类接口)是指同一计算机不同功能层之间的通信规则称为接口(来自百度百科) 抛开复杂的修饰词, 这里我们可以剥离出一个核心词汇: 规则。 那什么是规则?
场景1: 晚上 8:00 在 xxx 影城门口等女朋友一起去看电影 场景2: 周五下午 3:00 给老板汇报工作 场景3:水深有鳄鱼禁止下水游泳
规则可以说是任何人为的约定。至于这个约定是否要遵守,当然你可以试试,晚到电影院,错过汇报,下去陪鳄鱼戏水。但是约定的东西终归是不究竟的,也就是并不是强制一板一眼的遵守。回到我们的开发场景,在开发中我们会约定接口文档,可能是一个 doc 文档,也可能是 idl 文档,也可能是 swagger 文档,当然也可能是口头的约定,甚至通过聊天工具诸如微信等。下面是 Webkit 内核代码中关于 Document 这个类型的约定,这只是一部分, 这里有一些大家熟悉的诸如 getElemxxx 一些方法的定义,这就是一个接口,也就是一个约定。开发者会按照这个约定开发功能,使用者也是按照这个约定来使用。
interface Document : Node {
readonly attribute DocumentType? doctype;
[ DOMJIT =Getter] readonly attribute Element? documentElement;
HTMLCollection getElementsByTagName(DOMString qualifiedName);
HTMLCollection getElementsByTagNameNS(DOMString? namespaceURI, DOMString localName);
HTMLCollection getElementsByClassName(DOMString classNames);
[NewObject, ImplementedAs=createElementForBindings] Element createElement(DOMString localName); // FIXME: missing options parameter.
[NewObject] Element createElementNS(DOMString? namespaceURI, DOMString qualifiedName); // FIXME: missing options parameter.
[NewObject] DocumentFragment createDocumentFragment();
[NewObject] Text createTextNode(DOMString data);
[NewObject] CDATASection createCDATASection(DOMString data);
[NewObject] Comment createComment(DOMString data);
};
前后端数据交互的理论依据
下面以 swagger 文档为例, 其他的 idl 语言也有很方便的工具
{
"basePath": "xxxx", //
"definitions": { }, // 这是后端定义的实体,也就是类
"host": "xxxxx",
"paths": {}, // 这里存放着接口的定义
"tag": [],
"info": {}
}
我们重点看一下 paths 和 definitions 这两个定义
{
"definitions": {
"User": { // 这是关于User这个类型的定义,
"type": "object", // 每个类型都会有type这个属性
"title": "User",
"properties": {
"userId": { // 该类型的userId字段定义
"type": "string"
"description": "用户id"
"allowEmptyValue": false
},
"userName": { // 该类型的userName字段定义
"type": "string"
"description": "用户姓名"
"allowEmptyValue": false
}
}
}
}
}
{
"paths": {
"xxx/get/user-list": { // key是我们的请求路径
"get": { // get请求的定义, 还可以是post delete 等等
"consumes": ["application/json"],
"description": "用户列表",
"operationId": "xxxxxx",
"parameters": [{ // parameters是关于请求参数的描述
"in": "query", // in: query是说明在query带过来,还有可能是body
"name": "userId",
"description": "备注",
"required": true,
"type": "string",
}],
"responses": {
200: {// responses是返回信息,这里只对200的返回进行了定义 其他一般省略
description: "OK",
// schema是表示这个返回结果是一个引用类型,类型说明在后边的$ref: 中体现,
// JsonResult«List«User»» 这是一个复合类型(这里其实就是泛型的概念,不了解的可以理解成传参),
// 这里JsonResult是一个类型, List是一个类型 User是上边定义的类型。
schema: {$ref: "#/definitions/JsonResult«List«User»»"}}
}
}
}
}
}
}
有了接口定义之后,我们可以参考之进行前端数据结构的设计, 设计原则: 数据一致性。下面参考后端给的接口 json 数据映射成前端的 interface 代码
// bad
interface User {
user_id: string
user_name: string
}
// good
interface User {
userId: string
userName: string
}
这里解释一下,为什么要一致性, 自然存在的东西是不需要额外的能量维持的, 反之需要足够的能量去维持, 如果不是某种特意宣传, 谁能想到 风清扬会是代指你的老板,而这种关联关系的记忆又无形中消耗了你的能量。 这种刻意,意味着需要多一次映射关系。 就上述这个例子而言 userId 与 user_id 都是可以的, 但是在与后端交互,以及与其他端进行交流时, 就多了一次映射关系,而这种关系是熵增的,也就是有害的。所以各端数据一致会更便于沟通。
实践&优化
上述手写 interface 这个过程中,前端其实是没有任何的信息植入的(没有信息植入可以理解为, 接口的方法,接口参数类型,参数名字,返回类型,类型的结构体,全部都是既定的,不需要任何额外的植入,也可以用纯函数的概念解释这个情况)。
无框架实现 Interface 自动生成
既然没有信息植入,我们可以考虑将这个过程自动化。比如上边的 User 的定义,可以用下边的代码实现, 这里需要利用 node 文件写入的能力(其实也可以用其他语言代替)
const content =
"interface User {" +
"userId: string" +
"userName: string" +
"}"
try {
const data = fs.writeFileSync('./interface.d.ts', content)
//文件写入成功。
} catch (err) {
console.error(err)
}
所以也就是只需要实现一个从 swagger.json 转换成 前端字符串的方法即可
interface IDefinitions {
type: string;
title?: string;
properties: Record<string, IPropertyField>
}
interface IPropertyField {
type?: string;
description?: string;
items?: { // 带有这个属性的说明type是array
type?: string,
$ref?: string
};
$ref?: string // $ref 凡是带有这个属性的都是引用一个自定义的类型
}
function parseDef(def: IDefinitions) {
if(def.type === 'object' && def.properties) {
const interfaceDef = `
interface ${def.title} {
$$$
}
`
const props = def.properties
const keys = Object.keys(def.properties)
const fields = keys.map(key => {
const field = def.properties[key]
if(field.type === 'object') {
// 略
}
if(field.type === 'array') {
// 略
}
return key + ': ' + field.type;
}).join(";");
return interfaceDef.replace('$$$', fields)
}
}
// 处理所有definition
const interface = Object.keys(definitions).map(key => {
const definition = definitions[key];
return parseDef(definition)
}).join('\n');
// 写入
try {
const data = fs.writeFileSync('./interface.d.ts', interface)
//文件写入成功。
} catch (err) {
console.error(err)
}
// 至于如何格式化,这里不再作解释
上边的实现是为了讲解实现原理,保持了原生的写法, 当然我们也可以借助一些强大的工具,比如 babel,recast 但是这也意味着需要额外的学习成本,如果仅仅是为了实现自动化生成, 上边的写法优化一下足以。 但是如果处于学习目的,以及想走的更远,可以学习一下 babel 和 recast。
Recast实现api生成
import { parse, prettyPrint } from "recast";
import { visit as v, builders as b, namedTypes } from "ast-types";
const ast = parse("", {
parser: require("recast/parsers/typescript"),
});
v(ast, {
visitProgram(astNode) {
genApi(astNode)
}
})
const code = prettyPrint(ast, { tabWidth: 2 }).code;
interface IPathsDef {
[k: string]: IPath
}
interface IPath {
get?: {
};
post?: {
}
}
/*
这个函数的目标是生成一个形如下边代码的接口调用方法。
这个模板仅供参考,可以根据需要定制你所要生成的代码
export async function xxxGetUser_list(params: {userId: string}): Promise<JsonResult<Array<User>>> {
const res = await Axios.post("xxx/get/user-list", params);
return res as JsonResult<Array<User>>
}
*/
function genApi(astNode) {
Object.keys(paths) // paths 是后端返回的paths列表
.forEach( url => {
const path = paths[url]
const name = getFunName(url) // 一个处理生成名字的函数, 这里省略了
const summary = getSummary(path) // 解析接口注释
const funcDeclar = b.functionDeclaration.from({
async: true,
body: parseBody(path) ,
id: b.identifier(name),
params: [ ... ], // 这里省略了对params处理的代码
returnType: genReturnType(path) // 生成return type
});
// exportNamedDeclaration 生成一个具名的函数导出
const decl = b.exportNamedDeclaration.from({
declaration: funcDeclar,
comments: [b.commentLine(summary)],
});
astNode.get("body").push(decl)
})
}
const API_TOOL = "Axios";
function parseBody (info) {
const method = getMethod(info) // 解析出info中的method,返回get,post delete等
const returnType = getReturnType(info) // 解析出info中的return type
// blockStatement生成块语句
return b.blockStatement([
// variableDeclaration 变量声明
b.variableDeclaration("const", [
b.variableDeclarator(
// identifier 生成标识符
b.identifier("res"),
// awaitExpression await表达式
b.awaitExpression(
// callExpression 函数调用表达式
b.callExpression(
// memberExpression 成员表达式
b.memberExpression(
b.identifier(API_TOOL), // ajax对象封装
b.identifier(method)
),
[b.stringLiteral(path), b.identifier("params")]
)
)
),
]),
// returnStatement 生成return语句
b.returnStatement(
// as 表达式
b.tsAsExpression(
b.identifier("res"),
b.tsTypeReference(
b.identifier(returnType)
)
)
)
])
}
注: 这里用到了大量的 b.xxx 这是往ast语法树上插入内容, 相当于替我们写代码,只是我们键盘输入,这个是通过函数生成, 另外,虽然这个东西方法特别多,但是很容易上手,而且 ts 都有语法提醒,也不需要刻意去记忆。
效果展示
interface User {
userId: string
userName: string
}
export async function xxxGetUser_list(params: {userId: string}): Promise<JsonResult<Array<User>>> {
const res = await Axios.post("xxx/get/user-list", params);
return res as JsonResult<Array<User>>
}
babel 实现一部分 store 中的代码( mobx 为例)
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import template from "@babel/template";
const parseCode = {
type: "File",
program: {
type: "Program",
interpreter: null,
body: [],
},
};
const buildRequire = template(
`import { ${apis.reduce((res, cur) => {
return res + nameMap.get(cur) + ", ";
}, "")} } from 'servace.ts';
import { action, observable } from 'mobx';
class XxxStore {
${apis.reduce((res, cur) => {
// nameMap 就是一个将 xxx/get/user-list名字转化成xxxGetUser_list 之后的map, 这里不给具体的实现了
const apiName = nameMap.get(cur);
const lastName = cur.split("/").pop();
return `
${res}
@observable
${lastName}Reponse;
@action
${lastName}Api = async (params) => {
const res = await ${apiName}(params);
this.${lastName}Reponse = res;
}
`;
}, "")}
}
export default new XxxStore()
`,
{
plugins: ["typescript", "decorators-legacy"],
}
);
const ast = buildRequire({});
traverse(parseCode, {
enter(path) {
if (path.node.type === "Program") {
const body = path.node.body;
if ("type" in ast) {
body.push(ast);
} else {
ast.forEach((node) => body.push(node));
}
}
return false;
},
});
const output = generate(parseCode, {}).code;
效果展示
import { xxxGetUser_list } from 'servace.ts';
import { action, observable } from 'mobx';
class XxxStore {
@observable
user_listReponse;
@action
user_listApi = async (params) => {
const res = await xxxGetUser_list(params);
this.user_listReponse = res;
}
}
后记: 本文主要围绕数据一致性的应用展开,文中围绕常用的 swagger.json 这种常用的前后端交互接口 schema 展开进行具体的描述,并且给出了三种常用的自动化生成前端相关代码的参考,文中代码仅仅是提供思路--这恰是本文的目的。社区有一些通用的 sdk,文章末尾会贴一下链接。