一、客户端 / 服务器架构
什么是客户端 / 服务器架构?服务器就是一系列硬件或软件,为一个或多个客户端或用户提供所需的服务。它存在唯一目的就是等待客户端的请求,并响应它们提供服务,然后等待更多请求。另一方面,客户端因特定的请求而联系服务器,并发送必要的数据,然后等待服务器的回应,最后完成请求或给出故障的原因。服务器无限地运行下去,并不断地处理请求;而客户端会对服务发出请求,然后接收服务器返回的数据,最后结束它们之间的事务。客户端在一段时间后可能会再次发出请求,但这些都被当作不同的事务。
现在一个常见的软件服务器就是 Web 服务器。如果个人或公司想要运行自己的 Web 服务器,那么必须拥有一台或多台计算机,在上面安装希望提供给用户的 Web 页面和 Web 应用程序,然后启动 Web 服务器。一个这样的服务器的工作就是接受客户端请求,并向客户端(即用户计算机上的浏览器)回送数据,浏览器收到数据后渲染出 Web 页面,然后服务器继续处理下一个客户端的请求或等待请求。这些服务器一旦开启,都将可能永远运行。虽然它们并不能实现这一目标,但是它们会尽可能长时间地运行,除非受到一些外力驱使才会停止,如显式地关闭,或灾难性地关闭(由于硬件故障)。
在服务器响应客户端请求之前,必须进行一些初步的设置流程来为之后的工作做准备。首先会创建一个通信端点,它能够使服务器监听请求。可以把服务器比作公司前台,或者应答公司主线呼叫的总机接线员。一旦电话号码和设备安装成功且接线员到达时,服务就可以开始了。这个过程与网络世界一样,一旦一个通信端点已经建立,监听服务器就可以进入无限循环中,等待客户端的连接并响应它们的请求。当然,为了使公司电话接待员一直处于忙碌状态,我们绝不能忘记将电话号码放在公司信笺、广告或一些新闻稿上,否则将没有人会打电话过来!相似地,必须让潜在的客户知道存在这样的服务器来处理他们的需求;否则,服务器将永远不会得到任何请求。
客户端相对简单,所需要做的只是创建它的单一通信端点,并建立一个到服务器的连接。然后客户端就可以发出请求,该请求包括任何必要的数据交换。一旦请求被服务器处理,且客户端收到结果或某种确认信息,此次通信就会被终止。
二、套接字:通信端点
套接字是计算机网络数据结构,它体现了前文所述的 “通信端点” 的概念。在任何类型的通信开始之前,网络应用程序必须创建套接字。可以将它们比作电话插孔,没有它将无法进行通信。
套接字的起源可以追溯到 20 世纪 70 年代,它是加利福尼亚大学的伯克利版本 UNIX 的一部分。因此,有时你可能会听过将套接字称为伯克利套接字或 BSD 套接字。套接字最初是为同一主机上的应用程序所创建,使得主机上运行的一个程序(也可以说一个进程)与另一个运行的程序进行通信。这就是所谓的进程间通信。有两种类型的套接字:基于文件的和面向网络的。
套接字家族 | 说明 |
---|---|
AF_UNIX | 基于文件的套接字家族 |
AF_INET | 面向网络的套接字家族 |
如果套接字是像电话插孔一样允许通信的基础设施,那么主机名和端口号就像区号和电话号码的组合。然而,拥有硬件和通信的能力本身并没有任何用处,除非你知道电话打给谁以及如何拨打电话。一个网络地址由主机名和端口号对组成,而这是网络通信所需要的。此外,并未事先说明必须有其他人在另一端接听,否则你将听到这个熟悉的声音 “对不起,您所拨打的电话是空号,请核对后再拨”。有效的端口号范围为 0~65535 ,小于 1024 的端口号预留给了系统。如果你正在使用 POSIX 兼容系统,如 Linux、Mac OS X 等,那么可以在 /etc/services 文件中找到预留端口号的列表以及服务器 / 协议和套接字类型。众所周知的端口号列表可以在这个网站中查看。
不管你采用的是哪种地址家族,都有两种不同类型的套接字。第一种是面向连接的,第二种是无连接的。
套接字类型 | 说明 |
---|---|
SOCK_STREAM | 创建 TCP 套接字时使用,面向连接,数据流式 |
SOCK_DGRAM | 创建 UDP 套接字时使用,无连接,数据报式 |
面向连接的套接字意味着在进行通信之前必须先建立一个连接,例如,使用电话系统给一个朋友打电话。这种类型的通信也称为虚拟电路或流套接字。面向连接的通信提供序列化的、可靠的和不重复的数据交付,而没有记录边界。这基本上意味着每条消息可以拆分成多个片段,并且每一条消息片段都确保能够到达目的地,然后将它们按顺序组合在一起,最后将完整消息传递给正在等待的应用程序。实现这种连接类型的主要协议是传输控制协议 TCP。为了创建 TCP 套接字,必须使用 SOCK_STREAM 作为套接字类型。
与虚拟电路形成鲜明对比的是数据报类型的套接字,它是一种无连接的套接字。这意味着,在通信开始之前并不需要建立连接。此时,在数据传输过程中并无法保证它的顺序性、可靠性或重复性。然而,数据报确实保存了记录边界,这就意味着消息是以整体发送的,而非首先分成多个片段。使用数据报的消息传输可以比作邮政服务。信件和包裹或许并不能以发送的顺序到达。事实上,它们可能不会到达。为了将其添加到并发通信中,在网络中甚至有可能存在重复的消息。
既然有这么多副作用,为什么还使用数据报呢?由于面向连接的套接字提供了各种保证,因此它们的设置以及对虚拟电路连接的维护需要大量的开销。然而数据报不需要这些开销,即它的成本更加低廉。因此它们通常能提供更好的性能,可能更适合一些类型的应用程序。实现这种连接类型的主要协议是用户数据报协议 UDP。为了创建 UDP 套接字,必须使用 SOCK_DGRAM 作为套接字类型。
三、socket 模块创建套接字对象
要创建套接字,必须使用 socket.socket
函数,它的语法如下:
import socket
socket.socket(socket_family, socket_type, protocol=0)
参数说明:
- socket_family 套接字家族
- socket_type 套接字类型
- protocol 通常省略,默认为 0
为了创建 TCP/IP 套接字,可以用下面的方式:
tcpSock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
为了创建 UDP/IP 套接字,可以用下面的方式:
udpSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
套接字对象的内置方法
名称 | 描述 |
---|---|
服务器方法 | |
bind | 将地址(主机名、端口号)绑定到套接字上 |
listen | 设置并启动 TCP 监听器 |
accept | 被动接受 TCP 客户端连接,一直等待,直到连接到达(阻塞) |
客户端方法 | |
connect | 主动发起 TCP 服务器连接,遇到问题会报错 |
connect_ex | connect 的扩展版本,会以错误码的形式返回问题,不会报错 |
普通方法 | |
recv | 接收 TCP 消息 |
recv_into | 接收 TCP 消息到指定的缓冲区 |
send | 发送 TCP 消息 |
sendall | 完整地发送 TCP 消息 |
recvfrom | 接收 UDP 消息 |
recvfrom_into | 接收 UDP 消息到指定的缓冲区 |
sendto | 发送 UDP 消息 |
getpeername | 连接到套接字 TCP 的远程地址 |
getsockname | 当前套接字的地址 |
getsockopt | 返回给定套接字选项的值 |
setsockopt | 设置给定套接字选项的值 |
shutdown | 关闭连接 |
close | 关闭套接字 |
detach | 在未关闭文件描述符的情况下关闭套接字,返回文件描述符 |
面向阻塞 | |
setblocking | 设置套接字的阻塞或非阻塞模式 |
settimeout | 设置阻塞套接字操作的超时时间 |
gettimeout | 获取阻塞套接字操作的超时时间 |
面向文件 | |
fileno | 套接字的文件描述符 |
makefile | 创建与套接字关联的文件对象 |
数据属性 | |
family | 套接字家族 |
type | 套接字类型 |
proto | 套接字协议 |
四、创建 TCP 服务器
创建通用 TCP 服务器的一般伪代码:
ss = socket() # 创建服务器套接字
ss.bind() # 套接字与地址绑定
ss.listen() # 监听连接
inf_loop: # 服务器无限循环
cs = ss.accept() # 接受客户端连接
comm_loop: # 通信循环
cs.recv()/cs.send() # 对话(接收/发送)
cs.close() # 关闭客户端套接字
ss.close() # 关闭服务器套接字(可选)
所有套接字都是通过使用
socket.socket
函数来创建的。因为服务器需要占用一个端口并等待客户端的请求,所以它们必须绑定到一个本地地址。因为 TCP 是一种面向连接的通信系统,所以在 TCP 服务器开始操作之前,必须安装一些基础设施。特别地,TCP 服务器必须监听传入的连接。一旦这个安装过程完成后,服务器就可以开始它的无限循环。
调用accept
函数之后,就开启了一个简单的(单线程)服务器,它会等待客户端的连接。默认情况下,accept
是阻塞的,这意味着后续代码的执行将被暂停,直到一个连接到达。另外,套接字也支持非阻塞模式,可以参考文档或操作系统教材了解如何使用非阻塞套接字的更多细节。
一旦服务器接受了一个连接,就会利用accept
返回一个独立的客户端套接字,用来与即将到来的消息进行交换。使用新的客户端套接字类似于将客户的电话切换给另一个电话接线员或客服代表。 当一个客户电话最后接进来时,总机接线员会接到这个电话,并使用另一条线路将这个电话转接给合适的客服代表来处理客户的需求。这将能够空出主线(原始服务器套接字),以便接线员可以继续等待新的电话(客户请求),而此时客户及其连接的客服代表能够进行他们自己的谈话。同样地,当另一个请求到达时,服务器会创建一个新的通信端口来直接与客户端进行通信,再次空出主端口,使其能够接受新的客户端连接。
一旦创建了临时套接字(客服代表),通信就可以开始,通过使用这个新的套接字,客户端与服务器就可以开始发送和接收对话,直到连接终止。当一方关闭连接或者向对方发送一个空字符串时,通常就会关闭连接。
在代码中,一个客户端连接关闭之后,服务器就会等待另一个客户端连接。最后一行代码是可选的,在这里关闭了服务器套接字。其实,这种情况永远也不会碰到,因为服务器应该在一个无限循环中运行。在示例中这行代码用来提醒读者,当为服务器实现一个智能的退出方案时,建议调用close
方法。例如,当一个处理程序检测到某些外部条件时,服务器就应该调用close
方法关闭。
以下代码是一个 TCP 服务器程序,它接受客户端发送的数据字符串,并将其打上时间标签,并返回给客户端。将代码写入 tcp_server.py 文件:
import time
from socket import socket, AF_INET, SOCK_STREAM
# HOST 变量是空白的,这是对 bind 方法的标识,表示它可以使用任何可用的地址
HOST = ''
# 选择一个随机的未被占用的端口号
PORT = 21567
# 将缓冲区大小设置为 1KB
BUFSIZ = 1024
# 主机端口元组
ADDR = (HOST, PORT)
# 定义 TCP 服务器套接字
tcp_server_sock = socket(AF_INET, SOCK_STREAM)
# 将地址绑定到套接字上
tcp_server_sock.bind(ADDR)
# 开启服务器监听,在连接被转接或拒绝之前,传入连接请求的最大数是 5
tcp_server_sock.listen(5)
# 进入监听状态后,等待客户端连接
while True:
print('Waiting for connection...')
# 下一行为阻塞运行状态,等待客户端的连接
# 服务器套接字相当于总机接线员,接到客户电话后转给分机客服
# 当成功接入一个客户端,accept 方法会返回一个临时服务端套接字和对方地址
tcp_extension_sock, addr = tcp_server_sock.accept()
# 如果此时另一个客户端向服务器发送连接请求,也是可以的
# 请求数由 listen 方法的参数决定
# 连接成功后保持等待,前一个已连接的客户端断开连接后,才会处理下一个
print('...connected from: {}'.format(addr))
while True:
# 临时服务端套接字的 recv 方法获得客户端传来的数据
# 此方法也是阻塞运行,每次接收固定量的数据,直到全部接收完毕
data = tcp_extension_sock.recv(BUFSIZ)
if not data:
break
# data 为二进制对象,将其转换为 UTF-8 格式
print('收到数据:{}'.format(data.decode()))
# 临时服务端套接字的 send 方法向客户端发送数据
# 先将 data 转换为 UTF-8 格式,再整体转换为二进制对象
tcp_extension_sock.send('{} {}'.format(
time.ctime(), data.decode()).encode())
# while 循环结束后,关闭临时服务器套接字
tcp_extension_sock.close()
tcp_server_sock.close()
对应的客户端代码如下,将其写入 tcp_client.py 文件中:
1 from socket import socket, AF_INET, SOCK_STREAM
2
3 # 定义客户端套接字
4 tcp_client_sock = socket(AF_INET, SOCK_STREAM)
5 # 向服务器发送连接请求,注意 IP 地址为服务器的 IP 地址
6 tcp_client_sock.connect(('localhost', 21567))
7
8 # 连接成功后,进入发送/接收数据循环
9 while True:
10 data = input('输入内容:')
11 if not data:
12 break
13 # 发送二进制数据
14 tcp_client_sock.send(data.encode())
15 # 接收二进制数据
16 data = tcp_client_sock.recv(1024)
17 if not data:
18 break
19 print(data.decode())
20 # 关闭客户端套接字
21 tcp_client_sock.close()
在终端执行 python3 tcp_server.py
启动服务器,然后在本机其它终端或局域网内其它电脑终端执行 python3 tcp_client.py
启动客户端并访问服务器即可。
多线程实现异步服务器
上文创建的服务器套接字为单线程运行,虽然可以接收多个客户端的连接,但处理下一个连接必须等待上一个连接断开。将上文代码的内层 while 循环收发消息的代码写入一个函数,采用多线程方式运行,可以实现异步处理客户端连接,达到同时处理多个连接的效果。
修改 tcp_server.py 代码如下:
import time
import threading
from socket import socket, AF_INET, SOCK_STREAM
ADDR = ('', 1234)
BUFSIZ = 1024
tcp_server_sock = socket(AF_INET, SOCK_STREAM)
tcp_server_sock.bind(ADDR)
tcp_server_sock.listen()
def handle(sock, addr):
while True:
# 套接字的 recv 方法阻塞等待,直到客户端发送消息过来
# 阻塞等待期间释放 CPU ,CPU 可以执行其它线程中的任务
data = sock.recv(BUFSIZ).decode()
if not data:
sock.close()
break
print('收到信息:{}'.format(data))
sock.send(
'[{}] {}'.format(time.ctime(), data).encode())
# 关闭临时服务器套接字
print('{} 已关闭'.format(addr))
sock.close()
def main():
print('等待客户端请求...')
# 进入无限循环,每出现一个客户端请求,就会循环一次,创建一个子线程
# 这样可以创建多个线程来并发处理多个客户端请求
while True:
# 这个 try 语句是为了捕获终端 Ctrl + C 结束程序时触发的 Keyboard 异常
# 捕获异常后,while 循环可能并不会立刻结束
# 它会阻塞等待,直到所有子线程结束后结束
try:
tcp_extension_sock, addr = tcp_server_sock.accept()
print('建立连接:', addr)
except KeyboardInterrupt:
break
# 子线程运行前面定义的 handle 任务
t = threading.Thread(
target=handle, args=(tcp_extension_sock, addr))
t.start()
# while 循环结束,关闭服务器套接字,退出程序
print('\nExit')
tcp_server_sock.close()
if __name__ == '__main__':
main()
五、创建 UDP 服务器
UDP 服务器不需要 TCP 服务器那么多的设置,因为它不是面向连接的。除了等待传入的连接之外,几乎不需要做其它工作。创建 UDP 服务器的伪代码如下:
ss = socket() # 创建服务器套接字
ss.bind() # 套接字与地址绑定接字
inf_loop: # 服务器无限循环
cs = ss.recvfrom()/ss.sendto() # 对话(接收/发送)
ss.close() # 关闭服务器套接字(可选)
从以上伪代码中可以看到,除了普通的创建套接字并将其绑定到本地地址外,并没有额外的工作。无限循环包含接收客户端消息、打上时间戳并返回消息,然后回到等待另一条消息的状态。再一次,close 方法的调用是可选的,并且由于无限循环的缘故,它并不会被调用,但它提醒我们,它应该是我们已经提及的优雅或智能退出方案的一部分。UDP 和 TCP 服务器之间的另一个显著差异是,因为数据报套接字是无连接的,所以就没有为了成功通信而使一个客户端连接到一个独立的临时套接字的 “转换” 操作。服务器仅仅接受消息并有可能回复数据。
UDP 服务器代码如下,写入 udp_server.py 文件:
1 import time
2 from socket import socket, AF_INET, SOCK_DGRAM
3
4 ADDR = ('', 12345)
5 BUFSIZ = 1024
6 udp_server_sock = socket(AF_INET, SOCK_DGRAM)
7 udp_server_sock.bind(ADDR)
8
9 # 这个循环每次只收发一次消息,无需客户端连接
10 while True:
11 print('Waiting for message ...')
12 # 这个 try 语句是为了捕获终端 Ctrl + C 结束程序时触发的异常
13 try:
14 # recvfrom 方法阻塞等待,直到某个客户端发来消息
15 # 返回值为元组,包括二进制消息和客户端地址
16 data, addr = udp_server_sock.recvfrom(BUFSIZ)
17 except KeyboardInterrupt:
18 break
19 # sendto 方法向客户端发送消息,注意消息须为二进制数据
20 udp_server_sock.sendto(
21 '{} {}'.format(time.ctime(), data.decode()).encode(), addr)
22 print('... received from {} and returned'.format(addr))
23
24 # while 循环结束,关闭服务器套接字,退出程序
25 print('\nExit')
26 udp_server_sock.close()
客户端代码如下,写入 udp_client.py 文件:
1 from socket import socket, AF_INET, SOCK_DGRAM
2
3 ADDR = ('192.168.0.102', 12345)
4 BUFSIZ = 1024
5 udp_client_sock = socket(AF_INET, SOCK_DGRAM)
6
7 while True:
8 data = input('输入内容:')
9 if not data:
10 break
11 # 向服务器发送二进制数据
12 udp_client_sock.sendto(data.encode(), ADDR)
13 # 接收服务器返回的数据
14 data, ADDR = udp_client_sock.recvfrom(BUFSIZ)
15 if not data:
16 break
17 print(data.decode())
18
19 udp_client_sock.close()
在终端执行 python3 udp_server.py
启动服务器,然后在本机其它终端或局域网内其它电脑终端执行 python3 udp_server.py
即可收发消息。