使用 Spring WebFlux 和服务器发送的事件进行实时通知

1,182 阅读2分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 19 天,点击查看活动详情

有时可能需要向用户发送实时数据,例如前端应用程序中的通知。为此,您可以使用以下简单方法,使用来自 Project Reactor 的 Flux 和来自 Spring Boot 的服务器发送事件。

首先,您需要创建一个类来存储通知。为简单起见,它只有两个字段:id 和 message。

public class Notification {

    private final Integer id;
    private final String message;

    public Notification(Integer id, String message) {
      this.id = id;
      this.message = message;
    }

    public Integer getId() {
      return id;
    }
}

接下来,我们将创建一个管理订阅和通知的服务。我们将通过用户 id 来确定谁拥有通知。我们将为每个通知生成一个随机 ID。让我们将随机数限制在区间 [0, 3] 内。

private Integer generateId() {  
return RANDOM.nextInt(4);  
}

我们将每 2 秒生成一次简单的通知,而不是真实数据。

private ServerSentEvent<Notification> generateNotification() {
  return ServerSentEvent.<Notification>builder()
          .data(new Notification(generateId(), "Notification"))
          .build();
}

private void generateNotifications(FluxSink<ServerSentEvent<Notification>> sink) {
    Flux.interval(Duration.ofSeconds(2)) // Generate simple notifications every 2 seconds.
      .map(i -> generateNotification())
      .doOnNext(serverSentEvent -> {
        sink.next(serverSentEvent); // Sending notifications to the global Flux via its FluxSink
        log.info("Sent for {}", serverSentEvent.data().getId());
      })
      .doFinally(signalType -> log.info("Notification flux closed")) // Logging the closure of our generator
      .takeWhile(notification -> !sink.isCancelled()) // We generate messages until the global Flux is closed
      .subscribe();
}

如果我们立即从生成器开始发送通知,我们可能会遇到超时问题和断开连接。目前,通知每 2 秒创建一次,但在真实系统中,通知可以在几分钟甚至几小时内创建。如果在一定时间内(通常是几分钟)没有数据发送到打开的连接,它将自动关闭。

为了避免这种情况,让我们创建一个心跳——一个额外的空评论流,它将被发送到打开的连接以保持它打开。Spring 本身会负责关闭它并自动关闭我们的心跳。

private <T> Flux keepAlive(Duration duration, Flux<T> data, Integer id) {
    Flux<ServerSentEvent<T>> heartBeat = Flux.interval(duration) // Создаем Flux с определенным интервалом
      .map(
          e -> ServerSentEvent.<T>builder() /Create a new SSE object with a comment and an empty body
      .comment(“keep alive for: “ + id)
      .build())
      .doFinally(signalType -> log.info(“Heartbeat closed for id: {}”, id));
    return Flux.merge(heartBeat, data);
}

现在让我们编写一个简单的方法来通过 id 订阅通知。

public Flux<ServerSentEvent<Notification>> subscribe(int id) {
    return keepAlive(Duration.ofSeconds(3),
      notificationFlux.filter(notification -> notification.data() == null || 
      notification.data().getId() == id), id);
}

让我们在服务构造函数中创建一个全局 Flux。

private final Flux<ServerSentEvent<Notification>> notificationFlux;  
  
public NotificationService () {  
notificationFlux = Flux.push(这::generateNotifications);  
}

现在让我们创建一个 RestController 和一个用于订阅通知的简单端点。

@RestController
public class NotificationController {

    private final NotificationService notificationService;

    @Autowired
    public NotificationController(NotificationService notificationService) {
      this.notificationService = notificationService;
    }

    @GetMapping("/subscribe/{id}")
    public Flux<ServerSentEvent<Notification>> subscribe(@PathVariable Integer id) {
      return notificationService.subscribe(id);
    }
}

如果您在浏览器中打开http://localhost:8080/subscribe/1,您将获得以下输出。现在您可以停止加载页面或关闭它。

:keep alive for: 1  
:keep alive for: 1  
data:{"id":1,"message":"Notification"}  
:keep alive for: 1  
:keep alive for: 1  
data:{"id":1,"message":"Notification"}

您可以使用内置的 JavaScript 工具或使用第三方库在前端应用程序上接收通知。我使用sse.js和 JQuery。

<!DOCTYPE HTML>
<html>
    <head>
        <script src="jquery.min.js"></script>
    </head>
    <body>
        <h1>Watcher</h1>
        <div class="container"></div>
        <script src="sse.js"></script>
        <script>
            window.onload = function () {
                const source = new SSE("http://localhost:8080/subscribe/1");
                source.addEventListener('message', function (e) {
                if (e.data) {
                    const payload = JSON.parse(e.data);
                    $(".container").append('<p>' + payload.action + '</p>')
                }});
                source.stream();
            };
        </script>
    </body>
</html>