- 作者:老汪软件技巧
- 发表时间:2024-09-14 07:01
- 浏览量:
痛点
近几年都流行的是前后端分离的开发方式,后端只写http接口,前端根据文档请求这些接口。经历了好几年的带团队开发,我发现团队开发时,很大一部分拉低效率和代码质量的就是在前后端对接接口的过程,我相信这也是绝大部分团队都遇到的问题。
前后端是不同的人在写代码,后端写好的接口该如何传达给前端呢?最早期是口述,写word文档,后来出现了swagger和类swagger的东西(后续出现的swagger均指代swagger及类似swagger的其他产品),直到现在swagger模式依然是最流行的方式之一。
人的追求是一直在增长的,不可否认,swagger的出现极大的解决了后端开发人员书写接口文档的麻烦,通过注解快速生成清晰美观的接口文档,给后端开发人员节省了大量的时间。但,对于前端开发人员来说,swagger的出现,并没有太多的提升,无非就是从看word文档变成了看swagger ui。
我们来模拟一下通过swagger对接接口的流程:
1.假设后端出了一个接口,swagger截图如下:
2.前端看到文档后,开始书写这个接口的调用方法:
import axios from 'axios'
interface PostStoreOrderBody {
id: number
petId: number
quantity: number
shipDate: string
status: string
complete: boolean
}
interface PostStoreOrderResponse {
id: number
petId: number
quantity: number
shipDate: string
status: string
complete: boolean
}
/**Place an order for a pet */
export function postStoreOrder(body:PostStoreOrderBody): Promise<PostStoreOrderResponse> {
return axios.post('/store/order', body)
}
3.然后前端开始去问后端,body参数里的petId是什么意思,quantity代表什么,前后端一顿吧啦吧啦...想想,从前端刚才书写第一个代码到吧啦完最快要耗时多久?万一中途出点什么粗心大意的错误,或者后端丢完文档就出去上厕所或者抽烟去了,还得等他回来再吧啦吧啦...
我相信,以上描述的步骤,是绝大多数团队目前的前后端对接情况。
寻找解决方案
既然大量的时间花在前端阅读文档和书写请求代码上,那么有没有什么工具,能根据后端接口生成前端请求代码呢?找了一圈,还真有,一个叫做nestia的库,下面是它的官方介绍里的截图:
左边是服务端代码,右边是前端代码经过调研和体验后,发现nestia这个库,对代码侵入性很严重,例如:原始nestjs的@Get()装饰器,需要替换成由nestia提供的@TypedRoute.Get(),以下是使用nestia写的一个控制器代码示例,可以看出,很多装饰器都需要替换成它的。
@Controller("exception")
export class ExceptionController {
@TypedRoute.Post(":section/typed")
@TypedException<TypeGuardError>({
status: 400,
description: "invalid request",
example: {
name: "BadRequestException",
method: "TypedBody",
path: "$input.title",
expected: "string",
value: 123,
message: "invalid type",
},
})
@TypedException<INotFound>(404, "unable to find the matched section")
@TypedException<IUnprocessibleEntity>(428)
@TypedException<IInternalServerError>("5XX", "internal server error")
public async typed( @TypedParam("section") section: string,@TypedBody()input:IBbsArticle.IStore,): Promise<IBbsArticle> {
section;
input;
return typia.random<IBbsArticle>();
}
}
我们用nestjs开发了好几个项目,基本每一个项目都有几十个模块,上百个接口,不可能为了它去全部重构一遍。而且这个库生成的前端代码,也不是很好使用,具体详说了,感兴趣的可以自己去体验这个库。
自己造解决方案
既然找不到合适的方案,那就只有自己造工具了!工具的核心诉求是:
撸起袖子就干!于是基于TypeScript AST抽象语法树原理的nest-canddy工具就诞生了!
nest-canddy
github地址
安装
npm -g nest-canddy
安装之后,拥有两个命令:nests和nestc,分别对应服务端工具和客户端工具。
nests 服务端工具命令
在后端项目根目录下新增一个nest-canndy的配置文件nestcanddy.config.js:
module.exports = {
server:{
port: 13270,//作为给前端提供SDK拉取服务的端口号
outputPath:'./ts-sdk',//生成SDK的保存目录 基于当前项目目录
},
}
配置文件添加完成之后,即可通过以下命令开始使用了:
1.生成前端SDK代码
nests generate 或者 nests g
运行该命令后,命令行中会扫描并根据当前项目的依赖情况,列出树状结构,按→进入子模块,按←返回上级,按回车键确认开始生成所选模块的SDK(如果该模块有子模块,会一并构建)。
示例:假设项目结构如下
然后运行nests g,将会在根目录下生成一个输出目录,目录名为nestcanddy.config.js中配置的outputPath。
输出目录中的文件就是提供给客户端拉取的Typescript SDK文件。
2.启动nests服务,以便前端能够拉取SDK代码
nests server 或者 nests s
运行该命令后,会出现两个链接,Server就是需要发给你的前端小伙伴了,他通过此链接来拉取SDK代码。
3.Web UI
这是一个网页版的可视化Nest项目模块分析,双击打开之前运行nests s命令出现的Web UI地址。在可视化界面中可以大概预览整个项目的模块依赖、控制器、和方法(仅包含restful接口的方法),可以查看代码,也可以右键点击某个模块或者控制,单独生成前端SDK,这个细粒度的生成SDK,在后端仅仅只修改了某个控制器方法时很有用。
nestc 客户端工具命令
作为前端开发人员,如果需要拉取上文中后端生成的SDK代码,只需要在项目根目录下新增一个nest-canndy的配置文件nestcanddy.config.cjs:
module.exports = {
client:{
host: 'localhost:13270',//后端提供的SDK服务地址 去掉http
outputPath:'./output',//SDK输出到当前项目的相对路径
httpAdapterPath:'axios',//发起http请求的适配器引用路径
httpAdapterName:'axios'//发起http请求的适配器引用名称
}
}
关于httpAdapterPath和httpAdapterName,这里解释一下,后端生成的SDK代码是这样的:
import {{HTTP_ADAPTER_NAME}} from "{{HTTP_ADAPTER_PATH}}";
export class Test1Controller {
static async create(createTest1Dto: CreateTest1Dto): Promise<string> {
return {{HTTP_ADAPTER_NAME}}.post(`test1`,createTest1Dto)
}
static async findAll(): Promise<string> {
return {{HTTP_ADAPTER_NAME}}.get(`test1`)
}
static async findOne(id: string): Promise<string> {
return {{HTTP_ADAPTER_NAME}}.get(`test1/${id}`)
}
static async update(id: string, updateTest1Dto: UpdateTest1Dto): Promise<string> {
return {{HTTP_ADAPTER_NAME}}.patch(`test1/${id}`,updateTest1Dto)
}
static async remove(id: string): Promise<string> {
return {{HTTP_ADAPTER_NAME}}.delete(`test1/${id}`)
}
}
可以看到代码中有{{HTTP_ADAPTER_NAME}}和{{HTTP_ADAPTER_PATH}}的模板字符串,是用于替换成前端项目的具体请求适配器的,例如,我们使用axios这个库,并且一般我们都会封装一个axios的实例供全局引用:
//假设 @/utils/http 是你已经封装好的axios实例
import myAxiosInstance from "@/utils/http"
那么在nestcanddy.config.cjs中的client.httpAdapterPath就填写@/utils/http,而client.httpAdapterName就填写myAxiosInstance,最终前端拉取到的代码就会是这个样子:
import myAxiosInstance from "@/utils/http";
export class Test1Controller {
static async create(createTest1Dto: CreateTest1Dto): Promise<string> {
return myAxiosInstance.post(`test1`,createTest1Dto)
}
static async findAll(): Promise<string> {
return myAxiosInstance.get(`test1`)
}
static async findOne(id: string): Promise<string> {
return myAxiosInstance.get(`test1/${id}`)
}
static async update(id: string, updateTest1Dto: UpdateTest1Dto): Promise<string> {
return myAxiosInstance.patch(`test1/${id}`,updateTest1Dto)
}
static async remove(id: string): Promise<string> {
return myAxiosInstance.delete(`test1/${id}`)
}
}
下面是动图演示:
可以看到,代码直接拉取到项目指定目录了,直接可以使用了。
最佳实践
nest-canddy生成前端SDK代码的过程都是基于后端的代码,包括注释、输入项、返回项。那么如果想让前端能更加清晰明了的使用SDK代码,后端在书写接口代码的时候需要规范一些。下面举例说明一些实践:
以Test1Controller为例:
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { Test1Service } from './test1.service';
import { CreateTest1Dto, CreateTest1Response } from './dto/create-test1.dto';
import { UpdateTest1Dto } from './dto/update-test1.dto';
/**
* @description 订单模块
* @author 张三
* @date 2024-09-13
*/
@Controller('test1')
export class Test1Controller {
constructor(private readonly test1Service: Test1Service) {}
/**创建订单 */
@Post()
create(@Body() createTest1Dto: CreateTest1Dto): CreateTest1Response {
return this.test1Service.create(createTest1Dto);
}
@ApiOperation({summary: "查询订单列表"})//支持读取swagger的装饰器作为注释文档
@Get()
findAll() {
return this.test1Service.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.test1Service.findOne(+id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateTest1Dto: UpdateTest1Dto) {
return this.test1Service.update(+id, updateTest1Dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.test1Service.remove(+id);
}
}
//test1/dto/create-test1.dto.ts
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateTest1Dto {
@ApiProperty({
description: "商品id", //支持读取swagger的装饰器作为注释文档
required: true,
type: String
})
@IsString()
@IsNotEmpty()
productId: string;
/**订单备注 */
@IsString()
@IsOptional()
notes: string;
}
export interface CreateTest1Response {
/**订单id */
orderId: string;
}
1. 注释即文档
上面的示例中在Test1Controller上方、create方法上方以标准的jsDoc语法标注了注释,findAll方法上方以swagger装饰器标注了注释,那么生成的前端代码就是这样:
import type { CreateTest1Dto, CreateTest1Response } from "./types/create-test1.d.ts";
import type { UpdateTest1Dto } from "./types/update-test1.d.ts";
import axiosInstance from "@/http";
/**
* @description 订单模块
* @author 张三
* @date 2024-09-13
*/
export class Test1Controller {
/** 创建订单 */
static async create(createTest1Dto: CreateTest1Dto): Promise<CreateTest1Response> {
return axiosInstance.post(`test1`,createTest1Dto)
}
/** 查询订单列表 */
static async findAll(): Promise<string> {
return axiosInstance.get(`test1`)
}
static async findOne(id: string): Promise<string> {
return axiosInstance.get(`test1/${id}`)
}
static async update(id: string, updateTest1Dto: UpdateTest1Dto): Promise<string> {
return axiosInstance.patch(`test1/${id}`,updateTest1Dto)
}
static async remove(id: string): Promise<string> {
return axiosInstance.delete(`test1/${id}`)
}
}
//前端 test1/types/create-test1.d.ts
export interface CreateTest1Dto {
/** 商品id */
productId: string;
/** 订单备注 */
notes: string;
}
export interface CreateTest1Response {
/** 订单id */
orderId: string;
}
额外提一下,有些懒惰的老6后端,喜欢用entity类作为返回类型,虽然我不建议这么做,但依旧支持:
// order.entity.ts
@Entity('orders')
export class OrderEntity{
@PrimaryGeneratedColumn()
id: number;
@Column({
type: "int",
nullable: false,
comment: "用户id",//<--这里会抽取作为前端字段的注释
})
@Index()
userId: number;
@Column({
type: "varchar",
length:"100",
nullable: false,
comment: "订单号",//<--这里会抽取作为前端字段的注释
})
@Index()
orderNo: string;
}
2. 尽可能显式的返回类型
/**创建订单 */
@Post()
create(@Body() createTest1Dto: CreateTest1Dto): CreateTest1Response {//<---这里显式的声明了返回类型
return this.test1Service.create(createTest1Dto);
}
@ApiOperation({summary: "查询订单列表"})
@Get()
findAll() { //<---这里并没有显式的声明返回类型
return this.test1Service.findAll();
}
但生成的前端代码依然会自动追踪方法返回的类型,它会根据AST抽象语法树自动分析该方法返回的类型。但这很容易出现预期之外的问题,比如返回类型跟后端想表达的类型不一致等。
/** 创建订单 */
static async create(createTest1Dto: CreateTest1Dto): Promise<CreateTest1Response> {
return axiosInstance.post(`test1`,createTest1Dto)
}
/** 查询订单列表 */
static async findAll(): Promise<string> {//<---这里自动推断了返回类型
return axiosInstance.get(`test1`)
}
3. NestJs中的特殊dto方法
PartialType、OmitType和PickType,这三个方法将会在前端代码被转义为Partial、Omit和Pick:
import { PartialType } from '@nestjs/mapped-types';
import { CreateTest1Dto } from './create-test1.dto';
export class UpdateTest1Dto extends PartialType(CreateTest1Dto) {}
转义为:
export type UpdateTest1Dto = Partial<CreateTest1Dto>;
export interface CreateTest1Dto {
/** 商品id */
productId: string;
/** 订单备注 */
notes: string;
}
总结
本工具实现了无侵入性的快速将Nest后端接口生成前端请求的SDK,并解决了代码分发给前端的问题。
基于AST抽象语法树的原理,其实还能干更多的事情,只是我现在想不到需要增加哪些有用的功能。后续可能会不断加入一些新功能。
本工具目前属于体验阶段,由于代码写法千千万,无法保证所有写法都能被支持,如果在使用过程中发现bug可以在github上提issue,顺便帮点个star吧!