前言
在web开发中,我们经常会遇到这样的场景:服务端某个操作完成,或者发生了某种变化时,需要实时通知到客户端/浏览器,我们就称其为即时通讯。例如,client提交了一个导出任务,服务端执行异步任务进行处理,在导出完成时,告知客户端,客户端再下载导出结果。又比如,常见webim通讯场景,客户端发送消息,服务端接收到消息后,通知到接收方。常用的即时通讯方式有以下几种:
- 短轮询。
- 长轮询。
- websocket。
- SSE。
- flash socket。
短轮询
短轮询,客户端周期性的向服务端发送请求(比如2s,3s,5s......),服务端收到请求后,不管是否有新消息/新通知,都立即返回。 示意图如下:
这是最简单的一种通讯方式,服务端就是普通的http接口。
当然,这种方式也是有缺点的。第一,频繁的建立连接,连接也是有开销的。第二,实时性不好,消息会有延迟。延迟的程度,要看轮询周期,比如轮询周期是3s,某次轮询之后来了一条新消息,那么客户端只有在下次轮询请求的时候才能拉到这条消息,延迟时间就约3s。
长轮询
和短轮询不同的是,在长轮询中,服务端收到请求后,如果没有新消息/新通知,并不立即返回,而是一直等待,直到达到超时时间。在等待期间,如果收到新消息/新通知,则立即返回给客户端,然后再进行下一次请求。
相比短轮询,其优点主要是:
- 消息实时性更好。
- 没有频繁建立网络连接的开销。
但是其缺点也是很明显:
- 占用大量的连接。尤其是在消息不多时,其连接大多数可能是无效的。
SSE
SSE的英文全称是Server-Sent Event,直译为 服务端发送事件。在使用sse时,服务端可以向客户端推送多条消息,发送之后并不关闭连接。其本质是基于http的长连接。服务端返回的header需要是:text/event-stream。
使用sse时,服务端可以指定事件名称和事件数据,客户端可以为特定的事件指定特定的处理函数。
服务端发送消息时,每条事件消息可以指定id、data、event、retry几个属性,各个属性之间以\n分割,整条消息以\n\n结束。具体可以看下面的示例。
需要注意的是:sse是单工的,在连接维持期间,只能是服务端向客户端推送数据,客户端不能再向服务端发送消息。其更适合服务端通知场景。
下面给出例子:
vue-客户端示例
vue
<template>
<div class="sseTest">
<h1>服务器发送事件(SSE)测试</h1>
<p>{{ message }}</p>
</div>
</template>
<script>
export default {
data() {
return {
message: '等待服务器消息...',
eventSource: null
}
},
created() {
this.testSSE()
},
methods: {
testSSE() {
this.eventSource = new EventSource('http://localhost:8080/events');
this.eventSource.onerror = (event) => {
console.log("error:",event)
}
this.eventSource.onopen = (event) => {
console.log("open;",event)
}
// 监听消息 tick-event
this.eventSource.addEventListener('tick-event', (event) => {
console.log("tick-event:",event)
this.message = event.data
})
}
}
}
</script>
<style scoped>
</style>
服务端示例1
go
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// 允许跨域。
w.Header().Set("Access-Control-Allow-Origin", "*")
id := 1
for {
fmt.Fprintf(w, "id:%d\nevent:%s\ndata:%s\n\n", id, "tick-event", time.Now().Local().String())
if f, ok := w.(http.Flusher); ok {
f.Flush()
} else {
log.Println("Unable to send!")
}
time.Sleep(time.Second)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
运行服务端程序->打开测试页面,显示结果是:
上面的例子中,我们是基于http1.1实现的,而每个浏览器&&域名,是有连接限制的。为了突破这个限制,我们下面再给一个使用http2的sse服务端程序:
服务端示例2
go
package main
import (
"fmt"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"log"
"net/http"
"os"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// 允许跨域。
w.Header().Set("Access-Control-Allow-Origin", "*")
id := 1
for {
fmt.Fprintf(w, "id:%d\nevent:%s\ndata:%s\n\n", id, "tick-event", time.Now().Local().String())
if f, ok := w.(http.Flusher); ok {
f.Flush()
} else {
log.Println("Unable to send!")
}
time.Sleep(time.Second)
}
})
s2 := &http2.Server{}
server := &http.Server{
Addr: ":8080",
Handler: h2c.NewHandler(mux, s2),
}
err := http2.ConfigureServer(server, nil)
if err != nil {
log.Fatal(err)
}
// 获取程序执行路径
pwd, _ := os.Getwd()
fmt.Println(pwd)
log.Fatal(server.ListenAndServeTLS("localhost-cert.pem", "localhost-privkey.pem"))
}
在上面的示例中,我使用了自己生成的ssl证书。
websocket
websocket是一种双工的通讯方式,client和server完成握手后,就可以向对方发送消息。 和上面介绍的几种方式不同的是,websocket是一种与http不同的协议,虽然两者都依赖于tcp。也正因为如此,要支持websocket,服务端和客户端都需要支持websocket协议,会比之前介绍的几种基于http的通讯方式复杂一些。
websocket有两个统一资源标识符,分别是ws和wss,ws是对应明文的,wss是对应加密连接的。
其优点是:
- 实时性好。
- 双工通讯。
其缺点是:
- 相对较为复杂,需要服务端和客户端都支持websocket协议。
下面给出一个使用websocket的例子:
服务端示例
go
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // 允许跨域
},
}
func echoHandler(w http.ResponseWriter, r *http.Request) {
// 升级HTTP连接为WebSocket连接
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
defer conn.Close()
for {
// 从客户端接收消息
_, message, err := conn.ReadMessage()
if err != nil {
log.Println(err)
break
}
// 将消息原样返回给客户端
err = conn.WriteMessage(websocket.TextMessage, []byte("服务端:"+string(message)))
if err != nil {
log.Println(err)
break
}
}
}
func main() {
// 设置路由,将 /echo 映射到 echoHandler 函数
http.HandleFunc("/echo", echoHandler)
// 启动WebSocket服务端
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
客户端示例
vue
<template>
<div>
<h1>聊天窗口</h1>
<div class="chat-window">
<div class="message-list" ref="messageList">
<div v-for="(message, index) in messages" :key="index" :class="getMessageClass(message)">
<div class="message-content">
<span>{{ message.content }}</span>
<span class="time">{{ message.time }}</span>
</div>
</div>
</div>
<div class="input-area">
<input v-model="messageInput" placeholder="请输入消息" @keyup.enter="sendMessage" />
<button @click="sendMessage">发送</button>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
messageInput: '',
messages: [],
socket: null
};
},
mounted() {
this.connectWebSocket();
this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight;
},
methods: {
connectWebSocket() {
const socket = new WebSocket('ws://localhost:8080/echo');
socket.onopen = () => {
console.log('WebSocket连接已打开');
};
socket.onmessage = (event) => {
const receivedMessage = {
content: event.data,
time: this.getCurrentTime(),
type: 'received'
};
this.messages.push(receivedMessage);
this.$nextTick(() => {
this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight;
});
};
socket.onclose = () => {
console.log('WebSocket连接已关闭');
};
this.socket = socket;
},
sendMessage() {
if (this.messageInput.trim() !== '') {
const sentMessage = {
content: this.messageInput,
time: this.getCurrentTime(),
type: 'sent'
};
this.messages.push(sentMessage);
this.socket.send(this.messageInput);
this.messageInput = '';
this.$nextTick(() => {
this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight;
});
}
},
getCurrentTime() {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
},
getMessageClass(message) {
return {
'message-sent': message.type === 'sent',
'message-received': message.type === 'received'
};
}
}
};
</script>
<style>
.chat-window {
display: flex;
flex-direction: column;
height: 400px;
border: 1px solid #ccc;
padding: 10px;
background-color: #f2f2f2;
}
.message-list {
flex: 1;
overflow-y: scroll;
padding: 10px;
}
.message {
margin-bottom: 10px;
padding: 5px;
border-radius: 5px;
max-width: 70%;
}
.message-content {
display: inline-block;
padding: 5px 10px 5px 10px;
border-radius: 5px;
}
.message-sent {
text-align: right;
}
.message-sent .message-content {
background-color: lightgreen;
}
.message-received .message-content {
background-color: white;
}
.message-received {
text-align: left;
}
.time {
font-size: 12px;
color: #999;
}
.input-area {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.input-area input {
flex: 1;
margin-right: 10px;
}
.input-area button {
padding: 5px 10px;
}
</style>
页面测试结果如下:
flash socket
需要浏览器支持才行,并不算常见。
后记
client-server之间实现即时通信/推送的方式有好几种,在实际业务中使用哪一种(或哪几种),要根据自己的业务要求确定。如果兼容性要求高,可能需要同时支持多种方式(本人就见过一个网站同时支持websocket、flash socet、轮询三种方式的)。需求简单的,可能短轮询就能满足需求了。
注:此文原载于本人个人网站,链接地址。
参考资料
- http-short-vs-long-polling-vs-websockets-vs-sse
- mozilla:Using_server-sent_events
- mozilla:WebSocket
- wiki:websocket
- SSE教程
本文由mdnice多平台发布