目录

    Phoenix监控平台技术解析(二):HTTP通信通道——请求/响应模型与RestTemplate封装

    Phoenix 支持 HTTP 和 WebSocket 双通道通信。本篇聚焦 HTTP 通道,从请求/响应的数据模型、客户端连接池封装、代理端 RestTemplate 配置、服务端接收与 AOP 加解密切面,逐层拆解 HTTP 通信的完整链路。


    一、先聊聊:监控数据怎么送出去?

    假设你开发了一个 Java 应用,接入了 Phoenix 的客户端 SDK。此刻,你的应用就像一个每隔 30 秒要向总部"报平安"的前哨站——它需要把心跳、JVM 状态、线程池快照等信息,源源不断地发往监控服务端。

    问题来了:这些数据怎么送过去?

    最直观的方案就是 HTTP。每隔一段时间,客户端构造一个 POST 请求,把监控数据塞进请求体,发到服务端的某个接口,服务端处理完返回一个 200 OK ——经典的请求-响应模型,简单、可靠、人人都懂。

    Phoenix 的 HTTP 通道正是这么做的。但"简单"不等于"简陋",当你深入源码会发现,这条看似朴素的 HTTP 通道上,藏着连接池管理、自适应压缩、透明加解密、声明式重试等一系列精心设计。

    在正式拆解之前,先明确一点:Phoenix 同时支持 HTTPWebSocket 双通道。两者的分工大致如下:

    维度 HTTP 通道 WebSocket 通道
    通信模式 请求-响应,短连接 全双工,长连接
    典型场景 告警上报、UI端操作指令、配置刷新 心跳、JVM、服务器信息等高频定时上报
    底层实现 Apache HttpClient 连接池 / Spring RestTemplate Netty WebSocket

    从源码可以看到,客户端的 HeartbeatThread(心跳线程)中已经把心跳发送从 HTTP 切换到了 WebSocket:

    // 改成用 WebSocket,弃用 HTTP
    // String result=Sender.send(UrlConstants.HEARTBEAT_URL,heartbeatPackage.toJsonString());
    WebSocketPackage requestPackage = new WebSocketPackage();
    requestPackage.setClassName(HeartbeatPackage .class.getName());
    requestPackage.setPayload(heartbeatPackage);
    DataExchanger.sendMessage(requestPackage);
    

    但 HTTP 通道并没有退役——告警上报、异常上报、UI 端的数据库会话管理、配置刷新等场景仍然走 HTTP。两条通道各司其职,互为补充。

    好,背景交代完毕,让我们正式进入 HTTP 通道的世界。

    二、数据模型:给监控数据一个"身份证"

    你寄快递的时候,除了包裹本身,快递单上还要写寄件人、收件人、电话、地址……这些元信息(metadata)让物流系统知道这个包裹从哪来、到哪去、是谁寄的

    Phoenix 的监控数据包也一样。无论你发送的是心跳包、告警包还是 JVM 信息包,它们都需要携带一组标准的元信息。Phoenix 通过一套继承体系来实现这一点。

    2.1 先看全貌

    ISuperBean(顶层接口:定义 toJsonString() 序列化方法)
      └── AbstractSuperBean(抽象基类)
            └── AbstractSuperPackage(抽象父包:携带身份标识)
                  ├── BaseRequestPackage(基础请求包:+ID、时间、附加信息)
                  │     ├── HeartbeatPackage(心跳包)
                  │     ├── ServerPackage(服务器信息包)
                  │     ├── JvmPackage(JVM信息包)
                  │     ├── AlarmPackage(告警包)
                  │     ├── ExceptionPackage(异常包)
                  │     ├── CommandPackage(命令包)
                  │     ├── DockerPackage(Docker信息包)
                  │     └── ...更多业务包
                  └── BaseResponsePackage(基础响应包:+ID、时间、处理结果)
    
    CiphertextPackage(密文数据包:独立体系,只有密文+压缩标志)
    

    看起来层级不少,但逻辑很清晰:每一层只做一件事

    2.2 ISuperBean:一切的起点

    整个 DTO 体系的根接口,只做一件事——定义 JSON 序列化方法:

    public interface ISuperBean {
        default String toJsonString() {
            return JSON.toJSONString(this, SerializerFeature.WriteMapNullValue);
        }
    }
    

    有个细节值得注意:WriteMapNullValue。这意味着序列化时会保留值为 null 的字段。为什么?

    在监控场景中,null 和"字段不存在"是两种不同的语义。比如服务器的 GPU 温度字段——null 表示"采集了但没有 GPU",而字段缺失可能意味着"这个版本的客户端根本不支持 GPU 采集"。保留 null 值让接收方能精确区分这两种情况。

    2.3 AbstractSuperPackage:数据包的"快递单"

    这是所有请求包和响应包的抽象父类,也是最关键的一层——它定义了"这个数据包是谁发出来的":

    public abstract class AbstractSuperPackage extends AbstractSuperBean {
        protected String instanceEndpoint;      // 端点类型:server/agent/client/ui
        protected String instanceId;            // 应用实例ID(全局唯一)
        protected String instanceName;          // 实例名称(如 "order-service")
        protected String instanceDesc;          // 实例描述
        protected String instanceLanguage;      // 程序语言(如 "Java")
        protected AppServerTypeEnums appServerType; // 应用服务器类型
        protected String ip;                    // 发送方IP
        protected String computerName;          // 计算机名
        protected Chain chain;                  // 链路信息(谁转发给谁)
    }
    

    想象一下:服务端同时接收着几十个应用实例的监控数据。没有这些字段,它根本无法分辨"这个心跳是谁的"。instanceId 就是每个应用实例的"身份证号"。chain 字段更有意思——它记录了数据的传递链路。比如一个心跳包的链路可能是 client → agent → server,这个链路信息后续被用来构建服务拓扑图。数据在流转的过程中,顺便把自己的行经路线记了下来。

    2.4 请求包 vs 响应包

    BaseRequestPackage(基础请求包)在"快递单"基础上加了三样东西:

    public class BaseRequestPackage extends AbstractSuperPackage {
        protected String id;              // 包ID(UUID,唯一标识这次请求)
        protected Date dateTime;          // 发送时间
        protected JSONObject extraMsg;    // 附加信息(一个万能的JSON口袋)
    }
    

    extraMsg 的设计很灵活——它是一个 JSONObject,可以往里面塞任何键值对。当某些场景需要传递临时性的额外参数时,不需要改继承体系,直接往 extraMsg 里扔就行。

    BaseResponsePackage(基础响应包)则简单得多:

    public class BaseResponsePackage extends AbstractSuperPackage {
        protected String id;
        protected Date dateTime;
        protected Result result;    // 处理结果:成功/失败 + 消息
    }
    

    Result 只有两个字段:boolean isSuccessString msg——干净利落,够用就好。

    2.5 具体业务包怎么写?

    有了这套基础设施,写一个新的业务包非常轻松。以 HeartbeatPackage 为例:

    public class HeartbeatPackage extends BaseRequestPackage {
        private long rate;                          // 心跳频率(秒)
        private boolean isEnableArthas = false;     // 是否启用Arthas诊断
        private boolean isCollectVmMetrics = true;  // 是否收集VM指标
        private boolean isCollectThreadPoolMetrics = false; // 是否收集线程池指标
    }
    

    继承 BaseRequestPackage 后,身份标识、时间、ID 等字段自动就有了,心跳包只需要声明自己独有的业务字段。新增一种监控类型,只需加一个子类——这就是继承体系带来的扩展性。

    2.6 CiphertextPackage:信封里的密信

    前面说的所有数据包,在网络上传输时并不是"裸奔"的。它们会被加密后装进一个"信封"——CiphertextPackage

    public class CiphertextPackage extends AbstractSuperBean {
        private String ciphertext;          // 加密后的密文(Base64字符串)
        private boolean isUnGzipEnabled;    // 接收方是否需要先解压
    }
    

    只有两个字段。在 HTTP 传输层面,接收方看到的 body 永远是这个结构——密文 + 一个布尔标志。至于密文里面装的是心跳包还是告警包,只有解密之后才知道。

    为什么要加这个 isUnGzipEnabled 标志?因为 Phoenix 的加密策略是先压缩再加密(如果需要压缩的话)。解密方需要知道"解密之后要不要再解压一次"。这个小小的布尔值,就是压缩与未压缩数据之间的路标。

    三、客户端的 HTTP 发射器:EnumPoolingHttpClient

    了解了数据模型后,接下来看客户端是怎么把数据"射"出去的。

    客户端的心跳默认每 30 秒一次,JVM 信息默认每 60 秒一次,如果同时开启了服务器信息采集、线程池采集……每分钟可能有好几次 HTTP 请求。如果每次都创建一个新的 TCP 连接,发完就关——三次握手、四次挥手的开销累积起来相当可观。

    所以 Phoenix 在客户端实现了一个HTTP 连接池——EnumPoolingHttpClient

    3.1 为什么用枚举单例?

    public class EnumPoolingHttpClient {
    
        private EnumPoolingHttpClient() {
        }
    
        private enum Singleton {
            INSTANCE;
            private final EnumPoolingHttpClient instance;
    
            Singleton() {
                instance = new EnumPoolingHttpClient();
            }
    
            private EnumPoolingHttpClient getInstance() {
                return instance;
            }
        }
    
        public static EnumPoolingHttpClient getInstance() {
            return Singleton.INSTANCE.getInstance();
        }
    }
    

    你可能见过各种单例写法——懒汉、饿汉、双重检查锁(DCL)、静态内部类……枚举单例是其中最"无脑安全"的一种。

    为什么?因为 Java 语言规范保证了:枚举实例的创建是线程安全的,而且天然防止反射攻击和序列化破坏。用 DCL 写单例,你还得操心 volatile 关键字;用枚举写,JVM 帮你把所有脏活都干了。

    对于 HTTP 连接池这种全局唯一、生命周期贯穿整个应用的组件,枚举单例是最佳选择。

    3.2 连接池初始化:300 个连接够不够用?

    static 代码块中,连接池的初始化是一个精心编排的过程。让我们逐个拆解关键配置:

    SSL 支持——信任一切

    SSLContext sslContext = SSLContexts.custom()
            .loadTrustMaterial(null, (chain, authType) -> true)  // 信任所有证书
            .build();
    sslConnectionSocketFactory =new SSLConnectionSocketFactory(sslContext, supportedProtocols, null,NoopHostnameVerifier.INSTANCE);  // 跳过主机名验证
    

    信任所有证书 + 跳过主机名验证——如果这是一个面向公网的 Web 应用,安全专家看到这段代码大概会当场晕厥。但在内网监控场景下,这是务实的选择。监控服务端往往使用自签名证书,如果严格校验证书链,部署成本会显著上升。当然,如果你的部署环境安全要求较高,可以替换为正式证书。

    连接池参数——MaxTotal 与 DefaultMaxPerRoute

    PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager(...);
    	manager.setMaxTotal(300);               // 整个连接池的最大连接数
    	manager.setDefaultMaxPerRoute(200);     // 到同一目标地址的最大连接数
    	manager.setValidateAfterInactivity(30*1000); // 空闲30秒后重新验证连接有效性
    

    这里有一个容易混淆的概念:MaxTotal 是连接池中所有连接的上限,DefaultMaxPerRoute 是到同一目标地址的连接上限。

    打个比方:连接池就像一个停车场,MaxTotal=300 意味着最多停 300 辆车。但如果所有车都要去同一个目的地(同一个服务端地址),那实际能同时出发的上限是 DefaultMaxPerRoute=200

    对于典型的 Phoenix 部署——客户端只连接一个服务端——真正的并发上限就是 200。这个数值对单实例的监控数据上报来说绰绰有余。

    setValidateAfterInactivity(30000) 也值得一提:一个连接如果 30 秒没有被使用,下次从池中取出时会先验证它是否还活着。这避免了拿到一个已经被服务端关闭的"僵尸连接"。

    三个超时——各管一段

    int connectTimeout = ConfigLoader.getMonitoringProperties().getComm().getHttp().getConnectTimeout();
    int socketTimeout = ConfigLoader.getMonitoringProperties().getComm().getHttp().getSocketTimeout();
    int connectionRequestTimeout = ConfigLoader.getMonitoringProperties().getComm().getHttp().getConnectionRequestTimeout();
    
    RequestConfig defaultRequestConfig = RequestConfig.custom()
            .setConnectTimeout(connectTimeout)              // 默认15秒
            .setSocketTimeout(socketTimeout)                // 默认15秒
            .setConnectionRequestTimeout(connectionRequestTimeout) // 默认15秒
            .setRedirectsEnabled(false)
            .build();
    

    三个超时各管一段:

    • connectTimeout:TCP 三次握手的超时时间。如果 15 秒内握手没完成,说明网络或服务端有问题
    • socketTimeout:连接建立后,等待响应数据的超时时间。请求发出去了,15 秒内没收到一个字节的响应,判定超时
    • connectionRequestTimeout:从连接池中"借"一个连接的等待时间。如果连接池被打满了(200 个连接都在用),新请求在池子门口排队,超过 15 秒还没借到就放弃

    这三个超时默认都是 15 秒,可通过配置文件调整。另外 setRedirectsEnabled(false) 禁用了自动重定向——监控数据的发送是明确的点对点通信,不应该被 302 重定向带到别处去。

    HttpClient 构建——一堆策略

    HTTP_CLIENT =HttpClients.custom()
        .setConnectionManager(manager)
        .setConnectionManagerShared(false)
        .evictIdleConnections(60,TimeUnit.SECONDS)     // 60秒回收空闲连接
        .evictExpiredConnections()                       // 回收过期连接
        .setConnectionTimeToLive(60,TimeUnit.SECONDS)   // 连接最长存活60秒
        .setConnectionReuseStrategy(DefaultConnectionReuseStrategy.INSTANCE)
        .setKeepAliveStrategy(DefaultConnectionKeepAliveStrategy.INSTANCE)
        .setRetryHandler(new DefaultHttpRequestRetryHandler(3, true))  // 失败重试3次
        .build();
    

    这段配置的核心思想是不让连接池中出现"坏连接"

    • evictIdleConnections(60s):60 秒没人用的连接,回收掉。别占着茅坑不拉屎。
    • setConnectionTimeToLive(60s):每个连接最长活 60 秒,到期强制关闭重建。即使连接看起来还能用,也不要长期持有——网络中间件(NAT、负载均衡器)可能早就把底层的 TCP 连接断了,你握着的只是一个空壳。
    • setRetryHandler(3, true):请求失败自动重试 3 次。网络抖动是家常便饭,重试能大幅提高可靠性。

    3.3 发送流程:Sender.send()

    客户端通过 Sender 类发出监控数据。这个类的代码非常简洁,但信息密度很高:

    public class Sender {
        public static String send(final String url, final String json) throws IOException {
            // Step 1: 加密——明文变密文
            String encryptStr = MsgPayloadUtils.encryptPayload(json);
            // Step 2: 发送——密文走网络
            EnumPoolingHttpClient httpClient = EnumPoolingHttpClient.getInstance();
            String result = httpClient.sendHttpPostByJson(url, encryptStr);
            // Step 3: 解密——密文变明文
            String decryptStr = MsgPayloadUtils.decryptPayload(result);
            return decryptStr;
        }
    }
    

    三步走:加密 → 发送 → 解密。对调用者而言,传入明文 JSON,返回明文 JSON,中间的加解密完全透明。

    sendHttpPostByJson 方法的实现也值得看一眼:

    public String sendHttpPostByJson(String url, String json) throws IOException {
        HttpPost httpPost = new HttpPost(url);
        try {
            StringEntity entity = new StringEntity(json, StandardCharsets.UTF_8);
            entity.setContentEncoding(CharsetUtil.UTF_8);
            entity.setContentType("application/json");
            httpPost.setEntity(entity);
    
            CloseableHttpResponse response = HTTP_CLIENT.execute(httpPost);
            int statusCode = response.getStatusLine().getStatusCode();
            if (statusCode == HttpStatus.SC_OK) {
                return EntityUtils.toString(response.getEntity(), CharsetUtil.UTF_8);
            } else {
                // 非 200,也要消费掉响应体,防止连接泄漏
                EntityUtils.consume(response.getEntity());
            }
            return null;
        } finally {
            httpPost.releaseConnection();  // 归还连接到连接池
        }
    }
    

    注意 finally 中的 releaseConnection()——这不是"关闭连接",而是把连接归还到池中。就像你在图书馆借了一本书,看完之后还回去,下一个人还能借。如果你忘了还,连接池里可用的连接会越来越少,最终所有请求都卡在 connectionRequestTimeout 上等死。

    还有一个容易忽视的细节:当 HTTP 响应状态码不是 200 时,代码执行了 EntityUtils.consume(entity)。为什么?因为 HTTP/1.1 的 keep-alive 要求每个响应的 body 都必须被完整读取,否则底层连接无法被复用。不消费响应体就归还连接,相当于还回去一本翻到一半的书——下一个借的人打开一看,全是上一个人的书签(残留数据)。

    四、加密与压缩:MsgPayloadUtils 的双重防护

    前面多次提到加密和压缩,现在来看看它到底是怎么实现的。

    4.1 加密方向:明文 → CiphertextPackage

    public static CiphertextPackage encryptPayloadTo(String plaintextJsonStr) {
        // 判断是否需要 Gzip 压缩
        boolean isNeedGzip = ZipUtils.isNeedGzip(plaintextJsonStr);
        if (isNeedGzip) {
            // 大数据:压缩 → 加密
            byte[] gzip = ZipUtil.gzip(plaintextJsonStr, CharsetUtil.UTF_8);
            String encrypt = SecureUtils.encrypt(gzip);
            return new CiphertextPackage(encrypt, true);   // 标记"需要解压"
        } else {
            // 小数据:直接加密
            String encrypt = SecureUtils.encrypt(plaintextJsonStr, StandardCharsets.UTF_8);
            return new CiphertextPackage(encrypt, false);  // 标记"不需要解压"
        }
    }
    

    这里有一个自适应策略:只有数据量超过一定阈值时才启用 Gzip 压缩。

    为什么不无脑全压缩?因为 Gzip 压缩本身有开销——对于几百字节的心跳包,压缩后的数据可能反而更大(Gzip 的头部信息就占了好几十字节),而且白白浪费了 CPU 周期。只有当数据量大到压缩能显著减少传输体积时(比如包含大量 Docker 容器信息的数据包),压缩才有意义。

    加密顺序也有讲究:先压缩,再加密。反过来行不行?不行,或者说不好。加密后的数据看起来是随机的,已经没有什么可被压缩的冗余信息了——压缩率极低。而先压缩再加密,压缩率完全取决于明文的冗余度,效果好得多。

    4.2 解密方向:CiphertextPackage → 明文

    解密是加密的镜像操作:

    public static String decryptPayloadFrom(CiphertextPackage ciphertextPackage) {
        boolean isUnGzipEnabled = ciphertextPackage.isUnGzipEnabled();
        String ciphertext = ciphertextPackage.getCiphertext();
        if (isUnGzipEnabled) {
            byte[] decrypt = SecureUtils.decrypt(ciphertext);   // 先解密
            return ZipUtil.unGzip(decrypt, CharsetUtil.UTF_8);  // 再解压
        } else {
            return SecureUtils.decrypt(ciphertext, StandardCharsets.UTF_8); // 直接解密
        }
    }
    

    解密时根据 isUnGzipEnabled 标志决定要不要解压——这就是为什么 CiphertextPackage 要携带这个布尔值。发送方在加密时做了什么决策,接收方需要完全对称地执行逆操作。

    五、代理端的 RestTemplate:另一种 HTTP 客户端

    代理端(phoenix-agent)在 Phoenix 架构中扮演着"中转站"的角色——它接收来自客户端的数据,转发给服务端。转发的时候,它也需要发 HTTP 请求。

    但代理端用的不是 EnumPoolingHttpClient,而是 Spring 的 RestTemplate。为什么?

    5.1 为什么客户端和代理端用不同的方案?

    答案很简单:客户端不能依赖 Spring,代理端可以。

    phoenix-client-core 的设计目标是能在任何 Java 程序中运行——哪怕是一个没有 Spring 的纯 Java 控制台应用。所以它不能依赖 Spring 容器来管理 Bean,只能自己用枚举单例管理 HTTP 连接池。

    而代理端本身就是一个 SpringBoot 应用,完全可以享受 Spring 生态的便利。RestTemplate + Spring Bean 管理 + @Retryable 声明式重试——开箱即用,何乐而不为?

    5.2 三层 Bean 结构

    代理端的 RestTemplateConfig 定义了三层 Bean:

    
    @Configuration
    public class RestTemplateConfig {
    
        @Autowired
        private MonitoringProperties monitoringProperties;
    
        @Bean
        public RestTemplate restTemplate() {
            return new RestTemplate(this.httpRequestFactory());
        }
    
        @Bean
        public ClientHttpRequestFactory httpRequestFactory() {
            return new HttpComponentsClientHttpRequestFactory(this.httpClient());
        }
    
        @Bean(destroyMethod = "close")
        public CloseableHttpClient httpClient() {
            // 连接池配置...
        }
    }
    

    层次很清晰:

    RestTemplate(Spring 的高级封装,提供 exchange()、postForObject() 等便捷 API)
      └── HttpComponentsClientHttpRequestFactory(适配器:把 Spring 的请求翻译成 Apache HttpClient 的请求)
            └── CloseableHttpClient(真正干活的:Apache HttpClient + 连接池)
    

    为什么需要这么多层?因为 Spring 的 RestTemplate 本身不负责 HTTP 连接管理——它只是一个"翻译官",把你的 Java 对象翻译成 HTTP 请求,再把 HTTP 响应翻译回 Java 对象。真正的网络通信还是委托给底层的 HttpClient

    destroyMethod = "close" 保证了 Spring 容器关闭时,HTTP 连接池会被正确关闭——不会留下泄漏的 TCP 连接。

    5.3 连接池参数

    代理端的连接池配置与客户端几乎一致(MaxTotal=300, DefaultMaxPerRoute=200),但有一个关键区别:超时参数从 Spring Bean 注入的 MonitoringProperties 中读取,而不是像客户端那样从静态方法 ConfigLoader.getMonitoringProperties() 中获取。

    int connectTimeout = this.monitoringProperties.getComm().getHttp().getConnectTimeout();
    int socketTimeout = this.monitoringProperties.getComm().getHttp().getSocketTimeout();
    int connectionRequestTimeout = this.monitoringProperties.getComm().getHttp().getConnectionRequestTimeout();
    

    这意味着代理端可以方便地利用 Spring 的配置刷新机制来调整超时参数。

    5.4 转发逻辑:HttpServiceImpl

    代理端接收到客户端数据后,通过 HttpServiceImpl 转发给服务端:

    
    @Service
    public class HttpServiceImpl implements IHttpService {
    
        @Autowired
        private RestTemplate restTemplate;
    
        @Override
        @Retryable   // 失败自动重试
        public BaseResponsePackage sendHttpPost(String json, String url) {
            // 1. 加密
            CiphertextPackage requestCiphertextPackage = MsgPayloadUtils.encryptPayloadTo(json);
            // 2. 构造请求
            HttpHeaders headers = new HttpHeaders();
            headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
            HttpEntity<CiphertextPackage> entity = new HttpEntity<>(requestCiphertextPackage, headers);
            // 3. 发送——注意这里直接传 CiphertextPackage 对象,RestTemplate 自动序列化
            ResponseEntity<CiphertextPackage> responseEntity =
                    this.restTemplate.exchange(url, HttpMethod.POST, entity, CiphertextPackage.class);
            // 4. 解密
            CiphertextPackage responseCiphertextPackage = Objects.requireNonNull(responseEntity.getBody());
            String decryptStr = MsgPayloadUtils.decryptPayloadFrom(responseCiphertextPackage);
            return JSON.parseObject(decryptStr, BaseResponsePackage.class);
        }
    }
    

    对比客户端的 Sender.send(),你会发现一个明显的区别:客户端传输的是密文 JSON 字符串,而代理端传输的是 CiphertextPackage 对象

    为什么?因为 RestTemplate.exchange() 会自动调用 HttpMessageConverter 把 Java 对象序列化为 JSON 再发送。你给它一个 CiphertextPackage 对象,它帮你变成 {"ciphertext":"xxx","isUnGzipEnabled":false} 的 JSON 字符串。省去了手动序列化的步骤。

    @Retryable 注解是 Spring Retry 框架的声明式重试——方法执行失败时自动重试,不需要写 try-catch-retry 的样板代码。与客户端 HttpClient 内置的 3 次重试不同,Spring Retry 的策略更灵活(可以配置重试次数、退避策略、重试条件等),只是这里用了默认配置。

    5.5 服务端也有 RestTemplate

    服务端(phoenix-server)也配置了自己的 RestTemplate,参数几乎一致。它的用途主要是:当服务端需要主动向代理端下发命令时(比如远程执行 Arthas 诊断),会通过 RestTemplate 发送 HTTP 请求。

    六、配置体系:一个 URL 牵动全局

    HTTP 通信的配置集中在 MonitoringCommHttpProperties 中:

    public class MonitoringCommHttpProperties {
        private String url;                      // 服务端URL(必填)
        private Integer connectTimeout;          // 连接超时(毫秒,默认15000)
        private Integer socketTimeout;           // 等待数据超时(毫秒,默认15000)
        private Integer connectionRequestTimeout;// 从连接池获取连接超时(毫秒,默认15000)
    }
    

    它归属于通信配置 MonitoringCommProperties

    public class MonitoringCommProperties {
        private MonitoringCommHttpProperties http;          // HTTP 通信配置
        private MonitoringCommWebSocketProperties websocket; // WebSocket 通信配置
    }
    

    配置文件的写法:

    # 必填:监控服务端(或代理端)的HTTP根地址
    monitoring.comm.http.url=http://127.0.0.1:16000/phoenix-server
    # 以下均为可选,有合理默认值
    monitoring.comm.http.connect-timeout=15000
    monitoring.comm.http.socket-timeout=15000
    monitoring.comm.http.connection-request-timeout=15000
    

    这个 url 配置是整个 HTTP 通道的锚点——客户端的 UrlConstants、代理端的 UrlConstants、UI 端的 UrlConstants 都从这里读取根路径,然后拼接各自的接口路径。

    七、URL 路由:每种数据包都有自己的"收件地址"

    Phoenix 为每种数据包定义了专属的 HTTP 接口地址,通过 UrlConstants 常量类统一管理:

    public final class UrlConstants {
        // 根路径——从配置文件读取
        private static final String ROOT_URI = ConfigLoader.getMonitoringProperties().getComm().getHttp().getUrl();
    
        // 各类数据包的接口地址
        public static final String HEARTBEAT_URL = ROOT_URI + "/heartbeat/accept-heartbeat-package";
        public static final String ALARM_URL = ROOT_URI + "/alarm/accept-alarm-package";
        public static final String SERVER_URL = ROOT_URI + "/server/accept-server-package";
        public static final String JVM_URL = ROOT_URI + "/jvm/accept-jvm-package";
        public static final String DOCKER_URL = ROOT_URI + "/docker/accept-docker-package";
        public static final String COMMAND_URL = ROOT_URI + "/command/accept-command-package";
        // ...更多地址
    }
    

    命名规则很统一:/{业务模块}/accept-{业务类型}-package。这种 RESTful 风格的 URL 设计让接口一目了然。

    客户端、代理端、UI 端各自维护自己的 UrlConstants,内容根据角色不同有所差异:

    • 客户端:主要是上报类接口(心跳、JVM、告警、异常等)
    • 代理端:除了上报类,还有大量操作类接口(测试网络连通性、管理数据库会话等)
    • UI 端:命令下发、配置刷新、线程池调参等操作类接口

    八、服务端接收:Controller 的"障眼法"

    数据到了服务端,由 Spring MVC Controller 接收。以心跳包为例:

    
    @RestController
    @RequestMapping("/heartbeat")
    @Tag(name = "信息包.心跳包")
    public class HeartbeatController {
    
        @Autowired
        private ServerPackageConstructor serverPackageConstructor;
    
        @Autowired
        private IHeartbeatService heartbeatService;
    
        @Operation(
                summary = "接收心跳包",
                requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
                        content = @Content(schema = @Schema(implementation = CiphertextPackage.class))),
                responses = @ApiResponse(
                        content = @Content(schema = @Schema(implementation = CiphertextPackage.class))))
        @PostMapping("/accept-heartbeat-package")
        public BaseResponsePackage acceptHeartbeatPackage(@RequestBody HeartbeatPackage heartbeatPackage) {
            TimeInterval timer = DateUtil.timer();
            Result result = this.heartbeatService.dealHeartbeatPackage(heartbeatPackage);
            BaseResponsePackage baseResponsePackage =
                    this.serverPackageConstructor.structureBaseResponsePackage(result);
            if (timer.intervalSecond() > 1) {
                log.warn("处理心跳包耗时:{}", timer.intervalPretty());
            }
            return baseResponsePackage;
        }
    }
    

    如果你仔细看,会发现一个"矛盾":

    • Swagger 文档说:请求体是 CiphertextPackage(密文)
    • 方法签名说:@RequestBody HeartbeatPackage(明文)

    实际的 HTTP 请求体确实是密文,但 Controller 方法拿到的却是明文对象。这不是 bug——这是 Phoenix 最精妙的设计之一:AOP 加解密切面

    九、AOP 加解密切面:魔法发生的地方

    Phoenix 利用 Spring 框架的 RequestBodyAdviceResponseBodyAdvice 机制,在 HTTP 请求体被反序列化之前和响应体被序列化之后,悄悄地完成了解密和加密。

    对 Controller 开发者来说,加解密完全不存在。

    这就好比你在公司收到一封加密邮件——邮件客户端自动帮你解密了,你看到的就是明文内容。你回复邮件时也不需要手动加密,邮件客户端在发送前自动加密。整个过程对你透明。

    9.1 请求解密:偷梁换柱的 HttpInputMessage

    
    @RestControllerAdvice(basePackages = "com.gitee.pifeng.monitoring.server.business.server.controller")
    public class RequestPackageDecryptAdvice implements RequestBodyAdvice {
    
        @Override
        public boolean supports(...) {
            return true;  // 对所有 Controller 方法生效
        }
    
        @Override
        public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, ...) throws IOException {
            // 关键:用自定义的 HttpInputMessage 替换原始的
            return new HttpInputMessagePackageDecrypt(inputMessage);
        }
    }
    

    Spring 在反序列化请求体之前,会调用 beforeBodyRead 方法。这里,Phoenix 把原始的 HttpInputMessage 偷偷换成了自己的 HttpInputMessagePackageDecrypt

    当 Spring 框架后续调用 getBody() 读取请求体时,读到的已经是解密后的明文:

    public class HttpInputMessagePackageDecrypt implements HttpInputMessage {
    
        @Override
        public InputStream getBody() throws DecryptionException {
            try {
                // 1. 读取原始请求体(这是密文)
                String bodyStr = IOUtils.toString(this.originalBody, StandardCharsets.UTF_8);
                // 2. 解析为 CiphertextPackage
                CiphertextPackage ciphertextPackage = OBJECT_MAPPER.readValue(...,CiphertextPackage.class);
                // 3. 解密
                String decryptStr = MsgPayloadUtils.decryptPayloadFrom(ciphertextPackage);
                // 4. 返回明文输入流——Spring 拿这个流去反序列化
                return IOUtils.toInputStream(decryptStr, StandardCharsets.UTF_8);
            } catch (Exception e) {
                throw new DecryptionException("请求数据解密失败,请检查密钥或数据格式!");
            }
        }
    }
    

    整个过程就像一个翻译官站在门口:外面的人说的是密码,翻译官翻译成明文后才转述给里面的人。里面的人(Controller方法)根本不知道外面的人说的是密码。

    9.2 响应加密:出门之前加把锁

    
    @RestControllerAdvice(basePackages = "com.gitee.pifeng.monitoring.server.business.server.controller")
    public class ResponsePackageEncryptAdvice implements ResponseBodyAdvice<Object> {
    
        @Override
        public boolean supports(...) {
            return true;  // 对所有响应生效
        }
    
        @Override
        public Object beforeBodyWrite(Object body, ...) {
            if (body != null) {
                // Controller 返回的是明文 BaseResponsePackage
                // 这里把它加密成 CiphertextPackage 再返回
                return new HttpOutputMessagePackageEncrypt().encrypt(body);
            }
            return null;
        }
    
        // 异常也要加密!
        @ExceptionHandler(value = Throwable.class)
        public CiphertextPackage handler(Throwable throwable, HttpServletRequest request) {
            log.error("请求客户端IP:{},URI:{},异常:{}", ...);
            Result build = Result.builder().isSuccess(false).msg(throwable.toString()).build();
            BaseResponsePackage baseResponsePackage = this.serverPackageConstructor.structureBaseResponsePackage(build);
            return new HttpOutputMessagePackageEncrypt().encrypt(baseResponsePackage);
        }
    }
    

    HttpOutputMessagePackageEncrypt 的实现只有三行:

    public CiphertextPackage encrypt(Object inputObject) {
        String jsonString = JSON.toJSONString(inputObject);
        return MsgPayloadUtils.encryptPayloadTo(jsonString);
    }
    

    明文对象 → JSON 字符串 → 密文数据包。简洁明了。

    这里有一个容易被忽视但很重要的设计:@ExceptionHandler。即使 Controller 方法抛出了异常,响应也会被加密。如果没有这个兜底,异常信息会以明文形式返回给客户端——在安全审计中这是不可接受的,因为异常堆栈可能包含类名、路径、数据库信息等敏感内容。

    9.3 代理端:两扇门都要守

    代理端同时有两个角色:接收客户端数据(被动)、转发到服务端(主动)。所以它的 AOP 切面覆盖了两个包路径:

    @RestControllerAdvice(basePackages = {
            "com.gitee.pifeng.monitoring.agent.business.client.controller",  // 接收客户端的请求
            "com.gitee.pifeng.monitoring.agent.business.server.controller"   // 接收服务端的命令
    })
    

    无论数据从哪个方向来,都会被自动解密;无论响应往哪个方向去,都会被自动加密。

    十、代理端的"命令执行器"模式

    代理端转发数据到服务端时,使用了一个值得一提的设计模式——MethodExecuteHandler(方法执行助手)+ InvokerHolder(命令执行器管理器)。

    public class MethodExecuteHandler {
    
        // 向服务端发送心跳包
        public static BaseResponsePackage sendHeartbeatPackage2Server(HeartbeatPackage heartbeatPackage) {
            Invoker invoker = InvokerHolder.getInvoker(IHeartbeatService.class, "sendHeartbeatPackage");
            return execute(invoker, heartbeatPackage);
        }
    
        // 向服务端发送告警包
        public static BaseResponsePackage sendAlarmPackage2Server(AlarmPackage alarmPackage) {
            Invoker invoker = InvokerHolder.getInvoker(IAlarmService.class, "sendAlarmPackage");
            return execute(invoker, alarmPackage);
        }
    
        // 向服务端发送基础请求包
        public static BaseResponsePackage sendBaseRequestPackage2Server(BaseRequestPackage pkg, String url) {
            Invoker invoker = InvokerHolder.getInvoker(IBaseRequestPackageService.class, "sendBaseRequestPackage");
            return execute(invoker, pkg, url);
        }
    
        // 统一执行
        public static BaseResponsePackage execute(Invoker invoker, Object... objects) {
            try {
                return (BaseResponsePackage) invoker.invoke(objects);
            } catch (Exception e) {
                Result result = Result.builder().isSuccess(false).msg(e.getMessage()).build();
                return AGENT_PACKAGE_CONSTRUCTOR.structureBaseResponsePackage(result);
            }
        }
    }
    

    这里的 Invoker 是通过 InvokerHolder 动态获取的——本质上是一种命令模式:把"发送数据到服务端"这个动作封装为一个可执行的对象。好处是统一了错误处理和结果封装——无论发送哪种类型的数据包,异常处理逻辑都在 execute() 方法中集中管理。

    十一、画一张全景图

    把所有组件串起来,一次完整的 HTTP 通信(以 UI 端通过代理端刷新服务端配置为例):

    ┌──────────┐                    ┌──────────────┐                  ┌──────────────┐
    │  UI 端    │                    │   代理端      │                  │   服务端      │
    └────┬─────┘                    └──────┬───────┘                  └──────┬───────┘
         │                                 │                                 │
         │ ① 构造 BaseRequestPackage       │                                 │
         │ ② toJsonString() 序列化         │                                 │
         │ ③ Sender.send()                │                                 │
         │   ├ encryptPayload():          │                                 │
         │   │ 判断 → [Gzip] → AES加密     │                                 │
         │   └ sendHttpPostByJson()        │                                 │
         │                                 │                                 │
         │─────── HTTP POST (密文) ───────▶│                                 │
         │                                 │                                 │
         │           ④ RequestPackageDecryptAdvice                           │
         │              beforeBodyRead → 解密请求体                           │
         │                                 │                                 │
         │           ⑤ Controller 收到明文对象                                │
         │           ⑥ Service → MethodExecuteHandler                       │
         │           ⑦ HttpServiceImpl.sendHttpPost()                       │
         │              ├ encryptPayloadTo():加密                            │
         │              └ restTemplate.exchange()                            │
         │                                 │                                 │
         │                                 │─── HTTP POST (密文) ──────────▶│
         │                                 │                                 │
         │                                 │  ⑧ RequestPackageDecryptAdvice │
         │                                 │     解密请求体                    │
         │                                 │  ⑨ Controller 处理业务          │
         │                                 │  ⑩ 构造 BaseResponsePackage    │
         │                                 │  ⑪ ResponsePackageEncryptAdvice│
         │                                 │     加密响应体                    │
         │                                 │                                 │
         │                                 │◀── HTTP Response (密文) ────────│
         │                                 │                                 │
         │           ⑫ decryptPayloadFrom():解密响应                         │
         │           ⑬ ResponsePackageEncryptAdvice                          │
         │              加密响应体                                            │
         │                                 │                                 │
         │◀─── HTTP Response (密文) ───────│                                 │
         │                                 │                                 │
         │ ⑭ decryptPayload():解密响应    │                                 │
         │ ⑮ 解析 BaseResponsePackage      │                                 │
         │ ⑯ result.isSuccess?             │                                 │
    

    如果是直连模式(不经过代理端),去掉中间的加解密中转,流程就简单多了。但核心机制完全一样:每经过一道门,进门解密,出门加密

    十二、设计复盘:几个值得学习的点

    "信封"与"信件"分离

    CiphertextPackage(信封)和 HeartbeatPackage(信件)是两套完全独立的继承体系。信封不关心里面装的是什么——心跳包也好、告警包也好,加密后在传输层面看起来一模一样。

    这带来了一个重要好处:AOP 切面只需要写一次。不管 Controller 接收的是哪种业务包,解密逻辑都是一样的——拆开信封,取出信件,交给 Spring 去反序列化。

    客户端与代理端/服务端的对称但不同

    两者都在做 HTTP 通信,但选择了不同的技术方案:

    客户端 代理端/服务端
    HTTP 库 Apache HttpClient(原生 API) Spring RestTemplate(封装 API)
    生命周期管理 枚举单例(不依赖 Spring) Spring Bean(容器管理)
    重试机制 HttpClient 内置(3次) @Retryable(声明式)
    适用环境 任何 Java 程序 SpringBoot 应用

    这不是随意的选择,而是根据各自的运行环境约束做出的最优决策。

    渐进式演进

    从源码中大量的 @Deprecated 标注和注释掉的旧代码可以看出,Phoenix 正在逐步把高频数据上报从 HTTP 迁移到 WebSocket。但这种迁移不是一刀切的——旧的 HTTP 通道始终保留,作为降级方案。

    这种渐进式演进策略在工程实践中非常重要:你永远不想在发布日当天发现新通道有 bug,而旧通道已经被删掉了。

    十三、小结

    本篇拆解了 Phoenix HTTP 通信通道的完整实现。回顾核心要点:

    • 数据模型ISuperBeanAbstractSuperPackageBaseRequestPackage / BaseResponsePackage 的继承体系,让每个数据包都携带完整的身份信息
    • 客户端连接池EnumPoolingHttpClient 用枚举单例 + Apache HttpClient 实现,MaxTotal=300、失败重试 3 次、60 秒回收空闲连接
    • 加密传输MsgPayloadUtils 实现自适应 Gzip 压缩 + AES/DES/SM4 加密,封装为 CiphertextPackage 统一传输
    • 代理端 RestTemplate:Spring Bean 管理 + @Retryable 声明式重试 + HttpComponentsClientHttpRequestFactory 桥接 Apache HttpClient
    • AOP 加解密切面RequestPackageDecryptAdvice 在反序列化前自动解密,ResponsePackageEncryptAdvice 在序列化后自动加密——Controller 完全无感知
    • URL 路由:各端通过 UrlConstants 统一管理接口地址,根路径从配置读取

    如果说 HTTP 通道是 Phoenix 通信的"基本款"——稳定、可靠、适用面广;那 WebSocket 通道就是"性能款"——实时、高效、为高频场景而生。下一篇,我们就来深入 WebSocket 通道,看看 Phoenix 如何基于 Netty 实现长连接,以及它和 HTTP 通道是如何协作的。

    end
  1. 作者: 锋哥 (联系作者)
  2. 发表时间: 2026-03-25 16:30
  3. 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)
  4. 转载声明:如果是转载博主转载的文章,请附上原文链接
  5. 公众号转载:请在文末添加作者公众号二维码(公众号二维码见右边,欢迎关注)
  6. 评论

    站长头像 知录

    你一句春不晚,我就到了真江南!

    文章0
    浏览0

    文章分类

    标签云