炸毛的猫炸毛的猫
归档
java
框架
中间件
数据库
杂谈
荧墨
归档
java
框架
中间件
数据库
杂谈
荧墨
  • 配置化无代码Http请求组件

配置化无代码Http请求组件

背景

在大多数需求中,如果我们需要接入接入请求三方的接口,需要:前期对接,开发对接,文档获取,账号获取,开发联调,正式上线。在我们的开发过程中,还需要进行依据文档进行参数封装、结果封装,最终将封装的结果数据进行持久化或其他操作。

最近面临了一个需求,若在某一标准流程中,入参来源与最终获取接口需要处理成为的数据结构固定,而根据入参请求第三方接口调用逻辑不通,且第三方接口是动态增加的(频率较低),能否实现请求三方的过程动态化,不需要手动实现代码,依赖某些配置,快速修改上线。

我进行了一系列尝试,实现了初步符合目标的实现,下面讲解下实现思路与过程。

设计

在设计实现,为了需求而实现,秉承从简原则,首先完成我们当前的目标即可,只需要最基本的两种请求类型:GET、POST。

一个请求分为了三个阶段:

  • 参数构建
  • 请求执行
  • 响应解析

实现请求与解析

请求格式分析

请求执行其实没有什么难度,无非是封装好Http请求对象,通过工具类执行好了,总不会基于 Socket 重新实现 Http请求与解析哈哈哈哈,那第一个问题是请求如何封装?对请求参数进行拆解,其实请求参数基本是以下几种类型。

请求位置请求类型是否支持GET是否支持POST
PATHURL拼接,替换是是
QUERYURL显示参数,URL拼接是是
HEADERHEADER键值对是是
BODYapplication/json否是
BODYapplication/x-www-form-urlencoded否是
BODYtext/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);
    }
		// .....映射结果返回
}

由此,我们基本实现了一个配置化动态请求工具。

流程图

懒得重新画了,先贴个初版设计图吧

image-20250622160929869

总结

本文实现了一个配置化的 HTTP 请求组件,支持通过 JSON 配置完成请求参数构建、请求发送、响应解析等过程。核心能力包括:

  • 支持多种请求参数类型(PATH、QUERY、HEADER、BODY)
  • 基于 SpEL 实现动态参数解析与结果映射
  • 参数结构支持嵌套对象与数组,具备较强表达能力
  • 引入 GlobalContext 和 RuntimeContext 实现上下文变量隔离
  • 支持链式请求及失败重试机制,适用于前后依赖顺序性接口调用

整体目标是减少三方接口对接开发成本,提升通用性和配置灵活性,目前已在实际项目中初步落地。

Last Updated:
Contributors: yancy, yancy0109