上一篇我们拆解了 Spring Boot Starter 的自动配置机制——通过
@EnableMonitoring注解、DeferredImportSelector、spring.factories、ImportAware等一套精巧的组合拳,让 Spring Boot 应用只需一个注解就能接入 Phoenix 监控。但现实世界里,并非所有 Java Web 项目都跑在 Spring Boot 上。那些基于传统 Spring MVC +web.xml的老项目,该如何集成 Phoenix?这一篇,我们来看 Phoenix 的另一条集成路径——phoenix-client-spring-mvc-integrator模块,以及它背后的 Servlet Listener 机制。
一、为什么需要一条“备用通道”?
Spring Boot 的 Starter 机制固然优雅,但它有一个隐含的前提——你的项目必须是 Spring Boot 应用。
在很多企业级系统中,尤其是维护了多年的老项目,技术栈往往是这样的:
- Spring MVC 4.x / 5.x(不是 Spring Boot)
- 部署在外部的 Tomcat / Jetty / WebLogic
- 配置全靠
web.xml - 没有
@SpringBootApplication,没有spring.factories,没有自动配置
对这类项目来说,Spring Boot Starter 那一套完全用不上。但监控需求是一样的——它们同样需要心跳上报、JVM 采集、线程池监控、服务器信息采集。
Phoenix 的解法是:提供一个独立的轻量模块 phoenix-client-spring-mvc-integrator,利用 Servlet 规范中最古老、最通用的扩展机制——ServletContextListener——来完成监控的初始化。
不依赖 Spring Boot,不依赖自动配置,不依赖注解扫描——只要你的应用跑在 Servlet 容器里,就能接入。
二、Servlet Listener:比 Spring 还早的“生命周期钩子”
在聊 Phoenix 的实现之前,先花一点篇幅回顾 Servlet 规范中的 Listener 机制。这是理解整个集成方案的基础。
2.1 Servlet 容器的启动流程
当你把一个 WAR 包丢进 Tomcat 的 webapps 目录(或者在 IDE 中点击“Run”),Servlet 容器的启动过程大致如下:
1. 解析 web.xml(或注解配置)
2. 创建 ServletContext 对象
3. 加载 <context-param> 参数到 ServletContext
4. 实例化并调用所有 ServletContextListener.contextInitialized() ← 这里
5. 初始化 Filter
6. 初始化 Servlet(如 DispatcherServlet)
7. 开始接受 HTTP 请求
注意第 4 步——ServletContextListener 的 contextInitialized() 方法在 Filter 和 Servlet 初始化之前就会被调用。 这意味着,在这个时间点执行的代码,可以保证在应用处理第一个 HTTP 请求之前就完成准备工作。
对于监控客户端来说,这个时机恰到好处:我们需要在应用“正式营业”之前,就把心跳、采集、数据交换器等一切基础设施启动起来。
2.2 ServletContextListener 接口
ServletContextListener 是 Servlet 规范(从 Servlet 2.3 开始)定义的接口,只有两个方法:
public interface ServletContextListener extends EventListener {
// Servlet 上下文初始化完成时调用
void contextInitialized(ServletContextEvent sce);
// Servlet 上下文即将销毁时调用
void contextDestroyed(ServletContextEvent sce);
}
它的设计理念和 Spring 的 ApplicationListener<ContextRefreshedEvent> 如出一辙——都是“生命周期钩子”。区别在于层级不同:ServletContextListener 工作在 Servlet 容器层面,比 Spring 容器更底层、更早触发。
如果说 Spring Boot 的 @EnableMonitoring 是在“Spring 世界”里找了一个入口,那 ServletContextListener 就是在“Servlet 世界”里找了一个入口。两条路,殊途同归。
2.3 context-param:Servlet 级别的“配置中心”
web.xml 中的 <context-param> 元素用于定义全局上下文参数,这些参数在 Servlet 容器启动时被加载到 ServletContext 对象中,所有 Servlet、Filter、Listener 都可以通过 servletContext.getInitParameter("paramName") 读取。
<context-param>
<param-name>configLocation</param-name>
<param-value>classpath:conf/monitoring.properties</param-value>
</context-param>
你可以把它理解为 Servlet 时代的“配置文件”——虽然没有 Spring Boot 的 application.yml 那么灵活,但对于传递一个配置文件路径来说绰绰有余。
三、phoenix-client-spring-mvc-integrator 模块结构
先看一眼这个模块的整体面貌:
3.1 包结构
phoenix-client-spring-mvc-integrator/
├── src/main/java/com/gitee/pifeng/monitoring/integrator/listener/
│ ├── MonitoringPlugInitializeListener.java // 核心:监控初始化监听器
│ └── package-info.java // 包级别文档
└── pom.xml
没错,整个模块只有一个有效的 Java 类。这不是偷工减料,而是精准克制——对于传统 Spring MVC 项目的集成,真正需要做的事只有一件:在 Servlet 上下文初始化时启动监控。一个 Listener,足矣。
3.2 Maven 依赖
<dependencies>
<!-- 监控客户端核心模块 -->
<dependency>
<groupId>com.gitee.pifeng</groupId>
<artifactId>phoenix-client-core</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- slf4j 日志 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<optional>true</optional>
</dependency>
<!-- logback 日志实现 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<optional>true</optional>
</dependency>
<!-- Servlet API -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
几个值得注意的点:
依赖 phoenix-client-core 而非 phoenix-client-spring-boot-starter:这个模块和 Spring Boot 没有半毛钱关系。它直接依赖核心模块,获取 Monitor 入口类和配置加载能力。
servlet-api 的 scope 是 provided:因为 Servlet API 由 Tomcat / Jetty 等容器在运行时提供,不需要也不应该打包进 JAR。如果打包进去,反而会和容器自带的版本冲突。
没有任何 Spring 相关依赖:没有 spring-boot-autoconfigure,没有 spring-context,甚至连 spring-web 都没有。这意味着——这个模块可以用在任何 Servlet 3.0+ 的容器环境中,不限于 Spring MVC,哪怕是纯 Servlet + JSP 的项目也能用。
这种极致的轻量化,让它成为了一个真正的“万能适配器”。
四、MonitoringPlugInitializeListener:逐行拆解
现在进入核心——MonitoringPlugInitializeListener 类的完整实现。
4.1 完整源码
@Slf4j
public class MonitoringPlugInitializeListener implements ServletContextListener {
@SneakyThrows
@Override
public void contextInitialized(ServletContextEvent sce) {
// 配置参数值
String configLocation = sce.getServletContext()
.getInitParameter("configLocation");
// 自定义了配置文件路径和名字
if (StringUtils.isNotBlank(configLocation)) {
String[] config = this.getConfigPathAndName(configLocation);
Monitor.start(config[0], config[1]);
} else {
Monitor.start();
}
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
log.info("Servlet上下文销毁!");
}
private String[] getConfigPathAndName(String configLocation)
throws BadListenerConfigException {
// 异常信息
String expMsg = "\r\n监控客户端初始化监听器配置有误,请参考如下配置:\r\n"
+ "<context-param>\r\n"
+ " <param-name>configLocation</param-name>\r\n"
+ " <param-value>classpath:conf/monitoring.properties</param-value>\r\n"
+ "</context-param>\r\n"
+ "<listener>\r\n"
+ " <listener-class>com.gitee.pifeng.monitoring.integrator"
+ ".listener.MonitoringPlugInitializeListener</listener-class>\r\n"
+ "</listener>\r\n";
// 返回值
String[] result = new String[2];
if (!StringUtils.startsWith(configLocation, "classpath:")
&& !StringUtils.startsWith(configLocation, "filepath:")) {
throw new BadListenerConfigException(expMsg);
}
String[] pathAndName = configLocation.split("/");
// 配置文件路径
StringBuilder builder = new StringBuilder();
for (int i = 0; i < pathAndName.length - 1; i++) {
builder.append(pathAndName[i]).append("/");
}
String path = builder.toString();
// 配置文件名字
String name = pathAndName[pathAndName.length - 1];
result[0] = path;
result[1] = name;
return result;
}
}
代码不长,但信息量不小。我们逐段分析。
4.2 contextInitialized():启动监控的“触发器”
@SneakyThrows
@Override
public void contextInitialized(ServletContextEvent sce) {
String configLocation = sce.getServletContext()
.getInitParameter("configLocation");
if (StringUtils.isNotBlank(configLocation)) {
String[] config = this.getConfigPathAndName(configLocation);
Monitor.start(config[0], config[1]);
} else {
Monitor.start();
}
}
整个方法只做一件事:从 ServletContext 中读取 configLocation 参数,然后调用 Monitor.start() 启动监控。
这里有两条分支:
分支一:用户配置了 configLocation
从 web.xml 的 <context-param> 中读取到配置文件路径(如 classpath:conf/monitoring.properties),解析出路径和文件名,调用 Monitor.start(path, name)。这最终会走到 ConfigLoader.load() 方法,从指定路径加载 properties 配置文件。
分支二:用户没有配置 configLocation
直接调用无参的 Monitor.start(),使用默认的配置文件路径和文件名(classpath:monitoring.properties)。
注意这里和 Spring Boot Starter 的一个关键区别:SpringMVC Integrator 只支持 properties 配置文件方式,不支持 application.yml 方式。 因为 application.yml 的解析依赖 Spring Boot 的 @ConfigurationProperties 绑定机制——而这个模块压根没有 Spring Boot。所以,这条集成路径走的始终是第九篇中讲过的“properties 文件”那条配置轨道。
@SneakyThrows 是 Lombok 提供的注解,它会在编译期自动添加 try-catch 并重新抛出受检异常,免去了在方法签名上声明 throws 的繁琐。这里可能抛出的异常包括 BadListenerConfigException(配置格式错误)、NotFoundConfigFileException(找不到配置文件)等。
4.3 getConfigPathAndName():路径解析的防御性设计
private String[] getConfigPathAndName(String configLocation)
throws BadListenerConfigException {
String expMsg = "\r\n监控客户端初始化监听器配置有误,请参考如下配置:\r\n"
+ "<context-param>\r\n"
+ " <param-name>configLocation</param-name>\r\n"
+ " <param-value>classpath:conf/monitoring.properties</param-value>\r\n"
+ "</context-param>\r\n"
+ "<listener>\r\n"
+ " <listener-class>com.gitee.pifeng.monitoring.integrator"
+ ".listener.MonitoringPlugInitializeListener</listener-class>\r\n"
+ "</listener>\r\n";
String[] result = new String[2];
if (!StringUtils.startsWith(configLocation, "classpath:")
&& !StringUtils.startsWith(configLocation, "filepath:")) {
throw new BadListenerConfigException(expMsg);
}
String[] pathAndName = configLocation.split("/");
StringBuilder builder = new StringBuilder();
for (int i = 0; i < pathAndName.length - 1; i++) {
builder.append(pathAndName[i]).append("/");
}
String path = builder.toString();
String name = pathAndName[pathAndName.length - 1];
result[0] = path;
result[1] = name;
return result;
}
这个方法做了三件事:
第一,校验路径前缀。 configLocation 必须以 classpath: 或 filepath: 开头。classpath: 表示从类路径加载(JAR 包内或 classes 目录下),filepath: 表示从文件系统的绝对路径加载。如果用户写了一个不带前缀的路径(比如 conf/monitoring.properties),直接抛出 BadListenerConfigException。
第二,拆分路径和文件名。 以 / 为分隔符,最后一段是文件名,前面所有段拼接起来是路径。例如:
| configLocation | 解析后的路径(path) | 解析后的文件名(name) |
|---|---|---|
classpath:conf/monitoring.properties |
classpath:conf/ |
monitoring.properties |
filepath:/opt/config/monitoring.properties |
filepath:/opt/config/ |
monitoring.properties |
classpath:monitoring.properties |
classpath: |
monitoring.properties |
第三,错误提示的“说人话”设计。 异常消息不是一句冷冰冰的 “Invalid config location”,而是直接把正确的 web.xml 配置范例贴出来——包括 <context-param> 和 <listener> 的完整写法。对于一个可能在凌晨三点排查启动失败的运维工程师来说,这种“手把手教你改”的错误提示,比任何文档都管用。
这和第十三篇中 MonitoringPlugAutoConfiguration.analysisConfigFilePath() 的设计如出一辙——那里也是检测到路径前缀不合法时,直接把 @EnableMonitoring 的正确用法贴出来。两个模块、两条集成路径,在错误处理的理念上保持了高度一致。
4.4 contextDestroyed():为什么几乎什么都没做?
@Override
public void contextDestroyed(ServletContextEvent sce) {
log.info("Servlet上下文销毁!");
}
你可能会好奇——Listener 既然能感知上下文销毁,为什么不在这里做监控资源的清理?
答案是:不需要,因为 Monitor.start() 的内部已经通过 JVM 关闭钩子(ShutdownHook)注册了清理逻辑。 在第八篇中我们详细讲过,ShutdownHook.addShutdownHook() 会在 JVM 退出前自动执行发送下线数据包、优雅关闭线程池、关闭 HTTP 连接池、关闭 WebSocket 数据交换器等操作。
JVM 关闭钩子和 Servlet 上下文销毁的执行时机略有不同:contextDestroyed() 在应用卸载时触发(比如 Tomcat 热部署时),而 ShutdownHook 在 JVM 进程结束时触发。但两者都能覆盖“应用停止”的场景。Phoenix 选择统一通过 ShutdownHook 处理,而不是在每条集成路径中重复实现清理逻辑——这是一种“关注点分离”的设计思路:集成层只负责“启动”,清理逻辑下沉到核心层统一管理。
五、web.xml 配置实战
理解了源码之后,来看看实际使用时的 web.xml 配置。
5.1 最简配置(使用默认路径)
如果你的 monitoring.properties 放在 classpath 根目录下,只需要注册 Listener,不需要 <context-param>:
<listener>
<listener-class>
com.gitee.pifeng.monitoring.integrator.listener.MonitoringPlugInitializeListener
</listener-class>
</listener>
此时 contextInitialized() 方法读到的 configLocation 为 null,走无参 Monitor.start() 分支,默认加载 classpath:monitoring.properties。
5.2 自定义配置文件路径
如果你的配置文件放在 classpath 的子目录中:
<context-param>
<param-name>configLocation</param-name>
<param-value>classpath:conf/monitoring.properties</param-value>
</context-param>
<listener>
<listener-class>
com.gitee.pifeng.monitoring.integrator.listener.MonitoringPlugInitializeListener
</listener-class>
</listener>
5.3 使用文件系统绝对路径
如果你的配置文件放在服务器的某个目录下(而非 classpath 中):
<context-param>
<param-name>configLocation</param-name>
<param-value>filepath:/opt/phoenix/config/monitoring.properties</param-value>
</context-param>
<listener>
<listener-class>
com.gitee.pifeng.monitoring.integrator.listener.MonitoringPlugInitializeListener
</listener-class>
</listener>
filepath: 前缀适用于配置文件需要脱离 WAR 包独立管理的场景——比如同一个 WAR 包部署在多台服务器上,每台服务器的监控配置不同(实例名、服务端地址等),就可以把配置文件放在服务器本地目录中,通过 filepath: 指向它。
六、异常体系:BadListenerConfigException
当 configLocation 参数格式不合法时,getConfigPathAndName() 方法会抛出 BadListenerConfigException。看一下它的定义:
public class BadListenerConfigException extends MonitoringUniversalException {
private static final long serialVersionUID = -1672157366010494089L;
public BadListenerConfigException() {
super();
}
public BadListenerConfigException(String message) {
super(message);
}
}
它继承自 MonitoringUniversalException——Phoenix 异常体系中的“通用基类”。Phoenix 为不同的错误场景定义了语义明确的异常类:
| 异常类 | 使用场景 | 归属模块 |
|---|---|---|
BadListenerConfigException |
Listener 配置参数格式错误 | SpringMVC Integrator |
BadAnnotateParamException |
@EnableMonitoring 注解参数格式错误 |
Spring Boot Starter |
NotFoundConfigFileException |
找不到监控配置文件 | Client Core |
NotFoundConfigParamException |
缺少必要的配置参数 | Client Core |
ErrorConfigParamException |
配置参数值不合法 | Client Core |
BadListenerConfigException 和 BadAnnotateParamException 是一对“平行异常”——一个服务于 Listener 集成路径,一个服务于注解集成路径。它们的职责相同(校验入口层的参数格式),只是触发场景不同。
七、两条集成路径的全景对比
到这里,Phoenix 客户端的两条集成路径都已经拆解完毕。把它们放在一起对比,能更清晰地理解各自的定位:
7.1 架构对比
路径一:Spring Boot Starter
═══════════════════════════════════════
@EnableMonitoring
→ @Import(EnableMonitoringPlugSelector)
→ DeferredImportSelector
→ spring.factories
→ MonitoringPlugAutoConfiguration
→ ImportAware 读取注解参数
→ Monitor.start(monitoringProperties)
或 Monitor.start(path, name)
路径二:SpringMVC Integrator
═══════════════════════════════════════
web.xml
→ <listener> 注册 MonitoringPlugInitializeListener
→ <context-param> 传递 configLocation
→ contextInitialized()
→ getConfigPathAndName() 解析路径
→ Monitor.start(path, name)
或 Monitor.start()
两条路径最终都汇聚到同一个终点——Monitor.start()。 区别只在于“从哪里出发”和“怎么拿到配置”。
7.2 特性对比
| 对比维度 | Spring Boot Starter | SpringMVC Integrator |
|---|---|---|
| 适用场景 | Spring Boot 应用 | 传统 Spring MVC / 纯 Servlet 应用 |
| 集成方式 | @EnableMonitoring 注解 |
web.xml 中注册 Listener |
| 配置来源 | application.yml 或独立 properties 文件 |
仅支持独立 properties 文件 |
| 依赖 | spring-boot-autoconfigure |
仅 servlet-api(provided) |
| 模块中的 Java 类数量 | 6 个(注解、选择器、配置类、属性类、后置处理器、工具类) | 1 个(Listener) |
| 线程池自动纳管 | 支持(@MonitoringThreadPool + BeanPostProcessor) |
不支持 |
| 启动时机 | Spring 容器刷新阶段 | Servlet 容器启动阶段(更早) |
| 对 Spring 的依赖 | 强依赖 Spring Boot | 无依赖 |
7.3 关于线程池自动纳管
你可能注意到上表中的一个差异——SpringMVC Integrator 不支持 @MonitoringThreadPool 注解的自动线程池纳管。这是因为 MonitoringThreadPoolBeanPostProcessor 是 Spring Boot 的 BeanPostProcessor 机制,它依赖 Spring 容器的 Bean 生命周期管理。在没有 Spring Boot 的环境中,这个能力自然无法使用。
但这并不意味着传统项目就不能监控线程池。在第十二篇中我们讲过,Phoenix 的线程池监控有两种注册方式:
@MonitoringThreadPool注解:由BeanPostProcessor自动替换(仅 Spring Boot)- 构造器直接注册:直接使用
MonitoredThreadPoolExecutor或MonitoredScheduledThreadPoolExecutor,构造时自动注册到ThreadPoolManager
对于传统 Spring MVC 项目,使用第二种方式——把 new ThreadPoolExecutor(...) 替换为 new MonitoredThreadPoolExecutor(...)——就能实现线程池监控。少了一层“自动”的便利,但核心能力完全等价。
八、Monitor.start() 的三个重载:三条路径的汇聚点
无论从哪条集成路径进入,最终都会调用 Monitor 类的 start() 方法。回顾一下它的三个重载:
// 重载一:无参——使用默认路径加载 monitoring.properties
public static MonitoringProperties start() {
return run(null, null, null);
}
// 重载二:指定路径和文件名——加载指定位置的 properties 文件
public static MonitoringProperties start(String configPath, String configName) {
return run(configPath, configName, null);
}
// 重载三:直接传入配置对象——由 Spring Boot @ConfigurationProperties 绑定好的
public static MonitoringProperties start(MonitoringProperties monitoringProperties) {
run(null, null, monitoringProperties);
return monitoringProperties;
}
SpringMVC Integrator 只会触发前两个重载:
- 用户没配
configLocation→Monitor.start()(重载一) - 用户配了
configLocation→Monitor.start(path, name)(重载二)
Spring Boot Starter 三个都可能触发:
usingMonitoringConfigFile = true且路径为空 → 重载一usingMonitoringConfigFile = true且指定了路径 → 重载二usingMonitoringConfigFile = false(默认)→ 重载三
重载三是 Spring Boot 独享的“特权”——只有通过 @ConfigurationProperties 绑定出来的 MonitoringSpringBootProperties 对象,才能走这条路。这也解释了为什么 SpringMVC Integrator 不支持 application.yml 配置:因为没有 Spring Boot 的属性绑定机制,就无法构造出 MonitoringProperties 对象来调用重载三。
三个重载最终都汇入同一个私有方法 run(),执行完全相同的启动序列:打印 Banner → 加载配置 → 验证许可证 → 初始化加解密 → 启动数据交换器 → 心跳 → 服务器采集 → JVM 采集 → 线程池采集 → 网络设备采集 → Arthas 挂载 → 注册关闭钩子。
集成层的职责到“调用 Monitor.start()”为止,后续的一切由核心层统一处理。 这就是良好分层设计带来的好处——新增一条集成路径,不需要改动核心层的任何一行代码。
九、设计思考:为什么不合并成一个模块?
你可能会想:phoenix-client-spring-mvc-integrator 只有一个类,为什么不直接放进 phoenix-client-core 里?
答案是依赖隔离。
phoenix-client-core 是纯 Java 模块,不依赖任何 Web 相关的 API。而 MonitoringPlugInitializeListener 依赖了 javax.servlet.ServletContextListener——这是 Servlet 规范的接口。
如果把它放进 core 模块,那么所有使用 phoenix-client-core 的项目都会间接依赖 servlet-api。对于非 Web 应用(比如一个纯后台的定时任务进程),这个依赖是多余的,甚至可能引起冲突。
同理,phoenix-client-spring-boot-starter 依赖了 spring-boot-autoconfigure,也不能放进 core。
三个模块各自的依赖边界非常清晰:
phoenix-client-core → 纯 Java,无框架依赖
phoenix-client-spring-boot-starter → 依赖 Spring Boot
phoenix-client-spring-mvc-integrator → 依赖 Servlet API
用户按需引入:Spring Boot 项目引 starter,传统项目引 integrator,非 Web 项目直接用 core。 没有人会被强迫引入不需要的依赖。这就是 Maven 多模块工程“按职责拆分”的典型实践。
十、小结
这一篇我们拆解了 Phoenix 客户端的第二条集成路径——phoenix-client-spring-mvc-integrator 模块。它的核心只有一个 MonitoringPlugInitializeListener,但麻雀虽小,五脏俱全。回顾几个关键要点:
-
ServletContextListener作为入口:利用 Servlet 规范中最古老、最通用的生命周期钩子,在 Servlet 上下文初始化完成时启动监控。不依赖 Spring Boot,不依赖注解扫描,兼容一切 Servlet 3.0+ 环境。 -
context-param传递配置路径:通过web.xml的<context-param>传递configLocation参数,支持classpath:和filepath:两种前缀。不传则使用默认路径,传错则给出完整的配置范例提示。 -
路径解析的防御性设计:
getConfigPathAndName()方法以/为分隔符拆分路径和文件名,并在前缀不合法时抛出BadListenerConfigException,异常消息直接包含正确的web.xml配置示例。 -
与 Spring Boot Starter 的“殊途同归”:两条集成路径最终都汇聚到
Monitor.start(),区别仅在于入口机制(注解 vs Listener)和配置来源(application.yml/ properties 文件 vs 仅 properties 文件)。核心层的启动逻辑完全复用。 -
模块独立与依赖隔离:整个模块只依赖
phoenix-client-core和servlet-api(provided),不引入任何 Spring 依赖。core、starter、integrator 三个模块按依赖边界严格分离,用户按需引入。
从 Spring Boot Starter 到 SpringMVC Integrator,Phoenix 为不同技术栈的项目提供了对应的集成方案。但无论走哪条路,最终启动的监控引擎是同一个——配置加载、心跳上报、数据采集、加解密、数据交换,一个都不少。好的 SDK 设计,就是让用户在不同的环境中,都能以最低的成本获得完全相同的能力。
下一篇,我们将转向 Phoenix 客户端的业务层能力——同步与异步告警发送 API 的设计。敬请期待。
项目地址:
https://gitee.com/pifeng/phoenix
https://gitee.com/monitoring-platform/phoenix
https://github.com/709343767/phoenix

评论