Phoenix 支持 HTTP 和 WebSocket 双通道通信。本篇聚焦 HTTP 通道,从请求/响应的数据模型、客户端连接池封装、代理端 RestTemplate 配置、服务端接收与 AOP 加解密切面,逐层拆解 HTTP 通信的完整链路。
一、先聊聊:监控数据怎么送出去?
假设你开发了一个 Java 应用,接入了 Phoenix 的客户端 SDK。此刻,你的应用就像一个每隔 30 秒要向总部"报平安"的前哨站——它需要把心跳、JVM 状态、线程池快照等信息,源源不断地发往监控服务端。
问题来了:这些数据怎么送过去?
最直观的方案就是 HTTP。每隔一段时间,客户端构造一个 POST 请求,把监控数据塞进请求体,发到服务端的某个接口,服务端处理完返回一个 200 OK ——经典的请求-响应模型,简单、可靠、人人都懂。
Phoenix 的 HTTP 通道正是这么做的。但"简单"不等于"简陋",当你深入源码会发现,这条看似朴素的 HTTP 通道上,藏着连接池管理、自适应压缩、透明加解密、声明式重试等一系列精心设计。
在正式拆解之前,先明确一点:Phoenix 同时支持 HTTP 和 WebSocket 双通道。两者的分工大致如下:
| 维度 | 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 isSuccess 和 String 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 框架的 RequestBodyAdvice 和 ResponseBodyAdvice 机制,在 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 通信通道的完整实现。回顾核心要点:
- 数据模型:
ISuperBean→AbstractSuperPackage→BaseRequestPackage/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 通道是如何协作的。
评论