0、引言
阅读提示:本文适合有一定网络通信技术基础的IM新手阅读。如果你对网络编程,以及IM的一些理论知识知之甚少,请务必首先阅读:《新手入门一篇就够:从零开发移动端IM》,按需补充相关知识。
配套源码:本文写的虽然有点浅显但涉及内容不少,建议结合代码一起来读,文章配套的完整源码 请从本文文末 “11、完整源码下载” 处下载!
本站的另几篇同类代码你可能也喜欢:
《自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)》《拿起键盘就是干:跟我一起徒手开发一套分布式IM系统》《适合新手:从零开发一个IM服务端(基于Netty,有完整源码)》
另外:本文作者的另一篇文章,有兴趣也可以关注一下:《12306抢票带来的启示:看我如何用Go实现百万QPS的秒杀系统(含源码)》。
本文已同步发布于“即时通讯技术圈”公众号.
1、正文概述
前阵子看了《创业时代》,电视剧的剧情大概是这样的:IT工程师郭鑫年与好友罗维与投行精英那蓝等人一起,踏上互联网创业之路。创业开发的是一款叫做“魔晶”的IM产品。郭鑫年在第一次创业失败后,离了婚,还欠了很多外债,骑着自行车经历了西藏一次生死诀别之后产生了灵感,想要创作一款IM产品“魔晶”,“魔晶”的初衷是为了增加人与人之间的感情。虽然剧情纯属虚构,但确实让人浮想QQ当初的设想是不是就是这样的呢?
有一点是可以确定的,即时通讯确实是一个时代的里程碑。腾讯的强大离不开两款产品:QQ和微信。这两款产品设计的思路是不一样的,QQ依托于IM系统,为了打造个人空间、全民娱乐而设计,我们常常会看到QQ被初高中生喜爱,QQ账号也往往与音乐、游戏绑定在一起;微信从QQ导流以后,主打商业领域,从刚开始推出微信支付与支付宝竞争,在商业支付领域占得了一席之地(微信支付主要被用户用于小额支付场景,支付宝主要用在企业大额转账、个人金融理财领域)以后。微信又相继推出了公众号、小程序,很明显在商业领域已经占据了支付宝的上风,成为了商业APP中的霸主,后来才有了聊天宝、多闪和马桶三大门派围攻微信的闹剧,结果大家可能都知道了......
阿里依托于IM系统进击办公领域,打造了“钉钉”。这又是一款比较精致的产品,其中打卡考勤、请假审批、会议管理都做的非常好,和微信不同的是,企业通过钉钉交流的信息,对方是能看到信息是否“已读”的(毕竟是办公,这个功能还是很有必要的)。腾讯也不甘示弱,创建“企业微信”,开始和“钉钉”正面交锋,虽然在市场份额上还是落后于钉钉,但用户增长很快。
企业微信于4月发布1.0版本,也只有简单的考勤、请假、报销等功能,在产品功能上略显平淡。彼时再看钉钉,凭借先发优势,初期就确定的产品线“讨好”老板,企业数100万,这个数量上升到700万,可见钉钉发展速度之快,稳固了钉钉在B端市场的地位。企业微信早期举棋不定的打法,也让它在企业OA办公上玩不过钉钉。但企业微信在发布3.0版本后,局面开始扭转,钉钉在用户数量上似乎已经饱和,难以有新的突破,而企业微信才真正开始逐渐占据市场。
依托于IM系统发展起来的企业还有陌陌、探探。相比较与微信来讲,它们的功能更集中于交友和情感。(不知道这是不是人家企业每年年终都人手一部iphone的原因,开个玩笑)
笔者今年参加了一次Gopher大会,有幸听探探的架构师分享了它们今年微服务化的过程,本文快速搭建的IM系统也是使用Go语言来快速实现的,这里先和各位分享一下探探APP的架构图:
以上讲了一些IM系统的产品方面不着边际的废话,下边我们回归主题,大概说一下本文的章节内容安排。
本文的目的是帮助读者较为深入的理解socket协议,并快速搭建一个高可用、可拓展的IM系统。同时帮助读者了解IM系统后续可以做哪些优化和改进。
本文的内容概述:
1)本文演示的IM系统包含基本的注册、登录、添加好友基础功能;2)提供单聊、群聊,并且支持发送文字、表情和图片,在搭建的系统上,读者可轻松的拓展语音、视频聊天、发红包等业务。2)为了帮助读者更清楚的理解IM系统的原理,第3节我会专门深入讲解一下websocket协议,websocket是长链接中比较常用的协议;3)然后第4节会讲解快速搭建IM系统的技巧和主要代码实现;4)在第5节笔者会对IM系统的架构升级和优化提出一些建议和思路;5)在最后章节做本文的回顾总结。2、相关文章
更多实践性代码参考:
《开源移动端IM技术框架MobileIMSDK》(* 推荐)《自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)》《适合新手:从零开发一个IM服务端(基于Netty,有完整源码)》《拿起键盘就是干:跟我一起徒手开发一套分布式IM系统》《一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)》《正确理解IM长连接的心跳及重连机制,并动手实现(有完整IM源码)》《手把手教你用Netty实现网络通信程序的心跳机制、断线重连机制》《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示 [附件下载]》《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示 [附件下载]》《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战 [附件下载]》《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战 [附件下载]》《一个WebSocket实时聊天室Demo:基于node.js+socket.io [附件下载]》
相关IM架构方面的文章:
《浅谈IM系统的架构设计》《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》《一套原创分布式即时通讯(IM)系统理论架构方案》《从零到卓越:京东客服即时通讯系统的技术架构演进历程》《蘑菇街即时通讯/IM服务器开发之架构选择》《一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践》
3、深入理解websocket协议
3.1 简介
WebSocket的目标是在一个单独的持久连接上提供全双工、双向通信。在Javascript创建了Web Socket之后,会有一个HTTP请求发送到浏览器以发起连接。在取得服务器响应后,建立的连接会将HTTP升级从HTTP协议交换为WebSocket协议。
由于WebSocket使用自定义的协议,所以URL模式也略有不同。未加密的连接不再是http://,而是ws://;加密的连接也不是https://,而是wss://。在使用WebSocket URL时,必须带着这个模式,因为将来还有可能支持其他的模式。
使用自定义协议而非HTTP协议的好处是,能够在客户端和服务器之间发送非常少量的数据,而不必担心HTTP那样字节级的开销。由于传递的数据包很小,所以WebSocket非常适合移动应用。
接下来的篇幅会对Web Sockets的细节实现进行深入的探索,本文接下来的四个小节不会涉及到大量的代码片段,但是会对相关的API和技术原理进行分析,相信大家读完下文之后再来看这段描述,会有一种豁然开朗的感觉。
即时通讯网有大量关于Web端即时通讯技术的文章,以下目录可供你系统地学习和了解。
Web即时通讯新手入门贴:
《新手入门贴:详解Web端即时通讯技术的原理》
Web端即时通讯技术盘点请参见:
《Web端即时通讯技术盘点:短轮询、Comet、Websocket、SSE》
有关Comet技术的详细介绍请参见:
《Comet技术详解:基于HTTP长连接的Web端实时通信技术》
《WEB端即时通讯:HTTP长连接、长轮询(long polling)详解》
《WEB端即时通讯:不用WebSocket也一样能搞定消息的即时性》
《开源Comet服务器iComet:支持百万并发的Web端即时通讯方案》
更多WebSocket的详细介绍请参见:
《新手快速入门:WebSocket简明教程》
《WebSocket详解(一):初步认识WebSocket技术》
《WebSocket详解(二):技术原理、代码演示和应用案例》
《WebSocket详解(三):深入WebSocket通信协议细节》
《WebSocket详解(四):刨根问底HTTP与WebSocket的关系(上篇)》
《WebSocket详解(五):刨根问底HTTP与WebSocket的关系(下篇)》
《WebSocket详解(六):刨根问底WebSocket与Socket的关系》
《理论联系实际:从零理解WebSocket的通信原理、协议格式、安全性》
《Socket.IO介绍:支持WebSocket、用于WEB端的即时通讯的框架》
《socket.io和websocket 之间是什么关系?有什么区别?》
有关SSE的详细介绍文章请参见:
《SSE技术详解:一种全新的HTML5服务器推送事件技术》
3.2 WebSocket复用了HTTP的握手通道
“握手通道”是HTTP协议中客户端和服务端通过"TCP三次握手"建立的通信通道。客户端和服务端使用HTTP协议进行的每次交互都需要先建立这样一条“通道”,然后通过这条通道进行通信。我们熟悉的ajax交互就是在这样一个通道上完成数据传输的,只不过ajax交互是短连接,在一次 request->response 之后,“通道”连接就断开了。
下面是HTTP协议中建立“握手通道”的过程示意图:
在Javascript创建了WebSocket之后,会有一个HTTP请求发送到浏览器以发起连接,然后服务端响应,这就是“握手“的过程。
在这个握手的过程当中,客户端和服务端主要做了两件事情:
1)建立了一条连接“握手通道”用于通信(这点和HTTP协议相同,不同的是HTTP协议完成数据交互后就释放了这条握手通道,这就是所谓的“短连接”,它的生命周期是一次数据交互的时间,通常是毫秒级别的);2)将HTTP协议升级到WebSocket协议,并复用HTTP协议的握手通道,从而建立一条持久连接。说到这里可能有人会问:HTTP协议为什么不复用自己的“握手通道”,而非要在每次进行数据交互的时候都通过TCP三次握手重新建立“握手通道”呢?
答案是这样的:虽然“长连接”在客户端和服务端交互的过程中省去了每次都建立“握手通道”的麻烦步骤,但是维持这样一条“长连接”是需要消耗服务器资源的,而在大多数情况下,这种资源的消耗又是不必要的,可以说HTTP标准的制定经过了深思熟虑的考量。到我们后边说到WebSocket协议数据帧时,大家可能就会明白,维持一条“长连接”服务端和客户端需要做的事情太多了。
说完了握手通道,我们再来看HTTP协议如何升级到WebSocket协议的。
3.3 HTTP协议升级为WebSocket协议
升级协议需要客户端和服务端交流,服务端怎么知道要将HTTP协议升级到WebSocket协议呢?它一定是接收到了客户端发送过来的某种信号。下面是我从谷歌浏览器中截取的“客户端发起协议升级请求的报文”,通过分析这段报文,我们能够得到有关WebSocket中协议升级的更多细节。
首先,客户端发起协议升级请求。采用的是标准的HTTP报文格式,且只支持GET方法。
下面是重点请求的首部的意义:
1)Connection:Upgrade:表示要升级的协议2)Upgrade: websocket:表示要升级到websocket协议3)Sec-WebSocket-Version: 13:表示websocket的版本4)Sec-WebSocket-Key:UdTUf90CC561cQXn4n5XRg== :与Response Header中的响应首部Sec-WebSocket-Accept: GZk41FJZSYY0CmsrZPGpUGRQzkY=是配套的,提供基本的防护,比如恶意的连接或者无意的连接。其中Connection就是我们前边提到的,客户端发送给服务端的信号,服务端接受到信号之后,才会对HTTP协议进行升级。
那么服务端怎样确认客户端发送过来的请求是否是合法的呢?
在客户端每次发起协议升级请求的时候都会产生一个唯一码:Sec-WebSocket-Key。服务端拿到这个码后,通过一个算法进行校验,然后通过Sec-WebSocket-Accept响应给客户端,客户端再对Sec-WebSocket-Accept进行校验来完成验证。
这个算法很简单:
1)将Sec-WebSocket-Key跟全局唯一的(GUID)标识:258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接;2)通过SHA1计算出摘要,并转成base64字符串。258EAFA5-E914-47DA-95CA-C5AB0DC85B11 这个字符串又叫“魔串",至于为什么要使用它作为Websocket握手计算中使用的字符串,这点我们无需关心,只需要知道它是RFC标准规定就可以了,官方的解析也只是简单的说此值不大可能被不明白WebSocket协议的网络终端使用。
我们还是用世界上最好的语言来描述一下这个算法吧:
public function dohandshake($sock, $data, $key) {if(preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $data, $match)) { $response= base64_encode(sha1($match[1] . 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, true)); $upgrade= "HTTP/1.1 101 Switching Protocol\r\n"."Upgrade: websocket\r\n"."Connection: Upgrade\r\n"."Sec-WebSocket-Accept: ". $response. "\r\n\r\n"; socket_write($sock, $upgrade, strlen($upgrade)); $this->isHand[$key] = true; } }
服务端响应客户端的头部信息和HTTP协议的格式是相同的,HTTP1.1协议是以换行符(\r\n)分割的,我们可以通过正则匹配解析出Sec-WebSocket-Accept的值,这和我们使用curl工具模拟get请求是一个道理。这样展示结果似乎不太直观,我们使用命令行CLI来根据上图中的Sec-WebSocket-Key和握手算法来计算一下服务端返回的Sec-WebSocket-Accept是否正确。
如下图所示:
从图中可以看到,通过算法算出来的base64字符串和Sec-WebSocket-Accept是一样的。那么假如服务端在握手的过程中返回一个错误的Sec-WebSocket-Accept字符串会怎么样呢?当然是客户端会报错,连接会建立失败,大家可以尝试一下,例如将全局唯一标识符258EAFA5-E914-47DA-95CA-C5AB0DC85B11改为258EAFA5-E914-47DA-95CA-C5AB0DC85B12。
3.4 WebSocket的帧和数据分片传输
下图是我做的一个测试:将小说《飘》的第一章内容复制成文本数据,通过客户端发送到服务端,然后服务端响应相同的信息完成了一次通信。
可以看到一篇足足有将近15000字节的数据在客户端和服务端完成通信只用了150ms的时间。
我们还可以看到浏览器控制台中frame栏中显示的客户端发送和服务端响应的文本数据,你一定惊讶WebSocket通信强大的数据传输能力。数据是否真的像frame中展示的那样客户端直接将一大篇文本数据发送到服务端,服务端接收到数据之后,再将一大篇文本数据返回给客户端呢?
这当然是不可能的,我们都知道HTTP协议是基于TCP实现的,HTTP发送数据也是分包转发的,就是将大数据根据报文形式分割成一小块一小块发送到服务端,服务端接收到客户端发送的报文后,再将小块的数据拼接组装。关于HTTP的分包策略,大家可以查看相关资料进行研究,websocket协议也是通过分片打包数据进行转发的,不过策略上和HTTP的分包不一样。
frame(帧)是websocket发送数据的基本单位,下边是它的报文格式:
报文内容中规定了:数据标示、操作代码、掩码、数据、数据长度等格式。
不太理解没关系,下面我通过讲解大家只要理解报文中重要标志的作用就可以了。
首先我们明白了客户端和服务端进行Websocket消息传递是这样的:
1)客户端:将消息切割成多个帧,并发送给服务端;2)服务端:接收消息帧,并将关联的帧重新组装成完整的消息。服务端在接收到客户端发送的帧消息的时候,将这些帧进行组装,它怎么知道何时数据组装完成的呢?
这就是报文中左上角FIN(占一个比特)存储的信息,1表示这是消息的最后一个分片(fragment)如果是0,表示不是消息的最后一个分片。
websocket通信中,客户端发送数据分片是有序的,这一点和HTTP不一样,HTTP将消息分包之后,是并发无序的发送给服务端的,包信息在数据中的位置则在HTTP报文中存储,而websocket仅仅需要一个FIN比特位就能保证将数据完整的发送到服务端。
接下来的RSV1,RSV2,RSV3三个比特位的作用又是什么呢?这三个标志位是留给客户端开发者和服务端开发者开发过程中协商进行拓展的,默认是0。拓展如何使用必须在握手的阶段就协商好,其实握手本身也是客户端和服务端的协商。
3.5 Websocket连接保持和心跳检测
Websocket是长连接,为了保持客户端和服务端的实时双向通信,需要确保客户端和服务端之间的TCP通道保持连接没有断开。
但是对于长时间没有数据往来的连接,如果依旧保持着,可能会浪费服务端资源。但是不排除有些场景,客户端和服务端虽然长时间没有数据往来,仍然需要保持连接。就比如说你几个月没有和一个QQ好友聊天了,突然有一天他发QQ消息告诉你他要结婚了,你还是能在第一时间收到。那是因为,客户端和服务端一直再采用心跳来检查连接。
客户端和服务端的心跳连接检测就像打乒乓球一样:
1)发送方->接收方:ping2)接收方->发送方:pong等什么时候没有ping、pong了,那么连接一定是存在问题了。
说了这么多,接下来我使用Go语言来实现一个心跳检测。
Websocket通信实现细节是一件繁琐的事情,直接使用开源的类库是比较不错的选择,我使用的是:gorilla/websocket。这个类库已经将websocket的实现细节(握手,数据解码)封装的很好啦。
如果觉得《适合新手:手把手教你用Go快速搭建高性能 可扩展的IM系统》对你有帮助,请点赞、收藏,并留下你的观点哦!