如何封装HTTP客户端

我们知道SOLID、DRY、KISS等众多原则,而在运用的时候依然犯难,纵观开源界常说这么一句“让开发者将精力集中到业务本身”,这也只是对优秀封装的评价。怎么做(How)?各家有各家的办法,借此机会,我就来聊聊最近的一些想法。

一切因“让开发者将精力集中到业务本身”而起,它是我们期望达到的理想状态,是评价封装策略优劣的依据。

外观

作为业务开发者,我们最关心的部分是,这里需要调用处理某业务的服务接口,完。就像是提纲或是故事主线,我们不希望看到与此无关的各种细节,包括但不限于:url是什么,用什么HTTP方法,用什么内容格式请求,又用什么内容格式响应等等。这么看来封装完毕之后应该是这样

// 以登录为例
const res = await authServer.login({username, password});
if (!res.ok) {
	message.error(res.msg);
	return;
}

// 继续登录成功后的逻辑

像极了流程图。

一个HTTP请求可以配置的参数很多,包括url、httpMethod、请求或响应的contentType等等,它们对于同一个后端服务是固定的,所以我们希望只设置一次,四处使用,而不是每次发起请求时反复传递同样的配置。

可能有人会问还能不能继续简化?我想应该可以,因为对于应用的业务主线,一切流程都正确是大概率的事情。将正确流程作为首要关注,反之对于错误处理这种出现概率相对小的流程,我们希望简化处理,最好按照统一的方式一并处理。基于这样的考虑调整代码,于是又变成这样

// 以登录为例
// authServer            - 表示要向认证服务发起请求
// login                 - 表示发起何种请求
// {username, password}  - 请求参数
await authServer.login({username, password});

// 继续登录成功后的逻辑

上面这段代码让人疑惑,登录失败的流程去哪儿了?答案是,它被统一约定的处理方式隐藏了。

/**
 * @param baseUrl - 构造请求URL的base地址
 * @remark 此处省略一些其他设计
 */
function request(baseUrl: string, ...) {
	/**
	 * @param api - 接口配置
	 * @param [json] - 请求内容类型为JSON
	 * @param [data] - 请求内容类型为表单Form
	 * @param [params] - 请求内容类型为URL Query String
	 * @remark 传入json时,默认使用POST请求,且自动设置JSON类型的 content-type
	 *         传入data时,默认使用POST请求,且自动设置FORM类型的 content-type
	 *         传入params时,使用GET请求,使用URL对象构造Query String
	 */
	return (...) => {
	  // 请求部分
	  const resp = await fetch(...);
	  // ...
	  const content = resp.json(); // 比如这里按照约内容定格式解析。而通常依照请求配置区别解析内容

	  const result = server.commonHandler(content);
	  if (result.isOver) return; // 已经处理完毕,不必进行后续处理

	  return handler(result.data);
	}
}

type HttpMethod = 'get' | 'post' | 'put' | 'delete';

interface Api {
	/** 接口路径 */
	uri: string;
	/** 请求头 */
	method?: HttpMethod;
	/** HTTP请求头 */
	headers: Record<string, string>
	/** 自定义错误处理 */
	customErrorHandler?: (content: any) => { isOver: true; data: any };
}

/** 定义请求指定(auth)服务的HTTP客户端 */
const authRequest = request('http://your.authserver.com/api');

/** 配置认证服务接口 */
const authServer = {
	async login(json: { username: string; password: string }) {
		return authRequest({
			api: { uri: '/login' },
			json,
		});
	}
};

/** 服务响应内容 */
interface ServerResponseContent {
    /** 内容码,约定通过 `code = 0` 表示请求处理成功,`code != 0` 表示请求处理失败 */
    code: number;
    /** 需要客户端提示的消息内容 */
    msg: string;
    /** 需要客户端提示的类型 */
    msgType: 'info' | 'warn' | 'error' | 'success' | undefined;
}

/**
 * 通用处理,即和服务端约定好的统一处理形式,如统一错误处理
 * @remark: 这里约定通过 `content.code = 0` 表示请求处理成功,`content.code != 0` 表示请求处理失败
 *     约定 `content.msgType` 表示需要客户端提示的类型,`content.msg` 表示需要客户端提示的消息内容
 *     客户端约定返回 `.isOver` 表示是否处理完毕(无需后续handler处理),`.data` 处理之后的消息,向后续handler传递
*/ 
function commonHandler(content: ServerResponseContent) {
    // 统一的消息提示处理
    messageFuncMap: Partial<Record<keyof ServerResponseContent, Function>> = {
        info: message.info,
        warn: message.warn,
        error: message.error,
        success: message.success,
    };
    const showMsg = messageFuncMap[content.msgType];
    if (showMsg) showMsg(content.msg);

    // 正确流程
    if (content.code === 0) return { isOver: false; data: content.data };

    // 自定义错误处理流程
    // ...
    
    return { isOver: true; data: undefined };
}

通过与服务端约定协议统一规则,我们将消息提示变为公共逻辑。这时,对于要求服务端返回错误后仅提示消息的情形,便无需单独编写错误处理流程,于是就得到“没有”错误处理的登录流程。

对于统一约定处理之外的错误,传入自定义错误处理回调函数即可。

// 登录时连续5次帐密错误,禁止登录24小时
await authServer.login({username, password}, {
    customErrorHandler: (content: ServerResponseContent) {
        if (content.data.authcFailedCount >= 5) {
            // 帐密连续错误5次,启动禁止登录
            // ...
        }
    },
});

总之,在设计封装外观的时候反复问自己:哪些是使用者关心的?哪些又是可以用统一惯例隐藏掉的?

实现层次

在实现前文封装的时候,需要分为多个层次,每个层次关注点(业务)不同,我们需要把前文陈述的外观设计原则落实到每一层。比如:Python 的 requests 库、Javascript 的 axios 库它们都一些外观相似的封装

axios对请求方法的包装

axios.get(...);
axios.post(...);
axios.put(...);
axios.delete(...);

requests对请求方法的包装

requests.get(...)
requests.post(...)
requests.put(...)
requests.delete(...)

毫不意外,对于高频使用的东西,业内倾向于简化其使用方式。关于简化高频惯例,有个极致到反常理的例子 = 表示赋值,而 == 才表示判断相等。

requests、axios 库封装了HTTP层面的操作,在与业务系统集成的时候,我们还需要在它们之间加入一个融合层,将业务系统的惯例封装起来,并以此简化使用。

融合层的意义是,将系统中统一设计显式固定为惯例,让惯例来简化设计、使用、维护避免大家做重复的事情,并减少犯错。

实际上,当我们持续地改进封装,并期望“让开发者将精力集中到业务本身”时,又会发现 requests、axios 这些库的弊端,最后改进必然会用自己的实现代替三方库,只是往往我们并不需要如此极致。

我们有必要追求持续改进融合层的封装吗?答案是肯定的,融合层中无关业务的设计,统一规划的部分通常能够应用于不同项目中,它其实是非业务功能的最佳实践,是可以沉淀下来的技术资产,是能够长期获益的投入,所以投入产出比高的事情咱们何乐而不为。

集成者

现代开发模式中,软件开发者往往会使用许多三方库来快速达成目标,从这个角度看,人人都是集成者。集成这件事情也是有经验可沉淀的,前文提到的融合层,实际讲得就是三方库和业务之间的通用设计,由此可以看出,同样都是集成者,能够持续沉淀和改进融合层这种最佳实践的人才是其中的佼佼者,而能够在此基础上,重新改进依赖三方库的开发者往往又是行业翘楚。

文章中设计的实现 http-client 仓库

posted @ 2025-07-24 11:41  络终  阅读(9)  评论(0)    收藏  举报