如何优雅解决前后端数据结构重复问题
21 年的时候曾在知乎上提了一个问题 如何优雅解决前后端重复数据结构问题,那个时候自己写了后端接口,需要编写成文档通知到前端,然后前端的开发去根据文档一个个修改,效率十分低下。一直在想有没有什么好的解决方案,也确实找到一些比较好的解决方案。
前后端通信
现在前后端项目都基本是分离了的,随着 TS 的流行,越来越多的前端项目开始使用 TS 来写,后端百家争鸣,基本每种语言都有自己的 web 框架。从 PHP、Python、Java、Golang 等等都有,但是都避免不了的是定义各种 数据结构 进行数据的传输,那么这个时候就会出现一个问题:
TS 的静态类型检查,让我们在前端进行数据请求的时候,需要明确定义响应的相应数据结构,这时,前端需要重新定义一份在后端已经定义过的 数据结构 ,一旦后端发生一点改变不及时通知,前后端就会造成不统一的情况。这时前后端就必须保持两者的一致性,一个修改另一个必须时刻保持同步,同时还要维护不同语言的相同代码。
要解决这个问题无非就是两种方案
-
统一用一套数据结构代码:前后端基于同一种语言,开发引入的是一个公共模块的实体代码。
- 例如 Next.js+ tRPC+ Prisma 方案,Flutter Web + Dart Web 方案,Vaadin + Spring Boot 方案,亦或者最近 Jetbrain 推出的 Kotlin Wasm + Compose Multiplatform + Ktor 方案
-
一端修改另一端监听后自动生成:以后端为主,前端随着后端结构体修改而自动生成。
今天我们主要说的也就是第二种方案,因为第一种对于大多数国内公司不适用,少部分可以适用的无疑是幸福的~
openapi-typescript
大概一年前参与开发 Astro 的 大佬 drwpow 开发了 openapi-typescript,仿佛发现了新大陆。直接基于 openapi 文档进行生成 types,完美解决了这个问题。 相比起基于 Java class 或者 Golang struct 的生成更加方便快捷。而且现在很多库都可以直接集成 openapi 生成文档,可以说适用性更高。同时他生成的 types 也可以用于 Axios。只需要一个命令就可以生成。并且配套了 openapi-fetch 模块,直接可以使用。
生成类型
我们可以直接 npm/yarn/pnpm 进行安装
shell
npm i -D openapi-typescript
# or
yarn add -D openapi-typescript
# or
pnpm add -D openapi-typescript
然后直接生成即可
shell
# npm Local schema
npx openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts
# 🚀 ./path/to/my/schema.yaml -> ./path/to/my/schema.d.ts [7ms]
# pnpm Remote schema
pnpm exec openapi-typescript https://localhost:8080/api/v1/openapi.yaml -o ./path/to/my/schema.d.ts
# 🚀 https://localhost:8080/api/v1/openapi.yaml -> ./path/to/my/schema.d.ts [250ms]
在我们需要的地方进行导入
typescript
import type { paths, components } from "./api/v1"; // generated by openapi-typescript
// Schema Obj
type MyType = components["schemas"]["MyType"];
// Path params
type EndpointParams = paths["/my/endpoint"]["parameters"];
// Response obj
type SuccessResponse =
paths["/my/endpoint"]["get"]["responses"][200]["content"]["application/json"]["schema"];
type ErrorResponse =
paths["/my/endpoint"]["get"]["responses"][500]["content"]["application/json"]["schema"];
openapi-fetch
我们直接使用它配套的 openapi-fetch
shell
npm i openapi-fetch
# or
yarn add openapi-fetch
# or
pnpm add openapi-fetch
使用方式也很简单,引用我们上一步生成的 path
即可直接创建一个客户端
typescript
import createClient from "openapi-fetch";
import type { paths } from "./api/v1";
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
const { data, error } = await client.GET("/blogposts/{post_id}", {
params: {
path: { post_id: "my-post" },
query: { version: 2 },
},
});
const { data, error } = await client.PUT("/blogposts", {
body: {
title: "New Post",
body: "<p>New post body</p>",
publish_date: new Date("2023-03-01T12:00:00Z").getTime(),
},
});
Spring Boot 适配
每个请求库对于数组格式的 query param
处理方式不一样,我们需要修改 openapi-typescript 一下以适配 Spring Boot 中的格式
typescript
import createClient from "openapi-fetch";
import {decodeUrl} from "~/server/utils";
import {env} from "~/env";
import {type paths} from "~/common/core-client";
const coreClient = createClient<paths>({
baseUrl: decodeUrl(env.CORE_API_ROOT).baseUrl,
headers: {
// "x-api-key": "",
},
querySerializer(query) {
const queryArray: string[] = []
for (const [k, v] of Object.entries(query)) {
if (Array.isArray(v)) {
// 数组格式需要修改为 a=1&a=2 而不是 a[]=1,2
for (const value of v) {
queryArray.push(`${k}=${value}`)
}
} else if (k === 'pageable') {
// Spring boot 接受的参数是分页的时候需要进行处理下
const pv = v as Record<string, never>;
for (const [k, v] of Object.entries(pv)) {
queryArray.push(`${k}=${v}`)
}
} else {
queryArray.push(`${k}=${v}`)
}
}
// 可以添加打印语句看看结果 console.log(`query param ${queryArray.join("&")}`)
return queryArray.join("&");
}
});
export default coreClient;
授权
请求之前添加授权校验即可,这里对于每个请求库差别不大
typescript
import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./api/v1";
let accessToken: string | undefined = undefined;
const authMiddleware: Middleware = {
async onRequest(req) {
// 获取 access token,例如可以从 cookie 获取
if (!accessToken) {
const authRes = await someAuthFunc();
if (authRes.accessToken) {
accessToken = authRes.accessToken;
} else {
// 授权错误处理,可以全局消息提示或者 抛出异常
}
}
// 添加 token 到请求头中
req.headers.set("Authorization", `Bearer ${accessToken}`);
return req;
},
};
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
// 使用中间件
client.use(authMiddleware);
const authRequest = await client.GET("/some/auth/url");
uni-app 使用
在 uni-app 中微信小程序端无法使用 openapi-fetch
的,因为有一部分 api 在微信小程序中是没有的。但是可以使用 axios
,所以可以使用 openapi-typescript 配合一起使用。
生成了以后直接使用即可
typescript
import axios from 'uniapp-axios-adapter'
const request = axios.create({
baseURL: 'http://127.0.0.1:8080',
timeout: 10000
})
request.interceptors.request.use((config) => {
// 权限校验等
return config
})
request.interceptors.response.use(async (response) => {
if (response.status >= 200 && response.status < 300) {
return await Promise.resolve(response.data)
}
return await Promise.reject(response)
})
// 直接使用即可
export const wechatAuth = async (code: string) => {
const response = await request.get<components['schemas']['CodeSession']>(`/wechat/auth/${code}`)
return response.data
}
swagger-typescript-api
swagger-typescript-api 则是基于 swagger 的生成器,同上面一样,安装后直接使用即可
shell
npm i -D swagger-typescript-api
# or
yarn add -D swagger-typescript-api
# or
pnpm add -D swagger-typescript-api
直接使用即可
shell
# npm Local schema
npx swagger-typescript-api -p ./swagger.json -o ./src -n myApi.ts
# 🚀 ./wagger.json -> ./src/myApi.ts
# pnpm Remote schema
pnpm exec swagger-typescript-api -p http://127.0.0.1:8080/swagger/documentation.yaml -o ./src -n myApi.ts
# 🚀 http://127.0.0.1:8080/swagger/documentation.yaml -> ./src/myApi.ts
相比于 openapi-typescript,他可选项更多,他不依赖于特定的请求库但是使用门槛也相对高一点。他还提供了与 axios 直接集成,对于接入老项目也很方便!
生成结果对比
简单对比一下一个微信登录接口的生成情况
openapi-typescript
typescript
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
'/': {
get: {
responses: {
/** @description OK */
200: {
content: {
'text/plain': string
}
}
}
}
}
'/wechat/auth/{code}': {
get: {
parameters: {
path: {
code: string
}
}
responses: {
/** @description OK */
200: {
content: {
'*/*': components['schemas']['CodeSession']
}
}
}
}
}
}
export type webhooks = Record<string, never>
export interface components {
schemas: {
CodeSession: {
sessionKey: string
unionId: string
errorMessage: string
openId: string
/** Format: int32 */
errorCode?: number
}
}
responses: never
parameters: never
requestBodies: never
headers: never
pathItems: never
}
export type $defs = Record<string, never>
export type external = Record<string, never>
export type operations = Record<string, never>
swagger-typescript-api
typescript
/* eslint-disable */
/* tslint:disable */
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export interface CodeSession {
sessionKey?: string;
unionId?: string;
errorMessage?: string;
openId?: string;
/** @format int32 */
errorCode?: number;
}
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">;
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = "https://TimeLine";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
};
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
return keys
.map((key) => (Array.isArray(query[key]) ? this.addArrayQueryParam(query, key) : this.addQueryParam(query, key)))
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
[ContentType.Text]: (input: any) => (input !== null && typeof input !== "string" ? JSON.stringify(input) : input),
[ContentType.FormData]: (input: any) =>
Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData()),
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
public request = async <T = any, E = any>({
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
const secureParams =
((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
this.securityWorker &&
(await this.securityWorker(this.securityData))) ||
{};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
return this.customFetch(`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, {
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}),
},
signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null,
body: typeof body === "undefined" || body === null ? null : payloadFormatter(body),
}).then(async (response) => {
const r = response as HttpResponse<T, E>;
r.data = null as unknown as T;
r.error = null as unknown as E;
const data = !responseFormat
? r
: await response[responseFormat]()
.then((data) => {
if (r.ok) {
r.data = data;
} else {
r.error = data;
}
return r;
})
.catch((e) => {
r.error = e;
return r;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
if (!response.ok) throw data;
return data;
});
};
}
/**
* @title TimeLine API
* @version 1.0.0
* @baseUrl https://TimeLine
*
* TimeLine API
*/
export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDataType> {
/**
* No description
*
* @name GetRoot
* @request GET:/
*/
getRoot = (params: RequestParams = {}) =>
this.request<string, any>({
path: `/`,
method: "GET",
...params,
});
wechat = {
/**
* No description
*
* @name AuthDetail
* @request GET:/wechat/auth/{code}
*/
authDetail: (code: string, params: RequestParams = {}) =>
this.request<CodeSession, any>({
path: `/wechat/auth/${code}`,
method: "GET",
...params,
}),
};
}
可以看得出后者会更复杂约束会更多一点,但是也更完整一点。
总结
如果你希望前端约束开箱即用,那么 openapi-typescript 无疑是你最好的选择。如果你希望可以有更多自定义空间,并且可以与 axios 无缝集成,那么 swagger-typescript-api 更适合你!