配置化无代码Http请求组件
背景
在大多数需求中,如果我们需要接入接入请求三方的接口,需要:前期对接,开发对接,文档获取,账号获取,开发联调,正式上线。在我们的开发过程中,还需要进行依据文档进行参数封装、结果封装,最终将封装的结果数据进行持久化或其他操作。
最近面临了一个需求,若在某一标准流程中,入参来源与最终获取接口需要处理成为的数据结构固定,而根据入参请求第三方接口调用逻辑不通,且第三方接口是动态增加的(频率较低),能否实现请求三方的过程动态化,不需要手动实现代码,依赖某些配置,快速修改上线。
我进行了一系列尝试,实现了初步符合目标的实现,下面讲解下实现思路与过程。
设计
在设计实现,为了需求而实现,秉承从简原则,首先完成我们当前的目标即可,只需要最基本的两种请求类型:GET、POST。
一个请求分为了三个阶段:
- 参数构建
- 请求执行
- 响应解析
实现请求与解析
请求格式分析
请求执行其实没有什么难度,无非是封装好Http请求对象,通过工具类执行好了,总不会基于 Socket 重新实现 Http请求与解析哈哈哈哈,那第一个问题是请求如何封装?对请求参数进行拆解,其实请求参数基本是以下几种类型。
请求位置 | 请求类型 | 是否支持GET | 是否支持POST |
---|---|---|---|
PATH | URL拼接,替换 | 是 | 是 |
QUERY | URL显示参数,URL拼接 | 是 | 是 |
HEADER | HEADER键值对 | 是 | 是 |
BODY | application/json | 否 | 是 |
BODY | application/x-www-form-urlencoded | 否 | 是 |
BODY | text/plain | 否 | 是 |
OK,写到这里突然想起来还有 Cookie,基本没用到,基本没想到。🥹
基于以上种类,我们可以去实现请求的封装。
请求配置设计
请求需要参数,在当前工具规划中,我把请求划分为 GlobalContext 与 RuntimeContext:
- GlobalContext: 作为全局变量存在于应用的生命周期,通过配置文件或其他形式,直接注入在ConcurrentHashMap中进行全局共享。
- RuntimeContext: 每次请求前创建的参数,在我们业务流程中,在调用通用接口时,手动注入到 RuntimeContext 中进行传递。
基于以上形式,我的配置片段示例如下:
"requestUrl": "https://ip:port/{path}",
"requestType": "POST",
"globalVariables": {
"username": "username",
"pwd": "pwd"
},
"params": [
{
"paramPosition": "PATH",
"param": {
"isObject": true,
"fields": [
{
"key": "path",
"valueExpression": "'login'"
}
]
}
},
{
"paramPosition": "BODY",
"bodyFormatType": "FORM_URLENCODED",
"param": {
"isObject": true,
"fields": [
{
"key": "userName",
"valueExpression": "#global.getParam('username')"
},
{
"key": "password",
"valueExpression": "#global.getParam('pwd')"
}
]
}
}
]
可以注意到,我在配置文件中 通过 ValueExpression 指出了一个值的计算方式,这是基于 SpEL 的计算表达式,在进行参数封装处理时,提前将 GlobalContext 与 RuntimeContext 注入,即可轻松在解析参数时拿到动态值。
基于 SpEL,我能够完成很多丰富的值解析动作,而不是静态参数传递。
global.getParam('username')
参数解析与结果解析
参数解析最简单的部分实际上是 PATH、PARAMS、TEXT,而最复杂的是 JSON 与 FORM
- JSON:JSON对象/数组
- FORM:每个field对应了一个字符串,从最外层看实际是一个JSON对象,只不过最终将每个字段解析为了表单不同项。
参数解析与结果解析作为不同部分,为什么会在此标题下一同出现呢,因为他们都需要达成一个目标:如何通过配置文件去描述出一个复杂对象(JSON Object、JSON Array、Value)。
在开发的初期阶段,我只是以JSON(Field-Value)形式来确定一个数据结构,而再开发的后期阶段,需要引入数组对象作为表单某个键的值对象,很幸运的是,我很快意识到他们是嵌套递归结构,并进行了实现。
Java代码如下:
类结构
/**
* 参数键值
*/
public static class ParamEntry {
/**
* 参数名
*/
private String key;
/**
* 是否为对象
*/
private boolean isObject = false;
/**
* 对象属性列表, 递归参数
*/
private List<ParamEntry> fields;
/**
* 是否为数组
*/
private boolean isArray = false;
/**
* 数组元素[数组元素模版]
*/
private ParamEntry item;
/**
* 参数值SpEL表达式(GlobalContext, RuntimeContex, 等)
* 若非对象非数组, 则为当前值的取值表达式
* 数组: 长度取值表达式
* 对象: 忽略
* 其他: 取值表达式
*/
private String valueExpression;
}
解析代码:
/**
* 递归构造值对象
* @param entry 参数Entry
* @param context 全局变量
* @param parser SpEL处理器
* @return Entry对应的结果
*/
public static Object buildValue(RequestMeta.ParamEntry entry, EvaluationContext context, ExpressionParser parser) {
// 当前值对应Key
final String key = entry.getKey();
// 值为对象类型
if (entry.isObject()) {
// 创建JSON对象
JSONObject obj = new JSONObject();
context.setVariable(SpELConstant.JSON_OBJECT + key, obj); // 将当前对象放入 SpEL Context
// 递归处理器值
for (RequestMeta.ParamEntry field : entry.getFields()) {
obj.put(field.getKey(), buildValue(field, context, parser));
}
return obj;
} else
// 值为数组类型
if (entry.isArray()) {
// 获取数组长度
Integer value = parser.parseExpression(entry.getValueExpression()).getValue(context, Integer.class);
int len = value == null ? 0 : value;
JSONArray arr = new JSONArray();
context.setVariable(SpELConstant.JSON_ARRAY + key, arr); // 将当前数组对象放入 SpEL Context
for (int i = 0; i < len; i++) {
context.setVariable(key + SpELConstant.INDEX, i); // 将当前数组值处理索引放入 SpEL Context
arr.add(buildValue(entry.getItem(), context, parser));
}
return arr;
} else {
// 值为普通类型, 直接计算返回
return parser.parseExpression(entry.getValueExpression()).getValue(context);
}
}
我在处理构建对象时,在递归构造JSON对象/JSON数组,会依据Key将当前完整对象、数组值构建索引放入 SpEL Context,使得 SpEL值解析能够更好的感知上下文,增强动态性。
参考JSON示例:
{
// 表明当前对象为一个JSON对象
// 作为根层级, Key我并不关注,而是在对应逻辑处理时对根层级进行额外处理,来将根层级对象放入SpELContext,如FORM、JSON等
"isObject": true,
// 逐个解析每个field作为JSON对象的字段
"fields": [
{
"key": "list",
"isArray": true, // 表示当前为数组对象,将 Array_list 置入SpELContext
"item": { // 遍历创建每个item时,将创建index以 list_index(arrayKey_index)置入SpELContext
"isObject": true,
"fields": [
{
"key": "index",
"valueExpression": "#list_index" // 普通值类型,list_index为SpELContext保存的数组索引值
},
{
"key": "number",
"valueExpression": "#list_index + 1" // 普通值类型
}
]
},
"valueExpression": "3" // 数组长度为3, 遍历创建三个item对象
},
{
"key": "totalSize",
"valueExpression": "#list.size()" // 普通值类型
}
]
}
我们可以依据此构造FORM/JSON形式的参数,请求构造JSON、FORM参数代码片段示例:
case JSON:
Object val = ParamEntryObjectParser.buildValue(param.getParam(), context, parser);
StringEntity jsonEntity;
if (val instanceof JSONArray || val instanceof JSONObject) {
jsonEntity = new StringEntity(((JSON) val).toJSONString(), StandardCharsets.UTF_8);
} else {
throw new RuntimeException("解析Body JSON格式数组错误, ParamEntry解析数据结果非Json对象或Json数组.");
}
jsonEntity.setContentType(BodyFormatType.JSON.getValue());
httpPost.setEntity(jsonEntity);
break;
case FORM_URLENCODED:
Map<String, NameValuePair> formParams = new HashMap<>();
// 设置完整请求参数
context.setVariable(SpELConstant.REQUEST_FORM, formParams);
// 表单情况下, 最外层参数必须为JsonObject
if (!param.getParam().isObject()) {
throw new RuntimeException("解析Body Form-UrlEncoded格式数组错误, ParamEntry解析数据结果非Json对象.");
}
// 遍历处理每个表单Field
for (RequestMeta.ParamEntry field : param.getParam().getFields()) {
Object value = ParamEntryObjectParser.buildValue(field, context, parser);
if (value != null) {
formParams.put(field.getKey(), new BasicNameValuePair(field.getKey(), value.toString()));
}
}
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(formParams.values(), StandardCharsets.UTF_8);
httpPost.setEntity(formEntity);
break;
在进行请求获取Response后,将Response Data处理为JSON对象并置入SpELContext,再依据上述配置与解析方法,我们也可以构造复杂嵌套的结果对象,结果对象处理示例:
@Override
public JSONObject handle(JSONObject result) {
// 构造上下文
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable(SpELConstant.GLOBAL, globalContext);
context.setVariable(SpELConstant.RUNTIME, runtimeContext);
context.setVariable(SpELConstant.REAL_DATA, result); // 注入响应参数
JSONObject finalResult = new JSONObject(); // 构造最终的响应对象, 在当前我的系统应用设计中, 映射最外层必须为一个对象
context.setVariable(SpELConstant.MAPPED_DATA, finalResult); // 注入当前正在构造的映射对象
for (RequestMeta.ParamEntry field : responseMap.getFields()) { // 遍历field, 创建每个对象
Object val = ParamEntryObjectParser.buildValue(field, context, parser);
if (val != null) {
finalResult.put(field.getKey(), val);
}
}
return finalResult;
}
以上结束~
扩展:支持链式请求
上文结束后,我们实际完成了单个Http请求,然后大多数系统,不足与凭借单接口即可完成接口访问,若请求某个系统数据接口前,必须访问其认证接口,那么我们并形成了请求链路,并且前后响应参数存在一定依赖关系(若未持有有效Token,必须进行一次认证获取Token再获取数据)。
再之前的基础上,我再次对请求进行了扩展:
/**
* 请求标识符, 标识
*/
private String requestId;
/**
* 执行SpEL表达式, 默认为true, 请求链参数
*/
private String invokeExpression = "true";
/**
* 失败处理列表, 请求链参数
*/
private List<FailureAction> failureActions;
/**
* 全局设置, 请求链参数
* Key: GlobalContext参数名
* Value: 基于GlobalContext, RuntimeContext, MappedResponse进行的SpEL计算
*/
private Map<String, String> globalSettings;
/**
* 运行时设置, 如果存在下一个请求Meta, 将用于进行参数设置, 作为其请求的参数入口,
* 为空, 则无链路下一个请求无入参
* 请求链参数
* Key: RuntimeContext参数名
* Value: 基于GlobalContext, RuntimeContext, MappedResponse进行的SpEL计算
*/
private Map<String, String> runtimeSettings;
/**
* 失败行为
*/
@Data
public static class FailureAction {
/**
* 错误条件:SpEL表达式
*/
private String condition;
/**
* 行为: RequestId, 不存在RequestId则为失败,直接异常抛出
*/
private String action;
/**
* 最大失败行为重试次数
*/
private int retryTimes = 3;
}
举例:
通过 invokeExpression 参数,我能在判断是否执行登录获取Token的接口,在第一次请求时,也可以通过 globalSettings 将token直接置入到 globalContext 之中,这样 invokeExpression 就不会再触发执行获取Token的操作。
若 Token 过期了怎么办?再执行数据获取请求时,会根据 FailureAction 数组进行判断,若其响应数据符合
FailureAction(condition="response.statusCode=401", action="login")
便会根据 action 重新触发登录请求(失败跳转执行的情况忽略 invokeExpression)
请求链执行器核心实现示例:
public JSONObject execute(RuntimeContext runtimeContext) {
// ...... 上下文构造
boolean skipInvokeCheck = false;
for (int i = 0; i < requestMetas.size();) {
final RequestMeta requestMeta = requestMetas.get(i);
// 判断是否需要执行
if (!skipInvokeCheck) {
if (Boolean.FALSE.equals(parser.parseExpression(requestMeta.getInvokeExpression())
.getValue(context, Boolean.class))) {
i++;
continue;
}
} else {
// 恢复跳过执行判断标示
skipInvokeCheck = false;
}
// 请求执行
final SimpleExecutor executor = new SimpleExecutor(requestMeta);
final Response response = executor.execute(globalContext, runtimeContext);
// 设置响应至SpELContext
context.setVariable(SpELConstant.RESPONSE, response);
// 判断响应是否符合成功
final List<RequestMeta.FailureAction> failureActions = requestMeta.getFailureActions();
boolean doFailedAction = false;
// 遍历判断每一种情况
if (failureActions != null) {
for (RequestMeta.FailureAction failureAction : failureActions) {
final String action = failureAction.getAction();
// 若符合失败情况, 进行执行回退
if (Boolean.TRUE.equals(parser.parseExpression(failureAction.getCondition())
.getValue(context, Boolean.class))) {
// 判断是否超过最大重试次数
final int retryTimes = failureAction.getRetryTimes();
final String failedId = requestMeta.getRequestId() + ":" + action;
// 错误计数+1
failCounter.putIfAbsent(failedId, 0);
failCounter.put(failedId, failCounter.get(failedId) + 1);
// 若错误计算>retryTimes, 达到最大次数限制
if (failCounter.get(failedId) > retryTimes) {
throw new RuntimeException("请求失败次数超过最大限制, 触发表达式: " + failureAction.getCondition());
}
// 重试
Integer index = requestIdIndexMap.get(action);
if (index == null) {
throw new RuntimeException("请求失败默认行为:失败, 触发表达式: " + failureAction.getCondition());
}
i = index;
doFailedAction = true;
break;
}
}
if (doFailedAction) {
// 失败回退执行链, 标记跳过执行判断
skipInvokeCheck = true;
continue;
}
}
// 本次请求正常
JSONObject realResult = parser.parseExpression(requestMeta.getRealResult()).getValue(context, JSONObject.class);
context.setVariable(SpELConstant.REAL_DATA, realResult);
// 补充全局变量 && 运行变量
if (requestMeta.getGlobalSettings() != null) {
// ........
}
// 映射参数
mappedResult = new DefaultResultMappingHandler(requestMeta.getResponseMap(), globalContext, runtimeContext)
.handle(realResult);
// 执行链前进
i++;
// 直接通过SpELContext将上次映射后请求结果置入, 便于下个请求可以拿到相关信息
context.setVariable(SpELConstant.LAST_DATA, mappedResult);
}
// .....映射结果返回
}
由此,我们基本实现了一个配置化动态请求工具。
流程图
懒得重新画了,先贴个初版设计图吧
总结
本文实现了一个配置化的 HTTP 请求组件,支持通过 JSON 配置完成请求参数构建、请求发送、响应解析等过程。核心能力包括:
- 支持多种请求参数类型(PATH、QUERY、HEADER、BODY)
- 基于 SpEL 实现动态参数解析与结果映射
- 参数结构支持嵌套对象与数组,具备较强表达能力
- 引入 GlobalContext 和 RuntimeContext 实现上下文变量隔离
- 支持链式请求及失败重试机制,适用于前后依赖顺序性接口调用
整体目标是减少三方接口对接开发成本,提升通用性和配置灵活性,目前已在实际项目中初步落地。