🔥以统一请求(request)为例描述如何在多端业务架构下提供多端复用代码

129 阅读8分钟

前言

前端跨端开发一直都是一个热门的话题。因为一份代码可以跑在不同的端内,可以极大的提高人效,且可以快速的将业务在不同的端进行上线,提高产品的端侧覆盖率。与此同时,前端需要面临一个问题,如何在多端的业务场景下提供各端都可复用的代码逻辑,而不是针对不同的端去实现。当然了针对跨端也有很多跨端的框架,比如 taro 可以开发跨端的小程序和 H5ElectronTauri 可以通过跨桌面端端应用开发。但不可否认的是即使面对这些丰富的跨端框架下,业务层面仍会面临需要针对多端实现一下特定的业务逻辑,举例来讲,如何实现各端的统一监控和统一打点,不同的端抽象层可能是一样的,但是底层的实现层肯定是不一样的。最简单的,webH5 的上报方式可能是 sendBeaconXHRFetchnew Image等方式,而各端小程序可能是 wx.requestdd.httpRequestmy.request等方式,调用者实际是感知这些差异的,都是统一调用 sendLog 进行上报。这就涉及到如何提供抹平端侧差异的代码以支持各端的业务,且业务调用者无需感知端侧的差异,仅是直接调用对应的方法即可。这里我们以为各端提供统一的 Request 为例来描述如何提供多端复用代码。

/*
 * 提供抹平端侧差异的 Request 类
 * 各端仅需在 app 入口测进行实例化即可使用
 * 全局单例 globalConfig 设置 request 实例
 * seivice 层通过在 adapter 获取 request 实例进行 api 请求
*/

// 伪代码如下:

// request.ts
class Request {
    httpRequest(requestOptions: RequestOption) {}
}

// index.ts
import { Request } from './request';
import { setGlobalConfig } from './config';

setGlobalConfig({ request: new Request() });

// config.ts
let globalConfig: GlobalConfig = {}
const setGlobalConfig = (options: Partial<GlobalConfig>) => {
    globalConfig = {...globalConfig, ...options};
};
const getRequest = () => {
    return globalConfig.request!;
};

// adapter.ts
import { getRequest } from './config';

const httpRequest = (options: RequestOption) => {
    // 这里主要是作数据格式化的操作
    return getRequest().httpRequest(options);
};

// service.ts
import { httpRequest } from './adapter';

const getUserInfo = (data: GetUserInfoRequest) => {
    return httpRequest({
        url: 'xxx/xxx/xx',
        data,
    });
};

设计模式

这里我们思考几个问题:

  1. Request 类提供给所有的端使用,作为抽象侧如何定义好接口协议?
  2. 各端差异化的 baseRequest 如何实现?
  3. request 请求需要的 token 如何存取,且需要抹平端侧差异?
  4. 差异化的 baseRequesttokenManger 如何注入给 Request 类?
  5. token 的无感刷新与请求的去重等逻辑如何实现?

组合这些问题我们可以实现一个简单的 Request 类。

interface TokenManager {
    async getItem<T>(key: string) {
        
    }
    async setItem(key: string, value: string | Record<string, string | number | boolean>) {}
}

class Request {
    private _baseRequest; // 这里可以定义 baseRequest 的类型同 axios.request
    private _tokenManager; // 需要实现 TokenManager 
    constructor(baseRequest, tokenManager) {
        this._baseRequest = baseRequest;
        this._tokenManager = tokenManager;
    }
    
    async httpRequest<T>(options: RequestOptions) {
        const token = this._tokenManager.getItem<{
            accessToken: string;
            refershToken: string;
            expireTime: string;
        }>('token');
        // 这里判断 token 是否过期 过期刷新 token 使其有效
        // 组装 headers
        const header = {}
        try {
            /*
             * 这里需要注意
             * 请求去重,针对多个统一请求仅发送一个就可以
             * 有些请求需加锁 保证请求顺序
            */
            const res = await this._baseRequest();
        } catch (e) {
            // 判断 e 是否是 token 过期造成的 401 是 需要刷新token请求重试
        }
    }
}

依赖倒置

针对以上我们的伪代码设计了 Request 类。 这里需要明确一个设计原则。在针对 Request 类的设计中,需要用到 token 管理的类 TokenManger 与基础请求 baseRequest方法。这里我们做了解耦合,使得 Request 类对这两者的依赖是依赖于抽象接口协议,而不是具体的实现,具体的实现根据不同的端进行代码的实现。这里就有一个设计原则叫依赖倒置。依赖倒置原则(Dependency Inversion Principle,简称DIP)是面向对象设计的基本原则之一,属于 SOLID 设计原则之一,由罗伯特·C·马丁(Robert C. Martin)提出。这个原则的核心思想是“依赖于抽象,而不是依赖于具体”。在传统的软件设计中,高层模块往往依赖于底层模块,也就是说,具体的实现细节(底层模块)决定了模块间的耦合关系。而依赖倒置原则则是要求我们反过来,让底层模块依赖于抽象,高层模块依赖于抽象,这样,具体的实现细节就被“倒置”到了底层,高层模块与底层模块之间的耦合关系是基于抽象的,而不是基于具体实现的。

具体来说,依赖倒置原则包含以下两个方面:

  1. 抽象不应该依赖于细节,细节应该依赖于抽象。也就是说,我们应该面向接口编程,而不是面向具体实现编程。
  2. 高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。这样可以提高模块的独立性,降低耦合性。

通过遵循依赖倒置原则,我们可以使代码更加模块化,更加的解耦合,更易于测试和维护,因为高层模块对实现细节的依赖被解除了,只要保持接口不变,更换具体实现对高层模块几乎没有影响。

依赖注入

通过依赖倒置原则进行 Request 类的设计,那么如何将互相依赖的类进行耦合关系的关联呢。这里需要有到一个设计模式,叫依赖注入。

依赖注入(Dependency Injection,简称DI)是一种设计模式,它是依赖倒置原则的一种实现方式,主要用于解决组件之间的依赖关系,使得组件之间的耦合度降低,提高代码的可测试性和可维护性。

依赖注入的基本思想是:将一个对象所需的依赖(即其他对象)传递给它,而不是由它自己创建。这样,对象不再负责获取它的依赖,而是由外部(如容器或框架)在运行时注入。这样做的好处是,对象的依赖关系可以被灵活地配置,不需要修改对象的代码。

这里也需要明确依赖和注入这两个概念:

  1. 依赖:一个对象为了完成它的功能所需要的对象或资源。
  2. 注入:将依赖传递给对象的方式,而不是由对象自行创建依赖。

依赖注入主要有三种方式:

  1. 接口注入:通过接口,将依赖对象传递给需要它的类。
  2. 构造函数注入:在类的构造函数中接收依赖对象。
  3. 属性注入:通过设置类的公共属性来注入依赖对象。
// 构造函数注入
class Request {
  constructor(baseRequest, tokenManager) {
      this._baseRequest = baseRequest;
      this._tokenManager = tokenManager;
  }
}

// 接口注入
class Request {
  setBaseRequest(baseRequest) {
      this._baseRequest = baseRequest;
  }
  
  setTokenManager(tokenManager) {
      this._tokenManager = tokenManager;
  }
}

// 属性注入
class Request {
  set baseRequest(baseRequest) {
      this._baseRequest = baseRequest;
  }
  
  set tokenManager(tokenManager) {
      this._tokenManager = tokenManager;
  }
}

优化的几个点

Request 类的设计已经有,下面就可以针对依赖进行实现,以及需要进行业务控制的代码进行实现了,如请求加锁、请求去重、无感刷新、请求重试。

TokenManager 的实现

这里以 web 为例:

class TokenManager {
    async setItem(key: string, value: string | Record<string, string | number | boolean>) {
        localStorage.setItem(key, JSON.stringify(value));
    }
    
    async getItem<T>(key) {
        return JSON.parse(localStorage.getItem(key)) as T;
    }
}

baseRequest 实现

这里以 web 为例:

// 这里可以直接使用 axios 其它端模拟 axios.request 实现即可
export const webBaseRequest = (options) => {
    return axios.request(options);
}

请求加锁

请求加锁在很多业务场景就是为了保持请求的顺序,因此这里可以借助先用的成熟的库来解决这个问题。这里使用 async-mutex 这个库来解决。

请求去重

请求去重在业务场景中针对同一个请求同时发起了好几个,只需发起一个请求接口,其它请求仅返回第一个请求结果即可。这里我们简单实现一下:

type RequestFunc<T> = () => Promise<T>;
class RequestManager {
    private pendingRequests: Map<string, Promise<any>> = new Map();
    public async makeRequest<T>(key: string, requestFunc: RequestFunc<T>): Promise<T> {
        // 如果当前已有请求在进行中,直接返回这个请求的 Promise
        if (this.pendingRequests.has(key)) {
            return this.pendingRequests.get(key)!;
        }
        
        // 否则,发起请求并缓存这个请求的 Promise
        const requestPromise = requestFunc().finally(() => {
            // 请求完成后,从 pendingRequests 中移除 
            this.pendingRequests.delete(key);
        });
        
        this.pendingRequests.set(key, requestPromise);
        return requestPromise;
    }
}

无感刷新

无感刷新主要是针对 token 的操作,需要保证 token 的有效性。这里可以参看我之前的文章【axios】不可不知的axios无感刷新token

请求重试

请求重试也是为了请求做兜底的,当请求出现指定错误是可以通过一些操作手段是请求正常。这里可以使用 axios-retry 这个库做请求重试。

针对 refreshtoken 的 error

refreshtoken 时发生了 error 说明当前的 token 已经完全失效,需要重新登录了。

总结

这里以统一请求(request)为例,通过依赖倒置原则,借助依赖注入模式,实现多端架构下的代码复用。如果各位在实际的业务场景落地中,有更好的实践欢迎留言交流。