一、WebSocket是什么

初次接触WebSocket,大家都会问:我们已经有了HTTP协议,为什么还需要WebSocket?
因为HTTP协议中通信只能由客户端发起,而WebSocket协议中服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,实现了浏览器与服务器全双工通信(full-duplex),WebSocket属于服务器推送技术的一种。
WebSocket是HTML5的一种新协议,它使用JavaScript调用浏览器的API发出一个WebSocket请求至服务器,复用HTTP的握手通道经过一次握手和服务器建立了TCP通讯,因为它本质上是一个TCP连接,所以数据传输的稳定性强和数据传输量比较小。

二、WebSocket的优势

  1. 建立在TCP协议之上,服务器端的实现比较容易
  2. 与HTTP协议有着良好的兼容性,默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易被屏蔽能通过各种HTTP代理服务器。
  3. 数据格式比较轻量,性能开销小。连接创建后,ws客户端和服务器端进行数据交换时,协议控制的数据包头部较小。在不包含头部的情况下,服务端到客户端的包头只有2~10字节(取决于数据包长度),客户端到服务端的的话,需要加上额外的4字节的掩码。而HTTP协议每次通信都需要携带完整的头部。
  4. 可以发送文本,也可以发送二进制数据。
  5. 没有同源限制,客户端可以与任意服务器通信。
  6. 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。
  7. 支持扩展。ws协议定义了扩展,用户可以扩展协议或者实现自定义的子协议。(比如支持自定义压缩算法等)

三、在.NET Core中利用WebSocket实现简易在线聊天室

因为WebSocket复用了HTTP的握手通道与服务器建立连接,所以WebSocket的握手就是一次http请求,因此我们就可以使用一个middleware来识别并拦截WebSocket请求,把客户端与服务器建立的WebSocket连接统一进行管理,其实微软已经帮我们简单的封装过了。

1、创建Core框架的Web项目

2、新建WebsocketClientCollection类对客户端与服务器建立的WebSocket连接进行统一管理 

public class WebsocketClientCollection
{
    private static List<WebsocketClient> _clients = new List<WebsocketClient>();

    public static void Add(WebsocketClient client)
    {
        _clients.Add(client);
    }

    public static void Remove(WebsocketClient client)
    {
        _clients.Remove(client);
    }

    public static WebsocketClient Get(string clientId)
    {
        var client = _clients.FirstOrDefault(c => c.Id == clientId);

        return client;
    }

    public static List<WebsocketClient> GetAll()
    {
        return _clients;
    }

    public static List<WebsocketClient> GetClientsByRoomNo(string roomNo)
    {
        var client = _clients.Where(c => c.RoomNo == roomNo);
        return client.ToList();
    }
}

3、新建WebsocketHandlerMiddleware并识别和接收WebSocket请求
WebsocketHandlerMiddleware就是我们管理WebSocket连接的入口,我们可以在Invoke()方法中先用context.WebSockets.IsWebSocketRequest来识别WebSocket请求,然后调用context.WebSockets.AcceptWebSocketAsync()方法把请求转换为WebSocket连接。 

public async Task Invoke(HttpContext context)
{
    if (context.Request.Path == "/ws")
    {
        //仅当网页执行new WebSocket("ws://localhost:5000/ws")时,后台会执行此逻辑
        if (context.WebSockets.IsWebSocketRequest)
        {
            //后台成功接收到连接请求并建立连接后,前台的webSocket.onopen = function (event){}才执行
            WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
            string clientId = Guid.NewGuid().ToString(); ;
            var wsClient = new WebsocketClient
            {
                Id = clientId,
                WebSocket = webSocket
            };
            try
            {
                await Handle(wsClient);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Echo websocket client {0} err .", clientId);
                await context.Response.WriteAsync("closed");
            }
        }
        else
        {
            context.Response.StatusCode = 404;
        }
    }
    else
    {
        await next(context);
    }
}

4、在Handle()方法中循环接收客户端发送到后台的消息

private async Task Handle(WebsocketClient websocketClient)
{
    WebsocketClientCollection.Add(websocketClient);
    logger.LogInformation($"Websocket client added.");

    WebSocketReceiveResult clientData = null;
    do
    {
        var buffer = new byte[1024 * 1];
        //客户端与服务器成功建立连接后,服务器会循环异步接收客户端发送的消息,收到消息后就会执行Handle(WebsocketClient websocketClient)中的do{}while;直到客户端断开连接
        //不同的客户端向服务器发送消息后台执行do{}while;时,websocketClient实参是不同的,它与客户端一一对应
        //同一个客户端向服务器多次发送消息后台执行do{}while;时,websocketClient实参是相同的
        clientData = await websocketClient.WebSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
        if (clientData.MessageType == WebSocketMessageType.Text && !clientData.CloseStatus.HasValue)
        {
            var msgString = Encoding.UTF8.GetString(buffer);
            logger.LogInformation($"Websocket client ReceiveAsync message {msgString}.");
            var message = JsonConvert.DeserializeObject<Message>(msgString);
            message.SendClientId = websocketClient.Id;
            HandleMessage(message);
        }
    } while (!clientData.CloseStatus.HasValue);
    //关掉使用WebSocket连接的网页/调用webSocket.close()后,与之对应的后台会跳出循环
    WebsocketClientCollection.Remove(websocketClient);
    logger.LogInformation($"Websocket client closed.");
}

5、在HandleMessage()方法中对客户端发送到后台的消息进行解析并处理,最后推送处理结果到客户端

private void HandleMessage(Message message)
{
    var client = WebsocketClientCollection.Get(message.SendClientId);
    switch (message.action)
    {
        case "join":
            client.RoomNo = message.roomNo;
            client.SendMessageAsync($"{message.nick} join room {client.RoomNo} success .");
            logger.LogInformation($"Websocket client {message.SendClientId} join room {client.RoomNo}.");
            break;
        case "send_to_room":
            if (string.IsNullOrEmpty(client.RoomNo))
            {
                break;
            }
            var clients = WebsocketClientCollection.GetClientsByRoomNo(client.RoomNo);
            clients.ForEach(c =>
            {
                c.SendMessageAsync(message.nick + " : " + message.msg);
            });
            logger.LogInformation($"Websocket client {message.SendClientId} send message {message.msg} to room {client.RoomNo}");
            break;
        case "leave":
            #region 通过把连接的RoomNo置空模拟关闭连接
            var roomNo = client.RoomNo;
            client.RoomNo = "";
            #endregion

            #region 后台关闭连接
            //client.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
            //WebsocketClientCollection.Remove(client); 
            #endregion

            client.SendMessageAsync($"{message.nick} leave room {roomNo} success .");
            logger.LogInformation($"Websocket client {message.SendClientId} leave room {roomNo}");
            break;
        default:
            break;
    }
}

6、在startup中配置中间件

app.UseWebSockets(new WebSocketOptions
{
    KeepAliveInterval = TimeSpan.FromSeconds(60),
    ReceiveBufferSize = 1 * 1024
});
app.UseMiddleware<WebsocketHandlerMiddleware>(); 

 7、修改index.cshtml来实现一个简单的聊天室UI

<div style="margin-bottom:5px;">
    room no: <input type="text" id="txtRoomNo" value="99999" /> 
    <button id="btnJoin">join room</button> 
    <button id="btnLeave">leave room</button> 
    <button id="btnDisConnect">DisConnect</button>
</div>
<div style="margin-bottom:5px;">
    nick name: <input type="text" id="txtNickName" value="batman" />
</div>
<div style="height:300px;width:600px">
    <textarea style="height:100%;width:100%" id="msgList"></textarea>
    <div style="text-align: right">
        <input type="text" id="txtMsg" value="" />  <button id="btnSend">send</button>
    </div>
</div>

8、使用JavaScript来处理WebSocket连接并与服务器进行通信
 现代浏览器已经都支持WebSocket协议,JavaScript运行时也内置了WebSocket类,我们仅仅需要new一个WebSocket对象出来就可以利用他与后台进行双工通信。

var server = "ws://localhost:5000";//若开启了https则这里是wss
var webSocket = new WebSocket(server + "/ws");
//前台向后台发送连接请求,后台成功接收并建立连接后才会触发此事件
webSocket.onopen = function (event) {
    console.log("Connection opened...");
    $("#msgList").val("WebSocket connection opened");
};

//后台向前台发送消息,前台成功接收后会触发此事件
webSocket.onmessage = function (event) {
    console.log("Received message: " + event.data);
    if (event.data) {
        var content = $('#msgList').val();
        content = content + '\r\n' + event.data;
        $('#msgList').val(content);
    }
};

//后台关闭连接后/前台关闭连接后都会触发此事件
webSocket.onclose = function (event) {
    console.log("Connection closed...");
    var content = $('#msgList').val();
    content = content + '\r\nWebSocket connection closed';
    $('#msgList').val(content);
};

$('#btnJoin').on('click', function () {
    var roomNo = $('#txtRoomNo').val();
    var nick = $('#txtNickName').val();
    if (!roomNo) {
        alert("请输入RoomNo");
        return;
    }
    var msg = {
        action: 'join',
        roomNo: roomNo,
        nick: nick
    };
    if (CheckWebSocketConnected(webSocket)) {
        webSocket.send(JSON.stringify(msg));
    }
});

$('#btnSend').on('click', function () {
    var message = $('#txtMsg').val();
    var nick = $('#txtNickName').val();
    if (!message) {
        alert("请输入发生的内容");
        return;
    }
    if (CheckWebSocketConnected(webSocket)) {
        webSocket.send(JSON.stringify({
            action: 'send_to_room',
            msg: message,
            nick: nick
        }));
    }
});

$('#btnLeave').on('click', function () {
    var nick = $('#txtNickName').val();
    var msg = {
        action: 'leave',
        roomNo: '',
        nick: nick
    };
    if (CheckWebSocketConnected(webSocket)) {
        webSocket.send(JSON.stringify(msg));
    }
});

$("#btnDisConnect").on("click", function () {
    if (CheckWebSocketConnected(webSocket)) {
        //部分浏览器调用close()方法关闭WebSocket时不支持传参
        //webSocket.close(001, "closeReason");
        webSocket.close();
    }
});

9、至此我们的聊天室已经搭建完成了,项目运行之后我们启动两个页面,进入相同的房间号就能聊天了 

四、常见问题解答

1、Error during WebSocket handshake: Unexpected response code: 404
当VS设置使用IIS Express启动,但IIS没安装WebSocket时,会出现这个错误,解决方法有两个:①IIS安装WebSocket,②设置为项目自托管启动。

 

本文借鉴了https://www.cnblogs.com/kklldog/p/core-for-websocket.html,在此基础上对代码做了完善并加上了自己的理解,如果觉得本文对您有帮助的话,请点赞、评论鼓励下,谢谢。