本文记录了我在项目中对接超过 50 个第三方 API 后的经验总结,涵盖统一抽象层设计、适配器模式落地、指数退避重试、熔断保护、可观测性埋点等实战方案。完整代码可直接复用。
一、背景:当你需要对接第 10 个第三方 API 时,事情开始失控
我做的是一个 B2B 数据聚合平台,需要对接支付(微信/支付宝/Stripe)、短信(阿里云/腾讯云)、物流(顺丰/圆通/菜鸟)、地图(高德/百度)、企业信息(天眼查/企查查)等多类第三方 API。刚开始只有两三个时,每个服务写一个 HttpClient + 硬编码调用就行。
到了第 15 个接口时,问题集中爆发了:
- 每个第三方 API 的鉴权方式都不一样(Header Token / Query Sign / OAuth 2.0 / AK/SK 签名)
- 返回格式五花八门,有的是
{"code":0}、有的是{"status":"success"}��有的是{"ret":200} - 错误处理逻辑散落在各个 Service 里,改一个重试策略要改十几处
- 线上排查问题时,根本不知道是哪一层的错误,日志东一块西一块
- 新增一个第三方的成本极高,光是 "搭架子" 就要写几百行重复代码
经过几轮重构,最终沉淀出一套 「适配器 + 管道 + 策略」 的通用封装架构,新增一个第三方 API 接入从 2 天缩短到了 2 小时。
二、架构全景图
先上整体架构,心里有数再看代码:
┌──────────────────────────────────────────────────────────────┐
│ Business Service │
│ (只依赖 IThirdPartyApiClient 接口,不知道任何实现) │
└───────────────────────────┬──────────────────────────────────┘
│
┌───────────────────────────▼──────────────────────────────────┐
│ ThirdPartyApiClient │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ 鉴权管道 │ │ 重试管道 │ │ 熔断管道 │ │
│ │ AuthPipe │ │ RetryPipe │ │ CircuitBreakerPipe │ │
│ └──────┬──────┘ └──────┬───────┘ └──────────┬────────────┘ │
│ │ │ │ │
│ ┌──────▼───────────────▼────────────────────▼────────────┐ │
│ │ Adapter Layer (适配器层) │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ 天眼查 │ │ 支付宝 │ │ 高德地图 │ ... │ │
│ │ │ Adapter │ │ Adapter │ │ Adapter │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
核心设计原则:
- 业务层只靠接口编程,永远不直接依赖任何三方 SDK
- 管道模式处理横切关注点(鉴权、重试、熔断、日志),与业务逻辑解耦
- 适配器负责协议转换,把五花八门的 API 统一成内部标准
三、核心代码实现
3.1 统一请求/响应模型
首先定义一套内部标准协议,所有适配器都必须适配到这个模型:
// 统一请求模型
@Data
@Builder
public class ApiRequest {
private String method; // GET / POST / PUT / DELETE
private String path; // 接口路径,如 /v1/company/search
private Map<String, String> headers;
private Map<String, String> queryParams;
private String body; // JSON 字符串
private long timeoutMs; // 超时时间(毫秒)
}
// 统一响应模型
@Data
@Builder
public class ApiResponse {
private int httpStatus; // HTTP 状态码
private String rawBody; // 原始响应体
private boolean success; // 业务是否成功
private String errorCode; // 统一错误码
private String errorMessage; // 统一错误信息
private long latencyMs; // 耗时(毫秒)
private String traceId; // 链路追踪 ID
}
3.2 定义 SPI 接口:适配器的契约
/**
* 第三方 API 适配器标准接口 —— 所有第三方接入的唯一入口
*/
public interface ThirdPartyAdapter {
/** 获取适配器名称,如 "TianYanCha" */
String getName();
/** 构建鉴权信息(插入到请求头/查询参数) */
void authenticate(ApiRequest request);
/** 将内部统一请求转换为第三方特有的 HTTP 请求 */
HttpRequest convert(ApiRequest request);
/** 将第三方的原始响应解析为内部统一响应 */
ApiResponse parse(HttpResponse rawResponse);
/** 判断第三方返回是否业务成功 */
boolean isSuccess(ApiResponse response);
/** 获取该 API 的基础配置 */
ApiConfig getConfig();
}
3.3 适配器具体实现示例:天眼查
@Component
public class TianYanChaAdapter implements ThirdPartyAdapter {
@Value("${api.tianyancha.app-key}")
private String appKey;
@Value("${api.tianyancha.base-url}")
private String baseUrl;
@Override
public String getName() {
return "TianYanCha";
}
@Override
public void authenticate(ApiRequest request) {
// 天眼查签名算法:token + timestamp 做 MD5
String timestamp = String.valueOf(System.currentTimeMillis());
String sign = DigestUtils.md5Hex(appKey + timestamp + appKey);
request.getHeaders().put("Authorization", sign);
request.getHeaders().put("Timestamp", timestamp);
request.getQueryParams().put("appkey", appKey);
}
@Override
public HttpRequest convert(ApiRequest request) {
String fullUrl = baseUrl + request.getPath();
if (!request.getQueryParams().isEmpty()) {
fullUrl += "?" + buildQueryString(request.getQueryParams());
}
return HttpRequest.newBuilder()
.uri(URI.create(fullUrl))
.timeout(Duration.ofMillis(request.getTimeoutMs()))
.headers(buildHeaders(request.getHeaders()))
.method(request.getMethod(),
request.getBody() != null
? HttpRequest.BodyPublishers.ofString(request.getBody())
: HttpRequest.BodyPublishers.noBody())
.build();
}
@Override
public ApiResponse parse(HttpResponse rawResponse) {
String body = rawResponse.body();
int httpStatus = rawResponse.statusCode();
// 天眼查返回格式:{"error_code":0, "reason":"成功", "result":{...}}
JSONObject json = JSON.parseObject(body);
int errorCode = json.getIntValue("error_code");
return ApiResponse.builder()
.httpStatus(httpStatus)
.rawBody(body)
.success(errorCode == 0)
.errorCode(String.valueOf(errorCode))
.errorMessage(json.getString("reason"))
.build();
}
@Override
public boolean isSuccess(ApiResponse response) {
return response.isSuccess();
}
@Override
public ApiConfig getConfig() {
return ApiConfig.builder()
.baseUrl(baseUrl)
.maxRetries(3)
.retryDelayMs(1000)
.timeoutMs(10000)
.circuitBreakerThreshold(5) // 连续 5 次失败触发熔断
.circuitBreakerRecoveryMs(60_000) // 60 秒后半开尝试恢复
.build();
}
}
3.4 重试管道:指数退避 + 可重试错误判断
@Slf4j
@Component
public class RetryPipeline {
private static final Set<String> RETRYABLE_ERRORS = Set.of(
"CONNECTION_TIMEOUT",
"READ_TIMEOUT",
"SERVER_ERROR_5XX",
"RATE_LIMITED",
"NETWORK_IO_ERROR"
);
/**
* 带指数退避的重试执行
*/
public <T> T executeWithRetry(
ApiConfig config,
Supplier<T> call,
String apiName,
String operation) {
int maxRetries = config.getMaxRetries();
long baseDelay = config.getRetryDelayMs();
Exception lastException = null;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
long start = System.currentTimeMillis();
T result = call.get();
long latency = System.currentTimeMillis() - start;
log.info("[{}] {} 第{}次调用成功, 耗时 {}ms",
apiName, operation, attempt + 1, latency);
return result;
} catch (Exception e) {
lastException = e;
if (!isRetryable(e) || attempt == maxRetries) {
log.error("[{}] {} 第{}次调用失败(不可重试或已达上限): {}",
apiName, operation, attempt + 1, e.getMessage());
throw new ApiInvokeException(apiName, operation, e);
}
// 指数退避:1s -> 2s -> 4s,加随机抖动避免惊群
long delay = baseDelay * (1L << attempt); // 指数退避
delay += ThreadLocalRandom.current().nextLong(0, delay / 2); // 抖动
log.warn("[{}] {} 第{}次调用失败, {}ms 后重试: {}",
apiName, operation, attempt + 1, delay, e.getMessage());
sleepUnchecked(delay);
}
}
throw new ApiInvokeException(apiName, operation, lastException);
}
private boolean isRetryable(Exception e) {
if (e instanceof HttpTimeoutException) return true;
if (e instanceof ConnectException) return true;
if (e instanceof SocketException) return true;
// 503 Service Unavailable / 429 Too Many Requests
if (e.getMessage() != null &&
(e.getMessage().contains("503") || e.getMessage().contains("429"))) {
return true;
}
return false;
}
private void sleepUnchecked(long millis) {
try {
Thread.sleep(millis); }
catch (InterruptedException ie) {
Thread.currentThread().interrupt(); }
}
}
3.5 熔断管道:简易但实用的滑动窗口熔断器
@Slf4j
@Component
public class CircuitBreakerPipeline {
// Key: "apiName:operation", Value: 熔断状态
private final ConcurrentHashMap<String, CircuitState> stateMap = new ConcurrentHashMap<>();
/**
* 熔断保护执行
*/
public <T> T executeWithBreaker(
ApiConfig config,
String apiKey,
Supplier<T> call) throws CircuitBreakerOpenException {
CircuitState state = stateMap.computeIfAbsent(apiKey,
k -> new CircuitState(config.getCircuitBreakerThreshold()));
// 检查熔断状态
if (state.isOpen()) {
if (state.shouldTryHalfOpen(config.getCircuitBreakerRecoveryMs())) {
log.info("[{}] 熔断器进入半开状态,尝试放行一个请求", apiKey);
state.transitionTo(CircuitStatus.HALF_OPEN);
} else {
log.warn("[{}] 熔断器开启,拒绝请求", apiKey);
throw new CircuitBreakerOpenException(apiKey);
}
}
try {
T result = call.get();
// 成功时重置
if (state.getStatus() == CircuitStatus.HALF_OPEN) {
log.info("[{}] 半开请求成功,熔断器关闭", apiKey);
}
state.reset();
return result;
} catch (Exception e) {
state.recordFailure();
if (state.isThresholdReached()) {
log.error("[{}] 连续失败 {} 次,熔断器开启",
apiKey, state.getFailureCount());
state.transitionTo(CircuitStatus.OPEN);
}
throw e;
}
}
/** 熔断状态枚举 */
enum CircuitStatus {
CLOSED, OPEN, HALF_OPEN }
/** 滑动窗口状态 */
static class CircuitState {
private final int threshold;
private final Deque<Long> failureTimestamps = new ConcurrentLinkedDeque<>();
private volatile CircuitStatus status = CircuitStatus.CLOSED;
private volatile long openedAt;
CircuitState(int threshold) {
this.threshold = threshold; }
boolean isOpen() {
return status == CircuitStatus.OPEN; }
void transitionTo(CircuitStatus newStatus) {
this.status = newStatus;
if (newStatus == CircuitStatus.OPEN) {
this.openedAt = System.currentTimeMillis();
}
}
void recordFailure() {
failureTimestamps.addLast(System.currentTimeMillis());
// 只保留 60 秒窗口内的失败
long cutoff = System.currentTimeMillis() - 60_000;
while (!failureTimestamps.isEmpty()
&& failureTimestamps.peekFirst() < cutoff) {
failureTimestamps.removeFirst();
}
}
int getFailureCount() {
return failureTimestamps.size(); }
boolean isThresholdReached() {
return getFailureCount() >= threshold; }
boolean shouldTryHalfOpen(long recoveryMs) {
return System.currentTimeMillis() - openedAt > recoveryMs;
}
void reset() {
failureTimestamps.clear();
status = CircuitStatus.CLOSED;
openedAt = 0;
}
CircuitStatus getStatus() {
return status; }
}
}
3.6 统一客户端:把所有管道串起来
@Slf4j
@Component
public class ThirdPartyApiClient {
private final Map<String, ThirdPartyAdapter> adapterMap;
private final HttpClient httpClient;
private final RetryPipeline retryPipeline;
private final CircuitBreakerPipeline circuitBreaker;
public ThirdPartyApiClient(List<ThirdPartyAdapter> adapters,
RetryPipeline retryPipeline,
CircuitBreakerPipeline circuitBreaker) {
// Spring 自动注入所有 ThirdPartyAdapter 实现,按名称索引
this.adapterMap = adapters.stream()
.collect(Collectors.toMap(ThirdPartyAdapter::getName, Function.identity()));
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.retryPipeline = retryPipeline;
this.circuitBreaker = circuitBreaker;
}
/**
* 业务层唯一需要调用的方法
*/
public String call(String apiName, String operation, @NonNull ApiRequest request) {
ThirdPartyAdapter adapter = adapterMap.get(apiName);
if (adapter == null) {
throw new IllegalArgumentException("未找到适配器: " + apiName);
}
String apiKey = apiName + ":" + operation;
long startTime = System.currentTimeMillis();
// 外层:熔断保护
return circuitBreaker.executeWithBreaker(adapter.getConfig(), apiKey, () -> {
// 内层:重试保护
return retryPipeline.executeWithRetry(
adapter.getConfig(),
() -> doCall(adapter, request, apiKey, startTime),
apiName,
operation
);
});
}
private String doCall(ThirdPartyAdapter adapter, ApiRequest request,
String apiKey, long startTime) {
// 鉴权
request.setTimeoutMs(adapter.getConfig().getTimeoutMs());
adapter.authenticate(request);
// 发送 HTTP 请求
HttpRequest httpRequest = adapter.convert(request);
HttpResponse<String> httpResponse;
try {
httpResponse = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
throw new ApiInvokeException(apiKey, "网络请求失败", e);
}
// 解析响应
ApiResponse response = adapter.parse(httpResponse);
response.setLatencyMs(System.currentTimeMillis() - startTime);
response.setTraceId(MDC.get("traceId"));
// 日志埋点
log.info("[{}] status={} success={} latency={}ms errorCode={}",
apiKey, response.getHttpStatus(), response.isSuccess(),
response.getLatencyMs(), response.getErrorCode());
if (!adapter.isSuccess(response)) {
throw new ApiBusinessException(apiKey,
response.getErrorCode(), response.getErrorMessage());
}
return response.getRawBody();
}
}
3.7 业务层使用:极其简洁
@Service
public class CompanyService {
@Autowired
private ThirdPartyApiClient apiClient;
/**
* 查询企业信息 —— 业务层完全不知道底层调的是天眼查还是企查查
* 切换供应商只需改一个配置项
*/
public CompanyInfo queryCompany(String companyName) {
ApiRequest request = ApiRequest.builder()
.method("GET")
.path("/v1/company/search")
.queryParams(Map.of("keyword", companyName))
.build();
// 一行调用,背后是:适配器转换 → 鉴权 → 熔断 → 重试 → 解析
String rawJson = apiClient.call("TianYanCha", "searchCompany", request);
return parseToCompanyInfo(rawJson);
}
}
四、实战案例:对接一个新 API 只需 3 步
假设现在要接入 高德地图地理编码 API,只需:
Step 1:写适配器
@Component
public class AmapGeocodeAdapter implements ThirdPartyAdapter {
@Value("${api.amap.key}")
private String apiKey;
@Value("${api.amap.base-url}")
private String baseUrl;
@Override
public String getName() {
return "Amap";
}
@Override
public void authenticate(ApiRequest request) {
// 高德地图:key 放在 query 参数
request.getQueryParams().put("key", apiKey);
}
@Override
public HttpRequest convert(ApiRequest request) {
// 标准 HTTP 转换(与天眼查几乎一样,可以抽一个 BaseAdapter)
String fullUrl = baseUrl + request.getPath() + "?"
+ buildQueryString(request.getQueryParams());
return HttpRequest.newBuilder()
.uri(URI.create(fullUrl))
.timeout(Duration.ofMillis(request.getTimeoutMs()))
.GET()
.build();
}
@Override
public ApiResponse parse(HttpResponse rawResponse) {
// 高德返回格式:{"status":"1","info":"OK","geocodes":[...]}
JSONObject json = JSON.parseObject(rawResponse.body());
boolean success = "1".equals(json.getString("status"));
return ApiResponse.builder()
.httpStatus(rawResponse.statusCode())
.rawBody(rawResponse.body())
.success(success)
.errorCode(json.getString("infocode"))
.errorMessage(json.getString("info"))
.build();
}
@Override
public boolean isSuccess(ApiResponse response) {
return response.isSuccess();
}
@Override
public ApiConfig getConfig() {
return ApiConfig.builder()
.baseUrl(baseUrl)
.maxRetries(2)
.retryDelayMs(500)
.timeoutMs(5000)
.circuitBreakerThreshold(5)
.circuitBreakerRecoveryMs(30_000)
.build();
}
}
Step 2:加配置
api:
amap:
key: your_amap_key
base-url: https://restapi.amap.com
Step 3:直接调用
ApiRequest request = ApiRequest.builder()
.method("GET")
.path("/v3/geocode/geo")
.queryParams(Map.of("address", "北京市朝阳区"))
.build();
String result = apiClient.call("Amap", "geocode", request);
// 自动享受:鉴权注入、指数退避重试、熔断保护、日志埋点
完成。 不用写任何重试逻辑、错误处理、超时配置,这些都是框架层面的事。
五、踩坑经验:50 个 API 教会我的 5 条规则
坑 1:不要信任第三方的"成功"定义
有的 API 返回 HTTP 200 但 body 里是 {"code": 500},有的返回 HTTP 500 但 body 里写"其实是成功了"。永远在 parse() 里同时检查 HTTP 状态码和业务状态码。
// 这是我在线上查了一下午才找到的 bug 根源
// 某短信平台:发送成功返回 HTTP 200 + {"code": "0"}
// 但你的额度用完时,它也返回 HTTP 200 + {"code": "-10086", "msg": "余额不足"}
// 如果只看 HTTP 200 就认为成功,就会漏掉这个 case
坑 2:重试要有上限,并且必须区分可重试错误
不是所有错误都适合重试。"余额不足"重试 3 次还是余额不足,但每次都消耗一次 HTTP 请求配额。 参考上面 RetryPipeline 里的 isRetryable() 判断逻辑。
坑 3:超时时间不要一刀切
一刀切设 10 秒是在给自己埋坑。我的经验:
| 场景 | 建议超时 | 原因 |
|---|---|---|
| 短信发送 | 15s | 运营商返回慢 |
| 企业信息查询 | 5s | 纯数据库查询 |
| 支付下单 | 10s | 需要留余地 |
| 地图反向地理编码 | 3s | 高频调用,超时就 fast-fail |
坑 4:API Key 泄露是最大安全隐患
所有 Key/Secret 必须走配置中心(Apollo/Nacos),不要在代码里硬编码。代码仓库里搜到过 AK/SK 的,都不是个例。
// ❌ 绝对不要这样写
private static final String API_KEY = "sk-xxxxxxxxxxxx";
// ✅ 从配置中心注入
@Value("${api.tianyancha.app-key}")
private String appKey;
坑 5:一定要埋点:耗时、命中率、熔断次数
没有监控的 API 封装就是在裸奔。至少记录这几个指标:
// 关键指标
// 1. 调用量 + 成功率 (方便做告警)
// 2. P50/P99 耗时 (发现某方 API 变慢)
// 3. 熔断触发次数 (发现上游问题)
// 4. 重试次数分布 (发现网络问题)
log.info("api_metric|{}|{}|{}|{}|{}|{}|{}",
apiName, operation, // 维度
success ? 1 : 0, // 成功标识
latencyMs, // 耗时
retryCount, // 重试次数
circuitBreakerOpened ? 1 : 0, // 熔断标识
errorCode); // 错误码
接入 Prometheus + Grafana 后,一眼就能看出哪个第三方供应商不靠谱。
六、进阶方向
这套架构已经在生产环境跑了快两年,支撑了 50+ 第三方 API、日均百万级调用。如果要继续演进,有几个方向:
| 方向 | 说明 |
|---|---|
| 异步化 | 将 call() 改为返回 CompletableFuture,支持并发调用多个 API 后聚合 |
| 多级缓存 | 对查询类 API(企业信息、地图地理编码)加 Caffeine + Redis 两级缓存 |
| 供应商切换 | 通过配置动态选择"天眼查 vs 企查查",某个挂了自动降级到备用 |
| 协议扩展 | 目前只支持 HTTP,加入 gRPC / Dubbo 适配器支持内部 RPC 调用 |
七、总结
这套方案的核心价值不是让你少写几行代码,而是把 50 个第三方 API 的复杂度收敛到一个地方。当你需要:
- 统一改重试策略 → 改
RetryPipeline一处 - 接入新第三方 → 实现
ThirdPartyAdapter接口即可 - 排查线上问题 → 所有调用都有统一的 traceId 和日志格式
- 某个 API 挂了 → 熔断器自动保护,不会拖垮整个系统
这就是架构的价值 —— 不让你在第 51 个 API 面前崩溃。