5.3 Listener 与 JNDI:事件与资源 本节摘要:管道上还有两位不拦请求的后勤官。Listener 是事件广播员,容器与应用里发生的大事——启动、停止、会话建立与销毁、请求到来——都会向登记过的监听器广播;JNDI 是资源调度台,把数据源、环境变量登记在容器里,应用按名字查找使用。本节讲清监听器的三类事件与书写方式、JNDI 的全局与应用级两种定义、查找代码与容错要点。 前两节的组件都在拦请求,本节两位站在路边服务。别小看后勤:缓存预热、连接池借还、配置读取,这些"启动时干一次、运行时反复用"的事全靠它们——第 7 章讲内存泄漏排障时,监听器没清理资源是头号惯犯。
本节摘要:管道上还有两位不拦请求的后勤官。Listener 是事件广播员,容器与应用里发生的大事——启动、停止、会话建立与销毁、请求到来——都会向登记过的监听器广播;JNDI 是资源调度台,把数据源、环境变量登记在容器里,应用按名字查找使用。本节讲清监听器的三类事件与书写方式、JNDI 的全局与应用级两种定义、查找代码与容错要点。
前两节的组件都在拦请求,本节两位站在路边服务。别小看后勤:缓存预热、连接池借还、配置读取,这些"启动时干一次、运行时反复用"的事全靠它们——第 7 章讲内存泄漏排障时,监听器没清理资源是头号惯犯。
监听器的世界按事件源分三类:容器生命周期事件(应用启动停止)、会话事件(会话建立销毁属性变化)、请求事件(请求开始销毁)。接口全是规范预定义的,实现哪个就收哪类广播。
| 接口 | 广播时机 | 典型用途 |
|---|---|---|
| ServletContextListener | 应用初始化与销毁 | 缓存预热、连接池自建、清理现场 |
| HttpSessionListener | 会话建立与销毁 | 在线人数统计、会话配额 |
| ServletRequestListener | 请求开始与结束 | 全链路追踪埋点、慢请求计数 |
写一个监听器把三类事件都接住,日志里就能看到应用的呼吸节律。
// 应用内的监听器:三类事件全登记 import jakarta.servlet.*; import jakarta.servlet.http.*; import jakarta.servlet.annotation.WebListener; @WebListener public class AppListener implements ServletContextListener, HttpSessionListener, ServletRequestListener { @Override public void contextInitialized(ServletContextEvent sce) { // 应用启动时执行一次:预热缓存 建线程池都在这里 System.out.println("应用启动完成 名字是 " + sce.getServletContext().getContextPath()); } @Override public void contextDestroyed(ServletContextEvent sce) { // 应用销毁时执行:关线程池 清缓存 补上这句能少很多内存泄漏 System.out.println("应用即将销毁 清理现场"); } @Override public void sessionCreated(HttpSessionEvent se) { System.out.println("新会话建立 编号 " + se.getSession().getId()); } @Override public void sessionDestroyed(HttpSessionEvent se) { System.out.println("会话销毁 " + se.getSession().getId()); } @Override public void requestInitialized(ServletRequestEvent sre) { System.out.println("请求开始 " + ((HttpServletRequest) sre.getServletRequest()).getRequestURI()); } @Override public void requestDestroyed(ServletRequestEvent sre) { System.out.println("请求结束"); } }
一个工程细节值得强调:销毁回调不是摆设。contextDestroyed 里关闭自建线程池、释放外部连接,是防类加载器泄漏的第一道防线——热部署几次后内存涨、线程数不减,八成是这里没清理。另外 ServletContextEvent 之外还有容器层的 ServerListener 一族(在 server.xml 配置),监听整个 Server 的启停,适合做实例级初始化,与应用级监听器分属两个世界,别混用。
JNDI 的思想一句话:资源归容器管,应用按名取。数据库连接池为什么不该让应用自建?因为三个应用各自建池,数据库连接总数失去控制;由容器统一登记一个池,总量可控、配置集中、更换数据库只动容器一处。
两级登记先看全局级:资源定义在 server.xml 的 GlobalNamingResources 里,所有应用可见,再经 ResourceLink 放行给具体应用。
<!-- server.xml:全局资源登记 --> <GlobalNamingResources> <Resource name="jdbc/sharedb" auth="Container" type="javax.sql.DataSource" driverClassName="org.postgresql.Driver" url="jdbc:postgresql://db-internal:5432/shared" username="shared_app" password="从密钥管理读取" maxTotal="30" maxIdle="8" maxWaitMillis="6000"/> </GlobalNamingResources> <!-- 应用对应的 Context 片段里放行 --> <Context docBase="order" path="/order"> <ResourceLink name="jdbc/sharedb" global="jdbc/sharedb" type="javax.sql.DataSource"/> </Context>
应用级更简单:4.3 已在应用的 context.xml 里写过 Resource 定义,作用域仅限本应用。选型看共享需求——一个库多个应用用就走全局加放行,独享就写在应用侧。
应用侧的查找代码是标准三步:初始化命名上下文、按名查找、强转使用。
// 在 Servlet 或监听器里按名取数据源 import jakarta.annotation.Resource; import javax.sql.DataSource; import java.sql.Connection; public class OrderDao { // 方式一:注解注入 容器代劳查找(推荐) @Resource(name = "jdbc/orderdb") private DataSource dataSource; public Connection borrow() throws Exception { return dataSource.getConnection(); } }
// 方式二:手动 JNDI 查找 适合非托管环境 import jakarta.servlet.ServletContext; import javax.naming.InitialContext; import javax.sql.DataSource; public DataSource lookup() throws Exception { InitialContext ctx = new InitialContext(); return (DataSource) ctx.lookup("java:comp/env/jdbc/orderdb"); }
lookup 的名字有前缀讲究:应用私有的命名空间固定以 java:comp/env 开头,后面接资源名。查不到时的报错分两种:名字不对报 NameNotFoundException,定义了对不上类型报转换异常——前者查拼写与 ResourceLink,后者查 type 属性与实际实现类是否一致。
⚠️ 常见坑:驱动包只放进应用的 WEB-INF 却用了全局 Resource——全局资源的类加载器看不见应用的库,启动时报驱动类找不到。全局资源的依赖要放容器的 lib,应用级资源才可用应用的 lib。又是"谁的类放谁家"。
监听器与 JNDI 都不碰请求的主流程,却决定应用能不能"干净地活、体面地死"。三个带走要点:启动做加法(预热、建池),销毁做减法(关池、清缓存),资源交容器(连接池全局统管,应用按名取)。这三条做全,第 7 章讲到的热部署泄漏、连接耗尽两类故障基本与你绝缘。
后勤齐备,请求终于要进店了。第 6 章是终点站:过滤器链怎么一环环传递、JSP 如何被编译成 Servlet、WebSocket 如何升级连接、Spring Boot 为什么把 Tomcat 装进了应用里。