PAYATHON 2026

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 事务。

例如,下面的执行顺序会造成状态不一致:

  1. 数据库更新成功;
  2. 邮件已经发送;
  3. 后续数据库操作抛出异常;
  4. 数据库事务回滚;
  5. 收件人仍然收到了“支付成功”邮件。

反过来,如果数据库提交后直接发送邮件,而邮件服务器暂时不可用,也会出现支付成功但通知失败的情况。

如果系统对通知可靠性的要求不高,可以直接调用 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 连接起来。

备注:内容仅供参考。