ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

EJB入门实战:从@Stateless到事务回滚,一条路走通

EJB入门实战:从@Stateless到事务回滚,一条路走通 简介面向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无状态会话 BeanService/ 无状态 Service都是无状态业务组件的标准实现Stateful有状态会话 BeanScope(session)Bean保留客户端会话状态容器管理事务 CMTTransactional声明式事务边界异常触发回滚MessageDriven消息驱动 BeanJMS 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.sourceEncodingUTF-8/project.build.sourceEncoding maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target /properties dependencies dependency groupIdjakarta.platform/groupId artifactIdjakarta.jakartaee-api/artifactId version10.0.0/version scopeprovided/scope /dependency /dependencies build finalNameejb-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 version1.0 encodingUTF-8? module xmlnsurn:jboss:module:1.9 namecom.h2 resources resource-root pathh2-2.2.224.jar/ /resources dependencies module namejava.sql/ /dependencies /module然后用 WildFly 自带的 CLI 工具注册驱动和数据源/subsystemdatasources/jdbc-driverh2:add(driver-nameh2,driver-module-namecom.h2,driver-class-nameorg.h2.Driver)>?xml version1.0 encodingUTF-8? persistence xmlnshttps://jakarta.ee/xml/ns/persistence version3.0 persistence-unit nameorderPU jta-data-sourcejava:/OrderDS/jta-data-source properties property namehibernate.hbm2ddl.auto valuecreate-drop/ property namehibernate.show_sql valuetrue/ /properties /persistence-unit /persistencehbm2ddl.autocreate-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); ListPurchaseOrder 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 ListPurchaseOrder 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 可以直接注入 OrderServicepackage 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?customerzhangsanamount199.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 包的 finalNameOrderServiceBean是 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?customerlisiamount-100会看到 500 响应并且输出error订单金额不能为负数。此时再去查询订单列表WebServlet(/listOrders) public class ListOrdersServlet extends HttpServlet { EJB private OrderService orderService; Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { ListPurchaseOrder 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:/OrderDSCLI 里也必须绑定同一个 JNDI 名称。一旦漏掉java:/前缀或者数据源根本没创建成功部署时就会报Unable to resolve persistence unit。排查顺序是先确认 CLI 里style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
返回列表