背景

在日常的业务开发中,HTTP 客户端的封装可以说是最为常见的基础模块,无论是调用第三方 API 服务、对接内部微服务还是抓取外部数据,几乎每一个项目中都能看到类似于 HttpUtilsHttpClientWrapper 的身影。然而看似简单的网络请求,在不同的经验层级与架构范式下,写出来的代码质量与设计哲学却有着天壤之别。很多初级的封装仅仅停留在‘把功能调通’的阶段,忽略了底层 TCP 连接池、资源生命周期管理以及超时控制,为线上高并发场景埋下了严重的性能与稳定性隐患。

本文将以一个普通的 HTTP 客户端封装为例一步步演进重构,探讨如何写出完善严谨的代码,并借此梳理软件架构设计与编程范式的变迁。

第一次封装

在一些祖传项目或是更早的基于 JDK 1.7 以及后来的 JDK 8 项目中经常可以看到 Apache HttpClient 类库的身影,以下参考的 HttpUtils 可能是某个项目上默默工作的最为平凡的封装。

public class HttpUtils {

    private static final Logger log = LoggerFactory.getLogger(HttpUtils.class);

    public static String doGet(String url, Map<String, String> headers) {
        CloseableHttpClient httpClient = HttpClients.createDefault();
        HttpGet httpGet = new HttpGet(url);

        if (headers != null) {
            headers.forEach(httpGet::addHeader);
        }

        CloseableHttpResponse response = null;
        try {
            response = httpClient.execute(httpGet);
            if (response.getStatusLine().getStatusCode() == 200) {
                HttpEntity entity = response.getEntity();
                return entity != null ? EntityUtils.toString(entity, StandardCharsets.UTF_8) : null;
            } else {
                log.error("GET 请求失败 - URL: {} Status Code: {}", url, response.getStatusLine().getStatusCode());
            }
        } catch (Exception e) {
            log.error("GET 请求发生异常 - URL: {} Exception: ", url, e);
        } finally {
            if (response != null) {
                try {
                    response.close();
                } catch (IOException e) {
                    log.error("关闭 response 失败", e);
                }
            }
        }
        return null;
    }

    public static String doPostJson(String url, String jsonPayload) {
        CloseableHttpClient httpClient = HttpClients.createDefault();
        HttpPost httpPost = new HttpPost(url);
        httpPost.setHeader("Content-Type", "application/json;charset=UTF-8");

        CloseableHttpResponse response = null;
        try {
            if (jsonPayload != null) {
                httpPost.setEntity(new StringEntity(jsonPayload, StandardCharsets.UTF_8));
            }
            response = httpClient.execute(httpPost);
            if (response.getStatusLine().getStatusCode() == 200) {
                return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
            }
        } catch (Exception e) {
            log.error("POST 请求发生异常 - URL: {} Exception: ", url, e);
            throw new RuntimeException("HTTP POST Failed", e);
        } finally {
            if (response != null) {
                try {
                    response.close();
                } catch (IOException e) {
                    log.error("关闭 response 失败", e);
                }
            }
        }
        return null;
    }
}

这段代码逻辑并不复杂,可以说有着三到五年经验的 Java 开发者都能写出来。同时相信比较有经验的开发者一眼便可以看出这段代码中存在一些比较明显问题。首先是资源的频繁创建与销毁,每次调用 doGetdoPostJson 都会执行 HttpClients.createDefault(),由于没有使用单例或连接池(PoolingHttpClientConnectionManager),在高并发场景下会导致大量 TIME_WAIT 状态的 TCP 连接;其次是资源没有正确释放,CloseableHttpClient 本身实现了 Closeable 接口,但代码中没有关闭 httpClient,很容易导致 HTTP 连接无法释放;另外就是灵活性差,没有暴露超时等关键参数,全局采用默认配置,面对不稳定的下游服务时会造成线程卡死。当然还有其他一些问题在此处不再一一列举。

通过更好的类库进行改进

考虑到 Apache HttpClients 这个库诞生已经很久了不能说跟不上目前 Java 的发展趋势倒也可以说在针对 HTTP 等场景出现了很多更好的方案。为了解决以上问题同时契合现代化 Java 开发,接下来引入 okHttp 作为底层接入库。

public class SmartHttpClient {

    private static final Logger log = LoggerFactory.getLogger(SmartHttpClient.class);

    private final OkHttpClient okHttpClient;

    public SmartHttpClient() {
        this.okHttpClient = new OkHttpClient.Builder()
                .connectTimeout(5, TimeUnit.SECONDS)
                .readTimeout(10, TimeUnit.SECONDS)
                .writeTimeout(10, TimeUnit.SECONDS)
                .connectionPool(new ConnectionPool(32, 5, TimeUnit.MINUTES))
                .build();
    }

    public record HttpResponse(int statusCode, String body, Map<String, String> headers) {
        public boolean isSuccessful() {
            return statusCode >= 200 && statusCode < 300;
        }
    }

    public static class HttpClientException extends Exception {
        private final int statusCode;

        public HttpClientException(String message, Throwable cause) {
            super(message, cause);
            this.statusCode = -1;
        }

        public HttpClientException(int statusCode, String message) {
            super(message);
            this.statusCode = statusCode;
        }

        public int getStatusCode() {
            return statusCode;
        }
    }

    public HttpResponse doGet(String url, Map<String, String> headers) throws HttpClientException {
        Request.Builder requestBuilder = new Request.Builder().url(url).get();
        if (headers != null) {
            headers.forEach(requestBuilder::addHeader);
        }
        return execute(requestBuilder.build());
    }

    public HttpResponse doPostJson(String url, String jsonPayload, Map<String, String> headers) throws HttpClientException {
        RequestBody body = RequestBody.create(jsonPayload, MediaType.parse("application/json; charset=utf-8"));
        Request.Builder requestBuilder = new Request.Builder().url(url).post(body);
        if (headers != null) {
            headers.forEach(requestBuilder::addHeader);
        }
        return execute(requestBuilder.build());
    }

    private HttpResponse execute(Request request) throws HttpClientException {
        try (Response response = okHttpClient.newCall(request).execute()) {
            
            int statusCode = response.code();
            String responseBody = "";

            ResponseBody body = response.body();
            if (body != null) {
                responseBody = body.string();
            }

            if (!response.isSuccessful()) {
                log.warn("HTTP 请求未成功 - Status: {} URL: {}", statusCode, request.url());
            }

            Map<String, String> headersMap = new HashMap<>();
            response.headers().forEach(pair -> headersMap.put(pair.getFirst(), pair.getSecond()));
            return new HttpResponse(statusCode, responseBody, headersMap);
        } catch (IOException e) {
            log.error("HTTP 请求发生 IO 异常 - URL: {} Exception: ", request.url(), e);
            throw new HttpClientException("Network execution failed for URL: " + request.url(), e);
        }
    }
}

以上代码首先通过单例模式OkHttpClient复用底层的 TCP 连接池与线程池,其内部的 ConnectionPoolDispatcher 被所有请求共享可以有效避免高并发下频繁建连导致的 TCP TIME_WAITSocket 耗尽等问题;其次利用 try-with-resources 语法,确保 ResponseBody 不论是正常返回还是发生异常,底层的 ResponseBody 都能被及时关闭并归还连接,避免了显式资源管理;另外引入了自定义的 Response 结构体与强类型异常,不再返回模糊的 null,让调用方感知 HTTP 状态码与网络层故障;同时通过为 OkHttpClient 配置 Interceptor,可以在不修改主逻辑的前提下统一实现全局链路日志记录、通用 Header 注入、重试等功能。

关于向上抛出异常与传递错误信息这一点在很多内部封装的二方包中处理的不太好进而导致掩盖了一些问题和线索,作为二方包应当将最原始的错误信息暴露给应用层或调用方。

引入不可变性及函数式思维

上一版本中由于依赖 throws HttpClientException 控制错误流程,在复杂的业务链路中受检异常会导致方法签名层层传染或是导致开发者在 Lambda 表达式中不得不编写丑陋的 try-catch 块。同时在 execute() 方法包含了网络 I/O 副作用,但在方法签名层面却和普通的纯内存计算无异,上层调用者无法通过类型系统直观看出这个操作是否包含副作用以及可能的错误分支。

接下来我们在前一版本的基础上通过引入 Vavr 这个函数式库继续完善,不再依靠抛出 Exception 控制流程也不再返回可能隐藏问题的裸对象。我们将 可能失败的计算显式的错误类型 提升为一等公民 First-class Citizen,利用 Monad(单子) 模式实现声明式的链式组合。

public class FnHttpClient {

    private final OkHttpClient okHttpClient;

    public FnHttpClient() {
        this.okHttpClient = new OkHttpClient.Builder()
                .connectTimeout(5, TimeUnit.SECONDS)
                .readTimeout(10, TimeUnit.SECONDS)
                .connectionPool(new ConnectionPool(32, 5, TimeUnit.MINUTES))
                .build();
    }

    public record HttpResponse(int statusCode, String body, Map<String, String> headers) {
        public boolean isSuccess() {
            return statusCode >= 200 && statusCode < 300;
        }
    }

    public sealed interface HttpError {
        record NetworkError(Throwable cause) implements HttpError {}
        record StatusError(int statusCode, String body) implements HttpError {}
    }

    public Either<HttpError, HttpResponse> doGet(String url, Map<String, String> headers) {
        Request.Builder builder = new Request.Builder().url(url).get();
        optionallyAddHeaders(builder, headers);
        return execute(builder.build());
    }

    public Either<HttpError, HttpResponse> doPostJson(String url, String jsonPayload, Map<String, String> headers) {
        RequestBody body = RequestBody.create(jsonPayload, MediaType.parse("application/json; charset=utf-8"));
        Request.Builder builder = new Request.Builder().url(url).post(body);
        otionallyAddHeaders(builder, headers);
        return execute(builder.build());
    }

    private Either<HttpError, HttpResponse> execute(Request request) {
        return Try.of(() -> executePhysicalCall(request))
                .toEither()
                .mapLeft(HttpError.NetworkError::new)
                .flatMap(response -> response.isSuccess()
                        ? Either.right(response)
                        : Either.left(new HttpError.StatusError(response.statusCode(), response.body())));
    }

    private HttpResponse executePhysicalCall(Request request) throws Exception {
        try (Response response = okHttpClient.newCall(request).execute()) {
            int code = response.code();
            ResponseBody body = response.body();
            String responseStr = (body != null) ? body.string() : "";

            Map<String, String> headersMap = HashMap.ofAll(response.headers().toMultimap())
                    .mapValues(list -> String.join(", ", list));

            return new HttpResponse(code, responseStr, headersMap);
        }
    }

    private void otionallyAddHeaders(Request.Builder builder, Map<String, String> headers) {
        if (headers != null && !headers.isEmpty()) {
            headers.forEach(builder::addHeader);
        }
    }
}

首先使用 VavrTry<T> 容器包裹网络 I/O 副作用将异常转化为可传输的值避免了上层应用需要显式处理受检异常;其次使用 Either<HttpError, HttpResponse> 强类型清晰区分是连接超时或 DNS 解析错误等网络系统层面故障还是业务层或 401500HTTP 协议层错误;

同时还解决了如果业务需要 发起请求 A -> 拿到结果解析后发起请求 B -> 失败则降级到请求 C,在 Java 的指令式风格下代码会退化为层层嵌套的 if-elsetry-catch,无法以声明式的方式进行组合问题。以下代码片段则展示了如何通过在 Either 类型上的 Monad 算子 (flatMap) 实现声明式的链式管道调用优雅地表达整个流程:

public class BusinessWorkflowExample {

    private static final Logger log = LoggerFactory.getLogger(BusinessWorkflowExample.class);
    private final FnHttpClient httpClient = new FnHttpClient();

    public Either<FnHttpClient.HttpError, UserDetail> getUserWorkflow(String userId) {

        // 获取 Token
        return fetchToken(userId)
                // 拿到 Token 后解析并发起请求 B
                .flatMap(token -> fetchUserDetail(userId, token))
                // 如果请求 B 失败降级执行请求 C
                .recoverWith(error -> {
                    log.warn("请求 B 失败, 启动降级逻辑调用请求 C - Exception: ", error);
                    return fetchGuestUserDetail(userId);
                });
    }

    /**
     * 请求 A 获取 Token
     */
    private Either<FnHttpClient.HttpError, String> fetchToken(String userId) {
        return httpClient.doGet("https://api.example.com/v1/auth/token?userId=" + userId, HashMap.empty())
                // map 会自动处理 Either 的 Right 情况, 如果是 Left 则直接返回
                .map(response -> parseTokenFromJson(response.body()));
    }

    /**
     * 请求 B 根据 Token 获取用户详情
     */
    private Either<FnHttpClient.HttpError, UserDetail> fetchUserDetail(String userId, String token) {
        var headers = HashMap.of("Authorization", "Bearer " + token);
        return httpClient.doGet("https://api.example.com/v1/users/" + userId, headers)
                .map(response -> parseUserDetailFromJson(response.body()));
    }

    /**
     * 请求 C 降级接口 - 备用游客模式
     */
    private Either<FnHttpClient.HttpError, UserDetail> fetchGuestUserDetail(String userId) {
        return httpClient.doGet("https://backup-api.example.com/v1/guest-users/" + userId, HashMap.empty())
                .map(response -> parseUserDetailFromJson(response.body()));
    }

    public record UserDetail(String id, String name, String type) {}
}

这样无论内部发生了多少次请求拼接和降级,上层业务方拿到的是一个干净的 Either 结果可以通过 fold 实现一站式消费:

Either<HttpError, UserDetail> result = workflow.getUserWorkflow("someUser");

result.fold(
    error  -> log.error("流程终极失败, 链路中所有重试降级均已耗尽 - Exception: ", error),
    detail -> log.info("成功获取用户详情 - name: {} type: {}", detail.name(), detail.type())
);

完全的函数式封装

接下来我们使用 Scala 来进一步完善 HTTP 客户端封装。考虑到很多 RESTful 服务需要进行鉴权,因此我们首先实现一个基于 Cats Effect 的并发 Token 管理器:

final case class TokenState(token: String, expiresAt: FiniteDuration)

trait TokenProvider[F[_]] {
  def getToken: F[String]
  def invalidate: F[Unit] // 收到 401 时调用, 强制下次重新获取
}

object TokenProvider {
  def make[F[_]: Async](
      fetchToken: F[TokenState],                // 实际调用鉴权接口获取新 token 的逻辑
      safetyMargin: FiniteDuration = 30.seconds // 提前多久算过期(默认 30s), 避免临界点问题
  ): F[TokenProvider[F]] =
    for {
      ref <- Ref.of[F, Option[TokenState]](None)
      sem <- Semaphore[F](1) // 保证同一时刻只有一个请求在刷新
    } yield new TokenProvider[F] {

      private def isExpired(s: TokenState, now: FiniteDuration): Boolean =
        now >= s.expiresAt - safetyMargin

      private def refresh: F[TokenState] =
        sem.permit.use { _ =>
          for {
            now <- Clock[F].monotonic
            cur <- ref.get
            // 双重检查 - 拿到锁后再判断一次, 避免重复刷新
            state <- cur match {
              case Some(s) if !isExpired(s, now) => Async[F].pure(s)
              case _                             => fetchToken.flatTap(s => ref.set(Some(s)))
            }
          } yield state
        }

      def getToken: F[String] =
        for {
          now   <- Clock[F].monotonic
          cur   <- ref.get
          state <- cur match {
            case Some(s) if !isExpired(s, now) => Async[F].pure(s)
            case _                             => refresh
          }
        } yield state.token

      def invalidate: F[Unit] = ref.set(None)
    }
}

这里我们在定义用于存储 TokenTokenState 时选择使用单调时钟Monotonic Clock)代替墙上时钟Wall-clock)可以避免系统时间回拨引起的 Token 提前失效或永不失效问题。我们只需在获取 Token 时记录当前的单调时间,并加上 expires_in 的偏移量,即可安全判断过期状态。值得一提的是这里的 F[_] 并非普通的泛型而是 Scala 中特有的 高阶类型F 自身并不是一个完整的类型且必须接受一个类型参数才能变成具体类型,从而将具体实现交给调用方选择是使用 Scala 标准库的 Future 还是 Cats EffectIO 抑或是 ZIO 等具体实现。

在定义好 TokenProvider 后接下来就是定义具体如何获取 TokenTokenFetcher 了:

final case class OAuthConfig(
    tokenUrl: sttp.model.Uri,
    clientId: String,
    clientSecret: String,
    scope: Option[String] = None,
    extraParams: Map[String, String] = Map.empty
)

// 携带 HTTP 状态码,方便区分哪些错误值得重试
final case class TokenFetchException(status: Int, message: String) extends RuntimeException(message)

object TokenFetcher extends LazyLogging {

  // 真正发起 HTTP 请求获取 token 的逻辑(不含重试)
  private def rawFetchToken[F[_]: Async](
      backend: Backend[F],
      config: OAuthConfig
  ): F[TokenState] = {
    val body = Map(
      "grant_type"    -> "client_credentials",
      "client_id"     -> config.clientId,
      "client_secret" -> config.clientSecret
    ) ++ config.scope.map("scope" -> _) ++ config.extraParams
    for {
      resp <- basicRequest
        .post(config.tokenUrl)
        .body(body)
        .response(asJson[TokenResponse])
        .send(backend)
      now   <- Clock[F].monotonic
      state <- resp.body match {
        case Right(tr) =>
          Async[F].pure(TokenState(tr.accessToken, now + tr.expiresIn.seconds))
        case Left(err) =>
          Async[F].raiseError[TokenState](TokenFetchException(resp.code.code, err.getMessage))
      }
    } yield state
  }

  // 判断哪些异常值得重试: 网络抖动, 超时, 5xx 需要重试; 4xx(凭证错误等配置问题)不重试
  private def isRetriable[F[_]: Async](e: Throwable): F[Boolean] = e match {
    case _: java.net.ConnectException                    => Async[F].pure(true)
    case _: java.util.concurrent.TimeoutException        => Async[F].pure(true)
    case TokenFetchException(status, _) if status >= 500 => Async[F].pure(true)
    case TokenFetchException(status, _) if status < 500  => Async[F].pure(false)
    case _                                               => Async[F].pure(true) // 未知异常保守重试
  }

  /** 构造带重试/退避策略的 fetchToken,直接传给 TokenProvider.make */
  def withRetry[F[_]: Async](
      backend: Backend[F],
      config: OAuthConfig,
      maxRetries: Int = 5,
      baseDelay: FiniteDuration = 200.millis,
      maxDelay: FiniteDuration = 5.seconds
  ): F[TokenState] = {
    // fullJitter:指数退避基础上加随机抖动,避免多实例同步重试打爆鉴权服务
    val basePolicy = RetryPolicies
      .fullJitter[F](baseDelay)
      .join(RetryPolicies.limitRetries[F](maxRetries))
    val policy = RetryPolicies.capDelay[F](maxDelay, basePolicy)

    retryingOnSomeErrors[TokenState](
      policy = policy,
      isWorthRetrying = isRetriable[F],
      onError = (e: Throwable, details: RetryDetails) =>
        Async[F].delay(
          logger.info(
            s"[TokenFetcher] fetch token failed (${details.retriesSoFar} retries so far), retrying... error=${e.getMessage}"
          )
        )
    )(rawFetchToken[F](backend, config))
  }
}

接下来是业务层或调用方根据实际业务场景定义具体的业务接口:

final class APIClient[F[_]: Async](backend: Backend[F], baseUrl: Uri) extends LazyLogging {

  /** 获取任务列表 */
  def listTasks(): F[List[TaskSummary]] =
    basicRequest
      .get(baseUrl.addPath("api", "tasks"))
      .response(asJson[List[TaskSummary]])
      .send(backend)
      .flatMap(handleResponse)

  /** 获取任务详情 */
  def getTaskDetail(taskId: String): F[TaskDetail] =
    basicRequest
      .get(baseUrl.addPath("api", "tasks", taskId))
      .response(asJson[TaskDetail])
      .send(backend)
      .flatMap(handleResponse)

  def uploadFile(taskId: String, fileName: String, fileBytes: Array[Byte]): F[UploadResult] =
    basicRequest
      .post(baseUrl.addPath("api", "tasks", taskId, "files"))
      .multipartBody(
        multipart("file", fileBytes).fileName(fileName)
      )
      .response(asJson[UploadResult])
      .send(backend)
      .flatMap(handleResponse)

  /** 统一处理响应:成功则取出解析好的 body 失败则包装成异常抛出 */
  private def handleResponse[T](resp: Response[Either[ResponseException[String], T]]): F[T] =
    resp.body match {
      case Right(value) => Async[F].pure(value)
      case Left(err)    => Async[F].raiseError(ExternalApiException(resp.code.code, err.getMessage))
    }
}

在具体调用时针对此前定义的 F[_] 需要显式指定副作用容器:

  implicit private val ioRuntime: IORuntime = cats.effect.unsafe.IORuntime.global

  private val (client, releaseClient): (APIClient[IO], IO[Unit]) = {
    val resource: Resource[IO, MCAPIClient[IO]] =
      for {
        httpBackend <- HttpClientCatsBackend.resource[IO]()
        authBackend <- Resource.eval {
          TokenProvider
            .make[IO](fetchToken = TokenFetcher.withRetry[IO](httpBackend, oauthConfig))
            .map(tp => new AuthBackend[IO](httpBackend, tp))
        }
      } yield new APIClient[IO](authBackend, Uri.parse(baseUrl).toOption.get)

    resource.allocated.unsafeRunSync()
  }

  /** 系统内任何地方都可以通过 `ExternalApiClientExtension(system).client` 拿到这个共享实例 */
  def apiClient: APIClient[IO] = client

  logger.info(s"[ExternalAPIExtension] 外部客户端初始化完成 - baseURL: $baseUrl")

值得一提的是这里忽略了 APIClient 生命周期管理,如果是基于 Spring 环境则可以利用 Spring 框架的 Bean 进行生命周期管理,当然在 Scala 中选择又有所不同,具体可以根据实际情况进行讨论。

总结

回顾整个 HTTP 客户端的演进历程,我们不仅完成了一套代码的重构,更经历了一场从指令式工程套路到纯函数式 Effect 系统的思想跃迁。刚开始关注如何快速实现功能但容易因缺乏资源意识而留下严重的线上隐患,后续通过引入 OkHttp单例模式try-with-resources 与结构化异常解决了高并发下的套接字泄露与连接复用问题,达到了生产可用的标准工程质量。接着引入 FP 理念通过 Vavr 引入 EitherTry,将错误转化为显式的值并利用 flatMaprecoverWithMonad 算子,优雅地消除了层层嵌套的 if-else 与受检异常,让多步依赖与降级链路变成了流畅的声明式管道。 最后通过 Scala + Cats Effect 的纯函数式架构跨越到了纯函数式的终极形态。利用高阶类型 F[_] 与代数数据类型我们将 HTTP 请求抽象为纯净的计算蓝图(IO)。

架构设计从来没有绝对的**银弹,**只有在特定语境下的权衡与选择。在实际业务中我们或许并不是每一个项目都有使用 Cats EffectZIO 等重型 FP 工具的场景,乃至 Scala 的使用也是极其罕见,但理解这四种范式的演进逻辑能让我们在面对复杂系统设计时拥有更高的视角——学会如何控制副作用、如何设计严谨的类型契约,以及如何写出真正健壮、易于推导与组合的代码。

补充

考虑到目前基于 Spring 的微服务项目占据了绝大多数,相当多的团队都采用了基于声明式调用的 OpenFeignRetrofit 作为服务间调用工具。这些框架虽然极大地简化了业务开发流程,但正因为其高度屏蔽了底层的网络细节与错误处理机制,容易让开发者对包括 TCP 连接池、超时熔断以及错误码传递等在内的核心机制产生认知盲区。当遇到复杂链路故障或性能瓶颈时,缺乏对底层演进脉络理解的开发者往往无所适从。理解从最基础的 HttpClient 到函数式 Effect 系统的演进并不是要求在所有项目中都盲目推行 FP 框架,而是为了让我们在面对黑盒工具时,能够保持对底层运行机制的掌控力,在架构选型与故障排查时看清事物的本质