简介:面向Java EE初学者的EJB入门项目,基于IDEA与JBoss 7.1.1构建,涵盖会话Bean、实体Bean、消息驱动Bean三大核心组件,特别适合希望掌握分布式企业级业务逻辑开发与部署的开发者。资源包为rar格式,共46个文件,包括15个class、12个xml、5个java、4个jsp、4个properties、3个iml、2个jar等,涵盖Maven配置、Java源码、JSP页面、数据库及JNDI配置文件,压缩包仅2.84MB,结构清晰便于查阅。已有774人学习使用。通过该项目可完整了解EJB的接口定义、注解使用、事务控制、生命周期管理,以及如何通过JMS处理异步消息,并借助JBoss进行实际部署运行,从而理解EJB在真实环境中的工作方式,为后续更复杂的Java EE项目打下坚实基础。 前阵子同事接手一个十年前的物流核心系统,打开代码满屏都是@Stateless、@EJB、@PersistenceContext这些注解。他第一反应是皱眉:这不都是早该进博物馆的技术吗?等真正跟完几个线上工单,他才发现,EJB 这套东西远没有传言里那么不堪,EJB 3.1 以后的设计,甚至比很多新框架更接近容器化的本质。
如果你正在学 Jakarta EE,或者刚接触遗留企业系统,又或者一直想搞清楚“EJB 到底是个啥但被各种EJB重如泰山的说法劝退”,这篇文章就是给你准备的。我会用一个完整的 EJB 入门项目,从环境搭建、无状态会话 Bean 编写、事务回滚验证到最后的部署调试,一条路走通。学完你不仅能跑起来一个带数据库操作的 EJB 项目,还能理解为什么 EJB 至今仍在金融、电信、物流这些核心系统里占据一席之地。
1. 为什么我劝你先别急着给 EJB“盖棺定论”
1.1 “EJB 已死”是最大的误解
先说结论:EJB 没有死,它只是被 Spring 重新表达了一遍。
很多人对 EJB 的印象还停留在十几年前的 EJB 2.x:写一个 Bean 要继承一堆接口,部署描述符写到怀疑人生,开发效率低得离谱。那是 EJB 被骂得最惨的时代,也是 Spring 趁势崛起的原因之一。但从 EJB 3.0 开始,尤其是 3.1 之后,EJB 已经变成了一套非常干净的注解式组件模型。你只需要写一个普通类,加上@Stateless或@Stateful,容器就会帮你搞定池化、事务、并发控制、依赖注入这些繁琐的基础设施。
我现在做技术评估时有一个习惯:任何技术框架,先别管别人怎么说,去看它实际要解决的问题,以及它最近几个大版本的变化。EJB 在 Jakarta EE 里的定位一直很稳定:它是容器管理事务和分布式组件的标准方案。尤其在一些追求合规、看重长期稳定性的行业,EJB 项目的存量代码量远超想象。你学会了它,至少在接手这类系统时不会心里发慌。
1.2 EJB 和你熟悉的 Spring,其实是一对“亲兄弟”
如果你已经熟悉 Spring,学 EJB 其实并不需要清空大脑,反而可以一一对应着理解。很多 EJB 概念换个马甲,就是你天天在写的东西:
| EJB 概念 | 对应的 Spring 概念 | 说明 |
|---|---|---|
@Stateless无状态会话 Bean | @Service/ 无状态 Service | 都是无状态业务组件的标准实现 |
@Stateful有状态会话 Bean | @Scope("session")Bean | 保留客户端会话状态 |
| 容器管理事务 CMT | @Transactional | 声明式事务边界,异常触发回滚 |
@MessageDriven消息驱动 Bean | JMS Listener 或@KafkaListener | 异步消费消息 |
@EJB/@Inject注入 | @Autowired | 依赖注入方式 |
| Interceptor 拦截器 | Spring AOP / HandlerInterceptor | 在方法执行前后织入逻辑 |
这张对应关系表想表达的核心是:EJB 不是一种陌生的外星技术,它和 Spring 解决的是同一类问题,只是更贴近 Jakarta EE 规范,也更依赖应用服务器这个“容器”来提供运行时能力。
2. 从零搭一个 EJB 工程:选型思路和目录结构
2.1 为什么选了 WildFly 而不是 Tomcat
很多新手学 EJB 时第一个坑,就是把 war 包丢进 Tomcat,然后发现@Stateless没有任何效果。这不是代码问题,是运行环境问题。
Tomcat 只是一个 Servlet 容器,它不提供完整的 EJB 容器,也不管理事务、JNDI、实例池这些能力。EJB 必须跑在完整的 Jakarta EE 应用服务器上。常见的选择有 WildFly、Open Liberty、Payara、WebLogic、WebSphere。我推荐 WildFly 作为入门首选,原因有三个:它内置了 Hibernate 和完整的 JPA 支持,默认配置就能满足一个带数据库的 EJB 项目;管理台和 CLI 工具做得比较顺手;社区资料多,遇到奇怪问题更容易查到答案。
如果你在 Java 17 环境下学习,建议用 WildFly 27 或更高版本,它对应 Jakarta EE 10,正好能用上最新的jakarta.*命名空间。需要注意,Java EE 8 时代的包名是javax.*,Jakarta EE 9 换成了jakarta.*,网上很多老教程代码会报java.lang.NoClassDefFoundError,本质就是包名不匹配。
2.2 工程骨架与 Maven 依赖
我习惯用一个普通的 war 项目来承载 EJB 和 Web 层,这样既能在 Web 端直接注入 EJB,又不会引入太多工程结构负担。Maven 项目结构如下:
ejb-demo/ ├── pom.xml └── src/main/ ├── java/com/demo/ejb/ │ ├── entity/PurchaseOrder.java │ ├── service/OrderService.java │ ├── service/OrderServiceBean.java │ └── web/CreateOrderServlet.java ├── resources/META-INF/persistence.xml └── webapp/WEB-INF/web.xml(可选,Servlet 3.0+ 可以不要)pom.xml里最关键的依赖只有一个:
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>jakarta.platform</groupId> <artifactId>jakarta.jakartaee-api</artifactId> <version>10.0.0</version> <scope>provided</scope> </dependency> </dependencies> <build> <finalName>ejb-demo</finalName> </build>注意 scope 是provided,因为完整 API 由 WildFly 运行时提供,打进 war 反而会引发类冲突。最终打出来的包名是ejb-demo.war,这个 finalName 很重要,因为后面 JNDI 名称会和它相关。
2.3 H2 数据源配置:最容易被卡住的一步
入门项目不引入外部中间件,我选了 H2 内存数据库。这样最省事,也足够演示 JPA 和事务。
首先把 H2 驱动放到 WildFly 模块目录下。以 WildFly 27 为例,路径结构是:
$WILDFLY_HOME/modules/system/layers/base/com/h2/main/在这个目录下创建module.xml,并把h2-2.2.224.jar放进去:
<?xml version="1.0" encoding="UTF-8"?> <module xmlns="urn:jboss:module:1.9" name="com.h2"> <resources> <resource-root path="h2-2.2.224.jar"/> </resources> <dependencies> <module name="java.sql"/> </dependencies> </module>然后用 WildFly 自带的 CLI 工具注册驱动和数据源:
/subsystem=datasources/jdbc-driver=h2:add(driver-name=h2,driver-module-name=com.h2,driver-class-name=org.h2.Driver)><?xml version="1.0" encoding="UTF-8"?> <persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.0"> <persistence-unit name="orderPU"> <jta-data-source>java:/OrderDS</jta-data-source> <properties> <property name="hibernate.hbm2ddl.auto" value="create-drop"/> <property name="hibernate.show_sql" value="true"/> </properties> </persistence-unit> </persistence>hbm2ddl.auto=create-drop会在启动时建表、停机时删表,专门用来做演示和测试。生产环境绝对不要这么用,这点务必记牢。
3. 写第一个无状态会话 Bean:订单服务完整代码拆解
3.1 从接口到实现:@Stateless 的完整生命周期
先写一个实体类,对应业务数据库表:
package com.demo.ejb.entity; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import java.math.BigDecimal; @Entity @Table(name = "purchase_order") public class PurchaseOrder { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String customerName; private BigDecimal amount; // getter / setter 省略,实际代码中必须补齐 }然后定义接口。为什么 EJB 一定要接口?因为客户端拿到的并不是 Bean 实例本身,而是容器生成的代理对象。接口是客户端和实现类之间的契约,容器正是通过这个契约在调用前后插入事务、安全、池化等逻辑。
package com.demo.ejb.service; import com.demo.ejb.entity.PurchaseOrder; import java.math.BigDecimal; import java.util.List; public interface OrderService { Long createOrder(String customerName, BigDecimal amount); List<PurchaseOrder> listOrders(); }接着是核心实现类:
package com.demo.ejb.service; import com.demo.ejb.entity.PurchaseOrder; import jakarta.ejb.Stateless; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.math.BigDecimal; import java.util.List; @Stateless public class OrderServiceBean implements OrderService { @PersistenceContext private EntityManager em; @Override public Long createOrder(String customerName, BigDecimal amount) { if (amount == null || amount.signum() < 0) { throw new IllegalArgumentException("订单金额不能为负数"); } PurchaseOrder order = new PurchaseOrder(); order.setCustomerName(customerName); order.setAmount(amount); em.persist(order); return order.getId(); } @Override public List<PurchaseOrder> listOrders() { return em.createQuery("select o from PurchaseOrder o", PurchaseOrder.class) .getResultList(); } }@PersistenceContext注入的EntityManager是容器管理的持久化上下文,它跟当前 JTA 事务绑定。你不需要手动entityManager.getTransaction().begin(),事务边界由 EJB 容器统一管理。这是 EJB 和 JPA 原生 API 用法最大的区别,也是新手最容易搞混的地方。
3.2 Web 层调用:@EJB 注入背后的代理
在同一个 war 包里,Servlet 可以直接注入 OrderService:
package com.demo.ejb.web; import com.demo.ejb.service.OrderService; import jakarta.ejb.EJB; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.math.BigDecimal; @WebServlet("/createOrder") public class CreateOrderServlet extends HttpServlet { @EJB private OrderService orderService; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { try { String customer = req.getParameter("customer"); BigDecimal amount = new BigDecimal(req.getParameter("amount")); Long id = orderService.createOrder(customer, amount); resp.getWriter().println("created order id=" + id); } catch (RuntimeException e) { resp.setStatus(500); resp.getWriter().println("error=" + e.getMessage()); } } }访问http://localhost:8080/ejb-demo/createOrder?customer=zhangsan&amount=199.9,就能看到订单创建成功。
这里要屏蔽一个非常普遍的误解:@EJB注入的并不是OrderServiceBean的实例,而是容器动态生成的代理。你每一次调用orderService.createOrder(),都是把请求交给代理,由代理从实例池里取一个OrderServiceBean,开启事务,然后执行业务方法。这也是为什么@StatelessBean 里不能写有状态字段——多个客户端可能共享同一个实例池中的 Bean,你存了状态也没法区分归属。
3.3 事务边界在哪里,代码就写到哪里
EJB 容器管理事务(CMT)的默认行为是REQUIRED:如果调用方已经处于事务中,就直接加入;否则新建一个事务。这意味着只要一个方法被 EJB 容器调用,方法本身就被隐式包在了一个事务里。
事务边界有三个层面,这个理解能帮你省掉大量“为什么没回滚”的排查时间:
第一层是 EJB 方法入口。进入方法后事务开启,方法正常返回时提交,抛出RuntimeException时回滚。第二层是内部调用关系。同一个 Bean 内部调用另一个方法,事务不重新开启,就是同一个事务。第三层是跨 Bean 调用。当方法 A 调用另一个 EJB 的 Bean B 方法时,B 会默认加入 A 的事务,除非 B 声明了REQUIRES_NEW。
如果想显式指定一个查询方法只读,可以加上@TransactionAttribute(TransactionAttributeType.SUPPORTS),在有事务的情况下加入事务,无事务时就不开启。我之前遇到过一个项目,把所有查询方法都默认成REQUIRED,一次报表接口调了十几个查询,每次都强制要求事务上下文,虽然能跑,但确实浪费了一部分不必要的开销。虽然不是性能瓶颈,但说明很多人没有真正理解 EJB 的事务语义。
4. 部署到 WildFly 之后,JNDI 和调用链路要怎么理解
4.1 部署 war 包的三种方式
开发阶段最推荐的方式是直接把打好的包复制到 WildFly 的部署目录:
mvn clean package cp target/ejb-demo.war $WILDFLY_HOME/standalone/deployments/WildFly 默认处于自动部署模式,复制进去后日志里会出现类似Deployed "ejb-demo.war"的字样。另外两种方式分别是管理台上传和 CLI 部署。CLI 部署更可控,适合脚本化:
deploy target/ejb-demo.war --force三种方式原理都一样:应用服务器在接收到 war 包后,会扫描里面的类,识别@Stateless、@Stateful、@MessageDriven注解,在 JNDI 命名空间里建立起对应的名称映射。注意,这一步是 EJB 和普通 Servlet 项目最重要的运行期差异——Servlet 容器只管 URL 到方法的映射,而 EJB 容器还会额外管理一组命名绑定和实例池。
4.2 JNDI 名称并不是玄学
入门阶段很多人会看到javax.naming.NameNotFoundException就懵了。其实 EJB 的 JNDI 名称有规则可循,常见的是:
java:global/ejb-demo/OrderServiceBean!com.demo.ejb.service.OrderService结构拆开是这样的:java:global是全局命名空间,ejb-demo是模块名(正是 war 包的 finalName),OrderServiceBean是 Bean 的类名,!后面的部分是接口的全限定名。
同一个应用内部的 Servlet 用@EJB注入时不需要自己拼 JNDI,容器会帮你找到对应类型。但如果你在一个独立的客户端程序里用 JNDI 查找,这个名称就很重要。另外一个小技巧:如果记不住准确名称,可以在 WildFly 控制台的 Runtime 页面查看绑定信息,或者部署后看日志,WildFly 会把每个 EJB 的 JNDI 绑定名打出来。
4.3 本地接口和远程接口的区别
我在项目中往往默认用@Local标注接口,也就是前面代码中OrderService接口的默认语义。它的意思是这个 Bean 只对同一个 JVM 内的调用方暴露。在当前演示项目里,Servlet 和 EJB 部署在同一个 war 包,跑在同一个 WildFly 实例里,用本地接口就够了。
如果业务拆成两个应用,比如一个前端 Web 应用、一个后端 EJB 服务端,就需要用到@Remote接口:
@Remote public interface RemoteOrderService { Long createOrder(String customerName, BigDecimal amount); }@Remote的语义是支持跨 JVM 调用。客户端工程里只需要引入接口类和相关依赖,再通过 JNDI 查找到对应 Proxy。这里的成本在于你需要额外配置jboss-ejb-client.properties才能让客户端找到远程服务器端点,刚入门不必立刻掌握,知道有这个区别就行。
入门项目里我建议先用单应用的@Local方式跑通,明确自己正在和容器打交道,再去碰远程调用这种分布式的复杂场景。
5. 事务回滚的验证与三个必踩的入门坑
5.1 用一次故意失败验证事务回滚
EJB 入门最值得做的实验,不是写一个“成功创建订单”的接口,而是故意让它失败,看看事务到底有没有回滚。
我们在createOrder方法里已经埋了一个判断:金额为负数时抛IllegalArgumentException。从 Servlet 调用:
http://localhost:8080/ejb-demo/createOrder?customer=lisi&amount=-100会看到 500 响应,并且输出error=订单金额不能为负数。此时再去查询订单列表:
@WebServlet("/listOrders") public class ListOrdersServlet extends HttpServlet { @EJB private OrderService orderService; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { List<PurchaseOrder> orders = orderService.listOrders(); for (PurchaseOrder o : orders) { resp.getWriter().println(o.getId() + " " + o.getCustomerName() + " " + o.getAmount()); } } }如果第一条 zhangshan 的订单在,而第二条 lisi 的订单不存在,说明回滚生效了。这个实验结论很直观:RuntimeException会让 EJB 容器主动把当前事务标成RollbackOnly,即使persist()已经执行,最终也不会提交。
有个细节需要强调:只有RuntimeException和Error默认触发回滚。如果你自定义了一个异常继承自Exception,默认不会导致回滚。想让这类业务异常也能触发回滚,需要加注解:
@ApplicationException(rollback = true) public class InsufficientStockException extends RuntimeException { public InsufficientStockException(String message) { super(message); } }@ApplicationException(rollback = true)的意思很明确:虽然你是一个业务异常,但请你触发回滚。这个细节项目里经常出问题,开发者定义了一个业务异常,结果数据没回滚,排查半天才发现是默认不回滚导致的。
5.2 三个必踩的坑
第一个坑:部署在 Tomcat 里。运行时缺少 EJB 容器类库,@Stateless注解不生效,注入的@EJB字段是 null。判断方法很简单:看启动日志有没有 EJB 模块扫描记录,或者看看Standalone和Tomcat这两个环境的类加载器差异。别在 Tomcat 上浪费时间,换 WildFly 这类完整应用服务器。
第二个坑:JNDI 数据源名称对不上。persistence.xml里写的是java:/OrderDS,CLI 里也必须绑定同一个 JNDI 名称。一旦漏掉java:/前缀,或者数据源根本没创建成功,部署时就会报Unable to resolve persistence unit。排查顺序是:先确认 CLI 里style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />