• 作者:老汪软件技巧
  • 发表时间: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吧!