🥱还在手撸接口?来整个接口请求生成器吧!(下)

861 阅读2分钟

这是我参与8月更文挑战的第8天,活动详情查看:8月更文挑战

数据获取

之前我们已经知道如何可以获取到相应的接口信息,那么我们现在就来实现它.我们先要来判断我们需要生成的服务,这里有单服务和多服务两种情况,就是看我们是否在网关下有多个独立的Swagger API接口.

export function generateService({ gateway, services, version }) {
    if (services && Object.keys(services).length) {
        // 多服务模式
        return Object.entries(services).map(([key, service]) => ({
            key: key,
            name: service,
            url: `${gateway}/${service}/${version}/api-docs`
        }))
    } else {
        // 单服务模式
        return [{
            key: '',
            name: '',
            url: `${gateway}/${version}/api-docs`
        }]
    }

}

我们根据services生成了需要请求的Swagger Api列表,现在已经为请求做好了准备.

export function generate(service) {
    fetch(service.url, { method: 'GET' })
        .then((res) => res.json())
        .then(
            ({
                tags,
                paths
            }: {
                tags: any[]
                paths: { [keys: string]: any }
            }) => {
                // 控制器列表
                const controllers: any = []
                // 填装控制器列表
                generateControllers(service, controllers, paths, tags)
                // 生成控制器文件
                generateControllerFiles(service, controllers)
                // 生成服务文件
                generateServiceFiles(service, controllers)
            }
        )
}

根据之前我们所说的,paths是接口列表的部分,我们可以根据paths的数据,计算我们需要生成的controller文件和service文件有哪里,generateControllers的主要作用就是从paths提取我们需要的主要数据.

我们可以先按我们认知的任务来处理,按服务端分为controlleraction,一个controller中包含着多个action,controller的路径是action的上层路径.

export function generateControllers(
    service: { key: string, name: string, url: string },
    controllers: any[],
    paths: { [keys: string]: any },
    tags: any[]
) {
    Object.entries(paths)
        .filter(([key]) => key.startsWith('/api') || key.startsWith(`/${service.name}`))
        .map(([key, config]: [string, { [keys: string]: any }]) => ({
            path: key.replace(new RegExp(`^\/${service.name}\/`), "/"),
            config
        }))
        .forEach(({ path, config }) => {
            // 接口行为
            Object.entries(config).forEach(
                ([
                    method,
                    {
                        summary,
                        tags: currentTag,
                        operationId
                    }
                ]) => {
                    const getController = config.getControllerResolver ? config.getControllerResolver : getControllerName
                    const controller = getController(path, currentTag, tags)
                    const action = getActionName(operationId)
                    const filename = controller
                        .replace(/([A-Z])/g, '-$1')
                        .replace(/^-/g, '')
                        .toLowerCase()

                    // 查询并创建控制器
                    let target = controllers.find(
                        (x) => x.controller === filename
                    )

                    // 控制器不存在则自动创建
                    if (!target) {
                        target = {
                            controller: filename,
                            filename: filename,
                            controllerClass: `${controller}Controller`,
                            serviceClass: `${controller}Service`,
                            actions: []
                        }
                        controllers.push(target)
                    }

                    // 添加控制器行为
                    target.actions.push({
                        path,
                        controller,
                        action: (action || method).replace(/-(\w)/g, ($, $1) =>
                            $1.toUpperCase()
                        ),
                        defaultAction: !action,
                        method: method.replace(/^\S/, (s) => s.toUpperCase()),
                        summary
                    })
                }
            )
        })
}

我们从path字段中从获取了对应的controller的名称,从operationId中获取了action名称,其中生成的文件名我们按串式命名法进行命名(kebab-case)生成filename,

最终我们获得了一个为controllers的数据,他们所有controller的集合,每个controller中存在字段actions是这个controller所有的action,我们的目的就是每个controller我们会生成一个controller文件和service文件,action是在每个controller文件中实际调用的接口.

代码生成

准备好了数据我们就可以来生成相应的代码,我们可以使用handlebars来书写代码模板进行代码生成.

  • Controller模板
/**
 * This file is auto generated.
 * Do not edit.
 */
import { RequestMethod } from '@gopowerteam/http-request'

const controller = '{{controller}}'
const service = '{{service}}'

export const {{controllerClass}} = {
{{#each actions}}
    // {{summary}}
    {{action}}: {
        service,
        controller,
        path: '{{path}}',
        {{#unless defaultAction}}
        action: '{{action}}',
        {{/unless}}
        type: RequestMethod.{{method}}
    }{{#unless @last}},{{/unless}}
{{/each}}
}
  • Service模板
/**
 * This file is auto generated.
 * Do not edit.
 */
import { Request, RequestParams } from '@gopowerteam/http-request'
import type { Observable } from 'rxjs'
import { {{controllerClass}} } from '{{controllerDir}}/{{controller}}.controller'

export class {{serviceClass}} {
{{#each actions}}
    /**
     * {{summary}}
     */
    @Request({
        server: {{../controllerClass}}.{{action}}
    })
    public {{action}}(requestParams: RequestParams): Observable<any> {
        return requestParams.request()
    }
{{/each}}
}

生成的contrller文件是用来配置接口信息,而service文件用来方便我们调用.

需要处理的就是一些接口可能是没有action的,我们需要去生成默认的action名称,例如

@Controller('/api')
export class AppController {
 @Get()
 async get() {
 ...
 }
 
 @Post()
 async post() {
 ...
 }
}

这个情况下我们可以把method(get|post|...)当做action的名称.

这下我们就可以使用之前准备好的数据来生成对应的模板文件了.

let templateSource = readFileSync(serviceTemplatePath, ENCODING)
let template = compile(templateSource)
let serviceFileContent = template(
    Object.assign(controller, { service: service.key, controllerDir: [loadConfig().controllerAlias, service.key].filter(x => x).join('/') })
)

writeServiceFile(service.key, controller, serviceFileContent).then(
    (filename) => {
        log(`Service File Generate`, filename)
    }
)

生成的文件按配置中的controllerDirserviceDir目录生成到配置好的目录中即可.

现在我们就可以轻松的自动生成接口的配置文件了,在需要更新接口是我们可以执行更新接口的命令来自动的生成对应的接口调用文件,可以将生成代码的命令添加到package.jsonscripts中来方便调用.

Vite插件支持

我们在执行请求的时候按照之前的设想一般会这么写.

import {UserService} from '...'

const userService = new UserService()

userService.login(new RequestParams()).subscribe({
    next: data => {
        console.log(data)
    }
})

我需要先导入UserService,然后进行实例化再调用,一个页面中如果有多个调用,就需要导入多个对应的Service,那么有没有可能再优化一点点.

有一种方案可以让我写起来再方便一点,就是通过vite的虚拟导入功能,,我之前也写过一篇关于Vite插件的虚拟导入的文章,当时就是为了实现这个功能.

使用Vite插件虚拟导入的方式,我们可以动态的生成一个函数,这个函数导入了所有的service,然后根据选择的Service返回实例化对象即可.

修改之后可以这样来写:

import { useRequest } from 'virtual:http-request'

const posterService = useRequest(
  services => services.UserService
)

userService.login(new RequestParams()).subscribe({
    next: data => {
        console.log(data)
    }
})

好处是我们不在需要导入对应的Service,在选择services时也可以做到自动提示的支持.

实现也并不麻烦,首先来生成导入代码

function generateImportCode(
  services: {
    name: string;
    path: string;
  }[],
  placeholder = ""
) {
  return services
    .map((service) => `import { ${service.name} } from '${service.path}'`)
    .join(`\r\n${placeholder}`);
}

再来生成全体服务的列表

function generateServiceCode(
  services: {
    name: string;
    path: string;
  }[],
  placeholder = ""
) {
  return `const serviceList = {
    ${services.map((service) => service.name).join(`,\r\n${placeholder}`)}
  }`;
}

之后我们生成虚拟文件导出的代码即可

function generateCode(services: service[]) {
  const importCode = generateImportCode(services);
  const serviceCode = generateServiceCode(services);

  return `
${importCode}
${serviceCode}

export function useRequest(
  select
) {
  const service = select(serviceList)
  return new service()
}
  `;
}

可以看到我们生成一个useRequest函数,它的参数一个select函数,他会从serviceList中取对应的Service,之后会把选择的Service进行实例化,这样就实现了我们之前的要求.

但是现在还有一个问题,就是现在并没有TypeScript类型提示的支持,我们在输入services=>services.并不会出现代码提示,因为services的类型还是一个any.

那么我们需要一个declaration文件来实现自动提示的支持.

function generateDeclaration(services: service[], serviceDeclaration: string) {
  const importCode = generateImportCode(services, "  ");
  const serviceCode = generateServiceCode(services, "    ");

  const declaration = `declare module '${MODULE_ID}' {
  ${importCode}
  ${serviceCode}

  export function useRequest<T>(
    select: (services: typeof serviceList) => { new (): T }
  ): T
}
`;

 ...
}

我们通过generateDeclaration函数,首先生成了一个declare module ...的类型定义文件,其中指定了select的类型,通过指定select函数中参数中的services类型为typeof serviceList我们也就获得TypeScript的类型支持了.

~撒花 🎉🎉🎉

源码地址: github

如果您觉得这篇文章有帮助到您的的话不妨🍉关注+点赞+收藏+评论+转发🍉支持一下哟~~😛