网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接

1、引言 好多小白初次接触即时通讯(比如:IM或者消息推送应用)时,总是不能理解Web短连接(就是最常见的HTTP通信了)跟长连接(主要指TCP、UDP协议实现的socket通信,当然HTML5里的Websocket协议也是长连接)的区别,导致写即时通讯这类系统代码时往往找不到最佳实践,搞的一脸蒙逼。 本篇我们先简单了解一下 TCP/IP,然后通过实现一个 echo 服务器来学习 Java 的 Socket API。最后我们聊聊偏高级一点点的 socket 长连接和协议设计。 另外,本系列文章的前2篇《网络编程懒人入门(一):快速理解网络通信协议(上篇)》、《网络编程懒人入门(二):快速理解网络通信协议(下篇)》快速介绍了网络基本通信协议及理论基础,如果您对网络基础毫无概念,则请务必首先阅读完这2篇文章。本系列的第3篇文章《网络编程懒人入门(三):快速理解TCP协议一篇就够》有助于您快速理解TCP协议理论的方方面面,建议也可以读一读。 TCP 是互联网的核心协议之一,鉴于它的重要性,希望通过阅读上面介绍的几篇理论文章,再针对本文的动手实践,能真正加深您对TCP协议的理解。 如果您正打算系统地学习即时通讯开发,在读完本文后,建议您可以详细阅读《新手入门一篇就够:从零开发移动端IM》。 (提示:本文完整源码可以从文末附件打包下载) 2、系列文章 本文是系列文章中的第8篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》(本文) 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 如果您觉得本系列文章过于基础,您可直接阅读《不为人知的网络编程》系列文章,该系列目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 如果您对服务端高性能网络编程感兴趣,可以阅读以下系列文章: 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 3、参考资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《简述传输层协议TCP和UDP的区别》 《为什么QQ用的是UDP协议而不是TCP协议?》 《移动端即时通讯协议选择:UDP还是TCP?》 4、TCP/IP 协议简介 TCP/IP协议族是互联网最重要的基础设施之一,如有兴趣了解TCP/IP的贡献,可以读一读此文:《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》,本文因篇幅原因仅作简要介绍。 4.1IP协议 首先我们看 IP(Internet Protocol)协议。IP 协议提供了主机和主机间的通信。 为了完成不同主机的通信,我们需要某种方式来唯一标识一台主机,这个标识,就是著名的IP地址。通过IP地址,IP 协议就能够帮我们把一个数据包发送给对方。 4.2TCP协议 前面我们说过,IP 协议提供了主机和主机间的通信。TCP 协议在 IP 协议提供的主机间通信功能的基础上,完成这两个主机上进程对进程的通信。 有了 IP,不同主机就能够交换数据。但是,计算机收到数据后,并不知道这个数据属于哪个进程(简单讲,进程就是一个正在运行的应用程序)。TCP 的作用就在于,让我们能够知道这个数据属于哪个进程,从而完成进程间的通信。 为了标识数据属于哪个进程,我们给需要进行 TCP 通信的进程分配一个唯一的数字来标识它。这个数字,就是我们常说的端口号。 TCP 的全称是 Transmission Control Protocol,大家对它说得最多的,大概就是面向连接的特性了。之所以说它是有连接的,是说在进行通信前,通信双方需要先经过一个三次握手的过程。三次握手完成后,连接便建立了。这时候我们才可以开始发送/接收数据。(与之相对的是 UDP,不需要经过握手,就可以直接发送数据)。 下面我们简单了解一下三次握手的过程:   首先,客户向服务端发送一个 SYN,假设此时 sequence number 为 x。这个 x 是由操作系统根据一定的规则生成的,不妨认为它是一个随机数; 服务端收到 SYN 后,会向客户端再发送一个 SYN,此时服务器的 seq number = y。与此同时,会 ACK x+1,告诉客户端“已经收到了 SYN,可以发送数据了”; 客户端收到服务器的 SYN 后,回复一个 ACK y+1,这个 ACK 则是告诉服务器,SYN 已经收到,服务器可以发送数据了。 经过这 3 步,TCP 连接就建立了,这里需要注意的有三点: 连接是由客户端主动发起的; 在第 3 步客户端向服务器回复 ACK 的时候,TCP 协议是允许我们携带数据的。之所以做不到,是 API 的限制导致的; TCP 协议还允许 “四次握手” 的发生,同样的,由于 API 的限制,这个极端的情况并不会发生。 TCP/IP 相关的理论知识我们就先了解到这里,如果对TCP的3次握手和4次挥手还不太理解,那就详细读读以下文章: 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 关于 TCP,还有诸如可靠性、流量控制、拥塞控制等非常有趣的特性。强烈推荐读者看一看 Richard 的名著《TCP/IP 详解 - 卷1》(注意,是第1版,不是第2版)。   ▲ 网络编程理论经典《TCP/IP 详解 - 卷1》 另外,TCP/IP协议其实是一个庞大的协议族,《计算机网络通讯协议关系图(中文珍藏版)》一文中为您清晰展现了这个协议族之间的关系,很有收藏价值,建议务必读一读。   ▲ TCP/IP协议族图 下面我们看一些偏实战的东西。 5、Socket 基本用法 Socket 是 TCP 层的封装,通过 socket,我们就能进行 TCP 通信。 在 Java 的 SDK 中,socket 的共有两个接口:用于监听客户连接的 ServerSocket 和用于通信的 Socket。 使用 socket 的步骤如下: 1)创建 ServerSocket 并监听客户连接; 2)使用 Socket 连接服务端; 3)通过 Socket.getInputStream()/getOutputStream() 获取输入输出流进行通信。 下面,我们通过实现一个简单的 echo 服务来学习 socket 的使用。所谓的 echo 服务,就是客户端向服务端写入任意数据,服务器都将数据原封不动地写回给客户端。 5.1第一步:创建 ServerSocket 并监听客户连接 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class EchoServer {     private final ServerSocket mServerSocket;     public EchoServer(int port) throws IOException {         // 1. 创建一个 ServerSocket 并监听端口 port         mServerSocket = new ServerSocket(port);     }     public void run() throws IOException {         // 2. 开始接受客户连接         Socket client = mServerSocket.accept();         handleClient(client);     }     private void handleClient(Socket socket) {         // 3. 使用 socket 进行通信 ...     }     public static void main(String[] argv) {         try {             EchoServer server = new EchoServer(9877);             server.run();         } catch (IOException e) {             e.printStackTrace();         }     } } 5.2第二步:使用 Socket 连接服务端 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class EchoClient {     private final Socket mSocket;     public EchoClient(String host, int port) throws IOException {         // 创建 socket 并连接服务器         mSocket = new Socket(host, port);     }     public void run() {         // 和服务端进行通信     }     public static void main(String[] argv) {         try {             // 由于服务端运行在同一主机,这里我们使用 localhost             EchoClient client = new EchoClient("localhost", 9877);             client.run();         } catch (IOException e) {             e.printStackTrace();         }     } } 5.3第三步:通过 socket.getInputStream()/getOutputStream() 获取输入/输出流进行通信 首先,我们来实现服务端: 01 02 03 04 05 06 07 08 09 10 11 12 13 public class EchoServer {     // ...     private void handleClient(Socket socket) throws IOException {         InputStream in = socket.getInputStream();         OutputStream out = socket.getOutputStream();         byte[] buffer = new byte[1024];         int n;         while ((n = in.read(buffer)) > 0) {             out.write(buffer, 0, n);         }     } } 可以看到,服务端的实现其实很简单,我们不停地读取输入数据,然后写回给客户端。 下面我们看看客户端: 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class EchoClient {     // ...     public void run() throws IOException {         Thread readerThread = new Thread(this::readResponse);         readerThread.start();         OutputStream out = mSocket.getOutputStream();         byte[] buffer = new byte[1024];         int n;         while ((n = System.in.read(buffer)) > 0) {             out.write(buffer, 0, n);         }     }     private void readResponse() {         try {             InputStream in = mSocket.getInputStream();             byte[] buffer = new byte[1024];             int n;             while ((n = in.read(buffer)) > 0) {                 System.out.write(buffer, 0, n);             }         } catch (IOException e) {             e.printStackTrace();         }     } } 客户端会稍微复杂一点点,在读取用户输入的同时,我们又想读取服务器的响应。所以,这里创建了一个线程来读服务器的响应。 不熟悉 lambda 的读者,可以把Thread readerThread = new Thread(this::readResponse) 换成下面这个代码: 1 2 3 4 5 6 Thread readerThread = new Thread(new Runnable() {     @Override     public void run() {         readResponse();     } }); 打开两个 terminal 分别执行如下命令: 1 2 3 4 5 6 7 8 9 $ javac EchoServer.java $ java EchoServer $ javac EchoClient.java $ java EchoClient hello Server hello Server foo foo 在客户端,我们会看到,输入的所有字符都打印了出来。 5.4最后需要注意的有几点 1)在上面的代码中,我们所有的异常都没有处理。实际应用中,在发生异常时,需要关闭 socket,并根据实际业务做一些错误处理工作; 2)在客户端,我们没有停止 readThread。实际应用中,我们可以通过关闭 socket 来让线程从阻塞读中返回。推荐读者阅读《Java并发编程实战》; 3)我们的服务端只处理了一个客户连接。如果需要同时处理多个客户端,可以创建线程来处理请求。这个作为练习留给读者来完全。 6、Socket、ServerSocket 傻傻分不清楚 在进入这一节的主题前,读者不妨先考虑一个问题:在上一节的实例中,我们运行 echo 服务后,在客户端连接成功时,一个有多少个 socket 存在? 答案是 3 个 socket:客户端一个,服务端有两个。跟这个问题的答案直接关联的是本节的主题——Socket 和 ServerSocket 的区别是什么。 眼尖的读者,可能会注意到在上一节我是这样描述他们的: 在 Java 的 SDK 中,socket 的共有两个接口:用于监听客户连接的 ServerSocket 和用于通信的 Socket。 注意:我只说 ServerSocket 是用于监听客户连接,而没有说它也可以用来通信。下面我们来详细了解一下他们的区别。 注:以下描述使用的是 UNIX/Linux 系统的 API。 首先,我们创建 ServerSocket 后,内核会创建一个 socket。这个 socket 既可以拿来监听客户连接,也可以连接远端的服务。由于 ServerSocket 是用来监听客户连接的,紧接着它就会对内核创建的这个 socket 调用 listen 函数。这样一来,这个 socket 就成了所谓的 listening socket,它开始监听客户的连接。 接下来,我们的客户端创建一个 Socket,同样的,内核也创建一个 socket 实例。内核创建的这个 socket 跟 ServerSocket 一开始创建的那个没有什么区别。不同的是,接下来 Socket 会对它执行 connect,发起对服务端的连接。前面我们说过,socket API 其实是 TCP 层的封装,所以 connect 后,内核会发送一个 SYN 给服务端。 现在,我们切换角色到服务端。服务端的主机在收到这个 SYN 后,会创建一个新的 socket,这个新创建的 socket 跟客户端继续执行三次握手过程。 三次握手完成后,我们执行的 serverSocket.accept() 会返回一个 Socket 实例,这个 socket 就是上一步内核自动帮我们创建的。 所以说:在一个客户端连接的情况下,其实有 3 个 socket。 关于内核自动创建的这个 socket,还有一个很有意思的地方。它的端口号跟 ServerSocket 是一毛一样的。咦!!不是说,一个端口只能绑定一个 socket 吗?其实这个说法并不够准确。 前面我说的TCP 通过端口号来区分数据属于哪个进程的说法,在 socket 的实现里需要改一改。Socket 并不仅仅使用端口号来区别不同的 socket 实例,而是使用 <peer addr:peer port, local addr:local port> 这个四元组。 在上面的例子中,我们的 ServerSocket 长这样:<*:*, *:9877>。意思是,可以接受任何的客户端,和本地任何 IP。 accept 返回的 Socket 则是这样:<127.0.0.1:xxxx, 127.0.0.1:9877>。其中,xxxx 是客户端的端口号。 如果数据是发送给一个已连接的 socket,内核会找到一个完全匹配的实例,所以数据准确发送给了对端。 如果是客户端要发起连接,这时候只有 <*:*, *:9877> 会匹配成功,所以 SYN 也准确发送给了监听套接字。 Socket/ServerSocket 的区别我们就讲到这里。如果读者觉得不过瘾,可以参考《TCP/IP 详解》卷1、卷2。 7、Socket “长”连接的实现 7.1背景知识 Socket 长连接,指的是在客户和服务端之间保持一个 socket 连接长时间不断开。 比较熟悉 Socket 的读者,可能知道有这样一个 API: 1 socket.setKeepAlive(true); 嗯……keep alive,“保持活着”,这个应该就是让 TCP 不断开的意思。那么,我们要实现一个 socket 的长连接,只需要这一个调用即可。 遗憾的是,生活并不总是那么美好。对于 4.4BSD 的实现来说,Socket 的这个 keep alive 选项如果打开并且两个小时内没有通信,那么底层会发一个心跳,看看对方是不是还活着。 注意:两个小时才会发一次。也就是说,在没有实际数据通信的时候,我把网线拔了,你的应用程序要经过两个小时才会知道。 这个话题,对于即时通讯的老手来说,也就是经常讨论的“网络连接心跳保活”这个话题了,感兴趣的话可以读一读《聊聊iOS中网络编程长连接的那些事》、《为何基于TCP协议的移动端IM仍然需要心跳保活机制?》、《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》、《Android端消息推送总结:实现原理、心跳保活、遇到的问题等》。 在说明如果实现长连接前,我们先来理一理我们面临的问题。 假定现在有一对已经连接的 socket,在以下情况发生时候,socket 将不再可用: 1)某一端关闭是 socket(这不是废话吗):主动关闭的一方会发送 FIN,通知对方要关闭 TCP 连接。在这种情况下,另一端如果去读 socket,将会读到 EoF(End of File)。于是我们知道对方关闭了 socket; 2)应用程序奔溃:此时 socket 会由内核关闭,结果跟情况1一样; 3)系统奔溃:这时候系统是来不及发送 FIN 的,因为它已经跪了。此时对方无法得知这一情况。对方在尝试读取数据时,最后会返回 read time out。如果写数据,则是 host unreachable 之类的错误。 4)电缆被挖断、网线被拔:跟情况3差不多,如果没有对 socket 进行读写,两边都不知道发生了事故。跟情况3不同的是,如果我们把网线接回去,socket 依旧可以正常使用。 在上面的几种情形中,有一个共同点就是,只要去读、写 socket,只要 socket 连接不正常,我们就能够知道。基于这一点,要实现一个 socket 长连接,我们需要做的就是不断地给对方写数据,然后读取对方的数据,也就是所谓的心跳。只要心还在跳,socket 就是活的。写数据的间隔,需要根据实际的应用需求来决定。 心跳包不是实际的业务数据,根据通信协议的不同,需要做不同的处理。 比方说,我们使用 JSON 进行通信,那么,可以为协议包加一个 type 字段,表面这个 JSON 是心跳还是业务数据: 1 2 3 4 5 {     "type": 0,  // 0 表示心跳     // ... } 使用二进制协议的情况类似。要求就是,我们能够区别一个数据包是心跳还是真实数据。这样,我们便实现了一个 socket 长连接。 7.2实现示例 这一小节我们一起来实现一个带长连接的 Android echo 客户端。完整的代码可以在本文末尾的附件找到。 首先了接口部分: 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public final class LongLiveSocket {     /**      * 错误回调      */     public interface ErrorCallback {         /**          * 如果需要重连,返回 true          */         boolean onError();     }     /**      * 读数据回调      */     public interface DataCallback {         void onData(byte[] data, int offset, int len);     }     /**      * 写数据回调      */     public interface WritingCallback {         void onSuccess();         void onFail(byte[] data, int offset, int len);     }     public LongLiveSocket(String host, int port,                           DataCallback dataCallback, ErrorCallback errorCallback) {     }     public void write(byte[] data, WritingCallback callback) {     }     public void write(byte[] data, int offset, int len, WritingCallback callback) {     }     public void close() {     } } 我们这个支持长连接的类就叫 LongLiveSocket 好了。如果在 socket 断开后需要重连,只需要在对应的接口里面返回 true 即可(在真实场景里,我们还需要让客户设置重连的等待时间,还有读写、连接的 timeout等。为了简单,这里就直接不支持了。 另外需要注意的一点是,如果要做一个完整的库,需要同时提供阻塞式和回调式API。同样由于篇幅原因,这里直接省掉了。 下面我们直接看实现: 001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 062 063 064 065 066 067 068 069 070 071 072 073 074 075 076 077 078 079 080 081 082 083 084 085 086 087 088 089 090 091 092 093 094 095 096 097 098 099 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 public final class LongLiveSocket {     private static final String TAG = "LongLiveSocket";     private static final long RETRY_INTERVAL_MILLIS = 3 * 1000;     private static final long HEART_BEAT_INTERVAL_MILLIS = 5 * 1000;     private static final long HEART_BEAT_TIMEOUT_MILLIS = 2 * 1000;     /**      * 错误回调      */     public interface ErrorCallback {         /**          * 如果需要重连,返回 true          */         boolean onError();     }     /**      * 读数据回调      */     public interface DataCallback {         void onData(byte[] data, int offset, int len);     }     /**      * 写数据回调      */     public interface WritingCallback {         void onSuccess();         void onFail(byte[] data, int offset, int len);     }     private final String mHost;     private final int mPort;     private final DataCallback mDataCallback;     private final ErrorCallback mErrorCallback;     private final HandlerThread mWriterThread;     private final Handler mWriterHandler;     private final Handler mUIHandler = new Handler(Looper.getMainLooper());     private final Object mLock = new Object();     private Socket mSocket;  // guarded by mLock     private boolean mClosed; // guarded by mLock     private final Runnable mHeartBeatTask = new Runnable() {         private byte[] mHeartBeat = new byte[0];         @Override         public void run() {             // 我们使用长度为 0 的数据作为 heart beat             write(mHeartBeat, new WritingCallback() {                 @Override                 public void onSuccess() {                     // 每隔 HEART_BEAT_INTERVAL_MILLIS 发送一次                     mWriterHandler.postDelayed(mHeartBeatTask, HEART_BEAT_INTERVAL_MILLIS);                     mUIHandler.postDelayed(mHeartBeatTimeoutTask, HEART_BEAT_TIMEOUT_MILLIS);                 }                 @Override                 public void onFail(byte[] data, int offset, int len) {                     // nop                     // write() 方法会处理失败                 }             });         }     };     private final Runnable mHeartBeatTimeoutTask = () -> {         Log.e(TAG, "mHeartBeatTimeoutTask#run: heart beat timeout");         closeSocket();     };     public LongLiveSocket(String host, int port,                           DataCallback dataCallback, ErrorCallback errorCallback) {         mHost = host;         mPort = port;         mDataCallback = dataCallback;         mErrorCallback = errorCallback;         mWriterThread = new HandlerThread("socket-writer");         mWriterThread.start();         mWriterHandler = new Handler(mWriterThread.getLooper());         mWriterHandler.post(this::initSocket);     }     private void initSocket() {         while (true) {             if (closed()) return;             try {                 Socket socket = new Socket(mHost, mPort);                 synchronized (mLock) {                     // 在我们创建 socket 的时候,客户可能就调用了 close()                     if (mClosed) {                         silentlyClose(socket);                         return;                     }                     mSocket = socket;                     // 每次创建新的 socket,会开一个线程来读数据                     Thread reader = new Thread(new ReaderTask(socket), "socket-reader");                     reader.start();                     mWriterHandler.post(mHeartBeatTask);                 }                 break;             } catch (IOException e) {                 Log.e(TAG, "initSocket: ", e);                 if (closed() || !mErrorCallback.onError()) {                     break;                 }                 try {                     TimeUnit.MILLISECONDS.sleep(RETRY_INTERVAL_MILLIS);                 } catch (InterruptedException e1) {                     // interrupt writer-thread to quit                     break;                 }             }         }     }     public void write(byte[] data, WritingCallback callback) {         write(data, 0, data.length, callback);     }     public void write(byte[] data, int offset, int len, WritingCallback callback) {         mWriterHandler.post(() -> {             Socket socket = getSocket();             if (socket == null) {                 // initSocket 失败而客户说不需要重连,但客户又叫我们给他发送数据                 throw new IllegalStateException("Socket not initialized");             }             try {                 OutputStream outputStream = socket.getOutputStream();                 DataOutputStream out = new DataOutputStream(outputStream);                 out.writeInt(len);                 out.write(data, offset, len);                 callback.onSuccess();             } catch (IOException e) {                 Log.e(TAG, "write: ", e);                 closeSocket();                 callback.onFail(data, offset, len);                 if (!closed() && mErrorCallback.onError()) {                     initSocket();                 }             }         });     }     private boolean closed() {         synchronized (mLock) {             return mClosed;         }     }     private Socket getSocket() {         synchronized (mLock) {             return mSocket;         }     }     private void closeSocket() {         synchronized (mLock) {             closeSocketLocked();         }     }     private void closeSocketLocked() {         if (mSocket == null) return;         silentlyClose(mSocket);         mSocket = null;         mWriterHandler.removeCallbacks(mHeartBeatTask);     }     public void close() {         if (Looper.getMainLooper() == Looper.myLooper()) {             new Thread() {                 @Override                 public void run() {                     doClose();                 }             }.start();         } else {             doClose();         }     }     private void doClose() {         synchronized (mLock) {             mClosed = true;             // 关闭 socket,从而使得阻塞在 socket 上的线程返回             closeSocketLocked();         }         mWriterThread.quit();         // 在重连的时候,有个 sleep         mWriterThread.interrupt();     }     private static void silentlyClose(Closeable closeable) {         if (closeable != null) {             try {                 closeable.close();             } catch (IOException e) {                 Log.e(TAG, "silentlyClose: ", e);                 // error ignored             }         }     }     private class ReaderTask implements Runnable {         private final Socket mSocket;         public ReaderTask(Socket socket) {             mSocket = socket;         }         @Override         public void run() {             try {                 readResponse();             } catch (IOException e) {                 Log.e(TAG, "ReaderTask#run: ", e);             }         }         private void readResponse() throws IOException {             // For simplicity, assume that a msg will not exceed 1024-byte             byte[] buffer = new byte[1024];             InputStream inputStream = mSocket.getInputStream();             DataInputStream in = new DataInputStream(inputStream);             while (true) {                 int nbyte = in.readInt();                 if (nbyte == 0) {                     Log.i(TAG, "readResponse: heart beat received");                     mUIHandler.removeCallbacks(mHeartBeatTimeoutTask);                     continue;                 }                 if (nbyte > buffer.length) {                     throw new IllegalStateException("Receive message with len " + nbyte +                                     " which exceeds limit " + buffer.length);                 }                 if (readn(in, buffer, nbyte) != 0) {                     // Socket might be closed twice but it does no harm                     silentlyClose(mSocket);                     // Socket will be re-connected by writer-thread if you want                     break;                 }                 mDataCallback.onData(buffer, 0, nbyte);             }         }         private int readn(InputStream in, byte[] buffer, int n) throws IOException {             int offset = 0;             while (n > 0) {                 int readBytes = in.read(buffer, offset, n);                 if (readBytes < 0) {                     // EoF                     break;                 }                 n -= readBytes;                 offset += readBytes;             }             return n;         }     } } 下面是我们新实现的 EchoClient: 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class EchoClient {     private static final String TAG = "EchoClient";     private final LongLiveSocket mLongLiveSocket;     public EchoClient(String host, int port) {         mLongLiveSocket = new LongLiveSocket(                 host, port,                 (data, offset, len) -> Log.i(TAG, "EchoClient: received: " + new String(data, offset, len)),                 // 返回 true,所以只要出错,就会一直重连                 () -> true);     }     public void send(String msg) {         mLongLiveSocket.write(msg.getBytes(), new LongLiveSocket.WritingCallback() {             @Override             public void onSuccess() {                 Log.d(TAG, "onSuccess: ");             }             @Override             public void onFail(byte[] data, int offset, int len) {                 Log.w(TAG, "onFail: fail to write: " + new String(data, offset, len));                 // 连接成功后,还会发送这个消息                 mLongLiveSocket.write(data, offset, len, this);             }         });     } } 就这样,一个带 socket 长连接的客户端就完成了。剩余代码跟我们这里的主题没有太大关系,感兴趣的读者可以看看文末附件里的源码或者自己完成这个例子。 下面是一些输出示例: 01 02 03 04 05 06 07 08 09 10 11 03:54:55.583 12691-12713/com.example.echo I/LongLiveSocket: readResponse: heart beat received 03:55:00.588 12691-12713/com.example.echo I/LongLiveSocket: readResponse: heart beat received 03:55:05.594 12691-12713/com.example.echo I/LongLiveSocket: readResponse: heart beat received 03:55:09.638 12691-12710/com.example.echo D/EchoClient: onSuccess: 03:55:09.639 12691-12713/com.example.echo I/EchoClient: EchoClient: received: hello 03:55:10.595 12691-12713/com.example.echo I/LongLiveSocket: readResponse: heart beat received 03:55:14.652 12691-12710/com.example.echo D/EchoClient: onSuccess: 03:55:14.654 12691-12713/com.example.echo I/EchoClient: EchoClient: received: echo 03:55:15.596 12691-12713/com.example.echo I/LongLiveSocket: readResponse: heart beat received 03:55:20.597 12691-12713/com.example.echo I/LongLiveSocket: readResponse: heart beat received 03:55:25.602 12691-12713/com.example.echo I/LongLiveSocket: readResponse: heart beat received 最后需要说明的是,如果想节省资源,在有客户发送数据的时候可以省略 heart beat。 我们对读出错时候的处理,可能也存在一些争议。读出错后,我们只是关闭了 socket。socket 需要等到下一次写动作发生时,才会重新连接。实际应用中,如果这是一个问题,在读出错后可以直接开始重连。这种情况下,还需要一些额外的同步,避免重复创建 socket。heart beat timeout 的情况类似。 8、跟 TCP/IP 学协议设计 如果仅仅是为了使用是 socket,我们大可以不去理会协议的细节。之所以推荐大家去看一看《TCP/IP 详解》,是因为它们有太多值得学习的地方。很多我们工作中遇到的问题,都可以在这里找到答案。 以下每一个小节的标题都是一个小问题,建议读者独立思考一下,再继续往下看。 8.1协议版本如何升级? 有这么一句流行的话:这个世界唯一不变的,就是变化。当我们对协议版本进行升级的时候,正确识别不同版本的协议对软件的兼容非常重要。那么,我们如何设计协议,才能够为将来的版本升级做准备呢? 答案可以在 IP 协议找到。 IP 协议的第一个字段叫 version,目前使用的是 4 或 6,分别表示 IPv4 和 IPv6。由于这个字段在协议的开头,接收端收到数据后,只要根据第一个字段的值就能够判断这个数据包是 IPv4 还是 IPv6。 再强调一下,这个字段在两个版本的IP协议都位于第一个字段,为了做兼容处理,对应的这个字段必须位于同一位置。文本协议(如,JSON、HTML)的情况类似。 8.2如何发送不定长数据的数据包? 举个例子,我们用微信发送一条消息。这条消息的长度是不确定的,并且每条消息都有它的边界。我们如何来处理这个边界呢? 还是一样,看看 IP。IP 的头部有个 header length 和 data length 两个字段。通过添加一个 len 域,我们就能够把数据根据应用逻辑分开。 跟这个相对的,还有另一个方案,那就是在数据的末尾放置终止符。比方说,想 C 语言的字符串那样,我们在每个数据的末尾放一个 \0 作为终止符,用以标识一条消息的尾部。这个方法带来的问题是,用户的数据也可能存在 \0。此时,我们就需要对用户的数据进行转义。比方说,把用户数据的所有 \0 都变成 \0\0。读消息的过程总,如果遇到 \0\0,那它就代表 \0,如果只有一个 \0,那就是消息尾部。 使用 len 字段的好处是,我们不需要对数据进行转义。读取数据的时候,只要根据 len 字段,一次性把数据都读进来就好,效率会更高一些。 终止符的方案虽然要求我们对数据进行扫描,但是如果我们可能从任意地方开始读取数据,就需要这个终止符来确定哪里才是消息的开头了。 当然,这两个方法不是互斥的,可以一起使用。 8.3上传多个文件,只有所有文件都上传成功时才算成功 现在我们有一个需求,需要一次上传多个文件到服务器,只有在所有文件都上传成功的情况下,才算成功。我们该如何来实现呢? IP 在数据报过大的时候,会把一个数据报拆分成多个,并设置一个 MF (more fragments)位,表示这个包只是被拆分后的数据的一部分。 好,我们也学一学 IP。这里,我们可以给每个文件从 0 开始编号。上传文件的同时,也携带这个编号,并额外附带一个 MF 标志。除了编号最大的文件,所有文件的 MF 标志都置位。因为 MF 没有置位的是最后一个文件,服务器就可以根据这个得出总共有多少个文件。 另一种不使用 MF 标志的方法是,我们在上传文件前,就告诉服务器总共有多少个文件。 如果读者对数据库比较熟悉,学数据库用事务来处理,也是可以的。这里就不展开讨论了。 8.4如何保证数据的有序性? 这里讲一个我曾经遇到过的面试题。现在有一个任务队列,多个工作线程从中取出任务并执行,执行结果放到一个结果队列中。先要求,放入结果队列的时候,顺序顺序需要跟从工作队列取出时的一样(也就是说,先取出的任务,执行结果需要先放入结果队列)。 我们看看 TCP/IP 是怎么处理的。IP 在发送数据的时候,不同数据报到达对端的时间是不确定的,后面发送的数据有可能较先到达。TCP 为了解决这个问题,给所发送数据的每个字节都赋了一个序列号,通过这个序列号,TCP 就能够把数据按原顺序重新组装。 一样,我们也给每个任务赋一个值,根据进入工作队列的顺序依次递增。工作线程完成任务后,在将结果放入结果队列前,先检查要放入对象的写一个序列号是不是跟自己的任务相同,如果不同,这个结果就不能放进去。此时,最简单的做法是等待,知道下一个可以放入队列的结果是自己所执行的那一个。但是,这个线程就没办法继续处理任务了。 更好的方法是,我们维护多一个结果队列的缓冲,这个缓冲里面的数据按序列号从小到大排序。 工作线程要将结果放入,有两种可能: 1)刚刚完成的任务刚好是下一个,将这个结果放入队列。然后从缓冲的头部开始,将所有可以放入结果队列的数据都放进去; 2)所完成的任务不能放入结果队列,这个时候就插入结果队列。然后,跟上一种情况一样,需要检查缓冲。 如果测试表明,这个结果缓冲的数据不多,那么使用普通的链表就可以。如果数据比较多,可以使用一个最小堆。 8.5如何保证对方收到了消息? 我们说,TCP 提供了可靠的传输。这样不就能够保证对方收到消息了吗? 很遗憾,其实不能。在我们往 socket 写入的数据,只要对端的内核收到后,就会返回 ACK,此时,socket 就认为数据已经写入成功。然而要注意的是,这里只是对方所运行的系统的内核成功收到了数据,并不表示应用程序已经成功处理了数据。 解决办法还是一样,我们学 TCP,添加一个应用层的 APP ACK。应用接收到消息并处理成功后,发送一个 APP ACK 给对方。 有了 APP ACK,我们需要处理的另一个问题是,如果对方真的没有收到,需要怎么做? TCP 发送数据的时候,消息一样可能丢失。TCP 发送数据后,如果长时间没有收到对方的 ACK,就假设数据已经丢失,并重新发送。 我们也一样,如果长时间没有收到 APP ACK,就假设数据丢失,重新发送一个。 关于数据送达保证和应应答机制,以下文章进行了详细讨论: 《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》 《IM消息送达保证机制实现(二):保证离线消息的可靠投递》 《IM群聊消息如此复杂,如何保证不丢不重?》 《从客户端的角度来谈谈移动端IM的消息可靠性和送达机制》 (原文链接:https://jekton.github.io/2018/06/23/socket-intro/,有改动) 附录:更多网络编程资料 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《UDP中一个包的大小最大能多大?》 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》 来源:即时通讯网 - 即时通讯开发者社区!

2018-10-17

网络编程懒人入门(七):深入浅出,全面理解HTTP协议

1、前言 HTTP(全称超文本传输协议,英文全称HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议。所有的WWW文件都必须遵守这个标准。设计HTTP最初的目的是为了提供一种发布和接收HTML页面的方法。 对于移动端即时通讯(尤其IM应用)来说,现今主流的数据通信总结下来无外乎就是长连接+短连接的方式,而短连接在应用上讲就是本文将要介绍的HTTP协议的应用,而而正确地理解HTTP协议对于写好IM来说,是相当有益的(关于移动端的HTTP具体应用情况,可以阅读《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》)。 本篇文章篇幅比较长,先来个思维导图预览一下:   2、“HTTP之父”其人   ▲ “HTTP之父”——Ted Nelson   ▲ HTTP协议logo 1960年Ted Nelson构思了一种通过计算机处理文本信息的方法,并称之为超文本(hypertext),这成为了HTTP超文本传输协议标准架构的发展根基。 Ted Nelson组织协调万维网协会(World Wide Web Consortium)和Internet工作小组(Internet Engineering Task Force)共同合作研究,最终发布了一系列的RFC,其中最著名的就是RFC 2616。RFC 2616定义了HTTP协议的我们今天普遍使用的一个版本——HTTP 1.1。 由于Ted Nelson对HTTP技术的发展做出的突破性历史贡献,他被称为“HTTP之父”。 3、系列文章 本文是系列文章中的第6篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》(本文) 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 如果您觉得本系列文章过于基础,您可直接阅读《不为人知的网络编程》系列文章,该系列目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 4、参考资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《简述传输层协议TCP和UDP的区别》 《为什么QQ用的是UDP协议而不是TCP协议?》 《移动端即时通讯协议选择:UDP还是TCP?》 5、HTTP概述 5.1计算机网络体系结构分层   5.2TCP/IP 通信传输流 利用 TCP/IP 协议族进行网络通信时,会通过分层顺序与对方进行通信。发送端从应用层往下走,接收端则从链路层往上走。 TCP/IP 通信传输流如下:   首先作为发送端的客户端在应用层(HTTP 协议)发出一个想看某个 Web 页面的 HTTP 请求; 接着,为了传输方便,在传输层(TCP 协议)把从应用层处收到的数据(HTTP 请求报文)进行分割,并在各个报文上打上标记序号及端口号后转发给网络层; 在网络层(IP 协议),增加作为通信目的地的 MAC 地址后转发给链路层。这样一来,发往网络的通信请求就准备齐全了; 接收端的服务器在链路层接收到数据,按序往上层发送,一直到应用层。当传输到应用层,才能算真正接收到由客户端发送过来的 HTTP请求。 HTTP 请求如下图所示: 在网络体系结构中,包含了众多的网络协议,这篇文章主要围绕 HTTP 协议(HTTP/1.1版本)展开。 HTTP协议(HyperText Transfer Protocol,超文本传输协议)是用于从WWW服务器传输超文本到本地浏览器的传输协议。它可以使浏览器更加高效,使网络传输减少。它不仅保证计算机正确快速地传输超文本文档,还确定传输文档中的哪一部分,以及哪部分内容首先显示(如文本先于图形)等。 HTTP是客户端浏览器或其他程序与Web服务器之间的应用层通信协议。在Internet上的Web服务器上存放的都是超文本信息,客户机需要通过HTTP协议传输所要访问的超文本信息。HTTP包含命令和传输信息,不仅可用于Web访问,也可以用于其他因特网/内联网应用系统之间的通信,从而实现各类应用资源超媒体访问的集成。 我们在浏览器的地址栏里输入的网站地址叫做URL (Uniform Resource Locator,统一资源定位符)。就像每家每户都有一个门牌地址一样,每个网页也都有一个Internet地址。当你在浏览器的地址框中输入一个URL或是单击一个超级链接时,URL就确定了要浏览的地址。浏览器通过超文本传输协议(HTTP),将Web服务器上站点的网页代码提取出来,并翻译成漂亮的网页。 6、HTTP 工作过程 HTTP请求响应模型:   HTTP通信机制是在一次完整的 HTTP 通信过程中,客户端与服务器之间将完成下列7个步骤: 1)建立 TCP 连接:在HTTP工作开始之前,客户端首先要通过网络与服务器建立连接,该连接是通过 TCP 来完成的,该协议与 IP 协议共同构建 Internet,即著名的 TCP/IP 协议族,因此 Internet 又被称作是 TCP/IP 网络。HTTP 是比 TCP 更高层次的应用层协议,根据规则,只有低层协议建立之后,才能进行高层协议的连接,因此,首先要建立 TCP 连接,一般 TCP 连接的端口号是80; 2)客户端向服务器发送请求命令:一旦建立了TCP连接,客户端就会向服务器发送请求命令; 例如:GET/sample/hello.jsp HTTP/1.1; 3)客户端发送请求头信息:客户端发送其请求命令之后,还要以头信息的形式向服务器发送一些别的信息,之后客户端发送了一空白行来通知服务器,它已经结束了该头信息的发送; 4)服务器应答:客户端向服务器发出请求后,服务器会客户端返回响应; 例如: HTTP/1.1 200 OK 响应的第一部分是协议的版本号和响应状态码; 5)服务器返回响应头信息:正如客户端会随同请求发送关于自身的信息一样,服务器也会随同响应向用户发送关于它自己的数据及被请求的文档; 6)服务器向客户端发送数据:服务器向客户端发送头信息后,它会发送一个空白行来表示头信息的发送到此为结束,接着,它就以 Content-Type 响应头信息所描述的格式发送用户所请求的实际数据; 7)服务器关闭 TCP 连接:一般情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接,然后如果客户端或者服务器在其头信息加入了这行代码 Connection:keep-alive ,TCP 连接在发送后将仍然保持打开状态,于是,客户端可以继续通过相同的连接发送请求。保持连接节省了为每个请求建立新连接所需的时间,还节约了网络带宽。 7、HTTP 协议基础 7.1通过请求和响应的交换达成通信 应用 HTTP 协议时,必定是一端担任客户端角色,另一端担任服务器端角色。仅从一条通信线路来说,服务器端和客服端的角色是确定的。HTTP 协议规定,请求从客户端发出,最后服务器端响应该请求并返回。换句话说,肯定是先从客户端开始建立通信的,服务器端在没有接收到请求之前不会发送响应。 7.2HTTP 是不保存状态的协议 HTTP 是一种无状态协议。协议自身不对请求和响应之间的通信状态进行保存。也就是说在 HTTP 这个级别,协议对于发送过的请求或响应都不做持久化处理。这是为了更快地处理大量事务,确保协议的可伸缩性,而特意把 HTTP 协议设计成如此简单的。 可是随着 Web 的不断发展,我们的很多业务都需要对通信状态进行保存。于是我们引入了 Cookie 技术。有了 Cookie 再用 HTTP 协议通信,就可以管理状态了。 7.3使用 Cookie 的状态管理 Cookie 技术通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。Cookie 会根据从服务器端发送的响应报文内的一个叫做 Set-Cookie 的首部字段信息,通知客户端保存Cookie。当下次客户端再往该服务器发送请求时,客户端会自动在请求报文中加入 Cookie 值后发送出去。服务器端发现客户端发送过来的 Cookie 后,会去检查究竟是从哪一个客户端发来的连接请求,然后对比服务器上的记录,最后得到之前的状态信息。 Cookie 的流程:   7.4请求 URI 定位资源 HTTP 协议使用 URI 定位互联网上的资源。正是因为 URI 的特定功能,在互联网上任意位置的资源都能访问到。 7.5告知服务器意图的 HTTP 方法(HTTP/1.1)   7.6持久连接 HTTP 协议的初始版本中,每进行一个 HTTP 通信都要断开一次 TCP 连接。比如使用浏览器浏览一个包含多张图片的 HTML 页面时,在发送请求访问 HTML 页面资源的同时,也会请求该 HTML 页面里包含的其他资源。因此,每次的请求都会造成无畏的 TCP 连接建立和断开,增加通信量的开销。 为了解决上述 TCP 连接的问题,HTTP/1.1 和部分 HTTP/1.0 想出了持久连接的方法。其特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。旨在建立一次 TCP 连接后进行多次请求和响应的交互。在 HTTP/1.1 中,所有的连接默认都是持久连接。 7.7管线化 持久连接使得多数请求以管线化方式发送成为可能。以前发送请求后需等待并接收到响应,才能发送下一个请求。管线化技术出现后,不用等待亦可发送下一个请求。这样就能做到同时并行发送多个请求,而不需要一个接一个地等待响应了。 比如,当请求一个包含多张图片的 HTML 页面时,与挨个连接相比,用持久连接可以让请求更快结束。而管线化技术要比持久连接速度更快。请求数越多,时间差就越明显。 8、HTTP 协议报文结构 8.1HTTP 报文 用于 HTTP 协议交互的信息被称为 HTTP 报文。请求端(客户端)的 HTTP 报文叫做请求报文;响应端(服务器端)的叫做响应报文。HTTP 报文本身是由多行(用 CR+LF 作换行符)数据构成的字符串文本。 8.2HTTP 报文结构 HTTP 报文大致可分为报文首部和报文主体两部分。两者由最初出现的空行(CR+LF)来划分。通常,并不一定有报文主体。 HTTP 报文结构如下:   8.3请求报文结构 请求报文的首部内容由以下数据组成: 请求行 —— 包含用于请求的方法、请求 URI 和 HTTP 版本; 首部字段 —— 包含表示请求的各种条件和属性的各类首部。(通用首部、请求首部、实体首部以及RFC里未定义的首部如 Cookie 等)。 请求报文的示例,如下: 8.4响应报文结构 响应报文的首部内容由以下数据组成: 状态行 —— 包含表明响应结果的状态码、原因短语和 HTTP 版本; 首部字段 —— 包含表示请求的各种条件和属性的各类首部。(通用首部、响应首部、实体首部以及RFC里未定义的首部如 Cookie 等)。 响应报文的示例,如下: 9、HTTP 报文首部之首部字段(重点分析) 9.1首部字段概述 先来回顾一下首部字段在报文的位置,HTTP 报文包含报文首部和报文主体,报文首部包含请求行(或状态行)和首部字段。 在报文众多的字段当中,HTTP 首部字段包含的信息最为丰富。首部字段同时存在于请求和响应报文内,并涵盖 HTTP 报文相关的内容信息。使用首部字段是为了给客服端和服务器端提供报文主体大小、所使用的语言、认证信息等内容。 9.2首部字段结构 HTTP 首部字段是由首部字段名和字段值构成的,中间用冒号“:”分隔。 另外,字段值对应单个 HTTP 首部字段可以有多个值。 当 HTTP 报文首部中出现了两个或以上具有相同首部字段名的首部字段时,这种情况在规范内尚未明确,根据浏览器内部处理逻辑的不同,优先处理的顺序可能不同,结果可能并不一致。 9.3首部字段类型 首部字段根据实际用途被分为以下4种类型: 9.4通用首部字段(HTTP/1.1) 9.5请求首部字段(HTTP/1.1) 9.6响应首部字段(HTTP/1.1) 9.7实体首部字段(HTTP/1.1) 9.8为 Cookie 服务的首部字段 10、其他首部字段 HTTP 首部字段是可以自行扩展的。所以在 Web 服务器和浏览器的应用上,会出现各种非标准的首部字段。以下是最为常用的首部字段。 X-Frame-Options: X-Frame-Options: DENY 首部字段 X-Frame-Options 属于 HTTP 响应首部,用于控制网站内容在其他 Web 网站的 Frame 标签内的显示问题。其主要目的是为了防止点击劫持(clickjacking)攻击。首部字段 X-Frame-Options 有以下两个可指定的字段值: DENY:拒绝; SAMEORIGIN:仅同源域名下的页面(Top-level-browsing-context)匹配时许可。(比如,当指定 http://sample.com/sample.html 页面为 SAMEORIGIN 时,那么 sample.com 上所有页面的 frame 都被允许可加载该页面,而 example.com 等其他域名的页面就不行了)。 X-XSS-Protection: X-XSS-Protection: 1 首部字段 X-XSS-Protection 属于 HTTP 响应首部,它是针对跨站脚本攻击(XSS)的一种对策,用于控制浏览器 XSS 防护机制的开关。首部字段 X-XSS-Protection 可指定的字段值如下: 0 :将 XSS 过滤设置成无效状态 1 :将 XSS 过滤设置成有效状态 DNT: DNT: 1 首部字段 DNT 属于 HTTP 请求首部,其中 DNT 是 Do Not Track 的简称,意为拒绝个人信息被收集,是表示拒绝被精准广告追踪的一种方法。首部字段 DNT 可指定的字段值如下: 0 :同意被追踪 1 :拒绝被追踪 由于首部字段 DNT 的功能具备有效性,所以 Web 服务器需要对 DNT做对应的支持。 P3P: P3P: CP="CAO DSP LAW CURa ADMa DEVa TAIa PSAa PSDa IVAa IVDa OUR BUS IND 首部字段 P3P 属于 HTTP 响应首部,通过利用 P3P(The Platform for Privacy Preferences,在线隐私偏好平台)技术,可以让 Web 网站上的个人隐私变成一种仅供程序可理解的形式,以达到保护用户隐私的目的。 要进行 P3P 的设定,需按以下操作步骤进行: 步骤 1:创建 P3P 隐私 步骤 2:创建 P3P 隐私对照文件后,保存命名在 /w3c/p3p.xml 步骤 3:从 P3P 隐私中新建 Compact policies 后,输出到 HTTP 响应中 11、HTTP 响应状态码 消息 描述 100 Continue 服务器仅接收到部分请求,但是一旦服务器并没有拒绝该请求,客户端应该继续发送其余的请求。 101 Switching Protocols 服务器转换协议:服务器将遵从客户的请求转换到另外一种协议。 消息 描述 200 OK 请求成功(其后是对GET和POST请求的应答文档。) 201 Created 请求被创建完成,同时新的资源被创建。 202 Accepted 供处理的请求已被接受,但是处理未完成。 203 Non-authoritative Information 文档已经正常地返回,但一些应答头可能不正确,因为使用的是文档的拷贝。 204 No Content 没有新文档。浏览器应该继续显示原来的文档。如果用户定期地刷新页面,而Servlet可以确定用户文档足够新,这个状态代码是很有用的。 205 Reset Content 没有新文档。但浏览器应该重置它所显示的内容。用来强制浏览器清除表单输入内容。 206 Partial Content 客户发送了一个带有Range头的GET请求,服务器完成了它。 消息 描述 300 Multiple Choices 多重选择。链接列表。用户可以选择某链接到达目的地。最多允许五个地址。 301 Moved Permanently 所请求的页面已经转移至新的url。 302 Found 所请求的页面已经临时转移至新的url。 303 See Other 所请求的页面可在别的url下被找到。 304 Not Modified 未按预期修改文档。客户端有缓冲的文档并发出了一个条件性的请求(一般是提供If-Modified-Since头表示客户只想比指定日期更新的文档)。服务器告诉客户,原来缓冲的文档还可以继续使用。 305 Use Proxy 客户请求的文档应该通过Location头所指明的代理服务器提取。 306 Unused 此代码被用于前一版本。目前已不再使用,但是代码依然被保留。 307 Temporary Redirect 被请求的页面已经临时移至新的url。 消息 描述 400 Bad Request 服务器未能理解请求。 401 Unauthorized 被请求的页面需要用户名和密码。 401.1 登录失败。 401.2 服务器配置导致登录失败。 401.3 由于 ACL 对资源的限制而未获得授权。 401.4 筛选器授权失败。 401.5 ISAPI/CGI 应用程序授权失败。 401.7 访问被 Web 服务器上的 URL 授权策略拒绝。这个错误代码为 IIS 6.0 所专用。 402 Payment Required 此代码尚无法使用。 403 Forbidden 对被请求页面的访问被禁止。 403.1 执行访问被禁止。 403.2 读访问被禁止。 403.3 写访问被禁止。 403.4 要求 SSL。 403.5 要求 SSL 128。 403.6 IP 地址被拒绝。 403.7 要求客户端证书。 403.8 站点访问被拒绝。 403.9 用户数过多。 403.10 配置无效。 403.11 密码更改。 403.12 拒绝访问映射表。 403.13 客户端证书被吊销。 403.14 拒绝目录列表。 403.15 超出客户端访问许可。 403.16 客户端证书不受信任或无效。 403.17 客户端证书已过期或尚未生效。 403.18 在当前的应用程序池中不能执行所请求的 URL。这个错误代码为 IIS 6.0 所专用。 403.19 不能为这个应用程序池中的客户端执行 CGI。这个错误代码为 IIS 6.0 所专用。 403.20 Passport 登录失败。这个错误代码为 IIS 6.0 所专用。 404 Not Found 服务器无法找到被请求的页面。 404.0 (无)–没有找到文件或目录。 404.1 无法在所请求的端口上访问 Web 站点。 404.2 Web 服务扩展锁定策略阻止本请求。 404.3 MIME 映射策略阻止本请求。 405 Method Not Allowed 请求中指定的方法不被允许。 406 Not Acceptable 服务器生成的响应无法被客户端所接受。 407 Proxy Authentication Required 用户必须首先使用代理服务器进行验证,这样请求才会被处理。 408 Request Timeout 请求超出了服务器的等待时间。 409 Conflict 由于冲突,请求无法被完成。 410 Gone 被请求的页面不可用。 411 Length Required "Content-Length" 未被定义。如果无此内容,服务器不会接受请求。 412 Precondition Failed 请求中的前提条件被服务器评估为失败。 413 Request Entity Too Large 由于所请求的实体的太大,服务器不会接受请求。 414 Request-url Too Long 由于url太长,服务器不会接受请求。当post请求被转换为带有很长的查询信息的get请求时,就会发生这种情况。 415 Unsupported Media Type 由于媒介类型不被支持,服务器不会接受请求。 416 Requested Range Not Satisfiable 服务器不能满足客户在请求中指定的Range头。 417 Expectation Failed 执行失败。 423 锁定的错误。 消息 描述 500 Internal Server Error 请求未完成。服务器遇到不可预知的情况。 500.12 应用程序正忙于在 Web 服务器上重新启动。 500.13 Web 服务器太忙。 500.15 不允许直接请求 Global.asa。 500.16 UNC 授权凭据不正确。这个错误代码为 IIS 6.0 所专用。 500.18 URL 授权存储不能打开。这个错误代码为 IIS 6.0 所专用。 500.100 内部 ASP 错误。 501 Not Implemented 请求未完成。服务器不支持所请求的功能。 502 Bad Gateway 请求未完成。服务器从上游服务器收到一个无效的响应。 502.1 CGI 应用程序超时。 · 502.2 CGI 应用程序出错。 503 Service Unavailable 请求未完成。服务器临时过载或当机。 504 Gateway Timeout 网关超时。 505 HTTP Version Not Supported 服务器不支持请求中指明的HTTP协议版本。 12、HTTP 报文实体 12.1HTTP 报文实体概述 大家请仔细看看上面示例中,各个组成部分对应的内容。 接着,我们来看看报文和实体的概念。如果把 HTTP 报文想象成因特网货运系统中的箱子,那么 HTTP 实体就是报文中实际的货物。 报文:是网络中交换和传输的数据单元,即站点一次性要发送的数据块。报文包含了将要发送的完整的数据信息,其长短很不一致,长度不限且可变; 实体:作为请求或响应的有效载荷数据(补充项)被传输,其内容由实体首部和实体主体组成。(实体首部相关内容在上面第六点中已有阐述。) 我们可以看到,上面示例右图中深红色框的内容就是报文的实体部分,而蓝色框的两部分内容分别就是实体首部和实体主体。而左图中粉红框内容就是报文主体。 通常,报文主体等于实体主体。只有当传输中进行编码操作时,实体主体的内容发生变化,才导致它和报文主体产生差异。 12.2内容编码 HTTP 应用程序有时在发送之前需要对内容进行编码。例如,在把很大的 HTML 文档发送给通过慢速连接上来的客户端之前,服务器可能会对其进行压缩,这样有助于减少传输实体的时间。服务器还可以把内容搅乱或加密,以此来防止未授权的第三方看到文档的内容。 这种类型的编码是在发送方应用到内容之上的。当内容经过内容编码后,编好码的数据就放在实体主体中,像往常一样发送给接收方。 内容编码类型: 12.3传输编码 内容编码是对报文的主体进行的可逆变换,是和内容的具体格式细节紧密相关的。 传输编码也是作用在实体主体上的可逆变换,但使用它们是由于架构方面的原因,同内容的格式无关。使用传输编码是为了改变报文中的数据在网络上传输的方式。 12.4分块编码 分块编码把报文分割成若干已知大小的块。块之间是紧挨着发送的,这样就不需要在发送之前知道整个报文的大小了。分块编码是一种传输编码,是报文的属性。 若客户端与服务器端之间不是持久连接,客户端就不需要知道它在读取的主体的长度,而只需要读取到服务器关闭主体连接为止。 当使用持久连接时,在服务器写主体之前,必须知道它的大小并在 Content-Length 首部中发送。如果服务器动态创建内容,就可能在发送之前无法知道主体的长度。 分块编码为这种困难提供了解决方案,只要允许服务器把主体分块发送,说明每块的大小就可以了。因为主体是动态创建的,服务器可以缓冲它的一部分,发送其大小和相应的块,然后在主体发送完之前重复这个过程。服务器可以用大小为 0 的块作为主体结束的信号,这样就可以继续保持连接,为下一个响应做准备。 来看看一个分块编码的报文示例: 12.5多部分媒体类型 MIME 中的 multipart(多部分)电子邮件报文中包含多个报文,它们合在一起作为单一的复杂报文发送。每一部分都是独立的,有各自的描述其内容的集,不同部分之间用分界字符串连接在一起。 相应得,HTTP 协议中也采纳了多部分对象集合,发送的一份报文主体内可包含多种类型实体。 多部分对象集合包含的对象如下: multipart/form-data:在 Web 表单文件上传时使用; multipart/byteranges:状态码 206 Partial Content 响应报文包含了多个范围的内容时使用。 12.6范围请求 假设你正在下载一个很大的文件,已经下了四分之三,忽然网络中断了,那下载就必须重头再来一遍。为了解决这个问题,需要一种可恢复的机制,即能从之前下载中断处恢复下载。要实现该功能,这就要用到范围请求。 有了范围请求, HTTP 客户端可以通过请求曾获取失败的实体的一个范围(或者说一部分),来恢复下载该实体。当然这有一个前提,那就是从客户端上一次请求该实体到这一次发出范围请求的时间段内,该对象没有改变过。例如: 1 2 3 4 GET  /bigfile.html  HTTP/1.1 Host: [url=http://www.sample.com]www.sample.com[/url] Range: bytes=20224- ··· 上面示例中,客户端请求的是文档开头20224字节之后的部分。 13、与 HTTP 协作的 Web 服务器 HTTP 通信时,除客户端和服务器外,还有一些用于协助通信的应用程序。如下列出比较重要的几个:代理、缓存、网关、隧道、Agent 代理。 13.1代理 HTTP 代理服务器是 Web 安全、应用集成以及性能优化的重要组成模块。代理位于客户端和服务器端之间,接收客户端所有的 HTTP 请求,并将这些请求转发给服务器(可能会对请求进行修改之后再进行转发)。对用户来说,这些应用程序就是一个代理,代表用户访问服务器。 出于安全考虑,通常会将代理作为转发所有 Web 流量的可信任中间节点使用。代理还可以对请求和响应进行过滤,安全上网或绿色上网。 13.2缓存 浏览器第一次请求: 浏览器再次请求: Web 缓存或代理缓存是一种特殊的 HTTP 代理服务器,可以将经过代理传输的常用文档复制保存起来。下一个请求同一文档的客户端就可以享受缓存的私有副本所提供的服务了。客户端从附近的缓存下载文档会比从远程 Web 服务器下载快得多。 13.3网关 网关是一种特殊的服务器,作为其他服务器的中间实体使用。通常用于将 HTTP 流量转换成其他的协议。网关接收请求时就好像自己是资源的源服务器一样。客户端可能并不知道自己正在跟一个网关进行通信。 13.4隧道 隧道是会在建立起来之后,就会在两条连接之间对原始数据进行盲转发的 HTTP 应用程序。HTTP 隧道通常用来在一条或多条 HTTP 连接上转发非 HTTP 数据,转发时不会窥探数据。 HTTP 隧道的一种常见用途就是通过 HTTP 连接承载加密的安全套接字层(SSL)流量,这样 SSL 流量就可以穿过只允许 Web 流量通过的防火墙了。 13.5Agent 代理 Agent 代理是代表用户发起 HTTP 请求的客户端应用程序。所有发布 Web 请求的应用程序都是 HTTP Agent 代理。 (原文链接:https://www.jianshu.com/p/6e9e4156ece3) 附录:更多网络编程资料 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《UDP中一个包的大小最大能多大?》 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》  来源:即时通讯网 - 即时通讯开发者社区!

2018-10-17

网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门

1、前言 即时通讯网整理了大量的网络编程类基础文章和资料,包括《TCP/IP协议 卷1》、《[通俗易懂]深入理解TCP协议》系列、《网络编程懒人入门》系列、《不为人知的网络编程》系列、《P2P技术详解》系列、《高性能网络编程》系列、甚至还有图文并貌+实战代码的《NIO框架入门》等,目的是帮助即时通讯类应用的开发者,至少要掌握网络编程最基本的原理,所谓知其然更要知其所以然。尤其现在移动网络大行其道的时代,在网络环境如此复杂的情况下,能写好一套技术精湛、用户体验等俱佳的IM或消息推送系统,显然不是随便用用Netty、MINA、AFNetwoking、okhttp等服务端和客户端框架就能搞定的事。总之,即时通讯技术归根结底还是网络编程技术的应用,只有更深入地了解了网络编程及其相关知识,才能更好地写出优质的应用。 实际上计算机网络编程或者网络通信技术最基本的物理载体,就是集线器、交换机、路由器这些基本设备,了解这些基本设备的工作原理,对于程序员来说是基本素养,总不能什么事都甩锅给网管,何况中小公司根本就没有条件配备专职网管,还是得程序员亲自动手。但技多不压身,何况这些设备和技术总比那些毫无技术含量的插删改查代码撸起来有意思。话不多说,回归正题吧。 本文旨在简单地说明集线器、交换机与路由器的区别,因而忽略了很多细节,三者实际的发展过程和工作原理并非文中所写的这么简单。如果你看完本文能大概了解到三者的异同,本文的目的就达到了。至于更具体的技术问题,欢迎在留言中探讨。 另外,如果您正打算从零开发移动端IM,则建议您从《新手入门一篇就够:从零开发移动端IM》一文开始,此文按照IM开发所需的知识和技能要求,拟定了详尽的学习提纲和建议等。 2、系列文章 本文是系列文章中的第6篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》(本文) 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 如果您觉得本系列文章过于基础,您可直接阅读《不为人知的网络编程》系列文章,该系列目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 3、帝国时代 我相信我们都玩过一款特别火的游戏:帝国时代。小时候想要玩帝国时代,需要到软件城购买盗版光盘安装,大概3块钱一张左右的样子,当时已经觉得很便宜了,谁想到现在有了网络之后是免费。 小A是一个帝国时代大神,他打通了游戏的所有关卡,可以一个人单挑8个疯狂的电脑玩家。渐渐地他觉得无聊了,想要找小伙伴一起PK。 但是两个电脑需要互联才行,如何实现两台设备的互联呢? 小A很聪明,他发明了一个类似于USB口一样的可以传输数据的端口,他将其命名为网口。小A通过一根网线将自己的电脑与小B的网口相连,实现了两台电脑间的互连(如下图)。 4、集线器(Hub) 两个小伙伴很开心,联机玩了起来,这时被路过的小C看见了,小C也要加入进来。 但是我们知道,每台电脑只有一个网口,无法实现三台电脑的相互连接,那要要怎么办呢?   这时候小B出了一个主意:咱们再找一台计算机,给他多设计几个网口,我们每个人都连到这台计算机的网口上,不也实现咱们哥几个之间的互连了吗。 说干就干,于是他们设计出了一款微型计算机,他本身具备多个网口,专门实现多台计算机的互联作用,这个微型计算机就是集线器(HUB)。 顾名思义,集线器起到了一个将网线集结起来的作用,实现最初级的网络互通。 集线器是通过网线直接传送数据的,我们说他工作在物理层(如下图所示)。   5、交换机 有了集线器后,越来越多的小伙伴加入到游戏中,小D、小E等人都慕名而来。 然而集线器有一个问题,由于和每台设备相连,他不能分辨出具体信息是发送给谁的,只能广泛地广播出去。 例如小A本来想问小C:你吃了吗?结果小B,小D和小E等所有连接在集线器上的用户都收到了这一信息。 由于处于同一网络,小A说话时其他人不能发言,否则信息间会产生碰撞,引发错误,对这种情况,我们称为各设备处于同一冲突域内。     这样的设备用户体验极差,于是小伙伴们一起讨论改进措施。 这时聪明的小D发话了:我们给这台设备加入一个指令,让他可以根据网口名称自动寻址传输数据。 比如我把小A的网口命名为macA,将小C的命名为macC,这时如果小A想要将数据传给小C,则设备会根据网口名称macA和macC自动将资料从A的电脑传送到C的电脑中,而不让小B、小D和小E收到。 (补充说明: 这里的macA, macB指的就是MAC地址,相当于一个人的身份证,独一无二。) 也就是说,这台设备解决了冲突的问题,实现了任意两台电脑间的互联,大大地提升了网络间的传输速度,我们把它叫做交换机。 由于交换机是根据网口地址传送信息,比网线直接传送多了一个步骤,我们也说交换机工作在数据链路层(如下图)。     这回小伙伴们高兴了,他们愉快地玩耍起来。 6、路由器 渐渐地,他们在当地有了名气,吸引了越来越多的小伙伴加入到他们的队伍中。有一天,一个外村的小伙突然找上门来,希望能和他们一起互联,实现跨村间的网络对战。 小A说可以呀,于是他们找了一根超长的网线将两个村落的交换机连在了一起。结果发现一件奇怪的事:两个村落间竟然不能相互通信。 怎么回事?原来那边的电脑和他们用的不是一套操作系统,这导致信息间的传送形式的不匹配。在这期间,还有其他村落的人也来找过小A,可是小A发现,每个村子之间用的操作系统都不一样。 这可咋办呐?难道以后只能各自村子玩各自的了吗?为了解决这一问题,各村的小伙伴们坐在一起组织了一场会议,最终得出了一套解决方案:采用同样的信息传送形式(像不像秦始皇统一度量衡?)。 那如何实现呢?小伙伴们规定,不同的村子间先在各自的操作系统上加上一套相同的协议。不同村落通信时,信息经协议加工成统一形式,再经由一个特殊的设备传送出去。这个设备就叫做路由器。 在这套协议中,每个机器都被赋予了一个IP地址,相当于一个门牌号一样。路由器通过IP地址寻址,我们说它工作在计算机的网络层。 这样,经由如此的一系列改装,小A终于带领村民们实现了整个乡镇的通信。随着越来越多的城里人也加入小A的协议,小A带领村民逐步实现了全市、全国乃至全世界的通信。这一套协议便是TCP/IP协议簇,互联网也便这样形成了(关于TCP/IP改变世界的故事,详见《技术往事:改变世界的TCP/IP协议》)。   然而,即便如今全网络已遍布了全世界,在小A和村里的小伙伴对战帝国时代的时候,也仍然用着交换机。只有和外面更大的世界交流的时候才用到路由器。 其实上图只是为了帮助您更好地理解路由器,一个真实的网络拓扑中,路由器、交换机、集线器是一起分工合作的,正如下图所示:   (如上图所示:Router即路由器、Switch即交换机、Hub即集线器) 附录:更多网络编程资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《UDP中一个包的大小最大能多大?》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《聊聊iOS中网络编程长连接的那些事》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 《IPv6技术详解:基本概念、应用现状、技术实践(上篇)》 《IPv6技术详解:基本概念、应用现状、技术实践(下篇)》 来源:即时通讯网 - 即时通讯开发者社区!作者:JackJiang

2018-10-17

网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势

1、前言 对于即时通讯开者新手来说,在开始着手编写IM或消息推送系统的代码前,最头疼的问题莫过于到底该选TCP还是UDP作为传输层协议。本文延续《网络编程懒人入门》系列文章的风格,通过快速对比分析 TCP 和 UDP 的区别,来帮助即时通讯初学者快速了解这些基础的知识点,从而在IM、消息推送等网络通信应用场景中能准确地选择合适的传输层协议。 随着网络技术飞速发展,网速已不再是传输的瓶颈,UDP协议以其简单、传输快的优势,在越来越多场景下取代了TCP,如网页浏览、流媒体、实时游戏、物联网。本文作为《网络编程懒人入门》系列文章的第5篇,将为您快速梳理UDP协议在某些场景下对比TCP协议所具有的优势。 另外,即时通讯网的文章:《简述传输层协议TCP和UDP的区别》、《为什么QQ用的是UDP协议而不是TCP协议?》、《移动端即时通讯协议选择:UDP还是TCP?》,更详细地阐述了类似的内容,可以为您提供更多的参考。 2、系列文章 本文是系列文章中的第4篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》(本文) 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 如果您觉得本系列文章过于基础,可直接阅读《不为人知的网络编程》系列文章,目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 3、参考资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《计算机网络通讯协议关系图(中文珍藏版)》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《简述传输层协议TCP和UDP的区别》 《UDP中一个包的大小最大能多大?》 《为什么QQ用的是UDP协议而不是TCP协议?》 《移动端即时通讯协议选择:UDP还是TCP?》 4、网速的提升给UDP稳定性提供可靠网络保障 CDN服务商Akamai报告从2008年到2015年7年时间,各个国家网络平均速率由1.5Mbps提升为5.1Mbps,网速提升近4倍。网络环境变好,网络传输的延迟、稳定性也随之改善,UDP的丢包率低于5%,如果再使用应用层重传,能够完全确保传输的可靠性。 5、对比测试结果UDP性能优于TCP 为了提升浏览速度,Google基于TCP提出了SPDY协议以及HTTP/2。Google在Chrome上实验基于UDP的QUIC协议,传输速率减少到100ms以内。   Google采用QUIC后连接速率能有效提升75%; Google搜索采用QUIC后页面加载性能提升3%; YouTube采用QUIC后重新缓冲次数减少了30%。 6、TCP设计过于冗余,速度难以进一步提升 TCP为了实现网络通信的可靠性,使用了复杂的拥塞控制算法,建立了繁琐的握手过程以及重传策略。由于TCP内置在系统协议栈中,极难对其进行改进。 7、UDP协议以其简单、传输快的优势,在越来越多场景下取代了TCP 7.1网页浏览 使用UDP协议有三个优点 : 能够对握手过程进行精简,减少网络通信往返次数; 能够对TLS加解密过程进行优化; 收发快速,无阻塞。 7.2流媒体 采用TCP,一旦发生丢包,TCP会将后续包缓存起来,等前面的包重传并接收到后再继续发送,延迟会越来越大。基于UDP的协议如实时音视频开源工程WebRTC是极佳的选择。 2010年google 通过收购 Global IP Solutions,获得了WebRTC(网页实时通信Web Real-Time Communication)技术,用于提升网页视频速率。关于WebRTC的介绍,请见:《访谈WebRTC标准之父:WebRTC的过去、现在和未来》,更多WebRTC文章点此进入。 7.3实时游戏 对实时要求较为严格的情况下,采用自定义的可靠UDP协议,比如Enet、RakNet(用户有 sony online game、minecraft)等,自定义重传策略,能够把丢包产生的延迟降到最低,尽量减少网络问题对游戏性造成的影响。 采用UDP的经典游戏如FPS游戏Quake、CS,著名的游戏引擎Unity3D采用的也是RakNet。 7.4物联网 2014年google旗下的Nest建立Thread Group,推出了物联网通信协议Thread,完善物联网通信。 采用UDP有3个关键点: 网络带宽需求较小,而实时性要求高; 大部分应用无需维持连接; 需要低功耗。 8、本文小结 如今全球将近50%的人都在使用互联网,人们不断的追求更快、更好的服务,一切都在变化,在越来越多的领域,UDP将会抢占TCP的主导地位。 附录:更多高性能网络编程文章 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《有关“为何选择Netty”的11个疑问及解答》 《开源NIO框架八卦——到底是先有MINA还是先有Netty?》 《选Netty还是Mina:深入研究与对比(一)》 《选Netty还是Mina:深入研究与对比(二)》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《Netty 4.x学习(一):ByteBuf详解》 《Netty 4.x学习(二):Channel和Pipeline详解》 《Netty 4.x学习(三):线程模型详解》 《Apache Mina框架高级篇(一):IoFilter详解》 《Apache Mina框架高级篇(二):IoHandler详解》 《MINA2 线程原理总结(含简单测试实例)》 《Apache MINA2.0 开发指南(中文版)[附件下载]》 《MINA、Netty的源代码(在线阅读版)已整理发布》 《解决MINA数据传输中TCP的粘包、缺包问题(有源码)》 《解决Mina中多个同类型Filter实例共存的问题》 《实践总结:Netty3.x升级Netty4.x遇到的那些坑(线程篇)》 《实践总结:Netty3.x VS Netty4.x的线程模型》 《详解Netty的安全性:原理介绍、代码演示(上篇)》 《详解Netty的安全性:原理介绍、代码演示(下篇)》 《详解Netty的优雅退出机制和原理》 《NIO框架详解:Netty的高性能之道》 《Twitter:如何使用Netty 4来减少JVM的GC开销(译文)》 《绝对干货:基于Netty实现海量接入的推送服务技术要点》 《Netty干货分享:京东京麦的生产级TCP网关技术实践总结》 (原文链接:https://yq.aliyun.com/articles/88122,有改动) 来源:即时通讯网 - 即时通讯开发者社区!

2018-10-17

网络编程懒人入门(四):快速理解TCP和UDP的差异

1、前言 对于即时通讯开者新手来说,在开始着手编写IM或消息推送系统的代码前,最头疼的问题莫过于到底该选TCP还是UDP作为传输层协议。本文延续《网络编程懒人入门》系列文章的风格,通过快速对比分析 TCP 和 UDP 的区别,来帮助即时通讯初学者快速了解这些基础的知识点,从而在IM、消息推送等网络通信应用场景中能准确地选择合适的传输层协议。 即时通讯网的另一篇文章《简述传输层协议TCP和UDP的区别》也阐述了类似的内容,希望能为您提供更多的参考。 2、系列文章 本文是系列文章中的第4篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》(本文) 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 如果您觉得本系列文章过于基础,您可直接阅读《不为人知的网络编程》系列文章,该系列目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 3、参考资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《计算机网络通讯协议关系图(中文珍藏版)》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《简述传输层协议TCP和UDP的区别》 《UDP中一个包的大小最大能多大?》 《为什么QQ用的是UDP协议而不是TCP协议?》 《移动端即时通讯协议选择:UDP还是TCP?》 4、建立连接方式的差异 4.1TCP 说到 TCP 建立连接,相信大多数人脑海里肯定可以浮现出一个词,没错就是--“三次握手”。TCP 通过“三次握手”来建立连接,再通过“四次挥手”断开一个连接。在每次挥手中 TCP 做了哪些操作呢? 流程如下图所示(TCP的三次握手和四次挥手):   上图就从客户端和服务端的角度,清楚的展示了 TCP 的三次握手和四次挥手。可以看到,当 TCP 试图建立连接时,三次握手指的是客户端主动触发了两次,服务端触发了一次。 我们可以先明确一下 TCP 建立连接并且初始化的目标是什么呢? 1)初始化资源; 2)告诉对方我的序列号。 所以三次握手的次序是这样子的: 1)client端首先发送一个SYN包告诉Server端我的初始序列号是X; 2)Server端收到SYN包后回复给client一个ACK确认包,告诉client说我收到了; 3)接着Server端也需要告诉client端自己的初始序列号,于是Server也发送一个SYN包告诉client我的初始序列号是Y; 4)Client收到后,回复Server一个ACK确认包说我知道了。 其中的 2 、3 步骤可以简化为一步,也就是说将 ACK 确认包和 SYN 序列化包一同发送给 Client 端。到此我们就比较简单的解释了 TCP 建立连接的“三次握手”。 4.2UDP 我们都知道 TCP 是面向连接的、可靠的、有序的传输层协议,而 UDP 是面向数据报的、不可靠的、无序的传输协议,所以 UDP 压根不会建立什么连接。 就好比发短信一样,UDP 只需要知道对方的 ip 地址,将数据报一份一份的发送过去就可以了,其他的作为发送方,都不需要关心。 (关于TCP的3次握手和4次挥手文章,可详见《理论经典:TCP协议的3次握手与4次挥手过程详解》、《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》) 5、数据发送方式的差异 关于 TCP、UDP 之间数据发送的差异,可以体现二者最大的不同之处: TCP: 由于 TCP 是建立在两端连接之上的协议,所以理论上发送的数据流不存在大小的限制。但是由于缓冲区有大小限制,所以你如果用 TCP 发送一段很大的数据,可能会截断成好几段,接收方依次的接收。 UDP: 由于 UDP 本身发送的就是一份一份的数据报,所以自然而然的就有一个上限的大小。 那么每次 UDP 发送的数据报大小由哪些因素共同决定呢? UDP协议本身,UDP协议中有16位的UDP报文长度,那么UDP报文长度不能超过2^16=65536; 以太网(Ethernet)数据帧的长度,数据链路层的MTU(最大传输单元); socket的UDP发送缓存区大小。 先来看第一个因素,UDP 本身协议的报文长度为 2^16 - 1,UDP 包头占 8 个字节,IP 协议本身封装后包头占 20 个字节,所以最终长度为: 2^16 - 1 - 20 - 8 = 65507 字节。 只看第一个因素有点理想化了,因为 UDP 属于不可靠协议,我们应该尽量避免在传输过程中,数据包被分割。所以这里有一个非常重要的概念 MTU -- 也就是最大传输单元。 在 Internet 下 MTU 的值为 576 字节,所以在 internet 下使用 UDP 协议,每个数据报最大的字节数为: 576 - 20 - 8 = 548 (有关UDP协议的最大包长限制,详见《UDP中一个包的大小最大能多大?》) 6、数据有序性的差异 我们再来谈谈数据的有序性。 6.1TCP 对于 TCP 来说,本身 TCP 有着超时重传、错误重传、还有等等一系列复杂的算法保证了 TCP 的数据是有序的,假设你发送了数据 1、2、3,则只要发送端和接收端保持连接时,接收端收到的数据始终都是 1、2、3。 6.2UDP 而 UDP 协议则要奔放的多,无论 server 端无论缓冲池的大小有多大,接收 client 端发来的消息总是一个一个的接收。并且由于 UDP 本身的不可靠性以及无序性,如果 client 发送了 1、2、3 这三个数据报过来,server 端接收到的可能是任意顺序、任意个数三个数据报的排列组合。 7、可靠性的差异 其实大家都知道 TCP 本身是可靠的协议,而 UDP 是不可靠的协议。 7.1TCP TCP 内部的很多算法机制让他保持连接的过程中是很可靠的。比如:TCP 的超时重传、错误重传、TCP 的流量控制、阻塞控制、慢热启动算法、拥塞避免算法、快速恢复算法 等等。所以 TCP 是一个内部原理复杂,但是使用起来比较简单的这么一个协议。 7.2UDP UDP 是一个面向非连接的协议,UDP 发送的每个数据报带有自己的 IP 地址和接收方的 IP 地址,它本身对这个数据报是否出错,是否到达不关心,只要发出去了就好了。 所以来研究下,什么情况会导致 UDP 丢包: 数据报分片重组丢失:在文章之前我们就说过,UDP 的每个数据报大小多少最合适,事实上 UDP 协议本身规定的大小是 64kb,但是在数据链路层有 MTU 的限制,大小大概在 5kb,所以当你发送一个很大的 UDP 包的时候,这个包会在 IP 层进行分片,然后重组。这个过程就有可能导致分片的包丢失。UDP 本身有 CRC 检测机制,会抛弃掉丢失的 UDP 包; UDP 缓冲区填满:当 UDP 的缓冲区已经被填满的时候,接收方还没有处理这部分的 UDP 数据报,这个时候再过来的数据报就没有地方可以存了,自然就都被丢弃了。 8、使用场景总结 在文章最后的一部分,聊聊 TCP、UDP 使用场景。 先来说 UDP 的吧,有很多人都会觉得 UDP 与 TCP 相比,在性能速度上是占优势的。因为 UDP 并不用保持一个持续的连接,也不需要对收发包进行确认。但事实上经过这么多年的发展 TCP 已经拥有足够多的算法和优化,在网络状态不错的情况下,TCP 的整体性能是优于 UDP 的。 那在什么时候我们非用 UDP 不可呢? 对实时性要求高:比如实时会议,实时视频这种情况下,如果使用 TCP,当网络不好发生重传时,画面肯定会有延时,甚至越堆越多。如果使用 UDP 的话,即使偶尔丢了几个包,但是也不会影响什么,这种情况下使用 UDP 比较好; 多点通信:TCP 需要保持一个长连接,那么在涉及多点通讯的时候,肯定需要和多个通信节点建立其双向连接,然后有时在NAT环境下,两个通信节点建立其直接的 TCP 连接不是一个容易的事情,而 UDP 可以无需保持连接,直接发就可以了,所以成本会很低,而且穿透性好。这种情况下使用 UDP 也是没错的。 以上我们说了 UDP 的使用场景,在此之外的其他情况,使用 TCP 准没错。 毕竟有一句话嘛: when in doubt,use TCP。 附录:更多网络编程资料 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》 来源:即时通讯网 - 即时通讯开发者社区!作者:JackJiang

2018-10-17

网络编程懒人入门(三):快速理解TCP协议一篇就够

1、前言 本系列文章的前两篇《网络编程懒人入门(一):快速理解网络通信协议(上篇)》、《网络编程懒人入门(二):快速理解网络通信协议(下篇)》快速介绍了网络基本通信协议及理论基础,建议开始阅读本文前先读完此2篇文章。 TCP 是互联网的核心协议之一,鉴于它的重要性,本文将单独介绍它的基础知识,希望能加深您对TCP协议的理解。 老规矩,为了让文字尽量通俗易懂、不浪费你的脑细胞,本文尽量点到为止,不对理论进行深入挖掘,如需深入理论细节,请参见下方参考资料中有关TCP协议的详细介绍和学习文章。 群神镇楼:   2、系列文章 本文是系列文章中的第3篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》(本文) 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 如果您觉得本系列文章过于基础,您可直接阅读《不为人知的网络编程》系列文章,该系列目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 3、参考资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《简述传输层协议TCP和UDP的区别》 《为什么QQ用的是UDP协议而不是TCP协议?》 《移动端即时通讯协议选择:UDP还是TCP?》 4、TCP 协议的作用 互联网由一整套协议构成。TCP 只是其中的一层,有着自己的分工。   ▲ TCP 是以太网协议和 IP 协议的上层协议,也是应用层协议的下层协议 最底层的以太网协议(Ethernet)规定了电子信号如何组成数据包(packet),解决了子网内部的点对点通信。 ▲ 以太网协议解决了局域网的点对点通信 但是,以太网协议不能解决多个局域网如何互通,这由 IP 协议解决。   ▲ IP 协议可以连接多个局域网 IP 协议定义了一套自己的地址规则,称为 IP 地址。它实现了路由功能,允许某个局域网的 A 主机,向另一个局域网的 B 主机发送消息。   ▲ 路由器就是基于 IP 协议。局域网之间要靠路由器连接 路由的原理很简单。市场上所有的路由器,背后都有很多网口,要接入多根网线。路由器内部有一张路由表,规定了 A 段 IP 地址走出口一,B 段地址走出口二,......通过这套"指路牌",实现了数据包的转发。   ▲ 本机的路由表注明了不同 IP 目的地的数据包,要发送到哪一个网口(interface) IP 协议只是一个地址协议,并不保证数据包的完整。如果路由器丢包(比如缓存满了,新进来的数据包就会丢失),就需要发现丢了哪一个包,以及如何重新发送这个包。这就要依靠 TCP 协议。 简单说,TCP 协议的作用是,保证数据通信的完整性和可靠性,防止丢包。 5、TCP 数据包的大小 以太网数据包(packet)的大小是固定的,最初是1518字节,后来增加到1522字节。其中, 1500 字节是负载(payload),22字节是头信息(head)。IP 数据包在以太网数据包的负载里面,它也有自己的头信息,最少需要20字节,所以 IP 数据包的负载最多为1480字节。   ▲ IP 数据包在以太网数据包里面,TCP 数据包在 IP 数据包里面 TCP 数据包在 IP 数据包的负载里面。它的头信息最少也需要20字节,因此 TCP 数据包的最大负载是 1480 - 20 = 1460 字节。由于 IP 和 TCP 协议往往有额外的头信息,所以 TCP 负载实际为1400字节左右。因此,一条1500字节的信息需要两个 TCP 数据包。HTTP/2 协议的一大改进, 就是压缩 HTTP 协议的头信息,使得一个 HTTP 请求可以放在一个 TCP 数据包里面,而不是分成多个,这样就提高了速度。   ▲ 以太网数据包的负载是1500字节,TCP 数据包的负载在1400字节左右 6、TCP 数据包的编号(SEQ) 一个包1400字节,那么一次性发送大量数据,就必须分成多个包。比如,一个 10MB 的文件,需要发送7100多个包。发送的时候,TCP 协议为每个包编号(sequence number,简称 SEQ),以便接收的一方按照顺序还原。万一发生丢包,也可以知道丢失的是哪一个包。 第一个包的编号是一个随机数。为了便于理解,这里就把它称为1号包。假定这个包的负载长度是100字节,那么可以推算出下一个包的编号应该是101。这就是说,每个数据包都可以得到两个编号:自身的编号,以及下一个包的编号。接收方由此知道,应该按照什么顺序将它们还原成原始文件。   ▲ 当前包的编号是45943,下一个数据包的编号是46183,由此可知,这个包的负载是240字节 7、TCP 数据包的组装 收到 TCP 数据包以后,组装还原是操作系统完成的。应用程序不会直接处理 TCP 数据包。对于应用程序来说,不用关心数据通信的细节。除非线路异常,收到的总是完整的数据。应用程序需要的数据放在 TCP 数据包里面,有自己的格式(比如 HTTP 协议)。 TCP 并没有提供任何机制,表示原始文件的大小,这由应用层的协议来规定。比如,HTTP 协议就有一个头信息Content-Length,表示信息体的大小。对于操作系统来说,就是持续地接收 TCP 数据包,将它们按照顺序组装好,一个包都不少。 操作系统不会去处理 TCP 数据包里面的数据。一旦组装好 TCP 数据包,就把它们转交给应用程序。TCP 数据包里面有一个端口(port)参数,就是用来指定转交给监听该端口的应用程序。   系统根据 TCP 数据包里面的端口,将组装好的数据转交给相应的应用程序。上图中,21端口是 FTP 服务器,25端口是 SMTP 服务,80端口是 Web 服务器。 应用程序收到组装好的原始数据,以浏览器为例,就会根据 HTTP 协议的 Content-Length 字段正确读出一段段的数据。这也意味着,一次 TCP 通信可以包括多个 HTTP 通信。 8、慢启动和 ACK 服务器发送数据包,当然越快越好,最好一次性全发出去。但是,发得太快,就有可能丢包。带宽小、路由器过热、缓存溢出等许多因素都会导致丢包。线路不好的话,发得越快,丢得越多。 最理想的状态是,在线路允许的情况下,达到最高速率。但是我们怎么知道,对方线路的理想速率是多少呢?答案就是慢慢试。 TCP 协议为了做到效率与可靠性的统一,设计了一个慢启动(slow start)机制。开始的时候,发送得较慢,然后根据丢包的情况,调整速率:如果不丢包,就加快发送速度;如果丢包,就降低发送速度。 Linux 内核里面设定了(常量TCP_INIT_CWND),刚开始通信的时候,发送方一次性发送10个数据包,即"发送窗口"的大小为10。然后停下来,等待接收方的确认,再继续发送。默认情况下,接收方每收到两个 TCP 数据包,就要发送一个确认消息。"确认"的英语是 acknowledgement,所以这个确认消息就简称 ACK。 ACK 携带两个信息: 1)期待要收到下一个数据包的编号; 2)接收方的接收窗口的剩余容量。 发送方有了这两个信息,再加上自己已经发出的数据包的最新编号,就会推测出接收方大概的接收速度,从而降低或增加发送速率。这被称为"发送窗口",这个窗口的大小是可变的。   ▲ 每个 ACK 都带有下一个数据包的编号,以及接收窗口的剩余容量,双方都会发送 ACK 注意:由于 TCP 通信是双向的,所以双方都需要发送 ACK。两方的窗口大小,很可能是不一样的。而且 ACK 只是很简单的几个字段,通常与数据合并在一个数据包里面发送。   上图一共4次通信。第一次通信,A 主机发给B 主机的数据包编号是1,长度是100字节,因此第二次通信 B 主机的 ACK 编号是 1 + 100 = 101,第三次通信 A 主机的数据包编号也是 101。同理,第二次通信 B 主机发给 A 主机的数据包编号是1,长度是200字节,因此第三次通信 A 主机的 ACK 是201,第四次通信 B 主机的数据包编号也是201。 即使对于带宽很大、线路很好的连接,TCP 也总是从10个数据包开始慢慢试,过了一段时间以后,才达到最高的传输速率。这就是 TCP 的慢启动。 9、数据包的遗失处理 TCP 协议可以保证数据通信的完整性,这是怎么做到的? 前面说过,每一个数据包都带有下一个数据包的编号。如果下一个数据包没有收到,那么 ACK 的编号就不会发生变化。 举例来说,现在收到了4号包,但是没有收到5号包。ACK 就会记录,期待收到5号包。过了一段时间,5号包收到了,那么下一轮 ACK 会更新编号。如果5号包还是没收到,但是收到了6号包或7号包,那么 ACK 里面的编号不会变化,总是显示5号包。这会导致大量重复内容的 ACK。 如果发送方发现收到三个连续的重复 ACK,或者超时了还没有收到任何 ACK,就会确认丢包,即5号包遗失了,从而再次发送这个包。通过这种机制,TCP 保证了不会有数据包丢失。   ▲ Host B 没有收到100号数据包,会连续发出相同的 ACK,触发 Host A 重发100号数据包 附录:更多网络编程资料 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《UDP中一个包的大小最大能多大?》 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》  来源:即时通讯网 - 即时通讯开发者社区!作者:JackJiang

2018-10-17

网络编程懒人入门(二):快速理解网络通信协议(下篇)

1、前言 本文上篇《网络编程懒人入门(一):快速理解网络通信协议(上篇)》分析了互联网的总体构思,从下至上,每一层协议的设计思想。基于知识连贯性的考虑,建议您先看完上篇后再来阅读本文。 本文从设计者的角度看问题,今天我想切换到用户的角度,看看用户是如何从上至下,与这些协议互动的。 2、系列文章 本文是系列文章中的第1篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》(本文) 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 如果您觉得本系列文章过于基础,您可直接阅读《不为人知的网络编程》系列文章,该系列目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 3、参考资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《简述传输层协议TCP和UDP的区别》 《为什么QQ用的是UDP协议而不是TCP协议?》 《移动端即时通讯协议选择:UDP还是TCP?》 4、一个小结 先对前面的内容,做一个小结(详见本文上篇《网络编程懒人入门(一):快速理解网络通信协议(上篇)》)。 我们已经知道,网络通信就是交换数据包。电脑A向电脑B发送一个数据包,后者收到了,回复一个数据包,从而实现两台电脑之间的通信。 数据包的结构,基本上是下面这样:   发送这个包,需要知道两个地址: * 对方的MAC地址; * 对方的IP地址。 有了这两个地址,数据包才能准确送到接收者手中。但是,前面说过,MAC地址有局限性,如果两台电脑不在同一个子网络,就无法知道对方的MAC地址,必须通过网关(gateway)转发。   上图中,1号电脑要向4号电脑发送一个数据包。它先判断4号电脑是否在同一个子网络,结果发现不是(后文介绍判断方法),于是就把这个数据包发到网关A。网关A通过路由协议,发现4号电脑位于子网络B,又把数据包发给网关B,网关B再转发到4号电脑。 1号电脑把数据包发到网关A,必须知道网关A的MAC地址。所以,数据包的目标地址,实际上分成两种情况: 场景 数据包地址 同一个子网络 对方的MAC地址,对方的IP地址 非同一个子网络 网关的MAC地址,对方的IP地址 发送数据包之前,电脑必须判断对方是否在同一个子网络,然后选择相应的MAC地址。接下来,我们就来看,实际使用中,这个过程是怎么完成的。 5、用户的上网设置 5.1静态IP地址 你买了一台新电脑,插上网线,开机,这时电脑能够上网吗?   通常你必须做一些设置。有时,管理员(或者ISP)会告诉你下面四个参数,你把它们填入操作系统,计算机就能连上网了: * 本机的IP地址; * 子网掩码; * 网关的IP地址; * DNS的IP地址。 下图是Windows系统的设置窗口:   这四个参数缺一不可,后文会解释为什么需要知道它们才能上网。由于它们是给定的,计算机每次开机,都会分到同样的IP地址,所以这种情况被称作"静态IP地址上网"。但是,这样的设置很专业,普通用户望而生畏,而且如果一台电脑的IP地址保持不变,其他电脑就不能使用这个地址,不够灵活。出于这两个原因,大多数用户使用"动态IP地址上网"。 5.2动态IP地址 所谓"动态IP地址",指计算机开机后,会自动分配到一个IP地址,不用人为设定。它使用的协议叫做DHCP协议。 这个协议规定,每一个子网络中,有一台计算机负责管理本网络的所有IP地址,它叫做"DHCP服务器"。新的计算机加入网络,必须向"DHCP服务器"发送一个"DHCP请求"数据包,申请IP地址和相关的网络参数。 前面说过,如果两台计算机在同一个子网络,必须知道对方的MAC地址和IP地址,才能发送数据包。但是,新加入的计算机不知道这两个地址,怎么发送数据包呢?DHCP协议做了一些巧妙的规定。 5.3DHCP协议 首先,它是一种应用层协议,建立在UDP协议之上,所以整个数据包是这样的:   1)最前面的"以太网标头":设置发出方(本机)的MAC地址和接收方(DHCP服务器)的MAC地址。前者就是本机网卡的MAC地址,后者这时不知道,就填入一个广播地址:FF-FF-FF-FF-FF-FF。 2)后面的"IP标头":设置发出方的IP地址和接收方的IP地址。这时,对于这两者,本机都不知道。于是,发出方的IP地址就设为0.0.0.0,接收方的IP地址设为255.255.255.255。 3)最后的"UDP标头":设置发出方的端口和接收方的端口。这一部分是DHCP协议规定好的,发出方是68端口,接收方是67端口。 这个数据包构造完成后,就可以发出了。以太网是广播发送,同一个子网络的每台计算机都收到了这个包。因为接收方的MAC地址是FF-FF-FF-FF-FF-FF,看不出是发给谁的,所以每台收到这个包的计算机,还必须分析这个包的IP地址,才能确定是不是发给自己的。当看到发出方IP地址是0.0.0.0,接收方是255.255.255.255,于是DHCP服务器知道"这个包是发给我的",而其他计算机就可以丢弃这个包。 接下来,DHCP服务器读出这个包的数据内容,分配好IP地址,发送回去一个"DHCP响应"数据包。这个响应包的结构也是类似的,以太网标头的MAC地址是双方的网卡地址,IP标头的IP地址是DHCP服务器的IP地址(发出方)和255.255.255.255(接收方),UDP标头的端口是67(发出方)和68(接收方),分配给请求端的IP地址和本网络的具体参数则包含在Data部分。 新加入的计算机收到这个响应包,于是就知道了自己的IP地址、子网掩码、网关地址、DNS服务器等等参数。 5.4上网设置:小结 这个部分,需要记住的就是一点:不管是"静态IP地址"还是"动态IP地址",电脑上网的首要步骤,是确定四个参数。 这四个值很重要,值得重复一遍: * 本机的IP地址; * 子网掩码; * 网关的IP地址; * DNS的IP地址。 有了这几个数值,电脑就可以上网"冲浪"了。接下来,我们来看一个实例,当用户访问网页的时候,互联网协议是怎么运作的。 6、一个实例:访问网页 6.1本机参数 我们假定,经过上一节的步骤,用户设置好了自己的网络参数: * 本机的IP地址:192.168.1.100; * 子网掩码:255.255.255.0; * 网关的IP地址:192.168.1.1; * DNS的IP地址:8.8.8.8。 然后他打开浏览器,想要访问Google,在地址栏输入了网址:www.google.com。   这意味着,浏览器要向Google发送一个网页请求的数据包。 6.2DNS协议 我们知道,发送数据包,必须要知道对方的IP地址。但是,现在,我们只知道网址www.google.com,不知道它的IP地址。DNS协议可以帮助我们,将这个网址转换成IP地址。已知DNS服务器为8.8.8.8,于是我们向这个地址发送一个DNS数据包(53端口)。   然后,DNS服务器做出响应,告诉我们Google的IP地址是172.194.72.105。于是,我们知道了对方的IP地址。 6.3子网掩码 接下来,我们要判断,这个IP地址是不是在同一个子网络,这就要用到子网掩码。 已知子网掩码是255.255.255.0,本机用它对自己的IP地址192.168.1.100,做一个二进制的AND运算(两个数位都为1,结果为1,否则为0),计算结果为192.168.1.0;然后对Google的IP地址172.194.72.105也做一个AND运算,计算结果为172.194.72.0。这两个结果不相等,所以结论是,Google与本机不在同一个子网络。 因此,我们要向Google发送数据包,必须通过网关192.168.1.1转发,也就是说,接收方的MAC地址将是网关的MAC地址。 6.4应用层协议 浏览网页用的是HTTP协议,它的整个数据包构造是这样的:   HTTP部分的内容,类似于下面这样: 1 2 3 4 5 6 7 8 9 GET / HTTP/1.1 Host: [url=http://www.google.com]www.google.com[/url] Connection: keep-alive User-Agent: Mozilla/5.0 (Windows NT 6.1) ...... Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Encoding: gzip,deflate,sdch Accept-Language: zh-CN,zh;q=0.8 Accept-Charset: GBK,utf-8;q=0.7,*;q=0.3 Cookie: ... ... 我们假定这个部分的长度为4960字节,它会被嵌在TCP数据包之中。 6.5TCP协议 TCP数据包需要设置端口,接收方(Google)的HTTP端口默认是80,发送方(本机)的端口是一个随机生成的1024-65535之间的整数,假定为51775。TCP数据包的标头长度为20字节,加上嵌入HTTP的数据包,总长度变为4980字节。 6.6IP协议 然后,TCP数据包再嵌入IP数据包。IP数据包需要设置双方的IP地址,这是已知的,发送方是192.168.1.100(本机),接收方是172.194.72.105(Google)。IP数据包的标头长度为20字节,加上嵌入的TCP数据包,总长度变为5000字节。 6.7以太网协议 最后,IP数据包嵌入以太网数据包。以太网数据包需要设置双方的MAC地址,发送方为本机的网卡MAC地址,接收方为网关192.168.1.1的MAC地址(通过ARP协议得到)。 以太网数据包的数据部分,最大长度为1500字节,而现在的IP数据包长度为5000字节。因此,IP数据包必须分割成四个包。因为每个包都有自己的IP标头(20字节),所以四个包的IP数据包的长度分别为1500、1500、1500、560。 6.8服务器端响应 经过多个网关的转发,Google的服务器172.194.72.105,收到了这四个以太网数据包。根据IP标头的序号,Google将四个包拼起来,取出完整的TCP数据包,然后读出里面的"HTTP请求",接着做出"HTTP响应",再用TCP协议发回来。 本机收到HTTP响应以后,就可以将网页显示出来,完成一次网络通信。   这个例子就到此为止,虽然经过了简化,但它大致上反映了互联网协议的整个通信过程。 附录:更多网络编程资料 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《UDP中一个包的大小最大能多大?》 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》  来源:即时通讯网 - 即时通讯开发者社区!作者:JackJiang

2018-10-17

网络编程懒人入门(一):快速理解网络通信协议(上篇)

1、写在前面 论坛和群里常会有技术同行打算自已开发IM或者消息推送系统,很多时候连基本的网络编程理论(如网络协议等)都不了解,就贸然定方案、写代码,显得非常盲目且充满技术风险。 即时通讯网论坛里精心整理了《[通俗易懂]深入理解TCP协议》、《不为人知的网络编程》、《P2P技术详解》、《高性能网络编程》这几个网络编程的系列文章,甚至还有图文并貌+实战代码的《NIO框架入门》等等。资料虽好,无奈很多同行或许是时间紧迫,也或许是心态浮躁,反正就是没办法静下心来仔细研读,导致错过了很多必须掌握的网络编程知识基础(如果您正打算从零开发移动端IM,则建议您从此文开始《新手入门一篇就够:从零开发移动端IM》)。 本次《网络编程懒人入门》系列文章(共3篇),将为大家(尤其是上面说的浮躁的开发者同行)提供懒人快速入门,希望在你没办法耐心读完上面的几个系列文章(但还是强烈建议优先去读一读)的情况还能对基本的网络编程知识有所了解和掌握,从而对您的IM系统或消息推系统的技术选型、方案制定、代码编写起到理论支撑作用。 本文将从网络通信协议讲起,懒人们,动起来^_^ ! 2、正文引言 我们每天使用互联网,你是否想过,它是如何实现的? 全世界几十亿台电脑,连接在一起,两两通信。上海的某一块网卡送出信号,洛杉矶的另一块网卡居然就收到了,两者实际上根本不知道对方的物理位置,你不觉得这是很神奇的事情吗? 互联网的核心是一系列协议,总称为"互联网协议"(Internet Protocol Suite)。它们对电脑如何连接和组网,做出了详尽的规定。理解了这些协议,就理解了互联网的原理。 下面就是我的学习笔记。因为这些协议实在太复杂、太庞大,我想整理一个简洁的框架,帮助自己从总体上把握它们。为了保证简单易懂,我做了大量的简化,有些地方并不全面和精确,但是应该能够说清楚互联网的原理。 另外,如果您很好奇承载这些网络协议的物理设备是怎么工作的,可以先看看《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》。 3、系列文章 本文是系列文章中的第1篇,本系列文章的大纲如下: 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》(本文) 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》 《网络编程懒人入门(七):深入浅出,全面理解HTTP协议》 《网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接》 本站的《脑残式网络编程入门》也适合入门学习,本系列大纲如下: 《脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手》 《脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?》 《脑残式网络编程入门(三):HTTP协议必知必会的一些知识》 《脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)》 如果您觉得本系列文章过于基础,您可直接阅读《不为人知的网络编程》系列文章,该系列目录如下: 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 关于移动端网络特性及优化手段的总结性文章请见: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 4、参考资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《简述传输层协议TCP和UDP的区别》 《为什么QQ用的是UDP协议而不是TCP协议?》 《移动端即时通讯协议选择:UDP还是TCP?》 5、内容概述 5.1五层模型 互联网的实现,分成好几层。每一层都有自己的功能,就像建筑物一样,每一层都靠下一层支持。用户接触到的,只是最上面的一层,根本没有感觉到下面的层。要理解互联网,必须从最下层开始,自下而上理解每一层的功能。 如何分层有不同的模型,有的模型分七层,有的分四层。我觉得,把互联网分成五层,比较容易解释:   如上图所示,最底下的一层叫做"实体层"(Physical Layer),最上面的一层叫做"应用层"(Application Layer),中间的三层(自下而上)分别是"链接层"(Link Layer)、"网络层"(Network Layer)和"传输层"(Transport Layer)。越下面的层,越靠近硬件;越上面的层,越靠近用户。 它们叫什么名字,其实并不重要。只需要知道,互联网分成若干层就可以了。 5.2层与协议 每一层都是为了完成一种功能。为了实现这些功能,就需要大家都遵守共同的规则。大家都遵守的规则,就叫做"协议"(protocol)。 互联网的每一层,都定义了很多协议。这些协议的总称,就叫做"互联网协议"(Internet Protocol Suite)。它们是互联网的核心,下面介绍每一层的功能,主要就是介绍每一层的主要协议。 6、实体层 我们从最底下的一层开始。 电脑要组网,第一件事要干什么?当然是先把电脑连起来,可以用光缆、电缆、双绞线、无线电波等方式。   这就叫做"实体层",它就是把电脑连接起来的物理手段。它主要规定了网络的一些电气特性,作用是负责传送0和1的电信号。 7、链接层 7.1定义 单纯的0和1没有任何意义,必须规定解读方式:多少个电信号算一组?每个信号位有何意义? 这就是"链接层"的功能,它在"实体层"的上方,确定了0和1的分组方式。 7.2以太网协议 早期的时候,每家公司都有自己的电信号分组方式。逐渐地,一种叫做"以太网"(Ethernet)的协议,占据了主导地位。 以太网规定,一组电信号构成一个数据包,叫做"帧"(Frame)。每一帧分成两个部分:标头(Head)和数据(Data)。   "标头"包含数据包的一些说明项,比如发送者、接受者、数据类型等等;"数据"则是数据包的具体内容。 "标头"的长度,固定为18字节。"数据"的长度,最短为46字节,最长为1500字节。因此,整个"帧"最短为64字节,最长为1518字节。如果数据很长,就必须分割成多个帧进行发送。 7.3MAC地址 上面提到,以太网数据包的"标头",包含了发送者和接受者的信息。那么,发送者和接受者是如何标识呢? 以太网规定,连入网络的所有设备,都必须具有"网卡"接口。数据包必须是从一块网卡,传送到另一块网卡。网卡的地址,就是数据包的发送地址和接收地址,这叫做MAC地址。   每块网卡出厂的时候,都有一个全世界独一无二的MAC地址,长度是48个二进制位,通常用12个十六进制数表示。   前6个十六进制数是厂商编号,后6个是该厂商的网卡流水号。有了MAC地址,就可以定位网卡和数据包的路径了。 7.4广播 定义地址只是第一步,后面还有更多的步骤: 1)首先:一块网卡怎么会知道另一块网卡的MAC地址? 回答是有一种ARP协议,可以解决这个问题。这个留到后面介绍,这里只需要知道,以太网数据包必须知道接收方的MAC地址,然后才能发送。 2)其次:就算有了MAC地址,系统怎样才能把数据包准确送到接收方? 回答是以太网采用了一种很"原始"的方式,它不是把数据包准确送到接收方,而是向本网络内所有计算机发送,让每台计算机自己判断,是否为接收方。   上图中,1号计算机向2号计算机发送一个数据包,同一个子网络的3号、4号、5号计算机都会收到这个包。它们读取这个包的"标头",找到接收方的MAC地址,然后与自身的MAC地址相比较,如果两者相同,就接受这个包,做进一步处理,否则就丢弃这个包。这种发送方式就叫做"广播"(broadcasting)。 有了数据包的定义、网卡的MAC地址、广播的发送方式,"链接层"就可以在多台计算机之间传送数据了。 8、网络层 8.1网络层的由来 以太网协议,依靠MAC地址发送数据。理论上,单单依靠MAC地址,上海的网卡就可以找到洛杉矶的网卡了,技术上是可以实现的。 但是,这样做有一个重大的缺点。以太网采用广播方式发送数据包,所有成员人手一"包",不仅效率低,而且局限在发送者所在的子网络。也就是说,如果两台计算机不在同一个子网络,广播是传不过去的。这种设计是合理的,否则互联网上每一台计算机都会收到所有包,那会引起灾难。 互联网是无数子网络共同组成的一个巨型网络,很像想象上海和洛杉矶的电脑会在同一个子网络,这几乎是不可能的。   因此,必须找到一种方法,能够区分哪些MAC地址属于同一个子网络,哪些不是。如果是同一个子网络,就采用广播方式发送,否则就采用"路由"方式发送。("路由"的意思,就是指如何向不同的子网络分发数据包,这是一个很大的主题,本文不涉及。)遗憾的是,MAC地址本身无法做到这一点。它只与厂商有关,与所处网络无关。 这就导致了"网络层"的诞生。它的作用是引进一套新的地址,使得我们能够区分不同的计算机是否属于同一个子网络。这套地址就叫做"网络地址",简称"网址"。 于是,"网络层"出现以后,每台计算机有了两种地址,一种是MAC地址,另一种是网络地址。两种地址之间没有任何联系,MAC地址是绑定在网卡上的,网络地址则是管理员分配的,它们只是随机组合在一起。 网络地址帮助我们确定计算机所在的子网络,MAC地址则将数据包送到该子网络中的目标网卡。因此,从逻辑上可以推断,必定是先处理网络地址,然后再处理MAC地址。 8.2IP协议 规定网络地址的协议,叫做IP协议。它所定义的地址,就被称为IP地址。目前,广泛采用的是IP协议第四版,简称IPv4。 IPv4这个版本规定,网络地址由32个二进制位组成:   习惯上,我们用分成四段的十进制数表示IP地址,从0.0.0.0一直到255.255.255.255。 互联网上的每一台计算机,都会分配到一个IP地址。这个地址分成两个部分,前一部分代表网络,后一部分代表主机。比如,IP地址172.16.254.1,这是一个32位的地址,假定它的网络部分是前24位(172.16.254),那么主机部分就是后8位(最后的那个1)。处于同一个子网络的电脑,它们IP地址的网络部分必定是相同的,也就是说172.16.254.2应该与172.16.254.1处在同一个子网络。 但是,问题在于单单从IP地址,我们无法判断网络部分。还是以172.16.254.1为例,它的网络部分,到底是前24位,还是前16位,甚至前28位,从IP地址上是看不出来的。 那么,怎样才能从IP地址,判断两台计算机是否属于同一个子网络呢?这就要用到另一个参数"子网掩码"(subnet mask)。 所谓"子网掩码",就是表示子网络特征的一个参数。它在形式上等同于IP地址,也是一个32位二进制数字,它的网络部分全部为1,主机部分全部为0。比如,IP地址172.16.254.1,如果已知网络部分是前24位,主机部分是后8位,那么子网络掩码就是11111111.11111111.11111111.00000000,写成十进制就是255.255.255.0。 知道"子网掩码",我们就能判断,任意两个IP地址是否处在同一个子网络。方法是将两个IP地址与子网掩码分别进行AND运算(两个数位都为1,运算结果为1,否则为0),然后比较结果是否相同,如果是的话,就表明它们在同一个子网络中,否则就不是。 比如,已知IP地址172.16.254.1和172.16.254.233的子网掩码都是255.255.255.0,请问它们是否在同一个子网络?两者与子网掩码分别进行AND运算,结果都是172.16.254.0,因此它们在同一个子网络。 总结一下,IP协议的作用主要有两个,一个是为每一台计算机分配IP地址,另一个是确定哪些地址在同一个子网络。 8.3IP数据包 根据IP协议发送的数据,就叫做IP数据包。不难想象,其中必定包括IP地址信息。但是前面说过,以太网数据包只包含MAC地址,并没有IP地址的栏位。那么是否需要修改数据定义,再添加一个栏位呢? 回答是不需要,我们可以把IP数据包直接放进以太网数据包的"数据"部分,因此完全不用修改以太网的规格。这就是互联网分层结构的好处:上层的变动完全不涉及下层的结构。 具体来说,IP数据包也分为"标头"和"数据"两个部分:   "标头"部分主要包括版本、长度、IP地址等信息,"数据"部分则是IP数据包的具体内容。它放进以太网数据包后,以太网数据包就变成了下面这样:   IP数据包的"标头"部分的长度为20到60字节,整个数据包的总长度最大为65,535字节。因此,理论上,一个IP数据包的"数据"部分,最长为65,515字节。前面说过,以太网数据包的"数据"部分,最长只有1500字节。因此,如果IP数据包超过了1500字节,它就需要分割成几个以太网数据包,分开发送了。 8.4ARP协议 关于"网络层",还有最后一点需要说明。因为IP数据包是放在以太网数据包里发送的,所以我们必须同时知道两个地址,一个是对方的MAC地址,另一个是对方的IP地址。通常情况下,对方的IP地址是已知的(后文会解释),但是我们不知道它的MAC地址。 所以,我们需要一种机制,能够从IP地址得到MAC地址。 这里又可以分成两种情况: 1)第一种情况:如果两台主机不在同一个子网络,那么事实上没有办法得到对方的MAC地址,只能把数据包传送到两个子网络连接处的"网关"(gateway),让网关去处理; 2)第二种情况:如果两台主机在同一个子网络,那么我们可以用ARP协议,得到对方的MAC地址。ARP协议也是发出一个数据包(包含在以太网数据包中),其中包含它所要查询主机的IP地址,在对方的MAC地址这一栏,填的是FF:FF:FF:FF:FF:FF,表示这是一个"广播"地址。它所在子网络的每一台主机,都会收到这个数据包,从中取出IP地址,与自身的IP地址进行比较。如果两者相同,都做出回复,向对方报告自己的MAC地址,否则就丢弃这个包。 总之,有了ARP协议之后,我们就可以得到同一个子网络内的主机MAC地址,可以把数据包发送到任意一台主机之上了。 9、传输层 9.1传输层的由来 有了MAC地址和IP地址,我们已经可以在互联网上任意两台主机上建立通信。 接下来的问题是,同一台主机上有许多程序都需要用到网络,比如,你一边浏览网页,一边与朋友在线聊天。当一个数据包从互联网上发来的时候,你怎么知道,它是表示网页的内容,还是表示在线聊天的内容? 也就是说,我们还需要一个参数,表示这个数据包到底供哪个程序(进程)使用。这个参数就叫做"端口"(port),它其实是每一个使用网卡的程序的编号。每个数据包都发到主机的特定端口,所以不同的程序就能取到自己所需要的数据。 "端口"是0到65535之间的一个整数,正好16个二进制位。0到1023的端口被系统占用,用户只能选用大于1023的端口。不管是浏览网页还是在线聊天,应用程序会随机选用一个端口,然后与服务器的相应端口联系。 "传输层"的功能,就是建立"端口到端口"的通信。相比之下,"网络层"的功能是建立"主机到主机"的通信。只要确定主机和端口,我们就能实现程序之间的交流。因此,Unix系统就把主机+端口,叫做"套接字"(socket)。有了它,就可以进行网络应用程序开发了。 9.2UDP协议 现在,我们必须在数据包中加入端口信息,这就需要新的协议。最简单的实现叫做UDP协议,它的格式几乎就是在数据前面,加上端口号。 UDP数据包,也是由"标头"和"数据"两部分组成:   "标头"部分主要定义了发出端口和接收端口,"数据"部分就是具体的内容。然后,把整个UDP数据包放入IP数据包的"数据"部分,而前面说过,IP数据包又是放在以太网数据包之中的,所以整个以太网数据包现在变成了下面这样:   UDP数据包非常简单,"标头"部分一共只有8个字节,总长度不超过65,535字节,正好放进一个IP数据包。 9.3TCP协议 UDP协议的优点是比较简单,容易实现,但是缺点是可靠性较差,一旦数据包发出,无法知道对方是否收到。为了解决这个问题,提高网络可靠性,TCP协议就诞生了。这个协议非常复杂,但可以近似认为,它就是有确认机制的UDP协议,每发出一个数据包都要求确认。如果有一个数据包遗失,就收不到确认,发出方就知道有必要重发这个数据包了。 因此,TCP协议能够确保数据不会遗失。它的缺点是过程复杂、实现困难、消耗较多的资源。 TCP数据包和UDP数据包一样,都是内嵌在IP数据包的"数据"部分。TCP数据包没有长度限制,理论上可以无限长,但是为了保证网络的效率,通常TCP数据包的长度不会超过IP数据包的长度,以确保单个TCP数据包不必再分割。 10、应用层 应用程序收到"传输层"的数据,接下来就要进行解读。由于互联网是开放架构,数据来源五花八门,必须事先规定好格式,否则根本无法解读。"应用层"的作用,就是规定应用程序的数据格式。 举例来说,TCP协议可以为各种各样的程序传递数据,比如Email、WWW、FTP等等。那么,必须有不同协议规定电子邮件、网页、FTP数据的格式,这些应用程序协议就构成了"应用层"。这是最高的一层,直接面对用户。它的数据就放在TCP数据包的"数据"部分。 因此,现在的以太网的数据包就变成下面这样:   11、本文小结 至此,整个互联网的五层结构,自下而上全部讲完了。这是从系统的角度,解释互联网是如何构成的。下一篇《网络编程懒人入门(二):快速理解网络通信协议(下篇)》,我反过来,从用户的角度,自上而下看看这个结构是如何发挥作用,完成一次网络数据交换的。敬请期待! 附录:更多网络编程资料 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《UDP中一个包的大小最大能多大?》 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》 《NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示》 《NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示》 《NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战》 《NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》  来源:即时通讯网 - 即时通讯开发者社区!作者:JackJiang

2018-10-17

移动端IM开发者必读(二):史上最全移动弱网络优化方法总结

1、前言 本文接上篇《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》,关于移动网络的主要特性,在上篇中已进行过详细地阐述,本文将针对上篇中提到的特性,结合我们的实践经验,总结了四个方法来追求极致的“爽快”:快链路、轻往复、强监控、多异步,从理论讲到实践、从技术讲到产品,理论联系实际,举一反三,希望给您带来启发。 如果您还未阅读完上篇《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》,建议您先行读完后再续本文。 本篇的目的,就是希望以通俗易懂的语言,帮助移动端IM开发者更好地针对性优化移动网络的各种特性,使得开发出的功能给用户带来更好的使用体验。 本文乃全网同类文章中,唯一内容最全、“粪”量最重者,请做好心理准备耐心读下去,不要辜负作者已打上石膏的双手和用废的键盘。 另外,《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》这篇文章也提到了本文所阐述的相关内容,强烈建议阅读。 2、系列文章 ▼ 本文是《移动端IM开发者必读》系列文章的第2篇: 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》(本文) 如果您是IM开发初学者,强烈建议首先阅读《新手入门一篇就够:从零开发移动端IM》。 3、相关文章 1)关于网络通信的基础文章: 如果您对网络通信知识了解甚少,建议阅读《网络编程懒人入门系列文章》、《脑残式网络编程入门系列》,更高深的网络通信文章可以阅读《不为人知的网络编程系列文章》。 2)涉及移动端网络特性的文章: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《谈谈移动端 IM 开发中登录请求的优化》 《移动端IM开发需要面对的技术问题(含通信协议选择)》 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》 《微信对网络影响的技术试验及分析(论文全文)》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(上篇)》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(下篇)》 《如约而至:微信自用的移动端IM网络层跨平台组件库Mars已正式开源》 4、优化方法一:“快链路” 我们需要有一条(相对)快速、(相对)顺畅、(相对)稳定的网络通道承载业务数据的传输,这条路的最好是传输快、不拥堵、带宽大、收费少。生活中做个类比,我们计划驱车从深圳到广州,如果想当然走广深高速十之八九要杯具,首先这个高速略显破败更像省道,路况不佳不敢提速;其次这条路上的车时常如过江之鲫,如果身材不好操控不便,根本就快不起来;最后双向六车道虽然勉强可以接受,但收费居然比广深沿江高速双向八车道还贵;正确的选路方案目前看是走沿江高速,虽然可能要多跑一段里程,但是通行更畅快。 实际上,真实情况要更复杂,就如同上篇中【图二 有线互联网和移动互联网网络质量差异】所示(就是下图),漫漫征途中常常会在高速、国道、省道、田间小道上切换。   4.1TCP/IP协议栈参数调优 纯技术活,直接上建议得了,每个子项争取能大致有个背景交待,如果没说清楚,可以先看看以下资料: 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 ① 控制传输包大小 控制传输包的大小在1400字节以下。暂时不讲为什么这样建议,先举个例子来类比一下,比如一辆大卡车满载肥猪正在高速上赶路,猪笼高高层叠好不壮观,这时前方突然出现一个隧道限高标识,司机发现卡车超限了,这下咋整。方案一,停车调头重新找路,而且十之八九找不到,最后只能哪来回哪;方案二,把其中一群猪卸下来放本地找人代养,到达目的地卸完货回来再取,你别说,这个机制在TCP/IP协议栈中也有,学名“IP分片”,后面会专门介绍。这个故事侧面证实美国计算机科学家也曾经蹲在高速路边观察生猪超载运输的过程,并饱受启发。且慢,每次遇到问题,想到一些方案后我们都应该再扪心自问:“还有没有更好的办法呢?”。当然有,参照最近流行的说法,找个台风眼,把猪都赶过去,飞一会就到了,此情此景想想也是醉了。 回归正题,概括的说,我们设定1400这个阈值,目的是减少往复,提高效能。因为TCP/IP网络中也有类似高速限高的规定,如果在超限时想要继续顺畅传输,要么做IP分片要么把应用数据拆分为多个数据报文(意指因为应用层客户端或服务器向对端发送的请求或响应数据太大时,TCP/IP协议栈控制机制自动将其拆分为若干独立数据报文发送的情况,后面为简化讨论,都以IP分片这个分支为代表,相关过程分析和结论归纳对二者均适用)。而一旦一个数据报文发生了IP分片,便会在数据链路层引入多次的传输和确认,加上报文的拆分和拼接开销,令得整个数据包的发送时延大大增加,并且,IP分片机制中,任何一个分片出现丢失时还会带来整个IP数据报文从最初的发起端重传的消耗。有点枯燥了,更深入的理解,请参见:《海量之道系列文章之弱联网优化 (二)》。 我们可以得出如下结论,TCP/IP数据报文大小超过物理网络层的限制时,会引发IP分片,从而增加时空开销。 因此,设定合理的MSS至关重要,对于以太网MSS值建议是1400字节。什么,你的数学是体育老师教的吗?前面说以太网最大的传输数据大小是1500字节,IP数据报文包头是20字节,TCP报文包头是20字节,算出来MSS怎么也得是1460字节呀。如果回答是因为很多路由设备比如CISCO路由器把MSS设定为1400字节,大伙肯定不干,回忆一下IP和TCP的数据报包头都各有40字节的可选项,MTU中还需要为这些可选项留出空间,也就压缩了MSS的空间。要是再追问为啥这个值不是1380字节,那就有点过分了。 知识加油站:什么是MSS? TCP MSS(TCP Maximum Segment Size,TCP最大报文段长度,后面均简称MSS)表示TCP/IP协议栈一次可以传往另一端的最大TCP数据长度,注意这个长度是指TCP报文中的有效“数据”(即应用层发出的业务数据)部分,它不包括TCP报文包头部分,我们可以把它理解为卡车能装运生猪的最大数量或重量。它是TCP选项中最经常出现,也是最早出现的选项,占4字节空间。 MSS是在建立TCP链接的三次握手过程中协商的,每一方都会在SYN或SYN/ACK数据报文中通告其期望接收数据报文的MSS(MSS也只能出现在SYN或SYN/ACK数据报中),说是协商,其实也没太多回旋的余地,原因一会讲。如果协商过程中一方不接受另一方的MSS值,则TCP/IP协议栈会选择使用默认值:536字节。 那么问题来了,控制“限高”哪种方案才最强。我们尝试探讨一下。 首先,可以在我们自己IDC内将各种路由交换设备的MSS设定小于或等于1400字节,并积极参与TCP三次握手时的MSS协商过程,期望达到自动控制服务器收发数据报文大小不超过路径最小MTU从而避免IP分片。这个方案的问题是如果路由路径上其它设备不积极参与协商活动,而它的MTU(或MSS设置值)又比较low,那就白干了。这就好比国家制定了一个高速沿途隧道限高公示通告标准,但是某些地方政府就是不告诉你,没辙。 其次,可以在业务服务中控制应用数据请求/响应的大小在1400字节以下(注:也无法根本避免前述方案中间路由MTU/MSS low的问题),在应用层数据写入时就避免往返数据包大小超过协商确定的MSS。但是,归根到底,在出发前就把数据拆分为多个数据报文,同IP分片机制本质是相同的,交互响应开销增加是必然的。考虑到人在江湖,安全第一,本方案从源头上控制,显得更实际一些。 当然,最靠谱的还是做简法,控制传输数据的欲望,用曼妙的身姿腾挪有致,相关的内容放到轻往复章节探讨。 对应到前面的快乐运猪案例,就是要么在生猪装车之前咱们按照这条路上的最低限高来装车(问题是怎么能知道整个路上的最低限高是多少),要么按照国家标准规定允许的最小限高来装车,到这里,肥猪们终于可以愉快的上路了,风和日丽,通行无阻,嗯,真的吗? ② 放大TCP拥塞窗口 把TCP拥塞窗口(cwnd)初始值设为10,这也是目前Linux Kernel中TCP/IP协议栈的缺省值。放大TCP拥塞窗口是一项有理有据的重要优化措施,对移动网络尤其重要,我们同样从一些基本理论开始逐步深入理解它。 TCP是个传输控制协议,体现控制的两个关键机制分别是基于滑动窗口的端到端之间的流量控制和基于RTT/RTO测算的端到网络之间的拥塞控制。 流量控制目标是为了避免数据发送太快对端应用层处理不过来造成SOCKET缓存溢出,就像一次发了N车肥猪,买家那边来不及处理,然后临时囤货的猪圈又已客满,只好拒收/抛弃,相关概念和细节我们不展开了,有兴趣可以研读《TCP/IP详解 卷一:协议》。 拥塞控制目标是在拥塞发生时能及时发现并通过减少数据报文进入网络的速率和数量,达到防止网络拥塞的目的,这种机制可以确保网络大部分时间是可用的。拥塞控制的前提在于能发现有网络拥塞的迹象,TCP/IP协议栈的算法是通过分组丢失来判断网络上某处可能有拥塞情况发生,评判的具体指标为分组发送超时和收到对端对某个分组的重复ACK。在有线网络时代,丢包发生确实能比较确定的表明网络中某个交换设备故障或因为网络端口流量过大,路由设备转发处理不及时造成本地缓存溢出而丢弃数据报文,但在移动网络中,丢包的情况就变得非常复杂,其它因素影响和干扰造成丢包的概率远远大于中间路由交换设备的故障或过载。比如短时间的信号干扰、进入一个信号屏蔽的区域、从空闲基站切换到繁忙基站或者移动网络类型切换等等。网络中增加了这么多不确定的影响因素,这在TCP拥塞控制算法最初设计时,是无法预见的,同时,我们也确信未来会有更完善的解决方案。这是题外话,如有兴趣可以找些资料深入研究(详见:《TCP/IP详解 - 第21章·TCP的超时与重传》、《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》、《海量之道系列文章之弱联网优化 (三)》)。 拥塞控制是TCP/IP协议栈最经典的和最复杂的设计之一,互联网自我牺牲的利他精神表露无遗,设计者认为,在拥塞发生时,我们应该减少数据报文进入网络的速率和数量,主动让出道路,令网络能尽快调整恢复至正常水平。 ③ 调大SOCKET读写缓冲区 把SOCKET的读缓冲区(亦可称为发送缓冲区)和写缓冲区(亦可称为接收缓冲区)大小设置为64KB。在Linux平台上,可以通过 setsockopt 函数设置SO_RCVBUF和SO_SNDBUF选项来分别调整SOCKET读缓冲区和写缓冲区的大小。 这两个缓冲区跟我们的TCP/IP协议栈到底有怎么样的关联呢。我们回忆一下TCP数据报格式及首部中的各字段里面有个16位窗口大小(见下图),还有我们前面提到的流量控制机制和滑动窗口的概念,大幕徐徐拉开,主角纷纷粉墨登场。在正式详细介绍之前,按照传统,我们还是先站在猪场老板的角度看一下,读缓冲区就好比买家用来囤货的临时猪圈,如果货到了买家使用部门来不及处理,就先在这里临时囤着,写缓冲区就好比养猪场根据订单装好车准备发货,如果买家说我现在可以收货便可速度发出,有点明白了吧。 ④ 调大RTO(Retransmission TimeOut)初始值 将RTO(Retransmission TimeOut)初始值设为3s。 TCP为每一个报文段都设定了一个定时器,称为重传定时器(RTO),当RTO超时且该报文段还没有收到接收端的ACK确认,此时TCP就会对该报文段进行重传。当TCP链路发生超时时,意味着很可能某个报文段在网络路由路径的某处丢失了,也因此判断此时网络出现拥塞的可能性变得很大,TCP会积极反应,马上启动拥塞控制机制。 RTO初始值设为3s,这也是目前Linux Kernel版本中TCP/IP协议栈的缺省值,在链路传输过程中,TCP协议栈会根据RTT动态重新计算RTO,以适应当前网络的状况。有很多的网络调优方案建议把这个值尽量调小,但是,我们开篇介绍移动网络的特点之一是高时延,这也意味着在一个RTT比较大的网络上传输数据时,如果RTO初始值过小,很可能发生不必要的重传,并且还会因为这个事件引起TCP协议栈的过激反应,大炮一响,拥塞控制闪亮登场。 猪场老板的态度是什么样的呢:曾经有一份按时发货的合同摆在我的面前,我没有去注意,等到重新发了货才追悔莫及,尘世间最痛苦的事莫过于此,如果上天能给我一个再来一次的机会,我希望对甲方说耐心点,如果非要给这个耐心加一个期限的话,我希望是一万年。 ⑤ 禁用TCP快速回收 TCP快速回收是一种链接资源快速回收和重用的机制,当TCP链接进入到TIME_WAIT状态时,通常需要等待2MSL的时长,但是一旦启用TCP快速回收,则只需等待一个重传时间(RTO)后就能够快速的释放这个链接,以被重新使用。 Linux Kernel的TCP/IP协议栈提供了一组控制参数用于配置TCP端口的快速回收重用,当把它们的值设置为1时表示启用该选项: 1)  net.ipv4.tcp_tw_reuse = 1 2) net.ipv4.tcp_tw_recycle = 1 3)  net.ipv4.tcp_timestamps = 1(tcp_tw_recycle启用时必须同时启用本项,反之则不然,timestamps用于RTT计算,在TCP报文头部的可选项中传输,包括两个参数,分别为发送方发送TCP报文时的时间戳和接收方收到TCP报文响应时的时间戳。Linux系统和移动设备上的Android、iOS都缺省开启了此选项,建议不要随意关闭) 以上参数中tw是TIME_WAIT的缩写,TIME_WAIT与TCP层的链接关闭状态机相关。具体TIME_WAIT是谁,从哪里来,往哪里去,可以详见:《海量之道系列文章之弱联网优化 (四)》。 ⑥ HTTP协议:打开SOCKET的TCP_NODELAY选项 TCP/IP协议栈为了提升传输效率,避免大量小的数据报文在网络中流窜造成拥塞,设计了一套相互协同的机制,那就是Nagle's Algorithm和TCP Delayed Acknoledgement。 Nagle算法(Nagle's Algorithm)是以发明人John Nagle的名字来命名。John Nagle在1984年首次用这个算法来尝试解决福特汽车公司的网络拥塞问题(RFC 896),该问题的具体描述是:如果我们的应用程序一次产生1个字节的数据(典型的如telnet、XWindows等应用),而这个1个字节数据又以网络数据包的形式发送到远端服务器,那么就很容易使网络中有太多微小分组而导致过载。 因为传输1个字节有效数据的微小分组却需花费40个字节的额外开销(即IP包头20字节 + TCP包头20字节),这种有效载荷利用率极其低下的情况被统称为愚蠢窗口症候群(Silly Window Syndrome),前面我们在谈MSS时也提到过,如果为一头猪开个大卡车跑一趟,也够愚钝的。对于轻负载广域网或者局域网来说,尚可接受,但是对于重负载的广域网而言,就极有可能引起网络拥塞导致瘫痪。 现代TCP/IP 协议栈默认几乎都启用了这两个功能。 我们在移动APP的设计实现中,请求大部分都很轻(数据大小不超过MSS),为了避免上述分析的问题,建议开启SOCKET的TCP_NODELAY选项,同时,我们在编程时对写数据尤其要注意,一个有效指令做到一次完整写入(后面会讲协议合并,是多个指令一次完整写入的设计思想),这样服务器会马上有响应数据返回,顺便也就捎上ACK了。 4.2接入调度 ① 就快接入 在客户端接入服务器调度策略的演化过程中,我们最早采用了“就近接入”的策略,在距离客户端更近的地方部署服务器或使用CDN,期望通过减少RTT来提高网络交互响应性能。这个策略在国内的落地执行还需要加一个前缀:“分省分运营商”,这就给广大负责IDC建设的同学带来了巨大的精神和肉体折磨。 在持续运营的过程中,根据观察到的数据,发现并非物理距离最近的就是最快的。回忆一下前面谈到的吞吐量指标BDP,它与链路带宽和RTT成正比关系,而RTT是受物理距离、网络拥塞程度、IDC吞吐量、跨网时延等诸多因素综合影响的,单纯的就近显然不够精细了。 “就快接入”在“就近接入”策略的基础上改善提升,它利用客户端测速和报告机制,通过后台大数据分析,形成与客户端接入IP按就快原则匹配接入服务器的经验调度策略库,令客户端总能优先选择到最快的服务器接入点。 有关就快接入的更详细方案,请参见:《海量之道系列文章之弱联网优化(五)》一文的“3.1.2节”。 ② 去DNS的IP直连 DNS不但需要1个RTT的时间消耗,而且移动网络下的DNS还存在很多其它问题: 1) 部分DNS承载全网用户40%以上的查询请求,负载重,一旦故障,影响巨大,这样的案例在PC互联网也有很多,Google一下即可感受触目惊心的效果; 2) 山寨、水货、刷ROM等移动设备的LOCAL DNS设置错误; 3) 终端DNS解析滥用,导致解析成功率低; 4) 某些运营商DNS有域名劫持问题,实际上有线ISP也存在类似问题。域名劫持对安全危害极大,产品设计时要注意服务端返回数据的安全校验(如果协议已经建立在安全通道上时则不用考虑,安全通道可以基于HTTPS或者私有安全体系)。对于劫持的判断需要客户端报告实际拉取服务数据的目标地址IP等信息; 5) DNS污染、老化、脆弱。 综上就是在前述就快接入小节中,接入调度FSM会优先使用动态服务器列表的原因。 ③ 网络可达性探测 在连接建立过程中如果出现连接失败的现象,而终端系统提供的网络状态接口反馈网络可用时,我们需要做网络可达性探测(即向预埋的URL或者IP地址发起连接尝试),以区别网络异常和接入服务异常的情况,为定位问题,优化后台接入调度做数据支持。 探测数据可以异步报告到服务器,至少应该包含以下字段: 1) 探测事件ID,要求全局唯一不重复; 2) 探测发生时间; 3) 探测发生时网络类型和其它网络信息(比如WIFI时的SSID等); 4) 本地调度的接入服务器集合类型; 5) 本地调度的接入服务器IP(如使用域名接入,可忽略); 6) 探测的目标URL或IP地址 7) 本次探测的耗时。 4.3链路管理 链路就是运肥猪的高速路,就快接入是选路,链路管理就是如何高效的使用这条路。下面是一些实践总结: ① 链路复用 我们在开篇讨论无线网络为什么慢的时候,提到了链接建立时三次握手的成本,在无线网络高时延、频抖动、窄带宽的环境下,用户使用趋于碎片化、高频度,且请求响应又一次性往返居多、较频繁发起等特征,建链成本显得尤其显著。 因此,我们建议在链路创建后可以保持一段时间,比如HTTP短链接可以通过HTTP Keep-Alive,私有协议可以通过心跳等方式来保持链路。 具体要点建议如下: 1) 链路复用时,如果服务端按就快策略机制下发了新的接入动态服务器列表,则应该按照接入调度FSM的状态变迁,在本次交互数据完成后,重建与新的接入服务器的IP链路,有三个切换方案和时机可选择: - a. 关闭原有链接,暂停网络通讯,同时开始建立与新接入服务器的TCP链路,成功后恢复与服务器的网络交互; - b. 关闭原有链接,暂停网络通讯,待有网络交互需求时开始建立与新接入服务器的IP链路; - c. 原有链接继续工作,并同时开始建立与新接入服务器的TCP链路,成功后新的请求切换到新建链路上,这个方式或可称为预建链接,原链接在空闲时关闭。 2) 链路复用时区分轻重数据通道,对于业务逻辑等相关的信令类轻数据通道建议复用,对于富媒体拉取等重数据通道就不必了; 3) 链路复用时,如与协议合并(后面会讨论)结合使用,效果更佳。 ② 区分网络类型的超时管理 在不同的网络类型时,我们的链路超时管理要做精细化的区别对待。链路管理中共有三类超时,分别是连接超时、IO超时和任务超时。 我们有一些经验建议,提出来共同探讨: 1) 连接超时:2G/3G/4G下5 ~ 10秒,WIFI下5秒(给TCP三次握手留下1次超时重传的机会,可以研究一下《TCP/IP详解 卷一:协议》中TC P的超时与重传部分); 2) IO超时:2G/3G/4G下15 ~ 20秒(无线网络不稳定,给抖动留下必要的恢复和超时重传时间),WIFI下15秒(1个MSL); 3) 任务超时:根据业务特征不同而差异化处理,总的原则是前端面向用户交互界                     面的任务超时要短一些(尽量控制在30秒内并有及时的反馈),后台任务可以长一些,轻数据可以短一些,重数据可以长一些; 4) 超时总是伴随着重试,我们要谨慎小心的重试,后面会讨论。 超时时间宜短不宜长,在一个合理的时间内令当前链路因超时失效,从而驱动调度FSM状态的快速变迁,效率要比痴痴的等待高得多,同时,在用户侧也能得到一个较好的正反馈。 各类超时参数最好能做到云端可配可控。 ③ 优质网络下的并发链路 当我们在4G、WIFI(要区分是WIFI路由器还是手机热点)等网络条件较优时,对于请求队列积压任务较多或者有重数据(富媒体等下载类数据)请求时,可以考虑并发多个链路并行执行。 对于单一重数据任务的多链接并发协同而言,需要服务器支持断点续传,客户端支持任务协同调度; ④ 轻重链路分离 轻重链路分离,也可以说是信令和数据分离,目的是隔离网络通讯的过程,避免重数据通讯延迟而阻塞了轻数据的交互。在用户角度看来就是信息在异步加载,控制指令响应反馈及时。 移动端大部分都是HTTP短链接模式工作,轻重数据的目标URL本身就不同,比较天然的可以达到分离的要求,但是还是要特别做出强调,是因为实践中有些轻数据协议设计里面还会携带类似头像、验证码等的实体数据。 ⑤ 长链接 长链接对于提升应用网络交互的及时性大有裨益,一方面用户使用时,节省了三次握手的时间等待,响应快捷;另一方面服务器具备了实时推送能力,不但可以及时提示用户重要信息,而且能通过推拉结合的异步方案,更好的提升用户体验。 长链接的维护包括链接管理、链接超时管理、任务队列管理等部分,设计实施复杂度相对高一些,尤其是在移动网络环境下。为了保持链路还需要做心跳机制(从另外一个角度看,这也是针对简单信息一个不错的PULL/PUSH时机,,但需注意数据传输要够轻,比如控制在0.5KB以内),而心跳机制是引入长链接方案复杂度的一个重要方面,移动网络链路环境复杂,国内网关五花八门,链路超时配置各有千秋,心跳时长选择学问比较大,不但要区分网络类型,还得区分不同运营商甚至不同省市,历史上曾经实践了2分钟的心跳间隔,最近比较多的产品实践选择4.5分钟的心跳间隔。而且长链接除了给移动网络尤其是空中信道带来负担外,移动设备自身的电量和流量也会有较大的消耗,同时还带来后端带宽和服务器投入增加。 所以,除了一些粘性和活跃度很高、对信息到达实时性要求很高的通讯类APP外,建议谨慎使用长链接,或可以考虑采用下面的方式: 1)  退化长链接:即用户在前台使用时,保持一个长链接链路,活跃时通过用户使用驱动网络IO保持链路可用;静默时通过设置HTTP Keep-Alive方式,亦或通过私有协议心跳方式来保持链路。一旦应用切换后台,且在5~10分钟内没有网络交互任务则自行关闭链路,这样在用户交互体验和资源消耗方面取得一个平衡点; 2)  定时拉取/询问:对于一些有PUSH需求的APP,我们可以采用一个云端可配置间隔时长的定时拉取/询问方案。有三个重点,一是定时的间隔云端可以配置,下发更新到客户端后下次生效;二是拉取/询问时,如果下发的指令有要求进一步PULL时,可以复用已建立的链路,即前述退化长链接的模式;三是定时拉取/询问时机在客户端要做时间上的均匀离散处理,避免大的并发查询带来带宽和负载的巨大毛刺; 3) 如果可能,优先使用OS内置的PUSH通道:比如iOS的APNS、Andriod的 GCM(Google这个以工程师文化著称的公司,在做OS级基础设施建设时,却表现出了很差的前瞻性和系统思考的能力,GCM的前身C2DM都没怎么普及使用就被替换了,这也意味着Android各种版本PUSH能力不 一致的问题。但无论怎么说,OS级的基础设施无论在性能、稳定性还是在效率上都会优于APP层自己实现的方案),实施推拉结合的方案。特别要提到的一点是,中国特色无所不在,国内运营商曾经封过APNS的PUSH端口2195,也会干扰GCM的端口5528,更别提这些底层服务的长链接会被运营商干扰。对于Android平台,还存在系统服务被各种定制修改的问题。别担心,办法总比问题多,保持清醒。 有关Android的推送问题,可以参考: 《应用保活终极总结(三):Android6.0及以上的保活实践(被杀复活篇)》 《Android进程保活详解:一篇文章解决你的所有疑问》 《Android端消息推送总结:实现原理、心跳保活、遇到的问题等》 《深入的聊聊Android消息推送这件小事》 《为何基于TCP协议的移动端IM仍然需要心跳保活机制?》 《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》 《移动端IM实践:实现Android版微信的智能心跳机制》 《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》) ⑥ 小心重试 自动重试是导致后台雪崩的重要因素之一。在移动网络不稳定的条件下,大量及时的重试不但不能达到预期,反而无谓的消耗移动设备的电量甚至流量。 因此,我们在重试前要有一些差异化的考虑: 1) 当前移动设备的网络状况如何,如果没有网络,则不必重试; 2) 重试设定必要的时间间隔,因为移动接入网络抖动到恢复可能需要一点时间,马上重试并非最佳策略,反而可能无谓的消耗电量。实践中,可以在一次连接或IO失败(立即失败或超时)时,过3 ~ 5秒后再试; 3)  重试应设定必要的总时限,因为三个服务器列表比较长,每个服务器地址都要重试和等待若干次,最终可能导致接入调度FSM和服务器列表调度FSM流转耗时过长,此时用户侧体验表现为长时间等待无响应。总时限参数可以参考前述区分网络类型的超时管理中的任务超时值。一旦某次重试成功,重试总时限计时器要归零; 4) 服务器下发特定错误码(比如服务器故障、过载或高负载)时,提示客户端停止重试并告知安抚用户,我们在强监控这个主题下有详细的讨论。 每个目标服务器地址的重试次数、重试总时限和重试时间间隔最好能做到云端可配可控。 特别需要提出的一点是,移动APP采用HTTP短链接模式实现CS交互时,广泛的使用了系统原生组件或者开源组件,这些友好的模块把超时和重试都封装起来,其缺省值是否适合自己的业务特点,需要多多关注。使用前,最好能知其然更知其所以然。 ⑦ 及时反馈 透明和尊重,会带来信任和默契,家庭如此、团队如此、用户亦如此。欲盖弥彰和装傻充愣也许短暂取巧,拉长时间轴来看,肯定要付出惨重的代价。及时和真诚的告知状况,赢得谅解和信任,小付出,大回报,试过都知道。 当发现因为网络不存在或者其它属于移动端设备链路的异常时,应该及时和显著的提示用户,让用户注意到当前有诸如网络不存在、FREE WIFI接入认证页面需确认等等问题,使用户可以及时处理或理解问题状态。 当发现是服务器问题时,应及时、显著和真诚的告知用户,争取用户的谅解。 网络异常提示或服务器故障通告等信息的呈现要做到一目了然,无二义和二次交互。 4.4IO管理 基于一个快速和高效管理的链路之上,做好IO调度和控制,也是提升效能和改善用户体验的重要环节。 要探讨的内容包括: ① 异步IO 异步化IO的目的就是避免资源的集中竞争,导致关键任务响应缓慢。我们在后面差异服务个大的分类中会重点探讨。这里特别先提出来,是建议在程序架构顶层设计时,要在整体机制上支持异步化,设计必要的异步总线来联系各个层级模块,总线可能会涉及包括队列管理(优先级、超时、CRUD等)、事件驱动、任务调度等。 异步IO除了网络方面外,对移动设备,我们还特别要考虑一下磁盘IO的异步。因为频繁、大吞吐量的磁盘IO会造成APP的UI卡顿,从用户体验上看就是交互响应迟钝或者滑动帧率下降。一般来说,磁盘IO异步会选用空间换时间的方案,即缓存数据批量定时写入磁盘。 ② 并发控制 有了异步IO,并发控制就显得尤为重要。把异步机制当作银弹任意使用,就如同我们给移动APP设计了一个叫“发现”的地方一样,很可能各种膨胀的需求、不知道如何归类的需求就纷至沓来,期待有朝一日被“发现”。 异步IO提供了一个很好的发射后不用管的机制,这就会造成使用者的膨胀,无论是否必要、无论轻重缓急,把请求一股脑的丢给异步队列,自己潇洒的转身就走。这样不但会带来效率和交互响应性能的下降,也会造成资源的无谓消耗。 在后面多异步这个大分类的讨论中会涉及到轻重缓急的话题,在前述异步IO的磁盘IO的时空效率转换话题中,还应该包括IO并发的控制,我们即不能因为并发过多的链路造成网络带宽的独占消耗影响其它APP的使用,也不可因快速、大量的异步数据造成缓写机制形同虚设或是占用过大的内存资源。 ③ 推拉结合 PUSH机制应该是苹果公司在移动设备上取得辉煌成就的最重要两个机制之一,另外一个是移动支付体系。我们这里的讨论不包括iOS和APPLE移动设备的拟人化交互体验,只侧重根基性的机制能力。APNS解决了信息找人的问题,在过去,只有运营商的短信有这个能力,推送和拉取使得我们具备了实时获取重要信息的能力。 为何要推拉结合。因为系统级的推送体系也必须维持一个自己的链路,而这个链路上要承载五花八门的APP推送数据,如果太重,一方面会在设计上陷入个性化需求的繁琐细节中,另外一方面也会造成这条链路的拥堵和性能延迟。因此,通过PUSH通知APP,再由APP通过自己的链路去PULL数据,即有效的利用了PUSH机制,又能使得APP能按需使用网络,不但简化了链路管理,而且节省了电量和流量。 ④ 断点续传 一方面,在讨论链路管理时,我们建议了优质网络下的并发链路来完成同一个重数据拉取任务。这就会涉及到任务的拆分和并行执行,基础是后台能支持断点续传。 另外一方面,从客户端的角度而言,移动网络的不稳定特点,可能会造成某个重数据拉取任务突然失败,无论是自动重试还是用户驱动的重试,如果能从上次失效的上下文继续任务,会有省时间、省电量和省流量的效果,想想也会觉得十分美好。 5、优化方法二:“轻往复” “技”止此尔。强调网络交互的“少”,更应强调网络交互的“简”。 我们在一条高时延易抖动的通道上取得效率优势的关键因素就是减少在其上的往复交互,最好是老死不相往来(过激),并且这些往复中交换的数据要尽量的简洁、轻巧,轻车简从。这个概念是不是有点像多干多错,少干少错,不干没错。 把我们实践过的主要手段提出来探讨: ① 协议二进制化 二进制比较紧凑,但是可读性差,也因此形成可维护性和可扩展性差、调测不便的不良印象。这也造成了大量可见字符集协议的出现。计算机是0和1的世界,她们是程序猿的水和电,任何一个整不明白,就没法愉快的生活了。 ② 高效协议 高效的协议可以从两个层面去理解,一是应用层标准协议框架,二是基于其上封装的业务层协议框架,有时候也可以根据需要直接在TCP之上把这两个层面合并,形成纯粹的业务层私有协议框架。不过,为了简化网络模块的通讯机制和一些通用性、兼容性考虑,目前大多数情况下,我们都会选择基于HTTP这个应用层标准协议框架之上承载业务层协议框架。下面我们针对上述两个层面展开探讨。 首先是应用层的标准协议优化:比如HTTP/1.1的Pipeline、WebSocket(在HTML5中增加)、SPDY(由Google提出)、HTTP/2等,其中特别需要关注的是处在试验阶段的SPDY和草案阶段的HTTP/2。 SPDY是Google为了规避HTTP/1.1暨以前版本的局限性开展的试验性研究,主要包括以下四点: 1) 链路复用能力:HTTP协议最早设计时,选择了一问一答一连接的简单模式,这样对于有很多并发请求资源或连续交互的场景,链路建立的数量和时间成本就都增加了; 2) 异步并发请求的能力:HTTP协议最早的设计中,在拉取多个资源时,会对应并发多个HTTP链路(HTTP/1.1的Pipeline类似)时,服务端无法区分客户端请求的优先级,会按照先入先出(FIFO)的模式对外提供服务,这样可能会阻塞客户端一些重要优先资源的加载,而在链路复用的通道上,则提供了异步并发多个资源获取请求指令的能力,并且可以指定资源加载的优先级,比如CSS这样的关键资源可以比站点ICON之类次要资源优先加载,从而提升速度体验; 3) HTTP包头字段压缩:(注:特指字段的合并删减,并非压缩算法之意)精简,HTTP协议中HEAD中字段多,冗余大,每次请求响应都会带上,在不少业务场景中,传递的有效数据尺寸远远小于HEAD的尺寸,带宽和时间成本都比较大,而且很浪费; 4) 服务器端具备PUSH能力:服务器可以主动向客户端发起通信向客户端推送数据。 HTTP/2由标准化组织来制定,是基于SPDY的试验成果开展的HTTP协议升级标准化工作,有兴趣了解详细情况可以参考HTTP/2的DRAFT文档。 其次是业务层的协议框架优化:它可以从三个方面考察 一是协议处理性能和稳定性好,包括诸如协议紧凑占用空间小,编码和解码时内存占用少CPU消耗小计算快等等,并且bad casae非常少; 二是可扩展性好,向下兼容自不必说,向上兼容也并非不能; 三是可维护性强,在协议定义、接口定义上,做到可读性强,把二进制协议以可读字符的形式展示,再通过预处理转化为源码级文件参与工程编译。 可能会有同学强调协议调测时的可阅读、可理解,既然读懂01世界应该是程序员的基本修养,这一项可能就没那么重要了。 高效的业务层协议框架从分布式系统早期代表Corba的年代就有很多不错的实践项目,目前最流行的开源组件应属ProtoBuf,可以学习借鉴。 正所谓殊途同归、心有灵犀、不谋而合,英雄所见略同......,说来说去,高效协议的优化思路也都在链路复用、推拉结合、协议精简、包压缩等等奇技淫巧的范畴之内。 有关Protobuf等技术的详细文章,请参见: 《Protobuf通信协议详解:代码演示、详细原理介绍等》 《如何选择即时通讯应用的数据传输格式》 《强列建议将Protobuf作为你的即时通讯应用数据传输格式》 《全方位评测:Protobuf性能到底有没有比JSON快5倍?》 《移动端IM开发需要面对的技术问题(含通信协议选择)》 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》 《理论联系实际:一套典型的IM通信协议设计详解》 《58到家实时消息系统的协议设计等技术实践分享》 《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》 《金蝶随手记团队分享:还在用JSON? Protobuf让数据传输更省更快(原理篇)》 《金蝶随手记团队分享:还在用JSON? Protobuf让数据传输更省更快(实战篇)》 ③ 协议精简 协议精简的目的就是减少无谓的数据传输,提升网络效能。俗话说“千里不捎针”,古人诚不我欺也。 我们实践总结以下三点供参考: 1) 能不传的就不传:把需要的和希望有的数据都列出来,按照对待产品需求的态 度,先砍掉一半,再精简一半,估计就差不多了。另外,高效协议提供了比较好的扩展性,预留字段越少越好,移动互联网演化非常快,经常会发现前瞻的预留总是赶不上实际的需求; 2) 抽象公共数据:把各协议共性的属性数据抽象出来,封装在公共数据结构中, 即所谓包头一次就传一份,这个想法不新鲜,TCP/IP的设计者们早就身体力行了。除了带来数据冗余的降低外,还降低了维护和扩展的复杂度,一石二鸟,且抽且行; 3) 多用整数少用字符:数字比文字单纯,即简洁又清晰,还不需要担心英文不好被后继者BS; 4) 采用增量技术:通知变化的数据,让接收方处理差异,这是个很好的设计思想,实践中需要注意数据一致性的校验和保障机制,后面会有专门的细节讨论。 ④ 协议合并 协议合并的目标是通过将多条交互指令归并在一个网络请求中,减少链路创建和数据往复,提升网络效能。 把实战总结的六点提出来供参考: 1) 协议合并结合协议精简,效率翻番; 2) 协议合并的基础是业务模型的分析,在分类的基础上去做聚合。首先得区分出来缓急,把实时和异步的协议分类出来分别去合并;其次得区分出来轻重,协议请求或协议响应的数据规模(指压缩后),尽量确保在一个数据报文中可完成推拉; 3) 协议合并在包的封装上至少有两种选择,一是明文协议合并后统一打包(即压缩和解密);二是明文协议分别打包,最后汇总;前者效率高一些,在实战中用的也较普遍;后者为流式处理提供可能; 4) 协议合并对服务器的异步处理架构和处理性能提出了更高的要求,特别需要权衡网络交互效率和用户对后台处理返回响应期待之间的取舍; 5) 协议间有逻辑顺序关系时,要认真考虑设计是否合理或能否合并; 6) 重数据协议不要合并。 ⑤ 增量技术 增量技术准确分类应该算是协议精简的一个部分,它与业务特点结合的非常紧密,值得单独讨论一下。增量技术在CS数据流交互比较大的时候有充分发挥的空间,因为这个技术会带来客户端和服务器计算、存储的架构复杂度,增加资源消耗,并且带来许多保障数据一致性的挑战,当然,我们可以设计的更轻巧,容许一些不一致。 我们用一个案例来看看增量技术的运用。 在应用分发市场产品中,都有一个重要功能,叫更新提醒。它的实现原理很简单,以Android设备为例,客户端把用户移动设备上安装的APP包名、APP名称、APP签名、APP版本号等信息发送到服务器,服务器根据这些信息在APP库中查找相应APP是否有更新并推送到客户端。这个过程非常简单,但如果用户手机上装了50个APP,网络上交互的数据流就非常客观了,即浪费流量和电量,又造成用户体验的缓慢,显得很笨重。 这个时候,增量技术就可以派上用场了,比如下面的方案: 1) 每个自然日24小时内,客户端选择一个时间(优先选择驻留在后台的时候)上报一次全量数据; 2) 在该自然日24小时的其它时间,客户端可以定时或在用户使用时发送增量数据,包括卸载、安装、更新升级等带来的变化; 3) 作为弱一致性的保障手段,客户端在收到更新提示信息后,根据提醒的APP列表对移动设备上实际安装和版本情况做一次核对; 4) 上述择机或定时的时间都可以由云端通过下发配置做到精细化控制。 ⑥ 包压缩 前面精打细算完毕,终于轮到压缩算法上场了。选择什么算法,中间有哪些实战的总结,下面提出来一起探讨: 1) 压缩算法的选择:我们比较熟悉的压缩算法deflate、gzip、bzip2、LZO、Snappy、FastLZ等等,选择时需要综合考虑压缩率、内存和CPU的资源消耗、压缩速率、解压速率等多个纬度的指标,对于移动网络和移动设备而言,建议考虑使用gzip。另外需要注意的是,轻数据与重数据的压缩算法取舍有较大差异,不可一概而论; 2) 压缩和加密的先后秩序:一般而言,加密后的二进制数据流压缩率会低一些,建议先压缩再加密; 3) 注意一些协议组件、网络组件或数据本身是否已经做过压缩处理,要避免重复工作,不要造成性能和效率的下降:比如一些图片格式、视频或APK文件都有自己的压缩算法。 说到这,问题又来了,如果应用层标准协议框架做了压缩,那么基于其上封装的业务层协议框架还需要压缩吗,压缩技术到底哪家强?这个问题真不好回答,考虑到HTTP/2这样的应用层标准协议框架定稿和普及尚需时日,建议在业务层协议框架中做压缩机制。或者追求完美,根据后端应用层标准协议框架响应是否支持压缩及在支持时的压缩算法如何等信息,动态安排,总的原则就是一个字:只选对的,不选贵的。 5、优化方法三:“强监控” 可监方可控,我们在端云之间,要形成良好的关键运营数据的采集、汇总和分析机制,更需要设计云端可控的配置和指令下发机制。 本篇重点讨论与主题网络方面相关关键指标的“监”和“控”。 以就快接入为例来探讨一下强监控能力的构建和使用: 1) 接入质量监控:客户端汇总接入调度FSM执行过程元信息以及业务请求响应结果的元信息,并由此根据网络类型不同、运营商不同、网络接入国家和省市不同分析接入成功率、业务请求成功率(还可细化按业务类型分类统计)、前述二者失败的原因归类、接入302重定向次数分布暨原因、接入和业务请求测速等; 2) 建设云端可控的日志染色机制:便于快速有针对性的定点排查问题; 3) 终端硬件、网络状态的相关参数采集汇总; 4) 建设云端可控的接入调度(比如接入IP列表等)和网络参数(比如连接超时、IO超时、任务超时、并发链接数、重试间隔、重试次数等)配置下发能力; 5) 服务器根据汇总数据。 通过数据分析,结合服务器自身的监控机制,可以做到: a. 支持细粒度的接入调度和网络参数的优化云控; b. 支持服务器的部署策略优化; c. 发现移动运营商存在的一些差异化问题比如URL劫持、网络设备超时配置不当等问题便于推动解决; d. 发现分省市服务器服务质量的异常情况,可以动态云端调度用户访问或者降级服务,严重时可以及时提示客户端发出异常安抚通告,避免加剧服务器的负载导致雪崩。安民告示的快速呈现能力,考验了一个团队对可“控”理解的深度,我们在实践中,提供了三级措施来保障:     - 第一级是服务器端通过协议或跳转URL下发的动态通告,这在非IDC公网故障且业务接入服务器正常可用时适用; - 第二级是预埋静态URL(可以是域名或IP形式,优先IP)拉取动态通告,适用其它故障,静态URL部署的IP地址最好同本业务系统隔离,避免因为业务服务所在IDC公网故障不可用时无法访问; - 第三级是客户端本地预埋的静态通告文案,内容会比较模糊和陈旧,仅作不时之需; e. 支持异步任务的云端可配可控,比如下载类APP的下载时间、下载标的和下载条件约束(磁盘空间、移动设备电量、网络类型等)的差异化配置,通过错峰调度,达到削峰平谷并提升用户体验的效果。 特别需要注意的是,客户端数据报告一定要有数据筛选控制和信息过滤机制,涉及用户隐私的敏感信息和使用记录必须杜绝采样上报。在我们的日志染色机制中要特别注意,为了排查问题极可能把关键、敏感信息记录报告到后端,引入安全风险。 6、优化方法四:“多异步” 经过前面不懈的努力,初步打造了一个比较好的技术根基,好马配好鞍,好车配风帆,怎么就把领先优势拱手送与特斯拉了。 用户欲壑难平,资源供不应求,靠“术”并无法优雅的解决。跳出来从产品角度去观察,还有些什么能够触动我们思考的深度呢。根据不同的需求和使用场景,用有损服务的价值观去权衡取舍,用完美的精神追求不完美,此乃道的层面。 所谓大道至简,完美之道,不在无可添加,而在无可删减。通过多异步和各类缓存机制,提供区分网络、区分业务场景下的差异化服务,是我们孜孜以求的大“道”。 下面通过一些实践案例的总结,来探索简洁优雅的弱联网体验改善之道(开始肆无忌惮的吹嘘了)。 ① 网络交互可否延后 微博客户端某个版本启动时,从闪屏加载到timeline界面需要6秒+。这样的体验是无法接受的,与用户2秒以内的等待容忍度是背道而驰的。从技术角度去分析,很容易发现问题,诸如我们在启动时有10+个并发的网络请求(因为是HTTP短链接,意味着10+个并发的网络链接)、闪屏加载、主UI创建、本地配置加载、本地持久化数据加载至Cache等等程序行为,优化的目标很自然就集中在网络请求和本地配置、持久化数据加载上。 梳理并发网络请求,可以从以下三个方面考察: 1) 哪些请求是要求实时拉取的,比如timeline & 提及 & 私信的数字、身份校验; 2) 哪些请求是可以异步拉取的,比如timeline、用户Profile、云端配置、双向收听列表、闪屏配置、timeline分组列表、相册tag列表等; 3) 哪些请求是可以精简或合并的,比如timeline & 提及 & 私信的数字与身份校验合并。 此时,取舍就非常简单和清晰了,启动时1~2个网络请求足够应对。所做的仅仅是把一些请求延后发起,这是一种异步机制。 在移动APP里面还有大量类似的场景,比如用户更新了APP的某个设置项或者自己Profile的某个字段,是停在界面上转菊花等网络交互返回后再提示结果,亦或是把界面交互马上还给用户,延后异步向服务器提交用户请求,这里面的价值取向不同,“快”感也便不同。 ② 网络内容可否预先加载 微博客户端在timeline刷新时,用户向上快速滑屏,到达一个逻辑分页(比如30条微博消息)时,有两个取舍,一是提前预加载下个分页内容并自动拼接,给用户无缝滑动的体验;二是等到用户滑动到达分页临界点时现场转菊花,卡不卡看当时的网络状况。实践中选择了方案一。用户在滑动浏览第一个逻辑分页时,APP就利用这个时间窗主动预先拉取下一个逻辑分页的内容,使得用户能享受一个顺畅的“刷”的体验。 所做的仅仅是把一个请求提前发起了,这也是一种异步机制。思考的要点是: 1) 预先加载的内容是用户预期的吗,预先加载和自动下载之间,失之毫厘谬以千里; 2) 预先加载的内容对用户移动设备的资源(比如流量、电量等)和后端服务器的资源(比如带宽、存储、CPU等)消耗要做好估算和判断,体贴和恶意之间,也就一步之遥; 3) 预先加载区分轻重数据,轻数据可以不区分网络状况,重数据考虑仅限优质网络下执行,最好这些策略云端可以控制; 4) 预先通过网络拉取加载或存储的过程中,不要打搅用户的正常使用。 在移动APP中,预加载有大量的实践,比较典型的就是升级提醒,大家都采用了先下载好升级包,再提示用户有新版本的策略,让你顺畅到底。 ③ 用户体验可否降级 微博客户端在香港公共WIFI下刷新timeline总是失败,通过后台用户接入请求和响应日志分析,判断是香港IDC到香港公共WIFI的汇接口带宽窄、时延大,此时该如何应对。 从前面探讨的TCP/IP网络知识,可以知道,在一个窄带宽高时延网络中,吞吐量BDP必然很小,也就是说单位大小的数据传输所需的时间会很长。如果按照通常一次下发一个逻辑分页timeline数据的策略,那么从服务器到客户端传输,整个数据需要拆分成多个TCP数据报文,在缓慢的传输过程中,可能一个数据报文还未传输完成,客户端的链路就已经超时了。 如果在弱网络(需要在应用层有测速机制,类似TCP/IP的RTT机制,测速时机可以是拉取微博消息数字时)下,把逻辑分页的微博消息数由30调整为5会如何,如果方案成立,用户刷微博的体验是不是会下降,因为滑动一屏就要做一次网络交互,即便是配合预加载,也可能因为网络太慢,操控太快而又见菊花。外团在香港实测了这个版本,感叹,终于可以刷了。 在饥渴难耐和美酒佳肴之间,似乎还有很多不同层级的体验。聊胜于无,这个词很精准的表述了服务分层,降级取舍的重要性。思考的要点是: 1) 产品的核心体验是什么,即用户最在乎的是什么,在做宏观分层设计时要充分保障核心体验; 2) 每个产品交互界面中,什么数据是无法容忍短时间不一致的,即什么是用户不能容忍的错误,在做微观分层设计时要充分考虑正确性; 3) 在宏观和微观分层的基础上,开始设想在什么条件下,可以有什么样的降级取舍,来保障可用,保障爽快的体验; 4) 分层不宜太多太细,大部分产品和场景,3层足矣。 在移动弱网络条件下,处处可见降级取舍的案例。比如网络条件不佳时,降低拉取缩略图的规格,甚至干脆不自动拉取缩略图等等,分层由心,降级有意。 ④ 端和云孰轻孰重 移动APP时代,绝对的轻端重云或者轻云重端都是不可取的,只有端云有机的配合,才能在一个受限的网络通道上做出更好的用户体验。正所谓东家之子,胖瘦有致。 比如移动网游APP,如取向选择轻端重云,那么玩家的战斗计算就会大量的通过网络递交给服务器处理并返回,卡顿家常便饭,操控感尽失。 比如微博客户端,如果取向选择重端轻云,微博timeline所有的消息都拉取元数据(比如微博正文包括文字、各类URL、话题、标签、@、消息的父子关系、消息中用户profile、关系链等等),由客户端实时计算拼装,不但客户端用户需要消耗大量流量计算量,而且给后端服务器带来巨大的带宽成本和计算压力,如果过程中网络状况不佳,还会非常卡顿。 通过实践总结,端和云孰轻孰重,取舍的关键是在数据计算规模可控和数据安全有保障的前提下: 1) 减少网络往复,要快; 2) 减少网络流量,要轻。 端云有机结合,可以很好的演绎机制与策略分离的设计思想,从而使系统具备足够的柔韧性。 不得不再次特别提到的一点是,缓存技术是异步化的基础,它渗透在性能和体验提升的方方面面,从持久化的DB、文件,到短周期的内存数据结构,从业务逻辑数据,到TCP/IP协议栈,它无所不在。缓存涉及到数据结构组织和算法效能(耗时、命中率、内存使用率等)、持久化和启动加载、更新、淘汰、清理方案等,有机会我们可以展开做专题的介绍。牢记一个字,缓存是让用户爽到极致的利器,但千万别留下垃圾。 提倡多异步,实际上是要求团队认真审视产品的核心能力是什么,深入思考和发现什么是用户最关心的核心体验,把有限的资源聚焦在它们身上。通过考察用户使用产品时的心理模型,体验和还原用户使用场景,用追求完美的精神探索不完美之道。 互联网服务核心价值观之一“不要我等”,在移动互联网时代仍应奉为圭臬,如何面对新的挑战,需要更多的学习、思考、实践和总结,这篇文章即是对过去实践的总结,亦作为面对未来挑战的思考基点。 老子曰过:上士闻道,勤而行之;中士闻道,若存若亡;下士闻道,大笑之。不笑不足以为道。求求你了,笑一个。 知易行难,故知行合一似(jiu)为扯蛋,那么我们就且扯且珍惜吧。  来源:即时通讯网 - 即时通讯开发者社区!作者:JackJiang 标签:移动端IM开发 网络编程 IM开发干货系列

2018-10-17

移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”

1、前言 随着移动互联网的高速发展,移动端IM以移动网络作为物理通信载体早已深入人心,这其中的成功者就包括微信、手机QQ、支付宝(从即时通讯产品的角度来看,支付宝已经算的上是半个IM了)等等,也为移动端即时通讯开发者带来了各种可以参考的标杆功能和理念:语音对讲、具有移动端体验特性的图片消息、全时在线的概念、真正突破物理体验的实时通知等。 上述IM产品、功能和概念,在开发者间讨论时,无一例外都会被打上“移动端”这个特性,从网络通信的角度来说,这个特性的本质可以认为就是移动网络的特性。 以文件发送为例,传统的PC端IM(可以简单地理解为传统有线网络上的IM)可以直接实时点对点发送(理论上无需经过服务器中转)。 但在移动端IM里我们并不能这么干,原因是: 1)3G/4G/5G网络下P2P成功率并没有那么高(因为是对称型NAT,详见《通俗易懂:快速理解P2P技术中的NAT穿透原理》); 2)移动网络的信号跳变、抖动很难预测(甚至在你转身的瞬间,信号可能会立马由强变弱); 3)移动网络的延迟、丢包、重传等导致通信体验很差(就像从国内访问国外的网站那种“慢”,体验上是相似的); 4)延迟、丢包、重传带来的另一个后果就是电量消耗、流量消耗过大,这些都是不可接受的; 5)智能手机(主要是Android、iOS)的系统省电策略,导致网络可能被阻断,甚至进程被杀死,功能没办法在后台继续工作。 所以,正是移动网络的这些特性,使得原本在传统PC端再普通不过的功能(比如上面说的文件发送),在移动端IM中却不得不另想办法:以文件发送为例——主流的移动端IM现在都是使用服务器中转来搞定的。使用类似技术实现的功能,还有移动端IM里语音短消息的AMR音频小文件、图片消息的图片文件等。 那么回归到本文的正题:移动网络为什么会存在“弱”和“慢”这样的特性? 这个问题网络工程师来回答最为合适,对但于应用层的程序员来说,有关移动网络的理论太生涩枯燥,太难理解了。而对于网络工程师来说,他们也不理解“你们这些程序员到底在纠结移动网络的什么鬼?”。 就像黄品源的那首《那么爱你为什么》的歌曲里面莫文蔚的一段念白:“我讲又讲不清,你听又听不懂......”。这大概是应用层程序员很难能找到通俗易懂的有关移动网络资料的原因吧。 所以本文的目的,就是希望以通俗易懂的语言,帮助移动端IM开发者更好地理解移动网络的各种特性,使得开发出的功能能更好地适应移动网络,给用户带来更好的使用体验。 另外,《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》这篇文章也提到了本文所阐述的相关内容,强烈建议阅读。 2、系列文章 ▼ 本文是《移动端IM开发者必读》系列文章的第1篇: 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》(本文) 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 如果您是IM开发初学者,强烈建议首先阅读《新手入门一篇就够:从零开发移动端IM》。 3、相关文章 1)关于网络通信的基础文章: 如果您对网络通信知识了解甚少,建议阅读《网络编程懒人入门系列文章》、《脑残式网络编程入门系列》,更高深的网络通信文章可以阅读《不为人知的网络编程系列文章》。 2)涉及移动端网络特性的文章: 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《谈谈移动端 IM 开发中登录请求的优化》 《移动端IM开发需要面对的技术问题(含通信协议选择)》 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》 《微信对网络影响的技术试验及分析(论文全文)》 《腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(上篇)》 《腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(下篇)》 《如约而至:微信自用的移动端IM网络层跨平台组件库Mars已正式开源》 4、正文引言 移动互联网颠覆着我们的生活方式,这个每时每刻伴随着我们的网络到底有哪些特点,又是如何影响我们接入信息世界的体验呢。 以下场景如似曾相识,敬请对号入座: 1)上班路上收到朋友分享的一张美女图片,缩略图目测衣服用料相当节俭,立马兴奋点开欲详细钻研,却发现怎么脱也脱不下来,不对,是“拖”不是“脱”,仰望苍天,欲哭无泪。 2)进电梯前收到女友一条消息:“你到底爱不爱我!”,当然马上回复“必须的必!”,电梯门关闭了,北风那个吹,菊花那个转,等到春暖梯开,满屏都是女友的问候“在吗!”、“这都要想那么久!”、“跟哪个MM聊天呢!”、“我生气了!”、“你是好人,再见!”,看着自己的回复刚刚发送成功,停在最后一行,整个互动信息一气呵成,都是眼泪。 3)和朋友聚餐,菜端上先拍照分享,再大快朵颐,明明坐在对面,偏偏还得用手机聊天,世界最远的距离,莫过于我们坐在一起,却只能用手指切磋。 有因有果,有道有术,不入虎穴焉得虎子,不扯了,进入正题。 5、移动网络的特点 理论上说,我们看到移动网络有如下三个典型特点: 1)移动状态网络信号不稳定,高时延、易抖动丢包、通道狭窄; 2)移动状态网络接入类型和接入点变化频繁; 3)移动状态用户使用高频化、碎片化、非WIFI流量敏感。 为什么? 参考【图一 无线网络链路示意】,我们尝试从物理上追根溯源: ▲ 图一:无线网络链路示意 根据“图一:无线网络链路示意”所示内容,我们可以得到以下信息。 第一、直观印象是通讯链路长而复杂:从(移动)终端设备到应用服务器之间,相较有线互联网,要多经过基站、核心网、WAP网关(好消息是WAP网关正在被依法取缔)等环节,这就像送快递,中间环节越多就越慢,每个中转站的服务质量和服务效率不一,每次传递都要重新交接入库和分派调度,一不小心还能把包裹给弄丢了。 第二、这是个资源受限网络:移动设备接入基站空中信道数量非常有限,信道调度更是相当复杂,如何复杂就不展开了,莫文蔚那首歌词用在这里正合适:“我讲又讲不清,你听又听不懂......”,最最重要的是分配的业务信道单元如果1秒钟不传数据就会立马被释放回收,六亲不认童叟无欺。 第三、这个链条前端(无线端)是高时延(除某些WIFI场景外)、低带宽(除某些WIFI场景外)、易抖动的网络:无线各种制式网络带宽上限都比较低而传输时延比较大(参见【表一 运营商移动信号制式带宽标准】),并且,没事就能丢个包裹玩玩,最最重要的是,距离基站的远近,把玩手机的角度、地下室的深度等等都能影响无线信号的质量,让包裹在空中飞一会,再飞一会......。这些因素也造成了移动互联网网络质量稳定性差、接入变化频繁,与有线互联网对比更是天上人间的差别,从【图二 有线互联网和移动互联网网络质量差异】中可以有更直观的感受。 ▲ 图二:有线互联网和移动互联网网络质量差异 【表一 运营商移动信号制式带宽标准】数据来自互联网各种百科,定性不定量,仅供参考; 第四、这是个局部封闭网络:空中信道接入后要做鉴权、计费等预处理,WAP网络甚至还要做数据过滤后再转发,在业务数据有效流动前太多中间代理人求参与,效率可想而知。产品研发为什么又慢又乱,广大程序猿心里明镜似的;最最重要的是,不同运营商之间跨网传输既贵且慢又有诸多限制,聪明的运营商便也用上了缓存技术,催生了所谓网络“劫持”的现象。 如果我们再结合用户在移动状态下2G/3G/4G/WIFI的基站/AP之间,或者不同网络制式之间频繁的切换,情况就更加复杂了。 6、移动网络为什么“慢” 我们在移动网络的特点介绍中,很容易的得到了三个关键字: 1)“高时延”; 2)“易抖动”; 3)“通道窄”。 这些物理上的约束确实限制了我们移动冲浪时的速度体验,那么,还有别的因素吗。 当然有,汗牛充栋、罄竹难书: 1)DNS解析,这个在有线互联网上司空见惯的服务,在移动互联网上变成了一种负担,一个往复最少1s,还别提遇到移动运营商DNS故障时的尴尬; 2)链路建立成本暨TCP三次握手,在一个高时延易抖动的网络环境,并且大部分业务数据交互限于一个HTTP的往返,建链成本尤其显著; 3)TCP协议层慢启动、拥塞控制、超时重传等机制在移动网络下参数设定的不适宜; 4)不好的产品需求规定或粗放的技术方案实现,使得不受控的大数据包、频繁的数据网络交互等,在移动网络侧TCP链路上传输引起的负荷; 5)不好的协议格式和数据结构设计,使得协议封装和解析计算耗时、耗内存、耗带宽,甚至协议格式臃肿冗余,使得网络传输效能低下; 6)不好的缓存设计,使得数据的加载和渲染计算耗时、耗内存、耗带宽。 现在终于知道时间都去哪了,太浪费太奢侈,还让不让人愉快的玩手机了。天下武功,唯快不破,我们一起踏上“快”的探索之路吧。 更多有关TCP的基础理论性文章,可以看看下面的文章: 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 7、针对移动网络“弱”和“慢”的特点,有优化办法吗? 答案是:有。 在移动互联网时代,对我们的产品和技术追求提出了更高的挑战,如何从容和优雅的面对,需要先从精神上做好充分的准备,用一套统一的思考和行动准则武装到牙齿。 从来就没有什么救世主,只有程序猿征服一切技术问题的梦想在空中飘荡。屡败屡战,把过往实践中的经验教训总结出来,共同研讨。针对移动网络的特点,我们总结一些实用方法,请看下篇《移动端IM开发者必读(二):针对移动弱网络特性的优化方法总结》。 (意犹未尽?请看下篇:《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 ) 附录:更多计算机网络方面的资料 《TCP/IP详解 - 第11章·UDP:用户数据报协议》 《TCP/IP详解 - 第17章·TCP:传输控制协议》 《TCP/IP详解 - 第18章·TCP连接的建立与终止》 《TCP/IP详解 - 第21章·TCP的超时与重传》 《技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)》 《通俗易懂-深入理解TCP协议(上):理论基础》 《通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理》 《理论经典:TCP协议的3次握手与4次挥手过程详解》 《理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程》 《计算机网络通讯协议关系图(中文珍藏版)》 《UDP中一个包的大小最大能多大?》 《P2P技术详解(一):NAT详解——详细原理、P2P简介》 《P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解》 《P2P技术详解(三):P2P技术之STUN、TURN、ICE详解》 《通俗易懂:快速理解P2P技术中的NAT穿透原理》 《高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少》 《高性能网络编程(二):上一个10年,著名的C10K并发连接问题》 《高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了》 《高性能网络编程(四):从C10K到C10M高性能网络应用的理论探索》 《不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)》 《不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)》 《不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT》 《不为人知的网络编程(四):深入研究分析TCP的异常关闭》 《不为人知的网络编程(五):UDP的连接性和负载均衡》 《不为人知的网络编程(六):深入地理解UDP协议并用好它》 《不为人知的网络编程(七):如何让不可靠的UDP变的可靠?》 《网络编程懒人入门(一):快速理解网络通信协议(上篇)》 《网络编程懒人入门(二):快速理解网络通信协议(下篇)》 《网络编程懒人入门(三):快速理解TCP协议一篇就够》 《网络编程懒人入门(四):快速理解TCP和UDP的差异》 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》 《技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解》 《让互联网更快:新一代QUIC协议在腾讯的技术实践分享》 《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》 《聊聊iOS中网络编程长连接的那些事》 《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》 《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》 >> 更多同类文章 ……  来源:即时通讯网 - 即时通讯开发者社区!作者:JackJiang

2018-10-17