套接字

介绍

套接字(socket)在网络编程中是最低级的,使用套接字可以获得最大的自由度。在实际进行网络编程时,比如进行爬虫,我们会使用 requests 库,来更快的进行 HTTP 请求(体现封装的概念)。

什么是套接字?

要理解套接字是什么,我们先看它的英文单词释义。

套接字可以看成是两个网络应用程序进行通信时,各自通信连接中的端点。socket 取插座义,把服务器和客户端看作是插头和插座,是接入点和被接入点的关系。因此,socket 可以用来进行收发消息。

使用

我们先来认识 python 中套接字的支持。python 中,套接字由内置库 socket 提供。

建立套接字

使用 socket.socket(socket_family,socket_type) 函数建立套接字。

socket.socket 函数接收两个参数,分别为协议簇和套接字类型。

在本次(以及将来很久),你都只需要掌握一种套接字。其协议簇为 socket.AF_INET,指代 IPv4 协议,套接字类型为 socket.SOCK_STREAM,即基于 TCP 协议建立套接字。

以下是建立一个套接字的示例代码:

1
2
import socket
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

接下里的步骤略有点复杂,这是一张流程图。我们会分客户端和服务器端来分别讲解。

服务器端

服务器端在进入消息循环前分三步。1)绑定地址 2)开始监听 3)等待客户端连接

这三步分别通过 socket 对象的三个方法实现。三个方法使用方法如下表。(注意:下表中的 socket. 并不是指 socket 内置库,而是 socket 对象)

方法名 使用方法
socket.bind((host,port)) 接收一个二元组。host 为 IP地址,例:127.0.0.1;port 指端口。端口号用来标记每个主机的多个网络连接。取值范围为 0~65535。(有些端口通常是为特定服务保留的,例如:80-HTTP,443-HTTPS,22-SSH,所以一般使用四位或五位的端口号)
socket.listen(backlog) 接收一个整数。表示保留的还未连接的客户端数。一般设为 5(即保留 5 个还未成功连接的客户端)
socket.accept() -> (conn, addr) 阻塞等待,直到有客户端连接。返回一个二元组。第一个为客户端的套接字,第二个为客户端的地址。
1
2
3
4
5
6
7
8
9
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

host = '127.0.0.1'
port = 8080

server.bind((host,port))
server.listen(5)
cli, addr = server.accept()

客户端

客户端就比较简单,只需要进行连接。

方法名 使用方法
socket.connect((host,port)) 接收一个二元组。格式与 socket.bind 相同。连接到该地址的服务器。
1
2
3
4
import socket

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("192.168.1.4",8080))

消息循环

下一步,就是让服务器端和客户端实现通信了!

为此,我们再引入两个方法。

方法名 使用方法
socket.send(bytes) 发送二进制数据给套接字。本套接字必须已连接到远程套接字。
socket.recv(bufsize) 从套接字接收数据。返回值是一个字节对象,表示接收到的数据。bufsize 指定一次接收的最大数据量。

你可能注意到了上面粗体的两个词。事实上,你并不能发送一个字符串。因为字符串并不是通用的(有各种各样的编码),你能发送的只有二进制数据。那么,怎么编码、解码二进制数据呢?python 为我们提供了两个方法。

方法名 使用方法
str.encode(encoding) 将字符串编码为 bytes 对象。编码由 encoding 指定(请使用 utf-8 编码,因为是国际通用,而不是其他编码)
bytes.decode(encoding) 将 bytes 对象解码为字符串。编码由 encoding 指定。

来试一试吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

host = '127.0.0.1'
port = 8080


print("start running")

server.bind((host,port))
server.listen(5)
print(f"listen on {host}:{port}")

cli, addr = server.accept()

print(f"get client connected! address={addr}")

while True:
data = cli.recv(1024)
if not data:
print("client disconnected")
break
print("from client:", data.decode("utf-8"))
1
2
3
4
5
6
7
import socket

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1",8080))


client.send("hello, world!".encode("utf-8"))

保证报文完整性

分包/沾包

分包

分包现象就是一次传输的数据过多时,TCP协议会自动将本次的数据拆分成多个消息包进行发送。我们来做一个实验。传输一个比较大的文件。如果你身边没有比较好的文件,这里有一个代码来生成一个大约 5M 的文件。

1
2
3
4
with open("bigfile.txt","w") as f:
for i in range(1,100000):
s=str(i)
f.write(f"the {s+'st' if i%10==1 else s+'nd' if i%10 == 2 else s+'rd' if i%10 ==3 else s+'th'} positive integer is {s}, how amazing!\n")

提示:open(filename,"rb") 可打开一个二进制模式的只读文件。此时调用 read 方法就会返回 bytes 对象。

可以看到,无论你的 bufsize 设置的是多大,TCP 协议会自动将本次的数据拆分成多个消息包进行发送。

粘包

粘包现象就是当网络繁忙时,TCP协议会将多份小的消息包打包成一个消息包进行发送。例如,发送方发送两个字符串 hello + world,接收方却一次性接收到了 helloworld

自定义协议

为解决这个问题,我们可以自己定义一个通信协议。起名为 SJCP (Simple Jianping Communication Protocal)。

我们来写两个函数,来处理发送和接收。

发送

发送相比接收更简单,我们先来解决发送的问题。我们要解决的问题有:1)计算出消息体长度 2)将消息头的内容添加到消息体前面

定义一个函数即可。

1
2
3
4
5
6
7
def sjcp_send(sock:socket.socket, data:bytes):
length = len(data)

header_version = bytes([0x4a,0x01,0x00,0x00]) # 版本
header_length = length.to_bytes(4,'little') # 转化为四字节

sock.send(header_version+header_length+data)

来解释一下:

  • bytes 对象可以想象成字符串,支持加法、len、切片等操作。(这是多态的体现)
  • int 存在方法 int.to_bytes(length, byteorder)length 指定转化为 bytes 的长度,byteorder 指定转化后是大端序还是小端序。(我选择的是小端序,其实都可以,我们到解码的时候就会知道这个顺序怎么使用)

接收

接收就比较复杂了,我们画一个流程图梳理一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
data_buffer = bytes()
headerSize = 8

def sjcp_recv(sock):
while True:
data = sock.recv(1024*1024)
data_buffer += data
if len(data_buffer)<headerSize:
continue # 还不足以得到消息长度

bodySize = int.from_bytes(data_buffer[4:8],'little') # 读取bodySize

if len(data_buffer)<headerSize+bodySize:
continue # 消息还未读取完

result = data_buffer[headerSize:headerSize+bodySize]

data_buffer = data_buffer[headerSize+bodySize:] # 处理粘包

return result

我们可以看到,int 也有 from_bytes 方法,little 与前面的 little 相对应。

预告

在下一次,我们会把我们今天写的代码全部封装起来,并讲解类和面向对象。那时就会感受到封装带来的好处。

应用

请编写一个传输文件的发送端和接收端,并尝试在两台电脑上,真正实现文件的异地传输。要求:能够将文件名一并传输(请在SJCP的基础上修改消息体内容,或者你也可以设立你自己的协议)。