1.EventSource的工作原理
EventSource 基于 HTTP 协议实现,通过与服务器建立一个持续连接,实现了服务器向客户端推送事件数据的功能。在客户端,EventSource 对象通过一个 URL 发起与服务器的连接。连接成功后,服务器可以向客户端发送事件数据。在客户端,通过 EventSource 对象注册事件处理函数,以接收来自服务器的事件数据。
以下是 EventSource 的工作原理:
-
客户端向服务器发起 HTTP GET 请求,请求一个特定的 URL。
-
服务器接收请求,并在 HTTP 头中添加 “Content-Type: text/event-stream”。
-
服务器建立一个持续的 HTTP 连接,向客户端发送数据,直到连接被关闭。
-
当服务器有新的事件数据要发送时,它将这些数据以特定的格式发送给客户端。事件数据格式如下:
event: eventName; data: eventData; 其中,event 字段表示事件名,data 字段表示事件数据。
-
客户端通过 EventSource 对象注册事件处理函数,以接收来自服务器的事件数据。当客户端接收到来自服务器的事件数据时,它将创建一个 Event 对象,并触发相应的事件处理函数,传递 Event 对象作为参数。Event 对象包含以下属性:
type:事件类型,通常为 "message"。 data:事件数据。 lastEventId:上一个事件的 ID。 origin:事件源的 URL。
-
当连接出现错误或被关闭时,客户端将触发 “error” 事件或 “close” 事件,以便进行错误处理或重新连接。总的来说,EventSource 建立了一种持久化的 HTTP 连接,实现了服务器向客户端实时推送事件数据的功能。它非常适合于需要实时更新的应用程序,例如聊天室、股票市场等等。
2.spring boot使用EventSource,实现实时聊天后端服务
首先是一个spring的服务类,eventsource用于处理实时聊天的所有逻辑都在这里面,相关功能如下:
- register(String userId):这个方法用于注册一个新的用户到聊天服务。它创建一个新的SseEmitter对象,并将其添加到emitters映射中,这个映射保存了所有在线用户的SseEmitter对象。然后,向所有在线用户发送一条包含所有在线用户列表的消息。
- sendMessage(String receiverId, String message):这个方法用于向指定的接收者发送消息。它首先从emitters映射中获取接收者的SseEmitter对象,然后调用其send方法发送消息。
- sendAllMessage(String message):这个方法用于向所有在线用户发送消息。它遍历emitters映射中的所有SseEmitter对象,并调用其send方法发送消息。
- getOnlineUsers():这个方法用于获取所有在线用户的列表。它遍历emitters映射中的所有键(也就是用户ID),并将它们添加到一个JSON数组中,然后返回这个数组的JSON字符串表示。
代码如下
package me.zhengjie.gen.service;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@Slf4j
public class ChatService {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>(); // emitters用来保存所有连接到服务器的id和SseEmitter对象
public SseEmitter register(String userId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 设置为最大值,几乎等于永不超时
this.emitters.put(userId, emitter);
JSONObject result = new JSONObject();
JSONArray array = new JSONArray();
result.set("users",array);
for (String key : emitters.keySet()) {
JSONObject jsonObject = new JSONObject();
jsonObject.set("username",key);
array.add(jsonObject);
}
emitter.onCompletion(() -> this.emitters.remove(userId));
emitter.onTimeout(() -> this.emitters.remove(userId));
sendAllMessage(JSONUtil.toJsonStr(result));
return emitter;
}
public void sendMessage(String receiverId, String message) {
SseEmitter emitter = this.emitters.get(receiverId);
if (emitter != null) {
try {
emitter.send(message);
log.info("发送消息给{},消息为{}", receiverId, message);
} catch (IOException e) {
log.error("发送消息时发生错误", e);
}
}
}
public void sendAllMessage(String message) {
for (SseEmitter emitter : emitters.values()) {
try {
emitter.send(message);
} catch (IOException e) {
log.error("发送消息时发生错误", e);
}
}
}
public String getOnlineUsers() {
JSONObject result = new JSONObject();
JSONArray array = new JSONArray();
result.set("users",array);
for (String key : emitters.keySet()) {
JSONObject jsonObject = new JSONObject();
jsonObject.set("username",key);
array.add(jsonObject);
}
return JSONUtil.toJsonStr(result);
}
}
接着就是编写一个spring的控制类,用来处理与实时聊天相关的HTTP请求,这个类的主要方法如下:
- chat(String userId):当用户打开聊天页面时,前端会发送一个GET请求到"/ssechat/{userId}"。这个方法处理这个请求,调用ChatService的register方法注册用户,并返回一个SseEmitter对象。
- sendMessage(String receiverId, String message):当用户在前端发送消息时,前端会发送一个POST请求到"/api/ssechat/{receiverId}"。这个方法处理这个请求,调用ChatService的sendMessage方法向指定的接收者发送消息。
- getOnlineUsers():这个方法处理前端发送的GET请求到"/ssechat/onlineUsers",调用ChatService的getOnlineUsers方法获取所有在线用户的列表,并返回这个列表的JSON字符串表示。
代码如下:
package me.zhengjie.gen.rest;
import me.zhengjie.gen.service.ChatService;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@GetMapping("/ssechat/{userId}")
public SseEmitter chat(@PathVariable String userId) {
return this.chatService.register(userId);
}
@PostMapping("/api/ssechat/{receiverId}")
public void sendMessage(@PathVariable String receiverId, @RequestBody String message) {
this.chatService.sendMessage(receiverId, message);
}
@GetMapping("/ssechat/onlineUsers")
public String getOnlineUsers() {
return this.chatService.getOnlineUsers();
}
}
3.前端vue使用EventSource实现实时聊天的相关逻辑
为了简化代码,所以当实时聊天界面打开后就会立即创建一个EventSource对象与服务器进行连接
const sseUrl = 'http://localhost:8000/ssechat/' + username
this.eventSource = new EventSource(sseUrl)
一旦EventSource对象被创建并打开,它就会开始监听服务器发送的消息。代码中,定义了一个onmessage事件处理器来处理从服务器接收到的消息。
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
// 处理数据...
}
当服务器发送一个消息时,这个处理器会被触发。消息的内容可以通过event.data访问,然后可以根据这个数据更新页面状态。 代码还处理了错误情况,如果连接关闭,它会尝试重新连接。
this.eventSource.onerror = function(event) {
if (this.eventSource.readyState === EventSource.CLOSED) {
this.eventSource = new EventSource(sseUrl)
}
}
全部的vue代码如下:
<template>
<div style="padding: 10px; margin-bottom: 50px">
<el-row>
<el-col :span="8">
<el-card style="width: 100%; min-height: 300px; color: #333">
<div style="padding-bottom: 10px; border-bottom: 1px solid #ccc">在线用户<span style="font-size: 12px">(点击聊天气泡开始聊天)</span></div>
<div v-for="user in users" :key="user.username" style="padding: 10px 0">
<div @click="selectUser(user.username)">
<span>{{ user.username }}</span>
<i
class="el-icon-chat-dot-round"
style="margin-left: 10px; font-size: 16px; cursor: pointer"
/>
<span v-if="user.username === chatUser" style="font-size: 12px;color: limegreen; margin-left: 5px">chatting...</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="16">
<div
style="width: 800px; margin: 0 auto; background-color: white;
border-radius: 5px; box-shadow: 0 0 10px #ccc"
>
<div style="text-align: center; line-height: 50px;">
Web聊天室({{ chatUser }})
</div>
<div ref="scrollArea" style="height: 350px; overflow:auto; border-top: 1px solid #ccc">
<ChatBubble
v-for="(message, index) in messages"
:key="index"
:is-remote-user="message.from !== user.username"
:avatar-name="avatarName"
:text="message.text"
/>
</div>
<div style="height: 200px">
<textarea
v-model="text"
style="height: 160px; width: 100%; padding: 20px; border: none; border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc; outline: none"
/>
<div style="text-align: right; padding-right: 10px">
<el-button type="primary" size="mini" @click="send">发送</el-button>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
<script>
import ChatBubble from '../compoments/ChatBubble.vue'
import { mapGetters } from 'vuex'
import { getMessage } from '@/views/model1/api/message'
import { sendMessage } from '@/views/model1/api/sse'
import { getAvatar } from '@/api/system/user'
export default {
name: 'Sse',
components: { ChatBubble },
data() {
return {
isCollapse: false,
users: [],
chatUser: '',
avatarName: '',
text: '',
messages: [],
content: ''
}
},
computed: {
...mapGetters([
'user',
'baseApi'
])
},
created() {
this.init()
console.log(this.user)
},
methods: {
init() {
const _this = this
if (this.eventSource) {
this.eventSource.close()
}
// eslint-disable-next-line no-unused-vars
const username = this.user.username
const sseUrl = 'http://localhost:8000/ssechat/' + username
this.eventSource = new EventSource(sseUrl)
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data) // 对收到的json数据进行解析, 类似这样的: {"users": [{"username": "zhang"},{ "username": "admin"}]}
console.log(data)
if (data.users) { // 获取在线人员信息
_this.users = data.users.filter(user => user.username !== username) // 获取当前连接的所有用户信息,并且排除自身,自己不会出现在自己的聊天列表里
} else { // 如果服务器端发送过来的json数据 不包含 users 这个key,那么发送过来的就是聊天文本json数据
// 如果服务器端发送过来的json数据 不包含 users 这个key,那么发送过来的就是聊天文本json数据
// // {"from": "zhang", "text": "hello"}
console.log(222222)
_this.messages.push(data)
console.log('1111111111111111111' + JSON.stringify(_this.messages))
}
}
this.eventSource.onerror = function(event) {
if (this.eventSource.readyState === EventSource.CLOSED) {
// 如果连接已经关闭,那么尝试重新连接
this.eventSource = new EventSource(sseUrl)
}
}
},
getAvatarName(userName) {
getAvatar(userName).then(res => {
res ? this.avatarName = res : ''
})
},
selectUser(to) {
this.chatUser = to
this.getAvatarName(to)
getMessage(this.user.username, this.chatUser).then(res => {
res.sort((a, b) => new Date(a.date) - new Date(b.date))
this.messages = res
this.$nextTick(() => {
const scrollArea = this.$refs.scrollArea
scrollArea.scrollTop = scrollArea.scrollHeight
})
})
},
send() {
if (!this.chatUser) {
this.$message({ type: 'warning', message: '请选择聊天对象' })
return
}
if (!this.text) {
this.$message({ type: 'warning', message: '请输入内容' })
} else {
// 组装待发送的消息 json
// {"from": "zhang", "to": "admin", "text": "聊天文本"}
const message = { from: this.user.username, text: this.text }
sendMessage(this.chatUser, JSON.stringify(message)) // 将组装好的json发送给服务端,由服务端进行转发
const msg = { from: this.user.username, text: this.text }
console.log('msg' + JSON.stringify(msg))
this.messages.push(msg)
this.$nextTick(() => {
const scrollArea = this.$refs.scrollArea
scrollArea.scrollTop = scrollArea.scrollHeight
})
this.text = ''
}
}
}
}
</script>
<style>
.tip {
color: white;
text-align: center;
border-radius: 10px;
font-family: sans-serif;
padding: 10px;
width:auto;
display:inline-block !important;
display:inline;
}
.right {
background-color: deepskyblue;
}
.left {
background-color: forestgreen;
}
</style>
4.EventSource 与 WebSocket Http 的对比
EventSource 和 WebSocket 都是用于实现客户端与服务器之间实时双向通信的技术,但它们在很多方面有着不同的特点和适用场景。
-
协议 EventSource 基于 HTTP 协议,使用的是 HTTP 的长连接机制,而 WebSocket 则是一种独立的协议,与 HTTP 没有关系。
-
双向通信 WebSocket 支持双向通信,客户端和服务器都可以主动发送数据。而 EventSource 只支持服务器向客户端的单向通信,客户端只能接收数据,不能主动发送数据。
-
数据格式 WebSocket 可以发送任意格式的数据,包括文本、二进制等。而 EventSource 仅支持纯文本格式,采用了一种特殊的格式来传输事件数据。
-
浏览器兼容性 WebSocket 是 HTML5 新增的标准,兼容性相对较差,在一些旧版本的浏览器中不支持。而 EventSource 的兼容性相对较好,在大多数现代浏览器中都能够正常工作。
-
实时性 WebSocket 的实时性更高,它的通信速度和性能都比 EventSource 更优秀。因为 WebSocket 是双向通信,数据传输的效率更高,而 EventSource 由于是单向通信,数据传输的速度会稍慢一些。
-
跨域 WebSocket 和 EventSource 都可以跨域使用,但跨域的设置方式有所不同。
- WebSocket 需要在服务器端进行配置,允许客户端连接。服务器需要在 HTTP 头中添加 “Access-Control-Allow-Origin” 字段,允许来自指定域名的客户端连接。
- EventSource 跨域时默认使用 CORS 机制。服务器只需在 HTTP 头中添加 “Access-Control-Allow-Origin” 字段,允许来自指定域名的客户端连接即可。
-
浏览器兼容性
WebSocket 和 EventSource 在浏览器兼容性方面有所不同。
- WebSocket 在一些较老版本的浏览器中不被支持,例如 IE9 及以下版本。但在现代浏览器中,WebSocket 已经得到了广泛的支持。
- EventSource 则在较早的浏览器版本中也能够正常工作,包括 IE10 及以上版本、Firefox 6.0 及以上版本、Chrome 13.0 及以上版本等等。但在一些较老的浏览器中,如 Safari 5.1.7 及以下版本,EventSource 可能会遇到一些问题。
总的来说,WebSocket 和 EventSource 都有着自己的优点和适用场景。WebSocket 更适合需要双向通信的应用场景,例如实时游戏、在线协作等等。而 EventSource 则更适合需要单向数据推送的应用场景,例如实时监控、股票行情等等。因此,在选择使用哪种技术时,需要根据具体的需求进行选择。
参考:https://juejin.cn/post/7206261082452721722