从专业角度来看,计算机网络为信息交换提供了基础平台,而网络应用则是实现信息传递与资源共享的关键机制。没有网络应用,底层的网络设施将无法发挥其应有的价值。 自互联网诞生以来,各类网络应用不断涌现,极大地丰富了网络的功能与服务,推动了互联网技术的广泛应用与持续发展。所以这一部分,我们来看看计算机网络中的应用层面。

在设计和开发网络应用时,首先需要明确应用的核心属性。网络应用的本质,是在分布于不同终端设备上的多个进程之间,通过网络协议实现高效、可靠的数据交换。以网页浏览为例, 用户侧的浏览器进程与远程服务器上的Web服务进程通过HTTP等协议进行通信,实现信息的请求与响应。
这些应用进程往往部署在高性能的数据中心服务器集群中,能够支撑大规模并发访问和复杂的业务逻辑。无论用户身处家庭、学校、政企单位还是其他环境,正是这些网络应用的高效协作,保障了互联网服务的普遍可用性和关键性地位。
在应用开发的道路上,最核心的挑战是编写能够在多个终端设备上运行的软件。这些软件可以用C、Java或Python等语言编写。最重要的是,我们不需要编写运行在网络核心设备(如路由器或链路层交换机)上的软件。
我们知道,网络核心设备只在较低层次上运行,而应用软件被严格限制在终端设备上。这种设计使得新应用的开发和部署变得异常便捷,从而催生了海量的网络应用。
在开始编写代码之前,我们需要为应用选择合适的架构。从应用开发者的角度来看,网络架构是固定的,它为应用提供特定的服务。但应用架构是由开发者设计的,它决定了应用如何在各个终端设备之间组织。
现代网络应用通常采用两种主要的架构范式:客户端-服务器架构或对等网络(P2P)架构。

在客户端-服务器(Client-Server)架构中,系统中存在一台或多台长期在线、具备高可用性的主机,称为服务器(Server)。服务器负责集中处理来自大量其他主机——即客户端(Client)——发起的服务请求。 例如,在Web应用场景下,Web服务器持续运行,专门响应分布在各地的客户端浏览器发起的页面或资源请求。
当服务器接收到客户端的请求(如网页、文件等)时,会根据协议规范将所需数据返回给对应的客户端。 需要特别指出的是,在客户端-服务器架构下,所有客户端之间的通信均需通过服务器中转,客户端之间不会直接建立连接。例如,两个用户的浏览器在访问同一网站时,彼此之间并不直接通信,而是各自与服务器进行交互。
该架构的一个显著特征是服务器通常拥有固定且公开的IP地址,便于客户端随时通过网络定位和访问服务器资源。服务器的持续在线和地址的确定性,确保了服务的稳定性和可达性。
目前,众多主流网络应用均采用客户端-服务器架构,包括Web服务(HTTP)、文件传输(FTP)、远程登录(Telnet)以及电子邮件(SMTP/POP3/IMAP)等。这种架构以其集中管理、易于维护和安全可控等优势,成为互联网服务的基础模式之一。
通常情况下,单个服务器主机无法处理来自客户端的所有请求。为了应对这种情况,人们使用数据中心来创建强大的虚拟服务器。数据中心容纳了大量的主机,这些主机被用来处理请求。
最受欢迎的互联网服务——如搜索引擎(比如Google、Bing、百度)、互联网商务(比如Amazon、eBay、阿里巴巴)、基于网页的电子邮件(比如Gmail和Yahoo Mail)、社交媒体(比如Facebook、Instagram、Twitter和微信)——都运行在一个或多个数据中心中。
数据中心可以容纳数十万台服务器,这些服务器需要供电和维护。此外,服务提供商必须支付经常性的互连和带宽费用,以便将数据从其数据中心发送出去。
与客户端-服务器架构相比,对等网络(P2P)架构极大地减少了对专用服务器的依赖,甚至在某些场景下完全不需要服务器。P2P架构的核心在于,网络中的各个节点(即对等体)能够直接进行数据交换和通信。 这些对等体通常是由终端用户所拥有和控制的个人计算机或笔记本电脑,广泛分布于家庭、学校及企业环境中。
在P2P架构下,每个对等体既可以作为服务的请求方,也可以作为服务的提供方,实现资源的共享与协作。以BitTorrent为代表的文件共享系统就是典型的P2P应用场景。 通过这种架构,系统能够充分利用网络中各节点的计算和带宽资源,实现高效的数据分发和弹性扩展。
P2P架构最引人注目的特性之一是其自扩展性。比如在P2P文件共享应用中,虽然每个对等体通过请求文件产生工作负载,但每个对等体也通过向其他对等体分发文件来为系统增加服务容量。
P2P架构还具有成本效益,因为它们通常不需要大量的服务器基础设施和服务器带宽(与有数据中心的客户端-服务器设计相比)。
在我们动手写网络应用之前,咱们得先搞清楚一件事:分布在不同电脑上的程序,到底是怎么“说话”的?其实,咱们平时说的“程序”,在操作系统眼里叫“进程”。你可以把进程想象成一台电脑里正在跑的一个小机器人,它有自己的任务和空间。
如果两个进程在同一台电脑上,它们可以直接通过操作系统安排的“专用通道”交流,比如用管道、消息队列这些方式。但咱们这本书主要关心的是:当两个进程分别在不同的电脑上时,它们怎么才能互相传递消息呢?
答案其实很简单:它们靠网络来“递纸条”。一个进程把想说的话打包成消息,通过网络发出去,另一个进程收到后再拆开来看。如果有需要,还可以回个信。就像咱们用微信聊天一样,只不过主角变成了进程。

网络应用由成对的进程组成,这些进程通过网络相互发送消息。对于每一对通信进程,我们通常将两个进程中的一个标记为客户端,另一个标记为服务器。
对于每一对通信进程,我们根据通信的发起者来标记客户端和服务器进程:
在网页中,浏览器进程发起与网页服务器进程的联系,因此浏览器进程是客户端,网页服务器进程是服务器。在P2P文件共享中,当对等体A请求对等体B发送特定文件时,对等体A在该特定通信会话的上下文中是客户端,对等体B是服务器。
在网络应用的实际开发过程中,绝大多数应用都是由一对或多对分布在不同主机上的进程协同完成通信任务。这些进程之间的数据交换,必须依赖底层网络的支持。为了实现进程与网络之间的高效交互,操作系统为应用进程提供了一种标准化的软件接口——套接字(Socket)。 套接字本质上是应用层与传输层之间的桥梁,通常也被称为应用编程接口(API),它为网络应用的开发提供了统一的编程入口。
在套接字的使用过程中,应用开发者拥有对应用层侧的全部控制权,可以灵活地构建和处理应用数据。但对于传输层的细节,开发者的可控范围则相对有限。一般来说,开发者主要可以做两件事:一是选择合适的传输层协议(如TCP或UDP),二是根据实际需求调整部分传输层参数,例如设置缓冲区大小或最大报文段长度等。 一旦确定了传输协议,应用程序就只能基于该协议所提供的服务特性来实现具体的网络功能。
在网络世界里,进程之间想要互相“打招呼”,就必须有一个准确的“收件地址”。这个地址其实包含两部分:一是目标主机的IP地址,二是主机上具体负责收信的“门牌号”——也就是端口号。 IP地址就像是每台电脑在互联网中的身份证,而端口号则用来区分同一台电脑上不同的应用进程。
比如说,我们要把一条消息发给远方的朋友,首先得知道他家住哪(IP地址),还得知道他家里哪个房间收快递(端口号)。这样,消息才能准确无误地送到目标进程手里。每个网络应用都有自己专属的端口号,比如网页服务器常用80端口,邮件服务器用25端口。
所以,进程寻址其实就是“IP地址+端口号”的组合。只要这两样信息都对,消息就能顺利穿越互联网,准确送达目标进程,就像快递员凭地址和门牌号把包裹送到你手上一样。
套接字是应用进程和传输层协议之间的接口。发送侧的应用通过套接字向下推送消息。在套接字的另一侧,传输层协议负责将消息传递到接收进程的套接字。
许多网络,包括互联网,为应用提供了不止一种传输层协议。当你开发一个应用时,你必须从可用的传输层协议中选择一个。你如何做出这个选择?最有可能的是,你会研究可用传输层协议提供的服务,然后选择其服务最匹配你的应用需求的协议。
传输层协议可以为应用提供的服务可以沿着四个维度进行广泛分类:可靠数据传输、吞吐量、时序和安全性。
在计算机网络环境中,数据包在传输过程中存在丢失的风险。常见的丢失原因包括路由器缓冲区溢出导致的数据包丢弃,或数据在传输过程中发生比特错误而被主机或路由器判定为无效并丢弃。 对于诸如电子邮件、文件传输、远程登录、网页内容分发以及金融交易等对数据完整性要求极高的应用场景,任何数据丢失都可能带来严重后果,甚至影响业务的正常运行。

因此,为了满足这些关键应用的需求,网络协议需要提供机制,确保发送方传递的数据能够被接收方完整、无误地接收。 具备此类保障的数据传输服务,通常被称为“可靠数据传输”。可靠数据传输要求协议能够检测并恢复丢失或损坏的数据包,确保数据的有序、无差错交付。
然而,并非所有应用都需要可靠数据传输。例如,实时音视频通信等多媒体应用对部分数据丢失具有一定容忍度。 对于这类应用,偶尔的数据包丢失只会造成音频或视频的短暂卡顿或画面瑕疵,不会影响整体体验。因此,在这些场景下,协议可以牺牲部分可靠性以换取更低的时延和更高的传输效率。
吞吐量,专业上通常指的是发送进程能够以多快的速率将比特流交付给接收进程。在一次通信会话中,沿着网络路径的可用吞吐量,实际上就是数据从发送端到接收端单位时间内能够传递的比特数。 由于网络带宽会被多条会话动态共享,其他会话的加入和离开会导致可用吞吐量随时变化,因此,传输层协议有时会提供“带宽保证”服务,即允许应用请求一个最低吞吐量,协议则负责确保实际吞吐量不低于这个标准。
对于一些对带宽有严格要求的应用,比如实时语音通话或高清视频会议,只有在网络能够持续提供足够吞吐量时,应用才能正常运行。 例如,假设某个网络电话应用需要32kbps的稳定带宽来传输语音数据,如果网络无法满足这个速率,语音质量就会明显下降,甚至无法正常通话。这类对带宽敏感的应用,往往会优先选择能够提供吞吐量保证的传输协议。 而部分多媒体应用虽然可以通过自适应编码技术,根据当前网络状况动态调整数据速率,但大多数实时音视频服务依然对带宽有较高的敏感性。
与之相对的,还有一类弹性应用,比如电子邮件、文件下载和网页浏览等。这些应用对吞吐量的要求相对宽松,能够根据网络状况灵活调整传输速度。 虽然吞吐量越高,用户体验越好,但即使网络带宽有限,这些应用依然可以正常完成任务。正因如此,弹性应用在网络环境波动时表现出更强的适应能力,而带宽敏感型应用则更依赖于网络的稳定和高吞吐量保障。
传输层协议还能够为应用提供时序保障服务。与吞吐量保障类似,时序保障可以有多种具体实现方式。 例如,协议可以承诺:发送方注入到套接字的每一比特,必须在不超过100毫秒的时间内被可靠地传递到接收方的套接字。
这类时序保障对于对实时性要求极高的交互式应用至关重要,比如IP语音(VoIP)、虚拟现实、远程会议系统以及大型多人在线游戏等。这些应用场景对数据传输的时延和抖动有严格的约束,只有在满足这些时序要求的前提下,才能保证用户体验和系统的正常运行。
而对于非实时类应用(如电子邮件、文件传输等),虽然较低的端到端时延始终是理想的,但通常并不要求协议提供严格的时序保障。只要数据最终能够完整、正确地送达,应用即可正常工作。
在传输层,协议能够为应用提供多种安全性保障。具体来说,传输协议可以在发送端对数据进行加密处理,确保数据在网络传输过程中不被未授权方窃取或篡改; 在接收端,协议则负责对接收到的数据进行解密,还原为原始内容后再交付给目标进程。
通过上述机制,传输层能够实现端到端的数据机密性,即便数据在传输路径中被第三方截获,也无法被直接读取。 此外,传输协议还可支持数据完整性校验,防止数据在传输过程中被恶意或意外篡改,并可实现通信双方的身份认证,确保数据只在合法的端点之间传递。
到目前为止,我们一直在考虑计算机网络一般可以提供的传输服务。现在让我们更具体一些,考察互联网提供的传输服务的类型。互联网(以及更一般地说,TCP/IP网络)为应用提供了两种传输协议:UDP和TCP。
当你(作为应用开发者)为互联网创建一个新的网络应用时,你必须做出的第一个决定之一是使用UDP还是TCP。这两种协议中的每一种都为其调用应用提供不同的服务集。
TCP服务模型包括面向连接的服务和可靠数据传输服务。当应用调用TCP作为其传输协议时,应用从TCP接收这两个服务。
首先,TCP是面向连接的。这意味着客户端和服务器在应用级消息开始流动之前交换传输层控制信息。这种所谓的握手过程提醒客户端和服务器,让它们为即将到来的数据包做好准备。 握手阶段之后,在两个进程的套接字之间存在一个TCP连接。该连接是一个全双工连接,这意味着两个进程可以在连接上同时向对方发送消息。当应用完成发送消息时,它必须拆除连接。
其次,通信进程可以依靠TCP无错误地按正确顺序交付所有发送的数据。当应用的一侧将字节流传递到套接字时,它可以依靠TCP将相同的字节流交付到接收套接字,没有丢失或重复的字节。
TCP还包括拥塞控制机制,这是一个为互联网整体福祉提供的服务,而不是为通信进程的直接利益提供的。当发送者和接收者之间的网络拥塞时,TCP拥塞控制机制会限制发送进程(客户端或服务器)。我们将在后面看到,TCP拥塞控制还试图限制每个TCP连接到网络带宽的公平份额。
UDP(用户数据报协议)是一种极简型的传输层协议,主要为应用提供最基础的数据报服务。其核心特性在于无连接性,即通信双方在数据传输前无需建立连接,省略了握手等额外开销。这种设计使得UDP能够实现低延迟、高效率的数据传递,适用于对实时性要求较高但可容忍一定数据丢失的场景。
在可靠性方面,UDP不保证数据报一定能够送达目标进程,也不保证数据报的顺序性。换而言之,应用通过UDP套接字发送的数据报,可能会丢失、重复,或者乱序到达接收端。协议本身不提供任何纠错、重传或排序机制,相关功能需由上层应用自行实现。
此外,UDP不具备拥塞控制能力。发送方可以以任意速率将数据报注入网络层,不受协议层面的速率限制。然而,实际的端到端吞吐量仍受限于网络链路的带宽和当前的拥塞状况,过快的发送速率可能导致数据报在网络中被丢弃。 因此,UDP更适合那些对传输速率和时延敏感、但对可靠性要求不高的应用,如实时音视频、在线游戏等。
上世纪90年代初,互联网主要服务于学术和科研领域,应用场景以远程登录、文件传输和电子邮件为主。直到90年代中期,万维网的出现彻底改变了互联网的格局,使其成为全球最重要的信息平台,极大拓展了互联网的应用边界。
万维网的核心优势在于按需获取内容,用户可以随时访问所需信息,打破了传统媒体的时间和空间限制。它不仅降低了信息发布门槛,还通过超链接、搜索引擎和多媒体内容丰富了用户体验,为现代的在线视频、网页邮箱和移动应用等提供了坚实的基础。

超文本传输协议(HTTP)是现代网页通信的基础应用层协议,其标准由RFC 1945、RFC 7230和RFC 7540等文档定义。HTTP采用典型的客户端-服务器模型,客户端(如浏览器)和服务器分别在不同设备上运行,通过交换HTTP消息实现数据交互。H TTP详细规定了消息的格式以及双方的通信流程,确保了网页内容能够高效、规范地传递。
在HTTP体系中,网页实际上是由多个对象组成的集合。每个对象都可以通过唯一的URL进行访问,比如HTML文档、图片、脚本或样式表等。通常,一个网页包含一个基础的HTML文件,并通过URL引用若干外部资源。
URL由主机名和路径名两部分构成,例如 http://www.example.com/images/logo.png,其中 www.example.com 是主机名,/images/logo.png 是路径名。
HTTP协议依赖于TCP作为其底层传输机制。客户端在请求网页时,首先与服务器建立TCP连接,然后通过套接字接口发送HTTP请求消息,服务器收到请求后返回HTTP响应消息。 TCP为HTTP提供了可靠的数据传输保障,使得每一条请求和响应都能完整无误地到达对方,应用层无需关心底层的数据丢失或乱序问题。
值得注意的是,HTTP是一种无状态协议。服务器在处理完每次请求后,不会保留任何关于客户端的历史信息。即使同一个客户端短时间内多次请求同一资源,服务器也会像第一次一样重新处理。 这种无状态特性简化了服务器的设计,但也促使了如Cookie等机制的出现,用于实现会话管理。自HTTP/1.0诞生以来,HTTP协议不断演进,目前主流版本为HTTP/1.1和HTTP/2,后者在性能和并发性方面有显著提升。
在现代互联网应用中,客户端与服务器之间的通信往往涉及一系列请求与响应的交互。这些请求可能是连续、高频的,也可能是间歇性或定时触发的。 当这种交互基于TCP协议实现时,开发者需要权衡一个关键的架构选择:究竟是为每一对请求和响应分别建立独立的TCP连接,还是复用同一个TCP连接来承载多个请求与响应?
采用前者的方式,即每个请求/响应对都使用独立的TCP连接,这种模式被称为“非持久连接”;而后者,即多个请求/响应共享同一TCP连接,则称为“持久连接”。 为了更深入地理解这两种模式的技术特性与适用场景,我们可以结合HTTP协议进行分析。 HTTP协议既支持非持久连接,也支持持久连接。尽管当前主流的HTTP实现默认采用持久连接,但在实际部署中,客户端和服务器依然可以根据需求配置为使用非持久连接。
假设我们在家用电脑打开浏览器,输入了一个网址,比如 http://www.mybookstore.com/index.html,这是一个网上书店的主页。这个页面很简单,只有一个HTML文件和三张PNG格式的书籍封面图片。所有这些内容都放在同一个服务器上。
整个过程其实就像我们和书店老板打电话点书一样。我们每要一本书(或者一张图片),都得重新拨一次电话,聊完就挂断。下面咱们一步步看看:
首先,浏览器(就像我们)要和服务器(书店老板)建立联系。它会先打个电话(也就是建立TCP连接),用的号码是80(HTTP的默认端口)。这时候,浏览器和服务器各自都准备好一个“电话听筒”(也就是套接字socket),方便通话。
接下来,浏览器通过这个“电话”告诉服务器:“老板,我要/index.html这个页面!”(这就是发送HTTP请求消息,里面写着要哪个文件。)
服务器一听,赶紧去仓库(服务器的硬盘或者内存)找到了/index.html,然后把它装进一个包裹(HTTP响应消息),通过“电话”发回给浏览器。
这里有个小细节:不同的浏览器可能会用不同的方式来“拼装”页面,但HTTP协议只管怎么把内容从服务器传到浏览器,至于怎么展示,那是浏览器自己的事。
在这个例子里,我们一共打了三次电话(一次HTML,两次图片),也就是建立了两个TCP连接。每次连接只传输一个请求和一个响应,传完就挂断,这就是非持久连接的典型做法。早期的HTTP/1.0就是这么工作的。 有的浏览器还可以同时打好几个电话(并行连接),这样能更快地把所有图片都要回来。比如同时打三次电话要三张图片,比一张一张慢慢要快多了。
说到这里,咱们来估算一下,从我们点开链接到页面内容全部到手,大概要花多少时间。这里有个重要的概念叫“往返时间”(RTT),意思是一个小包裹从我们电脑到服务器再返回来的时间。这个时间包括了网络传输、路由器排队、服务器处理等各种延迟。 当我们点击链接时,浏览器会先和服务器“握个手”(三次握手),前两步就要花一个RTT。第三步握手时,浏览器会顺便把HTTP请求发过去。服务器收到后,把HTML文件发回来,这又要花一个RTT。所以,整个过程大致需要两个RTT,再加上服务器传输文件的时间。
这样一来,我们就能明白,非持久连接每次都要重新“打电话”,虽然简单,但效率不高,尤其是页面内容多的时候,建立和关闭连接的开销就很明显了。
在HTTP非持久连接模式下,每获取一个对象(如HTML文件或图片),客户端和服务器都需要单独建立并维护一条TCP连接。每条连接都要在客户端和服务器两端分配TCP缓冲区、维护相关状态变量。 当有大量客户端并发访问时,这会显著增加服务器的资源消耗和管理复杂度,尤其是对于高并发的Web服务来说,负担尤为明显。
此外,非持久连接还带来了额外的时延。每次获取对象时,首先要经历一次TCP三次握手(约等于一个RTT),然后才能发送HTTP请求并等待响应(又一个RTT)。因此,单个对象的传输至少需要两个RTT,整体页面加载效率较低。
为了解决这些问题,HTTP/1.1引入了持久连接机制。持久连接允许服务器在发送完响应后,保持TCP连接处于打开状态。 这样,后续同一客户端的多个HTTP请求和响应都可以复用这条连接,无需反复建立和关闭TCP连接。以一个包含多个图片的网页为例,所有资源都可以通过同一条持久连接顺序或并发地传输,大大减少了连接建立和拆除的开销。
更进一步,HTTP/1.1还支持流水线(pipelining)技术。客户端可以在前一个请求尚未收到响应时,连续发送多个请求,服务器则按顺序依次返回响应。这种方式进一步提升了数据传输效率,降低了整体延迟。 通常,HTTP服务器会设置一个超时时间,如果在这段时间内连接未被使用,则自动关闭持久连接,以节省资源。
根据HTTP协议的标准,所有HTTP通信都以特定格式的消息进行交换。HTTP消息主要分为两大类:请求消息(Request)和响应消息(Response)。接下来,我们将详细解析这两类消息的结构与作用。
以下是一个典型的HTTP请求消息:
|GET /somedir/page.html HTTP/1.1 Host: www.mysite.com Connection: close User-agent: Mozilla/5.0 Accept-language: fr
从专业角度分析,这个HTTP请求消息展现了协议的标准结构和关键要素。首先,HTTP请求采用ASCII编码,便于跨平台解析和调试。该示例请求由多行组成,每行以回车换行(CRLF)结尾,最后一行后还有一个额外的CRLF,标志着头部结束。实际请求的行数可根据具体需求变化。
请求消息的首行为“请求行”,包含三部分:方法(如GET、POST、HEAD、PUT、DELETE)、请求的资源路径(URL)以及所用的HTTP协议版本。例如,这里采用GET方法请求/somedir/page.html,协议版本为HTTP/1.1。GET方法是最常用的HTTP方法,主要用于获取资源。
紧随其后的为若干“头部行”,用于携带额外的元数据。Host: www.mysite.com头部明确指定了目标主机,这在虚拟主机环境下尤为重要,因为同一IP地址可能承载多个域名。Connection: close头部指示服务器在完成本次响应后关闭TCP连接,表明客户端不打算复用该连接。User-agent: Mozilla/5.0头部标识了发起请求的客户端软件类型,服务器可据此进行内容适配或统计分析。Accept-language: fr头部则表达了客户端对内容语言的偏好,服务器可根据该信息返回法语版本的资源(若可用),否则返回默认语言版本。这类内容协商头部在多语言网站和国际化场景下非常关键。
以下是一个请求消息的通用格式:
HTTP请求消息的通用格式与前述示例高度一致。需要注意的是,在所有头部字段(以及随后的回车换行符)之后,紧接着是“实体体”部分。对于GET方法,实体体通常为空;而在POST方法下,实体体则用于承载客户端向服务器提交的数据。
在实际应用中,HTTP客户端在用户提交表单数据时常常采用POST方法。例如,用户在搜索引擎页面输入关键词并提交时,浏览器会通过POST请求将表单中的内容封装在实体体中发送给服务器。 此时,虽然客户端依然是在请求网页资源,但返回的内容会根据用户输入的具体数据动态生成。如果请求方法为POST,实体体就包含了用户在表单中填写的全部数据。
需要补充的是,HTTP请求并非只能通过POST方法提交数据。实际上,HTML表单默认也可以采用GET方法,此时表单中的数据会被编码并附加在URL的查询字符串部分。
例如,若表单采用GET方法,包含两个字段,用户分别输入“monkeys”和“bananas”,则最终生成的URL可能为 www.somesite.com/animalsearch?monkeys&bananas,数据直接暴露在URL中,便于服务器解析。
在日常网页浏览中,你可能已经注意到这种扩展URL。
以下是一个典型的HTTP响应消息。这个响应消息可能是对上面讨论的示例请求消息的响应:
|HTTP/1.1 200 OK Connection: close Date: Tue, 18 Aug 2015 15:44:04 GMT Server: Apache/2.2.3 (CentOS) Last-Modified: Tue, 18 Aug 2015 15:11:03 GMT Content-Length: 6821 Content-Type: text/html (data data data data data ...)
该响应消息主要由三部分组成:状态行、若干头部字段以及实体体。实体体部分承载了客户端请求的实际资源内容(在示例中以 data data data data data ... 表示)。
首先,状态行包含三个核心字段:协议版本、状态码以及状态短语。本例中,状态行为 HTTP/1.1 200 OK,表明服务器采用HTTP/1.1协议,并成功处理了请求,正在返回所需资源。
接下来是头部字段。Connection: close 明确告知客户端,服务器将在本次响应后关闭TCP连接。Date: 字段标记了服务器生成并发送该HTTP响应的具体时间,这一时间点并不等同于资源本身的创建或修改时间,而是服务器检索资源、组装响应并发送时的时间戳。
Server: 字段指明了响应由Apache Web服务器生成,类似于请求消息中的 User-agent: 字段,用于标识服务器软件。Last-Modified: 字段反映了资源的最后修改时间,这一信息对于客户端或代理服务器的缓存机制至关重要,有助于实现高效的内容更新与一致性管理。
Content-Length: 字段给出了实体体的字节长度,便于客户端准确读取完整内容。Content-Type: 字段则明确指定了实体体的媒体类型(如text/html),客户端据此决定如何解析和展示响应内容。需要注意的是,内容类型以 Content-Type: 头部为准,而非依赖文件扩展名。
下面我们看看响应消息的通用格式:
上述通用格式与前文展示的HTTP响应消息结构保持一致。进一步来看,状态码及其对应的短语用于精确指示HTTP请求的处理结果,是HTTP协议中不可或缺的组成部分。常见的状态码及其含义如下:
我们前面提到HTTP协议是无状态的,这意味着每次请求都是独立的,服务器不会记住之前用户的任何信息。这种设计虽然简单高效,但也带来了一些问题:网站怎么知道这是同一个用户在访问呢?

让我们用淘宝购物的例子来详细解释cookies如何工作。小明第一次在淘宝上搜索“智能手机”,淘宝服务器看到这是新用户,就给他办了一张会员卡“9527”:
|HTTP/1.1 200 OK Set-Cookie: user_id=9527; path=/; domain=taobao.com Content-Type: text/html <html>欢迎来到淘宝,探索你的专属购物体验!</html>
小明的浏览器收到这个响应后,看到Set-Cookie头部,就把这个信息保存到Cookie文件中,就像把会员卡放进口袋。
下次小明再来淘宝时,浏览器会自动把会员卡信息放进HTTP请求里:
|GET /search?q=手机壳 HTTP/1.1 Host: taobao.com Cookie: user_id=9527 User-Agent: Mozilla/5.0
淘宝服务器看到Cookie: user_id=9527,就在数据库里查找9527号会员的信息,然后提供个性化服务:显示小明喜欢的手机壳款式、记住他的搜索偏好、提供专属优惠券。
Cookie技术由四个核心部分组成:
Cookies本身不是“坏东西”,关键在于如何使用它。淘宝合理使用cookies来提升购物体验,但也有人担心隐私问题。 比如说,如果淘宝把你的购物记录卖给广告商,或者被黑客盗取了你的登录信息,这就会变成隐私问题。 作为用户,我们可以在浏览器设置中管理cookies,定期清理cookies文件,或者使用隐私模式浏览。
Cookies让原本简单的HTTP协议变得更加智能,让我们享受更便捷的网络生活,但同时也需要我们更加关注个人隐私保护!
网页缓存(Web Cache,也常被称为代理服务器)是一种部署在网络中的中间实体,它能够代表原始服务器响应HTTP请求。网页缓存通常配备有本地磁盘或内存,用于保存近期被请求过的资源副本,从而提升访问效率。 在实际应用中,企业或学校的网络管理员可以将局域网内所有员工或学生的浏览器配置为优先向本地的网页缓存发送HTTP请求。这样,每当用户访问网页或下载资源时,请求都会首先到达网页缓存。
我们换一个更贴近生活的例子:假设在一家大型医院的局域网内,医生们经常需要访问医学影像资料,比如http://medicaldata.cn/images/ctscan123.jpg。下面是一次典型的访问流程:
首先,医生的浏览器会与医院内部的网页缓存建立TCP连接,并将对ctscan123.jpg的HTTP请求发送给缓存服务器。
接下来,网页缓存会检查自己本地的存储,看是否已经保存了这张CT影像的副本。如果缓存中已有该文件,缓存服务器就会直接用HTTP响应把影像数据返回给医生的浏览器,速度非常快。
如果缓存中没有这张影像,网页缓存就会主动与原始医学数据服务器(比如medicaldata.cn)建立TCP连接,并将医生的请求转发过去。原始服务器收到请求后,将影像数据通过HTTP响应发回给网页缓存。
需要注意的是,网页缓存在整个过程中既扮演了服务器的角色(对浏览器响应请求),也扮演了客户端的角色(向原始服务器发起请求并接收响应)。 正是这种双重身份,使得网页缓存能够有效地缓解原始服务器的压力,提高网络资源的利用率,并显著缩短用户的访问延迟。
网页缓存通常由运营商或大型机构部署在网络边缘,比如高校或企业会在局域网内部署缓存服务器,用户的浏览器请求会优先经过本地缓存。这样做的最大好处是显著降低了访问延迟,尤其是在本地网络与外部互联网之间带宽有限时,缓存能直接返回热门资源,极大提升了用户体验。
除了加速访问,网页缓存还能有效减少互联网出口链路的流量压力。以一个典型机构为例,如果没有缓存,所有请求都要穿越有限带宽的外网链路,容易造成拥堵和高延迟。部署缓存后,大量重复访问的内容可以在本地直接获取,只有未命中的请求才会占用外网带宽,从而节省了升级链路的高昂成本。
实际应用中,缓存的命中率通常在20%到70%之间。假设某高校的缓存命中率为40%,那么有40%的请求能在局域网内几乎瞬间响应,剩下的60%才需要访问外部服务器。这样一来,整体平均响应时间大幅下降,网络资源利用率也更高。
近年来,内容分发网络(CDN)进一步扩展了网页缓存的作用。CDN公司会在全球各地部署大量缓存节点,把热门内容分发到离用户最近的地方。无论是共享型CDN还是专用CDN,都极大地提升了互联网内容的分发效率,后续课程中我们会详细介绍CDN的工作原理。
虽然缓存能让我们上网更快,但也有个小麻烦:有时候,浏览器里存的“旧东西”可能和服务器上的“新东西”不一样。就像你把一份菜单拍照存在手机里,结果餐厅后来悄悄换了菜品,你还在看老菜单。 HTTP其实早就想到了这个问题。它有个很贴心的办法,叫“条件GET”。简单来说,就是让浏览器问问服务器:“嘿,这个东西最近有没有变过?如果没变,我就用我手里的,不用你再发一遍了。”
要实现这个“问一问”,浏览器发请求时会带上一个叫 If-Modified-Since: 的小标签,意思是“自从这个时间以后有更新吗?”只要用GET方法,并且加上这个头部,服务器就能明白你的意思。
首先,代理缓存(可以理解为帮大家存东西的"快递柜")代表浏览器,向网页服务器发出请求:
|GET /images/hello.gif HTTP/1.1 Host: www.mysite.com
然后源服务器会向缓存服务器返回响应消息,消息内容中包含所请求的资源对象:
|HTTP/1.1 200 OK Date: Sat, 3 Oct 2025 15:39:29 Server: Apache/1.3.0 (Unix) Last-Modified: Wed, 9 Sep 2025 09:23:24 Content-Type: image/gif (data data data data data ...)
缓存服务器在将对象转发给请求方浏览器的同时,会将该对象及其最后修改时间一并存储于本地。值得注意的是,缓存不仅保存了数据本身,还记录了服务器返回的最后修改时间戳。 假设一周后,另一台终端设备通过缓存服务器请求同一资源,此时缓存中依然存在该对象。 考虑到该对象在过去一周内可能已被源服务器更新,缓存服务器会主动进行一致性校验,具体做法是向源服务器发起条件GET请求。此时,缓存服务器发送如下内容:
|GET /images/hello.gif HTTP/1.1 Host: www.mysite.com If-modified-since: Wed, 9 Sep 2025 09:23:24
需要特别指出的是,If-modified-since: 头部字段的取值应与服务器此前返回的 Last-Modified: 时间戳严格一致。
通过这种条件GET机制,缓存服务器明确告知源服务器:仅当目标对象自该时间点之后发生过更新时,才需返回完整内容。
如果服务器端的资源自2025年9月9日09:23:24以来未发生任何更改,接下来服务器会向缓存服务器返回如下响应:
|HTTP/1.1 304 Not Modified Date: Sat, 10 Oct 2025 15:39:29 Server: Apache/2.2.3 (CentOS) (empty entity body)
我们看到,对条件GET的响应中,网页服务器仍然发送响应消息,但不在响应消息中包含请求的对象。包括请求对象只会浪费带宽并增加用户感知的响应时间,特别是如果对象很大。 注意,最后响应消息在状态行中有304 Not Modified,这告诉缓存它可以继续转发其(代理缓存的)缓存副本到请求浏览器。
电子邮件自互联网诞生之初便是最具代表性的应用之一,随着技术的演进,其体系结构和功能日益完善,成为现代网络通信不可或缺的基础服务。
电子邮件与传统邮政通信类似,均属于异步通信方式——用户可以根据自身时间安排灵活地发送和接收信息,无需与对方实时同步。与传统邮政相比,电子邮件具备传输速度快、分发范围广、成本低廉等显著优势。 当前的电子邮件系统还支持多种增强功能,例如附件传输、超链接、HTML富文本格式以及图片嵌入等,极大提升了信息表达的丰富性和交互性。

SMTP(Simple Mail Transfer Protocol,简单邮件传输协议)是互联网电子邮件传递的基石。它的标准定义在RFC 5321中。SMTP的历史比HTTP还要悠久,早在1982年就已经有了正式规范。虽然SMTP已经非常成熟和稳定,但它也保留了一些早期互联网的“老派”特征。 比如,SMTP最初只支持7位ASCII字符,这在上世纪八十年代是为了节省带宽和简化实现。但在今天,随着多媒体邮件的普及,发送图片、音频、视频等二进制内容时,必须先将这些内容编码成ASCII格式(比如Base64),传输后再解码回原始格式。这一点和HTTP直接支持二进制数据传输形成鲜明对比。
让我们用“xx科技有限公司”的实际业务场景来演示SMTP的工作流程。假设王明要向李娜发送一份项目进展报告,邮件内容为纯文本。 首先,王明在Outlook中输入李娜的邮箱地址(lina@xx.com),编辑好邮件内容,点击发送。Outlook会把邮件交给公司邮件服务器,邮件被放入外发队列。
接下来,邮件服务器上的SMTP客户端检测到有新邮件需要发送。它会主动与李娜所在的邮件服务器建立一条TCP连接(通常是25号端口)。如果李娜的服务器暂时不可达,王明的服务器会稍后重试,直到成功为止。
一旦连接建立,双方会进行一系列“自我介绍”和身份确认的应用层握手。比如,王明的服务器会告诉对方“我是mail.xx.com,准备发送邮件”,李娜的服务器则回应“欢迎,请继续”。随后,SMTP客户端会明确指出发件人和收件人的邮箱地址,确保邮件不会投递错地方。 握手完成后,王明的邮件内容会通过TCP连接可靠地传送到李娜的邮件服务器。SMTP协议保证了邮件内容的完整性和可靠性。如果王明还有其他邮件要发给李娜,完全可以复用这条TCP连接,连续发送多封邮件,直到所有邮件发送完毕后再关闭连接。
通过这个企业级的例子,我们可以看到,SMTP协议就像一位严谨的快递员,确保每一封邮件都能安全、准确地送达收件人手中,即使途中遇到障碍,也会反复尝试,直到任务完成或超时为止。
刚才咱们聊了xx科技公司的邮件发送过程,现在咱们来深入看看SMTP协议到底是怎么工作的。假设就你是xx科技公司的王明,想给同事李娜发个工作邮件。 你的电脑就像是客户端,公司的邮件服务器就是SMTP服务器。当你点击发送时,你的邮件客户端就开始和邮件服务器进行对话。
咱们来看看这个对话是怎么进行的。假设李娜的邮箱在另一个邮件服务器上,咱们叫它partner.com。下面就是你的邮件客户端和两个服务器之间可能的对话过程:
这个过程就像快递员在不同邮局之间传递包裹。每一步都有确认,确保邮件不会丢失或送错。
咱们继续用xx科技公司的例子来理解邮件消息的格式。想想王明发给李娜的那封工作邮件,它在电脑里是怎么存储和传输的? 其实,每封电子邮件都像是一封精心包装的信件。它不光有正文内容,还包含了很多“信封”信息,帮助邮件系统知道这封信该怎么处理。
比如,王明发给李娜的邮件,在邮件系统中可能长这样:
|From: wangming@xx.com To: lina@partner.com Subject: 项目进展报告 Date: Wed, 15 Oct 2025 10:30:00 +0800 Message-ID: <abc123@xx.com> 李娜,你好! 项目进展顺利,预计下周完成。 有什么问题随时联系。 王明
这封邮件有几个重要的部分, 首先是头部信息,就像信封上的地址标签一样:
From: 告诉我们谁发的邮件To: 告诉我们邮件要发给谁Subject: 就是邮件的主题Date: 记录邮件发送的时间Message-ID: 给每封邮件一个唯一的编号这些头部信息是用普通的文本格式写的,每行都是关键字: 值的格式。邮件系统就是靠这些信息来决定怎么处理邮件的。
头部信息和邮件正文之间有一个空行隔开,这个空行很重要,它告诉邮件系统头部结束了,正文开始了。
邮件正文就是我们平时看到的内容。在早期,邮件正文只能用纯文本格式,但现在我们可以用HTML格式发送漂亮的邮件,甚至还可以附加各种文件。
有趣的是,这些头部信息和咱们前面聊的SMTP命令不太一样。SMTP命令是邮件服务器之间对话时用的,比如MAIL FROM或者RCPT TO,那是传输协议的一部分。而头部信息是邮件内容本身的一部分,会跟着邮件一起保存和显示。
现在咱们来说说邮件的“最后一公里”问题——李娜收到王明邮件后,怎么在她的电脑或手机上看到这封邮件? 咱们想想,如果李娜的电脑必须一直开着,随时准备接收邮件,那得多耗电啊?而且如果她出差了或者电脑坏了怎么办?邮件不就丢了吗?
所以,现在的邮件系统是这样的:邮件先送到专门的邮件服务器上保存着,李娜需要的时候再去服务器上取。就像咱们去银行取钱或者去快递柜取快递一样。 咱们继续用xx科技公司的例子。李娜的邮件现在就静静地躺在partner.com的邮件服务器上,等着她来取。邮件服务器就像是个24小时营业的邮箱,随时准备着为用户服务。
现在问题来了,李娜怎么从邮件服务器上取邮件呢?她不能用SMTP协议,因为SMTP是用来“推”邮件的(从发件人推到收件人服务器),而李娜需要的是“收”邮件(从服务器拉到自己的设备)。
现在的邮件系统主要有两种取邮件的方式:
第一种是网页邮件,就像咱们用浏览器登录Gmail或者企业邮箱网站一样。这种方式用的是HTTP协议。李娜打开浏览器,输入邮箱地址和密码,服务器就把她的邮件用网页的形式展示给她。这种方式简单方便,不需要安装专门的软件,在任何有浏览器的设备上都能用。
第二种是用专门的邮件客户端软件,比如Outlook或者手机上的邮件APP。这种方式通常用IMAP协议。IMAP比HTTP更强大,它允许李娜在服务器上创建文件夹,把邮件分类整理。比如她可以建一个“重要项目”文件夹,把王明的工作邮件放进去,还可以把邮件标记为已读、未读,或者设置提醒。
不管用哪种方式,李娜都能随时随地访问她的邮件,而不用担心邮件会丢失。邮件服务器就像个忠诚的管家,把每一封邮件都妥妥当当地保存着,等主人来取。
在计算机网络中,主机的标识方式有多种。最常见的两种标识符分别是主机名(Hostname)和IP地址。主机名如www.baidu.com、mail.tsinghua.edu.cn等,便于人类记忆和识别,但主机名本身并不直接反映主机在网络中的具体位置。 主机名的结构灵活,长度可变,包含字母、数字和部分符号,这使得它们对网络设备(如路由器)来说处理起来并不高效。
相比之下,IP地址是一种面向网络设备的、结构化的主机标识符。IP地址采用32位(IPv4)或128位(IPv6)二进制数表示,通常以点分十进制(如192.168.1.1)或冒号分隔的十六进制(如2001:0db8:85a3::8a2e:0370:7334)形式展现。 每个IP地址都具有严格的分层结构,从高位到低位依次指明网络、子网及主机,能够精确定位主机在整个互联网中的位置。

这种分层结构类似于邮政地址的分级方式:省、市、区、街道、门牌号,每一级都提供了更具体的定位信息。网络中的路由器正是依赖IP地址的分层特性,实现高效的数据包转发和寻址。 因此,主机名适合人类使用,而IP地址则更适合网络设备处理。二者之间的映射和转换,是互联网正常运行的基础之一。
我们刚刚聊到,主机可以用主机名或者IP地址来标识。大家都喜欢用好记的主机名,比如www.taobao.com,而网络设备更喜欢结构清晰、长度固定的IP地址。为了让人和机器都能顺利沟通,我们就需要一个“翻译官”——也就是域名系统DNS,把主机名翻译成IP地址。
DNS其实就像是互联网的“电话簿”,它是一个分布式的数据库,由全球成千上万台DNS服务器共同维护。DNS服务器一般运行在UNIX系统上,常用的软件叫BIND。DNS协议用UDP协议传输,端口号是53。平时我们用浏览器上网、发邮件,其实背后都在用DNS帮我们查找主机的IP地址。
我们换一个例子来看看DNS是怎么帮忙的。假设小明在家里用电脑浏览网页,他想访问www.pku.edu.cn,也就是北京大学的官网。小明在浏览器地址栏输入网址后,浏览器其实并不知道www.pku.edu.cn对应的IP地址。这个时候,浏览器会把主机名www.pku.edu.cn交给操作系统里的DNS客户端。
DNS客户端就像个小助手,它会把这个主机名打包成一个查询请求,发给小明家里宽带运营商提供的本地DNS服务器。这个本地DNS服务器收到请求后,如果自己知道答案(比如之前有人查过,已经缓存了),就直接把IP地址返回给小明的电脑。 如果不知道,它会帮小明去更高级别的DNS服务器“打听”,一步步找到正确的IP地址,然后再告诉小明的电脑。
拿到IP地址后,浏览器就能和北京大学的服务器建立连接,顺利加载网页内容了。
通过这个例子,我们可以看到,DNS虽然帮我们省了不少事,但每次查找IP地址都要花一点时间,有时候还会遇到网络延迟。好在大多数常用网站的IP地址都会被本地DNS服务器缓存起来,这样下次再访问就会快很多,也能减少网络上的DNS流量。
假设小明在公司负责维护企业网站和邮件系统。公司的主机实际名称可能很长,比如web1.north-office.partner.com,但对外我们更希望用户记住简单的域名,比如partner.com或者www.partner.com。
这些简短好记的名字,就是主机的“别名”。而那个长长的、唯一的主机名,则叫做“规范主机名”。在实际应用中,DNS系统可以帮我们把这些别名自动解析成规范主机名和对应的IP地址。这样一来,无论用户输入哪个别名,最终都能准确访问到目标主机。
再比如,李娜在xx科技公司工作,她的邮箱地址是lina@partner.com。这个邮箱看起来很简洁,但实际上,负责收发邮件的服务器主机名可能是mail1.north-office.partner.com,甚至更复杂。
为了让大家不用记住这些难记的主机名,DNS系统专门为邮件服务设计了MX记录。MX记录允许我们把partner.com这个简单的名字,指向实际负责邮件收发的服务器。
这样,李娜只需要记住lina@partner.com,而不用关心背后邮件服务器的真实主机名。更进一步,企业还可以让网页服务器和邮件服务器共用同一个别名,比如都叫partner.com,DNS会根据不同的服务类型(比如网页访问还是邮件收发)自动找到正确的服务器。

在大型互联网公司,比如我们熟悉的“京东”或“淘宝”,一个网站往往有成百上千台服务器共同对外提供服务。为了让用户访问时不会都挤到同一台服务器,DNS系统还承担了“分流”的任务。比如,www.taobao.com这个域名背后,实际上对应着一组不同的IP地址。
每当用户发起DNS查询时,DNS服务器会把这些IP地址轮流返回给不同的用户。这样一来,大家的访问请求就被均匀分配到多台服务器上,实现了负载均衡。这个机制叫做“DNS轮转”。同样的道理,邮件服务器也可以用DNS轮转,让多台服务器共同分担邮件收发的压力。
其实,像内容分发网络(CDN)公司,比如Akamai,也会用更高级的DNS技术,把用户的请求智能地分配到最近、最快的服务器节点上,大大提升了访问速度和体验。
DNS的标准和细节在RFC 1034、1035等文档中有详细规定,感兴趣的同学可以查阅相关资料。我们这里主要结合一些简单的例子,帮助大家理解DNS在主机别名、邮件服务和负载均衡中的核心作用。
当用户在本地计算机上运行的应用程序(如网页浏览器或邮件客户端)需要访问某个主机时,通常只知道主机名(例如:www.fudan.edu.cn),而不直接掌握其对应的IP地址。
此时,应用程序会调用本地操作系统提供的DNS解析接口(在许多UNIX系统中常见的如gethostbyname()函数),将主机名提交给本地DNS解析器。
本地DNS解析器接收到主机名后,会自动构造DNS查询报文,并通过网络将该查询发送至配置好的本地DNS服务器。随后,本地DNS服务器会根据自身缓存情况,决定是直接返回结果, 还是继续向更高层级的DNS服务器(如根DNS、TLD DNS、权威DNS)发起递归或迭代查询。整个解析过程的响应时间通常在毫秒到秒级别。
最终,本地DNS解析器会收到包含目标主机IP地址的DNS响应报文,并将解析结果返回给最初发起请求的应用程序。对于应用程序而言,DNS解析过程是透明的,仿佛本地DNS解析器就是一个能够直接完成主机名与IP地址映射的“黑盒”服务。
然而,支撑这一“黑盒”服务的,是遍布全球、分层分布的大量DNS服务器,以及一套标准化的应用层协议,确保了主机名解析的高效性、可靠性与可扩展性。
在互联网这个庞大的系统中,主机数量极其庞大,DNS要想高效地管理和查询这些主机名与IP地址的映射,必须采用分布式、分层次的架构。我们可以把DNS想象成一棵巨大的树,树根、树干和树枝各自承担着不同的职责。没有哪一台DNS服务器能掌握全世界所有主机的“通讯录”, 而是把这些映射关系分散存储在全球成千上万台DNS服务器中。
从结构上看,DNS主要分为三大类服务器:根DNS服务器、顶级域(TLD)DNS服务器和权威DNS服务器。这三类服务器像接力棒一样,分工协作,层层递进,最终帮我们找到目标主机的IP地址。
我们举个例子来体会一下它们的配合过程。假如有一天,王老师在家里想访问www.taobao.com,他的电脑其实并不知道这个主机名对应的IP地址。于是,DNS客户端会发起一次查询。
首先,这个查询会被送到根DNS服务器,根服务器就像“总调度”,会告诉客户端负责“.com”这个顶级域的TLD服务器的地址。接下来,客户端再去联系taobao.com这个域名的权威DNS服务器在哪里。
最后,客户端再去找taobao.com的权威DNS服务器,权威服务器会把www.taobao.com真正的IP地址返回给客户端。
整个过程就像我们问路一样,先问到大致方向,再逐步缩小范围,最终找到目标。
如今,全球各地分布着一千多个根服务器实例。其实,这些根服务器本质上是13个不同根服务器的副本,由12家不同的组织共同管理和维护,这一切都由互联网号码分配局(IANA)进行协调。想要了解所有根名称服务器的详细列表、它们的管理机构以及对应的IP地址,可以查阅[Root Servers 2020]。根名称服务器的主要作用,就是为我们提供顶级域(TLD)服务器的IP地址。
每一个顶级域名(TLD),比如我们常见的 com、org、net、edu、gov,还有各个国家和地区的顶级域名,比如 cn、jp、uk、fr、ca 等,背后其实都对应着专门的顶级域名服务器(TLD 服务器),有时候甚至是一组服务器集群来共同承担压力。 以 com 域名为例,它的顶级域名服务器由 Verisign 这家公司负责维护,而 edu 域名的顶级服务器则由 Educause 机构管理。 TLD 服务器的网络架构往往非常庞大且复杂,支撑着全球海量的域名解析请求。如果我们想了解所有顶级域的详细列表,可以查阅相关的权威资料。
在互联网中,任何需要对外公开服务的主机,比如我们常见的网站服务器、邮件服务器等,都必须有一套公开的DNS记录,把主机名和IP地址一一对应起来。这些记录通常由组织自己的权威DNS服务器负责保存和管理。 对于大型企业或高校来说,他们往往会自建主备权威DNS服务器,确保服务的稳定性和安全性;而一些中小型组织则可能选择将DNS托管在专业服务商那里,由第三方权威DNS服务器代为管理。
DNS体系结构中,根DNS服务器、顶级域(TLD)DNS服务器和权威DNS服务器共同构成了分层的查询体系。不过,除了这三类“主角”之外,还有一种非常关键的角色——本地DNS服务器。 每个网络服务提供商(ISP),无论是家庭宽带还是校园网,都会为用户分配本地DNS服务器的地址。主机一旦连上网络,通常会通过DHCP自动获得本地DNS服务器的IP,这台服务器就像“前台接待”,负责接收用户的DNS请求,并帮忙去更高层的DNS服务器“打听消息”。
本地DNS服务器一般距离用户很近,可能就在同一个局域网里,或者只隔着一两个路由器。每当我们访问一个新网站,主机就会把DNS查询发给本地DNS服务器,由它代为向根、TLD和权威DNS服务器逐级查询,直到拿到最终的IP地址。这样做的好处是,用户只需要记住本地DNS服务器的地址,所有复杂的查询流程都由本地DNS服务器帮我们完成。
在实际查询过程中,DNS既支持递归查询,也支持迭代查询。比如,主机向本地DNS服务器发起请求时,通常是递归查询——也就是说,主机把“找IP”的任务全权委托给本地DNS服务器,让它帮忙跑完全程。而本地DNS服务器与根、TLD、权威DNS服务器之间的通信,则多采用迭代查询: 每一级服务器只告诉下一级的线索,由本地DNS服务器自己继续追查。这样既提高了效率,也方便了缓存和负载均衡。
我们前面聊了很多DNS的分层结构和查询流程,但如果每次都要从根服务器一级一级查下去,互联网早就被DNS流量挤爆了。实际上,DNS大量依赖缓存来提升响应速度、降低网络负载。 这个思路很简单:只要某台DNS服务器查到一个主机名和IP地址的对应关系,就会把这个结果暂时记在本地内存里。
还是我们刚才的例子,假如王老师在家里用电脑访问www.taobao.com,本地DNS服务器帮他查到了IP地址。过了一会儿,王老师的孩子也想访问淘宝,这时候本地DNS服务器就不用再去外面“打听”了,直接把刚才记下的IP地址拿出来用,速度快得多。
即使本地DNS服务器不是www.taobao.com的权威服务器,只要缓存里有结果,也能直接答复。
当然,互联网是不断变化的,主机名和IP地址的对应关系也可能会变。所以DNS缓存都有“保质期”,也就是TTL(Time To Live,生存时间)。TTL一到,缓存就会被清理掉,下一次再查就得重新走一遍流程。一般来说,TTL的时间可以从几分钟到几天不等,具体由权威DNS服务器设置。
有了缓存,很多常见的查询其实根本不会打扰到根服务器和TLD服务器。只有当本地DNS服务器缓存里没有答案,才会逐级向上查询。正因为如此,全球那么多用户每天都在访问网站,但根DNS服务器的压力其实远没有想象中那么大。
DNS之所以能高效地把主机名和IP地址对应起来,靠的就是一套标准的数据结构——资源记录(Resource Record,简称RR)。每一条资源记录都包含四个核心要素:
这里,“主机名”就是我们常说的域名,比如www.taobao.com;“值”可以是IP地址、另一个主机名或者邮件服务器的名字;“类型”用来标识这条记录的用途;“TTL”则规定了这条记录在缓存中能存活多久。
我们来看看常见的几种类型:
如果一台DNS服务器正好是某个主机名的权威服务器,那它就会保存该主机名的A记录。即使不是权威服务器,只要缓存里有,也能临时答复查询。如果不是权威服务器,它还会保存NS记录,指明下一级该找谁,并且通常会附带一条A记录,告诉你NS记录里提到的服务器的IP地址。
假设“edu”顶级域的DNS服务器不是“gaia.cs.umass.edu”的权威服务器,那它会有一条(umass.edu, dns.umass.edu, NS)记录,告诉大家“umass.edu”这个域名的权威服务器是“dns.umass.edu”;同时还会有一条(dns.umass.edu, 128.119.40.111, A)记录,直接给出“dns.umass.edu”的IP地址。
通过这些资源记录,DNS系统就可以让我们可以高效、准确地找到互联网中每一台主机的位置。
在前面的内容中,我们已经介绍了DNS协议中最核心的两类消息:查询(Query)和响应(Response)。实际上,DNS协议规定的消息类型主要就是这两种,并且它们在结构上完全一致。 每一条DNS消息都由一组严格定义的字段组成,这些字段共同描述了DNS请求与应答的详细内容和控制信息。下面我们将对DNS消息结构中的各个字段及其含义进行系统性说明:
如果我们想要直接从自己的电脑向某个DNS服务器发起DNS查询,其实操作起来非常简单。我们可以使用nslookup这个工具,它在Windows和大多数UNIX系统上都自带。 比如在Windows系统下,只需要打开命令提示符,输入“nslookup”并回车,就可以进入nslookup的交互界面。 在这里,我们可以指定任意DNS服务器(无论是根服务器、顶级域服务器还是权威服务器),然后发起查询。收到DNS服务器的响应后,nslookup会把结果用比较直观的方式展示出来。
在这一部分我们聚焦于P2P(点对点)技术在大文件分发场景下的实际应用。想象一下,我们需要把一个很大的文件,比如最新的Linux发行版、操作系统补丁,或者高清视频,从一台服务器分发给成千上万台电脑。 传统的客户端-服务器模式下,服务器得把完整的文件分别传给每一台电脑,这样一来,服务器的压力就非常大,带宽也很快就被耗尽。

而P2P分发方式就不一样了。每台参与的电脑(我们称之为“对等体”)在下载到文件的某一部分后,可以立刻把这部分内容分享给其他还没拿到的对等体。这样,服务器只需要把文件分发给一小部分人,剩下的分发工作就由大家一起完成,大大减轻了服务器的负担,提高了分发效率。 目前最流行的P2P文件分发协议就是BitTorrent。这个协议最早由Bram Cohen设计,现在已经有很多不同的BitTorrent客户端实现了这个协议,就像我们有很多种浏览器都能访问网页一样。
为了严谨比较客户端-服务器(Client-Server, C/S)架构与对等网络(P2P)架构的分发性能,下面建立一个定量模型。假设服务器与 个对等体均通过接入链路连接至互联网。
在C/S架构下,只有服务器负责分发,所有对等体仅作为接收方。
因此,C/S架构的最小分发时间下界为:
实际上,服务器可以通过合理调度达到该下界,因此取等号:
因此,当 较大时, 项主导,分发时间随对等体数量 线性增长。
在P2P架构下,对等体在接收到部分数据后可立即利用自身上行带宽协助分发。
综上,P2P架构的最小分发时间下界为:
从理论分析角度来看,只要每个对等体在接收到数据后能够立刻利用自身上行带宽参与转发,系统就可以通过合理的调度算法逼近上述分发时间下界。因此,在理想条件下,实际分发总时延可视为该下界的近似值:
假设所有对等体上行带宽均为 ,即 ,服务器带宽 ,且 ,则
此时,C/S架构分发时间随 线性增长,而P2P架构分发时间受 增长影响远小于C/S,且始终小于 小时。P2P架构展现出自扩展性:对等体越多,系统总上传能力越强,分发效率越高。
BitTorrent协议是当今最具代表性的P2P文件分发方案之一。它的核心思想是将大文件切分成许多小块,每个参与者(我们称为"对等体")既可以从别人那里下载自己没有的块,也可以把自己已有的块上传给其他人。 刚加入的对等体一开始什么都没有,随着下载进度的推进,手里的块会越来越多。等到某个对等体拿到全部块后,可以选择继续留在网络中帮助别人,或者直接离开。
BitTorrent网络中有一个叫"跟踪器"的节点,负责记录和管理所有参与者的信息。每当有新对等体加入时,它会向跟踪器报到,跟踪器会随机挑选一批现有对等体的地址发给新成员。 新成员会尝试和这些对等体建立连接,成为彼此的"邻居"。邻居的数量和具体对象会随着网络变化而动态调整。
每个对等体都只拥有文件的部分块,而且每个人手里的块都不完全一样。对等体会定期向邻居询问他们有哪些块,然后根据自己缺少的部分发起下载请求。 BitTorrent采用"稀缺优先"原则:优先下载那些在邻居中最少见的块,这样可以避免某些块变成瓶颈,提升整体分发效率。
在上传策略上,BitTorrent引入了"以牙还牙"的激励机制。每个对等体会优先把数据上传给那些给自己传输速度最快的邻居,通常选出四个主要上传对象。 每隔一段时间,还会随机挑选一个新邻居进行"乐观上传",让新加入的对等体也有机会获得数据。这样一来,上传和下载形成了互惠互利的关系,鼓励大家积极贡献带宽。
虽然这种激励机制理论上可以被个别用户规避,但在实际应用中,BitTorrent生态依然非常活跃,数以百万计的用户在成千上万个种子中共享资源。 除了文件分发,BitTorrent还广泛应用了分布式哈希表(DHT)技术,实现了去中心化的资源索引和查找,进一步增强了系统的健壮性和扩展性。

在当今互联网世界,在线视频流量已经成为主角。像抖音、爱奇艺、Bilibili这些平台,每天都在推动着全球网络流量的洪流。我们可以把这些流媒体服务想象成一个巨大的“点播图书馆”,用户只需轻点鼠标,就能随时从服务器上获取自己想看的视频内容。 这些服务器不仅仅是简单的存储空间,更像是智能的缓存管家,负责高效地把视频送到每一位观众手中。
说到视频本身,其实就是一连串快速播放的图片,每秒二十四帧、三十帧,像翻动的连环画一样。每一帧都是由无数像素点组成,每个像素都记录着亮度和颜色的信息。由于原始视频数据量极大,我们通常会用压缩算法来“瘦身”,这样既能节省带宽,也方便传输。 当然,压缩得越厉害,画质可能会有所下降,所以在清晰度和流畅度之间总要做个权衡。比如一部高清电影,压缩后每秒可能需要4Mbps甚至更高的带宽,而4K视频则可能超过10Mbps,这对网络提出了不小的挑战。
为了让大家都能顺畅地看视频,网络的平均吞吐量必须跟得上视频的码率。否则,播放时就会卡顿、缓冲,影响体验。聪明的做法是:为同一个视频准备多个不同清晰度的版本,比如300kbps、1Mbps和3Mbps。 这样,无论是用光纤上网的家庭,还是用3G流量的手机用户,都能根据自己的网络状况选择最合适的版本,既保证了流畅,也兼顾了画质。
像B站、爱奇艺这些视频网站,最早的视频播放方式其实很简单:我们可以把视频当作普通文件放在服务器上,用户点开视频时,浏览器就像下载图片一样,直接通过HTTP协议去服务器“拿”这个视频文件。 服务器会尽量快地把视频数据一股脑儿发过来,用户这边的播放器收到一部分数据后,先放进本地的缓冲区,等缓冲区里攒够了,播放器就开始边播边继续接收后面的内容。
不过,这种方式有个明显的短板。比如在高铁上用手机看视频,信号时好时坏,带宽忽高忽低,但服务器发过来的视频清晰度却是固定的。结果就是,有时候网速跟不上,视频就会一卡一卡的,体验很差。 为了解决这个问题,后来大家发明了DASH(动态自适应流),它的思路特别灵活:同一段视频,服务器会准备好多个不同清晰度、不同码率的版本。客户端每次只请求几秒钟的小片段,根据当前的网速和缓冲区情况,灵活选择是要高清的还是标清的。 比如在家用宽带时自动切高清,出门用流量时自动降到流畅模式,完全不用我们手动切换。
DASH的实现也很巧妙。每个视频版本在服务器上都有独立的地址,服务器还会提供一个“清单文件”,里面列出了所有版本的URL和对应的码率。 客户端先下载这个清单,了解有哪些选择,然后每次请求视频片段时,根据自己测到的带宽和缓冲区剩余量,动态决定下一个片段用哪个清晰度。 这样一来,无论我们是在地铁、咖啡馆还是家里,都能获得最适合当前网络状况的视频体验,既不卡顿,也不会浪费带宽。
想象一下,数以亿计的视频内容、数以亿计的播放请求,背后其实是对网络基础设施极高的考验。要让每一位观众都能流畅点播、随时互动,这可不是一件轻松的事。
如果我们只靠一个超级大数据中心,把所有视频都集中存储,然后直接从这里给全球用户推送视频,表面上看似简单,实际上却会遇到不少麻烦。 比如,离数据中心远的用户,数据包要跨越无数链路和运营商,任何一环卡顿,视频就会卡壳;热门视频还会在同一条链路上被反复传输,既浪费带宽又增加成本;更别说一旦数据中心或出口链路出故障,所有服务都得“罢工”。
为了解决这些难题,几乎所有主流视频平台都选择了内容分发网络(CDN)方案。CDN其实就是在全球各地部署一批服务器,把视频和其他内容的副本分散存储,然后根据用户的位置和网络状况,把请求智能地分配到最合适的节点。 这样一来,不仅能大幅提升播放流畅度,还能降低带宽消耗和单点故障风险。CDN有的由内容提供商自建,比如Google自家的CDN专门服务YouTube,也有第三方公司如阿里云、Cloudflare等为多家平台提供分发服务,成为现代互联网不可或缺的基础设施。
在内容分发网络(CDN)领域,服务器的部署位置直接决定了用户体验和平台成本。全球CDN巨头Akamai最早提出“深度下沉”策略,即把服务器集群深入部署到各地运营商的接入网,尽可能靠近用户。 这样做的好处是极大降低了用户访问延迟,提高了命中率,但也带来了极高的运维和管理复杂度。Akamai在全球数千个接入点部署了节点,维护压力可想而知。
与之相对,Limelight等CDN厂商则采用“带回家”策略:不再深入每个运营商的接入网,而是在有限的几十个大型节点(比如互联网交换中心IXP)部署大规模集群。这样做维护成本低,扩展和管理都更容易,但距离用户更远,可能牺牲部分访问速度和体验。
在中国,主流CDN服务商(如阿里云、腾讯云、网宿、百度云加速等)通常会结合两种思路:一方面在全国各大运营商(中国电信、中国移动、中国联通)骨干网和省级节点部署大量边缘节点,另一方面也会在核心城市的IXP和数据中心建设超级节点。这样既能兼顾广覆盖,也能保证热门内容的高效分发。
CDN内容分发时,并不会把所有视频或文件副本都推送到每一个节点。对于冷门内容,采用“拉取缓存”机制:只有当用户请求时,节点才会从中心仓库或上游节点拉取内容,并在本地缓存一份。缓存空间有限时,采用类似网页缓存的淘汰策略,优先保留热门内容,冷门内容自动清理。
无论采用哪种部署策略,CDN的核心任务都是:当用户请求某个内容时,如何智能地把请求引导到最合适的节点。比如优酷、爱奇艺、B站等,用户在浏览器中点击视频链接时,实际上会经历一系列“智能调度”过程。
绝大多数CDN采用DNS重定向技术来实现请求分流。例如B站用户访问视频页面时,视频URL会指向如video.bilibili.com这样的域名。用户本地的DNS服务器(通常由运营商分配)会递归查询B站的权威DNS服务器。
B站的DNS系统会根据请求来源IP、网络运营商、地理位置、节点负载等多维度信息,动态返回一个最优CDN节点的域名(如a1.bilivideo.com)。随后,用户的DNS服务器继续查询,最终获得具体CDN节点的IP地址。这样,用户的请求就被“无感知”地引导到了最近、最优的CDN服务器。
整个流程大致如下:
CDN如何选择“最优”节点?这背后有一套复杂的调度算法。最简单的做法是“地理就近”:通过IP地理库判断用户大致位置,分配最近的节点。但实际网络环境远比地理距离复杂。比如,某些用户的DNS服务器并不在本地,或者网络路径绕行,导致“最近”未必“最快”。
更高级的CDN会结合实时网络测量(如ping、traceroute、主动探测)、节点负载、带宽利用率等多维度数据,动态调整调度策略。部分CDN还会与运营商合作,获取更精细的网络拓扑和流量信息,实现“智能调度”。
爱奇艺作为中国最大的在线视频平台之一,采用了自建+第三方CDN混合的分发架构。自建CDN节点遍布全国各省市,深入三大运营商的骨干网和部分地市级接入网。对于热门影视剧、综艺等内容,爱奇艺会提前将多码率版本推送到各地节点,实现“推送缓存”。对于长尾内容,则采用“拉取缓存”策略,按需分发,节省存储和带宽资源。
例如爱奇艺的调度系统会根据用户的IP、运营商、网络质量、节点负载等信息,动态分配最优节点。对于部分合作运营商,爱奇艺还会在其机房内部署专用服务器,实现“网内直达”,极大提升访问速度和稳定性。
B站同样采用自建CDN+云CDN混合模式。自建节点主要覆盖核心城市和高校园区,云CDN则补充覆盖全国。B站的视频内容采用DASH自适应流,客户端会根据实时带宽和缓冲区情况,动态选择不同清晰度的视频分片。 B站的CDN调度系统不仅考虑地理和网络距离,还会根据节点实时负载、用户活跃度、内容热度等因素做智能分流,确保热门内容高可用、冷门内容高效分发。
B站还会针对高并发场景(如大型直播、热门番剧首播)提前做内容预热,将相关视频分片推送到各地节点,避免“雪崩效应”。
时至今日,中国CDN行业经过十余年发展,已经形成了“深度下沉+智能调度+冷热分层缓存”的成熟体系。无论是爱奇艺、B站,还是阿里云、腾讯云等云CDN,都在不断优化节点部署和调度算法,力求让每一位用户都能“秒开不卡”,享受高质量的音视频体验。 这背后,是数以万计的CDN节点、PB级的缓存空间、以及复杂的智能调度系统在默默支撑。
我们已经一起了解了各种经典的网络应用案例。接下来,让我们真正动手,看看如何亲自开发一个网络应用。还记得我们在之前聊过的内容吗?其实,绝大多数网络应用的本质,就是“客户端”和“服务器”这两个程序,分别运行在不同的终端设备上。 它们之间通过“套接字”这个桥梁,互相发送和接收数据。对于开发者来说,最核心的任务,就是把客户端和服务器的代码写出来,让它们能顺畅地沟通。

说到网络应用,其实大致可以分为两类。第一类是基于公开协议标准的应用,比如那些写在RFC文档里的协议。这类应用有个特点:规则透明,大家都能查到。 只要开发者严格按照协议规范来写,无论客户端还是服务器,哪怕是不同团队开发的程序,也能无缝协作。 比如,咱们平时用的HTTP协议,浏览器和Web服务器之间的通信,就是这种典型的“开放”模式。 你可以用Google Chrome访问Apache服务器,也可以用BitTorrent客户端和全球的tracker通信,这些都是因为大家都遵循了同一套公开的协议。
第二类则是“专有”网络应用。这里,客户端和服务器用的是自家定制的协议,这些协议没有公开发布,只有开发团队自己知道细节。这样做的好处是灵活,开发者可以完全掌控通信的每一个细节,但缺点也很明显:外部开发者很难写出能和你互通的程序。 比如有些公司内部的业务系统,或者某些特殊的即时通讯工具,就是这种“闭门造车”的模式。
UDP套接字其实很简单。每次我们要发数据,得告诉操作系统“我要把这包东西发给谁”,也就是要附上目标的IP地址和端口号。这样,数据包在互联网里就能被路由到正确的主机和对应的应用程序。你可以把IP地址想象成小区的门牌号,端口号就像是家里的房间号——只有两者都对,快递才能送到你手里。 当然,除了目标地址,数据包里还会自动带上“我是从哪来的”这个信息,也就是源IP和源端口。这个过程不用我们操心,操作系统会帮我们搞定。
为了让大家更直观地理解UDP的用法,我们来设计一个非常经典的小实验:客户端和服务器配合,做个“大小写转换器”。流程是这样的:客户端让用户输入一行小写字母,把这行字发给服务器; 服务器收到后,把字母全变成大写,再原路返回给客户端;客户端收到后,直接显示出来。整个过程就像是我们把一张写着小写字母的纸条递给朋友,朋友帮我们全部改成大写再递回来。
在实际编程时,我们会用最精简的代码来演示UDP的基本用法,重点突出核心逻辑。比如,端口号我们就随便定个12000,方便大家记忆。等会儿我们还会一行一行地讲解每段代码,让大家彻底搞明白UDP套接字的工作原理。
UDPClient.py的代码如下:
|from socket import * serverName = 'hostname' serverPort = 12000 clientSocket = socket(AF_INET, SOCK_DGRAM) message = input('Input lowercase sentence:') clientSocket.sendto(message.encode(),(serverName, serverPort)) modifiedMessage, serverAddress = clientSocket.recvfrom(2048) print(modifiedMessage.decode()) clientSocket.close()
现在让我们看看UDPClient.py中的各种代码行。
from socket import *
socket模块构成了Python中所有网络通信的基础。通过包含这一行,我们将能够在程序中创建套接字。
serverName = 'hostname'
serverPort = 12000
第一行将变量serverName设置为字符串'hostname'。这里,我们提供一个字符串,其中包含服务器的IP地址(例如,"128.138.32.126")或服务器的主机名(例如,"cis.poly.edu")。如果我们使用主机名,则会自动执行DNS查找以获取IP地址。第二行将整数变量serverPort设置为12000。
clientSocket = socket(AF_INET, SOCK_DGRAM)
这一行创建客户端的套接字,称为clientSocket。第一个参数指示地址族;特别是,AF_INET指示底层网络正在使用IPv4。(现在不用担心这个——我们将在第4章讨论IPv4。)第二个参数指示套接字是SOCK_DGRAM类型,这意味着它是一个UDP套接字(而不是TCP套接字)。注意,在创建它时,我们没有指定客户端套接字的端口号;我们让操作系统为我们做这件事。现在客户端进程的门已经创建,我们希望创建一个通过门发送的消息。
message = input('Input lowercase sentence:')
input()是Python中的内置函数。当执行此命令时,客户端的用户会收到"Input lowercase sentence:"提示。然后用户使用键盘输入一行,这会被放入变量message中。现在我们有了套接字和消息,我们希望将消息通过套接字发送到目标主机。
clientSocket.sendto(message.encode(), (serverName, serverPort))
在上面的行中,我们首先使用encode()方法将消息从字符串类型转换为字节类型,因为我们需要将字节发送到套接字。sendto()方法将目标地址(serverName, serverPort)附加到消息,并将结果数据包发送到进程的套接字clientSocket。(如前所述,源地址也被附加到数据包,尽管这是由代码自动完成的而不是显式完成的。)通过UDP套接字发送客户端到服务器的消息就是这么简单!发送数据包后,客户端等待从服务器接收数据。
modifiedMessage, serverAddress = clientSocket.recvfrom(2048)
当数据包从互联网到达客户端的套接字时,数据包的数据被放入变量modifiedMessage,数据包的源地址被放入变量serverAddress。变量serverAddress包含服务器的IP地址和服务器的端口号。程序UDPClient实际上不需要这个服务器地址信息,因为它从一开始就知道服务器地址;但这一行Python仍然提供了服务器地址。recvfrom方法还将缓冲区大小2048作为输入。(这个缓冲区大小适用于大多数目的。)
print(modifiedMessage.decode())
这一行在用户的显示器上打印出modifiedMessage,在将其从字节转换为字符串后。它应该是用户键入的原始行,但现在是大写的。
clientSocket.close()
在这最后一步,我们调用了clientSocket.close(),关闭套接字后,客户端和服务器之间的通信通道就被彻底断开了。此时,客户端程序也会随之结束运行。
现在让我们看看应用程序服务器端:
|from socket import * serverPort = 12000 serverSocket = socket(AF_INET, SOCK_DGRAM) serverSocket.bind(('', serverPort)) print("The server is ready to receive") while True: message, clientAddress = serverSocket.recvfrom(2048) modifiedMessage = message.decode().upper() serverSocket.sendto(modifiedMessage.encode(), clientAddress)
注意UDPServer的开头与UDPClient类似。它也导入socket模块,也将整数变量serverPort设置为12000,也创建一个SOCK_DGRAM类型的套接字(UDP套接字)。与UDPClient显著不同的第一行代码是:
serverSocket.bind(('', serverPort)
上面的行将端口号12000绑定(即分配)到服务器的套接字。因此,在UDPServer中,代码(由应用程序开发者编写)显式地为套接字分配端口号。这样,当任何人向服务器IP地址的端口12000发送数据包时,该数据包将被定向到这个套接字。
UDPServer然后进入while循环;while循环将允许UDPServer无限期地从客户端接收和处理数据包。在while循环中,UDPServer等待数据包到达。
message, clientAddress = serverSocket.recvfrom(2048)
这一行代码与我们在UDPClient中看到的类似。当数据包到达服务器的套接字时,数据包的数据被放入变量message,数据包的源地址被放入变量clientAddress。变量clientAddress包含客户端的IP地址和客户端的端口号。这里,UDPServer将使用这个地址信息,因为它提供了一个返回地址,类似于普通邮政邮件的返回地址。有了这个源地址信息,服务器现在知道应该将回复定向到哪里。
modifiedMessage = message.decode().upper()
这一行是我们简单应用程序的核心。它获取客户端发送的行,在将消息转换为字符串后,使用upper()方法将其大写。
serverSocket.sendto(modifiedMessage.encode(), clientAddress)
最后一行将客户端地址(IP地址和端口号)附加到大写消息(在将字符串转换为字节后),并将结果数据包发送到服务器的套接字。(如前所述,服务器地址也被附加到数据包,尽管这是由代码自动完成的而不是显式完成的。)互联网然后将数据包交付到这个客户端地址。服务器发送数据包后,它留在while循环中,等待另一个UDP数据包到达(来自运行在任何主机上的任何客户端)。
和UDP相比,TCP最大的特点就是“面向连接”。什么意思呢?就是客户端和服务器在正式传输数据之前,必须先“握个手”,建立一条专属的TCP连接。 想象一下,TCP就像是我们打电话,双方都要先接通,才能开始说话。这个连接一头连着客户端的套接字,另一头连着服务器的套接字。 建立连接时,系统会把客户端和服务器的IP地址、端口号都绑定在一起。只要连接建立好了,双方就可以随时通过各自的套接字把数据“塞”进这条专线,TCP会保证数据顺序、完整地送到对方手里。
我们来仔细聊聊TCP下客户端和服务器的互动。客户端的任务就是主动联系服务器。为了让服务器能及时响应,服务器得提前做好准备。 首先,服务器进程要先跑起来,等着客户端来找它。其次,服务器得有一个“欢迎门”,也就是一个专门用来接待新客户的套接字。 我们可以把这个过程想象成家里装了门铃,客户端来敲门,服务器听到后就开门迎客。
当服务器在运行时,客户端就可以发起TCP连接了。客户端会先创建一个TCP套接字,然后指定服务器的IP和端口号,告诉操作系统“我要找这台服务器”。 接下来,客户端会发起著名的“三次握手”,和服务器建立起一条可靠的连接。这个握手过程其实都在操作系统底层完成,程序员基本不用操心。
握手过程中,客户端就像敲响了服务器的门铃。服务器一旦“听到”敲门声,就会为这个客户端专门开一扇新门——也就是创建一个新的连接套接字。 这样,服务器的“欢迎门”负责接待新客户,每个客户进来后都有自己的“专属门”。很多刚学TCP的同学容易把“欢迎门”(serverSocket)和“专属门”(connectionSocket)搞混,其实它们的分工很明确。
从应用程序的角度看,客户端的套接字和服务器的连接套接字之间就像拉了一根管道。客户端可以随时把数据丢进管道,TCP保证服务器会按顺序收到。TCP的可靠性就在这里体现出来了。
为了让大家更直观地理解,我们还是用那个经典的小例子:客户端发一行小写字母给服务器,服务器收到后把它变成大写再发回来。通过这个例子,我们可以清楚地看到TCP套接字编程的基本流程和核心机制。
TCPClient.py的代码如下:
|from socket import * serverName = 'servername' serverPort = 12000 clientSocket = socket(AF_INET, SOCK_STREAM) clientSocket.connect((serverName,serverPort)) sentence = input('Input lowercase sentence:') clientSocket.send(sentence.encode()) modifiedSentence = clientSocket.recv(1024) print('From Server: ', modifiedSentence.decode()) clientSocket.close()
现在让我们看看与UDP实现显著不同的代码中的各种行。第一行是创建客户端套接字。
clientSocket = socket(AF_INET, SOCK_STREAM)
这一行创建客户端的套接字,称为clientSocket。第一个参数再次指示底层网络正在使用IPv4。第二个参数指示套接字是SOCK_STREAM类型,这意味着它是一个TCP套接字(而不是UDP套接字)。 注意,我们再次没有在创建它时指定客户端套接字的端口号;我们让操作系统为我们做这件事。现在下一行代码与我们在UDPClient中看到的非常不同:
clientSocket.connect((serverName,serverPort))
回想一下,在客户端可以使用TCP套接字向服务器(或反之)发送数据之前,必须首先在客户端和服务器之间建立TCP连接。 上面的行发起客户端和服务器之间的TCP连接。connect()方法的参数是连接的服务器端地址。执行这一行代码后,执行三方握手,客户端和服务器之间建立TCP连接。
sentence = input('Input lowercase sentence:')
与UDPClient一样,上面的从用户获取一个句子。字符串sentence继续收集字符,直到用户通过键入回车结束行。下一行代码也与UDPClient非常不同:
clientSocket.send(sentence.encode())
上面的行通过客户端的套接字将句子发送到TCP连接。注意,程序不会像UDP套接字那样显式创建数据包并将目标地址附加到数据包。 相反,客户端程序只需将字符串sentence中的字节放入TCP连接。客户端然后等待从服务器接收字节。
modifiedSentence = clientSocket.recv(2048)
当字符从服务器到达时,它们被放入字符串modifiedSentence。当行以回车字符结束时,字符停止在modifiedSentence中累积。打印大写句子后,我们关闭客户端的套接字:
clientSocket.close()
最后一行关闭套接字,从而关闭客户端和服务器之间的TCP连接。
现在让我们看看服务器程序。
|from socket import * serverPort = 12000 serverSocket = socket(AF_INET,SOCK_STREAM) serverSocket.bind(('',serverPort)) serverSocket.listen(1) print('The server is ready to receive') while True: connectionSocket, addr = serverSocket.accept() sentence = connectionSocket.recv(1024).decode() capitalizedSentence = sentence.upper()
现在让我们看看与UDPServer和TCPClient显著不同的行。与TCPClient一样,服务器使用以下创建TCP套接字:
serverSocket=socket(AF_INET,SOCK_STREAM)
与UDPServer类似,我们将服务器端口号serverPort与这个套接字关联:
serverSocket.bind(('',serverPort))
但在TCP中,serverSocket将是我们的欢迎套接字。建立这个欢迎门后,我们将等待并监听来自客户端的敲门:
serverSocket.listen(1)
这一行让服务器监听来自客户端的TCP连接请求。参数指定排队连接的最大数量(至少1)。
connectionSocket, addr = serverSocket.accept()
当客户端敲门时,程序为serverSocket调用accept()方法,这在服务器中创建一个新的套接字,称为connectionSocket,专门用于这个特定的客户端。 然后客户端和服务器完成握手,创建客户端的clientSocket和服务器的connectionSocket之间的TCP连接。一旦建立了TCP连接,客户端和服务器就可以通过连接相互发送字节。在TCP中,从一侧发送的所有字节不仅保证到达另一侧,而且保证按顺序到达。
connectionSocket.close()
在这个程序中,发送修改后的句子到客户端后,我们关闭连接套接字。但由于serverSocket保持打开,另一个客户端现在可以敲门并向服务器发送要修改的句子。
我们不妨在两台不同的电脑上分别运行这两个小程序,然后动手改一改它们,让它们实现一些有趣的新功能。比如,我们可以让客户端发送一段中文诗句,服务器收到后把它倒序返回。 这样做的过程中,我们可以亲自体验一下UDP和TCP这两种协议在实际使用时的差异。比如,UDP传输时可能会丢失诗句的一部分,而TCP则会保证每个字都完整无缺地送到对方手中。 通过这样的实验,我们能更直观地感受到这两种协议在可靠性、顺序性等方面的不同之处。
这一部分我们从零开始一步步了解了网络应用程序的世界。我们一起体验了客户端-服务器架构的普遍性,发现了它在网页浏览、邮件收发、文件传输和域名解析等场景中的身影。 我们还顺便探讨了P2P架构的独特之处,比较了它和客户端-服务器的不同。流媒体视频和CDN的结合也让我们看到了现代互联网内容分发的高效与巧妙。 通过动手实践,我们逐渐掌握了如何用套接字API来构建自己的网络小工具,体会了TCP和UDP两种传输方式的差异。 可以说,我们已经打开了网络分层架构的大门,同样也迈出了我们坚实的第一步。
回头看看,我们最初对“协议”这个词的理解还很模糊,只知道它大致是通信双方约定的消息格式和处理方式。现在,经过对HTTP、SMTP、DNS等协议的深入剖析,我们对协议的本质有了更直观的认识。 我们也初步了解了TCP和UDP为应用提供的服务模型,虽然还没揭开它们实现细节的神秘面纱,但也足够我们理解它们的工作原理了。 接下来,我们就要继续深入,探索传输层的奥秘,看看这些协议到底是如何在背后默默保障我们数据安全可靠传递的。
等服务器把包裹发完,就会说:“这次通话结束啦!”(告诉TCP可以关闭连接了。)不过,TCP会等确认浏览器真的收到了包裹,才会彻底挂断电话。
浏览器收到包裹后,打开一看,发现里面是主页的HTML文件。它仔细一读,发现页面里还引用了两张书籍封面图片。于是,浏览器又得分别给服务器打两次电话,每次都说:“老板,我还要/images/book1.png!”、“老板,我还要/images/book2.png!”,每次都是同样的流程:打电话、要文件、收包裹、挂电话。
等所有的内容都收齐了,浏览器就能把完整的页面展示给我们看了。
网页缓存收到原始服务器的响应后,会把这份影像数据存储到本地,以便下次有医生再请求同样的资料时可以直接提供。同时,缓存服务器会把这份数据通过现有的TCP连接返回给最初发起请求的医生浏览器。