前不久组内的萌新用不晓得从哪里学来的技术,说要封装一套 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 = PathVariableexport 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操作不会影响到生产环境的问题。

总结

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

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

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