IRequest - A class-based request library for Typescript/Javascript (二)IRequest 的设计与实现

首先说一下 IRequest 的设计目的是什么,后面的篇幅都将围绕这个目的来说。(本文所有的代码都是简化过的代码)

  1. 让使用者开箱即用,不需要也不应该对定义好的接口做任何的封装。
  2. 底层不依赖于具体的网络请求实现,也就是说,可以让任何其他的网络库接入 IRequest 定义的接口。

上一篇文章提到,大多数网络库的接口设计都是 Request.get(url, config) 这种基于函数调用传入 config 的方式。那为什么大家在实际使用的时候都得进行额外的一层封装呢?仔细想想你就会发现,根本原因是这种模式的扩展性不足,无法直接适用于复杂项目或者说需要长期维护的项目。像那些写完就不会再维护的项目,你当然可以怎么简单怎么搞。所以,如果我们想设计一个扩展性强的,能适用于复杂项目的网络库,那就得换一个思路: 我的选择是,使用 Command 模式,将每一个网络请求当成一个对象。首先简单复习一下什么是 Command 模式。

命令模式(Command Pattern):将一个请求封装为一个对象,从而使我们可用不同的请求对客户进行参数化; 对请求排队或者记录请求日志,以及支持可撤销的操作。命令模式是一种对象行为型模式,其别名为动作(Action)模式或事务(Transaction)模式。

这个定义里的请求指的是广义上的请求。根据业务场景发送不同的请求,这就符合了‘可用不同的请求对客户进行参数化’。而请求排队,日志,支持可撤销也是一个网络请求库几乎必备的功能。可以看到,一个网络请求几乎完全符合了这个模式的定义。当然,Command 模式也不是这么简单的。一个完整的 Command 模式包括以下五个角色: Command 抽象命令类、ConcreteCommand 具体命令类、Invoker 调用者、Receiver 接收者、Client 客户类。如果你要严格的按照这些定义来实现一个网络库,那就是过度设计(想想为什么)。俗话说了,读书不要读死,我们在学习一个设计模式的时候不需要死抠它的定义。后面可以看到,IRequest 的设计其实只用到了 Command 抽象命令类、ConcreteCommand 具体命令类和 Client 客户类这三个角色,Command 模式的精髓就是把请求封装成对象,网络请求的特征和 Command 模式的定义,你可以认为是“纯属巧合”:)

使用 Command 模式的通用优点有:

  • 降低系统的耦合度
  • 新的命令很容易加到系统中
  • 很容易设计一个命令队列或组合命令
  • 可以很方便地对请求进行 Undo 和 Redo

另外,对于网络请求这个具体的 Case,将每一个网络请求封装成一个对象,我认为还有以下几点优点:

  • 很容易根据具体的业务场景对请求进行扩展和定制化,这个用语言讲比较空,后面会有更多的实例来说明,上一篇文章中的 CommonListRequest 就是一个例子。如果用基于函数调用的方式,你是无法随意地把一个 List 请求里面的逻辑封装起来并复用的。
  • 从网络请求的层面提供了额外的一个代码复用和封装的维度。一个项目想要做到工程化,它必须得用良好的方式把复杂的代码拆分开,然后再用“胶水代码”把拆分开的代码组合起来。什么 MVC、MVVM、MVP 还有其他一些花里胡哨的架构说白了做的就是拆代码和组合代码的事,这些架构告诉你了什么代码该放在什么地方,但没有那么具体。而把请求封装成对象,可以在你使用这些视图层架构的基础之上,又给了你一个具体的选择 —— 把请求相关的逻辑放到这个具体的请求 Class 里面。

接下来我们来定义一个网络请求命令类,也就是 Command 抽象命令类。一个网络库至少需要具备以下几个功能:

  • 对 HTTP 请求进行配置。发送一个网络请求实际上就是在发送一个 HTTP 请求报文,网络库要做的事情就是提供友好的接口以生成这个请求报文。
  • 支持请求的 timeout。
  • 支持取消请求。

更进一步地

  • 支持对网络请求的生命周期进行 hook
  • 支持对网络请求的响应进行验证
    • 验证 statusCode 是否符合预期
    • 验证返回的数据是否符合预先定义的 scheme
  • 支持请求结果的 cache

基于以上,很快就能给出 IRequest 的接口定义。注意,下面的代码并不是完整的 IRequest 定义,只是拿出一些核心的字段作为例子。感谢面向对象,你可以很容易地用继承和组合的方式对这个定义进行扩展。

interface IRequest<T> {  
    readonly response: Response | undefined
    readonly responseObject: T | undefined
    readonly responseStatusCode: number
    start(): Promise<T>
    cancel(): void

    // methods below can be overrided optionally
    requestMethod(): HttpMethod
    requestUrl(): string
    baseUrl(): string | undefined
    requestTimeout(): number
    requestArgument(): { [key: string]: string | number } | undefined
    requestArgumentArrayFormat(): ArrayFormat
    additionalRequestHeaders(): { [key: string]: string } | undefined
    validateStatus(statusCode: number): boolean
    requestWillStart(): void
    requestDidEnd(): void
}

abstract class Request<T> implements IRequest<T> {  
    ...
}

使用者在使用的时候只需对每一个具体的请求声明一个 class:

class UserInfoRequest extends Request<UserInfo> {  
    requestUrl() {
        return 'user/info';
    }
}

const userInfo = await new UserInfoRequest().start();  

IRequest 的接口定义完了。注意,我们定义的可是一个顶层的、通用的网络请求接口,它不应该被使用者封装。那怎么样才能让用户从其他的网络库或实现迁移过来呢?IRequest 本身不包含任何的底层网络实现,所以我们要提供一个接口,让其他网络库无缝接入过来:

interface IRequestExecutor {  
    sendRequest(options: RequestOptions): Promise<Response>
}

发现了吗?这个接口其实就是大家熟悉的 Request.get(url, config), 这里只不过把它当成了 IRequest 的底层实现。有了这个所谓的 IRequestExecutor 后,IRequest 的 start() 方法就可以实现了

class Request implements IRequest {  
    ...

    async startUnwrap(): Promise<Request> {
        const response = await RequestExecutor.sendRequest(
            {
                url: joinParams(this.requestFullUrl, this.requestArgument(), this.requestArgumentArrayFormat()),
                method: this.requestMethod(),
                headers: {
                    'Content-Type': contentType,
                    ...this.additionalRequestHeaders(),
                },
                responseType: this.responseType(),
                data: requestData,
                timeout: this.requestTimeout()
            }
        )
        this.handleResponse(response)
        return this
    }
}

而 IRequestExecutor 也是非常容易实现的,这就能让其他的网络库快速接入 IRequest,以 axios 为例:

class AxiosRequestExecutor implements IRequestExecutor {  
    async sendRequest(options: RequestOptions, request: Request): Promise<Response> {
        const res = await axios.request({
            url: options.url,
            method: options.method!.toLowerCase(),
            headers: options.headers,
            data: options.data,
            timeout: options.timeout! * 1000,
            responseType: options.responseType,
            cancelToken: source.token
        })

        return {
            data: res.data,
            statusCode: res.status,
            headers: res.headers
        }
    }
}

看到了吗,只需简单几行代码,我们就完成了 Axios 到 IRequest 的转换。然而,实际情况会比这里复杂很多。因为各种网络库支持的功能可能都不太一样,有的可能支持取消和 timeout,有的是完全不支持的。对于这种功能,我们可以给 IRequestExecutor 加几个选择:

interface IRequestExecutor {  
    sendRequest(options: RequestOptions, request: Request): Promise<Response>
    useBuildinTimeout?(): boolean
    useBuildinCancellation?(): boolean
    cancelRequest?(request: Request): void
}

如果 useBuildinTimeout 返回 false,那就用 IRequest 自带的 timeout 实现,否则的话,由 IRequestExecutor 自行处理,这里我们可以规定好 timeout 需要抛出什么异常,让后让 IRequestExecutor 的实现去抛。对于 cancelRequest 或其他类似的功能也是采取这样的方案,这个时候 IRequestExeutor 其实就非常功能强大和灵活了。

从这个例子我们也可以看到面向接口而不是面向实现编程的强大之处,用户可以随意切换底层的网络实现,同时保持上层的接口完全不变。

这就是 IRequest 的核心设计和实现,很简单对吧?下一篇文章将会介绍 IReuqest 的实际使用。