一、 WebSocket 介绍
1.1 WebSocket 的诞生背景
在网络冲浪中,我们接触到最多的协议必定是 HTTP/HTTPS 协议,这两种协议的工作原理可简述为:客户端通过浏览器发送一个请求,服务器在接受到请求后进行处理并将得到的结果返回给客户端,由客户端处理结果。可见其主要为一种 “拉取” 信息的形式。
随着时代的发展,出现了一些需要实时发送信息的场景,比如体育实况更新、金融证券的实时信息、实时数据监控等。而如何实现 “推送” 信息的形式呢?在 WebSocket 还未诞生的时候,采用的是轮询技术来实现信息的推送:每间隔一定的时间,浏览器自动发送一个 HTTP 请求,以此主动拉取服务器的最新消息。使用轮询技术,需要不停向服务器发送 HTTP 请求,这样会占用很多的带宽和服务器资源,并且还是不能实现服务器主动向客户端推送数据。
在上述背景下,一种全双工的通信协议 WebSocket 应运而生,它实现了服务端主动向客户端推送数据,使得客户端与服务器间的信息交互更为便捷。
1.2 WebSocket 的概念
WebSocket 是一种基于 TCP 的网络通信协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 7 层模型的应用层。WebSocket 使用 ws 或 wss 的统一资源标志符(URI),例如:ws://localhost:8080/test, 其中 wss 表示基于 TLS 的 WebSocket。默认情况下 WebSocket 协议使用 80 端口;若运行在 TLS 之上时,则默认使用 443 端口。
1.3 WebSocket 与 HTTP 的关系
-
WebSocket 与 HTTP 协议一样都是基于 TCP 的,二者均为可靠的通信协议。
-
WebSocket 与 HTTP 协议均为应用层协议,但 WebSocket 是一种独立于 HTTP 的协议。
-
WebSocket 在建立握手连接时,数据是通过 HTTP 协议传输的。
-
WebSocket 建立好连接后,真正通信阶段的数据传输不依赖于 HTTP 协议。
1.4 WebSocket 与 HTTP 轮询技术的对比
HTTP 实现实时 “推送” 用到的轮询技术主要分为两种:短轮询与长轮询。
短轮询:客户端每间隔特定时间向服务器发送 HTTP 请求拉取数据,而服务器收到请求后不管是否有新的数据信息都直接响应请求。可见短轮询有如下缺点:不必要请求过多,浪费带宽与服务器资源;并不能获得真正实时信息,除非服务器数据更新的间隔时间固定且等于设置的轮询间隔时间,否则服务器响应相对于数据更新必定存在一定的延迟时间。
长轮询:客户端向服务器发起 HTTP 请求后,服务器一直保持连接打开并阻塞,直到服务器有新数据更新并可以发送,或者等待一定时间直至超时后才会返回。收到服务器的响应后浏览器关闭连接,随即又向服务器发起一个新的请求。上述过程在浏览器页面打开期间一直持续不断。长轮询虽然减少了请求量,并且不再存在延时;但在未有数据更新时会长时间霸占服务器资源,造成服务器资源浪费,实时交互能力也不够强。
WebSocket 是一种 TCP 长连接通讯模式。在 WebSocket 连接建立后,数据都以帧序列的形式传输。在客户端断开 WebSocket 连接或服务端中断连接前,不需要客户端和服务端重新发起连接请求。在海量并发及客户端与服务器交互负载流量大的情况下,极大的节省了网络带宽资源的消耗,有明显的性能优势。由于是全双工模式,接收与发送均在同一信道进行,服务器可以在数据更新时主动推送消息给客户端,实时性好。
1.5 WebSocket 与 HTTP2.0 对比
-
WebSocket 是直接从服务器将数据推送给 Web App 的。
-
HTTP2.0 所支持的 Server Push 是主动将资源推送到客户端缓存,并不允许将数据推送到客户端的 Web App 。例如,在浏览器请求某个 html 时,除了将 html 响应给客户端,还将 css、png 等网页所需的全部资源文件推送给浏览器。Server Push 只能由浏览器处理,不会在应用程序代码中弹出服务器数据,因此应用程序没有 API 来获取这些事件的通知。
1.6 WebSocket 的帧
WebSocket 使用了自定义的二进制分帧格式,将每个应用消息切分成一个或多个帧,对端等到接收到完整的消息后再进行组装与处理。
-
FIN :1 bit 表示消息结束标志位。0 表示还有后续帧, 1 表示最后一帧。一个消息可能拆分成多个帧,接收方判断为最后一帧后将前面的帧拼接组成消息。
-
RSV1 、 RSV2 、 RSV3 :1 bit 保留字段,除非一个扩展经过协商赋予了非零值的某种含义,否则必须为 0。
-
opcode :4 bit 解释 payload data 的类型。如果收到识别不了的 opcode,会直接断开。0 表示连续的帧; 1 表示 text(纯文本)帧; 2 表示 binary(二进制)帧 ; 8 表示 close(关闭连接)帧; 9 表示 ping 帧 ;10 表示 pong 帧;其余为非控制帧而预留。ping/pong 类型帧是为了在长时间无消息通信时,检测连接是否断开,目前只能由服务器发 ping 给浏览器,浏览器返回 pong 消息。
-
MASK :1 bit 标识 Payload data 是否经过掩码处理,如果是 1,Masking-key 域的数据即为掩码密钥,用于解码 Payload data。在标准规定,客户端发送数据必须使用掩码,而服务器发送则一定不使用掩码。
-
Payload len :7 bit | 7+16 bit | 7+64 bit 表示了 “有效负荷数据 Payload data” 的长度:(1)如果是 0~125,那么就直接表示了 payload 长度 (2) 如果是 126,那么接下来的两个字节表示的 16 位无符号整型数的值就是 payload 长度 (3)如果是 127,那么接下来的八个字节表示的 64 位无符号整型数的值就是 payload 长度
-
Masking-key :0 | 4 bytes 掩码密钥。所有从客户端发送到服务端的帧都包含一个 32bits 的掩码(如果 mask 被设置成 1),否则为 0。一旦掩码被设置,所有接收到的 payload data 都必须与该值以一种算法做异或运算来获取真实值。
-
Payload data :(x+y) bytes 它是 Extension data 和 Application data 数据的总和,但是一般扩展数据为空。
-
Extension data :x bytes 除非扩展被定义,否则就是 0
-
Application data :y bytes 占据 Extension data 后面的所有空间
1.7 Websocket 建立连接的步骤
首先客户端与服务器建立 TCP 连接,进行三次握手。这发生于传输层,是网络通信的基础,如果失败则后续步骤将不再执行。
当 TCP 连接成功后,进行 HTTP 的通信握手。
客户端通过 HTTP 协议向服务器传送带有 WebSocket 支持的版本号等信息的握手请求。
服务器收到客户端的握手请求后,同样采用 HTTP 协议返回应答消息。
当客户端收到了连接成功的应答消息之后,持续通过 TCP 通道进行传输通信。
也就是说 WebSocket 在建立握手时,连接信息是通过 HTTP 传输的。但是建立之后,在真正传输通信数据时候是不需要 HTTP 协议的。
一次 WebSocket 的握手请求与应答的典型报文如下:
WebSocket 浏览器(客户端)连接报文
GET /websocket/ HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xqBt3ImNzJbYqRINxEFlkg==
Origin: http://localhost:8080
Sec-WebSocket-Version: 13
从上述报文中可以看出,浏览器(客户端)发起的 WebSocket 连接报文与传统 HTTP 报文类似。值得注意的是,从第一行中可见,连接报文一定是 GET 方法,且必须基于 HTTP/1.1。
第三行中,Upgrade:websocket 参数值表明这是 WebSocket 类型的请求。
第四行中,Connection: Upgrade 表明本次通信要对 HTTP 协议进行升级。结合三四行,即表明本次通信需要将 HTTP 协议升级至 WebSocket 协议。
为了防止普通的 HTTP 消息被 “意外” 识别成 WebSocket,握手消息还增加了两个额外的认证用途的字段。
Sec-WebSocket-Key 是 WebSocket 客户端发送的一个 base64 编码的密文,要求服务端必须返回一个对应加密的 Sec-WebSocket-Accept 应答,否则客户端会抛出 “Error during WebSocket handshake” 错误,并关闭连接。
Sec-WebSocket-Version:协议的版本号,当前必须是 13 。
服务器收到 HTTP 请求报文,看到上述 4 个特殊字段,知道这是 WebSocket 的升级请求,于是就不执行普通的 HTTP 处理流程,而是构造一个特殊的应答报文:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept:K7DJLdLooIwIG/MOpvWFB3y3FE8=
HTTP/1.1 101 Switching Protocols,101 为升级成功的返回码,表示服务器接受 WebSocket 协议的客户端连接,双方握手成功,之后切换为 WebSocket 协议进行通信。
Sec-WebSocket-Accept 的值是服务端采用与客户端一致的密钥计算出来后返回客户端的,该字段是为了验证客户端请求报文,防止误连接。
二、WebSocket 的 Golang 实现
开源社区中有几个比较好的 Golang 库,本文选择基于 gorilla/websocket 进行构建 WebSocket服务。
一个简单的demo:
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func handler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
for {
messageType, p, err := conn.ReadMessage()
if err != nil {
log.Println(err)
return
}
if err := conn.WriteMessage(messageType, p); err != nil {
log.Println(err)
return
}
}
}
首先,需要初始化一个 Upgrader 对象
type Upgrader struct {
HandshakeTimeout time.Duration //握手超时时间
ReadBufferSize, WriteBufferSize int //读、写缓冲区大小(默认4096字节)
WriteBufferPool BufferPool //写缓冲区池
Subprotocols []string //子协议
Error func(w http.ResponseWriter, r *http.Request, status int, reason error) //错误函数
CheckOrigin func(r *http.Request) bool //校验函数
EnableCompression bool //是否压缩
}
接着,调用 func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Conn, error) 函数,将 HTTP 协议升级到 WebSocket 协议,即构造握手请求中服务器响应的过程。升级成功会返回一个 Conn 对象,此后将用 Conn 中的方法去进行数据通信。
func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Conn, error) {
...
c := newConn(netConn, true, u.ReadBufferSize, u.WriteBufferSize, u.WriteBufferPool, br, writeBuf)
...
p := buf
if len(c.writeBuf) > len(p) {
p = c.writeBuf
}
p = p[:0]
p = append(p, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "...)
...
p = append(p, "\r\n"...)
// Clear deadlines set by HTTP server. netConn.SetDeadline(time.Time{})
if u.HandshakeTimeout > 0 {
netConn.SetWriteDeadline(time.Now().Add(u.HandshakeTimeout))
}
if _, err = netConn.Write(p); err != nil {
netConn.Close()
return nil, err
}
if u.HandshakeTimeout > 0 {
netConn.SetWriteDeadline(time.Time{})
}
return c, nil
}
读取消息可以使用 func (c *Conn) ReadMessage() (messageType int, p []byte, err error) 方法,它会从读缓冲区中读取消息并返回,同时也返回消息类型 (Text, Binary, Close, Ping and Pong)。该函数会阻塞线程。
发送消息可以使用 func (c *Conn) WriteMessage(messageType int, data []byte) error 方法,它会向写缓冲区中写入消息。
除上述两个读写通用接口外,Conn 还提供了 func (c *Conn) ReadJSON(v interface{}) error 与 func (c *Conn) WriteJSON(v interface{}) error 两个接口,方便收发 json 消息。
该库中还提供了 Dialer 结构体和 func (d *Dialer) Dial(urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) 方法,实现了作为客户端拨号连接至对应的 WebSocket 服务器。
u := url.URL{Scheme: "ws", Host: "10.0.0.1:6666", Path: /WS, RawQuery: ""}
ws_c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
三、一个完整的示例
package main
import (
"flag"
"html/template"
"log"
"net/http"
"github.com/gorilla/websocket"
)
var addr = flag.String("addr", "localhost:8080", "http service address")
var upgrader = websocket.Upgrader{} // use default options
func echo(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("upgrade:", err)
return
}
defer c.Close()
for {
mt, message, err := c.ReadMessage()
if err != nil {
log.Println("read:", err)
break
}
log.Printf("recv: %s", message)
err = c.WriteMessage(mt, message)
if err != nil {
log.Println("write:", err)
break
}
}
}
func home(w http.ResponseWriter, r *http.Request) {
homeTemplate.Execute(w, "ws://"+r.Host+"/echo")
}
func main() {
flag.Parse()
log.SetFlags(0)
http.HandleFunc("/echo", echo)
http.HandleFunc("/", home)
log.Fatal(http.ListenAndServe(*addr, nil))
}
var homeTemplate = template.Must(template.New("").Parse(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script>
window.addEventListener("load", function(evt) {
var output = document.getElementById("output");
var input = document.getElementById("input");
var ws;
var print = function(message) {
var d = document.createElement("div");
d.textContent = message;
output.appendChild(d);
output.scroll(0, output.scrollHeight);
};
document.getElementById("open").onclick = function(evt) {
if (ws) {
return false;
}
ws = new WebSocket("{{.}}");
ws.onopen = function(evt) {
print("OPEN");
}
ws.onclose = function(evt) {
print("CLOSE");
ws = null;
}
ws.onmessage = function(evt) {
print("RESPONSE: " + evt.data);
}
ws.onerror = function(evt) {
print("ERROR: " + evt.data);
}
return false;
};
document.getElementById("send").onclick = function(evt) {
if (!ws) {
return false;
}
print("SEND: " + input.value);
ws.send(input.value);
return false;
};
document.getElementById("close").onclick = function(evt) {
if (!ws) {
return false;
}
ws.close();
return false;
};
});
</script>
</head>
<body>
<table>
<tr><td valign="top" width="50%">
<p>Click "Open" to create a connection to the server,
"Send" to send a message to the server and "Close" to close the connection.
You can change the message and send multiple times.
<p>
<form>
<button id="open">Open</button>
<button id="close">Close</button>
<p><input id="input" type="text" value="Hello world!">
<button id="send">Send</button>
</form>
</td><td valign="top" width="50%">
<div id="output" style="max-height: 70vh;overflow-y: scroll;"></div>
</td></tr></table>
</body>
</html>
`))
上面代码是官方的 WebSocket 示例,内部包含了一个简单的 HTTP Client端,当我们正常运行时,访问 http://127.0.0.1:8080 将会有如下页面。
当我们开始通过网页往服务端发送 WebSocket 消息时,会实时显示消息状态。