我正在构建一个 Spring Boot 应用程序来处理服务器发送的事件(SSE):
package com.example.demo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
public class SseBroadcaster {
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
public void addEmitter(String sessionId, SseEmitter emitter) {
emitters.put(sessionId, emitter);
}
public void removeEmitter(String sessionId) {
emitters.remove(sessionId);
}
public void broadcast(String message) {
for (String s : emitters.keySet()){
try {
emitters.get(s).send(SseEmitter.event().name("message").data(message));
log.info("Message has been sent to " + s);
} catch (IOException e) {
log.error("Failed to send message to " + s, e);
removeEmitter(s);
}
}
}
}
package com.example.demo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.UUID;
@RestController
@Slf4j
public class SseController {
@Autowired
private SseBroadcaster sseBroadcaster;
@GetMapping("/api/contest/sse")
public SseEmitter connectToSse() {
String username = "(Unknown)";
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
String sessionId = username + "-" + UUID.randomUUID().toString();
sseBroadcaster.addEmitter(sessionId, emitter);
log.info(username + " connected to SSE, session: " + sessionId);
emitter.onCompletion(() -> {
sseBroadcaster.removeEmitter(sessionId);
log.info(username + " disconnected from SSE, session: " + sessionId);
});
emitter.onTimeout(() -> {
sseBroadcaster.removeEmitter(sessionId);
log.info(username + " disconnected from SSE, session: " + sessionId);
});
return emitter;
}
@PostMapping("/api/contest/admin/broadcast")
public void publishBroadcast(String broadcast) {
sseBroadcaster.broadcast(broadcast);
log.info("Published a broadcast: " + broadcast);
}
}
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
对于前端,使用JavaScript建立SSE连接:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Message Broadcast</title>
<script>
function sendMessage() {
const inputContent = document.getElementById('messageInput').value;
const data = {
broadcast: inputContent
};
if (!inputContent) return;
fetch('/api/contest/admin/broadcast', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: Object.keys(data).map(key => encodeURIComponent(key) + '=' + encodeURIComponent(data[key])).join('&')
})
.catch((error) => {
console.error('Error:', error);
});
}
source = new EventSource('/api/contest/sse');
source.onmessage = function(event) {
alert(event.data);
}
source.onerror = function(event) {
console.error('SSE connection error: ', event);
}
</script>
</head>
<body>
<h1>Message Broadcast Tool</h1>
<p>Enter your message:</p>
<input type="text" id="messageInput" placeholder="Type your message here...">
<button onclick="sendMessage()">Send Message</button>
</body>
</html>
以及 nginx 作为反向代理服务器:
#user nobody;
worker_processes 1;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#access_log logs/access.log main;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
#gzip on;
server {
listen 80;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
client_max_body_size 1G;
}
location /api {
add_header Access-Control-Allow-Origin '*';
proxy_pass http://localhost:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 1G;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;
# location / {
# root html;
# index index.html index.htm;
# }
#}
# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;
# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# location / {
# root html;
# index index.html index.htm;
# }
#}
}
但是,消息无法立即发送到客户端。从日志中可以看出,客户端已成功连接到服务器 URI
/api/contest/sse
,服务器已收到来自 /api/contest/admin/broadcast
的广播消息,并将收到的消息发送给客户端,但客户端并未收到该消息。
2024-09-14T21:47:10.092+08:00 INFO 16020 --- [demo] [ main] com.example.demo.DemoApplication : Starting DemoApplication using Java 21.0.4 with PID 16020 (C:\develop\demo\target\classes started by gengy in C:\develop\demo)
2024-09-14T21:47:10.095+08:00 INFO 16020 --- [demo] [ main] com.example.demo.DemoApplication : No active profile set, falling back to 1 default profile: "default"
2024-09-14T21:47:10.683+08:00 INFO 16020 --- [demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2024-09-14T21:47:10.693+08:00 INFO 16020 --- [demo] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2024-09-14T21:47:10.694+08:00 INFO 16020 --- [demo] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.28]
2024-09-14T21:47:10.723+08:00 INFO 16020 --- [demo] [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2024-09-14T21:47:10.723+08:00 INFO 16020 --- [demo] [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 571 ms
2024-09-14T21:47:10.956+08:00 INFO 16020 --- [demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2024-09-14T21:47:10.962+08:00 INFO 16020 --- [demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 1.083 seconds (process running for 1.418)
2024-09-14T21:47:12.667+08:00 INFO 16020 --- [demo] [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-09-14T21:47:12.668+08:00 INFO 16020 --- [demo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2024-09-14T21:47:12.668+08:00 INFO 16020 --- [demo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 0 ms
2024-09-14T21:47:12.688+08:00 INFO 16020 --- [demo] [nio-8080-exec-1] com.example.demo.SseController : (Unknown) connected to SSE, session: (Unknown)-2e96934e-8a86-4267-bbbd-48270698a79b
2024-09-14T21:47:22.963+08:00 INFO 16020 --- [demo] [nio-8080-exec-2] com.example.demo.SseBroadcaster : Message has been sent to (Unknown)-2e96934e-8a86-4267-bbbd-48270698a79b
2024-09-14T21:47:22.963+08:00 INFO 16020 --- [demo] [nio-8080-exec-2] com.example.demo.SseController : Published a broadcast: 5678
如果我终止了 Spring Boot 应用程序,客户端将收到消息,然后关闭连接。另外打印几行日志:
2024-09-14T21:47:34.371+08:00 INFO 16020 --- [demo] [nio-8080-exec-3] com.example.demo.SseController : (Unknown) disconnected from SSE, session: (Unknown)-2e96934e-8a86-4267-bbbd-48270698a79b
2024-09-14T21:47:34.382+08:00 WARN 16020 --- [demo] [nio-8080-exec-3] .w.s.m.s.DefaultHandlerExceptionResolver : Ignoring exception, response committed already: org.springframework.web.context.request.async.AsyncRequestTimeoutException
2024-09-14T21:47:34.382+08:00 WARN 16020 --- [demo] [nio-8080-exec-3] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.context.request.async.AsyncRequestTimeoutException]
2024-09-14T21:47:34.382+08:00 INFO 16020 --- [demo] [nio-8080-exec-3] com.example.demo.SseController : (Unknown) disconnected from SSE, session: (Unknown)-2e96934e-8a86-4267-bbbd-48270698a79b
简而言之,在我关闭后端 Spring Boot 应用程序之前,客户端永远不会收到消息。
如果我改用caddy作为反向代理服务器,上述问题就消失了!所以,我相信我的nginx配置有一些错误。这个问题与spring框架和javascript无关,只是nginx的问题。
hmmmmmm gayet güzel bir çalışma olmuş