类 QQ IM 通讯软件开发实战

课程简介

用习惯了微信的你,还记得当初的 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 形式。

类 QQ IM 通讯软件开发实战_第1张图片

图1 IM 服务器

类 QQ IM 通讯软件开发实战_第2张图片

图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)。

类 QQ IM 通讯软件开发实战_第3张图片

图3 服务器中转

类 QQ IM 通讯软件开发实战_第4张图片

图4 P2P

P2P 的优势显而易见,少一道工序、少一个步骤,效率必然比服务器中转要高。然而,由于 NAT 设备的存在,好多终端都没有合法的公网 IP,和这样的终端进行通信就需要“内网穿透”(就是指常说的“打洞”)。但是 NAT 技术尚未标准化,各种 NAT 设备的实现策略也没有统一,内网穿透不保证一定会成功,所以当内网穿透失败时,P2P 就不能实现了,还时需要服务器对消息进行中转。

下面解释一下刚才提到的 NAT 技术。我们知道,当前32位的 IPv4 地址几乎已经耗尽,不可能给所有终端互联网用户都分配一个公网 IP,而互联网用户的数量又在不断增加,怎么办?于是就出现了所谓的 NAT 技术,简单来说,就是用一个 NAT 设备把一个公网 IP 提供给多个终端使用,使得多个电脑可以共用一个公网 IP 地址来上网。

NAT 设备一般都有一个特点,就是对外隐藏内网各个终端的真实 IP,内网的机器可以主动向外网发送信息,但外网不能主动向内网发送信息。这就给我们上面提到的 P2P 通信造成了很大的困难,因为我们不知道通信双方各自的真实 IP 地址;即使知道对方的 IP,也不能主动向对方发起通信,因为对方的 NAT 设备会拒绝。要想和 NAT 内的终端进行通信,就要想办法穿透 NAT 设备的壁垒,这就是所谓的内网穿透。

刚才提到了“服务器中转”这个概念,我们知道,服务器中转给通信引入了一道额外的步骤,本来双方之间可以通信的,非要另找一个人来传话,不但有可能传错话,当客户端较多时,服务器这个中间人的工作量就会很重,容易成为性能瓶颈。当然了,内网穿透失败,或者出于监视用户间通信的需要,仍然要用服务器来中转。

第01课:Socket 通讯基本原理

任何网络应用的实现都离不开 Socket 编程,当然,你也可以使用更高层次的抽象与封装,诸如 TcpListener、TcpClient、UdpClient,以及更加抽象的 WCF、WebService、Remoting 等技术。然而,要想更为深刻的理解网络通信的底层原理,最终还是绕不开套接字(Socket)。

只要对 Socket 编程稍有了解,就会知道诸如 Bind、Listen、Accept、Connect、Send、Receive 之类的操作,确实,所有的网络应用就是这些基本操作的合理使用。对于 TCP 而言,由于它是面向连接的协议,一般就需要一方充当服务器的角色进行监听、另一方充当客户端发起到服务器的主动连接。使用套接字进行 TCP 编程的一般用法如下。

服务器端代码:

Socket tcpListenSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);var tcpLocalEnd = new IPEndPoint(IPAddress.Any, listenPort);tcpListenSock.Bind(tcpLocalEnd);tcpListenSock.Listen(10);while(true){var workerSock = tcpListenSock.Accept();byte[] buf = new byte[1000];workerSock.Receive(buf);}

代码段1

客户端代码:

Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);serverSock.Connect(serverEndPoint);byte[] buf = new byte[1000];serverSock.Send(buf);

代码段2

上面代码的大致流程是:服务器监听连接、当有客户端连接时,服务器接收该连接,并开始接收客户端发送过来的数据并对其进行处理;客户端向服务器发起连接请求,连接成功后,向服务器发送数据。

网上好多关于 Socket 编程的教程大都一上来就介绍上面的这种操作,导致好多初学者在头脑里认为 Socket 编程就应该是这样的。甚至很多初学者会以为服务器只能接收数据、客户端只能发送数据,客户端要想接收服务器发送的数据,先要在客户端的某个端口上监听来自服务器的连接(见图1)。他们只知道TCP是全双工的协议,却仅仅停留在知道这个概念而已,和实际应用联系不起来。

类 QQ IM 通讯软件开发实战_第5张图片

图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<=)             break;       }    });}

这种方式比刚才那种“交互式同步阻塞 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设备各自的具体实现机制不一样,不能保证所有的内网都能被穿透。这种情况下,就需要借助服务器来转发用户之间的消息。

类 QQ IM 通讯软件开发实战_第6张图片

图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_ANAT_B 分别是 A 和 B 的网关,各自的 IP 地址及端口都已经标明。

类 QQ IM 通讯软件开发实战_第7张图片

图7 UDP 穿透示意图

获取通信双方的公网 IP 并不困难。我们知道,网络上的两台电脑要想相互通信,就必须要知道对方的 IP 地址及其端口号。由于电脑 A 和 B 都分别位于各自的内网中,它们都不具有合法的公网 IP 地址,所以二者不能直接通信。但是,A 可以和 NAT_B 的外网接口通信,B 也可以和 NAT_A 的外网接口通信,而 NAT_ANAT_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_ANAT_B 发送报文,经 NAT 转换后的端口号也是6001。这种情况下,在通过服务器得知对方的公网 IP 和端口以后,A 向 B 发送一条报文的步骤如下:

  1. A 先告诉服务器,“我要给 B 发送一条报文”;
  2. 服务器给 B 发一个命令,让 B 向 A 的公网端口6001发送一条报文 Dr_BA
  3. 报文 Dr_BA 发出后,B 通知服务器,“我已经向 A 发报文了”,然后服务器把该消息转告给 A;
  4. A 向 B 的公网端口8001发送一条报文 Dr_AB

我们知道,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 数据报。

类 QQ IM 通讯软件开发实战_第8张图片

图1 Socket 在分层网络体系中的位置

服务器

服务器和客户端之间的通信采用 TCP 协议。服务器负责监听客户端传入的连接,以及向客户端发送数据,一般过程是:

1.服务器端建立一个监听 Socket,并且设置好地址族、套接字类型以及协议类型。由于 TCP 协议属于基于比特流的流式协议,所以我们把该套接字设置为 IPv4 地址族、流式套接字以及 TCP 协议。

private Socket tcpListenSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

2.把监听 Socket 绑定到一个本地终结点,以后这个终结点收到的连接请求都由这个监听 Socket 处理。所谓终结点指的是 IP 地址和端口号,因为要想通过网络通信,网络层需要知道 IP 地址,传输层需要知道端口号。

tcpLocalEnd = new IPEndPoint(IPAddress.Any, listenPort);tcpListenSock.Bind(tcpLocalEnd);

需要注意的一点是,本地终结点中的 IP 地址我们指定为 IPAddress.Any,为的是能够监听本地计算机上所有网卡在端口 listenPort 上接收到的通信。虽然我们的电脑一般都只有一块网卡,但出于程序健壮性考虑,还是选择 IPAddress.Any。

3.在监听 Socket 上调用 Listen 函数,使其开始侦听已绑定本地终结点上的连接。要知道,Listen 函数只有在 TCP 通信中才需要使用,UDP 不需要,因为 UDP 不需要建立连接。

tcpListenSock.Listen(10);

在导读中我说过,Socket 编程中“坑”很多,这里就有一个“坑”,上面代码中 Listen 函数的参数“10”是什么意思?是该套接字最大只允许建立10个连接吗?对于这个参数的意义,相信很多朋友都有误区。

其实,这个参数的意思是,允许操作系统内核的 TCP/IP 协议栈为新传入的连接请求排入队列的最大个数。也就是说,如果应用进程来不及处理新传入的连接请求,协议栈会帮我们把超出应用进程处理能力之外的连接请求进行排队暂存,当应用进程有空时再从这个队列中取出新连接。这个队列的最大长度就是 Listen 函数的参数。

如果我们的应用进程正在忙于处理某一次连接,无暇顾及新传入的连接,操作系统内核会帮我们把新传入的连接暂时保存到一个队列当中,最多保存10个新连接,当第11个新连接传入时,若应用程序还没有从队列中取走连接,则第11个新连接就会被丢弃。

4.在监听 Socket 上调用 Accept 函数,准备接受一个新传入连接。

var workerSock = tcpListenSock.Accept();

如果没有新连接传入,Accept 函数会一直阻塞。当成功接受一个新连接后,Accept 函数返回一个新的 Socket,以后就可以用这个新的 Socket 来处理这条连接上的所有通信了。

大家注意,“坑”又来了!这里澄清一个容易让人困惑的地方,就是 Accept 函数返回的新 Socket 和原来的监听 Socket 之间是什么关系?它们是相同的 Socket 吗?它们是绑定到了相同的本地终结点吗?难道多个 Socket 可以绑定到同一个端口?

其实,Accept 函数返回的新 Socket 和原来的监听 Socket 是两个不同的 Socket,只不过他们俩绑定到了相同的本地终结点而已。既然它们绑定到了同一个本地端口,那么当网络上有数据来了以后,怎么区分数据是发给 Accept 函数返回的新 Socket,还是发给监听 Socket 呢?这是根据发送数据的远端终结点来决定的。

再进一步解释之前,先铺垫一下关于 TCP 中“连接”的概念。一条 TCP 连接是由一个五元组定义的,即:协议、发送端 IP 地址、发送端端口号、接收端 IP 地址、接收端端口号)。只要这五个元素中有一个不同,就代表不同的连接。

好了,继续刚才的叙述,见图2。每当Accept函数接受一个连接,协议栈都会把通信双方的五元组保存起来。当有后续数据发来时,检查一下远端终结点是否存在于本地保存的五元组记录当中。若不存在,则说明是新传入的连接(图5中的连接A),需要把数据发给监听Socket;若存在,则说明是在以前连接上传来的数据(图5中的连接B),需要把数据发给Accept函数返回的新Socket。

类 QQ IM 通讯软件开发实战_第9张图片

图2 监听 Socket 和 Accept 返回的 Socket 的关系

5.服务器端用 Receive 函数接收数据。如果网络上没有数据传过来,则 Receive 函数会一直阻塞。若 Receive 函数成功返回,其返回值是此次从网络上读取到的字节数。

byte[] buf = new byte[1000];workerSock.Receive(buf);

Receive 函数的参数是你用来存储接收数据的缓冲区,一般是字节数组。初学者经常会为如何确定这个数组的大小感到困惑,甚至干脆设置一个非常大的数组。这又是一个“坑”,这样做是不科学的,会造成内存空间的无谓浪费。

我们知道,TCP 协议是流式协议,报文之间不保留边界,不像 UDP 那样,每次接收到的数据都是一个完整的数据报。所以我们在接收数据时,只能按需读取合适长度的数据,也就是说,接收缓冲区数组的大小要根据你的应用层协议来确定,而不要简单粗暴的设置一个非常大的值。比如说,按照你自己制定的应用层协议,收到的数据中前4个字节代表发送端的用户名,那么你的接收缓冲区数组大小就设置为4;接下来的1个字节代表用户性别,那么第二次接收的缓冲区数组大小设置成1。

6.如果服务器需要向客户端发回响应,则调用 Send 函数向客户端发送数据。如果协议栈的发送缓冲区已满,则 Send 函数会阻塞,直到协议栈发送缓冲区有空间。

byte[] sendBuf = new byte[100];workerSock.Send(sendBuf);

这里有两个比较容易造成混淆概念:应用程序缓冲区协议栈缓冲区,是 Socket 编程中一个较为高大上的“坑”。在使用 Socket 进行网络编程(尤其是 TCP)过程中,不可避免的要接触到缓冲区的概念。缓冲区有两类,一类是我们的应用层代码在使用 Receive 或 Send 函数收发数据时,提供给 Socket 的字节数组;另一类是操作系统内核中 TCP/IP 协议栈软件为在网络上收发数据及流量控制而设置的内核缓冲区。

可以看下面这张图:

类 QQ IM 通讯软件开发实战_第10张图片

图3 关于各种缓冲区的示意

图3中 ABCD 各部分的含义分别是:

  • A:应用程序的发送缓冲区。这个缓冲区是我们的应用程序代码在调用 Socket 的 Send 函数时提供的参数,即打算发送到网络的字节数组。

  • B:应用程序的接收缓冲区。这个缓冲区是我们的应用程序代码在调用 Socket 的 Receive 函数时提供的参数,即用来存放从网络接收到的数据的字节数组空间。

  • C:协议栈的发送缓冲区我们在调用 Socket 的 Send 函数并返回时,并不代表已经把数据发了出去,只是意味着把用户数据拷贝到了操作系统内核的协议栈缓冲区中,也即是 C。之后,TCP/IP 协议栈软件再从内核缓冲区中取出数据,并发送到网络。如果我们在调用 Socket 的 Send 函数时,内核缓冲区满了,那么 Send 函数就会阻塞,一直到内核缓冲区有空间为止。

  • D:协议栈的接收缓冲区我们在调用 Socket 的 Receive 函数时,并不是直接从网络上读取数据,而是从内核的一个缓冲区中读取数据,也即是 D。协议栈在收到网络上传来的数据时,会先把这些数据存放在内核缓冲区中,等待应用程序代码来读取。

有人会问了,如果内核接收缓冲区满了怎么办?会丢失数据么?大家别忘了我们用的 TCP 协议,它是面向连接的可靠的流协议,在内核接收缓冲区满了的情况下,接收端会向发送端发送一个窗口通告,告诉发送端,“你先别发数据了,我没有地方存了,等我有地方了以后再告诉你!”,这就是 TCP 的流量控制机制。

如果内核接收缓冲区为空,那么 Receive 函数会阻塞,直到缓冲区有数据为止。

这里还有一个容易踩到的“坑”。有人会抓狂了,怎么老有坑?没错,Socket 编程就是这样,基本操作谁都懂,但是具体使用起来,可以说是一个“坑”接着一个“坑”。很多初学者以为服务器只能接收来自客户端的数据,认为服务器要想向客户端发送数据,得需要客户端也在本地监听某个端口,等待服务器的连接。这是一个非常常见的误区,我见过不少新手写出过这样的代码。我们知道,TCP 是全双工的协议,只要通信双方建立起连接,双方就可以在两个方向上相互通信。也就是说,服务器在接受一个客户端的连接请求后,除了可以接收来自客户端的数据外,也可以向客户端发送数据。

还有,服务器是应该先接收数据再发送数据、还是先发送数据再接收数据?一般我们习惯于先让服务器接收数据,然后再发送数据作为回送给客户端的响应。由于 TCP 是全双工的,其实完全可以在连接建立以后就向客户端发送数据。

服务器端的主要工作有:监听客户端连接、接收报文并根据报文类型作相应处理;保存用户登录状态、用户信息及好友列表;向所有客户端发送心跳包,以检测客户端在线状态;响应客户端关于好友 IP 地址的请求,以实现 P2P 通信;作为公网服务器,辅助实现内网(NAT)穿透。

第03课:程序骨架之客户端及协议设计
第04课:具体实现之报文类与 TCP 操作类
第05课:具体实现之服务器类与客户类
第06课:具体实现之点对点、服务器并发与心跳包机制
第07课:Socket 编程中容易踩的坑

阅读全文: http://gitbook.cn/gitchat/column/5b077eeeb9f775446da64412

你可能感兴趣的:(类 QQ IM 通讯软件开发实战)