Java EE 中如何让一个 Service 调用另一个 Service
结论
不要让 ExecutePaymentService 继承 EmailService。两者并不是父子关系,而是一个业务流程需要调用另一项业务能力,因此应该使用组合和依赖注入:
@EJB
private EmailService emailService;
如果“支付成功后发送邮件,失败后执行补偿操作”构成一个完整的业务流程,可以再增加一个 EJB 专门负责编排。JSF Managed Bean 只调用这个统一入口,不处理业务判断。
最直接的实现
一个 EJB 可以注入并调用另一个 EJB。EmailService 已使用 @Stateless 和 @LocalBean,所以在同一应用内可以通过无接口视图直接注入:
@Stateless
@LocalBean
public class ExecutePaymentService {
@EJB
private UserBean userBean;
@EJB
private EmailService emailService;
public PaymentResult executePayment(int amount, int userId) {
try {
// 执行支付逻辑
boolean successful = processPayment(amount, userId);
if (successful) {
String recipient = userBean.findEmailByUserId(userId);
emailService.sendEmail(recipient);
return PaymentResult.success();
}
handlePaymentFailure(userId);
return PaymentResult.failure("Payment rejected");
} catch (RuntimeException e) {
handleUnexpectedFailure(userId, e);
throw e;
}
}
private boolean processPayment(int amount, int userId) {
// 支付逻辑
return true;
}
private void handlePaymentFailure(int userId) {
// 处理可预期的支付失败
}
private void handleUnexpectedFailure(int userId, RuntimeException cause) {
// 记录或处理异常
}
}
EmailService 仍然是独立的服务:
@Stateless
@LocalBean
public class EmailService {
@EJB
private SomeBean someBean;
public void sendEmail(String recipient) {
// 邮件发送逻辑
}
}
这就是组合:ExecutePaymentService 持有一个 EmailService 依赖,但不继承它。实例创建、依赖注入、事务管理和 EJB 拦截器仍由容器负责。
大型应用可以增加流程编排服务
如果 ExecutePaymentService 只应负责支付,把邮件发送和失败处理也放在其中,会让它承担越来越多的职责。此时可以增加一个面向用例的 EJB,例如 PaymentWorkflowService:
@Stateless
public class PaymentWorkflowService {
@EJB
private ExecutePaymentService paymentService;
@EJB
private EmailService emailService;
@EJB
private PaymentFailureService failureService;
public PaymentResult pay(int amount, int userId) {
PaymentResult result = paymentService.executePayment(amount, userId);
if (result.isSuccessful()) {
emailService.sendEmail(result.getRecipient());
} else {
failureService.handleFailure(userId, result.getFailureReason());
}
return result;
}
}
支付服务只处理支付:
@Stateless
public class ExecutePaymentService {
@EJB
private UserBean userBean;
public PaymentResult executePayment(int amount, int userId) {
User user = userBean.find(userId);
boolean successful = processPayment(amount, user);
if (successful) {
return PaymentResult.success(user.getEmail());
}
return PaymentResult.failure("Payment rejected");
}
private boolean processPayment(int amount, User user) {
// 支付逻辑
return true;
}
}
JSF Managed Bean 只接收输入并展示结果:
@Named
@RequestScoped
public class PaymentBean {
@EJB
private PaymentWorkflowService paymentWorkflowService;
private int amount;
private int userId;
public String submit() {
PaymentResult result =
paymentWorkflowService.pay(amount, userId);
return result.isSuccessful()
? "payment-success"
: "payment-failure";
}
// getters and setters
}
各部分的职责如下:
ExecutePaymentService处理支付;EmailService负责发送邮件;PaymentWorkflowService编排整个业务用例;- JSF Managed Bean 只处理 Web 层输入和页面导航。
建议使用本地业务接口
在同一应用中直接调用时,@LocalBean 已经够用。但如果应用包含多个模块,或者需要替换实现、编写测试替身,显式接口通常更容易理解:
@Local
public interface EmailServiceLocal {
void sendEmail(String recipient);
}
@Stateless
public class EmailService implements EmailServiceLocal {
@Override
public void sendEmail(String recipient) {
// 邮件发送逻辑
}
}
调用方依赖接口:
@EJB
private EmailServiceLocal emailService;
这样,调用方依赖的是业务契约,不是具体实现类。如果应用规模较小,而且 EJB 只在同一模块中使用,继续采用 @LocalBean 也没有问题。
事务边界需要单独考虑
默认情况下,@Stateless EJB 的业务方法使用容器管理事务,其事务属性相当于:
@TransactionAttribute(TransactionAttributeType.REQUIRED)
因此,一个 EJB 调用另一个 EJB 时,被调用的方法通常会加入当前事务。这适合数据库操作,但发送邮件时要特别注意,因为 SMTP 邮件发送一般不参与 JTA 事务。
例如,下面的执行顺序会造成状态不一致:
- 数据库更新成功;
- 邮件已经发送;
- 后续数据库操作抛出异常;
- 数据库事务回滚;
- 收件人仍然收到了“支付成功”邮件。
反过来,如果数据库提交后直接发送邮件,而邮件服务器暂时不可用,也会出现支付成功但通知失败的情况。
如果系统对通知可靠性的要求不高,可以直接调用 EmailService,同时捕获并记录邮件异常,防止邮件故障导致支付回滚:
try {
emailService.sendEmail(recipient);
} catch (EmailDeliveryException e) {
// 记录失败,交给后续重试机制处理
}
是否忽略异常要由业务规则决定。如果邮件发送成功是整个操作成功的必要条件,就不能简单吞掉异常。不过,即使抛出异常,也无法撤回已经发出的邮件。
可靠发送邮件时使用异步消息或 Outbox
如果需要保证支付提交成功后,系统最终一定会尝试发送通知,可以采用以下流程:
- 在支付事务中写入一条待发送事件;
- 事务成功提交后,由独立消费者发送邮件;
- 发送失败时进行重试;
- 使用唯一业务标识防止重复消费时发送多封相同邮件。
在完整的 Java EE 环境中,可以使用 JMS。示意代码如下:
@Stateless
public class PaymentWorkflowService {
@EJB
private ExecutePaymentService paymentService;
@JMSContext
private JMSContext jmsContext;
@Resource(lookup = "java:/jms/queue/PaymentEmailQueue")
private Queue paymentEmailQueue;
public PaymentResult pay(int amount, int userId) {
PaymentResult result = paymentService.executePayment(amount, userId);
if (result.isSuccessful()) {
jmsContext.createProducer().send(
paymentEmailQueue,
new PaymentSucceededMessage(
result.getPaymentId(),
result.getRecipient()
)
);
}
return result;
}
}
消息驱动 Bean 负责消费事件:
@MessageDriven(
activationConfig = {
@ActivationConfigProperty(
propertyName = "destinationLookup",
propertyValue = "java:/jms/queue/PaymentEmailQueue"
),
@ActivationConfigProperty(
propertyName = "destinationType",
propertyValue = "jakarta.jms.Queue"
)
}
)
public class PaymentEmailListener implements MessageListener {
@EJB
private EmailService emailService;
@Override
public void onMessage(Message message) {
// 读取消息并调用 emailService.sendEmail(...)
}
}
具体使用哪一套注解包名,取决于应用采用 Java EE 的 javax.* API,还是 Jakarta EE 的 jakarta.* API。JMS 资源的 JNDI 名称和配置方式也由应用服务器决定。
如果支付数据和消息代理不能可靠地加入同一个事务,可以使用 Transactional Outbox。先在支付事务中把事件写入数据库,再由后台任务投递消息,从而避免支付已经提交,但通知事件未能成功产生的情况。
不要通过继承复用 Service
下面这种写法并不合适:
public class ExecutePaymentService extends EmailService {
}
继承表示 ExecutePaymentService 是一种 EmailService,这显然不符合业务含义。这样写还会引出一些问题:
- 支付服务会暴露原本不属于它的公共方法;
- 服务之间会形成脆弱的继承层级;
- Java 只支持单继承,无法自然组合多个服务能力;
- 调用可能绕过 EJB 容器代理,使事务、权限、异步和拦截器行为难以理解;
- 父类中的 EJB 配置和生命周期语义会增加维护成本。
服务之间需要协作时,应通过注入完成。多个服务共同组成一个业务用例时,则应交给专门的编排服务。
还要避免自调用和循环依赖
EJB 的容器能力通常通过代理调用生效。一个 Bean 直接调用自身的另一个方法时,某些拦截器、事务属性或异步行为可能不会像跨 EJB 调用那样生效。例如:
public void firstMethod() {
secondMethod(); // 普通的 Java 自调用
}
如果 secondMethod() 需要独立事务等容器语义,应把它拆到另一个 EJB 中,再通过注入调用。
还应尽量避免以下循环依赖:
PaymentService -> EmailService -> PaymentService
循环依赖通常意味着职责边界不够清楚。可以把共享逻辑提取到第三个服务中,也可以由上层的 Workflow 或 Facade EJB 统一编排。
推荐的结构是通过 @EJB 注入组合服务,并使用 Workflow 或 Facade EJB 承载跨服务的业务流程。如果邮件通知需要可靠地与支付结果保持一致,再引入 JMS、重试和 Outbox,不要通过继承把多个 Service 连接起来。
备注:内容仅供参考。
