前端工程化之——自动生成基于 Typescript 的 API 模块(文末有 npm 包可用)

4,262 阅读4分钟

前言

在 ts 成为前端的日常的情况下,带来的好处自不必多说。但随之而来需要编写各种类型定义,尤其是每次当接口变更,我们不得不手动更改对应的 ts 类型。

也许你已经使用 VSCode 的插件 json2ts,来减少手动编写 ts,但总体依旧是开发效率的降低。

这种简单机械的重复劳动,与业务无关的重复行为。这类工程化的问题,要如何解决呢?

swagger-typescript-api 提供了通过 swagger 生成 api 的方案,支持 OpenAPI 3.02.0(Swagger) 以及文件格式jsonyaml

image.png

image.png

swagger-typescript-api 的代码是使用模板生成,模板引擎可以选择 EJSEta。(下面选择 EJS)

EJS 是一套简单的模板语言,没有如何组织内容的教条,也没有再造一套迭代和控制流语法,有的只是普通的 JavaScript 代码而已。刚接触的话,也没有关系,因为很快就能上手。

文末会有 npm 包可用!

swagger-typescript-api

实际使用中,swagger-typescript-api 默认的输出结构,很少能直接满足我们的需求。

所以我们会用到自定义模板,需要复制一些 swagger-typescript-api 的模板 到项目内:

  • /templates/default 内的模板,会把所有内容写入到一个文件内。

  • /templates/modular 内的模板,会把 http client, data contracts, routes 分别单独生成文件。(我们自然是选择这个👈

模板文件的介绍

  • api.ejs 是用来生成 API 类模块的文件。

  • procedure-call.ejs 作为 api.ejs 的子模板,用来生成类中的方法(也就是对应后端的路由)被 api.ejs 引用。

  • route-docs.ejs 主要是生成 API 类中路由的注释文档,在 procedure-call.ejs 内被引用。

  • route-name.ejs Api 类中路由的路由名。

  • data-contracts.ejs 生成来自 Swagger 架构的所有类型(也就是接口请求参数和返回参数的 ts 类型)。

  • http-client.ejs 根据 axios 还是 fetch,选择对应的客户端 Http 请求 ts 的模板。

自定义模板

下面是我对 umi4 内置的请求模板。(只使用到 2 个自定义模板,放到项目内的 swaggerTemplate 文件夹内)

api.ejs
<%
const { utils, route, config, modelTypes } = it;
const { _, pascalCase, require } = utils;
const apiClassName = pascalCase(route.moduleName);
const routes = route.routes;
const dataContracts = _.map(modelTypes, "name");
%>

<% if (dataContracts.length) { %>
import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>"
<% } %>

import { HttpClient, RequestParams, ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>";

import { request, RequestConfig } from "./request";

class <%= apiClassName %>Service{
    <% for (const route of routes) { %>
        <%~ includeFile('./procedure-call.ejs', { ...it, route }) %>
    <% } %>
}

export default <%= apiClassName %>Service;
procedure-call.ejs
 <%
const { utils, route, config } = it;
const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route;
const { _, getInlineParseContent, getParseContent, parseSchema, getComponentByRef, require } = utils;
const { parameters, path, method, payload, query, formData, security, requestParams } = route.request;
const { type, errorType, contentTypes } = route.response;
const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants;
const routeDocs = includeFile("@base/route-docs", { config, route, utils });
const queryName = (query && query.name) || "query";
const pathParams = _.values(parameters);
const pathParamsNames = _.map(pathParams, "name");
const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH;
const requestConfigParam = {
    name: "options",
    optional: true,
    type: "RequestConfig",
    defaultValue: "{}",
}
const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`;
const rawWrapperArgs = config.extractRequestParams ?
    _.compact([
        requestParams && {
          name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName,
          optional: false,
          type: getInlineParseContent(requestParams),
        },
        ...(!requestParams ? pathParams : []),
        payload,
        requestConfigParam,
    ]) :
    _.compact([
        ...pathParams,
        query,
        payload,
        requestConfigParam,
    ])
    const wrapperArgs = _
    // Sort by optionality
    .sortBy(rawWrapperArgs, [o => o.optional])
    .map(argToTmpl)
    .join(', ')

// RequestParams["type"]
const requestContentKind = {
    "JSON": '"application/json"',
    "URL_ENCODED": '"application/x-www-form-urlencoded"',
    "FORM_DATA": '"multipart/form-data"',
    "TEXT": '"text/plain"',
}
// RequestParams["format"]
const responseContentKind = {
    "JSON": '"json"',
    "IMAGE": '"blob"',
    "FORM_DATA": isFetchTemplate ? '"formData"' : '"document"'
}
const bodyTmpl = _.get(payload, "name") || null;
const queryTmpl = (query != null && queryName) || null;
const bodyContentKindTmpl = requestContentKind[requestBodyInfo.contentKind] || null;
const responseFormatTmpl = responseContentKind[responseBodyInfo.success && responseBodyInfo.success.schema && responseBodyInfo.success.schema.contentKind] || null;
const securityTmpl = security ? 'true' : null;
%>

/**
<%~ routeDocs.description %>

 *<% /* Here you can add some other JSDoc tags */ %>

<%~ routeDocs.lines %>

 */
static <%~ route.routeName.usage %> = (<%~ wrapperArgs %>) =>
    request<<%~ type %>>(`<%~ path %>`,{
        method: '<%~ _.upperCase(method) %>',
        <%~ queryTmpl ? `params: ${queryTmpl},` : '' %>
        <%~ bodyTmpl ? `data: ${bodyTmpl},` : '' %>
        <%~ securityTmpl ? `secure: ${securityTmpl},` : '' %>
        <%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %>
        <%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
        ...<%~ _.get(requestConfigParam, "name") %>,
    })

生成 API

generateApi.js
const { generateApi } = require('swagger-typescript-api');
const path = require('path');

generateApi({
  output: path.resolve(process.cwd(), './src/services'),
  url: "http://11.168.x.xxxx:6666/v2/api-docs",
  httpClientType: false,
  modular: true,
  templates: path.resolve(process.cwd(),'./swaggerTemplate'),
  hooks: {
    onCreateRoute: (routeData) => {
      //增加接口请求前缀
      routeData.request.path = `/api${routeData.request.path}`;
    },
    onFormatRouteName: (routeInfo, templateRouteName) => {
      //自定义路由名称
      return templateRouteName.replace(/Using\w*/, '').replace(/[{}]/, '');
    },
  },
});

只要执行以下命令:

node generateApi.js

就会生成对应的 API 模块。

CLI 工具

为了避免新项目需要重新配置,甚至是复制模板,为了解决这个场景,我们下面写个 CLI。

关于如何写 CLI,我之前写过 前端工程化之—— CLI 脚手架(两步掌握核心)这一篇,关键点是一样的,所以下面直接给出源码。

目录结构

main.js
#! /usr/bin/env node
import { Command } from "commander";
import swaggerTsApi from "../lib/swaggerTsApi.js";

const program = new Command();

program
  .command("swaggerTsApi")
  .description("通过 swagger-typescript-api 生成 api")
  .requiredOption("-u, --url <http>", "获取 swagger.json 的 url")
  .option("-p, --proxy <string>", "proxy 前缀", "/api")
  .option("-o, --output <string>", "api 生成路径", "./src/services")
  .option("-r, --reserve", "swagger 自动生成的模板是否保留", false)
  .option("-t, --templatePath  <string>", "使用指定的 swagger 模板路径")
  .action(({ url, proxy, output, reserve, templatePath }) => {
    swaggerTsApi({ url, proxy, output, reserve, templatePath });
  });

program.parse();
swaggerTsApi.js
import path from "path";
import fs from "fs-extra";
import { fileURLToPath } from "url";
import { dirname } from "path";
import pkg from "swagger-typescript-api";
const { generateApi } = pkg;

export default async function swaggerTsApi({
  url,
  proxy,
  output,
  reserve,
  templatePath,
}) {
  try {
    const __dirname = dirname(fileURLToPath(import.meta.url));

    if (!templatePath) {
      fs.copySync(
        path.resolve(__dirname, "./swaggerTemplate"),
        path.resolve(process.cwd(), "./swaggerTemplate")
      );
    }

    generateApi({
      output: path.resolve(process.cwd(), output),
      url: url,
      httpClientType: false,
      modular: true,
      templates: path.resolve(
        process.cwd(),
        templatePath ?? "./swaggerTemplate"
      ),
      hooks: {
        onCreateRoute: (routeData) => {
          //增加接口请求前缀
          routeData.request.path = `${proxy}${routeData.request.path}`;
        },
        onFormatRouteName: (routeInfo, templateRouteName) => {
          //自定义路由名称
          return templateRouteName.replace(/Using\w*/, "").replace(/[{}]/, "");
        },
      },
    }).finally(() => {
      if (!reserve && !templatePath) {
        fs.remove("swaggerTemplate");
      }
    });
  } catch (e) {
    if (!reserve && !templatePath) {
      fs.remove("swaggerTemplate");
    }
    console.error(e);
  }
}

你可能会遇到的问题

  • 生成的类型名重复
    export interface ChestnutVO {
      list?: ChestnutVO[];
    }
    export interface ChestnutVO {
      id: number;
    }

当一个接口出现类型重复,就像上面 ChestnutVO 重复这样子。

问题在于后端在@ApiModelvalue属性指定了模型的名称,用了中文

    @ApiModel("栗子")
    /** 改写成如下就可以了**/
    @ApiModel(description = "栗子")

导致解析的时候,类型名字被替换成了中文去拼接,而中文又不对,被过滤掉了,所以导致出现了多个类型定义重复。

下面就是修复后的结果例子:

    export interface PageResultChestnutVO {
      list?: ChestnutVO[];
    }
    export interface ChestnutVO {
      id: number;
    }
  • 当你在 mac 系统上用 yarn 安装后,使用时会出现以下这个报错:env: node\r: No such file or directory

导致这个问题出现的原因,是不同平台系统之间标准的不同。在 vs code 右下角你可以看到 LF 或 CRLF ,而这个就是不同标准的行尾。

当然,如果你使用的是 npm 安装,不会出现这个错误,因为 npm 会自动处理行尾。

说回目前如何暂时解决,可以执行以下这个命令,去修复目前安装到本地的包:

npx crlf --set=LF node_modules/.bin/swagger  

  当然,后续我会增加 .gitattributes 对行尾进行规范。

* text=auto eol=lf

最后

swagger-typescript-api 的文档写的确实不怎么样,好在花点精力,还是能搞出来的,然后实现效果还算不错。

然后发布到 npm 上了,叫 swagger-ts-cli(如需加功能,可留言)

可直接执行下面的命令,不用安装尝试自动生成接口。

npx swagger-ts-cli -u http://11.168.3.186:6060/v2/api-docs

-u 后面的地址是 swagger 文档获取数据的 url。

生成的接口如何,取决于后端代码是否写的规范,这个工程规范化的事情,有时候会需要领导的支持。

最后,在此提供另一个方案的选择: pont —— 阿里生成前端接口层代码的方案,还有专门的 vscode 插件支持。(目前只支持 Swagger V2、V3)