领先一步
VMware 提供培训和认证,助你加速进步。
了解更多要查看此代码的更新,请访问我们的React.js 和 Spring Data REST 教程。 |
在上一篇教程中,你介绍了条件更新以避免在编辑同一数据时与其他用户发生冲突。你还学习了如何在后端使用乐观锁对数据进行版本控制。如果有人编辑了同一记录,你会收到提示,以便刷新页面并获取更新。
这很好。但你知道有什么更好的吗?让 UI 在其他人更新资源时动态响应。
在本篇教程中,你将学习如何使用 Spring Data REST 内置的事件系统来检测后端变化,并通过 Spring 的 WebSocket 支持向所有用户发布更新。然后,你将能够在数据更新时动态调整客户端。
请随意获取代码从这个仓库,并跟着操作。本篇教程基于上一篇教程的应用,并添加了一些额外功能。
开始之前,你需要向项目的 pom.xml 文件添加一个依赖项
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
这将引入 Spring Boot 的 WebSocket starter。
Spring 提供了强大的 WebSocket 支持。需要认识到的是,WebSocket 是一种非常低级的协议。它提供的功能仅限于在客户端和服务器之间传输数据。建议使用子协议(本篇教程中使用 STOMP)来实际编码数据和路由。
以下代码用于在服务器端配置 WebSocket 支持
@Component @EnableWebSocketMessageBroker public class WebSocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer {
static final String MESSAGE_PREFIX = "/topic"; @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/payroll").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker(MESSAGE_PREFIX); registry.setApplicationDestinationPrefixes("/app"); }
}
@EnableWebSocketMessageBroker
开启 WebSocket 支持。AbstractWebSocketMessageBrokerConfigurer
提供了一个方便的基类来配置基本功能。registerStompEndpoints()
用于配置后端供客户端和服务器连接的端点(/payroll
)。configureMessageBroker()
用于配置用于在服务器和客户端之间中继消息的 broker。通过此配置,现在可以利用 Spring Data REST 事件并通过 WebSocket 发布它们。
Spring Data REST 基于对 repository 执行的操作生成了一些应用事件。以下代码展示了如何订阅其中一些事件
@Component @RepositoryEventHandler(Employee.class) public class EventHandler {
private final SimpMessagingTemplate websocket; private final EntityLinks entityLinks; @Autowired public EventHandler(SimpMessagingTemplate websocket, EntityLinks entityLinks) { this.websocket = websocket; this.entityLinks = entityLinks; } @HandleAfterCreate public void newEmployee(Employee employee) { this.websocket.convertAndSend( MESSAGE_PREFIX + "/newEmployee", getPath(employee)); } @HandleAfterDelete public void deleteEmployee(Employee employee) { this.websocket.convertAndSend( MESSAGE_PREFIX + "/deleteEmployee", getPath(employee)); } @HandleAfterSave public void updateEmployee(Employee employee) { this.websocket.convertAndSend( MESSAGE_PREFIX + "/updateEmployee", getPath(employee)); } /** * Take an {@link Employee} and get the URI using * Spring Data REST's {@link EntityLinks}. * * @param employee */ private String getPath(Employee employee) { return this.entityLinks.linkForSingleResource(employee.getClass(), employee.getId()).toUri().getPath(); }
}
@RepositoryEventHandler(Employee.class)
标记此类以捕获基于employee的事件。SimpMessagingTemplate
和 EntityLinks
从应用上下文自动装配。@HandleXYZ
注解标记需要监听的方法。这些方法必须是 public 的。每个处理方法都调用 SimpMessagingTemplate.convertAndSend()
以通过 WebSocket 传输消息。这是一种发布/订阅方法,因此一条消息会被中继到每个连接的 consumer。
每条消息的路由都不同,这允许将多条消息发送到客户端上不同的接收者,同时只需要一个打开的 WebSocket 连接,这是一种资源效率高的方法。
getPath()
使用 Spring Data REST 的 EntityLinks
查找给定类类型和 id 的路径。为了满足客户端的需求,此 Link
对象被转换为一个 Java URI,并提取其路径。
注意
|
EntityLinks 提供了多个实用方法,可以编程方式查找各种资源的路径,无论是单个资源还是集合资源。 |
本质上,你正在监听创建、更新和删除事件,并在它们完成后,将通知发送给所有客户端。也可以在这些操作发生之前拦截它们,例如记录日志、因某些原因阻止它们,或者使用额外信息装饰领域对象。(在下一篇教程中,我们将看到一个非常实用的用例!)
下一步是编写一些客户端代码来消费 WebSocket 事件。主应用中的以下代码片段引入了一个模块。
var stompClient = require('./websocket-listener')
该模块如下所示
define(function(require) { 'use strict';
var SockJS = require('sockjs-client'); <b class="conum">(1)</b> require('stomp-websocket'); <b class="conum">(2)</b> return { register: register }; function register(registrations) { var socket = SockJS('/payroll'); <b class="conum">(3)</b> var stompClient = Stomp.over(socket); stompClient.connect({}, function(frame) { registrations.forEach(function (registration) { <b class="conum">(4)</b> stompClient.subscribe(registration.route, registration.callback); }); }); }
});
/payroll
端点的地方。registrations
数组,以便每个注册项都可以在消息到达时订阅回调。每个注册条目都有一个 route
和一个 callback
。在下一节中,你将看到如何注册事件处理器。
在 React 中,组件的 componentDidMount()
是在组件被渲染到 DOM 后调用的函数。这也是注册 WebSocket 事件的正确时机,因为组件现在已经在线并准备好工作了。查看以下代码
componentDidMount: function () {
this.loadFromServer(this.state.pageSize);
stompClient.register([
{route: '/topic/newEmployee', callback: this.refreshAndGoToLastPage},
{route: '/topic/updateEmployee', callback: this.refreshCurrentPage},
{route: '/topic/deleteEmployee', callback: this.refreshCurrentPage}
]);
},
第一行与之前相同,从服务器按页面大小获取所有 employee。第二行显示了一个 JavaScript 对象数组,这些对象注册了 WebSocket 事件,每个对象都有一个 route
和一个 callback
。
创建新 employee 后,行为是刷新数据集,然后使用分页链接导航到最后一页。为什么要在导航到末尾之前刷新数据?添加新记录可能会创建一个新页面。虽然可以计算是否会发生这种情况,但这违背了超媒体的意义。与其拼凑自定义页数,不如使用现有链接,只有在有性能驱动的原因时才走这条路。
当 employee 更新或删除时,行为是刷新当前页面。当你更新记录时,它会影响你正在查看的页面。当你删除当前页面上的记录时,下一页的记录会拉入当前页面,因此也需要刷新当前页面。
注意
|
这些 WebSocket 消息没有强制要求必须以 /topic 开头。这只是一个表明发布/订阅语义的常见约定。 |
在下一节中,你将看到执行这些操作的实际实现。
以下代码片段包含用于在收到 WebSocket 事件时更新 UI 状态的两个回调函数。
refreshAndGoToLastPage: function (message) { follow(client, root, [{ rel: 'employees', params: {size: this.state.pageSize} }]).done(response => { this.onNavigate(response.entity._links.last.href); }) },
refreshCurrentPage: function (message) { follow(client, root, [{ rel: 'employees', params: { size: this.state.pageSize, page: this.state.page.number } }]).then(employeeCollection => { this.links = employeeCollection.entity._links; this.page = employeeCollection.entity.page;
return employeeCollection.entity._embedded.employees.map(employee => { return client({ method: 'GET', path: employee._links.self.href }) }); }).then(employeePromises => { return when.all(employeePromises); }).then(employees => { this.setState({ page: this.page, employees: employees, attributes: Object.keys(this.schema.properties), pageSize: this.state.pageSize, links: this.links }); });
},
refreshAndGoToLastPage()
使用熟悉的 follow()
函数导航到employee链接,应用了size参数,并传入 this.state.pageSize
。收到响应后,你将调用与上一篇教程中相同的 onNavigate()
函数,并跳转到最后一页,即可以找到新记录的那一页。
refreshCurrentPage()
也使用 follow()
函数,但将 this.state.pageSize
应用到size,将 this.state.page.number
应用到page。这将获取你当前正在查看的同一页面,并相应地更新状态。
注意
|
这种行为是告诉每个客户端在发送更新或删除消息时刷新其当前页面。他们的当前页面可能与当前事件无关。然而,要弄清楚这一点可能会很棘手。如果被删除的记录在第二页,而你正在查看第三页呢?每一条记录都会改变。但这真的是期望的行为吗?也许是,也许不是。 |
在你结束本节之前,需要认识到一点。你刚刚添加了一种新的方式来更新 UI 中的状态:当 WebSocket 消息到达时。但是旧的更新状态的方式仍然存在。
为了简化代码对状态的管理,请移除旧的方式。换句话说,提交你的POST、PUT和DELETE调用,但不要使用它们的结果来更新 UI 的状态。相反,等待 WebSocket 事件回传,然后再进行更新。
以下代码片段显示了与上一篇教程中相同的 onCreate()
函数,只是简化了
onCreate: function (newEmployee) {
follow(client, root, ['employees']).done(response => {
client({
method: 'POST',
path: response.entity._links.self.href,
entity: newEmployee,
headers: {'Content-Type': 'application/json'}
})
})
},
在这里,使用 follow()
函数获取到employee链接,然后应用POST操作。注意 client({method: 'GET' …})
如何没有像之前那样的 then()
或 done()
方法?用于监听更新的事件处理器现在在刚才查看过的 refreshAndGoToLastPage()
函数中。
完成所有这些修改后,启动应用(./mvnw spring-boot:run
)并试用一下。打开两个浏览器选项卡并调整大小以便都能看到。开始在一个选项卡中进行更新,看看它们如何在另一个选项卡中即时更新。打开手机访问同一页面。找个朋友,让他或她做同样的事情。你可能会发现这种动态更新更酷。
想要挑战一下吗?试试上一篇教程中的练习,在两个不同的浏览器选项卡中打开同一条记录。尝试在一个选项卡中更新它,但不要看到它在另一个选项卡中更新。如果可以做到,条件 PUT 代码应该仍然会保护你。但这可能更难实现了!
在本篇教程中
有了所有这些功能,可以轻松地并排运行两个浏览器,看看在一个浏览器中更新如何同步到另一个浏览器。
问题?
虽然多个显示器能够很好地更新,但对精确行为进行完善是值得的。例如,创建一个新用户会导致所有用户跳转到末尾。对于如何处理这种情况有什么想法吗?
分页很有用,但会带来一个棘手的状态管理问题。在这个示例应用中,成本很低,而且 React 在更新 DOM 时非常高效,不会导致 UI 大量闪烁。但对于更复杂的应用,并非所有这些方法都适用。
在设计时考虑到分页,你必须决定客户端之间的期望行为以及是否需要更新。根据你的需求和系统的性能,现有的导航超媒体可能就足够了。