计算机网络02-应用层概览与SMTP电子邮件发送

0

应用层协议原理

应用程序体系结构

应用层是计算机网络结构体系的最顶层,也是计算机网络最终的发展目的。计算机网络的其余体系结构主要都是为应用层提供正常运作所需的服务而建立的。

应用层主要通过应用程序的交互来实现网络应用,其核心是编写能够运行在不同的端系统和通过网络彼此通信的程序。因此这一章内容基本只涉及软件方面的应用。

应用程序体系结构由研发者规定如何在各种端系统上组织来自多方的应用程序。目前来说有两种主要的体系结构:

客户-服务器体系结构(client-server architecture)中总是打开的主机称为服务器,它处理许多客户的请求。该体系结构中客户端之间不直接通信。例如,大型游戏需要有游戏服务器来记录玩家数据;视频网站需要有资源服务器来向用户提供视频。

P2P体系结构(Peer-to-Peer architecture)中,应用程序在间断连接的对等方主机对之间使用直接通信,这种对等方通信不需要通过专门的服务器。例如,一些小型游戏可以直接在两台主机间连接,而无需服务器的存在。

下图展示了这两种应用程序体系结构的区别:

客户-服务器体系结构与P2P体系结构

在客户-服务器体系结构中,一台服务器往往要同时接收并处理多个客户的请求,为了避免单台服务器性能不足的问题,大型互联网服务往往使用计算机集群构建一个强大的虚拟服务器。而 P2P 体系结构则不存在该问题,服务随着规模的扩大,对等方中服务的提供者也会随之增加。

进程通信

在应用层中,进行通信的实际上是进程(process),多个进程运行在相同的端系统上时,它们使用操作系统规定的进程进程通信机制相互通信。

进程是程序的载体。程序在执行时,操作系统会为它提供一些内存、输入设备(如键盘、鼠标)接口、权限等额外内容,由此产生的真正运行的任务称为进程。

由于应用层依赖操作系统提供的底层服务,因此应用层通信的真正主体是进程。

在两个不同端系统上的进程通过跨越计算机网络交换报文(message)。报文是一种特殊格式的数据,不同应用程序体系交换的报文格式也可能不同。发送进程被创建并向网络中发送报文;接收进程接收这些报文并可能通过回复报文响应发送方。

在一对进程之间的通信会话场景中,发起通信的进程被标识为客户端(client),在会话开始时等待联系的进程是服务端(server)。

进程通过套接字(socket)软件接口向网络发送和接收报文。套接字是同一台主机内应用层与运输层之间的接口,也称为应用程序编程接口(Application Programming Interface, API)。应用程序可以控制套接字在应用层端的功能。下图展示了套接字与进程在计算机网络中所处的位置(这里只关心应用层与运输层之间的部分):

进程、套接字与计算机网络其余部分的图示

为了向特定目的主机的进程发送分组,接收进程通过其IP 地址(IP address)标识,目前只需要知道它是一个 32 比特能够唯一标识主机的虚拟地址。目的地的端口号(port number)用于指定接收主机上的接收进程,它是一个无符号 16 位整数,用于区分一台主机上多个同时运行的套接字应用程序。总体来说,IP 地址和端口号结合,可以在互联网中找到一台主机上的特定进程。

在运输层中,会更详细地介绍端口号存在的意义;在网络层中,会更详细地介绍 IP 地址的作用。

运输服务

运输层是应用层之下的部分,运输层的作用就是提供可靠数据传输(reliable data transfer),确保一个端系统发送的数据能够正确、完全地交付给另一个端系统的应用程序。

当一个运输层协议不提供可靠数据传输时,容忍丢失的应用(lost-tolerant application)可以接受部分无法到达接收进程的数据。

可用吞吐量指两个进程通信时,发送进程向接收进程发送比特的速率。运输层协议应保证可用吞吐量不低于应用程序要求的吞吐量。具有吞吐量要求的应用被称为带宽敏感的应用(bandwidth-sensitive application),而弹性应用(elastic application)能根据当时可用的吞吐量调整传输速率并尽可能利用当前的吞吐量。例如,互联网电话、视频直播就属于带宽敏感的应用,在吞吐量不足时会造成卡顿、信息丢失等情况;而文件传输、电子邮件则属于弹性应用,此时发送方在一定程度上允许延后接收来适应缓慢的传输速率。

运输层协议也需要提供交互式实时应用程序的定时保证,发送方传递给套接字的每个比特到达接收方的套接字不能有太大间隔。较长的时延会使一些实时应用发生不自然的停顿等问题。

最后,运输协议也可能需要为应用程序提供若干安全性服务,以防止传输的数据被窃取或篡改。


互联网为应用程序提供了两个运输层协议:TCP 协议和 UDP 协议。这里仅对此做简要介绍,在运输层中才会真正涉及这两个协议的细节。

  • TCP 服务

TCP 服务模型包括面向连接服务和可靠数据传输服务。

在应用层数据报文传输之前,TCP 服务会先在客户和服务器之间握手,交互运输层控制信息,握手之后会在两个进程的套接字之间建立一个 TCP 连接,连接双方的进程可以在此连接上同时收发报文,并当应用程序结束报文发送后会拆除该连接。

使用 TCP 服务,可以按正确顺序接收所有数据,不会有字节的丢失、乱序等差错。

  • UDP 服务

UDP 是一种不提供不必要服务的轻量级运输服务,仅提供最小服务,因此它是无连接的,两个进程通信前没有握手过程。

除此之外,UDP 也不能保证报文到达接收进程,也不能保证接收的顺序正确。但是 UDP 传输更快,基于 UDP 的扩展性也更强。

实验:套接字编程

UDP 套接字编程

UDP 编程相对比比较简单,因此接下来先研究 UDP 编程。

当套接字使用 UDP 通信时,在发送进程能够将数据分组从应用层推到运输层之前,需要先将目的地址填入分组里,这样互联网可以正确连接到接收方的套接字。

一台主机可以运行多个网络进程,因此当生成一个套接字时,会为它分配一个端口号。发送进程除了为分组附加上目的地址,还要带上目的端口号。为了能接收到目的主机的响应信息,操作系统还会为发送方的分组附带源主机的 IP 地址和套接字端口号。

当分组到达接收套接字时,接收进程通过套接字取回分组,然后检查分组内容并采取适当动作。

下图描述了这一通信的过程:

UDP 套接字的通信过程
  • UDP 服务端套接字程序

服务器为了能够接收并回答该客户的报文,它必须提前运行套接字进程并等待接收。因此这里首先研究服务端的套接字程序。

操作系统提供了套接字的接口,为了使用套接字,首先需要导入相应的模块:

# server.py
import socket

socket 模块是 Python 对操作系统提供的套接字接口的封装,是网络通信的基础,通过该模块可以在程序中创建套接字。

对于每个生成的套接字,都需要为其分配一个端口号。例如,这里确定待分配的端口号为 12000 :

server_port = 12000

接下来,通过 socket 类创建一个套接字:

server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

第一个参数指示了所使用的地址族,这里 socket.AF_INET 表示使用的是一般的 IP 地址,不同类型的网络使用的地址族也不同,其它类型的地址留待网络层讨论。第二个参数指示该套接字是 socket.SOCK_DGRAM 类型,即一个 UDP 套接字。

接下来,需要将端口号 12000 与该服务器的套接字绑定,即让套接字运行在端口 12000 上:

server.bind(('', server_port))

这样,任何人向位于该服务器的 IP 地址的端口 12000 发送一个分组时,操作系统将该分组将导向该套接字。

服务端的套接字进程需要持续运行,以在任何时候接收到客户端发送的信息,因此需要使用 while True 无限循环:

while True:
    received_message, client_address = server.recvfrom(2048)
    message = received_message.decode()
    server.sendto(f'{message} ({len(message)})'.encode(), client_address)

套接字对象的 .recvform(bufsize) 方法将会返回一个有两个元素的元组,第一个元素是返回的数据字符串,第二个元素是数据的来源地址。.sendto(data, address) 方法则向特定远程地址发送数据。

报文需要以字节的形式发送,因此发送的字符串需要提前调用 .encode() 方法将其变为字节形式,接收的字节也需要通过 .decode() 将其解码为一般形式的字符串。

  • UDP 客户端套接字程序

为了找到服务端对应的套接字,客户端的 UDP 套接字程序还需要提供服务端的 IP 地址或主机名:

# client.py
import socket

server_name = 'localhost'
server_port = 12000

然后,创建一个用户端的 UDP 套接字对象:

client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

用户端的 UDP 套接字将报文发送到服务端的套接字里:

message = input('Enter something to send: ').encode()
client.sendto(message, (server_name, server_port))

received_message, address = client.recvfrom(2048)
print(received_message.decode())

发送时,操作系统会自动将源主机地址附加在分组上,无需显式通过代码完成。发送之后,客户端可能还需要再次接收来自服务端的响应报文。在这里,客户端已经不需要使用了,可以主动关闭:

client.close()

在本地主机上分别运行这两个套接字程序,即可看到实验现象:

$ python -u server.py
$ python -u client.py Enter something to send: Hello, world! Hello, world! (13)

TCP 套接字编程

TCP 是一个面向连接的协议,因此客户和服务器需要先握手创建一个 TCP 连接,然后才能够开始互相发送数据。TCP 连接的一端与客户套接字相联系,另一端与服务器套接字相联系。

当客户指定服务器套接字的地址与端口号后,客户会在运输层与服务器发起连接请求,然后服务器会生成一个新的用于连接的套接字,并且该连接套接字仅用于该客户。

当创建该连接套接字后,一侧要向另一侧发送数据时,它只需经过连接套接字将数据送入 TCP 连接;而 UDP 连接每次在发送数据时,都需要附加上目的地地址。

TCP 套接字的通信过程
  • TCP 客户端套接字程序

首先处理客户端的套接字程序。客户端套接字为了连接到服务器套接字,同样需要标识出服务器的地址和套接字端口:

# client.py
import socket

server_name = 'localhost'
server_port = 12000

TCP 套接字和 UDP 套接字程序的区别首先是套接字的创建:

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

socket.SOCK_STREAM 指定了该套接字是一个 TCP 套接字。

TCP 套接字在向服务器发送数据之前,必须在客户与服务器之间创建一个 TCP 连接,因此 TCP 客户端套接字的程序存在以下代码:

client.connect((server_name, server_port))

.connect(address) 方法执行后,会发生三次握手,并在客户端和服务器之间创建一条 TCP 连接。

TCP 套接字的通信过程也和 UDP 有一定区别:

client.send(message)
received_message = client.recv(1024)

由于连接已经建立了,因此客户端套接字程序在发送数据时,无需再附加上目的地址和端口号,在接收数据时,也无需接收发送方的地址和端口号,只需要简单调用 .send().recv() 方法就可以发送和接收数据。

UDP 连接也可以调用 .connect() 方法,但这种情况下只是简单地标明目标服务器的地址,之后调用 .send().recv() 方法时附加目的地址和端口号会自动完成,但没有经历连接并传输其它的信息这一过程,并且实际还是会在数据中添加目的地址和端口号。

最后,客户端在使用完成后,同样需要主动关闭套接字:

client.close()
  • TCP 服务端套接字程序

服务端的 TCP 套接字也需要先创建一个套接字对象,然后将其与服务器的端口号关联起来:

# server.py
import socket

server_port = 12000

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('', server_port))

对于 TCP 连接,还需要调用 .listen(num) 让套接字监听该端口号,获取用户的连接请求。该方法的参数决定了同时请求连接的最大数量:

server.listen(1)

TCP 套接字的循环部分为:

while True:
    connection, address = server.accept()
    received_message = connection.recv(1024)
    message = received_message.decode()
    connection.send(f'{message} ({len(message)})'.encode())
    connection.close()

当客户开始连接后,程序通过调用 .accept() 方法来接收连接请求,每次调用该方法都会等待一个新的客户连接该服务器,然后返回一个新的套接字,用于管理该连接的数据收发。借助于创建的 TCP ,客户可以直接与服务器收发信息,并且发送的所有内容可以按正确的顺序到达。

由于 TCP 通信下,每个连接都使用唯一的套接字,因此连接结束后,服务端对应的套接字也需要关闭。

同样可以在一台主机中运行这两个套接字程序,所得的现象与 UDP 套接字实验相同,这里不再展示。在下一章运输层中,还会结合运输层服务的内容对这几个程序中的细节作进一步分析。

电子邮件与SMTP协议

应用层协议

应用层协议(application-layer protocol)定义了在不同端系统上的应用程序如何相互传递报文,如:

  • 交换的报文类型
  • 报文类型的语法
  • 字段信息的含义
  • 进程发送报文的时间及方式、响应规则

例如,当在浏览器中输入一个网址时,浏览器从网站服务器中从发送请求到完整显示网页的过程中,就会利用应用层的超文本传输协议(HyperText Transfer Protocol, HTTP)。HTTP 对请求和响应的报文都做了非常严格的规定,以下是 HTTP 响应部分的内容格式:

HTTP 响应包体

其中头部字段包含许多数据,例如响应包体数据的类型(是文本、图像还是视频等)、响应包含的用户信息等,浏览器会综合这类信息决定如何将响应包体的内容展示到标签页中。

HTTP 是一个非常复杂的应用层协议,涉及的内容需要一本完整的书才能完全介绍。接下来介绍电子邮件所涉及的一个简单的应用层协议 SMTP ,并通过编写代码实现该协议来发生一封简单的邮件。

电子邮件的收发过程

互联网电子邮件主要由两个部分组成:用户代理(user agent)和邮件服务器(mail server),它们之间传输数据(发送邮件)采用的应用层协议主要是简单邮件传输协议(Simple Mail Transfer Protocol, SMTP)。

电子邮件服务的核心是邮件服务器,每个接收方在某个邮件服务器中都有一个邮箱(mailbox),发送邮件时,邮件从发生方的用户代理传输到发送方的邮件服务器,由接收方的邮件服务器分发到接收方的邮箱中。如果发生方的服务器不能将邮件传给接收方的服务器,那么它会在一个报文队列(message queue)中保留该邮件,并每隔一定时间尝试重新发生,直到认为发送失败了为止。邮件发送过程中一般没有中间服务器。

SMTP 是因特网电子邮件中主要的应用层协议。它使用 TCP 传输服务,从发送方的邮件服务器向接收方的邮件服务器发送邮件,因此它是一个推协议(push protocol),TCP 连接由要发送该文件的机器发起。

SMTP 要求每个报文的所有内容都采用 7bit ASCII 码格式,如果报文包含非 7bit ASCII 字符或二进制数据,则报文必须被编码。

接收方要访问邮件则需要使用拉协议(pull protocol)来获取报文,目前常见的邮件访问协议包括第三版邮局协议(Post Office Protocol-Version 3, POP3)、互联网邮件访问协议(Internet Mail Access Protocol, IMAP)以及 HTTP 。

邮件发送的过程

标准规定,SMTP 的套接字程序运行在 25 号端口上,并且 25 号端口也只能用于 SMTP 进程。

POP3 是一个非常简单的邮件访问协议,它使用 TCP 连接,运行在 110 号端口上。POP3 的处理分为 3 步:

  1. 验证(authorizarion):用户代理发送用户名和口令,鉴别用户身份
  2. 事务处理:可以取回报文、标记删除或撤销标记,以及邮件的统计信息
  3. 更新:结束该 POP3 会话,并执行删除邮件

POP3 功能简陋,而 IMAP 则具有更多的功能。IMAP 服务器将每一个邮件和一个文件夹联系,用户可以将一个邮件移动到另外的文件夹内,还可以按指定条件查询匹配的邮件,或获取邮件报文的某些部分等。IMAP 同样使用 TCP 连接,使用端口号 143 。

以上介绍的几个协议,运行这些协议的应用程序都具有确定的端口号。这样在编写套接字程序时,就可以按照规定直接将数据发送到正确的应用程序中并被解析。

端口号主要可以被划分为以下三类:

  • 知名端口:端口号范围 0~1023 ,被分配给最重要、最常见的服务,一般情况下不能运行别的服务
  • 注册端口:端口号范围 1024~49151 ,被许多应用较广泛的网络服务占用,但如果它们不在使用时,用户也可以运行自己的服务
  • 其它端口:端口号范围 49152~65535 ,用户可以随意使用

所有用于互联网标准协议的端口号能够在 http://www.iana.org 处查询到。

在客户端发送数据时,由于会将发送的 IP 地址和端口号附在发送的报文内提供给接收方,因此可以随意使用端口号。操作系统在用户没有确定端口号时,也会从其它端口中任意取出一个可用的端口号提供给客户端的应用程序。

Python 中,可以通过如下形式查询某个服务的端口号:

>>> import socket
>>> socket.getservbyname('smtp')
25

在 Linux 系统上,比较常用的服务名与对应的端口号保存在文件 /etc/services 里。

实验:SMTP发送

一个电子邮件主要由首部和报文体组成,两者之间以一个空行(CRLF)隔开。首部中的每一行由关键字、冒号及其值组成,From: 首部行和 To: 首部行是必须的,它们标识出发送方和接收方的邮箱地址;而 Subject: 等首部行则是可选的。

准备几个实验用的关键变量如下:

# mail-client.py
import socket

mail_server = 'smtp.qiye.aliyun.com'
mail_from = 'web@frozencandles.fun'
mail_to = 'alterdellusion@gmail.com'

mail = f'From: {mail_from}\r\n'\
       f'To: {mail_to}\r\n'\
        'Subject: Hello World\r\n'\
        '\r\n'\
        'Hello, world!\r\n'

SMTP 使用 TCP 连接,端口号为 25 ,因此首先生成客户端套接字并连接:

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((mail_server, 25))

当连接到邮件服务器后,邮件服务器会主动发送就绪应答码 220 给用户,应答码后面可能会跟随一些描述信息。这里使用如下代码接收并检查邮件服务器返回的信息:

received_message = client.recv(1024).decode()
print(received_message, end='')
if received_message[:3] != '220':
    raise RuntimeError('220 reply not received from server')

接下来,用户可以向服务器发送 HELO identity 指令,identity 表示用户身份。若服务器认为身份有效,则返回应答码 250 。公共的邮件服务器在发送邮件之前,用户需要通过 auth login 指令登录邮箱,服务器接收后返回应答码 334 并提示用户输入用户名。以上行为翻译为代码就是:

def send_message(msg, status):
    while True:
        client.send(msg)
        received_message = client.recv(1024).decode()
        print(received_message, end='')
        if received_message[:3] == str(status):
            break
        else:
            raise RuntimeError('Bad response from server')

send_message('HELO mailserver\r\n'.encode(), 250)
send_message('auth login\r\n'.encode(), 334)

注意每条指令都需要使用一个 CRLF 回车换行表示结束。

部分邮件服务器为了能够接收非 ASCII 字符,使用 base64 编码后传输。本例中将待发送的用户名使用 base64 编码后发送,若服务器认为用户名存在,便会再次返回 334 应答码并提示用户继续发送口令。使用 base64 编码发送口令后,若验证通过,服务器则会返回 235 应答码。以上行为翻译为代码就是:

from base64 import b64encode

send_message(b64encode(mail_from.encode()) + b'\r\n', 334)
send_message(b64encode('password123'.encode()) + b'\r\n', 235)

为了发送一封邮件,用户需要用 MAIL FROM <from_address> 指定邮件的发件邮箱,用 RCPT TO <to_address> 指定收件邮箱。若服务器认为邮箱有效,则返回应答码 250 ,用户便可以使用指令 DATA 告知服务器准备发送邮件内容,邮件服务器使用应答码 354 表明准备接收这部分内容。

当用户发送完成所有内容后,需要发送结束符 \r\n.\r\n ,若邮件服务器接收完毕,则返回应答码 250 。发送结束后,用户发送指令 QUIT 请求断开连接,邮件服务器则返回应答码 221 并主动断开连接。

以上行为翻译为代码就是:

send_message(f'MAIL FROM: <{mail_from}>\r\n'.encode(), 250)
send_message(f'RCPT TO: <{mail_to}>\r\n'.encode(), 250)
send_message('DATA\r\n'.encode(), 354)
client.send(mail.encode())
send_message('\r\n.\r\n'.encode(), 250)
send_message('QUIT\r\n'.encode(), 221)

运行以上程序,即可看到实验现象:

$ python -u client.py 220 smtp.aliyun-inc.com MX AliMail Server 250 Ok 334 dXNlcm5hbWU6 334 UGFzc3dvcmQ6 235 Authentication successful 250 Mail Ok 250 Rcpt Ok 354 End data with <CR><LF>.<CR><LF> 250 Data Ok: queued as freedom 221 Bye

dXNlcm5hbWU6 使用 base64 解码即为 Username: ,而 UGFzc3dvcmQ6 使用 base64 解码即为 Password: 。如果邮件没有被服务器判定为垃圾邮件,那么即可在目标邮箱内接收到该邮件。

在本节中,简单介绍了应用层体系结构。应用层本身主要通过应用程序来实现特定的互联网功能。应用层本身不参与数据的传输,因此需要操作系统提供的运输服务。应用层协议是计算机网络建立的最终目的,也是近几年计算机网络发展的最快的部分,各种各样的应用层协议层出不穷,让互联网在多媒体浏览、文件传输、远程主机登录,乃至工业物联网通信间的各种场景中可以自由发挥。

在下一节中,将介绍 DNS 应用层协议,它是一种用于实现网络设备名字到主机地址映射的网络服务。

京ICP备2021034974号
contact me by hello@frozencandles.fun