动态

详情 返回 返回

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操作不會影響到生產環境的問題。

總結

以上代碼早在半年前就已經寫好,奈何公司的保密措施非常嚴格,沒有辦法把代碼帶出來。

出來之後,想重新實現一遍的想法在腦海中醖釀許久,終於在上週末花了一天的時間就寫出來大部分代碼。

然後又額外花了一天時間,解決一些潛在的問題,然後寫了本文分享給大家,希望大家都能從中受到啓發。

Add a new 评论

Some HTML is okay.