从接口定义自动生成前端代码

1,097 阅读5分钟

背景

前后端分离是一个大家并不陌生的概念。那么哪些事情分离了?简单的讲分离是场景的分离,目标是为了互相之间不阻塞,能够高效的开发。但是终归这前后端是一个整体,前后端是需要进行数据的交流的。不仅是前后端开发,其他端也是需要数据交流的,那么,数据交流应该遵循什么样的规则呢? 答案是: 接口。什么是接口呢?

接口的概念

接口(硬件类接口)是指同一计算机不同功能层之间的通信规则称为接口(来自百度百科) 抛开复杂的修饰词, 这里我们可以剥离出一个核心词汇: 规则。 那什么是规则?

场景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,文章末尾会贴一下链接。

编译proto文件的protobuf.js

一个不错的完整的swagger转ts工具

如果你想学习一下recast,点之