即时通讯软件开发 课程简介用习惯了微信的你,还记得当初的 QQ 吗?曾几何时,你是否也在梦想自己也能写出一个像 QQ 一样牛气的即时通讯软件?即使你不曾有过这个“野心”,你肯定也对 QQ 的实现原理感到好奇过,对吧?本达人课即将带您一探 QQ 此类 IM 软件背后的诸多实现细节。
此达人课涵盖了网络编程、设计模式、通信协议等基础知识,基于套接字(socket)技术,实现了一个基于控制台的即时通讯软件(IM)。能够进行文本聊天、文件传送、发送表情等。支持服务器并发、内网穿透;当内网穿透失败时,允许服务器转发消息。
通过实现这样一个简单的 IM 软件,帮助读者消除 Socket 编程过程中的误区和困惑,更加深入的理解 TCP/IP 协议原理。另外,在现在这个年头,不把“高并发”挂在嘴上,都不好意思开口说话。高并发确实有着一定的门槛,但也并不是高不可攀,只是需要我们付出努力去学习、去实践,要知道,经验非常重要。我们的这个 IM 软件涉及到内网穿透(NAT 穿透、“打洞”)、服务器并发、心跳包检测等,这些技术对于网络应用都十分重要,想要深入网络编程的同学千万不能错过。
本达人课共包含以下四部分:
第一部分(第01课),作为开篇,对本项目做了一个整体的介绍,并对 IM 开发需要用到的知识进行概述;
第二部分(第02课),从基本原理层面,详细阐释了开发一个即时通讯软件需要理解和掌握的必备技能;
第三部分(第03-07课),从代码层面,给出了本项目主要部分的具体程序实现,便于读者较好的了解细节;
第四部分(第08课),作为总结,阐释了网络编程过程中常踩到的“坑”,希望能帮助读者在后续的 Socket 开发生涯中,少走一些弯路。
主要涵盖的技术点有:
Socket 编程
服务端并发
同步/异步、阻塞/非阻塞等 I/O 模型
内网穿透及 P2P 通信
心跳包检测机制
应用层通信协议设计
TCP/IP 协议栈原理
作者介绍汪磊,自由开发者,CSDN 博客作者,毕业于211,九年老司机。错上贼船已悟道,遂深耕于后端,前端略懂皮毛。丰富的项目经验,用代码诠释世界。 课程内容导读:功能概览引言用习惯了微信的你,还记得当初的 QQ 吗?曾几何时,你是否也在梦想自己也能写出一个像 QQ 一样牛掰的即时通讯软件?即使你不曾有过这个“野心”,你肯定也对 QQ 的实现原理感到好奇过,对吧?有人可能会说,“我从来没有好奇过”,好吧,我承认,你的这个回答只能说明两种可能,你是大神,或者你根本不是程序员!
记得当初我还是一个“懵懂少年”的时候,用 .NET 的 Remoting 技术写了一个及其丑陋的小聊天工具,知其然不知其所以然,踩了无数的坑,到最后不了了之。现在回想起来,总结为一句话,“基础不牢、地动山摇”。那时候,我对 TCP/IP、Socket 等一窍不通,正所谓“初生牛犊不怕虎”。
后来,一个偶然的机会,我接触到了《HTTP 权威指南》一书,进而找到《TCP/IP 详解卷》这本“圣经”级读物,从而一发不可收拾,开始了对网络底层原理的探究历程。如今,已是而立之年,岁月洗去了身上的浮躁,懂得静下心来好好沉淀一下自己的知识体系。回首当初自己一个又一个的“作品”,尽管散发着青涩,却记载着我的青春。
好了,瞎聊了这么多,我们言归正传吧。
在网络极其发达的今天,无论是 PC 端软件,还是移动端 APP,几乎都有联网功能。移动端诸如微信、支付宝、美团、京东及各种手游,PC 端诸如各种关系数据库(mysql、MSSQL、oracle)、高速缓存(Redis、memcached)、网站及 Web 浏览器、QQ 及各种网游,都以网络通信为基础。甚至 windows 下的远程桌面、网络邻居、共享文件夹以及使用 SSH 登录 linux,本质上也是通过 Socket 进行的,只不过设计了各自的通信协议而已,有兴趣的朋友可以通过 Wireshark 等工具亲自进行抓包,看看其交互过程。再比如,就是我们平常上网用到的 Web 浏览器(比如 IE、360、搜狗等),只不过是利用 Socket 同 Web 服务器通过 HTTP 协议进行了一种“请求/响应”操作,浏览器向服务器发出对某个 URL 的请求,然后服务器发回 HTML 形式的响应,浏览器再对 HTML 进行解析渲染。其实仔细想想,上面列举的这些司空见惯的软件,说到底不就是一些 Socket 操作吗?
然而,Socket 看似简单,但真正想把它用好却不简单,Socket 编程是出了名的“坑”多,相信有过 Socket 编程经历的朋友都有此感受。Socket 究竟是什么呢?说白了,它只不过是操作系统给开发人员提供的一个进行网络操作的接口,通过 Socket,我们可以和操作系统内核中的 TCP/IP 协议栈进行交互,从而实现网络信息的收发。这就涉及到 TCP/IP 协议族,这可是一个极度复杂的知识汪洋,值得你深入研究。
本课以 C# 为语言平台,阐述了如何实现一个基于控制台的即时通信软件,也就是常说的 IM。透过即时通讯工具的表象,探究其背后的网络通信基本原理,澄清关于 Socket 操作的一些细节和常见误区,让读者对 TCP/IP 协议栈的实现原理及其应用有更为深刻的理解。
另外,现在这个年头,不把“高并发”挂载嘴上,都不好意思开口说话。高并发确实有着一定的门槛,但也并不是高不可攀,只是需要我们付出努力去学习、去实践,要知道,经验非常重要。我们的这个 IM 软件涉及到内网穿透(NAT 穿透、“打洞”)、服务器并发、心跳包检测等,这些技术对于网络应用都是十分重要的,想要深入网络编程的同学千万不能错过。
功能概览 会当凌绝顶,一览众山小 为了专注于业务功能的实现,避免 UI 逻辑分散我们的注意力,我们的这款 IM 软件采用 Windows 控制台的形式。项目总体上包括服务器和客户端两个相互独立的部分,是一个典型的 C/S 结构。见下面的图1和图2所示。
怎么样,黑色背景配上绿色字体,很有科技感吧?有没有黑客帝国的感觉?呵呵!基于控制台实现的聊天程序在用户体验方面和窗口程序比起来显得比较 Low,不过基本原理是一样的,你完全可以写成 WinForm 形式。
图1 IM 服务器
图2 IM客户端 服务器端服务器作为各个客户端进行通信的枢纽及中介,主要作用包括:处理用户登录注册及退出等请求、维护用户信息、好友上线和下线通知、检测用户在线状态、辅助内网穿透以实现 P2P 通信、内网穿透失败情况下的消息中转、分发表情包等。
服务器端启动以后,会监听来自各个客户端的连接请求(登录、注册、注销、请求好友信息、内网穿透协助等),并根据请求类型分别返回合适的响应(见图1)。当有用户上线或下线时,服务器端会监听到该动作,并通知该用户的所有好友,以更新相应客户端的好友列表。
服务器的一个重要功能是,检测用户是否在线。有人会说了,这还不简单,客户端下线时向服务器发送一条消息,通知服务器“我要下线了”。没错,在客户端正常退出的情况下,这种方法行之有效,但如果客户端的下线是由于电脑死机、断网等突发事故造成的呢?客户端还来不及向服务器发送下线通知,就已经 Game Over 了。所以,服务器要采取合理策略,以应对客户端异常的连接中断。
客户端对于一个 IM 软件来说,客户端是普通用户接触最多的,其核心作用当然就是好友之间的聊天了,当然还包括一些辅助功能,如:用户的注册登录及退出、添加好友、查看好友列表、传送文件等。
在我们的 IM 中,双方只有互为好友才能聊天。客户端 A 可以向服务器 S 发出添加某个好友 B 的请求,服务器负责把该请求转达给好友 B,好友 B 同意后,二者即建立起好友关系。已登录的客户端可以从服务器获取自己的好友列表,以及哪些好友在线、哪些不在线。
出于简单考虑,本系统目前只支持文本形式的聊天会话。至于语音聊天、视频聊天,基本原理是一样的,有兴趣的朋友可以自己加以实现。我们还实现了发送表情的功能,当然,这里的表情指的是字符图案,而不是大家平时用 QQ、微信之类的可视化表情,毕竟是控制台程序嘛,要求不要太高!此外,还实现了表情包在线更新功能,当客户端连接到服务器时,服务器会自动向客户端推送最新的表情包,之后客户端便可以使用最新的表情了。用户可以查阅自己和其他好友的聊天记录,至于聊天记录是保存在客户端本地,还是保存在服务器,出于不同的考量,会有不同的策略。客户端之间可以以二进制形式互相传输文件,并且提供了哈希校验机制,以检查文件传输过程中的是否发生错误。
提到 P2P,相信大家都不陌生吧?但究竟什么是 P2P 呢?
P2P,即“点对点”,英文是“Peer to Peer”,意思是两个节点之间直接通信,不需要第三方充当中介进行中转。在一个 IM 系统中,用户之间的聊天信息有两种方式进行传递,一种是用户 A 把信息发送给服务器 S,服务器 S 再把该信息转发给用户 B(见图3);另一种就是我们这里所说的 P2P 方式,即用户 A 把信息直接发送给用户 B,而不用经过服务器 S(见图4)。
图1 错误的思路代码段1和代码段2只是说明了最最基本的 Socket 编程方式,用术语来说就是“交互式同步阻塞 I/O”。在这种方式中,服务器监听本地端口,当没有连接请求时,用户进程会阻塞在 Accept 函数,直到有客户端请求连接;另外,当有某个客户端连接传入后,服务器用户进程就会忙碌于接收客户端数据的工作,如果此时有新的客户端连接过来,服务器就不能进行响应。这种方式之所以称为“交互式”,就是因为类似于“客户端问一句、服务器答一句”的形式。
当然了,通过百度还可以找到如下代码示例:
while (true){ var workerSock = tcpListenSock.Accept(); var remoteEnd = workerSock.RemoteEndPoint as IPEndPoint; ThreadPool.queueuserworkitem(obj => { while (true) { byte[] buf = new byte[1000]; int r= workerSock.Receive(buf); if (r这种方式比刚才那种“交互式同步阻塞 I/O”要好一些,借助于线程池技术,在一些并发不高的简单场合完全可以适用。该方案用一个单独的线程来处理已经建立的连接,使得主线程能够继续监听其他客户端请求。但这种方式要求为每一个客户端连接都开辟一个单独的线程,在少量客户端连入的时候没有什么问题,但如果有成千上万的并发请求传入时,系统就要分配成千上万的线程来应对每一个连接,很显然,这种方案不能应对高并发。在生产环境中,应对高并发绝不是只用一台服务器来实现的,通常是一个服务器集群,采用负载均衡技术来给各个服务器分配任务。对于每一台服务器,还要采用诸如非阻塞、多路复用甚至异步 I/O 等模式,这都是较为高阶的网络编程技术,需要在实际工作中积累经验。
面向连接的 TCP由于服务器需要保存客户端登录、会话以及活动的各种状态,客户端和服务器之间的通信采用面向连接的 TCP 协议。另外,不像传统的 HTTP 服务器和浏览器之间采用短连接(现在的 HTTP 协议默认使用长连接),我们在这里连接采用长连接,也就是说,一旦客户端和服务器建立 TCP 连接后,不会自动断开连接,而是一直使用该连接传输数据,直到客户端主动断开连接为止。
用户注册、登录与注销在服务器已经运行的前提下,客户端启动后,会主动向服务器发起 TCP 连接请求,服务器一旦接受连接请求,二者之间就成功建立起一条 TCP 连接。客户端使用该连接向服务器发送注册、登录与注销的请求报文,服务器同样用这条连接向客户端发回相应的响应报文。
服务器监测客户端在线状态(心跳包)一种常用的策略是服务器和客户端之间维持一个“心跳包”通信,顾名思义,“心跳包”就是以某一频率在服务器和客户端之间传送的微型报文。就像心跳一样,有心跳就说明客户端和服务器之间的连接还存在,没有心跳就说明二者之间的连接 Over 了。这样,即使客户端由于停电、死机等突发状况,来不及向服务器报告下线通知,服务器也能够检测到该客户端已经不在线了。
服务器分发用户好友地址某个用户成功登录后,应该能够获取该用户的好友列表,并且能够给某个好友发送消息,这是 IM 应该具有的基本功能。上文中提到,用户 A 向好友 B 发送消息,既可以通过服务器进行中转,也可以用 P2P 方式进行直接通信。无论是哪种方式,都要知道 B 的 IP 地址,那么 B 的 IP 地址从哪里获得呢?我们知道,当用户 B 登录服务器时,会与服务器建立一条 TCP 连接,此时服务器肯定知道 B 的 IP。所以,服务器需要在 B 登录时,保存好用户 B 的 IP 地址,并向 B 的所有好友(包括 A)分发 B 的 IP 地址信息。
内网穿透失败时转发用户之间的通信虽然 P2P 通信效率较高且不会给服务器造成太大压力,但存在通信失败的可能。大家想一下我们家里上网用到的宽带路由器,它其实就是一个交换机和带有 NAT 功能的路由器的集合体(见图2),它负责把我们家里各个终端设备的内网ip转换成公网IP。要想实现P2P就要穿透这些NAT设备,就是所谓的“打洞”。虽然说用“打洞”技术可能实现内网穿透,但NAT技术还没有标准化,不同的NAT设备各自的具体实现机制不一样,不能保证所有的内网都能被穿透。这种情况下,就需要借助服务器来转发用户之间的消息。
图2 家用宽带路由器示意传送文件实现文件传送,既可以使用 TCP 协议,也可以使用 UDP 协议。有的人更偏好于 UDP,认为 UDP 协议简单轻量、网络负载低,就连 QQ 也是采用 UDP。不可否认,UDP 以“尽最大努力传输”为宗旨,没有 TCP 那样复杂的机制。但我们也要意识到 UDP 是不可靠的,要想实现可靠的端到端传输,需要应用层协议来实现诸如超时重传、流量及拥塞控制等机制,而 TCP 恰恰具备这些功能。也许有人会说,我自己实现重传、流控等机制不就行了么?你当然可以自己做这些,但这些功能是相当复杂的,需要你有十分丰富的网络编程及协议开发经验,而且你自己写的不一定有 TCP 高效。另外,TCP 不像你想象的那样重量级,除非你需要实现广播,或者对实时性有较高要求(在线播放音视频),否则完全可以放心的使用 TCP。
无连接的 UDP刚才说到 TCP 和 UDP 之间的抉择问题,确实需要因地制宜。TCP 的优势是稳定可靠,UDP 的优势是无连接、轻量级。IM 好友之间的普通文本聊天不需要建立持久的连接,因为一个用户在发送一条消息后,不知道下一条消息会在什么时候发送,所以没有必要用一条连接来为这种通信服务。此时,就可以采用无连接的 UDP,一个用户想说话时就发送一条 UDP 报文,不用关心对方什么时候回复,甚至即使一条两条消息丢失也不是什么大问题。当然了,如果你是完美主义者,你也可以在应用层加上一些简单的丢失重传机制。另外,由于 UDP 无连接的特性,它在实现内网穿透方面要比 TCP 方便一些。
P2P 聊天在服务器的帮助下,用户 A 可以得到好友 B 的 IP 地址,从而可以用 UDP 直接向好友 B 发送聊天报文,好友 B 在本地指定的 UDP 端口上接收相应的报文即可。当然,实现 P2P 通信的前提是通信双方都有合法的互联网 IP 地址,倘若一方或双方位于 NAT 设备的内网,用普通的方法就不能实现通信了,因为双方不知道对方的公网 IP 地址。此时,就需要内网穿透了。
内网(NAT)穿透在前面多次提到“内网穿透”,俗称“打洞”,这个概念听起来是不是显得非常“高大上”?其实,所谓的“NAT 穿透”、“内网穿透”、“打洞”都指的是一个概念,只是叫法不同而已。我们都知道,内网穿透的目的是,使得位于内网的两个终端能够直接进行通信,避免服务器作为第三方中转。那么内网穿透该怎么实现呢?
其实内网穿透的基本原理并不复杂,前提是想办法得到 P2P 双方的公网 IP 地址,关键是找出内网终端经过 NAT 转换后的通信端口。这里我们主要介绍的是 UDP 穿透,图3中的 A 和 B 是两个位于各自内网中的电脑终端,NAT_A 和 NAT_B 分别是 A 和 B 的网关,各自的 IP 地址及端口都已经标明。
图7 UDP 穿透示意图获取通信双方的公网 IP 并不困难。我们知道,网络上的两台电脑要想相互通信,就必须要知道对方的 IP 地址及其端口号。由于电脑 A 和 B 都分别位于各自的内网中,它们都不具有合法的公网 IP 地址,所以二者不能直接通信。但是,A 可以和 NAT_B 的外网接口通信,B 也可以和 NAT_A 的外网接口通信,而 NAT_A 和 NAT_B 外网接口的 IP 地址就是 A 和 B 经过 NAT 转换后的外网IP。也就是说,A、B 要想通信,先要获取对方的外网 IP 地址,具体方法是:A 和 B 都和服务器建立 TCP 连接,这样服务器就知道 A 和 B 各自的公网 IP,然后服务器把各自的公网 IP 通过 TCP 连接告诉对方即可。
难在如何获取 NAT 后的外网端口。要想弄明白内网穿透的实现细节,就要搞明白 NAT 设备如何把内网地址转换成公网地址。前面我们多次提到,NAT 技术还没有标准化,也就是说,不同厂家的 NAT 设备,内外网地址转换的实现方法也不一样。在有的 NAT 设备实现中,只要内网终端的 IP 和端口不变,不管访问公网的哪台服务器,转换成的外网端口也保持不变;而对于有的 NAT 设备,即使是相同的内网 IP 和端口,只要访问的外网服务器不同,转换成的外网端口也不同。有的 NAT 设备允许数据包从外网自由的进入到内网,而大多数 NAT 设备不允许不请自来的外部数据包进入。所以说,实现内网穿透的关键是找到内网主机被 NAT 设备所映射成的外网端口号。
正因为不同的 NAT 设备转换的外网端口不一定能得到,所以内网穿透不一定能成功。大家回想一下在使用 QQ 聊天的时候,有没有遇到过系统提示“服务器中转”?这就是由于内网穿透失败,QQ 服务器把聊天内容进行了中转。
最容易实现穿透的是同一内网 IP 和端口被 NAT 转换后的外网端口保持不变的 NAT 设备。如图3所示,A 通过 NAT_A 向服务器发送报文,经 NAT 转换后的端口号是6001;A 通过 NAT_A 向 NAT_B 发送报文,经 NAT 转换后的端口号也是6001。这种情况下,在通过服务器得知对方的公网 IP 和端口以后,A 向 B 发送一条报文的步骤如下:
[ol]
A 先告诉服务器,“我要给 B 发送一条报文”;
服务器给 B 发一个命令,让 B 向 A 的公网端口6001发送一条报文 Dr_BA;
报文 Dr_BA 发出后,B 通知服务器,“我已经向 A 发报文了”,然后服务器把该消息转告给 A;
A 向 B 的公网端口8001发送一条报文 Dr_AB。[/ol]我们知道,NAT 设备不允许不请自来的外部报文进入,于是报文 Dr_BA 会被 NAT_A 丢弃;但是报文 Dr_BA 会在 NAT_B 上留下一个映射记录,就相当于在 NAT_B 设备上打了一个“洞”,以后由外部发送到端口8001的报文就能够通过这个“洞”进入 NAT_B 内部网络,这样 A 到 B 的通信就成功了。 第02课:程序骨架之服务端我们这款 IM 包括服务器和客户端两部分,其中,服务器负责各个客户端之间的联络,以及服务器和客户端之间的交互;客户端就是我们终端用户接触到的聊天软件。
任何复杂的软件系统也不是一下子就凭空拔地而起的,总是由一些核心代码慢慢扩充而来,聊天软件的核心代码很简单,无非是服务器监听、客户端连接,以及客户端之间的通信而已。
上文讲基本原理的时候,列举了两段代码(代码段1和2),这两段代码其实就构成了服务器和客户端之间通信的核心代码。我们在这里使用的是最基础的 Windows 套接字(Socket),虽然用起来比 TCPListener、TCPClient 之类的要麻烦一些,但能够使我们更清晰的了解网络编程的基本原理,以及获得更高的灵活度。
Socket,直接翻译过来是“插座”的意思,术语俗称“套接字”。好多人对 Socket 到底是什么并没有一个清晰的概念,只知道它是用来操作网络通信的一个类。其实“插座”这个叫法还是比较形象的,它给我们提供了应用程序和操作系统内核中的 TCP/IP 协议栈软件之间的操作接口。图1用直观的形式说明了 Socket 在分层网络体系中的位置,由图可见 Socket 为我们在应用层和传输层及网络层之间搭建起了桥梁,借助 Socket,我们既可以操作 TCP/UDP 协议栈,又可以直接操作原始 IP 数据报。