深入理解WebSocket协议

您所在的位置:网站首页 websocket用的什么协议 深入理解WebSocket协议

深入理解WebSocket协议

2023-12-11 19:08| 来源: 网络整理| 查看: 265

前言

一直以来对WebSocket仅停留在使用阶段,也没有深入理解其背后的原理。当看到 x x x was not upgraded to websocket,我是彻底蒙了,等我镇定下来,打开百度输入这行报错信息,随即看到的就是大家说的跨域,或者Origin导致,其实webSocket本身不存在跨域问题,由于目前在Flutter项目中遇到这个错误,接下来我也将从Flutter源码中寻找问题所在。在此,特别感谢公司后台同事在此问题上的指导和帮助。

什么是跨域

既然提到了跨域,我们还是先弄清什么是跨域。在了解什么是跨域的时候,我们首先要了解一个概念,叫同源策略,什么是同源策略呢,就是我我们的浏览器出于安全考虑,只允许与本域下的接口交互。不同源的客户端脚本在没有明确授权的情况下,不能读写对方的资源。

是什么意思呢,就比如你刚刚登录了淘宝买了东西,但是你现在又点进去了另外一个网站,那么你现在的淘宝账户是属于登录状态,而并没有登出,所以你现在点进去的这个网站可以看到你的账户信息,并操作你的账户信息,这样子就很危险。我们再来了解一个概念,就是本域,什么是本域呢?就是同协议,同域名,同端口就叫本域。从下面的图片我们可以清楚的了解到,什么是本域,以及什么时候跨域。再结合前面WebSocket问题,就明白了为什么不存在跨域问题,因为我们通过WebSocket的API new出的都是当前的单一实例,要访问其他域则需要单独new出实例。

图片

为什么需要WebSocket

通常我们客户端都是通过http向服务端发送request请求,然后得到response响应,也就是说http是一问一答的模式,这种模式对数据资源、数据加载足够够用,但是需要数据推送的场景就不合适了。我们知道Http2有Server push概念,那只是推资源用的,比如我们浏览器请求了html,服务端可以连带css资源一起推给浏览器,浏览器可以选择接不接收。对即时通讯要求较高的场景就需要用到WebSocket了。所以WebSocket本质上一种计算机网络协议,用来弥补Http协议在持久通信能力上的不足。它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

WebSocket使用

以Flutter平台为例,其它平台都大同小异,仅作为参考

import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/status.dart' as status; main() async { final wsUrl = Uri.parse('ws://localhost:1234') var channel = WebSocketChannel.connect(wsUrl); channel.stream.listen((message) { channel.sink.add('received!'); channel.sink.close(status.goingAway); }); } WebSocket connect发生了什么

在上面的例子当中,当我们调用了WebSocket的connect方法后,究竟会经过什么流程呢?为了直观,我先截我们公司前端ws链接情况,如下:

图片

图片

WebSocket严格意义上和http没什么关系,从上面的流程可以看出,首先是发出了get请求,然后返回101进行了一次协议切换,如下图:

图片

切换的过程是这样的:从上面截图中可以清楚的看到,请求的Request Headers携带有以下值:

Connection: Upgrade Upgrade: websocket Cookie: user=emh1aHVpdGFvX3N0cnVnZ2xlQDE2My5jb20%3D; password=emh1aHVpdGFvMTIz; JSESSIONID=node01l5dg6m63bgvvbu8il1jvkylo0.node0 Sec-WebSocket-Key: I3BB/MVp6YrFg65CFIndTg== Sec-WebSocket-Version: 13 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36

前面两个就是升级到Websocket协议的意思,第三个是Cookie,携带的是用户的账号密码以及SessionId等,针对这个属性后面单独展开来说,因为这里不注意就会遇到坑,第三个header是保证安全的一个key,Sec-WebSocket-Version就是版本的意思,根据查看flutter WebSocket部分源码,使用Postman以及结合浏览器发现这个应该是写死的都是13。服务端返回的Response Headers:

Connection: upgrade Date: Tue, 28 Feb 2023 16:07:47 GMT Sec-WebSocket-Accept: 1VYpY/UEy4+X03+a8zMw05SjCtw= Sec-WebSocket-Extensions: permessage-deflate Server: nginx/1.21.5 Upgrade: websocket

上面只是复制了部分Headers,更详细的可以参考上面的截图,我们发现无论是Request Headers还是Response Headers,有重合相同的部分:

请求: Connection: Upgrade Upgrade:websocket Sec-WebSocket-Key:I3BB/MVp6YrFg65CFIndTg== 响应: Connection:upgrade Upgrade:websocket Sec-WebSocket-Accept:1VYpY/UEy4+X0+a8zMw05SjCtw

Sec-WebSocket-Accept是对请求带过来的Sec-WebSocket-Key值处理后的结果,加入这个header就是为了确定对方一定有WebSocket能力,不然建立了链接,对方一直不回消息,那不就白等了么。Sec-WebSocket-Key经过什么处理得到了Sec-WebSocket-Accept?其实很简单,就是客户端传过来的key,加上一个固定的字符串,经过sha1加密之后转成base64的结果,这个固定的字符串为:

258EAFA5-E914-47DA-95CA-C5AB0DC85B11

如果你不信,可以自己动手实现一下,或者在网上搜下这个字符串:

图片

如果你到这里还不信,我们稍后看看Flutter源码是怎么实现的,你就会真的明白它就是这样。前面提到WebSocket链接先通过get请求,服务端返回状态码101进行协议切换,然后也提到Headers的处理过程,下面我们通过阅读Flutter WebSocket源码证实我们上面分析的是不是正确的。进入connect方法,跳转到io.dart文件

factory IOWebSocketChannel.connect( Object url, { Iterable? protocols, Map? headers, Duration? pingInterval, Duration? connectTimeout, }) { late IOWebSocketChannel channel; final sinkCompleter = WebSocketSinkCompleter(); var future = WebSocket.connect( url.toString(), headers: headers, protocols: protocols, ); if (connectTimeout != null) { future = future.timeout(connectTimeout); } final stream = StreamCompleter.fromFuture(future.then((webSocket) { webSocket.pingInterval = pingInterval; channel._webSocket = webSocket; channel._readyCompleter.complete(); sinkCompleter.setDestinationSink(_IOWebSocketSink(webSocket)); return webSocket; }).catchError((Object error, StackTrace stackTrace) { channel._readyCompleter.completeError(error, stackTrace); throw WebSocketChannelException.from(error); })); return channel = IOWebSocketChannel._withoutSocket(stream, sinkCompleter.sink); }

url就是我们要链接的WebSocket url,是必须传的,其它都是配置选项,Headers就是我们上面提到的Request Headers,我在项目中的配置大概是下面这样,可以和后台协商配置。

wss() async { Map headers = {}; ///升级到WebSocket协议     headers['Connection'] = 'Upgrade';     headers['Upgrade'] = 'websocket';     headers['Sec-WebSocket-Extensions'] = 'permessage-deflate; client_max_window_bits';     ///验证     headers['Cookie'] = 'JSESSIONID=node0j43bo45tn9e81wkdqf9dau7bx1877.node0';     print(headers);     _channel = await IOWebSocketChannel.connect(ApiConfig.wss, headers: headers);     _channel?.stream.listen((message) {       print(message);       print(json.decode(message)); Map map = json.decode(message); var result = map['positions']; if (result != null) { for (var value in (result as List)) {           maps[value['deviceId'].toString()] = value;         }       }     }); }

再看看WebSocket的connect方法中,直接调用了WebSocket实现类_WebSocketImp中的connect方法。

static Future connect( String url, Iterable? protocols, Map? headers, {CompressionOptions compression = CompressionOptions.compressionDefault, HttpClient? customClient}) { Uri uri = Uri.parse(url); if (!uri.isScheme("ws") && !uri.isScheme("wss")) { throw WebSocketException("Unsupported URL scheme '${uri.scheme}'"); } Random random = Random(); // Generate 16 random bytes. Uint8List nonceData = Uint8List(16); for (int i = 0; i < 16; i++) { nonceData[i] = random.nextInt(256); } String nonce = base64Encode(nonceData); final callerStackTrace = StackTrace.current; uri = Uri( scheme: uri.isScheme("wss") ? "https" : "http", userInfo: uri.userInfo, host: uri.host, port: uri.port, path: uri.path, query: uri.query, fragment: uri.fragment); return (customClient ?? _httpClient).openUrl("GET", uri).then((request) { if (uri.userInfo != null && uri.userInfo.isNotEmpty) { // If the URL contains user information use that for basic // authorization. String auth = base64Encode(utf8.encode(uri.userInfo)); request.headers.set(HttpHeaders.authorizationHeader, "Basic $auth"); } if (headers != null) { headers.forEach((field, value) => request.headers.add(field, value)); } // Setup the initial handshake. request.headers ..set(HttpHeaders.connectionHeader, "Upgrade") ..set(HttpHeaders.upgradeHeader, "websocket") ..set("Sec-WebSocket-Key", nonce) ..set("Cache-Control", "no-cache") ..set("Sec-WebSocket-Version", "13"); if (protocols != null) { request.headers.add("Sec-WebSocket-Protocol", protocols.toList()); } if (compression.enabled) { request.headers .add("Sec-WebSocket-Extensions", compression._createHeader()); } return request.close(); }).then((response) { Future error(String message) { // Flush data. response.detachSocket().then((socket) { socket.destroy(); }); return Future.error( WebSocketException(message), callerStackTrace); } var connectionHeader = response.headers[HttpHeaders.connectionHeader]; if (response.statusCode != HttpStatus.switchingProtocols || connectionHeader == null || !connectionHeader.any((value) => value.toLowerCase() == "upgrade") || response.headers.value(HttpHeaders.upgradeHeader)!.toLowerCase() != "websocket") { return error("Connection to '$uri' was not upgraded to websocket"); } String? accept = response.headers.value("Sec-WebSocket-Accept"); if (accept == null) { return error( "Response did not contain a 'Sec-WebSocket-Accept' header"); } _SHA1 sha1 = _SHA1(); sha1.add("$nonce$_webSocketGUID".codeUnits); List expectedAccept = sha1.close(); List receivedAccept = base64Decode(accept); if (expectedAccept.length != receivedAccept.length) { return error( "Response header 'Sec-WebSocket-Accept' is the wrong length"); } for (int i = 0; i < expectedAccept.length; i++) { if (expectedAccept[i] != receivedAccept[i]) { return error("Bad response 'Sec-WebSocket-Accept' header"); } } var protocol = response.headers.value('Sec-WebSocket-Protocol'); _WebSocketPerMessageDeflate? deflate = negotiateClientCompression(response, compression); return response.detachSocket().then((socket) => _WebSocketImpl._fromSocket( socket, protocol, compression, false, deflate)); }); }

上面的代码很长也很多,但是真的很重要,也很容易理解,接下来逐一分析上面每一行的功能和作用。

1,判断传入的url是否满足WebSocket url格式,如果不满足就会抛出异常。 Uri uri = Uri.parse(url); if (!uri.isScheme("ws") && !uri.isScheme("wss")) { throw WebSocketException("Unsupported URL scheme '${uri.scheme}'"); } 2,在前面我们一直强调的请求Headers中的key,也就是Sec-WebSocket_key是怎么生成的呢,过程也很简单。 Random random = Random(); // Generate 16 random bytes. Uint8List nonceData = Uint8List(16); for (int i = 0; i < 16; i++) { nonceData[i] = random.nextInt(256); } String nonce = base64Encode(nonceData); final callerStackTrace = StackTrace.current;

最后会把这个nonce,赋值给Sec-WebSocket-key。

3,Get请求,并携带Headers。 return (customClient ?? _httpClient).openUrl("GET", uri).then((request) { if (uri.userInfo != null && uri.userInfo.isNotEmpty) { // If the URL contains user information use that for basic // authorization. String auth = base64Encode(utf8.encode(uri.userInfo)); request.headers.set(HttpHeaders.authorizationHeader, "Basic $auth"); } if (headers != null) { headers.forEach((field, value) => request.headers.add(field, value)); } // Setup the initial handshake. request.headers ..set(HttpHeaders.connectionHeader, "Upgrade") ..set(HttpHeaders.upgradeHeader, "websocket") ..set("Sec-WebSocket-Key", nonce) ..set("Cache-Control", "no-cache") ..set("Sec-WebSocket-Version", "13"); if (protocols != null) { request.headers.add("Sec-WebSocket-Protocol", protocols.toList()); } if (compression.enabled) { request.headers .add("Sec-WebSocket-Extensions", compression._createHeader()); } return request.close();

在前面提到配置Headers,比如升级协议Upgrade,安全校验的Sec-WebSocket-key等,其实我们只需要配置与服务端协商好的key,其它别的是不用配置的,也就是底层会自动帮我们加上这些信息,前面也提到版本,这里可以看到就是写死的13。 

4,协议切换,前面分析过程中提到了通过Get请求返回状态码为101进行WebSocket协议切换,这个101在哪出现然后判断的呢,我们且看下面的代码。 var connectionHeader = response.headers[HttpHeaders.connectionHeader]; if (response.statusCode != HttpStatus.switchingProtocols || connectionHeader == null || !connectionHeader.any((value) => value.toLowerCase() == "upgrade") || response.headers.value(HttpHeaders.upgradeHeader)!.toLowerCase() != "websocket") { return error("Connection to '$uri' was not upgraded to websocket"); }

这里出现了response.statusCode和HttpStatus.switchingProtocols,HttpStatus.switchingProtocols这个值是什么呢,跟进去看发现就是常量101。

static const int switchingProtocols = 101;

看到这里,是不是和我们前面分析的一模一样。还有重要的一点,这里出现了Connection to '$uri' was not upgraded to websocket。也就是我们最开始提到的异常,其实这里太笼统。只要返回的状态码不为101或者接收的connectionHeader为null,都直接抛出了这个头疼的not upgraded to websocket,所以不了解的时候,就像我开头说的根本无从下手,针对此问题在后面我也会分析会出现这个异常的原因。其实看到这里,我们心里或许也知道是什么导致的了。

5,Sec-WebSocket-Accept的值是怎么来呢?答案就是Sec-WebSocket-key加上固定的字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11, 通过Sha1加密转Base64的来的。 _SHA1 sha1 = _SHA1(); sha1.add("$nonce$_webSocketGUID".codeUnits); List expectedAccept = sha1.close(); List receivedAccept = base64Decode(accept); if (expectedAccept.length != receivedAccept.length) { return error( "Response header 'Sec-WebSocket-Accept' is the wrong length"); } for (int i = 0; i < expectedAccept.length; i++) { if (expectedAccept[i] != receivedAccept[i]) { return error("Bad response 'Sec-WebSocket-Accept' header"); } }

这里的 sha1.add("$nonce$_webSocketGUID".codeUnits),nonce就是我们上面请求Headers中携带的Sec-WebSocket-key值,然后加上_webSocketGUID,_webSocketGUID就是前面一直提到的固定字符串。

const String _webSocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

看到这里我们是不是如释重负的感觉,完全和我们分析的一模一样,这里首先进行了长度比较,然后再判断每一个字符是不是相等。

//长度比较 expectedAccept.length != receivedAccept.length //循环遍历每个字符 for (int i = 0; i < expectedAccept.length; i++) { if (expectedAccept[i] != receivedAccept[i]) { eturn error("Bad response 'Sec-WebSocket-Accept' header"); } }

通过上面的异常,比较之前的101状态码,还是可以接受一些,最起码告诉我们Headers有问题,这样我们找问题也就更直接了。

6,Request Headers中的Cookie

当然从上面的源码中也未发现这个东西。Cookie的概念其实在app中用的很少,至少在我开发过的项目里基本是没用到的,但是在浏览器中很常见。Cookie可以携带用户信息,如下面代码所示。

headers['Cookie'] = 'JSESSIONID=node0j43bo45tn9e81wkdqf9dau7bx1877.node0';

回到最开始项目中遇到的问题,就是Cookie导致的,由于我最开始不知道要携带这个Cookie导致服务器无法校验通过,也就无法成功返回101状态码,自然就无法成功切换到WebSocket协议了,JSESSIONID的值从哪获取呢,其实也是从Headers获取,一般我们调用登录接口,服务端返回的Response Header中就会携带这个值。

if (response.headers['set-cookie'] != null && esponse.headers['set-cookie']!.isNotEmpty) { DataCenter.COOKIE = response.headers['set-cookie']!.first; }

当我们拿到这个值的时候,保存下来,等链接wss的业务的时候取出来赋值给Cookie,就可以了。

总结

通过过上面的分析,我们知道了WebSocket整个链接过程,以及是如何从Http切换到WebSocke协议的,以及分析了出现异常的原因,这里大致总结一下出现异常大概有以下问题导致:

1,服务端WebSocket本身就有问题(可以在postman上调试排查问题)

2,url有问题,不是正常的url

3,Headers问题,最有可能的就是Cookie,出现问题后我们应该立马和后台同事沟通,问清楚他们到底需要什么值,沟通清楚再实现。

至此我们分析了WebSocket的整个链接和切换流程,如果你觉得写的不错,欢迎点个关注,感谢🙏。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3