SpringBoot はリアルタイム通知フロントエンドに SSE を使用します
説明する
プロジェクトの要件の 1 つは、タスクが読み込まれたことをリアルタイムでフロントエンドに通知することです。そこで私は 2 つの解決策を考えました。1 つは長時間接続するために WebSocket を使用することであり、もう 1 つは HTTP プロトコルの 1 つである SSE (Sever Send Event) を使用することです。Content-Type は text/event-stream です。長い接続を維持できます。
Websocket はバックエンドにメッセージを送信できるフロントエンドであり、バックエンドもフロントエンドにメッセージを送信できます。
SSE はバックエンドからフロントエンドにメッセージのみを送信できます。
バックエンド通知のみが必要なため、ここでは SSE を使用することにしました。
後で使用方法を忘れないように、最初にここにメモしておきます。
Mavenの依存関係
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.project</groupId>
<artifactId>test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>test</name>
<description>test</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!--web依赖,内嵌入tomcat,SSE依赖于该jar包,只要有该依赖就能使用SSE-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--lombok依赖,用来对象省略写set、get方法-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
SSEツールコード
package com.etone.project.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
@Slf4j
public class SseEmitterServer {
/**
* 当前连接数
*/
private static AtomicInteger count = new AtomicInteger(0);
private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
public static SseEmitter connect(String userId){
//设置超时时间,0表示不过期,默认是30秒,超过时间未完成会抛出异常
SseEmitter sseemitter = new SseEmitter(0L);
//注册回调
sseemitter.onCompletion(completionCallBack(userId));
//这个onError在springbooot低版本没有这个方法,公司springboot1.4.2版本,没有这个方法,可以进行注释。
sseemitter.onError(errorCallBack(userId));
sseemitter.onTimeout(timeoutCallBack(userId));
sseEmitterMap.put(userId,sseemitter);
//数量+1
count.getAndIncrement();
log.info("create new sse connect ,current user:{}",userId);
return sseemitter;
}
/**
* 给指定用户发消息
*/
public static void sendMessage(String userId, String message){
if(sseEmitterMap.containsKey(userId)){
try{
sseEmitterMap.get(userId).send(message);
}catch (IOException e){
log.error("user id:{}, send message error:{}",userId,e.getMessage());
e.printStackTrace();
}
}
}
/**
* 想多人发送消息,组播
*/
public static void groupSendMessage(String groupId, String message){
if(sseEmitterMap!=null&&!sseEmitterMap.isEmpty()){
sseEmitterMap.forEach((k,v) -> {
try{
if(k.startsWith(groupId)){
v.send(message, MediaType.APPLICATION_JSON);
}
}catch (IOException e){
log.error("user id:{}, send message error:{}",groupId,message);
removeUser(k);
}
});
}
}
public static void batchSendMessage(String message) {
sseEmitterMap.forEach((k,v)->{
try{
v.send(message,MediaType.APPLICATION_JSON);
}catch (IOException e){
log.error("user id:{}, send message error:{}",k,e.getMessage());
removeUser(k);
}
});
}
/**
* 群发消息
*/
public static void batchSendMessage(String message, Set<String> userIds){
userIds.forEach(userid->sendMessage(userid,message));
}
//移除用户
public static void removeUser(String userid){
sseEmitterMap.remove(userid);
//数量-1
count.getAndDecrement();
log.info("remove user id:{}",userid);
}
public static List<String> getIds(){
return new ArrayList<>(sseEmitterMap.keySet());
}
public static int getUserCount(){
return count.intValue();
}
private static Runnable completionCallBack(String userId) {
return () -> {
log.info("结束连接,{}",userId);
removeUser(userId);
};
}
private static Runnable timeoutCallBack(String userId){
return ()->{
log.info("连接超时,{}",userId);
removeUser(userId);
};
}
private static Consumer<Throwable> errorCallBack(String userId){
return throwable -> {
log.error("连接异常,{}",userId);
removeUser(userId);
};
}
}
コントローラーのテストコード
package com.project.test.controller;
import com.hjl.test.util.SseEmitterServer;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping(value = "/test")
public class TestController {
//sse连接接口
@GetMapping (value = "/sse/connect/{id}")
public SseEmitter connect(@PathVariable String id){
return SseEmitterServer.connect(id);
}
//sse向指定用户发送消息接口
@GetMapping (value = "/sse/send/{id}")
public Map<String,Object> send(@PathVariable String id,@RequestParam(value = "message", required = false) String message){
Map<String,Object> returnMap = new HashMap<>();
//向指定用户发送信息
SseEmitterServer.sendMessage(id,message);
returnMap.put("message","向id为"+id+"的用户发送:"+message+"成功!");
returnMap.put("status","200");
returnMap.put("result",null);
return returnMap;
}
//sse向所有已连接用户发送消息接口
@GetMapping (value = "/sse/batchSend")
public Map<String,Object> batchSend(@RequestParam(value = "message", required = false) String message){
Map<String,Object> returnMap = new HashMap<>();
//向指定用户发送信息
SseEmitterServer.batchSendMessage(message);
returnMap.put("message",message+"消息发送成功!");
returnMap.put("status","200");
returnMap.put("result",null);
return returnMap;
}
//sse关闭接口
@GetMapping (value = "/sse/close/{id}")
public Map<String,Object> close(@PathVariable String id){
Map<String,Object> returnMap = new HashMap<>();
//移除id
SseEmitterServer.removeUser(id);
System.out.println("当前连接用户id:"+SseEmitterServer.getIds());
returnMap.put("message","连接关闭成功!");
returnMap.put("status","200");
returnMap.put("result",null);
return returnMap;
}
}
テスト結果は次のとおりです。
ここで SSE 接続をテストするには、通常のインターフェイスと同様にリクエストするだけです。
ローカル呼び出しインターフェイス /sse/connect/1 は次のとおりです。
ここでは、指定されたユーザー ID への情報の送信と、接続されているユーザーへのメッセージのバッチ送信をシミュレートするために 2 人のユーザーを接続します。
バックエンド サービスは次のように出力します。
ローカル呼び出しインターフェイス /sse/send/1 は次のとおりです。
ユーザー 1 の結果は次のとおりで、メッセージを受信したことがわかります。
ユーザー 2 は次のように結果を受信しませんでした。
ローカル呼び出しインターフェイス /sse/batchSend は次のとおりです。
接続されているすべてのユーザーにメッセージをバッチで送信します。
ユーザー 1 の結果は次のとおりであり、メッセージを受信していることがわかります。
ユーザー 2 の結果は次のとおりで、メッセージを受信していることがわかります。
テスト結果はすべて期待どおりです。
郵便配達員の閉じるボタンをクリックして接続を閉じます。
フロントエンド接続は閉じられていますが、実際にはバックエンドはまだ接続されており、ユーザーを削除するためのプロンプトはまったく表示されないことがわかります。そのため、手動で次のように記述する必要があります
。終了インターフェイスは自分でテストしてください。
ローカル呼び出しインターフェイス /sse/close/1 は次のとおりです。
ユーザー ID 1 が削除され、ユーザー 2 のみがまだ接続されていることがわかります。
ここでのテストはすべて完了しており、結果は予想どおりです。
知らせ
タイムアウトを元の 0 ミリ秒から 30 ミリ秒 (整数の後の L は Long が 8 バイトを占めることを意味します) に変更すると、エラーが報告されます。
テスト結果は次のとおりです。
org.springframework.web.context.request.async.AsyncRequestTimeoutException で直接例外が発生し
、接続も切断されました。
springboot を 1.4.2.RELEASE などの下位バージョンに下げます。
テストに postman を使用すると、次のように、それが常にリクエストに含まれているわけではないことがわかりました:
Springboot を 1.4.2.RELEASE にダウングレードします。
springboot の 1.4.2.RELEASE バージョンには onError メソッドがないため、コメントアウトする必要があります。
postman テストは次のとおりです:
下位バージョンをテストすると、直接参照できる接続があることがわかりましたが、springboot バージョン 2.x を使用すると、常にリクエストを送信していることがわかりました。フロントエンドにメッセージを送信すると、これが表示されます。
Springboot 1.4.2.RELEASE バージョンの結果:
springboot 2.7.3 バージョンの結果:
まずこの状況を記録して、後で時間があるときに調べてみましょう。上位バージョンが下位バージョンのようにこの接続情報を返せないのはなぜですか? そのため、上位バージョンの SpringBoot が SSE を使用して接続すると、常にリクエスト送信状態になりますが、これは正常ですか? どなたか教えていただけませんか、よろしくお願いします。