3.1 引言
tiny httpd执行流程的第一步是调用startup()函数创建了一个socket。那么什么是socket呢?
3.2 初识socket
socket应该是最难解释的编程概念之一了。即便你能熟练地使用socket API编程,也很难简短地阐述出socket的核心概念。如果非要用一句话去概括socket,那么我的回答是:socket是操作系统对TCP/IP协议簇进行封装后,向上层提供的API接口。但使用专业名词去解释专业名词,并不能很好地帮助我们理解socket的概念。所以,我们不妨从socket这个词汇本身出发去寻找答案。在计算机专业领域,socket的翻译是套接字,但我个人并不太喜欢这个翻译。如果从socket的本意 —— 插座、插孔出发,可能反而更有助于我们理解它的概念。
3.3 简述TCP
前面已经提到了socket是对TCP/IP协议簇的封装与抽象,但是如果继续向下深究TCP/IP协议簇并不明智。因为TCP/IP协议簇是一个庞杂的体系,难以用三言两语阐释清楚。但好在我们研究的重点是HTTP协议,而HTTP协议是基于TCP协议的,所以我们只需要把注意力集中在TCP协议上就足够了。TCP协议的全称是Transmission Control Protocol(传输控制协议),它的内容也不少,但目前我们只需要关注其中一小部分就可以了。
首先,我们来简单地回顾一下TCP协议中的客户端与服务端的通信过程:客户端向服务端发送连接请求 → 经过三次握手,建立连接 → 发送数据 → 经过四次挥手,关闭连接。在这个过程中,服务端就像一个插座,客户端就像一个插头。服务端被动地等待客户端主动发起连接请求(插座等待插头插入),建立连接后(插头插入插座),再进行数据传输(充电),传输结束后,再断开连接(结束充电,拔出插头)。 实际上,TCP建立连接和进行数据传输的过程也是很复杂的。但操作系统将那些复杂的操作都隐藏到了底层,然后向上层暴露了socket接口。有了socket,我们可以像使用插座和插头一样,简便地使用TCP协议进行通信。
3.4 编写Echo Server
Taking is cheap !
现在,就让我们用Python来编写一个简单的Echo Server吧!之所以不用C语言,主要有以下三点原因:
- Linux C的socket编程比较复杂,而且需要引入另一个概念 —— 文件描述符(file descriptor)。目前我们需要把重点放在socket上,所以代码越简单越好。
- Linux C的socket API不能跨平台,Python的socket API可以跨平台。所以即便你暂时没有Linux环境,也不影响你学习socket。
- Python的socket API风格与Linux C的较为相近,能够比较好地衔接后面的内容。
echo_server.py
# 回响服务器
import socket
HOST = '127.0.0.1'
PORT = 5000
# 创建一个 socket 对象
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_sock:
server_sock.bind((HOST, PORT)) # 将 socket 绑定到指定地址上
server_sock.listen(1) # 启动服务器
while True:
conn, addr = server_sock.accept() # 接受连接
with conn:
print('accept a connection from {}'.format(addr)) # 打印客户端地址
while True:
data = conn.recv(1024) # 接收客户端数据
if not data:
break
print('the data from client: {}'.format(data)) # 打印客户端发送的数据
conn.sendall(data) # 向客户端回传数据
print('the connection from {} has been cloesed'.format(addr))
echo_client.py
# 客户端
import socket
HOST = '127.0.0.1'
PORT = 5000
# 创建一个 socket 对象
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_sock:
client_sock.connect((HOST, PORT)) # 向指定地址的服务端请求连接
client_sock.send(b'Hello!') # 向服务端发送数据
data = client_sock.recv(1024) # 接收服务端回传的数据
print('the data from server: {}'.format(data)) # 打印服务端回传的数据
先使用python echo_server.py命令运行服务端,然后使用python echo_client.py运行客户端。
服务端的日志如下:
accept a connection from ('127.0.0.1', 60789)
the data from client: b'Hello!'
the connection from ('127.0.0.1', 60789) has been cloesed
客户端的日志如下:
the data from server: b'Hello!'
可以看到服务端收到了来自客户端的连接,并将客户端发送给自己的数据回传给了客户端。
需要注意的两点是:
- 服务端需要绑定端口号,但是客户端的端口号是由操作系统自动分配的。所以,如果你的端口号与上面的不一致,是正常现象。
b'Hello!'代表了这是一个字节类型,而不是字符串。我们发送和接收的数据都是字节类型,因为TCP是面向字节流传输的。
3.5 分析 Echo Server 代码
大部分支持面向对象的语言都将socket封装成了一个类(Java甚至封装了ServerSocket和Socket两个类来分别表示服务端与客户端),Python也不例外。使用Python编写一个 Echo Server 的过程如下:
①创建一个socket对象
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_sock:
这段代码等价于
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
... # 省略中间代码
server_sock.close() # 关闭 socket
使用第一种写法创建socket,会在执行完with代码块内的语句后自动关闭socket,是官方推荐写法。
创建socket时,我们传入了两个参数 —— family 和 type,它们分别代表使用的地址族和socket类型。socket.AF_INET代表使用IPv4地址,socket.SOCK_STREAM表示面向字节流传输的socket,即使用TCP协议(关于TCP面向字节流传输的这一特点,我们将在讲解HTTP协议的部分深入探讨)。
②为服务端绑定一个地址
server_sock.bind((HOST, PORT))
在TCP通信的过程中,连接请求是由客户端发起的,所以服务端的地址必须是固定的,否则客户端就不知道该向何处发起请求。通过调用bind()方法可以将socket绑定到指定地址上。bind()的参数是一个由主机名(HOST)和端口号(PORT)组成的元组。根据主机名和端口号,客户端可以确定服务端在网络中的位置。
③开启监听
server_sock.listen(1)
调用listen()后,socket会开始监听所绑定的端口号是否有客户端发起连接请求。listen有一个名为 backlog,它代表了服务端允许的已建立连接但未被接受的最大连接数。“backlog”一词意为“积压的工作”,已建立但尚未被服务端接受的连接非常符合该词的词意。
④接受连接
conn, addr = server_sock.accept()
当服务端有已建立的连接时,accept()会接受一个已建立的连接,并返回当前连接对象(本质是一个socket)和发起该连接的客户端的地址。需要注意的一点是accept()方法是阻塞式的。也就是说如果当前服务端没有已建立的连接,那么accept()不会返回,也不会继续向下执行,而是停在那里,直到出现有可以接受的连接后,才会继续执行。
print('waiting for connections...')
conn, addr = server_sock.accept() # 接受连接
print('continue...')
你可以试着在accept()方法的前后添加如上两条输出语句。在没有客户端连接服务端时,服务端代码会在输出waiting for connections...后暂停运行(即阻塞),直至有客户端连接到服务端,服务端才会输出continue...,并继续向下运行。
⑤接收数据
data = conn.recv(1024)
recv()方法会从当前连接读取数据,也是阻塞式的。1024 是我们指定的一次接收的最大数据量。
⑥发送数据
conn.sendall(data)
sendall()方法会持续的将我们所要发送的数据发送到客户端,直到发送完成或者出现错误为止。
⑦关闭连接
当我们处理完一个连接之后,同样需要关闭连接。但当前我们使用了 with 语句,所以当处理完连接,离开 with 语句后,连接会被自动关闭。
现在,我们把目光转移到客户端,来看看客户端是如何发起一个连接的。
①创建一个socket对象
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_sock:
客户端创建socket的方式与服务端完全相同。
②发起连接
client_sock.connect((HOST, PORT))
connect()的参数和bind()相同,是一个由主机名和端口号组成的元组,代表服务端。客户端不需要指定主机名和端口号,操作系统会自动为其分配。connect()会完成三次握手,与服务端建立连接。
③发送数据
client_sock.sendall(b'Hello!')
客户端不需要获取服务端的连接,因为连接是由客户端发起的,客户端知道数据发送的目的地。
④接收数据
data = client_sock.recv(1024)
客户端接收数据的方式和服务端相同。
⑤关闭连接
离开 with 代码块后,socket会被自动关闭。
下图是服务端与客户端的通信过程:
3.6 总结
由于本系列是tiny httpd的源码解析,所以只讲解了如何使用socket进行TCP通信(甚至连TCP协议都讲得十分粗略)。如果读者想要更加深入地了解TCP/IP协议簇和socket编程,可以阅读计算机网络的相关书籍和Python官方文档。