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

总结

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

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

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

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理