preface
Recently, I wanted to write a request library that can adapt to multiple platforms. After studying XHR and FETCH, I found that the parameters, response and callback function of the two are very different. If the request library is to adapt to multiple platforms and needs a uniform format of input and response, then it must do a lot of judgment inside the request library, which is not only time-consuming and laborious, but also hides the underlying request kernel differences.
When reading the source code of AXIos and UMi-Request, I realized that the request library basically contains several common functions, such as interceptors, middleware and quick requests, which are independent of the specific request process. The user is then exposed to the underlying requesting kernel by passing parameters. The problem is that the request library has multiple low-level request kernels built in, and the parameters supported by the kernels are not the same. The superlibrary may do some processing to smooth out some parameter differences, but for the low-level kernel-specific functions, it either gives up or only adds some kernel-specific parameters to the parameter list. In AXIos, for example, the request configuration parameter list lists browser Only parameters, which are somewhat redundant for Axios that only needs to run in a Node environment. And if AXIOS were to support other requesting kernels (e.g., applets, fast applications, Huawei Hongmo, etc.), there would be more and more parameter redundancy and poor scalability.
Change the way of thinking to think, now that request in realization of unification of an adapter multi-platform library have these problems, so whether can you from the bottom up, according to different requests the kernel, provides a way can be very convenient for the interceptor, middleware, quick request a few common functions, such as different requests and retain the difference of the kernel?
Designed and implemented
In order for our request library to be independent of the request kernel, we can only adopt the mode of separating the kernel from the request library. To use it, you need to pass in the request kernel, initialize an instance, and then use it. Or based on our request library, pass in the kernel, preset request parameters for secondary encapsulation.
Basic architecture
Start by implementing a basic architecture
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())
// Create an instance
const prequest = new PreQuest(adapter)
// The adapter function is actually called here
prequest.request({ url: 'http://localhost:3000/api' })
Copy the code
As you can see, the adapter function is called through the instance method.
This leaves room for imagination to modify the request and response.
class PreQuest {
/ /... some code
async request(opt){
const options = modifyReqOpt(opt)
const res = await this.adapter(options)
return modifyRes(res)
}
/ /... some code
}
Copy the code
The middleware
Koa’s Onion model can be used to intercept and modify requests.
Examples of middleware calls:
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)
})
Copy the code
Implement the basic middleware model?
const compose = require('koa-compose')
class Middleware {
// Middleware list
cbs = []
// Register middleware
use(cb) {
this.cbs.push(cb)
return this
}
// Execute middleware
exec(ctx, next){
Middleware implementation details are not important, so use the KOa-compose library directly
return compose(this.cbs)(ctx, next)
}
}
Copy the code
Global middleware, just add a static method of use and exec.
PreQuest inherits from the Middleware class to register Middleware on instances.
So how do you call middleware before a request?
class PreQuest extends Middleware {
/ /... some code
async request(opt) {
const ctx = {
request: opt,
response: {}}// Execute middleware
async this.exec(ctx, async (ctx) => {
ctx.response = await this.adapter(ctx.request)
})
return ctx.response
}
/ /... some code
}
Copy the code
In the middleware model, the return value of the previous middleware is not transmitted to the next middleware, so it is passed and assigned by an object in the middleware.
The interceptor
Interceptors are another way to modify parameters and responses.
First take a look at how interceptors are used in Axios.
import axios from 'axios'
const instance = axios.create()
instance.interceptor.request.use(
(opt) = > modifyOpt(opt),
(e) = > handleError(e)
)
Copy the code
Depending on usage, we can implement a basic structure
class Interceptor {
cbs = []
// Register interceptors
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)
}
}
Copy the code
The code is simple, but the execution of the interceptor is a bit more difficult. There are two main points: array. reduce and promise. then the use of the second parameter.
When registering an interceptor, successHandler is paired with errorHandler. Errors thrown from successHandler are handled in the corresponding errorHandler, so errors received by errorHandler, Is thrown in the last interceptor.
How does the interceptor work?
class PreQuest {
// ... some code
interceptor = {
request: new Interceptor()
response: new Interceptor()
}
/ /... some code
async request(opt){
// Execute interceptor to modify request parameters
const options = await this.interceptor.request.exec(opt)
const res = await this.adapter(options)
// Execute interceptor to modify response data
const response = await this.interceptor.response.exec(res)
return response
}
}
Copy the code
Interceptor middleware
An interceptor can also be a middleware, which can be implemented by registering middleware. The request interceptor is executed before the await next() and the response interceptor after.
const instance = new Middleware()
instance.use(async (ctx, next) => {
// Promise makes a chain call to change the request parameters
await Promise.resolve().then(reqInterceptor1).then(reqInterceptor2)...
// Execute the next middleware, or the this.adapter function
await next()
// Promise is a chain call that changes the response data
await Promise.resolve().then(resInterceptor1).then(resInterceptor2)...
})
Copy the code
There are two types of interceptors: request interceptor and response interceptor.
class InterceptorMiddleware {
request = new Interceptor()
response = new Interceptor()
// Register middleware
register: async (ctx, next) {
ctx.request = await this.request.exec(ctx.request)
await next()
ctx.response = await thie.response.exec(ctx.response)
}
}
Copy the code
use
const instance = new Middleware()
const interceptor = new InterceptorMiddleware()
// Register interceptors
interceptor.request.use(
(opt) = > modifyOpt(opt),
(e) = > handleError(e)
)
// Register in the middle
instance.use(interceptor.register)
Copy the code
Type a request
Here I call requests like instance.get(‘/ API ‘) type requests. Integrating type requests into the library will inevitably contaminate the parameters of the External Adapter function. Because you need to assign key names to get and path/API requests and mix them into parameters, there is often a need to modify paths in middleware.
The implementation is simple, just iterate over the HTTP request type and hang it under this
class PreQuest {
constructor(private adapter) {
this.mount()
}
// Mount all types of alias requests
mount() {
methods.forEach(method= > {
this[method] = (path, opt) = > {
// Mix in the path and method arguments
return this.request({ path, method, ... opt }) } }) }/ /... some code
request(opt) {
/ /... some code}}Copy the code
A simple request
In AXIos, the call can be made directly using the following form
axios('http://localhost:3000/api').then(res= > console.log(res))
Copy the code
I call this a simple request.
How are we going to implement this request here?
Instead of using class, it is easier to write a function class in the traditional way. You just need to check whether the function is a new call and then execute a different logic inside the function.
The demo is as follows
function PreQuest() {
if(! (this instanceof PreQuest)) {
console.log('Not a new call')
return / /... some code
}
console.log('the new call')
/ /... some code
}
/ / the new call
const instance = new PreQuest(adapter)
instance.get('/api').then(res= > console.log(res))
// Simple call
PreQuest('/api').then(res= > console.log(res))
Copy the code
Class does not allow function calls. We can work with class instances.
First, initialize an instance to see how it works
const prequest = new PreQuest(adapter)
prequest.get('http://localhost:3000/api')
prequest('http://localhost:3000/api')
Copy the code
New instantiates an object. Objects cannot be executed as functions, so new cannot be used to create objects.
If the.create method returns a function that hangs all the methods on the new object, then we can do what we want.
Here’s a simple design:
Method 1: Copy the method from the prototype
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
}
}
Copy the code
Note: In some versions of ES,for in
The loop does not iterate over the method on the class generation instance prototype.
Method 2: You can also use Proxy to Proxy an empty function to hijack access.
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)
},
})
}
}
Copy the code
The disadvantage of the above two methods is that the create method will no longer return an instance of PreQuest, i.e
const prequest = PreQuest.create(adapter)
prequest instanceof PreQuest // false
Copy the code
I have not yet thought of the use of determining whether prequest is a Prequest instance or not, and I have not yet thought of a good solution. If you have a solution, let me know in the comments.
While creating ‘instances’ using.create may not be intuitive, we can also hijack the new operation by Proxy.
The Demo is as follows:
class InnerPreQuest {
create() {
/ /... some code}}const PreQuest = new Proxy(InnerPreQuest, {
construct(_, args) {
return () = >InnerPreQuest.create(... args) } })Copy the code
Request a lock
How to get the token before requesting the interface?
In the example below, the page makes multiple requests at the same time
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
Copy the code
First, it’s easy to imagine that we could add tokens to it using middleware
prequest.use(async (ctx, next) => {
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
Copy the code
But where do token values come from? The token needs to be retrieved from the request interface, and the request instance needs to be recreated to avoid retracing the logic of the middleware where the token was added.
So let’s just implement it briefly
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()
})
Copy the code
The token variable is used to avoid calling the interface for the token each time the interface is requested.
At first glance, there is no problem with the code, but after a careful thought, when multiple interfaces are requested at the same time and the tokenRequest request has not received a response, the subsequent requests all go to this middleware, and the token value is empty, resulting in multiple calls to tokenRequest. So how to solve this problem?
It’s easy to imagine a locking mechanism
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()
})
Copy the code
Here we add pending to judge the execution of tokenRequest, which successfully solves the problem that tokenRequest is executed multiple times, but also introduces a new problem: how should incoming requests be handled when tokenRequest is executed? If the code above simply returns, the request will be discarded. In fact, we want the request to pause here and request the middleware later when the token is in hand.
To request a pause, we can easily use async, await, or promise. But how do you use it here?
I took inspiration from Axios’ implementation of cancelToken. In AXIos, a state machine is simply implemented by using promise. Resolve in promise is assigned to external local variables to control the promise process.
So let’s just implement it briefly
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 controls the flow
token = await promise
} else {
pending = true
token = await tokenRequest('/token')
// Call resolve so that promise can execute the rest of the process
resolvePromise(token)
pending = flase
}
}
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
Copy the code
When tokenRequest is executed, the rest of the request’s interface goes into a promise-controlled flow. When the token is obtained, the promise is controlled to continue execution through external resolve, which sets the request header and executes the remaining middleware.
This approach fulfils the requirements, but the code is ugly.
We can encapsulate all the states in a function. To implement a call like the following. Such calls are intuitive and beautiful.
prequest.use(async (ctx, next) => {
const token = await wrapper(tokenRequest)
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
Copy the code
How to implement such a wrapper function?
First, the state cannot be wrapped in the Wrapper function, otherwise a new state will be generated each time and the Wrapper will become null and void. You can use closure functions to save state.
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
}
}
Copy the code
When used, just generate a wrapper using createWrapper
const wrapper = createWrapper()
prequest.use(async (ctx, next) => {
const token = await wrapper(tokenRequest)
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
Copy the code
In this way, we can achieve our goal.
However, there is a problem with the code here. The state is wrapped inside createWrapper and when the token is invalid there is nothing we can do about it.
It is better to pass in the state from the createWrapper parameter.
Code implementation, please refer to here
In actual combat
Take wechat applets for example. The wx.request widget doesn’t work well. Using the code we encapsulated above, you can easily build a small program request library.
Encapsulate the applets native request
Make the native applet request Promise and design the opt object
function adapter(opt) {
const{ path, method, baseURL, ... options } = optconst url = baseURL + path
return new Promise((resolve, reject) = >{ wx.request({ ... options, url, method,success: resolve,
fail: reject,
})
})
}
Copy the code
call
const instance = PreQuest.create(adapter)
// Middleware mode
instance.use(async (ctx, next) => {
// Modify request parameters
ctx.request.path = '/prefix' + ctx.request.path
await next()
// Modify the response
ctx.response.body = JSON.parse(ctx.response.body)
})
// Interceptor mode
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' })
Copy the code
Gets the native request instance
Let’s first look at how to interrupt a request in a small program
const request = wx.request({
/ /... some code
})
request.abort()
Copy the code
With the layer we encapsulated, you will not get the native request instance.
So what to do? We can start with the transfer of references
function adapter(opt) {
const { getNativeRequestInstance } = opt
let resolvePromise: any
getNativeRequestInstance(new Promise(resolve= > (resolvePromise = resolve)))
return new Promise(() = > {
const nativeInstance = wx.request(
// some code
)
resolvePromise(nativeInstance)
})
}
Copy the code
This is a reference to the implementation of cancelToken in AXIos, which uses a state machine to fetch native requests.
Usage:
const instance = PreQuest.create(adapter)
instance.post('http://localhost:3000/api', {
getNativeRequestInstance(promise) {
promise.then(instance= > {
instance.abort()
})
}
})
Copy the code
Compatible with multi-platform small programs
After checking several applets and quick applications, we found that the request mode is a set of applets. In fact, we can take out wx. Request and pass it in when creating the instance.
conclusion
In the above content, we basically implemented a request library independent of the request kernel, and designed two ways to intercept requests and responses, which we can choose according to our own needs and preferences.
This way of loading and unloading the kernel is easily extensible. When faced with a platform that Axios doesn’t support, don’t bother to find a good open source request library. I believe that many people in the development of small programs, basically have to find axios-miniprogram solution. With our PreQuest project, you can experience axios-like capabilities.
In the PreQuest project, in addition to the above mentioned, global configuration, global middleware, alias requests, and more are provided. There are also request libraries based on PreQuest encapsulation in the project,@prequest/ miniProgram,@prequest/fetch… It also provides a non-intrusive way to give PreQuest the ability to @prequest/wrapper for projects that use native XHR, FETCH, etc apis
reference
axios: github.com/axios/axios
Umi-request:github.com/umijs/umi-r…