使用JSF/Java EE实现数据库的实时更新

30

我有一个应用正在以下环境中运行。

  • GlassFish Server 4.0
  • JSF 2.2.8-02
  • PrimeFaces 5.1 final
  • PrimeFaces Extension 2.1.0
  • OmniFaces 1.8.1
  • EclipseLink 2.5.2,具有JPA 2.1
  • MySQL 5.6.11
  • JDK-7u11

有几个公共页面从数据库中进行了懒加载。一些CSS菜单显示在模板页的标题栏上,例如按类别/子类别显示推荐产品、畅销产品、新品等。

CSS菜单是根据数据库中各种产品类别动态填充的。

这些菜单在每次页面加载时都会填充,这完全是不必要的。其中一些菜单需要复杂/昂贵的JPA条件查询。

目前,填充这些菜单的JSF托管bean是视图范围的。它们都应该是应用程序范围的,只在应用程序启动时加载一次,并且仅在相应的数据库表(类别/子类别/产品等)中更新/更改时进行更新。

我尝试了解WebSokets(以前从未尝试过,对WebSokets完全陌生),例如像这个这个。它们在GlassFish 4.0上运行良好,但它们不涉及数据库。我仍然无法正确理解WebSokets的工作原理,特别是涉及数据库时。

在这种情况下,如何通知相关客户端并使用数据库中最新值更新上述CSS菜单,当相应的数据库表更新/删除/添加时?

一个简单的示例/将会很好。


请明确一下,您已经在JSF端(使用WebSockets)完成了推送部分,并且您只是想知道如何在JPA端触发它以响应实体更改事件?因此,JPA无法控制的数据库外部更改不需要考虑在内? - BalusC
无论如何,如果(相关的)数据库表中有任何更改,都应该反映并通知相关客户端。 (我自己缺乏实际概念 - 如何正确地完成此操作。我以前从未做过这样的事情)。 - Tiny
我阅读了这篇 Oracle文档,包括问题中的两个实践练习,但我仍然不理解它是如何工作的。我在问题中所写的只是我的初步想法,这并不一定会以那种方式发生。 - Tiny
前面评论中的链接已经失效。这个链接是Oracle教程的链接。这个示例是一个很好的开始(确实使用了GlassFish和NetBeans,但不需要特别提到它可以在任何等效环境中验证)。 - Tiny
3个回答

53

前言

在本篇答案中,我将假设以下情况:

  • 您不想使用<p:push>(我会在中间留下确切的原因,但是您至少有兴趣使用新的Java EE 7 / JSR356 WebSocket API)。
  • 您需要一个应用程序范围的推送(即所有用户同时收到相同的推送消息;因此,您对会话或视图范围的推送不感兴趣)。
  • 您希望直接从(MySQL)DB端调用推送(因此,您对使用实体侦听器从JPA端调用推送不感兴趣)。编辑:无论如何,我都会涵盖两个步骤。第3a步描述了DB触发器,第3b步描述了JPA触发器。要么使用它们中的一个,而不是两者!

1. 创建WebSocket端点

首先创建一个@ServerEndpoint类,该类基本上将所有WebSocket会话收集到一个应用程序范围的集合中。请注意,在这个特定的示例中,这可以是static,因为每个WebSocket会话基本上都有自己的@ServerEndpoint实例(它们与servlet不同,因此是无状态的)。

@ServerEndpoint("/push")
public class Push {

    private static final Set<Session> SESSIONS = ConcurrentHashMap.newKeySet();

    @OnOpen
    public void onOpen(Session session) {
        SESSIONS.add(session);
    }

    @OnClose
    public void onClose(Session session) {
        SESSIONS.remove(session);
    }

    public static void sendAll(String text) {
        synchronized (SESSIONS) {
            for (Session session : SESSIONS) {
                if (session.isOpen()) {
                    session.getAsyncRemote().sendText(text);
                }
            }
        }
    }

}

上面的示例有一个额外的方法sendAll(),它将给定的消息发送到所有打开的websocket会话(即应用程序范围的推送)。请注意,此消息也可以是JSON字符串。
如果您打算明确将它们存储在应用程序范围(或(HTTP)会话范围)中,则可以使用this answer中的ServletAwareConfig示例。您知道,在JSF中,ServletContext属性映射到ExternalContext#getApplicationMap()(而HttpSession属性映射到ExternalContext#getSessionMap())。
2. 在客户端打开WebSocket并侦听它
使用以下JavaScript代码片段打开WebSocket并侦听它:
if (window.WebSocket) {
    var ws = new WebSocket("ws://example.com/contextname/push");
    ws.onmessage = function(event) {
        var text = event.data;
        console.log(text);
    };
}
else {
    // Bad luck. Browser doesn't support it. Consider falling back to long polling.
    // See http://caniuse.com/websockets for an overview of supported browsers.
    // There exist jQuery WebSocket plugins with transparent fallback.
}

目前它仅记录推送的文本。我们希望将此文本用作更新菜单组件的指令。为此,我们需要另一个<p:remoteCommand>
<h:form>
    <p:remoteCommand name="updateMenu" update=":menu" />
</h:form>

假设您正在通过Push.sendAll("updateMenu")将JS函数名称作为文本发送,那么您可以按照以下方式解释和触发它:

    ws.onmessage = function(event) {
        var functionName = event.data;
        if (window[functionName]) {
            window[functionName]();
        }
    };

如果使用JSON字符串作为消息(可以通过$.parseJSON(event.data)解析),则可以实现更多的动态效果。

3a. 或者从数据库端触发WebSocket推送

现在我们需要从数据库端触发命令Push.sendAll("updateMenu")。其中最简单的一种方式是让数据库向Web服务发送HTTP请求。一个普通的Vanilla Servlet就足以充当Web服务:

@WebServlet("/push-update-menu")
public class PushUpdateMenu extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        Push.sendAll("updateMenu");
    }

}

如果需要,您当然可以根据请求参数或路径信息对推送消息进行参数化。不要忘记执行安全检查,以确定调用者是否被允许调用此servlet,否则世界上除了DB本身之外的任何人都能够调用它。例如,您可以检查调用者的IP地址,这在DB服务器和Web服务器运行在同一台机器上时非常方便。

为了让DB在该servlet上发出HTTP请求,您需要创建一个可重复使用的存储过程,该存储过程基本上调用操作系统特定的命令来执行HTTP GET请求,例如curl。MySQL不支持原生地执行特定于操作系统的命令,因此您需要先安装用户定义函数(UDF)。在mysqludf.org上,您可以找到一堆我们感兴趣的SYS。它包含我们需要的sys_exec()函数。安装后,在MySQL中创建以下存储过程:

DELIMITER //
CREATE PROCEDURE menu_push()
BEGIN 
SET @result = sys_exec('curl http://example.com/contextname/push-update-menu'); 
END //
DELIMITER ;

现在您可以创建插入/更新/删除触发器,以调用它(假设表名为menu):
CREATE TRIGGER after_menu_insert
AFTER INSERT ON menu
FOR EACH ROW CALL menu_push();

CREATE TRIGGER after_menu_update
AFTER UPDATE ON menu
FOR EACH ROW CALL menu_push();

CREATE TRIGGER after_menu_delete
AFTER DELETE ON menu
FOR EACH ROW CALL menu_push();

3b. 或者从JPA侧触发WebSocket推送

如果您的需求/情况只允许监听JPA实体更改事件,并且因此不需要覆盖对DB的外部更改,则可以替代步骤3a中描述的DB触发器,只需使用JPA实体更改侦听器。 您可以通过在@Entity类上使用@EntityListeners注释进行注册:

@Entity
@EntityListeners(MenuChangeListener.class)
public class Menu {
    // ...
}

如果您使用单个Web配置文件项目,其中所有内容(EJB/JPA/JSF)都在同一个项目中混合在一起,则可以直接在其中调用Push.sendAll("updateMenu")
public class MenuChangeListener {

    @PostPersist
    @PostUpdate
    @PostRemove
    public void onChange(Menu menu) {
        Push.sendAll("updateMenu");
    }

}

然而,在“企业”项目中,服务层代码(EJB/JPA等)通常分离在EJB项目中,而Web层代码(JSF/Servlets/WebSocket等)则保留在Web项目中。EJB项目不应该对Web项目产生无单一依赖。在这种情况下,最好触发CDI Event,Web项目可以@Observes
public class MenuChangeListener {

    // Outcommented because it's broken in current GF/WF versions.
    // @Inject
    // private Event<MenuChangeEvent> event;

    @Inject
    private BeanManager beanManager;

    @PostPersist
    @PostUpdate
    @PostRemove
    public void onChange(Menu menu) {
        // Outcommented because it's broken in current GF/WF versions.
        // event.fire(new MenuChangeEvent(menu));

        beanManager.fireEvent(new MenuChangeEvent(menu));
    }

}

请注意注释;在当前版本的GlassFish和WildFly(4.1 / 8.2)中,注入CDI Event存在问题;解决方法是通过BeanManager触发事件;如果仍然无法解决问题,则CDI 1.1的替代方法是CDI.current().getBeanManager().fireEvent(new MenuChangeEvent(menu))

public class MenuChangeEvent {

    private Menu menu;

    public MenuChangeEvent(Menu menu) {
        this.menu = menu;
    }

    public Menu getMenu() {
        return menu;
    }

}

然后在网络项目中:

@ApplicationScoped
public class Application {

    public void onMenuChange(@Observes MenuChangeEvent event) {
        Push.sendAll("updateMenu");
    }

}

更新:在上述答案半年后的2016年4月1日,OmniFaces推出了2.3版本,引入了<o:socket>,应该能使这一切更加简单。即将发布的JSF 2.3 <f:websocket>很大程度上基于<o:socket>。另请参见如何将异步变化推送到JSF创建的HTML页面中的服务器?


3
如果用户只对JPA实体监听器感兴趣,我在第三步中增加了3b。 - BalusC
javax.enterprise.event.Event<T extends Object> 是否必须像应用程序作用域 bean 一样注入到 CDI 管理的 bean 中(根据之前的一些评论,这种方式会失败),还是可以注入到普通的 Java 类中(换句话说,根据文档,这种注入是否必须发生在 CDI 管理的 bean 和普通的 Java 类中)?这是因为它已成功注入到 EJB 中。我刚试图将其注入到 GlassFish 4.1 中的单例 EJB 中,结果成功了。 - Tiny
没关系。从一开始就出现了库导入错误。我试图在三个不同的地方导入Java EE 7 API Library(包含CDI API),即Web模块、EJB模块和类库,但我错误地在EJB模块中导入了Java EE Web 7 API Library。在我用Java EE 7 API Library替换了那个库之后,CDI事件成功注入到GlassFish 4.1中。(在这里之后,我甚至用一个单独的JAR文件cdi-api.jar替换了Java EE 7 API Library)。 - Tiny
有关于 “view scoped push” 的特殊性吗?也就是说,是否有任何特殊的内置方式可以实现“view scoped push”? 通常情况下,我会通过查询字符串或 @PathParam 将一个随机的基于视图的令牌传递给 @ServerEndpoint("EndpointURI") 来完成它。 - Tiny
将特定于视图的参数(例如视图ID)作为请求(路径|查询)参数传递,并在映射中与会话相关联。在PrimeFaces中它被称为“通道”。 - BalusC
显示剩余13条评论

6
由于您正在使用Primefaces和Java EE 7,因此应该很容易实现以下操作:
使用Primefaces Push(示例在此处:http://www.primefaces.org/showcase/push/notify.xhtml
创建一个视图以侦听Websocket端点
创建一个数据库侦听器,在数据库更改时产生CDI事件
事件的有效负载可以是最新数据的增量或仅为更新信息
通过Websocket将CDI事件传播到所有客户端
客户端更新数据
希望这有所帮助。如果需要更多细节,请随时提问。
谢谢!

1
PrimeFaces有轮询功能,可以自动更新组件。在下面的例子中,<h:outputText>将会被<p:poll>每3秒自动更新一次。
如何通知相关的客户端,并使用数据库中的最新值更新上述CSS菜单?
创建一个名为process()的监听方法来选择你的菜单数据。<p:poll>将自动更新你的菜单组件。
<h:form>
    <h:outputText id="count"
                  value="#{AutoCountBean.count}"/> <!-- Replace your menu component-->

    <p:poll interval="3" listener="#{AutoCountBean.process}" update="count" />
</h:form>

@ManagedBean
@ViewScoped
public class AutoCountBean implements Serializable {

    private int count;

    public int getCount() {
        return count;
    }

    public void process() {
        number++; //Replace your select data from db.
    }
}   

<p:poll> 对于在指定时间点触发周期性操作非常有用。在这种情况下,它不像长轮询那样以固定间隔发生。这里的操作不依赖于计时器。它应该只在其他事件发生或从未发生时才会发生。 - Tiny

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接