前不久组内的萌新用不晓得从哪里学来的技术,说要封装一套 axios
库供大家应用。
等他开发完,在 code review
环节,大家看到他写的代码都面面相觑,不晓得该如何评估。
我一时间也不晓得该如何评估,只能揭示他不要写死代码,目前 axios
还没入选开源库,前期有可能换成其余替代品。
会后我专门到网上搜一番,发现二次封装 axios
的案例的确不少,但给我感觉其实都一丘之貉,不见得哪个更优良。
过后咱们刚从 Java 切换到 Go,因为 Go 对于 swagger 反对不够好,前后端对接的接口文档须要手写。
有时候后端批改了接口没有告诉前端,常常遇到互相扯皮的事件。
我突发奇想,既然 Go 对注解、装璜器的反对很不好,前端的 typescript
语法跟 Java
十分相似,为什么不把 Java 那套照搬到前端?
不仅能解决前端接口封装的问题,还能躲避 go 不反对 swagger 文档的问题。
useResource:申明式 API
说干就干,我参考 Open Feign
的设计,Feign
的设计很大水平上借鉴了 Spring MVC
。
只是 Feign
次要面向客户端,而 Spring MVC
面向服务端,两者的注解大同小异,Feign 兼容后者而已。
interface GitHub {@RequestLine("GET /repos/{owner}/{repo}/contributors")
List<Contributor> contributors(@Param("owner") String owner, @Param("repo") String repo);
@RequestLine("POST /repos/{owner}/{repo}/issues")
void createIssue(Issue issue, @Param("owner") String owner, @Param("repo") String repo);
}
显然这种申明式 API 的设计,比那些二次封装 axios
的计划优雅太多了,真正做到形象接口与具体实现拆散。
申明式 API 能够不改变业务代码的前提下,依据理论状况把具体实现在原生 fetch
和 axios
之间切换。
装璜器
其实说照搬 Java 的说法是不正确的,Typescript 只有装璜器的说法,并没有注解。
而且两者差异还挺大的,Java 是先定义注解 Annotation
,而后在运行时通过反射取得注解的元数据 metadata
;
然而装璜器 Decorator
的做法就十分间接白,间接一次性把所有的事件做完了。
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTION"
export interface Exchange {(...args: any[]): Promise<Response>
}
export interface Operation {
method?: Method
path?: string
headers?: Record<string, string>
pathVariables: {name: string, order: number}[]
requestBody?: {order: number, encode: (body: any) => BodyInit}
}
export interface Operations {[key: string]: Operation
}
export interface Resource {
exchange: Exchange
endpoint?: string
resourceName?: string
headers?: Record<string, string>
operations?: Operations
}
export const RESTfulHeader: Record<string, string> = {"Content-Type": "application/json"}
export function RESTful(endpoint: string, resource?: string, headers?: Record<string, string>) {return function<T extends { new (...args: any[]): Resource}>(target: T) {
return class extends target {constructor(...args: any[]) {super(...args)
this.endpoint = endpoint
this.resourceName = resource
this.headers = headers ? {...headers, ...RESTfulHeader} : {...RESTfulHeader}
}
}
}
}
export function RequestMapping(method: Method, path: string, headers?: Record<string, string>) {return function(target: Resource, methodName: string, descriptor: PropertyDescriptor) {if (!target.operations) {target.operations = {}
}
const op = target.operations[methodName] ?? {pathVariables: []}
op.method = method
op.path = path
op.headers = headers
target.operations[methodName] = op
}
}
export function Get(path: string, headers?: Record<string, string>) {return RequestMapping("GET", path, headers)
}
export function Post(path: string, headers?: Record<string, string>) {return RequestMapping("POST", path, headers)
}
export function Put(path: string, headers?: Record<string, string>) {return RequestMapping("PUT", path, headers)
}
export function Patch(path: string, headers?: Record<string, string>) {return RequestMapping("PATCH", path, headers)
}
export function Delete(path: string, headers?: Record<string, string>) {return RequestMapping("DELETE", path, headers)
}
export function Option(path: string, headers?: Record<string, string>) {return RequestMapping("OPTION", path, headers)
}
export function PathVariable(name: string) {return function(target: Resource, propertyKey: string | symbol, parameterIndex: number) {if (!target.operations) {target.operations = {}
}
const methodName = String(propertyKey)
const op = target.operations[methodName] ?? {pathVariables: []}
const pv = {name: name, order: parameterIndex}
op.pathVariables.push(pv)
target.operations[methodName] = op
}
}
export const PV = PathVariable
export interface Encoder<T> {(src: T): BodyInit
}
export function RequestBody<T>(encoder: Encoder<T>) {return function(target: Resource, propertyKey: string | symbol, parameterIndex: number) {if (!target.operations) {target.operations = {}
}
const methodName = String(propertyKey)
const op = target.operations[methodName] ?? {pathVariables: []}
op.requestBody = {order: parameterIndex, encode: encoder}
target.operations[methodName] = op
}
}
export function JSONBody() {return RequestBody<Object>(JSON.stringify)
}
export function PlainBody() {return RequestBody<Object>(String)
}
export function FileBody() {return RequestBody<Blob>((src) => src)
}
然而我在实现的过程,还是保持把这个过程给解耦了,装璜器只是单纯地把元数据保留到指标的 Resource
中。
useResource
接下来就是把保留在 Resource
的元数据读取进去,而后把 exchange
函数替换掉。
import {Delete, Exchange, Get, JSONBody, PV, Post, Put, RESTful, Resource} from "../annotations/restful"
import {useIoC} from "./ioc"
export interface Provider<T extends Resource> {(exchange: Exchange): T
}
export interface RequestInterceptor {(req: RequestInit): RequestInit
}
export interface ResponseInterceptor {(res: Response): Response
}
const globalRequestInterceptor: RequestInterceptor[] = []
const globalResponseInterceptor: ResponseInterceptor[] = []
export function addRequestInterceptor(interceptor: RequestInterceptor) {globalRequestInterceptor.push(interceptor)
}
export function addResponseInterceptor(interceptor: ResponseInterceptor) {globalResponseInterceptor.push(interceptor)
}
export function useResource<T extends Resource>(provider: (exchange: Exchange) => T): T {const context = useIoC()
const exchange = context.inject(DefaultExchange)
const sub = context.inject(provider)
const resource = sub(exchange)
invoke(resource, resource)
return resource
}
function DefaultExchange(...args: any[]) {return Promise.resolve(new Response("{}"))
}
function invoke<T extends Resource>(resource: T, top: T) {const proto = Object.getPrototypeOf(resource)
if (!proto) {return}
invoke(proto, top)
const props = Object.getOwnPropertyDescriptors(resource)
for (const key in props) {const prop = props[key].value
if (typeof prop == "function") {const exchange = sendRequest(key, resource, top)
if (exchange) {const replace = prop.bind({...resource, exchange: exchange})
const map = new Map([[key, replace]])
Object.assign(resource, Object.fromEntries(map.entries()))
}
}
}
}
function sendRequest<T>(methodName: string, res: Resource, top: Resource): Exchange | undefined {if (!res.operations) {return}
const op = res.operations[methodName]
if (!op) {return}
const headers = top.headers ?? {}
const opHeaders = op.headers ?? {}
return async (...args: any[]) => {
let path = op.path
if (path && op.pathVariables) {for (const pv of op.pathVariables) {path = path.replace("{" + pv.name + "}", String(args[pv.order]))
}
}
const url = `${top.endpoint}/${top.resourceName}/${path}`
let request: RequestInit = {
method: op.method,
headers: {...headers, ...opHeaders}
}
if (op.requestBody) {
const order = op.requestBody.order
request.body = op.requestBody.encode(args[order])
}
try {for (const interceptor of globalRequestInterceptor) {request = interceptor(request)
}
let response = await fetch(url, request)
for (const interceptor of globalResponseInterceptor) {response = interceptor(response)
}
return Promise.resolve(response)
} catch (e) {return Promise.reject(e)
}
}
}
一时间看不懂所有代码实现也没关系,能够先看看怎么应用:
先编写一个实现增删改查的基类 CURD<T>
,T
由子类决定,再继承基类编写 UserResource
import {Delete, Exchange, Get, JSONBody, PV, Post, Put, RESTful, Resource} from "../annotations/restful"
@RESTful("example.com", "resource")
export class CURD<T> implements Resource {
exchange: Exchange
constructor(exchange: Exchange) {this.exchange = exchange}
@Get("?page={page}&pageSize={pageSize}")
async list(@PV("page") page?: number, @PV("pageSize") pageSize?: number): Promise<T[]> {return (await this.exchange(page ?? 1, pageSize ?? 10)).json()}
@Post("")
async create(@JSONBody() t: T): Promise<Response> {return this.exchange(t)
}
@Get("{id}")
async get(@PV("id") id: string): Promise<T> {return (await this.exchange(id)).json()}
@Put("{id}")
async update(@PV("id") id: string, @JSONBody() t: T): Promise<Response> {return this.exchange(id, t)
}
@Delete("{id}")
async delete(@PV("id") id: string): Promise<Response> {return this.exchange(id)
}
}
export interface User {
username: string
password: string
role: string[]}
@RESTful("localhost", "users")
export class UserResource extends CURD<User> {
}
export function UserResourceProvider(exchange: Exchange): UserResource {return new UserResource(exchange)
}
接着,通过注入 UserResourceProvider
取得 UserResource
的实例,最初通过实例办法调用后端的接口:
const userRes = useResource(UserResourceProvider)
userRes.list().then(console.info)
const user = {username: "", password:"", role: []}
userRes.get('1').then(console.info)
userRes.create(user).then(console.info)
userRes.update('1', user).then(console.info)
userRes.delete('1').then(console.info)
拦截器
给每个 request 设置 token
addRequestInterceptor((req) => {const authToken = {}
if (req.headers) {const headers = new Headers()
headers.append("Authorization", "bear:xxxxx")
if (req.headers instanceof Array) {for (const h of req.headers) {headers.append(h[0], h[1])
}
}
req.headers = headers
}
req.headers = authToken
return req
})
useMock:基于依赖注入的 mock 工具
组内的成员都是搞前端开发的老手,不晓得如何 mock
后端接口。
我想起以前从没有为这件事件发过愁,起因是后端接口都接入 swagger/openapi
,能够间接生成 mock server。
只是后端切换到 Go 当前,他们不晓得该如何接入 swagger
,只能每个人都在本地保护一套 mock server。
要害是他们都放心 mock 代码会影响到生产环境,所以都没有提交代码仓库。
后果遇到某个问题须要定位,还得一个个找他们要 mock 数据。
当初有了依赖注入,要实现 mock 性能几乎不要太容易,几行代码就封装一个 useMock
:
import {Resource} from "../annotations/restful";
import {useIoC} from "./ioc";
import {Provider} from "./resource";
export function useMock<T extends Resource>(provider: Provider<T>, sub: Provider<T>) {const context = useIoC()
context.define(provider, sub)
}
mockServer
对于曾经在应用 mock Server
的接口,能够继承派生出一个子类:XXXResourceForMock
,
而后通过 RESTful
设置新的 endpoint
和 resource
,就能够就把申请转发到指定的服务器。
useMock(UserResourceProvider, (exchange: Exchange) => {@RESTful("http://mock-server:8080/backend", "users")
class UserResourceForMock extends UserResource { }
return new UserResourceForMock(exchange)
})
如果遇到问题,仔细观察 endpoint 是否为绝对路径,以及是否蕴含 http://
mockOperation
如果 mock server
返回后果无奈满足需要,能够独自 mock
某个办法,能够依据理论需要返回特定的后果。
useMock(UserResourceProvider, (exchange: Exchange) => {@RESTful("http://mock-server:8080/backend", "users")
class UserResourceForMock extends UserResource {async list(page: number, pageSize: number): Promise<User[]> {return Promise.resolve([])
}
async create(user: User): Promise<Response> {return Promise.resolve(new Response("{}"))
}
async get(id: string): Promise<User> {return Promise.resolve({username: "", password:"", role: []})
}
async update(id: string, user: User): Promise<Response> {return Promise.resolve(new Response("{}"))
}
async delete(id: string): Promise<Response> {return Promise.resolve(new Response("{}"))
}
}
return new UserResourceForMock(exchange)
})
pure_func
为了避免以上 mock
操作一不小心影响到生产环境,能够定义一个 developMockOnly
函数:
// 只用于开发环境的 mock 操作
function developMockOnly() {}
把所有的 mock
操作都放到下面的函数外部,而后批改生产环境的 webpack
配置:
{
minimizer: [
new TerserPlugin({
terserOptions: {
extractComments: 'all',
compress: {pure_funcs: ['console.info', 'developMockOnly']
},
}
}),
]
}
把 developMockOnly
加到 pure_funcs
数组中。
这样即使把 mock
操作提交到骨干分支,也不会呈现开发环境的 mock 操作不会影响到生产环境的问题。
总结
以上代码早在半年前就曾经写好,奈何公司的窃密措施十分严格,没有方法把代码带进去。
进去之后,想从新实现一遍的想法在脑海中酝酿许久,终于在上周末花了一天的工夫就写进去大部分代码。
而后又额定花了一天工夫,解决一些潜在的问题,而后写了本文分享给大家,心愿大家都能从中受到启发。