乐趣区

关于axios:useResource声明式API与useMock基于依赖注入的mock工具

前不久组内的萌新用不晓得从哪里学来的技术,说要封装一套 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 能够不改变业务代码的前提下,依据理论状况把具体实现在原生 fetchaxios 之间切换。

装璜器

其实说照搬 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 设置新的 endpointresource,就能够就把申请转发到指定的服务器。

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 操作不会影响到生产环境的问题。

总结

以上代码早在半年前就曾经写好,奈何公司的窃密措施十分严格,没有方法把代码带进去。

进去之后,想从新实现一遍的想法在脑海中酝酿许久,终于在上周末花了一天的工夫就写进去大部分代码。

而后又额定花了一天工夫,解决一些潜在的问题,而后写了本文分享给大家,心愿大家都能从中受到启发。

退出移动版