代码实战:从对接第三方API谈如何写健壮性的代码

38 阅读8分钟

我这边的业务系统里,有一个业务场景:业务单据需要实时同步到第三方的低代码平台,各个合作方的客户在低代码平台上对这些单据做发货、审核等操作。如果某张单据同步失败了,客户在平台上看不到,就是大故障。

对接低代码平台,需要通过它的OpenAPI来写入和查询数据。问题在于,调第三方接口不像调自己的内部服务那么可控。超时了、连接断了、返回了500了,各种意外情况都可能发生。而且调用失败不代表真的失败,对方可能已经处理完了,只是响应在回来的路上丢了。

对接第三方API,怎么保证代码足够健壮?这里用一个真实的业务场景以及实际的代码来说明一下。

统一调用入口

低代码平台的接口设计通常比较统一,写入数据用addRow,更新数据用updateRow,查询数据用getRows。不管你是同步哪种业务单据,底层调的都是这几个方法。

项目里需要有一个人把这些接口封装好,写一个LowCodeClient类,团队里其他人直接调addRow、updateRow就行,不需要关心HTTP请求怎么拼、网关怎么走、响应怎么解析。

这个统一入口的好处不只是省事。调用入口统一了,监控可以加在这里,日志可以记在这里,异常处理也可以在这里统一做。如果每个业务模块各自对接低代码平台的接口,代码分散在各处,出了问题排查起来非常困难,也没法统一加降级和重试逻辑。

LowCodeClientImpl里所有的写入操作最终都走一个exec方法:

GatewayRequestDTO req = buildRequest(apiName, appType, body);
JSONObject resp = JSON.parseObject(callGateway(req, apiName));
Object data = checkResponse(apiName, resp);
return data != null ? data.toString() : null;

buildRequest负责拼装请求参数,callGateway负责发HTTP请求,checkResponse负责校验响应结果。低代码平台的API响应里有个success字段,为false的时候直接抛异常,上层调用方通过catch来感知失败。

调用失败不代表真的失败

这是对接第三方系统时最容易踩的坑。

调用addRow往低代码平台写数据,返回超时了,或者网关报错了。很多人第一反应就是写入失败,直接报错。但实际上,请求可能已经到了对方那边,数据已经写进去了,只是响应没回来。

在生产环境里,这种事发生的频率比想象的高。网络抖动、对方服务重启、网关超时,都可能导致你拿不到正确的响应,但对方其实已经处理了你的请求。

所以一个基本的原则是:addRow失败了,先别急着判定结果,去查一下再说。

查询确认

低代码平台一般都有查询接口,支持按业务单据号查记录。当addRow抛异常的时候,不是直接当失败处理,而是先调查询接口,拿单据号去查:数据到底写进去没有?

查到了,说明写入其实成功了,只是响应丢失了,业务继续往下走。没查到,那才是真的失败了,进入下一步处理。

这一步很多人会忽略。觉得调用报错了就是失败了,重试一下就行。但如果对方其实已经写进去了,你再重试写入一次,就可能出现重复数据。虽然有些平台会根据幂等机制帮你挡住,但不能把希望寄托在对方的实现上。

重试一次

确认是真的失败了,可以重试。但只重试一次。

为什么是一次?如果是临时的网络抖动,一次重试大概率就能成功。如果重试一次还是失败,说明对方系统大概率是真的有问题,可能是服务挂了,可能是接口出了bug。这种情况下,你重试三次五次也没用,只会让当前请求卡在那里等半天,拖慢整个系统的响应速度。

快速失败,把问题交给后台去处理,比让实时请求一直在那里死等要合理得多。

写入任务表兜底

重试一次还是失败了,这时候不能再继续重试了。但数据不能丢,需要有一个兜底机制。

做法是往biz_task表里写一条记录,包含任务名称、业务单据号、业务类型等信息。后台有调度程序,会按照阶梯式的时间间隔自动重试这些任务:1分钟、5分钟、30分钟、2小时。随着对方系统恢复,大部分任务会自动重试成功。如果所有重试都失败了,系统会把告警信息推送到告警群,让人工介入。

biz_task表的结构:

CREATE TABLE biz_task (
    id             BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name           VARCHAR(400) NOT NULL COMMENT '任务名称',
    type           VARCHAR(100) DEFAULT '' NOT NULL COMMENT '任务类型',
    biz_id         VARCHAR(200) DEFAULT '' NOT NULL COMMENT '业务ID',
    biz_type       VARCHAR(100) NOT NULL COMMENT '业务类型',
    retry_count    INT UNSIGNED DEFAULT 0 NOT NULL COMMENT '重试次数',
    execute_result VARCHAR(20) COMMENT '执行结果',
    status         VARCHAR(20) DEFAULT '' NOT NULL COMMENT '状态',
    trigger_time   TIMESTAMP(3) COMMENT '触发时间',
    priority       INT DEFAULT 0 NOT NULL COMMENT '优先级',
    create_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
    update_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) COMMENT '业务任务';

这个任务表是我自己封装的一个轻量级业务task框架,专门处理这类失败后需要异步重试的场景。没有引入消息队列的延迟消息,也没有用定时任务框架,就是一个简单的任务表加调度程序,够用,也足够可控。这块后面有时间会专门写一篇来分析。

完整流程

把上面的逻辑串起来,整个单据同步的健壮性处理链路是这样的:

先通过统一的LowCodeClient调用addRow写入单据。如果写入成功,正常返回rowId,业务继续。如果写入抛了异常,先别慌,调低代码平台的查询接口,拿单据号查一下。查到了,说明其实成功了,返回已有的rowId。没查到,确认是真的失败了,重试一次addRow。重试成功,正常返回。重试还是失败,写一条biz_task记录,返回null。

用一段代码来表达:

try {
    rowId = lowCodeClient.addRow(SHEET_ID, APP_TYPE, controls, false);
} catch (Exception e) {
    rowId = lowCodeClient.queryRowIdByBizNo(SHEET_ID, docNo);
    if (rowId == null) {
        try {
            rowId = lowCodeClient.addRow(SHEET_ID, APP_TYPE, controls, false);
        } catch (Exception retryEx) {
            // 写入失败任务,由后台调度阶梯重试
            saveRetryTask(docNo, docType, controls);
        }
    }
}

整个流程的逻辑是:不信任任何一次调用的结果,失败了先查询确认,确认不了就交给后台任务慢慢重试,重试到最后还是失败了,则将告警信息同步到专门的监控群,人工介入处理。。

实际项目里,低代码平台的接口偶尔会因为升级、网络波动等原因出现短暂不可用。如果没有这套机制,一次接口抖动就可能导致一批单据同步丢失,客户那边直接投诉过来。有了这套机制,大部分同步失败都能在后台自动恢复,真正需要人工介入的情况很少。

小结

对接第三方API,代码层面的东西其实只是表象,背后的思考方式才是关键。

程序员之间的水平差距,很多时候不体现在能不能把功能实现出来,而体现在会不会主动去想那些不会出现在需求文档里的异常场景。网络超时了、对方返回了假成功、重试还是失败、服务突然挂了,这些问题不在需求里写着,但每天都在生产环境里发生。

对接第三方系统是最能体现这个差异的场景。你没法控制对方的行为,对方什么时候升级、什么时候重启、什么时候出bug,你完全不知道。你唯一能做的,就是在自己这一侧把所有可能的异常情况都考虑到,每一种异常都有对应的处理策略。程序不能一遇到异常就撂挑子不干,而是要有层次地应对:先确认、再重试、再兜底、最后告警。

充分考虑非功能性需求,是高级程序员和普通程序员之间一个重大区别。 功能实现只是及格线,在各种异常场景下系统仍然能稳定运行,才是真正有水平的体现。这种思考习惯不是看几篇文章就能学会的,需要在真实的项目里反复踩坑、反复总结,才能慢慢形成直觉和习惯。