☰
EJB入门项目实战:三类Session Bean与JNDI部署避坑指南
2026/10/13 19:40:17 网站建设 项目流程

简介:这是一份面向EJB初学者的入门实践项目,基于IDEA与JBoss 7.1.1应用服务器,覆盖无状态/有状态会话Bean、实体Bean与消息驱动Bean的搭建与部署,适合正在学习Java EE组件化开发、希望理解EJB生命周期与事务控制的读者。压缩包共46个文件,约2.84MB,含5个Java源码、15个class编译文件、12个xml配置及JSP页面,pom.xml负责依赖管理,META-INF与resources目录承载部署描述符和数据库连接配置,结构清晰便于对照学习。已有777人学习下载。通过该入门项目可获得完整可运行的EJB示例,直观掌握注解使用、接口定义、JNDI绑定、事务处理及消息驱动Bean与JMS的集成方式,为后续构建复杂的企业级Java EE应用打下基础。

1. EJB入门项目到底在学什么:把业务对象交还给容器托管的第一步

同一个业务接口,用Servlet写,事务要自己begin/commit,连接要自己管,多实例部署时Session还得塞进数据库做同步;换成EJB,容器把事务、安全、并发、实例池全部接走,业务类只需要是一个带注解的普通Java类。EJB入门项目要做的,就是亲手把一个最小的无状态Bean部署进应用服务器,再从客户端把它的远程接口调通。适合正在从Servlet或纯Spring Boot转向Jakarta EE的开发者,也适合要接手老EJB系统的同事——看懂这个最小闭环,后面翻任何架构文档都不再是黑匣子。

2. 先分清三类Session Bean:Stateless、Stateful、Singleton的适用边界

EJB家族里真正天天打交道的是三类Session Bean加上一个消息驱动Bean,入门阶段我建议先把Session Bean选明白。选错类型不是编译报错,而是上线后才暴露问题:购物车状态被多实例共享、缓存被并发写穿、长事务把连接池拖垮。这一章把三类Bean的语义、生命周期和事务上下文讲清楚,后面写代码才有依据。

2.1 Stateless Session Bean:无状态是“不保证有状态”,不是不能写字段

Stateless 是大多数EJB入门项目的第一个Bean,也是最容易被名字误导的一个。它的全称是无状态会话Bean,意思不是类里不能有字段,而是容器不保证两次方法调用落到同一个实例上,所以任何留在字段里的中间数据都不可信。

@Stateless public class OrderNoGeneratorBean { public String next(String prefix) { // 每次调用都可能是不同实例,这里只能放无状态逻辑 return prefix + "-" + UUID.randomUUID(); } }

这段代码里我没有写任何实例字段,因为两次next()调用可能被容器分发到不同的实例。@Stateless告诉容器:这个Bean可以被池化,实例可以在空闲时被回收或者复用。池化带来两个收益:一是内存占用可控,二是方法之间天然隔离,并发安全压力小。容器默认把所有方法的事务属性设成REQUIRED,也就是调用方没开事务时,方法自己开一个,方法正常返回就提交。

选Stateless的典型场景是:计算、查询、字典翻译、网关转发这类不携带用户会话的纯逻辑。如果你发现自己给Stateless Bean加了setUserId()再调业务方法,那说明类型选错了,应该考虑Stateful或者把用户身份作为方法参数传进来。项目里见过太多人把登录用户ID缓存在Stateless字段里,多实例部署后时而正常时而串号,这就是把“不保证有状态”理解成了“我运气好能碰上同一个实例”。

2.2 Stateful Session Bean:会话状态放内存前,先想好超时和钝化

Stateful 和 Stateless 正相反,容器会保证同一个客户端拿到的代理,后续方法调用都落在同一个Bean实例上。这个特性天然适合购物车、多步审批、向导式录入这类必须保留中间状态的过程。

@Stateful public class CartBean { private List<String> items = new ArrayList<>(); public void add(String sku) { items.add(sku); } public int size() { return items.size(); } @Remove public void checkout() { // 结账后显式告诉容器:这个实例可以销毁了 } }

@Remove是我在入门阶段不太容易被注意、但后期非常关键的注解。它标注的方法被调用后,容器会销毁这个Bean实例并释放资源。如果不写@Remove,实例就只能等会话超时被回收,购物车流程一多,内存里堆积的全是不再使用的Bean。Stateful Bean还有一组生命周期回调:@PostConstruct、@PrePassivate、@PostActivate、@PreDestroy。其中@PrePassivate和@PostActivate对应钝化与激活,容器把不活跃实例序列化到磁盘再重新加载,这时候记得把数据库连接、套接字这类不可序列化资源在回调里关掉和重建。

选Stateful前要回答三个问题:状态能不能由前端带回?状态能不能存数据库或缓存中间件?状态的量级是不是超过几KB?如果前端能带回就带回,能落库就落库,Stateful Bean适合的是“必须在一次会话里连续操作多步,而且每步产生的数据不适合立刻落库”的场景。

2.3 Singleton Session Bean:共享缓存和计数器的锁策略

Singleton 是整个应用服务器里每个应用只有一个实例的Bean,适合做配置缓存、计数器、应用级预热。它跟@ApplicationScoped的CDI Bean定位接近,但多了一套更严格的并发控制。

@Singleton public class AppConfigBean { private Map<String, String> cache = new ConcurrentHashMap<>(); @Lock(LockType.READ) public String get(String key) { return cache.get(key); } @Lock(LockType.WRITE) public void put(String key, String value) { cache.put(key, value); } @AccessTimeout(3000) public void refresh() { // 拿不到写锁时最多等3秒 } }

默认情况下Singleton的锁策略是@Lock(WRITE),也就是说所有方法默认串行执行。读多写少的场景一定要给查询方法标上@Lock(READ),否则并发上来后查询全部排队,性能直接崩。@AccessTimeout(3000)表示拿锁超时时间,单位毫秒;超时后容器会抛ConcurrentAccessTimeoutException。还有个容易被忽略的注解是@Startup,它让Singleton在应用启动时就实例化并执行@PostConstruct,我一般用它做缓存预热和自检。

Singleton 最容易踩的坑是“把重IO塞进被锁的方法里”。比如在put方法里同步写数据库,写库耗时3秒,所有读方法都被写锁挡住,整个应用表现为卡死。正确做法是让写方法只更新内存缓存,落库逻辑放到独立的异步线程或者另一个Stateless Bean里。

3. 用Maven搭一个能部署的EJB入门工程:pom配置、三类Bean代码与WildFly一键部署

原理讲完就该动手了。这一章我按最常见的方案从零搭一个最小工程:Maven + 单war包 + 开源应用服务器。你会发现EJB 3.1之后Bean可以直接放在war包里,不用再强制拆成ear,入门阶段这个简化能省掉大量构建配置的干扰。

3.1 工程骨架:为什么入门阶段用war而不是ear

传统EJB工程是ear结构:一个ejb jar放业务Bean,一个war放Web层,ear负责打包两者。这样做的历史原因是老规范强制EJB必须在独立jar里,而且ear能清晰表达模块边界。现在主流容器都支持在war里直接声明EJB,对入门项目来说,war结构只需要一个模块,部署、调试、查日志都简单一个量级。

ejb-min/ ├── pom.xml └── src/main/java/ └── com/example/biz/ ├── OrderService.java ├── OrderServiceBean.java └── ReportJobBean.java

这个结构里OrderService是业务接口,OrderServiceBean是实现,ReportJobBean是定时任务。war包方式下Bean类和普通类没有任何目录区别,靠的就是类上的@Stateless、@Stateful、@Singleton注解被容器识别。我一般还会建一个src/main/webapp/WEB-INF/web.xml空文件,避免某些容器对没有web.xml的war包做额外扫描。EJB的接口在这里有两条路线:给接口标上@Local或@Remote,这决定了调用方式是本地还是跨JVM。

3.2 pom.xml里的三项关键配置:依赖、打包插件、部署插件

<project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>ejb-min</artifactId> <version>1.0-SNAPSHOT</version> <packaging>war</packaging> <dependencies> <dependency> <groupId>jakarta.ejb</groupId> <artifactId>jakarta.ejb-api</artifactId> <version>4.0.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <version>6.0.0</version> <scope>provided</scope> </dependency> </dependencies> <build> <finalName>ejb-min</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>3.4.0</version> </plugin> <plugin> <groupId>org.wildfly.plugins</groupId> <artifactId>wildfly-maven-plugin</artifactId> <version>4.2.0</version> </plugin> </plugins> </build> </project>

三个关键点:第一,jakarta.ejb-api的scope是provided,因为应用服务器里已经内置了这套API,打包进去反而会造成类冲突。第二,finalName决定了war文件名,也直接决定了后面JNDI里的模块名,我习惯把artifactId和finalName设成一致,省得部署后对不上号。第三,wildfly-maven-plugin负责把war推到运行中的服务器上,走的是管理端口,不是HTTP端口。

如果你用的容器不是WildFly,这套配置思路同样适用:依赖改成容器对应的jakarta.ejb-api,部署插件换成容器官方提供的Maven插件或直接用管理接口上传。EJB API是所有容器共享的,差别只在外围的部署通道。

3.3 一个最小Stateless Bean加一个@Schedule定时任务,验证容器托管

Bean代码是入门项目里最不花哨的部分,但有几个约束必须刻在脑子里:类必须是public的,不能是final的,不能有final方法。这些约束不是编译器校验的,是容器在部署期通过反射检查的,违反它你会看到一个看起来像是“部署失败”但实际是类结构不合法的问题。

package com.example.biz; import jakarta.ejb.Stateless; @Stateless public class OrderService { public String createOrder(String sku, int qty) { // 这里没有写事务代码,容器自动管理 return "ORDER-" + sku + "-" + qty; } }

这个Bean没有实现任何接口,走的是EJB 3.1的无接口视图。容器会给它生成一个代理,同模块内的Servlet或者CDI Bean直接注入就能用。无接口视图的限制是它只能在本地调用,不能跨JVM。如果你预感到这个Bean以后会被独立客户端远程调用,从第一天就给它抽一个接口并标@Remote。

package com.example.biz; import jakarta.ejb.Schedule; import jakarta.ejb.Singleton; import jakarta.ejb.Startup; @Singleton @Startup public class ReportJobBean { @Schedule(hour = "2", minute = "30", persistent = false) public void generateDailyReport() { // 每天凌晨2:30由容器触发,不需要外部调度器 } }

@Schedule是让人最快感受到“容器托管”价值的注解:不用自己写守护线程,不用接Quartz,容器内部线程池负责按cron表达式触发。persistent = false表示这个定时任务不持久化,服务器重启后不补跑;如果任务丢一次会出事故,应该改成persistent = true,容器会在重启后补偿错过的触发点。

写完之后执行部署:

mvn clean package mvn wildfly:deploy -Dwildfly.hostname=127.0.0.1 -Dwildfly.port=9990

第一次跑wildfly:deploy之前,确认服务器已启动并且管理端口9990可访问。成功后访问管理控制台能看到ejb-min.war处于active状态,日志里会出现部署成功的行。这时候做一个最简单的验证:改一下createOrder的返回值,重新deploy,再调用一次,能看到容器把新代码加载进来,这就是热部署的体现。

4. 把客户端和EJB容器接上:JNDI命名的三种写法与独立客户端lookup

Bean部署进去只是第一步,客户端怎么找到它是入门项目真正卡人的地方。EJB的“寻址”靠JNDI,而JNDI字符串几乎每个新手都要拼错几回。这一章把命名规则拆开讲,再给一个从零写独立客户端lookup的完整例子。

4.1 java:global / java:app / java:module:三种命名的差异表

JNDI名字看着是一长串路径,其实结构非常规律,前缀决定了查找的生效范围。

JNDI前缀格式生效范围典型场景
java:globaljava:global/应用名/模块名/Bean名!接口全名整个服务器所有应用独立客户端远程调用
java:appjava:app/模块名/Bean名!接口全名同一个ear内部ear里war调用ejb jar
java:modulejava:module/Bean名!接口全名同一个模块内部war内Bean互相调用

三个默认命名规则是血泪经验:应用名默认是ear文件名去掉后缀,模块名默认是jar或war文件名去掉后缀,Bean名默认是类简单名。比如我的war叫ejb-min.war,里面有个OrderService且带远程接口com.example.biz.OrderServiceRemote,那它的完整JNDI就是:

java:global/ejb-min/ejb-min/OrderService!com.example.biz.OrderServiceRemote

看出坑在哪了吧:模块名和Bean名之间隔着两段相同的名字,一个来自war文件名,一个来自类名。很多人拼错就是少写了中间那段重复的模块名。对无接口视图的Bean,JNDI名不带接口后缀,直接是java:global/ejb-min/ejb-min/OrderService。

4.2 独立客户端里手写InitialContext:核心参数与依赖

独立客户端是EJB远程调用的标准用法:一个小Java程序,通过JNDI拿远程代理,然后像调本地方法一样调用。这里必须处理的不是业务代码,而是“怎么让客户端知道服务器在哪、用什么协议”。

package com.example.client; import com.example.biz.OrderServiceRemote; import javax.naming.Context; import javax.naming.InitialContext; import java.util.Properties; public class OrderClient { public static void main(String[] args) throws Exception { Properties props = new Properties(); props.put(Context.INITIAL_CONTEXT_FACTORY, "org.wildfly.naming.client.WildFlyInitialContextFactory"); props.put(Context.PROVIDER_URL, "http-remoting://127.0.0.1:8080"); InitialContext ctx = new InitialContext(props); OrderServiceRemote service = (OrderServiceRemote) ctx.lookup( "java:global/ejb-min/ejb-min/OrderService!com.example.biz.OrderServiceRemote"); String orderId = service.createOrder("SKU-1001", 2); System.out.println("created: " + orderId); } }

两个参数是远程调用能不能成的关键:INITIAL_CONTEXT_FACTORY指定客户端用哪个工厂类去解析JNDI,这个类必须来自容器提供的客户端jar;PROVIDER_URL使用的是http-remoting协议,端口是HTTP端口8080,不是管理端口9990。首次跑这个客户端,记得在依赖里加上容器分发的客户端库,WildFly对应的是jboss-client,把这个jar放到客户端的classpath里,上面两个类才能被找到。

如果你觉得手写Properties太啰嗦,也可以在客户端里放一个jndi.properties文件,把这两行配置写进去,直接new InitialContext()即可。但显式写Properties的好处是启动时能打印出来,确定生效的配置是哪份——我就在多个环境切换时被classpath里的旧配置文件坑过,显示配置永远比隐式配置好排错。

4.3 JNDI解析失败的排查顺序:从NoInitialContextException到NameNotFoundException

JNDI报错就两大类,排查顺序非常固定。第一类NoInitialContextException,意思是客户端压根没连上服务器,优先检查三处:容器是否在监听8080、PROVIDER_URL协议拼写对不对、客户端jar是否真的在classpath里。第二类NameNotFoundException,意思是连上了但名字不对,这时候别猜,直接把服务器上实际绑定的名字列出来看。

// 调试专用:把该上下文下所有绑定名打印出来 java.util.Hashtable<String, Object> env = new java.util.Hashtable<>(); env.put(Context.INITIAL_CONTEXT_FACTORY, "org.wildfly.naming.client.WildFlyInitialContextFactory"); env.put(Context.PROVIDER_URL, "http-remoting://127.0.0.1:8080"); InitialContext ctx = new InitialContext(env); java.util.Enumeration<javax.naming.NameClassPair> list = ctx.list("java:global"); while (list.hasMoreElements()) { System.out.println(list.nextElement().getName()); }

ctx.list("java:global")会把所有应用、模块、绑定名打出来,对照着拼真实名字,比盯着文档猜靠谱得多。我踩过最隐蔽的一次是war包被IDE热部署后文件名变成了ejb-min.war加时间戳副本,finalName失效,JNDI里模块名变成了带随机后缀的名字。从那以后,凡是我在哪对JNDI名都先list看看,这也是最快的学习路径。

5. EJB入门项目避坑指南:部署不生效、找不到Bean、ClassCastException与半夜回滚

这一章收集的是入门项目里反复出现的四个现场,每个都按“现象—原因—解决”来讲。这些不是理论问题,是真实部署环境里会挡住你半天甚至一周的坎。

5.1 部署日志一片正常,但新代码不生效

现象:mvn wildfly:deploy提示success,管理控制台里war是active,但访问接口拿到的还是旧逻辑。

原因:八成不是EJB的问题,是IDE或脚本把war包解压后做增量发布,旧class文件残留。WildFly部署默认是解压式部署,standalone/deployments目录下面会有一个和war同名的目录,里面有解压后的class。如果本地构建只替换了war里的部分文件,或者IDE默认做了“复制已更改文件”而不是“重建并完整上传”,残留class就会和新class混在一起,而容器优先加载了旧的。

解决:先mvn clean再package,确保war里没有旧class;部署前把standalone/deployments下对应目录删掉再重新deploy;最后确认finalName没被IDE覆盖。我给自己定了条规矩:排查部署不生效时,第一件事是比对war包里的class时间戳和服务器上的class时间戳,而不是重启服务器。

5.2 “No EJB found”但JNDI名看着完全正确

现象:异常信息是NameNotFoundException,名字和文档例子一字不差,但就是找不到。同一套代码换个容器又能跑。

原因:JNDI名字里的“模块名”不等于“artifactId”,它默认取打包产物的文件名。ear包里的模块名规则和war不同:一个war部署到服务器,模块名是war文件名;但如果war被打进ear,模块名变成war文件名内层再套一层ear文件名的路径。还有一种常见情况是IDE的部署工具自动给war改名为带上下文的版本号,模块名就跟着变了。

解决:不要凭记忆拼,启动服务器后用上一章的ctx.list("java:global")打出真实树。另一种办法是直接看启动日志,WildFly在应用发布成功时会在日志里打印该应用绑定的JNDI名列表,搜关键字“JNDI”就能看到完整名字。这个习惯帮我省下的时间,比写业务代码还多。

5.3 远程客户端抛ClassCastException,业务代码却没问题

现象:独立客户端lookup成功,代理也拿到了,但强转接口类型时抛ClassCastException,提示“xxx cannot be cast to xxx”,两边的类名明明一模一样。

原因:远程调用时客户端拿到的是动态代理,它只认服务器那边classloader加载过的那份接口类。如果客户端classpath里有一份自己打包的OrderServiceRemote,服务器的web模块里也有一份OrderServiceRemote,这两个类在JVM看来是“全限定名相同但身份不同”的类,强转必然失败。这就是典型的包分裂问题,常见于把接口类从业务模块里复制出来后直接拷给客户端。

解决:远程接口(包括远程方法签名里用到的DTO)必须单独抽成一个api jar,服务器端和客户端都依赖这同一个jar,而不是各自编译各自的副本。如果你临时调试,可以把服务器解压后的class和客户端class用javap对比,但最终方案一定是接口归一。这条规范我从入门第一天就执行,后来接第三方的接口时少踩了无数坑。

5.4 事务在凌晨回滚,白天怎么测都不复现

现象:定时任务每天凌晨跑批量数据处理,日志里出现TransactionRolledbackException,提示事务超时或连接获取超时;白天手动跑同样逻辑却正常。

原因:两个因素叠加。一是事务超时:EJB方法默认事务超时时间通常只有几分钟(实际值由容器配置决定,如300秒),凌晨批量数据量大,事务超过时限被容器强制回滚。二是连接池竞争:同一时刻其他定时任务和报表查询把连接池占满,EJB拿不到连接,等待超过时间后抛异常。很多人在bean方法里用try-catch把异常吞掉,导致日志里只有一条业务错误,真实原因被藏住。

解决:批量任务改用@TransactionAttribute(REQUIRES_NEW)或者拆分成多条消息分批处理,避免一个超长事务;给不同任务调整@Schedule的执行间隔错峰;检查容器的transaction-timeout配置,按业务量调大至合理值。最关键的一条纪律:EJB方法里不要捕获异常后不处理,容器判定回滚靠的是异常传播到方法边界,你吞掉异常,数据就停留在“看起来成功实际上没提交”的状态。

6. 把入门项目养成一个长期骨架:三个值得从第一天就坚持的EJB习惯

入门项目跑通后,很容易停在“能调通就行”的阶段。但EJB项目越到后面,维护成本越取决于早期的三个习惯,这里讲的就是我现在每个项目起步时必做的事。

第一个习惯:远程接口从第一天就单独抽一个api模块,哪怕现在只有一个客户端用。war包和独立的客户端程序都依赖这个api jar,DTO也放里面。这样做的回报在第一次跨模块调用时就能看到,不会陷进上一章说的ClassCastException泥潭。第二个习惯:业务异常全部用@ApplicationException显式声明回滚语义,不依赖默认规则去猜。

@ApplicationException(rollback = true) public class OrderBusinessException extends RuntimeException { }

这段代码的意思是:任何抛出OrderBusinessException的地方,容器都无条件回滚当前事务。如果忘了写rollback = true,默认Checked异常不会触发回滚,数据会停留在半提交状态。每个项目的异常体系里只要有这一个注解,事务边界就完全可控。第三个习惯:用@Singleton加@Startup做一个轻量自检Bean,在应用启动时检查数据库连通性、远程接口可达性、配置完整性,启动失败就抛异常让部署失败。这样做把配置错误提前到部署期暴露,而不是等到用户访问时才在日志里翻。

这三个习惯花的都是小成本。有一次我在一个老项目里排查了三天的问题,最后定位到就是接口类被copy了两份导致ClassCastException,那是第一次让我下定决心所有EJB远程接口一律抽独立jar包。后面几个项目靠着这些习惯,接口变更从两天缩短到半天,事务问题几乎绝迹。把入门项目的骨架打正,后面长出来的东西才值得维护,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询