使用应用程序事件解耦业务逻辑

在 CUBA 7 中,事件是响应应用程序的各种更改的主要机制。 同时,事件也是应用程序中用于将业务逻辑之间相互进行解耦的一种常见模式。 本指南概述了如何在CUBA应用程序中使用事件以及它们带来的益处。

将要构建的内容

本指南对 CUBA 宠物医院示例进行了改进,以演示应用程序事件的各种用例。特别是我们会涉及到以下用例:

  • 在创建一个新的就诊记录(Visit)时生成一个房间码(a room keycode )

  • 为预订的就诊发送房间码(room keycode)

  • 一旦一次诊疗活动被标记为完成就开始支付流程

  • 诊疗活动完成后刷新就诊记录列表

开发环境要求

您的开发环境需要满足以下条件:

下载 并解压本指南的源码,或者使用 git 克隆下来:

示例: CUBA 宠物诊所

这个示例是以 CUBA 宠物诊所项目为基础,而这个项目的基础是众所周知的 Spring 宠物诊所项目。CUBA 宠物诊所应用程序涉及到了宠物诊所的领域模型及与管理一家宠物诊所相关的业务流程。

这个应用程序的领域模型如下:

领域模型

主要的实体是 PetVisit。 Pet 到诊所就诊,就诊时(Vist) 会有一名兽医(Vet)负责照顾它。每个宠物都有主人,一个主人可以有多个宠物。一次就诊(Vist)即是一个宠物在主人的帮助下到诊所诊治的活动。

基于事件的业务逻辑带来的益处

跨应用程序逻辑进行通信的最常见方式是方法调用。 在 CUBA 和 Spring中,这是通过 Java 对象、Spring组件和 CUBA 服务相互交互完成的。这种方式是直接、简单的一种通信模式,但它不是唯一的。方法调用经常会被过度使用,这会造成应用程序内部高度耦合,进而导致整个系统的可维护性差。

基于事件的业务逻辑是应用程序逻辑的可选通信模式,其在降低通信参与者的耦合度方面具有很大的优势。它并不能适应所有通信方式,但对于大多数情况,它是一种可行的替代方案。 通常,基于事件的通信可用于以下场景:

  • 通知风格的通讯

  • 不需要双向交互的通讯

  • 技术上相互独立的应用程序逻辑之间的通讯

  • 用户不需要获得实时反馈

使用了基于事件通讯的的应用程序以将具备以下特性:

  • 事件的发送都和接收者之间低耦合

  • 发送者和接收接收者都易于独立测试

  • 为发送者提供了更高的吞吐量弹性

在 CUBA 应用程序中可用的事件类型

在 CUBA 应用程序中有多种应用程序事件,主要分类包括:

  • CUBA 实体生命周期事件

  • CUBA 应用程序生命周期事件

  • CUBA UI 事件

  • 自定义应用程序事件

通过 EntityChangedEvent 发布的实体变更事件

在宠物诊所示例中,将实现以下逻辑: 宠物诊所在就诊期间为宠物提供了房间,这种房间没有传统的钥匙或钥匙卡,而是在进入房间时需要提供 6 位数的钥匙码。一旦登记了新的就诊,系统会将该钥匙码提供给宠物的主人。钥匙码的的传递通过应用程序的SMS 通知进行。

这个示例属于 实体生命周期 分类,因为我们需要在就诊预订后执行业务逻辑。CUBA 的存储机制在一个实体被修改后会发送`EntityChangedEvent` 事件。

CUBA 会对 @PublishEntityChangedEvents 注解的实体触发这个事件。EntityChangedEvent 事件对象包含的信息有变更类型(create、 update 或 delete) 及变更的属性。

要为此事件注册事件监听器,需要在应用程序的 core 模块定义一个 Spring bean。在 EntityChangedEvent 件触发后要执行的方法需要使用 @TransactionalEventListener 进行注解。

有两种不同类型的注解可用于注册一个事件监听器: @TransactionalEventListener@EventListener 。它们在事务行为方面有所不同。在本指南中使用的是 @TransactionalEventListener 注解。可以在这里找到更多细节: CUBA 文档
RoomKeycodeToOwnerSender.java
@Component("petclinic_roomKeycodeToOwnerSender")
public class RoomKeycodeToOwnerSender {

    @Inject
    private DataManager dataManager;

    @Inject
    private MobilePhoneNotificationGateway mobilePhoneNotificationGateway;

    @TransactionalEventListener (1)
    public void sendRoomKeycode(EntityChangedEvent<Visit, UUID> event) { (2)

        if (event.getType().equals(EntityChangedEvent.Type.CREATED)) {
            Visit visit = loadVisit(event.getEntityId()); (3)
            tryToSendRoomKeycodeToPetsOwner(visit);
        }
    }

    private void tryToSendRoomKeycodeToPetsOwner(Visit visit) {

        if (visit.getPet().getOwner() != null) {

            String phoneNumber = visit.getPet().getOwner().getTelephone();

            if (phoneNumber != null) {
                String notificationText = createNotificationText(visit);

                mobilePhoneNotificationGateway.sendNotification(phoneNumber, notificationText);
            }
        }
    }

    // ...

}
1 注册 sendRoomKeycode bean 为事件监听器
2 定义 EntityChangedEvent 事件的作用范围为 Visit 实体
3 访问新建的 Visit 实体的 ID

有了这个事件监听器,应用程序将发送一个房间钥匙码给宠物主人,主人应该事先在宠物诊所进行了注册。

对于一个指定的事件,可以有多个事件监听器。在这个示例中,不仅需要给主人发送钥匙码,同时需要系统负责控制硬件,即房间门。我们还需要额外的与访宠物相关的就诊信息,以便自动调整床的高度、在房间的 TV上显示欢迎信息等。

下列事件监听器将负责通知房间系统:

RoomSystemNotifier.java
@Component("petclinic_roomSystemNotifier")
public class RoomSystemNotifier {

    @Inject
    private DataManager dataManager;

    @Inject
    private RoomSystemGateway roomSystemGateway;

    @TransactionalEventListener
    public void notifyRoomSystem(EntityChangedEvent<Visit, UUID> event) {

        if (event.getType().equals(EntityChangedEvent.Type.CREATED)) {
            Visit visit = loadVisit(event.getEntityId());
            tryToNotifyRoomSystemAboutVisit(visit);
        }
    }

    private void tryToNotifyRoomSystemAboutVisit(Visit visit) {
        roomSystemGateway.informAboutVisit(visit);
    }

    // ...
}
当有多个事件监听器时,对事件监听器的调用顺序常常不是很重要。但是有时候也需要一个事件监听器必须在另一个事件监听器之前被调用,Spring 对此提供了 @Order 用于定义监听器的执行顺序。参阅: CUBA 文档 来了解关于这个主题的详细资料。

事件的命名约定

事件一般以简单的过去式命名:"Entity Changed Event"。 这是一种常见模式,它强调了一个事件已经发生且不可改变的事实。另一方面,事件监听器应该以 “一般现在时”命名。

此外,事件监听器一般以其执行的特定动作以命名,避免使用一个含义宽泛的名称。比如 PetCreatedListener ,这个命名表示这个监听器可以处理所有宠物创建后需要做的事情,这显然是不合适的。事件监听器应该以相关的具体业务处理进行命名: RoomKeycodeToOwnerSender → 发送房间钥匙码给主人。

这是一个遵循 开闭原则的应用程序示例,遵循开闭原则通常会降低应用程序各部分之间的耦合,并且减少软件的维护量。

自定义应用程序逻辑事件

下面的示例是使用自定义应用程序事件以松散耦合的方式在应用程序的不同部分之间发送消息。在宠物康复后,就诊记录被检出并标记为完成。当此事件发生时,一些后续流程就可以启动。在这里我们以其中的开票流程为例。

使用自定义应用程序事件的第一步是在 core 模块中定义事件类: VisitCompletedEvent

VisitCompletedEvent.java
package com.haulmont.sample.petclinic.core.visit;

public class VisitCompletedEvent extends ApplicationEvent { (1)

    private final Visit visit;

    public VisitCompletedEvent(Object source, Visit visit) {

        super(source);
        this.visit = visit;
    }

    public Visit getVisit() {
        return visit;
    }
}
1 自定义事件必须扩展自 ApplicationEvent ,这样事件就可以通过 Spring 的事件系统传递。

下一步是使用 CUBA 的 Events 基础设施发送事件。

VisitStatusServiceBean.java
package com.haulmont.sample.petclinic.service;

import com.haulmont.cuba.core.global.Events;

// ...

@Service(VisitStatusService.NAME)
public class VisitStatusServiceBean implements VisitStatusService {

    @Inject
    private Events events; (1)

    // ...

    @Override
    public boolean completeVisit(Visit visit) {

        if (visit.getStatus().equals(VisitStatus.ACTIVE)) {
            markVisitAsComplete(visit);

            notifyAboutVisitCompletion(visit);

            return true;
        }
        // ...
    }

    private void notifyAboutVisitCompletion(Visit visit) {
        events.publish(new VisitCompletedEvent(this, visit)); (2)
    }

    private void markVisitAsComplete(Visit visit) {
        visit.setStatus(VisitStatus.COMPLETED);
        dataManager.commit(visit);
        log.info("Visit {} marked as complete", visit);
    }
}
1 注入 CUBA Events Spring bean
2 一旦就诊记录的状态发生变化,就发布新的 VisitCompletedEvent 事件

由于技术机制完全相同,最后剩下的部分与 CUBA 自己发布的其他事件相比没有什么不同。

事件监听器 InvoicingProcessInitializer 接收类型为 VisitCompletedEvent 的事件,并为接收到的就诊记录创建发票。

有两种方式可以监听事件。 上面使用了 @TransactionalEventListener 注解。 还可以实现 ApplicationListener<T> 接口,这种方式在事件监听器 InvoicingProcessInitializer 中用到了。
InvoicingProcessInitializer.java
package com.haulmont.sample.petclinic.core.payment;

@Component("petclinic_invoicingProcessInitializer")
public class InvoicingProcessInitializer implements ApplicationListener<VisitCompletedEvent> {

    // ...

    @Override
    public void onApplicationEvent(VisitCompletedEvent event) {
        log.info("Payment process initialized: {}", event.getVisit());

        CommitContext commitContext = new CommitContext();
        createInvoiceFor(event.getVisit(), commitContext);

        dataManager.commit(commitContext);
    }

    private void createInvoiceFor(Visit visit, CommitContext commitContext) {
        Invoice invoice = dataManager.create(Invoice.class);

        invoice.setVisit(visit);
        invoice.setInvoiceDate(visit.getVisitDate());
        invoice.setInvoiceNumber(createInvoiceNumber());

        List<InvoiceItem> invoiceItems = createInvoiceItemsFor(invoice);
        invoice.setItems(invoiceItems);

        invoiceItems.forEach(commitContext::addInstanceToCommit);
        commitContext.addInstanceToCommit(invoice);

    }

    //...
}

CUBA UI 事件

在 UI 层,事件有两个主要的使用场景。 首先,框架本身为在应用程序的用户界面内发生的某些交互发送事件。 此外,它也可以像在中间件中一样发送自定义的应用程序 UI 事件。 两种事件有共同之处,不同的是 UI 事件总是作用于 UI 的一个实例。也就是说,UI 事件的作用目标是单个浏览器标签页,如果一个应用程序在浏览器的两个标签页同时打开,则 UI 事件对每个标签页都触发一次。

框架 UI 事件

在 CUBA 7 中,UI 组件的事件监听从使用 API 和模板方法的编程式定义转变为使用注解的声明式订阅。可以使用 @Subscribe 注解在控制器中注册事件监听器。

可以监听以下事件来处理界面控制器的生命周期,如 InitEventBeforeCloseEventPreCommitEvent 等。 控制器中与数据相关的部分还提供了诸如 ItemChangeEventCollectionChangeEvent 之类的事件。 此外,UI组件本身会发送其状态更改的事件,如 EnterPressEventTextChangeEvent 等。

CUBA Studio 能够查看控制器的可订阅事件,如下图:

对于宠物诊所,VisitEdit 控制器利用 InitEntityEvent 事件在创建新实体时生成房间钥匙码:

VisitEdit.java
@UiController("petclinic_Visit.edit")
@UiDescriptor("visit-edit.xml")
@EditedEntityContainer("visitCt")
public class VisitEdit extends StandardEditor<Visit> {

    @Subscribe (1)
    protected void onInitEntity(InitEntityEvent<Visit> event) {
        event.getEntity().setRoomKeycode(generateRoomKeycode()); (2)
    }

    private String generateRoomKeycode() {
        int rookKeycode = new Random().nextInt(999999);
        return String.format("%04d", rookKeycode);
    }
}
1 onInitEntity 注册为 InitEntityEvent<Visit> 的事件监听器
2 设置新创建的Visit实体的房间钥匙码

自定义 UI 事件

与自定义应用程序事件一样,自定义 UI 事件允许将与 UI 相关的状态作为对象以通知的方式发送。 同样,UI 实例可注册并响应事件,以做到逻辑的解耦。在宠物诊所示例中,下面的用例使用了自定义 UI 事件。一旦就诊通过 UI 按钮被标记为完成,界面列表就会自动更新。

第一步也是需要定义一个事件类:

VisitCompletedUiEvent.java
public class VisitCompletedClickedEvent extends ApplicationEvent implements UiEvent { (1)

    public VisitCompletedClickedEvent(Object source) {
        super(source);
    }
}
1 以实现 UiEvent 接口的方式标记事件为 UI 事件
VisitEdit.java
public class VisitBrowse extends StandardLookup<Visit> {

    // ...

    @Inject
    private Events events; (1)

    @Subscribe("visitsTable.completeVisit")
    protected void completeVisit(Action.ActionPerformedEvent event) {
        Visit visit = visitsTable.getSingleSelected();
        boolean visitWasCompleted = visitStatusService.completeVisit(visit);

        if (visitWasCompleted) {
            events.publish(new VisitCompletedClickedEvent(visit)); (2)
        }
        // ...
    }

    @EventListener (3)
    protected void updateDataOnVisitCompleted(VisitCompletedClickedEvent event) {
        loadData(); (4)

        notifications.create()
                .setCaption(messages.formatMessage(this.getClass(), "visitCompleteSuccessful"))
                .setType(Notifications.NotificationType.TRAY)
                .show();
    }
}
1 使用 CUBA 的 Events 机制
2 发送 VisitCompletedUiEvent 事件
3 注册 updateDataOnVisitCompleted 方法为 VisitCompletedUiEvent 事件的监听器
4 重新加载数据

在这个例子中,发送者和接收者在同一个类,但这不是必须的。也可以在一个控制器中发送UI事件,在另一个控制器中接收事件。

在各层之间发送事件

从技术上讲,在与 CUBA 应用程序中使用事件交互时需要考虑一个重要的限制因素。由于 CUBA 是一个多模块应用程序,可以部署为两个主要部分:前端(Web模块)和中间件(Core模块),在两个层之间直接发送事件是不可能的。

事件机制的底层框架是 Spring,它具有在同一个应用程序中发送应用程序事件的功能,准确地说是在同一个 JVM 进程中。 由于前端和中间件可以是不同服务器上的两个不同的 JVM 进程,默认情况下 Spring 不具备跨 JVM 进程进行交互的能力。

有两种方法可以解决这个问题。第一个是利用像 RabbitMQ 这样的外部消息代理在应用程序(或本例中的应用程序层)之间进行交互。 内部应用程序事件可以在内部发送。然后,事件监听器(例如 RabbitMqForwarder ) 接收此消息并将其转发到外部消息代理。在接收端,外部 RabbitMQ 消息的另一个事件监听器将消息转换成内部 CUBA/Spring 消息。这样,应用程序就可以在 JVM 进程之间透明地进行通信。

另一种方法是使用名为 global-events(全局事件) 应用程序组件。 此组件由 Haulmont 开发,它专门解决 CUBA 中间件和前端之间的通信问题。

总结

CUBA 中的应用程序事件允许在应用程序中定义松散耦合的业务逻辑。应用程序内功能独立部分也可以实现为独立的组件。这种低耦合具有某些优点,例如更容易测试并且使应用程序各部件之间更具弹性。

但这种方法也有缺点。基于事件进行通讯时给开发人员带来新的挑战。使用方法调用时,可以及时得到被调用方法的反馈信息,但使用事件通常不容易做到这点。同时,有时候业务间的边界并不那么清晰,识别可独立执行的部分也是一种挑战。

有人可能会对上面的示例提出异议:只有房间系统知道了房间预订信息并一切准备就绪,才能发送房间钥匙码到宠物主人。 这种本能的一致性愿望通常很难在分布式系统(如宠物诊所管理系统和房间系统)中实现。消息机制在这方面提升了整个系统的弹性,同时也增加了维持系统一致性方面的成本。

正确地建模和使用应用程序事件会防止应该程序陷入系统内各种逻辑间及系统间错综复杂的调用泥潭。 在应用程序内部各部分之间使用消息机制是一种更易于维护的应用程序逻辑组织方式,并且这种方式通常可以更准确表示出问题域工作流程,因为现实世界即是事件驱动的方式工作。