Java EE 企业应用 03:WAR、EAR 与类加载边界如何影响交付
Maven 能编译,为什么部署时仍可能缺类
采购系统已经分出领域、用例、JDBC 适配器和 Web 四个 Maven 模块。构建机器上这几个项目彼此可见,部署机器却只拿到一个 WAR:服务器不会到开发者工作目录寻找 domain/target/classes。另一种失误刚好相反:以为 Jakarta EE API 用得多,就把 API JAR 连同应用类打包进 WEB-INF/lib,让应用带着和服务器提供的 API 同名的类。两种问题都要从包的内容和规范规定的可见性排查,不能只看 pom.xml 里写了依赖。
本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。
本章仍采用单 WAR。前两章的采购约束没有变:明细价格由服务端确定,非法审批状态拒绝,一张申请至多一张订单。检查 WAR 的目的只是确认运行这些代码所需的类位于正确位置,不表示这些业务路径已经通过 HTTP 或数据库验收。实际 RestApplication.java 把 REST 根设为 /api;HealthResource.java 提供 /health;它不调用采购用例。即便服务器返回 ready,也没有证明订单适配器已能访问数据库。
两种模块:构建边界与部署边界
Jakarta EE Platform 11 §8.1.1 允许把单个模块独立部署成应用;也允许把多个 Web、Enterprise Beans、应用客户端或资源适配器模块装配成一个 EAR。Java 语言的 module-info.java(JPMS)又是另一套模块机制,不能凭 Maven 的 <modules> 推断 WAR 用了 JPMS。当前 父 POM 的四个 <module> 是 Maven reactor 子项目,而 webapp/pom.xml 中 <packaging>war</packaging> 才决定这个部署包的类型。
逻辑上可把已有文件排成如下单 WAR。标为“预期”的路径应由干净构建后的 jar tf 核验,树本身不是这次运行产生的归档清单。
1 | |
WEB-INF/classes 容纳 Web 模块本身的类,WEB-INF/lib 容纳其依赖 JAR;两处都不是浏览器可以按 URL 直接下载的公共文件目录。Platform 11 §8.3.1 要求 Web 容器的组件能访问本 WAR 的 WEB-INF/classes 和 WEB-INF/lib 下的 JAR(不含子目录);Jakarta Servlet 6.1 §4.6、§10 的 Web 应用结构及资源规则与之配合。WEB-INF/beans.xml 是这个工程的 CDI 配置文件,不是另一个“全局服务器类路径”。目前用例类与 JDBC 适配器都通过本 WAR 的库 JAR 打包;把 procurement-domain 从 WAR 删除而只保留 Maven 依赖定义,不符合运行时需要的可见性。
观察加载链时还要分清类与资源。一个依赖包的 .class 文件出现在 WEB-INF/lib,使本 WAR 的业务类有机会解析它;这个包自己的 META-INF 配置可能通过 ClassLoader.getResource 被库发现,是否存在应检查归档实际条目。Platform 11 §8.2.3 要求引用的库资源同样可由类/类加载器的 getResource 访问,§8.2.4 则解释动态加载库可能需要使用线程上下文类加载器发现应用类。这并不意味着任何第三方库都能随意从应用类加载器向服务器类加载器取回私有类。排查 ClassNotFoundException 时记录“哪个代码路径、用哪个加载方式找哪个全限定名”,比笼统写一句“类加载有问题”更容易复现。
依赖还要从内到外沿着打包链检查。webapp/pom.xml 将 procurement-application、procurement-domain、procurement-jdbc 引入 WAR;如果只在本地 Maven 仓库保留前两者,服务器运行的就不是构建时那套 classpath。相反,把 procurement-jdbc 放到 WAR 中也不表示已经连库:它的 @Resource(lookup="jdbc/Procurement") 依赖运行时对应的受管 JNDI 资源。DatabaseCheckResource.java 使用相同名字,能查出 current_database(),但还未调用 JdbcRequestStore.insert。打包正确、资源绑定正确和用例业务写入正确是三次独立检查。
根 POM 管理的 jakarta.platform:jakarta.jakartaee-api:11.0.0 在 应用 POM、JDBC 适配器 POM 与 Web POM 中是 provided:编译期见到 jakarta.inject、jakarta.transaction、jakarta.ws.rs 等类型,WAR 不应私带整套平台 API。服务器的 server.xml 声明 Open Liberty 26.0.0.5 使用 jakartaee-11.0 和 jdbc-4.3,context root 为 /procurement;服务器版本、实际启用的特性和本地部署结果仍要分别验。打成 provided 并不能让一台只提供 Servlet 子集的运行时突然具有 Jakarta REST、CDI、Transactions 的实现。
javax.sql.DataSource 仍由 Java SE 的 javax.sql 提供。Jakarta EE 9 起更名的是相关企业 API,不是把所有 javax 代码全换成 jakarta。把旧 javax.servlet API JAR 放入面向 EE 11 的 WAR,也不会使旧版过滤器自动变成 jakarta.servlet.Filter:类型名、接口签名和服务器容器入口不匹配。Java EE 8 的迁移须冻结旧工程另作实验,不能用一条替换命令充当兼容性验收。
已有基础运行证据应当怎样引用?writing-plans/javaee-enterprise/verification/20261004T061900Z-pg16-foundation/ 记录了 clean verify 的 Maven 五模块 SUCCESS 和最终 BUILD SUCCESS;Open Liberty 26.0.0.5 的日志含有 procurement 应用启动条目,并列出 jakartaee-11.0、restfulWS-4.0、servlet-6.1 等已安装特性。health.headers.txt、datasource.headers.txt 都是 HTTP 200,响应体分别是 ready 与 connected;相应脚本的退出码文件都是 0。探针读取 javaee_lab 的库名是受管 JDBC 路径的正面证据,不是 Class-Path 跨模块可见性、EAR 部署或采购数据库写入的证据。构建输出也没给出本章故意删包后的部署日志;基础成功与负向探针的状态必须分开写。
EAR 共享库不是兄弟模块互相导入
如果以后要把 Web 与独立 EJB JAR 装配为一个应用,可提议下面的 EAR 结构;累计工程没有生成这个 EAR,也没有 approval-ejb.jar。
1 | |
Platform 11 §8.2.1 规定,EAR 的 META-INF/application.xml 如未给出 library-directory,默认目录是 lib;这个目录直接包含、后缀为 .jar 的文件必须对该 EAR 中所有组件可用,子目录里的 JAR 不因在 lib 下面就自动可见。可以显式改 library-directory,也可用空值指定没有库目录。与之不同,WAR 自己的 WEB-INF/lib 是 Web 模块内的库,不能仅凭两个模块在同一 EAR 就推导独立 EJB JAR 必须看见 WAR 私有 JAR。把双方共同使用的领域 API 放 EAR lib,或按规范用清楚的 manifest Class-Path 声明需要引用的库,才是可移植依赖的起点;同时避免把同一个领域 JAR 的不同版本又分别放入 WAR 和 EAR lib。
Platform 11 §8.3.1–§8.3.2 将“必须可见”“可以可见但不可依赖”“除非另有规则不得可见”分开。Web 组件必须看见本 WAR 的类及 EAR 共享库;对于同 EAR 里其他 Web/EJB 模块的类,规范允许实现提供额外可见性,可移植应用不得依赖它们一定可见或一定不可见。EJB 容器里的组件同样不得默认直接读取另一个模块私有的 WEB-INF/lib。某服务器恰好把整 EAR 放一个类加载器并不能变成跨服务器的保证;规范明确没有规定必须使用几层类加载器、哪一个先委派。对同名类,不能以“容器必定父优先”或“Web 模块总能盖过服务器类”作通用断言。
更隐蔽的错误是同一应用的两个组件依赖同名类的不同版本。Platform 11 的应用装配指导说,一个应用应只有一个版本的每个类,不保证每个组件有自己的隔离命名空间。即使打包器没有报错,运行时也可能出现链接错误、类型转换问题或行为不一致;具体表现取决于实现、类被加载的时刻和冲突的二进制接口。Platform 11 §8.2.2 对应用自带库和平台所需组件冲突说的是平台版本“可能且通常会”优先,而非强制某一种先后顺序。因此“故意打进一份平台 API”可以当成静态包冲突的可复现反例,不能承诺部署后一定抛某个固定异常。
例如两个 JAR 各自带一个同名 blog.javaee.domain.ProcurementRequest,其中一个构造器新增参数。某个组件编译时链接的是新签名,运行时如果先解析到旧类,可能直到调用该构造器时才出现 NoSuchMethodError;如果最终只保留一致版本,故障又不出现。另一类错误是两个不同类加载器分别加载同一全限定名,Java 类型身份还包含定义它的类加载器,互相传值时可能不能强转。这些是用于推导诊断步骤的条件情景,不是本工程已有的事故输出,也不表明 EE 11 保证使用多个类加载器。先用包清单排除重复 JAR,再核对异常里的类名、方法描述符及调用方,最后才考察发行版特有的类加载策略。
application.xml 只负责 EAR 层的模块、上下文根和库目录等装配信息;Web 模块自己的注解、WEB-INF 描述符与外部环境的绑定仍各有位置。Platform 11 §8.1.1 允许按约定省略模块描述符,§8.4.1 也允许在满足约定时省略 EAR 描述符,绝不能反推“没有 XML 便没有模块”或“多写一个 XML 就会引入隔离类加载器”。在当前 server.xml 中,context root 明确为 /procurement;它位于服务器配置,不是被 Maven 打进 WAR 的 WEB-INF/web.xml。若未来迁到另一台兼容服务器,Web 入口和环境资源绑定须分别核对,换包名不会自动生成新的数据源。
Class-Path 条目也有精确的约束:写在引用方归档的 META-INF/MANIFEST.MF,所列库路径相对于该归档的位置解析;Platform 11 §8.2.1 要求部署工具处理被引用的 JAR,保留相对关系,使它们在运行时的逻辑类路径可见。本 WAR 的 WEB-INF/lib 诸 JAR 原本就在 Web 类路径中,无需每个 JAR 互相添加 manifest 引用;EAR lib 的直属 JAR 同样属于全 EAR 可用的共享库。若把一个 JAR 放在 EAR 的 lib/nested/,再误以为“在 lib 下”就会自动生效,恰好违反直属目录规则。判断某个库“存在于包中”之后,仍须按它所在的位置判断“谁必须能看到它”。
这也是维护多 Web 模块 EAR 时最容易误判的地方。admin.war 中的 WEB-INF/lib/admin-only.jar 不能被当作 procurement-webapp.war 的 API;部署产品即使让 Web 模块暂时互见,跨产品部署时也可能消失。把 PriceCatalog 接口共享给多个模块,应只发布一份版本一致的契约 JAR,独立组件通过业务接口交互,而不是去调用兄弟 WAR 中的实现类。资源适配器及应用客户端也各有规范定义的可见范围,不能用“同一 EAR 内所有类都平坦共享”概括。是否抽出独立 EJB 模块应由实际业务部署需求决定,不能为显示 EAR 语法而改变目前已能构建和启动的单 WAR。
Jakarta EE 11 的完整 Platform 仍包括 Messaging 和 Enterprise Beans;Web Profile、Core Profile 与完整 Platform 不是同一个必备集合,Tomcat 有 Servlet 实现也不能当作完整平台。兼容清单描述经过认证的产品和范围,Platform 11 §8.3描述可见性契约,Liberty 配置是具体实现配置。三者都不是“这份采购 WAR 已部署”的运行证据。
包内容检查与故意缺依赖
在隔离副本中执行以下命令,需有 JDK 21、仓库 Maven Wrapper 和 zip 命令。命令从 examples/javaee-enterprise/ 运行;mktemp 的副本用于故障注入,不更改原 WAR。若构建失败就停在该阶段,不以旧的 target/ 产物代替干净构建。
1 | |
正常包应包含三个业务 JAR 和 Web 健康类,且不包含平台 API JAR;故障包缺少领域 JAR。静态断言可以定位包差异,尚不能证明安装服务器时一定在哪个阶段失败:容器可能在解析 Bean 时就发现缺类,也可能直到某条调用路径加载该类才暴露。故障诊断需另保存部署日志、异常链里首个缺失的类名、所用 WAR 的 SHA-256,以及访问 /procurement/api/health 和真实业务入口的结果。当前采购用例没有授权的 HTTP 入口,不能把“健康接口仍为 200”解释成采购已正常工作,也不能捏造业务调用的 500 状态码。对照试验只能在隔离服务器运行故障包,记录服务进程实际的退出码和日志,完成后恢复原始包。
这个对照试验的控制变量是 WAR 内容,不能一边删除领域 JAR 一边切换服务器 feature、JDK 或数据库。记录好原包与坏包哈希,在同一测试配置下分别部署;如果启动日志报告类找不到,还要核对它究竟属于 procurement-domain,而不是误删 procurement-application 导致的连锁失败。部署失败和“启动后只有用例路径失败”应写成不同观察,不能把后一种情形自动归咎于 CDI。由于现在没有可供外部调用的采购用例 HTTP 接口,若健康检查未触发缺失类,实验只能结束为“静态删包已验证,运行时加载时机未定位”,不可用编造的业务 500 填补缺口。
另一项容易漏记的证据是应用恢复后的结果。测试结束时重新部署原始哈希的 WAR,确认应用可以启动,再分别访问健康与数据库探针;这只能排除故障包继续占用当前部署位置,不能替代采购业务回归。两个探针各自只覆盖其真实执行的代码路径,缺失的领域 JAR 是否重新进入归档还须由原包清单和哈希交叉确认。
另一个可控反例是复制本地 Maven 缓存中 jakarta.jakartaee-api-11.0.0.jar 到临时 WAR 的 WEB-INF/lib,再用 jar tf 断言 API JAR 确实进入包;此时“打包违规”由检查证实,“容器启动必然失败”仍只是待验证假设。不要把临时包覆盖交付用 WAR,不要向生产服务器推送反例。这组正常与失败实验的实际构建输出、日志和包哈希均未在本章采集,统一标记 NOT_RUN;可填写的命令、期待值及证据表见包检查与规范阅读卡。
两道练习与答案
练习一:给提议的 EAR 增加 admin.war,并把 PriceCatalog 的接口 JAR 只放在 procurement-webapp.war/WEB-INF/lib。如果 approval-ejb.jar 和 admin.war 都直接引用它,能否依赖另一个 WAR 的私有库?给出两种改法与一种重复类风险。
答案:不能依赖。把共同接口 JAR 放进 EAR 根 lib,或者让各模块用符合规范的 manifest Class-Path 引用一个明确的共享 JAR;设计时应核对引用路径与可见性规则,而不是依赖某个产品刚好共享类加载器。若再在 WAR 私有库留另一版同名接口,可能出现两个版本竞争且行为由实现决定;应检查 EAR 内实际内容并控制唯一版本。application.xml 并非所有 EAR 都强制要有,只有显式控制模块装配或库目录时才需按相应场景编写。
练习二:某人把 jakarta.jakartaee-api 的 provided 改为默认编译范围,并说构建和 /health 都通过,就证明旧 javax.servlet.Filter 也能部署。请写出可以检查的两个反证,并说明哪一步仍未验证。
答案:对打包产物运行 jar tf,若发现 WEB-INF/lib/jakarta.jakartaee-api-*.jar,证明包里出现了不应捆绑的平台 API;对源码或编译后的接口检查 javax.servlet.Filter 与 EE 11 的 jakarta.servlet.Filter 全限定名不同,不能把前者当成后者交给容器。构建通过与健康接口响应都未验证旧过滤器被识别和执行;须在选定且启用相应特性的运行时提供明确的过滤器部署与请求路径、启动日志、调用证据,迁移还需冻结旧环境与回归断言。
版本与失败边界
规范依据:Jakarta EE Platform 11.0 §8.1.1、§8.2.1–8.2.2、§8.3.1–8.3.2、§8.4.1,Jakarta Servlet 6.1 §8.2、§10,Java SE 21 javax.sql,以及 Jakarta EE 11 Platform 兼容清单。本工程编译目标是 Java 21,EE 11 最低 Java SE 基线是 17;Maven 构建可见性、WAR/EAR 模块可见性与具体服务器的类加载策略不得混写为同一保证。GitLab master 链接是累计工程的源码入口,未合并的工作树文件尚需发布后核对;EAR 示例、故障包与其部署输出都不是已有工程成果。






