是否有一种方式,从底向上的,针对不同的请求内核可以很方便的为其赋予请求库拦截器、中间件、快捷请求等几个通用功能,并且保留不同请求内核的差异化?

前言

最近想写一个可以适配多平台的请求库,在研究 xhr 和 fetch 发现二者的参数、响应、回调函数等差别很大。想到如果请求库想要适配多平台,需要统一的传参和响应格式,那么势必会在请求库内部做大量的判断,这样不但费时费力,还会屏蔽掉底层请求内核差异。

阅读 axios 和 umi-request 源码时想到,请求库其实基本都包含了拦截器、中间件和快捷请求等几个通用的,与具体请求过程无关的功能。然后通过传参,让用户接触底层请求内核。问题在于,请求库内置多个底层请求内核,内核支持的参数是不一样的,上层库可能做一些处理,抹平一些参数的差异化,但对于底层内核的特有的功能,要么放弃,要么只能在参数列表中加入一些具体内核的特有的参数。比如在 axios 中,它的请求配置参数列表中,罗列了一些 browser only的参数,那对于只需要在 node 环境中运行的 axios 来说,参数多少有些冗余,并且如果 axios 要支持其他请求内核(比如小程序、快应用、华为鸿蒙等),那么参数冗余也将越来越大,扩展性也差。

换个思路来想,既然实现一个适配多平台的统一的请求库有这些问题,那么是否可以从底向上的,针对不同的请求内核,提供一种方式可以很方便的为其赋予拦截器、中间件、快捷请求等几个通用功能,并且保留不同请求内核的差异化?

设计实现

我们的请求库要想与请求内核无关,那么只能采用内核与请求库相分离的模式。使用时,需要将请求内核传入,初始化一个实例,再进行使用。或者基于请求库,传入内核,预置请求参数来进行二次封装。

基本架构

首先实现一个基本的架构

class PreQuest {
    constructor(private adapter)
    
    request(opt) {
        return this.adapter(opt)
    }
}

const adapter = (opt) => nativeRequestApi(opt)
// eg: const adapter = (opt) => fetch(opt).then(res => res.json())

// 创建实例
const prequest = new PreQuest(adapter)

// 这里实际调用的是 adapter 函数
prequest.request({ url: 'http://localhost:3000/api' })

可以看到,这里饶了个弯,通过实例方法调用了 adapter 函数。

这样的话,为修改请求和响应提供了想象空间。

class PreQuest {
    // ...some code
    
    async request(opt){
        const options = modifyReqOpt(opt)
        const res = await this.adapter(options)
        return modifyRes(res)
    }

    // ...some code
}

中间件

可以采用 koa 的洋葱模型,对请求进行拦截和修改。

中间件调用示例:

const prequest = new PreQuest(adapter)

prequest.use(async (ctx, next) => {
    ctx.request.path = '/perfix' + ctx.request.path
    await next()
    ctx.response.body = JSON.parse(ctx.response.body)
})

实现中间件基本模型?

class Middleware {
    // 中间件列表
    cbs = []
    
    // 注册中间件
    use(cb) {
       this.cbs.push(cb)
       return this
    }
    
    // 执行中间件
    exec(ctx, next){

        let times = -1
    
        const dispatch = (pointer = 0): Promise<any> => {
            if (cbs.length < pointer) return Promise.resolve()

            const fn = this.cbs[pointer] || next

            if (pointer <= times) throw new Error('next function only can be called once')
             // 确保每个中间价中 next 方法只调用一次
             times = pointer

            // 洋葱模型
             return fn(ctx, () => dispatch(++pointer))
        }

        return dispatch()
    }
}

全局中间件,只需要添加一个 use 和 exec 的静态方法即可。

PreQuest 继承自 Middleware 类,即可在实例上注册中间件。

那么怎么在请求前调用中间件?

class PreQuest extends Middleware {
    // ...some code
     
    async request(opt) {
    
        const ctx = {
            request: opt,
            response: {}
        }
        
        // 执行中间件
        async this.exec(ctx, async (ctx) => {
            ctx.response = await this.adapter(ctx.request)
        })
        
        return ctx.response
    }
        
    // ...some code
}

中间件模型中,前一个中间件的返回值是传不到下一个中间件中,所以是通过一个对象在中间件中传递和赋值。

拦截器

拦截器是修改参数和响应的另一种方式。

首先看一下 axios 中拦截器是怎么用的。

import axios from 'axios'

const instance = axios.create()

instance.interceptor.request.use(
    (opt) => modifyOpt(opt),
    (e) => handleError(e)
)

根据用法,我们可以实现一个基本结构

class Interceptor {
    cbs = []
    
    // 注册拦截器
    use(successHandler, errorHandler) {
        this.cbs.push({ successHandler, errorHandler })
    }
    
    exec(opt) {
      return this.cbs.reduce(
        (t, c, idx) => t.then(c.successHandler, this.handles[idx - 1]?.errorHandler),
        Promise.resolve(opt)
      )
      .catch(this.handles[this.handles.length - 1].errorHandler)
    }
}

代码很简单,有点难度的就是拦截器的执行了。这里主要有两个知识点: Array.reduce 和 Promise.then 第二个参数的使用。

注册拦截器时,successHandlererrorHandler 是成对的, successHandler 中抛出的错误,要在对应的 errorHandler 中处理,所以 errorHandler 接收到的错误,是上一个拦截器中抛出的。

拦截器怎么使用呢?

class PreQuest {
    // ... some code
    interceptor = {
        request: new Interceptor()
        response: new Interceptor()
    }
    
    // ...some code
    
    async request(opt){
        
        // 执行拦截器,修改请求参数
        const options = await this.interceptor.request.exec(opt)
        
        const res = await this.adapter(options)
        
        // 执行拦截器,修改响应数据
        const response = await this.interceptor.response.exec(res)
        
        return response
    }
    
}

拦截器中间件

拦截器也可以是一个中间件,可以通过注册中间件来实现。请求拦截器在 await next() 前执行,响应拦截器在其后。

const instance = new Middleware()

instance.use(async (ctx, next) => {
    // Promise 链式调用,更改请求参数
    await Promise.resolve().then(reqInterceptor1).then(reqInterceptor2)...
    // 执行下一个中间件、或执行到 this.adapter 函数
    await next()
    // Promise 链式调用,更改响应数据
    await Promise.resolve().then(resInterceptor1).then(resInterceptor2)...
})

拦截器有请求拦截器和响应拦截器两类。

class InterceptorMiddleware {
    request = new Interceptor()
    response = new Interceptor()
    
    // 注册中间件
    register: async (ctx, next) {
        ctx.request = await this.request.exec(ctx.request)
        await next()
        ctx.response = await thie.response.exec(ctx.response)
    }
}

使用

const instance = new Middleware()
const interceptor = new InterceptorMiddleware()

// 注册拦截器
interceptor.request.use(
    (opt) => modifyOpt(opt),
    (e) => handleError(e)
)

// 注册到中间中
instance.use(interceptor.register)

类型请求

这里我把类似 instance.get('/api') 这样的请求叫做类型请求。库中集成类型请求的话,难免会对外部传入的adapter 函数的参数进行污染。因为需要为请求方式 get 和路径 /api 分配键名,并且将其混入到参数中,通常在中间件中会有修改路径的需求。

实现很简单,只需要遍历 HTTP 请求类型,并将其挂在 this 下即可

class PreQuest {
    constructor(private adapter) {
        this.mount()
    }
    
    // 挂载所有类型的别名请求
    mount() {
       methods.forEach(method => {
           this[method] = (path, opt) => {
             // 混入 path 和 method 参数
             return this.request({ path, method, ...opt })
           }
       })
    }
    
    // ...some code

    request(opt) {
        // ...some code
    }
}

简单请求

axios 中,可以直接使用下面这种形式进行调用

axios('http://localhost:3000/api').then(res => console.log(res))

我将这种请求方式称之为简单请求。

我们这里怎么实现这种写法的请求方式呢?

不使用 class ,使用传统函数类写法的话比较好实现,只需要判断函数是否是 new 调用,然后在函数内部执行不同的逻辑即可。

demo 如下

function PreQuest() {
    if(!(this instanceof PreQuest)) {
        console.log('不是new 调用')
        return // ...some code
    }
   
   console.log('new调用') 
   
   //... some code
}

// new 调用
const instance = new PreQuest(adapter)
instance.get('/api').then(res => console.log(res))

// 简单调用
PreQuest('/api').then(res => console.log(res))

class 写法的话,不能进行函数调用。我们可以在 class 实例上做文章。

首先初始化一个实例,看一下用法

const prequest = new PreQuest(adapter)

prequest.get('http://localhost:3000/api')

prequest('http://localhost:3000/api')

通过 new 实例化出来的是一个对象,对象是不能够当做函数来执行,所以不能用 new 的形式来创建对象。

再看一下 axios 中生成实例的方法 axios.create, 可以从中得到灵感,如果 .create 方法返回的是一个函数,函数上挂上了所有 new 出来对象上的方法,这样的话,就可以实现我们的需求。

简单设计一下:

方式一: 拷贝原型上的方法

class PreQuest {

    static create(adapter) {
        const instance = new PreQuest(adapter)
        
        function inner(opt) {
           return instance.request(opt)
        }
        
        for(let key in instance) {
            inner[key] = instance[key]
        }
        
        return inner
    }
}

注意: 实测发现某些版本的 es 中,for in 循环遍历不出 class 生成实例原型上的方法。

方法二: 原型继承

class PreQuest {

    static create(adapter) {
        const instance = new PreQuest(adapter)
        
        function request(...rest) {
           return instance.request(...rest)
        }
        
        Reflect.setPrototypeOf(request, instance)
        
        return request
    }
}

将 PreQuest 实例当做 request 方法的原型

方式三: 使用 Proxy 代理一个空函数,来劫持访问。

class PreQuest {
    
    // ...some code

    static create(adapter) {
        const instance = new PreQuest(adapter)
       
        return new Proxy(function (){}, {
          get(_, name) {
            return Reflect.get(instance, name)
          },
          apply(_, __, args) {
            return Reflect.apply(instance.request, instance, args)
          },
        })
    }
}

上面两种方法的缺点在于,通过 create 方法返回的将不再是 PreQuest 的实例,即

const prequest = PreQuest.create(adapter)

prequest instanceof PreQuest  // false

个人目前还没有想到,判断 prequest 是不是 PreQuest 实例有什么用,并且也没有想到好的解决办法。有解决方案的请在评论里告诉我。

使用 .create 创建 '实例' 的方式可能不符合直觉,我们还可以通过 Proxy 劫持 new 操作。

Demo如下:

class InnerPreQuest {
  create() {
     // ...some code
  }
}

const PreQuest = new Proxy(InnerPreQuest, {
    construct(_, args) {
        return () => InnerPreQuest.create(...args)
    }
})

请求锁

如何实现在请求接口前,先拿到 token 再去请求?

下面的例子中,页面同时发起多个请求

const prequest = PreQuest.create(adapter)

prequest('/api/1').catch(e => e)     // auth fail
prequest('/api/2').catch(e => e)    // auth fail
prequest('/api/3').catch(e => e)    // auth fail

首先很容易想到,我们可以使用中间件为其添加 token

prequest.use(async (ctx, next) => {
    ctx.request.headers['Authorization'] = `bearer ${token}`
    await next()
})

但 token 值从何而来?token 需要请求接口得来,并且需要重新创建请求实例,以避免重新走添加 token 的中间件的逻辑。

简单实现一下

const tokenRequest = PreQuest.create(adapter)

let token = null
prequest.use(async (ctx, next) => {
    if(!token) {
        token = await tokenRequest('/token')
    }
    ctx.request.headers['Authorization'] = `bearer ${token}`
    await next()
})

这里使用了 token 变量,来避免每次请求接口,都去调接口拿 token。

代码乍一看没有问题,但仔细一想,当同时请求多个接口,tokenRequest 请求还没有得到响应时,后面的请求又都走到这个中间件,此时 token 值为空,会造成多次调用 tokenRequest。那么如何解决这个问题?

很容易想到,可以加个锁机制来实现

let token = null
let pending = false
prequest.use(async (ctx, next) => {
    if(!token) {
        if(pending) return
        pending = true
        token = await tokenRequest('/token')
        pending = flase
    }
    ctx.request.headers['Authorization'] = `bearer ${token}`
    await next()
})

这里我们加了 pending 来判断 tokenRequest 的执行,成功解决了 tokenRequest 执行多次的问题,但又引入了新的问题,在执行 tokenRequest 时,后面到来的请求应当怎么处理?上面的代码,直接 return 掉了,请求将被丢弃。实际上,我们希望,请求可以在这里暂停,当拿到 token 时,再请求后面的中间件。

暂停,我们也可以很容想到使用 async、await 或者 promise 来实现。但在这里如何用呢?

我从 axios 的 cancelToken 实现中得到了灵感。axios 中,利用 promise 简单实现了一个状态机,将 Promise 中的 resolve 赋值到外部局部变量,实现对 promise 流程的控制。

简单实现一下

let token = null
let pending = false
let resolvePromise
let promise = new Promise((resolve) => resolvePromise = resolve)

prequest.use(async (ctx, next) => {
    if(!token) {
        if(pending) {
            // promise 控制流程
            token = await promise
        } else {
            pending = true
            token = await tokenRequest('/token')
            // 调用 resolve,使得 promise 可以执行剩余的流程
            resolvePromise(token)
            pending = flase
        }
    } 

    ctx.request.headers['Authorization'] = `bearer ${token}`
    await next()
})

当执行 tokenRequest 时,其余请求的接口,都会进入到一个 promise 控制的流程中,当 token 得到后,通过外部 resolve, 控制 promise 继续执行,以此设置请求头,和执行剩余中间件。

这种方式虽然实现了需求,但代码丑陋不美观。

我们可以将状态都封装到一个函数中。以实现类似下面这种调用。这样的调用符合直觉且美观。

prequest.use(async (ctx, next) => {
  const token = await wrapper(tokenRequest)
  ctx.request.headers['Authorization'] = `bearer ${token}`
  await next()
})

怎么实现这样一个 wrapper 函数?

首先,状态不能封装到 wrapper 函数中,否则每次都会生成新的状态,wrapper 将形同虚设。可以使用闭包函数将状态保存。

function createWrapper() {
    let token = null
    let pending = false
    let resolvePromise
    let promise = new Promise((resolve) => resolvePromise = resolve)
    return function (fn) {
        if(pending) return promise
        if(token) return token

        pending = true

        token = await fn()

        pending = false
        resolvePromise(token)

        return token
    }
}

使用时,只需要利用 createWrapper 生成一个 wrapper 即可

const wrapper = createWrapper()

prequest.use(async (ctx, next) => {
  const token = await wrapper(tokenRequest)
  ctx.request.headers['Authorization'] = `bearer ${token}`
  await next()
})

这样的话,就可以实现我们的目的。

但,这里的代码还有问题,状态封装在 createWrapper 内部,当 token 失效后,我们将无从处理。

比较好的做法是,将状态从 createWrapper 参数中传入。

代码实现,请参考这里

实战

以微信小程序为例。小程序中自带的 wx.request 并不好用。使用上面我们封装的代码,可以很容易的打造出一个小程序请求库。

封装小程序原生请求

将原生小程序请求 Promise 化,设计传参 opt 对象

function adapter(opt) {
  const { path, method, baseURL, ...options } = opt
  const url = baseURL + path
  return new Promise((resolve, reject) => {
    wx.request({
      ...options,
      url,
      method,
      success: resolve,
      fail: reject,
    })
  })
}

调用

const instance = PreQuest.create(adapter)

// 中间件模式
instance.use(async (ctx, next) => {
    // 修改请求参数
    ctx.request.path = '/prefix' + ctx.request.path
    
    await next()
    
    // 修改响应
    ctx.response.body = JSON.parse(ctx.response.body)
})

// 拦截器模式
instance.interecptor.request.use(
    (opt) => {
        opt.path = '/prefix' + opt.path
        return opt
    }
)

instance.request({ path: '/api', baseURL: 'http://localhost:3000' })

instance.get('http://localhost:3000/api')

instance.post('/api', { baseURL: 'http://loclahost:3000' })

获取原生请求实例

首先看一下在小程序中怎样中断请求

const request = wx.request({
    // ...some code
})

request.abort()

使用我们封装的这一层,将拿不到原生请求实例。

那么怎么办呢?我们可以从传参中入手

function adapter(opt) {
    const { getNativeRequestInstance } = opt

    return new Promise(() => {
        const nativeInstance = wx.request(
           // some code
        )
        
        getNativeRequestInstance(nativeInstance)
    })
}

用法如下:

const instance = PreQuest.create(adapter)

instance.post('http://localhost:3000/api', {
    getNativeRequestInstance(instance) {
        instance.abort()
    }
})

需要注意的是:因为 wx.request 的执行是在 n 个中间件、拦截器之后执行的,里面存在大量异步任务,所以通过上面拿到的 instance 只能在异步中执行。

兼容多平台小程序

查看了几个小程序平台和快应用,发现请求方式都是小程序的那一套,那其实我们完全可以将 wx.request 拿出来,创建实例的时候再传进去。

结语

上面的内容中,我们基本实现了一个与请求内核无关的请求库,并且设计了两种拦截请求和响应的方式,我们可以根据自己的需求和喜好自由选择。

这种内核装卸的方式非常容易扩展。当面对一个 axios 不支持的平台时,也不用费劲的去找开源好用的请求库了。我相信很多人在开发小程序的时候,基本都有去找 axios-miniprogram 的解决方案。通过我们的 PreQuest 项目,可以体验到类似 axios 的能力。

PreQuest 项目中,除了上面提到的内容,还提供了全局配置、全局中间件、别名请求等功能。项目中也有基于 PreQuest 封装的请求库,@prequest/miniprogram,@prequest/fetch...也针对一些使用原生 xhr、fetch 等 API 的项目,提供了一种不侵入的方式来赋予 PreQuest的能力 @prequest/wrapper

参考

axios: https://github.com/axios/axios

umi-request:https://github.com/umijs/umi-request