前言
目前前端前后端联调的时候会存在以下问题:
- 后端接口更新通知不及时:后端接口更新,无法及时通知到前端;
- 重复编写接口代码:需要前端重复编写请求接口代码;
- 对字段困难:在对接口的时候总是要打开相应的swagger或者YAPI去对字段;
- mock数据困难:部分团队将mock数据放置在YAPI,有时候YAPI的数据更新不及时,前端的接口就会版本落后了;
而以上的问题pont能够很好的帮你去处理。现根据团队使用了近一年的pont使用做了以下分享。
认识pont
Pont 把 swagger、rap、dip 等多种接口文档平台,转换成 Pont 元数据。Pont 利用接口元数据,可以高度定制化生成前端接口层代码,接口 mock 平台和接口测试平台。详细介绍
在官网中都有很多详细的说明,这里就不在做赘述了。这里主要说具体在项目中的实践
在vue-cli中使用pont
主要
1. 在项目中安装pont-engine
yarn add pont-engine -D
2. 在项目中建pont-config.json文件
建议在src文件夹同级中建config,并将其放置在里面
{
"outDir": "../src/api", // 生成代码的存放路径,使用相对路径即可
// 配置每个数据来源, 一个项目里面往往会有多个数据源,建议这样配置
"origins": [
{
// 接口平台提供数据源的 open api url(需要免登),目前只支持 Swagger。
"originUrl": "https://petstore.swagger.io/v2/swagger.json",
// 建议配置,后面可以在模板文件里面使用
"name": "petstore",
// 配置是否启用多数据源,建议默认开启
"usingMultipleOrigins": true
}
],
// 本地mock配置
"mocks": {
// 是否开启本地mock
"enable": true,
// 本地mock的接口,注意看看该端口是否被占用
"port": 8081
},
// 生成模板文件路径,使用相对路径即可
"templatePath": "./pont-template"
}
说明: 所有的参数说明都在配置里面说明了,使用的时候因为这个是JSON文件,应该将其注释去除后才使用
3. 在项目中建自定义代码生成器的文件pont-template
import {
CodeGenerator,
Interface,
Mod,
BaseClass,
Surrounding,
} from "pont-engine";
import * as pont from "pont-engine";
export default class MyGenerator extends CodeGenerator {
/** 获取某个基类的类型定义代码 */
getBaseClassInDeclaration(base: BaseClass): string {
return `class ${base.name} {
${base.properties
.map((prop) => {
// 替换 defs. 不使用 defs 命名空间
let propertyCode = prop
.toPropertyCode(Surrounding.typeScript, true)
.replace(/defs\./g, "");
if ((prop.dataType as any).reference) {
// 这里可以通过正则将类型进行字符串的替换
propertyCode = propertyCode
.replace(/\?/, "")
.replace(/content:/, "content?:")
.replace(/payload:/, "payload?:");
}
return propertyCode;
})
.join("\n")}
}
`;
}
/** 获取所有基类的类型定义代码,一个 namespace */
getBaseClassesInDeclaration(): string {
const content = `namespace ${this.dataSource.name || "defs"} {
${this.dataSource.baseClasses
.map(
(base) => `
export ${this.getBaseClassInDeclaration(base)}
`
)
.join("\n")}
}
`;
return content;
}
/** 获取接口内容的类型定义代码 */
getInterfaceContentInDeclaration(inter: Interface): string {
let bodyParams = inter.getBodyParamsCode();
if (bodyParams.includes("defs.")) {
bodyParams = bodyParams.replace(/defs\./g, "");
}
// 这里可以将部分统一在请求拦截的参数全部剔除出来,从请求拦截的时候再添加这些参数
const paramsCode = inter.getParamsCode().replace(/tenantId:/g, "openId?:");
// 将空声明清除
const isEmptyParams =
paramsCode.replace(/(\n|\s)/g, "") === "classParams{}";
const requestArgs = [];
!isEmptyParams && requestArgs.push("params: Params");
bodyParams && requestArgs.push(`bodyParams: ${bodyParams}`);
requestArgs.push(`options?: RequestConfig`);
const requestParams = requestArgs.join(", ");
let responseType = inter.responseType;
if (responseType.includes("defs.")) {
responseType = responseType.replace(/defs\./g, "");
}
const resArr = responseType.split("<");
const lastItem = resArr[resArr.length - 1];
let resStr = responseType;
if (lastItem && lastItem.indexOf(".") > -1) {
resArr.splice(resArr.length - 1, 0, "Required");
resStr = resArr.join("<") + ">";
}
const urlParamsKeysStr = (inter.path.match(/(?<={)(.*?)(?=})/g) || [])
.map((item) => `'${item}'`)
.join("|");
// 获取URL中的keys
// 在params中获取对应的值
// 额外
return `
${isEmptyParams ? "" : "export " + paramsCode}
export type ResponseType = Promise<${resStr}>
${
urlParamsKeysStr ? `export type UrlParamType = ${urlParamsKeysStr}` : ""
}
export function getUrl(${
urlParamsKeysStr ? "params: Pick<Params, UrlParamType>" : ""
}): string;
export function request(${requestParams}): ResponseType;
`;
}
/** 获取公共的类型定义代码 */
getCommonDeclaration(): string {
return `
import {RequestConfig} from "@/utils/fetch";
type Required<T> = { [P in keyof T]-?: T[P] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
`;
}
/** 获取接口实现内容的代码 */
getInterfaceContent(inter: Interface): string {
const bodyParams = inter.getBodyParamsCode();
const paramsCode = inter.getParamsCode();
const isEmptyParams =
paramsCode.replace(/(\n|\s)/g, "") === "classParams{}";
const contentType =
inter.consumes && inter.consumes.length
? inter.consumes[0]
: "application/json";
const requestArgs: string[] = [];
!isEmptyParams && requestArgs.push(`params`);
bodyParams && requestArgs.push(`bodyParams`);
requestArgs.push("options");
const requestParams = requestArgs.join(", ");
return `
import { fetch, urlResolve } from "@/utils/fetch";
const getWholePath = (path) => {
const prefix = ''
return prefix + path
}
/**
* @desc 获取请求的URL
*/
export function getUrl(paramsObj) {
return urlResolve(getWholePath('${inter.path}'), paramsObj)
}
/**
* @desc ${inter.description}
*/
export function request(${requestParams}) {
const fetchOption = Object.assign({
url: getWholePath('${inter.path}'),
method: '${inter.method}',
headers: {
'Content-Type': '${contentType}'
},
${isEmptyParams ? "" : "" + "params: params,"}
${bodyParams ? "data: bodyParams" : ""}
},
options)
return fetch(fetchOption);
}
`;
}
/** 获取单个模块的 index 入口文件 */
getModIndex(mod: Mod): string {
return `
/**
* @description ${mod.description}
*/
${mod.interfaces
.map((inter) => {
return `import * as ${inter.name} from './${inter.name}';`;
})
.join("\n")}
export {
${mod.interfaces.map((inter) => inter.name).join(", \n")}
}
`;
}
/** 获取所有模块的 index 入口文件 */
getModsIndex(): string {
let conclusion = `
export const API = {
${this.dataSource.mods.map((mod) => mod.name).join(", \n")}
};
`;
// dataSource name means multiple dataSource
if (this.dataSource.name) {
conclusion = `
export const ${this.dataSource.name} = {
${this.dataSource.mods.map((mod) => mod.name).join(", \n")}
};
`;
}
return `
${this.dataSource.mods
.map((mod) => {
return `import * as ${mod.name} from './${mod.name}';`;
})
.join("\n")}
${conclusion}
`;
}
/** 获取接口类和基类的总的 index 入口文件代码 */
getIndex(): string {
let conclusion = `
export * from './mods/';
`;
// dataSource name means multiple dataSource
if (this.dataSource.name) {
conclusion = `
export { ${this.dataSource.name} } from "./mods/";
`;
}
return conclusion;
}
}
export class FileStructures extends pont.FileStructures {
getModsDeclaration(originCode: string): string {
return `
export ${originCode}
`;
}
getBaseClassesInDeclaration(originCode: string): string {
return `
export ${originCode}
`;
}
getDataSourcesDeclarationTs(): string {
const dsNames = (this as any).generators.map((ge) => ge.dataSource.name);
return `
${dsNames
.map((name) => {
return `export {${name}} from './${name}/api';`;
})
.join("\n")}
export as namespace defs;
`;
}
getDataSourcesTs(): string {
const dsNames = (this as any).generators.map((ge) => ge.dataSource.name);
return `
${dsNames
.map((name) => {
return `import { ${name} } from "./${name}";`;
})
.join("\n")}
import defs from './api';
export type apitype = typeof defs;
export const api = {${dsNames.join(",")}} as apitype;
`;
}
}
// eslint-disable-next-line
import { CodeGenerator, Interface, Mod, BaseClass, Surrounding } from 'pont-engine'
import * as pont from 'pont-engine'
export default class MyGenerator extends CodeGenerator {
/** 获取某个基类的类型定义代码 */
getBaseClassInDeclaration(base: BaseClass) {
return `class ${base.name} ${base.name === 'Payload' || base.name === 'PageBean' ? '<T0>' : ''} {
${base.properties
.map(prop => {
// 替换 defs. 不使用 defs 命名空间
let propertyCode = prop.toPropertyCode(Surrounding.typeScript, true).replace(/defs\./g, '')
// 如果属性是范型参考,则属性为必选
// 例如 data?: T0 , creditCustomerConsumptionDailyVo?: CreditManagerV2.AggregateAllTransactionDetailsWithinDimensions[]
if (prop.dataType.reference || base.name === 'Payload' || base.name === 'PageBean') {
propertyCode = propertyCode.replace(/\?/, '')
.replace(/content\:/, 'content?:')
.replace(/payload\:/, 'payload?:')
}
return propertyCode
})
.join('\n')}
}
`
}
/** 获取所有基类的类型定义代码,一个 namespace */
getBaseClassesInDeclaration() {
const content = `namespace ${this.dataSource.name || 'defs'} {
${this.dataSource.baseClasses
.map(
base => `
export ${this.getBaseClassInDeclaration(base)}
`
)
.join('\n')}
}
`
return content
}
/** 获取接口内容的类型定义代码 */
getInterfaceContentInDeclaration(inter: Interface) {
let bodyParams = inter.getBodyParamsCode()
if (bodyParams.includes('defs.')) {
bodyParams = bodyParams.replace(/defs\./g, '')
}
const paramsCode = inter.getParamsCode()
.replace(/tenantId\:/g, 'tenantId?:')
.replace(/userId\:/g, 'userId?:')
.replace(/username\:/g, 'username?:')
const isEmptyParams = paramsCode.replace(/(\n|\s)/g, '') === 'classParams{}'
const requestArgs = []
// @ts-ignore
!isEmptyParams && requestArgs.push('params: Params')
// @ts-ignore
bodyParams && requestArgs.push(`bodyParams: ${bodyParams}`)
// @ts-ignore
requestArgs.push(`options?: RequestConfig`)
const requestParams = requestArgs.join(', ')
let responseType = inter.responseType
if (responseType.includes('defs.')) {
responseType = responseType.replace(/defs\./g, '')
}
const resArr = responseType.split('<')
const lastItem = resArr[resArr.length - 1]
let resStr = responseType
if (lastItem && lastItem.indexOf('.') > -1) {
resArr.splice(resArr.length - 1, 0, 'Required')
resStr = resArr.join('<') + '>'
}
const urlParamsKeysStr = (inter.path.match(/(?<={)(.*?)(?=})/g) || []).map(item => `'${item}'`).join('|')
// 获取URL中的keys
// 在params中获取对应的值
return `
${isEmptyParams ? '' : 'export ' + paramsCode}
export type ResponseType = Promise<${resStr}>
${urlParamsKeysStr ? `export type UrlParamType = ${urlParamsKeysStr}` : ''}
export function getUrl(${urlParamsKeysStr ? 'params: Pick<Params, UrlParamType>' : ''}): string;
export function request(${requestParams}): ResponseType;
`
}
/** 获取公共的类型定义代码 */
getCommonDeclaration() {
return `
import {RequestConfig} from '@@/utils/fetch'
type Required<T> = { [P in keyof T]-?: T[P] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
`
}
/** 获取接口实现内容的代码 */
getInterfaceContent(inter: Interface) {
const bodyParams = inter.getBodyParamsCode()
const paramsCode = inter.getParamsCode()
const isEmptyParams = paramsCode.replace(/(\n|\s)/g, '') === 'classParams{}'
const contentType =
inter.consumes && inter.consumes.length ? inter.consumes[0] : 'application/json'
const requestArgs: string[] = []
!isEmptyParams && requestArgs.push(`params`)
bodyParams && requestArgs.push(`bodyParams`)
requestArgs.push('options')
const requestParams = requestArgs.join(', ')
return `
import { fetch, urlResolve } from '@@/utils/fetch';
import { MEMBER_SERVER, STOCK_SERVER } from '@@/const/global-context.js'
const serverPrefix = {
'memberServer': MEMBER_SERVER,
'stockServe': STOCK_SERVER
}
const getWholePath = (path) => {
const prefix = serverPrefix['${this.dataSource.name}'] || ''
return prefix + path
}
/**
* @desc 获取请求的URL
*/
export function getUrl(paramsObj) {
return urlResolve(getWholePath('${inter.path}'), paramsObj)
}
/**
* @desc ${inter.description}
*/
export function request(${requestParams}) {
const fetchOption = Object.assign({
url: getWholePath('${inter.path}'),
method: '${inter.method}',
headers: {
'Content-Type': '${contentType}'
},
${isEmptyParams ? '' : '' + 'params: params,'}
${bodyParams ? 'data: bodyParams' : ''}
},
options)
return fetch(fetchOption);
}
`
}
/** 获取单个模块的 index 入口文件 */
getModIndex(mod: Mod) {
return `
/**
* @description ${mod.description}
*/
${mod.interfaces
.map(inter => {
return `import * as ${inter.name} from './${inter.name}';`
})
.join('\n')}
export {
${mod.interfaces.map(inter => inter.name).join(', \n')}
}
`
}
/** 获取所有模块的 index 入口文件 */
getModsIndex() {
let conclusion = `
export const API = {
${this.dataSource.mods.map(mod => mod.name).join(', \n')}
};
`
// dataSource name means multiple dataSource
if (this.dataSource.name) {
conclusion = `
export const ${this.dataSource.name} = {
${this.dataSource.mods.map(mod => mod.name).join(', \n')}
};
`
}
return `
${this.dataSource.mods
.map(mod => {
return `import * as ${mod.name} from './${mod.name}';`
})
.join('\n')}
${conclusion}
`
}
/** 获取接口类和基类的总的 index 入口文件代码 */
getIndex() {
let conclusion = `
export * from './mods/';
`
// dataSource name means multiple dataSource
if (this.dataSource.name) {
conclusion = `
export { ${this.dataSource.name} } from './mods/';
`
}
return conclusion
}
}
export class FileStructures extends pont.FileStructures {
getModsDeclaration(originCode: string, usingMultipleOrigins: boolean) {
return `
export ${originCode}
`
}
getBaseClassesInDeclaration(originCode: string, usingMultipleOrigins: boolean) {
return `
export ${originCode}
`
}
getDataSourcesDeclarationTs() {
const dsNames = (this as any).generators.map(ge => ge.dataSource.name)
return `
${dsNames
.map(name => {
return `export {${name}} from './${name}/api';`
})
.join('\n')}
export as namespace defs;
`
}
getDataSourcesTs() {
const dsNames = (this as any).generators.map(ge => ge.dataSource.name)
return `
${dsNames
.map(name => {
return `import { ${name} } from './${name}';`
})
.join('\n')}
import defs from './api';
export type apitype = typeof defs;
export const api = {${dsNames.join(',')}} as apitype;
`
}
}
TODO 特殊的说明
- 可以对多暴露出来一个获取请求路径的方法,方便一些特殊组件的使用
- 有一些接口里面包含的公共参数,有时候我们会在公共请求里面统一添加了,那么可以通过生成模板直接去掉该参数的声明
- 不同的接口服务请求的上下文可能不一样,可以通过环境变量的形式传进来,然后注入到请求的链接上
- 里面的utils/fetch应该为各自项目中的请求拦截收口文件,文章的项目demo链接里面有可以自行取用
4. 在Vue.prototype中注入$api
在入口文件中注入$api方便项目中的使用
// 注入$api,这样声明能够在使用的时候再进行引入,降低首屏的加载压力
Vue.prototype.$api = async function () {
return await import('@/api')
}
5. 全局注入$api的声明
TS有较好的代码提示的作用,为了更加方便的使用,应该在全局里面注入其声明
// ts识别全局方法/变量
import VueRouter, { Route } from "vue-router";
import Vue from "vue";
import { Store } from "vuex";
import { api } from "./api/index";
declare global {
interface window {
require: any;
}
}
// 识别 this.$route
declare module "vue/types/vue" {
interface Vue {
$router: VueRouter; // 这表示this下有这个东西
$route: Route;
$store: Store<any>;
$api: () => Promise<{ api: typeof api }>;
}
}
6. 原型链中使用pont
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<script>
export default {
mounted() {
const api = await this.$api()
// 直接在原型链上获取到链式调用
api.api.petstore.store.getInventory.request().then((res) => {
console.log("res: ", res);
});
// 获取请求URL
const url = api.api.petstore.store.getInventory.getUrl();
console.log("url: ", url);
},
}
</script>
7. 使用pont中的mock功能
只需要在vue-cli中将相关的proxy配置好即可
module.exports = {
devServer: {
proxy: {
"/store": { // 写入需要拦截的接口
target: "http://localhost:8081", // 你本地mock的地址,即你在pont-config.json
ws: true,
changeOrigin: true,
},
},
},
};
在线demo仓库
在nuxtJs中使用pont
1. 在项目中安装pont-engine
同上
2. 在项目中建pont-config.json文件
同上
3. 在项目中建自定义代码生成器的文件pont-template
同上
4. 从@nuxtjs/axios将请求方法映射出来
如果不将方法映射出来的话,只能在Vue.prototype上用$axios,对于统一的请求封装会比较麻烦 具体步骤如下:
1)在plugins文件夹下面新建一个axios文件,其内容如下:
import { setClient } from '@/utils/apiClient'
import { api } from '@/api'
export default function (ctx, inject) {
const { $axios, app } = ctx
// 映射$axios
setClient(app.$axios)
ctx.$api = api
inject('api', api)
$axios.onRequest((config) => {
return config
})
$axios.onResponse((resp) => {
return Promise.resolve(resp)
})
$axios.onError((error) => {
// 将错误信息继续抛出,业务逻辑可以进行后续的操作
return Promise.reject(error)
})
}
2) 新建apiClient文件接收映射出来的$axios
import { AxiosRequestConfig, AxiosStatic } from 'axios'
interface ClientInter extends AxiosStatic {
request<T = any>(config: AxiosRequestConfig): Promise<T>
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
head<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
options<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T>
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
patch<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T>
$request<T = any>(config: AxiosRequestConfig): Promise<T>
$get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
$delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
$head<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
$options<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
$post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T>
$put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T>
$patch<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T>
}
export type MethodsTypes =
| 'request'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'post'
| 'put'
| 'patch'
| '$get'
| '$put'
| '$delete'
| '$post'
let client: ClientInter
export function setClient(newClient: ClientInter) {
client = newClient
}
// Request helpers
const reqMethods: Array<MethodsTypes> = [
'request',
'delete',
'get',
'head',
'options', // url, config
'post',
'put',
'patch', // url, data, config
'$get',
'$put',
'$delete',
'$post',
]
const service: ClientInter = {} as ClientInter
reqMethods.forEach((method: MethodsTypes) => {
service[method] = (...rest: any[]): Promise<any> => {
if (!client) throw new Error('apiClient not installed')
return (client[method] as any).apply(null, rest)
}
})
export const GET = service.get
export const POST = service.post
export const DELETE = service.delete
export const PUT = service.put
export default service
这个文件可以按照实际需求进行自定义,主要做了二封,兼容普通的axios
3) 结合封装好的axios,将其加入业务代码
这个文件主要糅合了一些自己需求的请求处理了,需要根据自己的需求高度自定义。这里就不贴代码了,有兴趣可以去文件结尾的代码仓库里面看。
5. 在Vue.prototype中注入$api
需要在nuxt.config.js中的plugins加入更改声明好的./plugins/axios.js
export default {
// ...
plugins: ['@/plugins/axios'],
// ...
}
6. 全局注入$api的声明
同上
7. 原型链中使用pont
同上
8. 使用pont中的mock功能
export default {
//...
proxy: {
'/store/inventory': {
target: 'http://localhost:8081', // pont-config.json中配置的端口号
changeOrigin: true,
},
},
axios: {
proxy: true,
prefix: '/'
},
//...
}
在线demo仓库
使用技巧
以下1~3是基于VSCode总结出来的使用技巧(需要安装vscode-pont插件)
1. 巧用mock数据
mock数据是可以编辑以及实时预览的
2. 快速检索接口
可以在vscode编辑器界面,同时按下 cmd + ctrl + p 然后输入pont进行接口查找
3. 监控后端接口改动
当接口后端接口改动的时候,vscode-pont插件就会显示相应的改动。这个时候可以先将本地更改commit(有一个接口的历史记录,避免以后跟后端有接口改动的问题进行扯皮),然后再进行更新。注意:接口声明的更新以及接口的改动都会导致插件提示改动
4. 统一团队的接口请求
在团队开发中,团队小伙伴经常将后端接口放置在service文件夹中,有些小伙伴在service添加了一些接口请求路径,又有一些小伙伴添加了业务处理逻辑进去,导致接口重用性很低。而用pont统一生成接口调用文件,很大程度的提升了接口的通用性。
注意事项
1. 后端声明接口的时候需要将name和tags字段改为因为名字
2. mock接口冲突
在同时打开了多个pont项目的时候,而这些pont项目都启用了mock功能,各个项目直接的mock端口会有端口冲突的问题。导致mock服务无法使用。解决方式:不同的pont项目用不同的mock端口号
3. mock缓存数据没有自动清理
当pont-config.json中的originUrl更改的时候,.mock文件夹中的数据有可能存在没有更新的问题。解决方式:将.mock文件夹删除,重新打开vscode的问题(需要注意,有一些你自己自定义的mock数据有可能被删除)。
总结
以上总结了那么多pont的优点以及使用方式,那么pont的使用有没有什么缺点呢?缺点当然是有的。我们团队遇到的问题主要有: 1、当后端的接口类型声明更改(接口没改)的时候,对于前端的改动是毁灭性的,前端需要跟着后端的声明去改(如果只是接口请求链接改动的话,前端就无比轻松了,只需要重新生成接口文件即可); 2、当后端的swagger服务过多的时候,前端项目就会生成很多接口文件,项目打包构建起来的时候就会慢一些。ps:我们有一个项目同时调用了6个后台swagger的服务,这个时候有部分配置低的电脑有点吃不消了(如果要解决这个问题的话,可以通过事先声明调用的URL,然后模板根据这些URL生成部分接口文件); 当然,这些需要自己团队权衡利弊了。对于我来说:pont真香。今天的分享先这样了,有些不对的地方还望大佬们斧正,谢谢!
附录
本文中涉及到的代码仓库:
- 在vue-cli中使用pont在线demo
- 在nuxtJs中使用pont