前后端集成没你想的这么难
by Teobler on 01 / 11 / 2020
views
网上时不时就能看到一些求助帖,大意是前后端集成所产生的一些争执,其实集成的时候如果能有一些”规范“,这件事情可以很简单。
技术栈
本文跟技术栈强相关,但是理论上从里面抽离出来的方法论可以实践在任意的技术栈上,只需要投入一点点时间和精力写一个小工具就好了,下面是本文所用到的一些技术栈:
- React
- TypeScript
- SWR
- axios
- 后端使用 Swagger 作为API文档
- @openapi-integration/swr-request-generator
swr-request-generator
其中最后一项就是上面提到的需要自己去实现的小工具,这里我是把组里之前给redux用的工具改了一版,先是从 swagger2.0 升级到 openAPI 标准,然后改为生成 SWR 的代码而不是redux的,具体工具怎么使用可以看我的 github,里面有详细的 readme 和 example。这个工具虽然简单,却是前后端集成不可或缺的重要组成部分,稍后我们会讲到。
SWR
然后 SWR 是一个很酷的用于获取远端数据的一个基于 React hooks 的请求库。它实现了 HTTP 规范中的 stale-while-revalidate
,即其会先使用 catch 中已存在的 data 先渲染页面,然后发送新的请求去验证 catch 中的 data 是否为最新,如果已经是最新则什么都不做,如果不是最新则更新本地的 catch 然后使用最新的 catch 重新渲染页面。
这样做的好处是可以极大地提升用户体验,用户在重复浏览同一页面时,如果页面数据更新不频繁则没有任何等待时间。在新项目上使用几个月后,我欣喜地发现 SWR 登上了 ThoughtWorks 最新一期的技术雷达,和 Recoil、Svelte 一起暂时位于评估象限。
不过技术雷达也一针见血地指出了 SWR 的缺陷:
我们的开发者在使用 SWR 时获得了很好的开发体验,并且因为数据总是显示在屏幕上,从而显著提升用户体验。然而,我们提醒团队,只有当应用程序返回过时数据是合适的时候,才能使用 SWR 缓存策略。要注意,HTTP 通常要求缓存要用最新的响应返回给请求,只有在需要非常慎重的场景下,才会允许返回过时的响应数据。
也就是说,如果你的坏境对数据更新的要求极高,需要实时拿到最新的数据的话,不适合使用 SWR。
TypeScript
TypeScript 现在几乎成为了一门前端必上的技术栈,相比 JavaScript,其提供的类型系统能够保证程序员们常犯的”低级错误“在写代码时就暴露出来。
但也是由于其类型系统,我们在集成后端 API 时需要写一堆麻烦的接口类型,比如一个 request 的参数,这个 response 的数据类型等等。
而这些接口类型其实是后端定义的,我们其实是在依赖后端写的接口来写类型。这就很尴尬了,后端改了接口,前端也得跟着改类型,这多麻烦,后面我们会一起解决这个问题。
为什么前后端集成问题频出?
要解决一个问题,那么我们首先应该做的是想清楚为什么会出现这样的问题。
首先我们来回忆一下常规的开发过程:拿到需求,前后端讨论出接口的各种参数,开始写代码,这中间可能后端(前端)发现有问题,然后改接口,然后差不多写好的接口得重新改,改类型,改参数。
这是常规的,不常规的呢?后端自己定好接口的参数,然后告诉你他要啥给你啥,然后你按照他给的写,写好发现不能用,去找他,他说接口改了,你重新改下。那还能咋说,只能网上对线了。
说白了就是,前后端讨论好的东西可能会变(甚至都没有经过讨论,由单方面直接决定好了),变了之后由于各种各样的原因没能及时同步信息,即使及时同步了,改接口代码也是一件烦人的事情。
咋办?
从契约测试展开
首先,我们需要搞清楚什么是契约测试?
契约测试,又叫”消费者驱动的契约测试“(Consumer-Driven Contracts,简称CDC)。其中有两个角色,一个消费者,一个生产者。由消费者提供一份自己的”需求清单“(契约,约定好request和response),然后生产者根据“清单”进行相应实现。之后双方依赖于这份契约进行测试和实现。
其实契约测试将依赖双方做了一个类似于解耦的操作,从消费者依赖于生产者变成双方依赖于消费者提供的契约。我们是不是可以运用这个思想呢?
其实大多数项目里已经有一份契约了,没错,就是 swagger。只不过这份契约是生产者提供的,而且由生产者决定上面有啥,消费者没法决定。
但是其实我们可以将这份契约做一个转换。swagger 上通常会给出 API 的详细信息,包括 request 的参数和 response 长什么样子。而且其实 swagger 只是一份 json 文件,我们所看到的 swagger-ui 是后端的 lib 自动生成的。那么我们是不是可以拿到这份 json 文件。解析后直接生成前端需要的 request 和类型呢?
这样带来了几个好处:
- 前端写代码可以不用一个个写 API 了,所有的url, request params, request method, response type等等信息我们都可以通过 swagger 自动生成
- 后端接口改了?没问题,2秒钟更新好最新的接口代码,然后 TypeScript 的好处就体现出来了,根据新的接口类型改参数就好了
- 生成的代码有问题?那就是后端的问题,你的实现和你自己提供的契约不一致,要么你实现不对,要么你契约不对
上代码
说了这么多都是理论,谁知道效果呢?没关系,我们直接上代码,下面所有的代码都可以在链接里面找到。
首先看一下这个example的目录:
. |____swagger | |____openAPI.json |____types.ts |____request | |____api.ts | |____useRequest.ts | |____client.ts |____page.tsx
其中 swagger 文件夹下是我从网上生成的 openAPI 规范的 swagger 文档。
request 文件夹下有三个文件:
- api.ts 这个文件就是通过 @openapi-integration/swr-request-generator 根据上面的 swagger 文档生成的,里面包含 API 接口方法和相应的 request params 和 response的类型定义
- client.js 里面只有一个单纯的 axios client instance,用于发送请求
- useRequest 里面我将 SWR 和 axios 封装起来以供 api.ts 这个文件调用
这东西怎么用呢?
首先当然是安装这个包 npm i @openapi-integration/swr-request-generator -D
然后在项目的根目录添加一个配置文件,里面可以配置生成文件的输出目录,文件名,需要提前引入的方法,从哪里拿到 swagger 文件等(具体可以看readme)
然后跑一下 npm run ts-codegen
,就会在你配置的相应生成对应的 API 文件。
比如下面这个方法就是生成的:
import { ISWRConfig, useRequest } from "./useRequest";
import { IResponseError } from "../types";
import { client } from "./client";
export const useDownloadUsingGetRequest = (
{
id,
}: {
id: string;
},
SWRConfig?: ISWRConfig<IResource, IResponseError>,
) =>
useRequest<IResource, IResponseError>(
{
url: `/${id}`,
method: "get",
},
SWRConfig,
);
export interface IResource {
description?: string;
file?: IFile;
filename?: string;
inputStream?: IInputStream;
open?: boolean;
readable?: boolean;
uri?: IUri;
url?: IUrl;
}
而你只需要在调用的时候直接:
const { data, error } = useDownloadUsingGetRequest({ id: "id"})
假如后端改了代码,这个接口不要 id 了,要 name,你只需要重新跑一次npm run ts-codegen
,然后这个方法就会更新,TypeScript 就会报错,告诉你这里要的是 name 不是 id,如果 response 的结构也改了,那么所生成的文件里面的 response 的类型也会跟着变, TypeScript 依然会提醒你 response 结构的改变。
这样一来接口无论怎么变其实前端都不在意,顶多就是跑一遍命令,重新穿个参数就好了,大大提升开发效率,降低内耗。