Hi,大家好,我是Payne,欢迎大家关注我的博客,我的博客地址是:https://qinyuanpei.github.io。今天这篇博客,我们来说说WebSocket。各位可能会疑惑,为什么我会突然间对WebSocket感兴趣,这是因为最近接触到了部分“实时”的业务场景,譬如:用户希望在远程视频通话过程中,实时地监控接入方的通话状态,实时地将接入方的响应时间、通话时长以及接通率等信息推送到后台。与此同时,用户可以通过监控平台看到实时变化着的图表。坦白地讲,这种业务场景陌生吗?不,每一年的双11,都能见到小伙伴们实时地“剁手”。所以,在今天这篇文章中,我们会以WebSocket聊天室为例,来讲解如何基于WebSocket构建实时应用。

WebSocket概述

  WebSocket是HTML5标准中的一部分,从Socket这个字眼我们就可以知道,这是一种网络通信协议。WebSocket是为了弥补HTTP协议的不足而产生的,我们知道,HTTP协议有一个重要的缺陷,即:请求只能由客户端发起。这是因为HTTP协议采用了经典的请求-响应模型,这就限制了服务端主动向客户端推送消息的可能。与此同时,HTTP协议是无状态的,这意味着连接在请求得到响应以后就关闭了,所以,每次请求都是独立的、上下文无关的请求。这种单向请求的特点,注定了客户端无法实时地获取服务端的状态变化,如果服务端的状态发生连续地变化,客户端就不得不通过“轮询”的方式来获知这种变化。毫无疑问,轮询的方式不仅效率低下,而且浪费网络资源,在这种背景下,WebSocket应运而生。

  WebSocket协议最早于2008年被提出,并于2011年成为国际标准。目前,主流的浏览器都已经提供了对WebSocket的支持。在WebSocket协议中,客户端和服务器之间只需要做一次握手操作,就可以在客户端和服务器之间实现双向通信,所以,WebSocket可以作为服务器推送的实现技术之一。因为它本身以HTTP协议为基础,所以对HTTP协议有着更好的兼容性,无论是通信效率还是传输的安全性都能得到保证。WebSocket没有同源限制,客户端可以和任意服务器端进行通信,因此具备通过一个单一连接来支持上下游通信的能力。从本质上来讲,WebSocket是一个在握手阶段使用HTTP协议的TCP/IP协议,换句话说,一旦握手成功,WebSocket就和HTTP协议再无瓜葛,下图展示了它与HTTP协议的区别:

HTTP与WebSocket的区别
HTTP与WebSocket的区别

构建一个聊天室

  OK,在对WebSocket有了一个基本的认识以后,接下来,我们以一个最简单的场景来体验下WebSocket。这个场景是什么呢?你已经知道了,答案就是网络聊天室。这是一个非常典型的实时场景。这里我们分为服务端实现和客户端实现,其中:服务端实现自豪地采用.NET Core,而客户端实现采用Vue的双向绑定特性。现在是公元2018年了,当jQuery已成往事,操作DOM这种事情交给框架去做就好,而且我本人很喜欢MVVM这种模式,Vue的渐进式框架,非常适合我这种不会写ES6的伪前端。

.NET Core与中间件

  关于.NET Core中对WebSocket的支持,这里主要参考了官方文档,在这篇文档中,演示了一个最基本的Echo示例,即服务端如何接收客户端消息并返回消息给客户端。这里,我们首先需要安装Microsoft.AspNetCore.WebSockets这个库,直接通过Visual Studio Code内置的终端安装即可。接下来,我们需要在Startup类的Configure方法中添加WebSocket中间件:

1
app.UseWebSockets()

更一般地,我们可以配置以下两个配置,其中,KeepAliveInterval表示向客户端发送Ping帧的时间间隔;ReceiveBufferSize表示接收数据的缓冲区大小:

1
2
3
4
5
6
var webSocketOptions = new WebSocketOptions()
{
KeepAliveInterval = TimeSpan.FromSeconds(120),
ReceiveBufferSize = 4 * 1024
};
app.UseWebSockets(webSocketOptions);

  好了,那么怎么接收一个来自客户端的请求呢?这里以官方文档中的示例代码为例来说明。首先,我们需要判断下请求的地址,这是客户端和服务端约定好的地址,默认为/,这里我们以/ws为例;接下来,我们需要判断当前的请求上下文是否为WebSocket请求,通过context.WebSockets.IsWebSocketRequest来判断。当这两个条件同时满足时,我们就可以通过context.WebSockets.AcceptWebSocketAsync()方法来得到WebSocket对象,这样就表示“握手”完成,这样我们就可以开始接收或者发送消息啦。

1
2
3
4
5
6
7
8
if (context.Request.Path == "/ws")
{
if (context.WebSockets.IsWebSocketRequest)
{
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
//TODO
}
});

  一旦建立了Socket连接,客户端和服务端之间就可以开始通信,这是我们从Socket中收获的经验,这个经验同样适用于WebSocket。这里分别给出WebSocket发送和接收消息的实现,并针对代码做简单的分析。

1
2
3
4
5
6
7
8
9
10
11
12
private async Task SendMessage<TEntity>(WebSocket webSocket, TEntity entity)
{
var Json = JsonConvert.SerializeObject(entity);
var bytes = Encoding.UTF8.GetBytes(Json);

await webSocket.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
true,
CancellationToken.None
);
}

  这里我们提供一个泛型方法,它负责对消息进行序列化并转化为byte[],最终调用SendAsync()方法发送消息。与之相对应地,客户端会在onmessage()回调中就会接受到消息,这一点我们放在后面再说。WebSocket接收消息的方式,和传统的Socket非常相似,我们需要将字节流循环读取到一个缓存区里,直至所有数据都被接收完。下面给出基本的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
var buffer = new ArraySegment<byte>(new byte[bufferSize]);
var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
while (!result.EndOfMessage)
{
result = await webSocket.ReceiveAsync(buffer, default(CancellationToken));
}

var json = Encoding.UTF8.GetString(buffer.Array);
json = json.Replace("\0", "").Trim();
return JsonConvert.DeserializeObject<TEntity>(json, new JsonSerializerSettings()
{
DateTimeZoneHandling = DateTimeZoneHandling.Local
});

  虽然不大清楚,为什么这里反序列化后的内容中会有大量的\0,以及这个全新的类型ArraySegment到底是个什么鬼,不过程序员的一生无非都在纠结这样两个问题,“it works” 和 “it doesn’t works”,就像人生里会让你纠结的无非是”她喜欢你“和”她不喜欢我“这样的问题。有时候,这样的问题简直就是玄学,五柳先生好读书而不求甚解,我想这个道理在这里同样适用,截止到我写这篇博客前,这个代码一直工作得很好,所以,这两个问题我们可以暂时先放在一边,因为眼下还有比这更为重要的事情。

  通过这篇文档,我们可以非常容易地构建出一个”实时应用“,可是它离我们这篇文章中的目标依然有点距离,如果各位足够细心的话,就会发现这样一个问题,即示例中的代码都是写在app.Use()方法中的,这样会使我们的Startup类显得臃肿,而熟悉OWIN或者ASP.NET Core的朋友,就会知道Startup类是一个非常重要的东西,我们通常会在这里配置相关的组件。在ASP.NET Core中,我们可以通过Configure()方法来为IApplicationBuilder增加相关组件,这种组件通常被称为中间件。那么,什么是中间件呢?

中间件示意图
中间件示意图

  从这张图中可以看出,中间件实际上是指在HTTP请求管道中处理请求和响应的组件,每个组件都可以决定是否要将请求传递给下一个组件,比如身份认证、日志记录就是最为常见的中间件。在ASP.NET Core中,我们通过app.Use()方法来定义一个Func<RequestDelegate,RequestDelegate>类型的参数,所以,我们可以简单地认为,在ASP.NET Core中,Func<RequestDelegate,RequestDelegate>就是一个中间件,而通过app.Use()方法,这些中间件会根据注册的先后顺序组成一个链表,每一个中间件的输入是上一个中间件的输出,每一个中间件的输出则会成为下一个中间件的输入。简而言之,每一个RequestDelegate对象不仅包含了自身对请求的处理,而且包含了后续中间件对请求的处理,我们来看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.Use(async (context,next)=>
{
await context.Response.WriteAsync("这是第一个中间件\r\n");
await next();
});

app.Use(async (context,next)=>
{
await context.Response.WriteAsync("这是第二个中间件\r\n");
await next();
});

app.Use(async (context,next)=>
{
await context.Response.WriteAsync("这是第三个中间件\r\n");
await next();
});

  通过Postman或者任意客户端发起请求,我们就可以得到下面的结果,现在想象一下,如果我们在第一种中间件中不调用next()会怎么样呢?答案是中间件之间的链路会被打断,这意味着后续的第二个、第三个中间件都不会被执行。什么时候我们会遇到这种场景呢?当我们的认证中间件认为一个请求非法的时候,此时我们不应该让用户访问后续的资源,所以直接返回403对该请求进行拦截。在大多数情况下,我们需要让请求随着中间件的链路传播下去,所以,对于每一个中间件来说,除了完成自身的处理逻辑以外,还至少需要调用一次next(),以保证下一个中间件会被调用,这其实和职责链模式非常相近,可以让数据在不同的处理管道中进行传播。

ASP.NET Core中间件示例
ASP.NET Core中间件示例

  OK,这里我们继续遵从这个约定,将整个聊天室相关的逻辑写到一个中间件里,这样做的好处是,我们可以将不同的WebSocket互相隔离开,同时可以为我们的Startup类”减负“。事实证明,这是一个正确的决定,在开发基于WebSocket的弹幕功能时,我们就是用这种方式开发了新的中间件。这里,我们给出的是WebSocketChat中间件中最为关键的部分,详细的代码我已经放在Github上啦,大家可以参考WebSocketChat类,其基本原理是:使用一个字典来存储每一个聊天室中的会话(Socket),当用户打开或者关闭一个WebSocket连接时,会向服务器端发送一个事件(Event),这样客户端中持有的用户列表将被更新,而根据发送的消息,可以决定这条消息是被发给指定联系人还是群发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public async Task Invoke(HttpContext context)
{
if (!IsWebSocket(context))
{
await _next.Invoke(context);
return;
}

var userName = context.Request.Query["username"].ToArray()[0];
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
while (webSocket.State == WebSocketState.Open)
{
var entity = await Receiveentity<MessageEntity>(webSocket);
switch (entity.Type)
{
case MessageType.Chat:
await HandleChat(webSocket, entity);
break;
case MessageType.Event:
await HandleEvent(webSocket, entity);
break;
}
}

await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Close", default(CancellationToken));
}

  其中,HandleEvent负责对事件进行处理,HandleChat负责对消息进行处理。当有用户加入聊天室的时候,首先会向所有客户端广播一条消息,告诉大家有新用户加入了聊天室,与此同时,为了让大家可以和新用户进行通信,必须将新的用户列表推送到客户端。同理,当有用户离开聊天室的时候,服务器端会有类似的事件推送到客户端。事件同样是基于消息来实现的,不过这两种采用的数据结构不同,具体大家可以通过源代码来了解。发送消息就非常简单啦,给指定用户发送消息是通过用户名来找WebSocket对象,而群发消息就是遍历字典中的所有WebSocket对象,这一点我们不再详细说啦!

Vue驱动的客户端

  在实现服务端的WebSocket以后,我们就可以着手客户端的开发啦!这里我们采用原生的WebSocket API来开发相关功能。具体来讲,我们只需要实例化一个WebSocket类,并设置相应地回调函数就可以了,我们一起来看下面的例子:

1
2
var username = "PayneQin"
var websocket = new WebSocket("ws://localhost:8002/ws?username=" + username);

  这里我们使用/s这个路由来访问WebSocket,相应地,在服务端代码中我们需要判断context.Request.Path,WebSocket在握手阶段是基于HTTP协议的,所以我们可以以QueryString的形式给后端传递一个参数,这里我们需要一个用户名,它将作为服务端存储WebSocket时的一个键。一旦建立了WebSocket,我们就可以通过回调函数来监听服务器端的响应,或者是发送消息给服务器端。主要的回调函数有onopen、onmessage、onerror和onclose四个,基本使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
websocket.onopen = function () {
console.log("WebSocket连接成功");
};

websocket.onmessage = function (event) {
console.log("接收到服务端消息:" + event.data)
};

websocket.onerror = function () {
console.log("WebSocket连接发生错误");
};

websocket.onclose = function () {
console.log("WebSocket连接关闭");
};

  原生的WebSocket API只有两个方法,即send()和close(),这两个方法非常的简单,我们这里不再说明。需要说明的是,客户端使用了Vue来做界面相关的绑定,作为一个不会写CSS、不会写ES6的伪前端,我做了一个相当简洁(简陋)的前端页面,下面给出主要的页面结构,ViewModel层的代码比较多,大家可以参考这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="app">
Hi,{{ username }}。欢迎来到WebSocket聊天室!
<hr/> 发送给:
<select v-model="sendTo">
<option value="All">全部</option>
<option v-for="user in userList" :value="user">{{user}}</option>
</select>
<hr/>
<input id="text" type="text" v-model="message" />
<button v-on:click="sendMessage">发送消息</button>
<hr/>
<button v-on:click="openWebSocket">打开WebSocket连接</button>
<button v-on:click="closeWebSocket">关闭WebSocket连接</button>
<button v-on:click="clearMessageList">清空聊天记录</button>
<hr/>
<div id="messageList" v-html="messageList">
{{ messageList }}
</div>
</div>

  下面是实际的运行效果,果然是非常简洁呢,哈哈:laughing:

WebSocket聊天室展示
WebSocket聊天室展示

再看Websocket

  好了,我们花了如此大的篇幅来讲WebSocket,那么你对WebSocket了解了多少呢?或许通过这个聊天室的实例,我们对WebSocket有了一个相对直观的认识,可你是否想过换一个角度来认识它呢?我们说过,WebSocket是以HTTP协议为基础的,那么至少可以在握手阶段捕获到相关请求吧!果断在Chrome中打开”开发者工具“,在面板上选择监听”WebSocket”,然后我们就会得到下面的内容。

WebSocket的秘密-请求
WebSocket的秘密-请求

  相比HTTP协议,WebSocket在握手阶段的请求有所变化,主要体现在Upgrade、Connection这两个字段,以及Sec-WebSocket系列的这些字段。下面来分别解释下这些字段的含义,Upgrade和Connection这两个字段,是最为关键的两个字段,它的目的是告诉Apache、Nginx这些服务器,这是一个WebSocket请求。接下来,是Sec-WebSocket-Key、Sec-WebSocket-Protocol和Sec-WebSocket-Version这三个字段,其中Sec-WebSocket-Key是一个由浏览器采用Base64算法随机生成的字符串,目的是验证服务器是否真的支持WebSocket;Sec-WebSocket-Protocol则是一个由用户指定的字符串,目的是区分同一URL下,不同服务所需要的协议;Sec-WebSocket-Version是告诉服务器浏览器支持的WebSocket版本,标准规定9-12的版本号是保留字段,所以在这里我们看到的版本号是13.

WebSocket的秘密-响应
WebSocket的秘密-响应

  那么,对于这个浏览器发起的这个请求,服务端是如何做出响应的呢?这就要来看看服务端返回的内容。 和客户端发起的请求类似,服务端返回的内容中依然会有Upgrade和Connection这两个字段,它们和请求中的含义是完全一致的。这里需要说明的是Sec-WebSocket-Accept这个字段,我们前面提到,浏览器会通过WebSocket-Key检验服务器是否真的支持WebSocket,具体怎么检验呢?是通过下面的算法。除此之外,一个特殊的地方是这个Response的状态码是101,这表示服务端说:下面我们就按照WebSocket协议来通信吧!当然,一个更为残酷的现实是,从这里开始,就不再是HTTP协议的势力范围了啊:

1
sec-websocket-accept = base64(hsa1(sec-websocket-key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11))

本文小结

  这篇文章选取了“实时应用”这样一个业务场景作为切入点,引出了本文的主题——WebSocket。WebSocket是一种建立在HTTP协议基础上的双向通信协议,它弥补了以“请求-响应”模型为基础的HTTP协议先天上的不足,客户端无需再通过“轮询”这种方式来获取服务端的状态变化。WebSocket在完成“握手”后,即可以长连接的方式在客户端和服务端间构建双向通道,因而WebSocket可以在实时应用场景下,作为服务器推送技术的一种方案选择。本文以一个WebSocket聊天室的案例,来讲解WebSocket在实际项目中的应用,在这里我们使用ASP.NET Core来完成服务端WebSocket的实现,而客户端选用原生WebSocket API和Vue来实现,在此基础上,我们讲解了ASP.NET Core下中间件的概念,并将服务器端WebSocket以中间件的形式实现。在下一篇文章中,我们将偏重于服务器端的数据推送,客户端将作为数据展现层而存在。好了,以上就是这篇文章的全部内容啦,谢谢大家,让我们一起期待下一篇文章吧!