金三银四:了解AST为牛年新机会助力

841 阅读9分钟

基于AST的简易代码自动生成工具实现思路与原理剖析

背景

经常负责业务系统开发的小伙板门应该会经常遇到这样的一种情况,刚开始跟服务端约定好了接口文档之后,前端的小伙伴就会开始巴拉巴拉地根据接口文档提供的请求参数、响应结构等信息编写属于前端Models层用于描述接口请求参数、响应结果的结构,编写Services层用于发起接口请求等。但是,一旦服务端稍微改动了某一个接口的字段描述或代码接口,我们又得抱着开(捅)开(他)心(一)心(刀)的心态去调整我们的ModelsServices结构,反复几次的话,简直不要太Happy(苦不堪言)了。

基于上述的“和谐场景”,作为一个爱偷懒的程序员,我们有没有办法让程序帮我们做一些事呢?答案是肯定的。举一个现在一个比较成熟的解决方案:

Swagger-UI代码自动生成方案

以上方案根据我们常用的接口文档生成工具Swagger产生的JSON文件作为数据源,可以生成不同平台的代码,如我最喜欢的:Typescript

那么,小伙伴们可能会问了,既然官方都有实现这样的一个方案,咱们干嘛还要重复造轮子呢?欲知原因为何,且听老夫一一道来:

  1. 生成代码拆分不友好:使用官方的代码生成方案生成的所有的ModelService都放在同一个文件,如api.ts中,当一个项目接口足够多时,看见这代码就像撞墙,跟不用谈维护的痛苦了
  2. 代码注释不友好:官方生成的代码只有Service层有注释,而在Model层,对于我们的对象结构等,都没有注释描述
  3. 数据源适配不友好:只接受Swagger约定的JSON格式的数据源,假如以后我们换了一个接口文档生成工具,不好扩充
  4. 不够灵活:毕竟不是自己维护的一个框架,假如说我们某一个特定的需求,官方不支持,还得等着官方更新才能够实现,远远没有自己维护的工具效率高

基于上述几个原因,便有了这个项目。那么,不多废话了,开搞!

等会,好像忘了一件事,对了,我们还应该给我们这个项目起一个高端大气响当当的名字,嗯~~,就叫:

玲珑linglong)吧

模块划分与实现思路

根据上面的需求,我们对这个工具拆分成一下几大模块:

从上图中,我们大概了解了需要做哪一些模块来实现我们的功能,并且,我们可以看到上面的处理流程其实是管道式的,也就是说,上一个模块处理的结果会成为下一个模块的输入参数,这样,我们就可以设计一个Pipeline的结构来将我们这个项目的各个模块有机整合再一起。那么,接下来,我们就来一起看一下这个项目的一些主要模块要怎么去设计和实现

注意:由于本文主要讲述原理和思想,因此,在代码实现上会尽可能采用简单易懂的方式实现,甚至是一些伪代码,觉得某些代码实现不够优雅的同学,可以在基础实现思路不偏移的情况下实现除更加优雅的代码

基础模块:Pipeline(管道式)

管道式编程在大部分的开发语言都有这样的一个概念,甚至演变成了一种设计模式,因此,在此就不再过多赘述,只要知道管道式编程的最鲜明的特征即可:上一个模块的返回值作为下一个模块的输入。

// 实现一个简单的Pipeline函数,仅仅使用一个函数的组合来实现,此处不过多考虑一些边界问题
export const pipeline = <T>(
  val: any, ...fns: Function[]
) => {
  const res: T = fns.reduce((pre, cur) => cur(pre), val);
  return res;
};

// 调用示例
pipeline(datasource, parser, compiler, genCode, dest, lintFix);

从上面的代码中也可以看出,其实pipeline的基本思想很简单,我们只要控制好数据的流向就可以了。

模块解析:DataSource (数据源)

数据源模块主要的功能就是收集需要用来生成代码的接口文档的sechma,收集数据源有很多种方式,如:通过接口获取、通过mock数据获取、通过本地文件获取,这些都是数据源模块需要处理的事情

// 从远程接口文档获取数据源
export async function getDsByRemote(url: string): Promise<SwaggerStruct> {
  const res: AxiosResponse<SwaggerStruct> = await axios.get<SwaggerStruct>(url);
  return res.data;
}

// 从本地文件获取数据源
export async function getDsByLocalFile(): Promise<SwaggerStruct> {
  return mockData;
}

// 简易的数据源收集器
export async function DataSource(url: string, mock: boolean = false): Promise<SwaggerStruct> {
  return mock ? getDsByLocalFile() : getDsByRemote(url);
}

模块解析:Parser (数据转换/适配器)

由于通过数据源模块收集上来的数据格式可能是千奇百怪的,因此,我们需要通过这个数据适配器,将这些千奇百怪的数据转换成我们真正想要的统一格式的数据

import { SwaggerStruct } from '@/inner/swagger.ts';
import { JSONParser } from '@/main/parser/JSONParser.ts';

export enum ParserType {
    swaggerJson='swaggerJson'
}

// parser是一个高阶函数,他接收到数据源类型后,将会根据数据源的类型选择不同的适配器返回,用以处理不同数据源的适配
export type ParserReturnType = (data: any) => SwaggerStruct;

// 根据不同的数据源类型选择不同的适配器进行转换
export function parser(type: ParserType): ParserReturnType {
  switch (type) {
    case ParserType.swaggerJson:
      return (data) => JSONParser(data);
    default:
      return (data) => JSONParser(data);
  }
}

模块解析:Compiler (编译器)

编译器模块是我们这个玲珑框架的重头戏,他负责把经过适配器适配的数据源转换成AST(抽象语法树),在生成抽象语法树是,我们可以对我们即将要生成的代码做一些优化以及代码结构的调整等等,让我们生成的代码可读性更高,代码复用性更强

import { SwaggerStruct } from '@/inner/swagger.ts';
import { baseModelCompiler } from '@/main/compiler/base-model-compiler.ts';
import { modelCompiler } from '@/main/compiler/model-compiler.ts';
import { serviceCompiler } from '@/main/compiler/service-compiler.ts';
import { File } from '@babel/types';

export type CompilerReturnType = (data: SwaggerStruct) => File[];
export type CompilerOption = {
  baseUrlKey: string,
  outputDir?: string
};

export function compiler(opt: CompilerOption): CompilerReturnType {
  return (data: SwaggerStruct) => {
    // 生成基础请求文件,包含基础的请求方法,如axios的封装
    const baseModelAsts = baseModelCompiler(data, opt);
    // 跟据paths和definitions生成model,将接口请求参数和返回结果的interface定义出来
    const modelAsts = modelCompiler(data);
    // 根据paths和已经生成的model生成用于接口请求的service层
    const serviceAsts = serviceCompiler(data);
    // TODO 若项目中使用的store层框架能够统一,也可以根据接口文档,动态生成store层相关的代码

    return [...baseModelAsts, ...modelAsts, ...serviceAsts];
  };
}

从上面代码可以看到,我们的编译器包含了若干个子编译器,每个子编译器负责不同模块的编译工作,最终他们各自会返回一个抽象语法树的数组,最后再将所有的抽象语法树数组合并成一个数组,最终交由代码生成及进行代码生成。

下面,以baseModelCompilermodelCompiler这两个子编译器为切入点,讲一下怎么生成一个抽象语法树。

我们要生成一个抽象语法树,不需要自己额外再造轮子,我们的@babel给我们提供了一系列的npm包,可以让我们方便快捷的生成、操作抽象语法树。

// utils/index.ts
import parser from '@babel/parser';
import {
  File,
} from '@babel/types';

// 用于根据现有的代码文件使用@babel/parser模块创建一个抽象语法树
export function createBaseAst(tplPath?: string): File {
  let core = '';
  if (tplPath) {
    core = fs.readFileSync(tplPath).toString('utf-8');
  }
  return parser.parse(core, {
    sourceType: 'module',
    plugins: [
      'typescript',
      ['decorators', { decoratorsBeforeExport: true }],
      'classProperties',
      'classPrivateProperties',
    ],
  });
}

那到了抽象语法树之后,我们要怎么对抽象语法树中的某一个代码节点进行修改呢。

import { modelDir } from '@/config/index.ts';
import { SwaggerStruct } from '@/inner/swagger.ts';
import { CompilerOption } from '@/main/compiler/compiler.ts';
import { createBaseAst } from '@/utils/index.ts';
import { File } from '@babel/types';
import path from 'path';

const fs = require('fs-extra');

const { default: traverse } = require('@babel/traverse');
const { isStringLiteral } = require('@babel/types');

export function baseModelCompiler(data: SwaggerStruct, opt: CompilerOption = { baseUrlKey: 'DEFAULT_BASE_URL' }): File[] {
  if (opt.outputDir) {
    const filePathTs = path.resolve(opt.outputDir, 'BaseModel.ts');
    const filePathJs = path.resolve(opt.outputDir, 'BaseModel.js');

    if (fs.pathExistsSync(filePathJs) || fs.pathExistsSync(filePathTs)) {
      return [];
    }
  }
  // 创建抽象语法树
  const ast = createBaseAst(path.resolve(process.cwd(), 'src/main/tpl/BaseModel.ts'));
  // const {
  //   baseUrlKey,
  // } = opt;

  // fs.writeFileSync('test.log', '');
  // 递归遍历所有的抽象语法树节点,找打要更改的目标节点并修改某个节点的值
  traverse(ast, {
    enter(path: any) {
      // fs.appendFileSync('test.log', `[${path.type}]:${JSON.stringify(path.node, null, 4)}\n\n`);
      if (isStringLiteral(path) && path.node.value === '__BASE_URL_PLACEHOLDER__') {
        path.node.value = `//${data.host}${data.basePath}`;
      }
    },
  });

  // 可以添加一些额外的信息再extra中,辅助我们的代码生成器生成代码
  ast.extra = {
    fileName: `${modelDir}/BaseModel`,
    swaggerData: data,
  };

  // console.log(ast);

  return [ast];
}

baseModelCompiler只是在现有的一个模版文件的基础上,修改了一些东西,并没有从无到有创建抽象语法树,那么,接下来来看一下baseModelCompiler

import { modelDir } from '@/config/index.ts';
import {
  BaseDepDataStruct,
  ApiStruct,
  DefinitionsItemStruct,
  RequestParamStruct,
  SchemaTypeStruct,
  SwaggerStruct, ResponseBodyStruct,
} from '@/inner/swagger.ts';

import { createInterface, getStrTypeBySwaggerType, getTsTypeBySwaggerType } from '@/utils/astHelper.ts';
import {
  addCommentBlock,
  CommentParamsOptionStruct,
  createBaseAst, createStructName,
  getBaseApiFromApiPath,
  normalizeUri, optionalParams, optionalType,
  upperCamelize,
} from '@/utils/index.ts';
import {
  File, TSInterfaceBody, tsOptionalType, TSTypeElement,
} from '@babel/types';

const {
  tsInterfaceBody,
  tsPropertySignature,
  tsTypeReference,
  identifier,
  addComment,
  tsTypeAnnotation,
} = require('@babel/types');

/**
 * 创建依赖对象的结构体(包含对象的每个属性的描述)
 * @param {DefinitionsItemStruct} dep 依赖对象
 * @param {File} ast 抽象语法树
 */
function createObjectInterface(dep: DefinitionsItemStruct, ast: File): TSInterfaceBody {
  if (!dep || !dep?.properties) {
    return tsInterfaceBody([]);
  }
  const propKeys = Object.keys(dep.properties || {});
  return tsInterfaceBody(propKeys.map((propKey: string) => {
    const prop = dep!.properties![propKey];
    const node = tsPropertySignature(
      identifier(optionalType(propKey, prop.required)),
      getTsTypeBySwaggerType(prop?.type, prop?.format),
    );
    addComment(node, 'leading', prop?.description || prop?.title || '', true);
    return node;
  }));
}

function createInterfaceBodyItem<T extends BaseDepDataStruct>(item: T, ast: File): TSTypeElement {
  if (item.schema) {
    if (item.dep) {
      const dep = createInterface({
        name: upperCamelize(`${createStructName(item.name)}`),
        body: createObjectInterface(item.dep as DefinitionsItemStruct, ast),
      });

      const commentParams: CommentParamsOptionStruct = {
        test: {
          title: '',
          type: SchemaTypeStruct.object,
        },
      };

      if (item.dep) {
        const props = (item!.dep as DefinitionsItemStruct)!.properties || {};
        const keys = Object.keys(props);
        keys.forEach((key: string) => {
          const prop = props[key];
          commentParams[key] = {
            title: prop.description,
            description: prop.description,
            type: getStrTypeBySwaggerType(prop.type),
            required: prop.required,
          };
        });
      }

      delete commentParams.test;
      addComment(dep, 'leading', addCommentBlock({
        name: item.name,
        desc: item.description || '',
        params: commentParams,
      }));
      ast.program.body.push(dep);
    }
    const node = tsPropertySignature(
      identifier(optionalType(item.name, item.required)),
      tsTypeAnnotation(tsTypeReference(identifier(upperCamelize(createStructName(item.name))))),
    );

    addComment(node, 'leading', item?.description || createStructName(item.name) || '', true);

    return node;
  }
  if (item.type) {
    const node = tsPropertySignature(
      identifier(optionalType(item.name, item.required)),
      getTsTypeBySwaggerType(item.type, item.format),
    );
    addComment(node, 'leading', optionalParams(item?.description || item?.name || '', item.required), true);
    return node;
  }
  return tsPropertySignature(
    identifier(createStructName(optionalType(item.name, item.required))),
    getTsTypeBySwaggerType(SchemaTypeStruct.object),
  );
}

/**
 * 根据参数列表创建interface body
 * @param {RequestParamStruct[]} params  参数列表
 * @param {File} ast     抽象语法树
 */
function createRequestInterfaceBodyByParams(
  params: RequestParamStruct[],
  ast: File,
): TSInterfaceBody {
  return tsInterfaceBody(
    params.map<TSTypeElement>(
      (item: RequestParamStruct) => createInterfaceBodyItem<RequestParamStruct>(item, ast),
    ),
  );
}
/**
 * 根据response创建interface body
 * @param {ResponseBodyStruct} params  响应参数结构
 * @param {File} ast     抽象语法树
 */
function createResponseInterfaceBodyByParams(
  params: ResponseBodyStruct,
  ast: File,
): TSInterfaceBody {
  return tsInterfaceBody(
    [createInterfaceBodyItem<ResponseBodyStruct>(params, ast)],
  );
}

/**
 * 编译请求参数结构
 * @param uri
 * @param data
 * @param ast
 * @param curModel
 */
function compileRequestParamsStruct(
  uri: string,
  data: SwaggerStruct,
  ast: File,
  curModel: ApiStruct,
): void {
  const key = Object.keys(curModel)[0];
  const apiStruct = curModel[key];
  const params = apiStruct.parameters;
  const normalizeUriStr = normalizeUri(uri);

  if (!params) {
    return;
  }
  // 生成一个请求参数Interface名称
  const mainInterfaceName = upperCamelize(`${createStructName(`${normalizeUriStr}Request`)}`);

  // 创建一个到处模块
  const exportModule = createInterface({
    name: mainInterfaceName,
    body: createRequestInterfaceBodyByParams(params, ast),
  });

  // 辅助用于生成代码注释的对象
  const mainParams: CommentParamsOptionStruct = {
    test: {
      title: '',
      type: SchemaTypeStruct.string,
    },
  };
  params.forEach((item: RequestParamStruct) => {
    let type: any;
    if (item.schema) {
      type = upperCamelize(`${createStructName(item.name)}`);
    } else {
      type = `${item.type}${item.format ? `<${item.format}>` : ''}`;
    }
    mainParams[item.name] = {
      title: item.name,
      type,
      description: item.description,
      required: item.required,
    };
  });
  delete mainParams.test;
  // 在interface上添加代码注释
  addComment(exportModule, 'leading', addCommentBlock({
    name: mainInterfaceName,
    desc: apiStruct.summary || '',
    params: mainParams || {},
  }));

  // 将模块代码加入到抽象语法树program的body中
  ast.program.body.push(exportModule);
}

/**
 * 编译响应数据结构
 * @param uri
 * @param data
 * @param ast
 * @param curModel
 */
function compileResponseDataStruct(
  uri: string,
  data: SwaggerStruct,
  ast: File,
  curModel: ApiStruct,
): void {
  // TODO 对响应返回数据的编译解析
}

// 编译主入口
export function modelCompiler(data: SwaggerStruct): File[] {
  const asts: File[] = [];

  Object.keys(data.paths || {}).forEach((uri: string) => {
    const baseApi: string = getBaseApiFromApiPath(uri);
    const curModel: ApiStruct = data.paths[uri];
    const ast = createBaseAst();
    compileRequestParamsStruct(uri, data, ast, curModel);
    compileResponseDataStruct(uri, data, ast, curModel);
    ast.extra = {
      fileName: `${modelDir}/${upperCamelize(`model-${baseApi}`)}`,
      swaggerData: data,
    };
    asts.push(ast);
  });

  // fs.writeFileSync(path.resolve(__dirname, 'swagger-ui.json'), JSON.stringify(data, null, 4));

  return asts;
}

通过modelCompiler我们已经实现了从无到有生成一个抽象语法树了,一些抽象语法树生成的具体操作,可以看一下@babel/types的官方文档和示例,这里就不再赘述了。

模块解析:GenCode (代码生成器)

经过编译器的一顿折腾之后,我们得到了一个抽象语法树的数组,这个时候,我们就要根据需求生成不同平台语言的代码,这个时候,我们的代码生成器就派上用场了

import { SwaggerStruct } from '@/inner/swagger.ts';
import generate from '@babel/generator';
import { Node } from '@babel/types';

export type GenCodeResult = {
  fileName: string,
  core: string,
  ast: Node,
  swaggerData: SwaggerStruct | null
};

export enum GenLangType {
  Typescript='Typescript',
  Javascript='Javascript',
}

export type GenCodeOptionStruct = {
  langType: GenLangType
};

export type GenCodeReturnType = (asts: Node[]) => GenCodeResult[];

export function getExtNameByLangType(type: GenLangType) {
  return type === GenLangType.Typescript ? '.ts' : '.js';
}

export function genCode(
  opt: GenCodeOptionStruct = { langType: GenLangType.Typescript },
): GenCodeReturnType {
  return (asts: Node[]) => asts.map((ast: Node) => {
    const generateCode = generate(ast, {
      retainLines: false,
      sourceMaps: false,
      decoratorsBeforeExport: true,
      jsescOption: {
        quotes: 'single',
      },
    });
    return ({
      fileName: (ast?.extra?.fileName as string || 'test') + getExtNameByLangType(opt.langType),
      core: generateCode.code,
      ast,
      swaggerData: ast?.extra?.swaggerData as SwaggerStruct || null,
    });
  });
}

这里我们就直接使用babel给我们提供的@babel/generator库进行代码生成

模块解析:FsManager (文件管理器)

当我们代码生成器已经帮你生成好了源代码,现在就要把它输出到我们的项目目录当中去了

import { GenCodeResult } from '@/main/generator/genCode.ts';
import path from 'path';

const fsExtra = require('fs-extra');

export type DestReturnType = (opt: GenCodeResult[]) => Promise<void>;
export function dest(rootDir: string = ''): DestReturnType {
  return async (opts: GenCodeResult[]) => {
    opts.map(
      (opt: GenCodeResult) => {
        const filePath = path.resolve(process.cwd(), rootDir, opt.fileName);
        if (!fsExtra.pathExistsSync(filePath)) {
          fsExtra.mkdirpSync(filePath.substring(0, filePath.lastIndexOf('/')));
        }
        return fsExtra.writeFileSync(
          filePath,
          opt.core,
          {
            encoding: 'utf-8',
          },
        );
      },
    );
  };
}

到此我们代码生成工作就已经完成了,不过生成的代码总感觉有点瑕疵,代码风格跟我们项目整体风格不一致,那么就再加一个代码风格修整器美华一下吧。

模块解析:LintFix (代码风格修正器)

上述生成的代码分隔可能跟你项目的代码风格不太相符,这是后我们就借助代码风格修正器对我们的代码风格进行修正,实现也很简单。

import path from 'path';

const shell = require('shelljs');

export type LintFixReturnType = () => void;
export function lintFix(dir: string = path.resolve(process.cwd(), '.')): LintFixReturnType {
  return () => {
    shell.cd(dir).exec(`eslint --fix --ext .js --ext .ts ${dir}`);
  };
}

结果展示

最终生成的代码长这样:

总结

以上就已经完成了一个简易版的根据接口文档生成modelservice代码的工具了,虽然这个工具暂时还不是很完善,但大概的思路就是这样,剩下的只是对一些边界问题、细节的打磨,后续这个项目完善后也会在这里加上传送门。

GitHub 传送门

玲珑