网络编程之基于 TCP 套接字(三)
创始人
2024-05-30 01:21:35

11. 基于 TCP 的套接字

11.1 基于 tcp 套接字模板

TCP 是基于链接的,必须先启动服务器,再启动客户端。以下为基于 TCP 的套接字客户端与服务端框架:

TCP 服务器

s = socket(socket.AF_INET, socket.SOCK_STREAM)	# 创建服务器套接字
s.bind(host, port)	# 绑定主机名、端口号到套接字
s.listen(backlog)	# 监听链接
inf_loop:		# 服务器无限循环(while),重复接收客户端链接conn, addr = s.accept()		# 接收客户端链接comm_loop:			# 通讯循环(while),能够循环接收发送信息conn.recv()/conn.send()		# 对话(接收与发送)conn.close()		# 关闭客户端套接字
s.close()		# 关闭服务器套接字(可选)

TCP 客户端

s = socket(socket.AF_INET, socket.SOCK_STREAM)	# 创建客户端套接字
s.connect()		# 尝试连接服务器
comm_loop:		# 通讯循环s.send()/s.recv()	# 对话(发送/接收)
s.close()	# 关闭客户端套接字

地址占用问题

  • **问题:**有时在重启服务器时可能遇到:OSError:[Errno 48] Adress already in use

  • **问题分析:**这是因为服务端仍然存在四次挥手的 time_wait 状态在占用地址,在 win 上出现比较少,Linux 比较多。

  • 解决办法:

方法一:在服务端加入一条 socket 配置,重用 ip 和端口

s=socket()
s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)	# 加上这条
s.bind()

方法二:调整 Linux 内核参数

vi /etc/sysctl.conf# 编辑文件,加入以下内容:
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_fin_timeout = 30# 然后执行 /sbin/sysctl -p 让参数生效。net.ipv4.tcp_syncookies = 1 表示开启SYN Cookies。# 当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭;net.ipv4.tcp_tw_reuse = 1 # 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;net.ipv4.tcp_tw_recycle = 1 # 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。net.ipv4.tcp_fin_timeout # 修改系統默认的 TIMEOUT 时间

11.2 基于 TCP 实现远程执行命令

需求:基于 TCP 实现远程执行命令,类似于 xshell。在客户端发送命令,服务端返回命令结果。

客户端

from socket import *ip_port = ('127.0.0.1', 8080)
buffer_size = 1024remote_client = socket(AF_INET, SOCK_STREAM)
remote_client.connect(ip_port)while True:cmd = input('>>>').strip()if not cmd:continueif cmd == 'quit':breakremote_client.send(cmd.encode('utf-8'))cmd_res = remote_client.recv(buffer_size)print('命令执行结果:', cmd_res.decode('gbk'))# Windows系统 上 res.stdout.read()读出的就是GBK编码的,在接收端需要用GBK解码
remote_client.close()

服务端

from socket import *
import subprocessip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024remote_server = socket(AF_INET, SOCK_STREAM)
remote_server.bind(ip_port)
remote_server.listen(back_log)while True:conn, addr = remote_server.accept()      # cmd:命令,addr:地址while True:try:		# 客户端非正常退出cmd = conn.recv(buffer_size)if not cmd: break    # 客户端输入 quit 正常退出后,服务端会一直接收空,死循环print('来自 %s 的命令:%s' % (addr, cmd.decode('utf-8')))res = subprocess.Popen(cmd.decode('utf-8'), shell=True,stdout=subprocess.PIPE,stdin=subprocess.PIPE,stderr=subprocess.PIPE)# 判断用户输入错误命令时err = res.stderr.read()if err:      # 当 err 管道里有内容表示是错误命令cmd_res = errelse:        # 当 err 管道里没有内容表示是正确命令cmd_res = res.stdout.read()if not cmd_res:     # 一些没有执行结果的命令,如 cd..cmd_res = '执行成功'.encode('utf-8')conn.send(cmd_res)except Exception as e:print(e)breakconn.close()remote_server.close()

客户端输入 dir 命令后,服务端返回 dir 命令结果,客户端再将结果打印。客户端输入 quit 正常退出。

subprocess 模块

两个程序之间不能直接进行通讯,需要通过管道才能实现,恰好 Python 有个 subprocess 模块可以满足条件。

Popen 函数:

res = subprocess.Popen(命令, shell=True [,shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE])		# res 为一个 Popen 对象
res.stdout.read()		# 标准输出
res.stdoin.read()		# 标准输入
res.stderr.read()		# 标准错误输出

第一个参数是要执行的命令,第二个参数是用什么执行(默认是 shell,即 Python解释器)第一个命令,后面的参数为标准输出、输入、错误信息输出到管道中。

Popen 函数默认将命令结果输出到屏幕(屏幕也算是个程序),如果要将结果输出到其他程序中,需要先将结果输出到 管道 中。

示例:

输入一个正确的命令,命令结果将会存储在标准输出的管道中,并获得一个 Popen 对象。要想获得命令结果,就需要用这个对象读取结果。

res = subprocess.Popen('dir', shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
res.stdout.read().decode('gbk')		# Windows系统 上 res.stdout.read()读出的就是GBK编码

当输入一个错误命令时,结果将会存储在标准错误输出管道中,再从标准错误管道中就可以读取结果。

res = subprocess.Popen('dirdsaafa', shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
res.stdout.read().decode('gbk')	

**Tips:**管道中的内容,只能读取一次。如果不指定输出到哪个程序,默认输出到屏幕。win 系统上读取结果要用 gbk 编码。

11.3 粘包

TCP (transport control protocol,传输控制协议)是面向连接(面向流)的协议,收发两端(server 和 client)都要有成对的 socket。发送端为了将包更有效的发到接收端,采用了优化方法(Nagle 算法)。将多次间隔较小、数据量小的数据,合并成一个大的数据块,然后进行封包。这样,接收端就难以分辨出来了,上必须提供科学的拆包机制。即面向流的通信是无消息保护边界的。

UDP (user datagram protocol,用户数据报协议)是面向消息的协议。接收端的内核缓冲区采用了 链式结构 来记录每一条信息,每个包就会有了 消息头(来源地址,端口信息等)。这样接收端,就容易区分处理了。因此 UDP 不会出现粘包现象。即面向消息的通信是由消息保护边界的。

UDP 的 recvfrom 是阻塞的,一个 recvfrom(x) 必须对唯一一个 sendto(y),接收完 x 个字节数据就算完成。若 y > x 数据就会丢失,也就意味着 udp 不会粘包,但会丢失数据,不可靠。

基于 tcp 的 socket 客户端往服务端上传文件,发送时文件内容以字节流的形式发送。接收端不知道该文件的字节流从何处开始结束。也就是说粘包问题,其主要原因还是接收端不知道消息的边界,不知道一次性提取多少字节数据造成的。

**Tips:**tcp 基于数据流,收发消息不能为空。因此在 client 和 server 都要有空消息处理机制,以防止程序卡住。而 udp 即使上传的是空,也不会当做空消息处理,udp 协议会给它装上消息头。

11.3.1 TCP 和 UDP 区别

  • **TCP:**面向连接、无消息保护边界、数据不会丢失,没有接收完的包,下次继续接收(除非收到 ack 即断开连接时,才会清空缓冲区内容),数据可靠、粘包。
  • **UDP:**面向消息,为每条消息装上消息头。有消息保护边界、数据会丢失,不可靠,不会粘包。

问题一:TCP 为什么更可靠?

因为 TCP 的三次握手双向机制,保证校验了数据,TCP 接收端只有接收到 ack 后,才会断开连接,所以可靠。而 UDP 信息发出后,不会验证是否达到对方,所有不可靠。

问题二:接收端缓存中内容什么时候才会被清空?

只有当缓存中的内容全部 copy 到 socket 程序中才会清空。

11.3.2 出现粘包的两种情况

情况一

发送端需要等缓冲区满才发送出去,造成粘包(接收端缓冲区大小大于第一次发送的数据大小(字节),并和后面发送的数据合到一起,等缓冲区满了之后才发送出去)。

服务端:

from socket import *ip_port = ('127.0.0.1', 8080)
back_log = 5test_server = socket(AF_INET, SOCK_STREAM)
test_server.bind(ip_port)
test_server.listen(back_log)conn, addr = test_server.accept()data1 = conn.recv(10)   # 第一次接收 10 个字节data2 = conn.recv(10)   # 第二次也是接收 10 个字节print('第一次接收:', data1.decode('utf-8'))print('第二次接收:', data2.decode('utf-8'))conn.close()test_server.close()

客户端:

from socket import *ip_port = ('127.0.0.1', 8080)test_client = socket(AF_INET, SOCK_STREAM)
test_client.connect(ip_port)test_client.send('hello'.encode('utf-8'))       # 第一次发送 5 个字节test_client.send('python'.encode('utf-8'))      # 第二次发送 6 个字节test_client.close()
第一次接收: hellopytho
第二次接收: n

服务端第一次接收 10 个字节,但是客户端第一次只发送了 5 个。它会把第二次发送的 5 个字节糅合在一起,剩余的 1 个字节给第二次接收。

情况二

接收端不及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只接收了一小部分,服务端下次再接收的时候还是从缓冲区拿上次遗留的数据,产生粘包)

服务端:

# 接收端缓冲区小于发送数据大小
from socket import *ip_port = (gethostname(), 8080)
back_log = 5test_server = socket(AF_INET, SOCK_STREAM)
test_server.bind(ip_port)
test_server.listen(back_log)conn, addr = test_server.accept()data1 = conn.recv(2)    # 第一次接收 2 个字节
data2 = conn.recv(6)    # 第二次接收 6 个字节print('第一次数据:', data1.decode('utf-8'))
print('第二次数据:', data2.decode('utf-8'))conn.close()
test_server.close()

客户端:

from socket import *ip_port = (gethostname(), 8080)test_client = socket(AF_INET, SOCK_STREAM)test_client.connect(ip_port)test_client.send('hello'.encode('utf-8'))
test_client.close()
第一次数据: he
第二次数据: llo

客户端发送了 5 个字节,服务端第一次接收了 2 个字节,剩余 3 个字节第二次接收。

11.3.3 粘包解决方法

解决方案

产生粘包的主要原因是不知道发送端发送的数据长度,接收端不知道一次接收多少。我们可以自定义一个 报头,先将报头发过去,再将真实的数据长度与报头长度比较,这样就不会导致粘包了。

服务端:

from socket import *
import subprocess
import structip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024server = socket(AF_INET, SOCK_STREAM)	# 创建一个基于 TCP,基于网络的套接字
server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
server.bind(ip_port)	  # 绑定ip、port
server.listen(back_log)	  # 监听while True:conn, addr = server.accept()	# 连接客户端print(addr)while True:try:cmd = conn.recv(buffer_size)	# 接收命令print('客户端输入命令', cmd.decode('utf-8'))# 定义一个管道,以 shell 方式执行。判断若是正确命令,则结果输出到标准输出管道中,否则放在错误输出管道中res = subprocess.Popen(cmd.decode('utf-8'), shell=True,stdout=subprocess.PIPE,stdin=subprocess.PIPE,stderr=subprocess.PIPE)err = res.stderr.read()		# 读取错误输出管道中内容if err:		# 若管道中有内容,表示命令错误,那么输出错误内容cmd_res = errelse:cmd_res = res.stdout.read()		# 不为空,则表示命令正确,输出命令执行结果# 对于一些没有结果输出的命令,要进行判断(如 cd ..)if not cmd_res:cmd_res = '执行成功'.encode('utf-8')data_head = struct.pack('i', len(cmd_res))	# 制作报头(命令执行结果序列化为二进制形式方便传输)conn.send(data_head)	# 发送报头conn.send(cmd_res)except Exception as e:print(e)conn.close()	# 关闭连接
server.close()		# 关闭服务器套接字

客户端:

from socket import *
import subprocess
import structip_port = ('127.0.0.1', 8080)
buffer_size = 1024client = socket(AF_INET, SOCK_STREAM)
client.connect(ip_port)while True:cmd = input('>>>').strip()	# 输入命令if not cmd: continue	# 输入为空,继续if cmd == 'quit': break		# 输入为 quit,退出client.send(cmd.encode('utf-8'))	# 发送命令给服务端data_head = client.recv(4)		# 接收报头data_head_size = struct.unpack('i', data_head)[0]	# 解压报头# 根据报头数据长度对数据接收recv_size = 0	# 接收的数据长度total_data = b''	# 总共接收的数据while recv_size < data_head_size:	# 将接收的数据长度与报头数据长度比较data_recv = client.recv(buffer_size)	# 接收真实数据recv_size += len(data_recv)		# 接收的数据长度相加total_data += data_recvprint('命令执行结果是', total_data.decode('gkb'))
client.close()

优化方法

思路是将服务端报头信息进行优化,对发送的内容用字典描述。但是字典不能进行网络传输,因此将其转换为 json 字符串,再转换为 bytes 格式进行发送。但是 bytes 格式的 json 字符串长度不固定,所以要使用 struct 模块将 bytes 格式的 json 字符串长度打包成 4 个字节。

  • 发送时:先发报头长度,再编码报头内容发送,最后发送真实数据
  • 接收时:先接受报头长度,用 struct 提取。根据取出的长度收取报头内容,然后解码,反序列化。最后从反序列化的结果中取出待取数据的详细信息,然后取真实数据内容。

大致流程:

服务端:

import pickle
import struct# 定制报文头(文件名、文件大小)
file_info = {'filename': 'a.txt','filesize': 13333
}
# 用 pickle模块,将报文头(字典)转换为字节流,以便传输
baotou = pickle.dumps(file_info)    # baotou: b'\x80\x03}q\x00(X\x08\x00\x00\x00filenameq\x01\x154u.'
length = len(baotou)   # 报文头长度   length: 53# 再利用 struct 模块,将报文头转换为固定长度的字节
baotou_length = struct.pack('i', length)    # 转换为固定 4 个字节长度   b'5\x00\x00\x00'# 发送报文头长度
conn.send(baotou_length)# 发送报文头
conn.send(baotou)# 发送真实数据
conn.send(cmd_res)

客户端:

import pickle
import struct# 接收报头长度,因为报头长度是 4 个字节
baotou_length = client.recv(4)   #  b'5\x00\x00\x00'# 解压报头长度
length = struct.unpack('i', baotou_length)[0]   # 接收报头
baotou = client.recv(length)# 解压报头
file_info = pickle.loads(baotou)# 取出数据长度
data_len = file_info['data_size']

服务端:

from socket import *
import subprocess
import struct
import jsonip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024server = socket(AF_INET, SOCK_STREAM)	# 创建一个基于 TCP,基于网络的套接字
server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
server.bind(ip_port)	  # 绑定ip、port
server.listen(back_log)	  # 监听while True:conn, addr = server.accept()	# 连接客户端print(addr)while True:try:cmd = conn.recv(buffer_size)	# 接收命令print('客户端输入命令', cmd.decode('utf-8'))# 定义一个管道,以 shell 方式执行。判断若是正确命令,则结果输出到标准输出管道中,否则放在错误输出管道中res = subprocess.Popen(cmd.decode('utf-8'), shell=True,stdout=subprocess.PIPE,stdin=subprocess.PIPE,stderr=subprocess.PIPE)err = res.stderr.read()		# 读取错误输出管道中内容if err:		# 若管道中有内容,表示命令错误,那么输出错误内容cmd_res = errelse:cmd_res = res.stdout.read()		# 不为空,则表示命令正确,输出命令执行结果# 对于一些没有结果输出的命令,要进行判断(如 cd ..)if not cmd_res:cmd_res = '执行成功'.encode('utf-8')headers = {'data_size': len(cmd_res)}	# 定制报头head_json = json.dumps(headers)		# 将字典转换为 json 字符串head_json_bytes = bytes(head_json, encoding='utf-8')	# 将 json 字符转换为字节流以便传输# 将报头字节流转换为固定 4 个字节长度,并发报头长度conn.send(struct.pack('i', len(head_json_bytes)))	conn.send(head_json_bytes)	# 发送报头conn.send(cmd_res)      # 发真实内容except Exception as e:print(e)conn.close()	# 关闭连接
server.close()		# 关闭服务器套接字

客户端:

from socket import *
import struct
import jsonip_port = ('127.0.0.1', 8080)
buffer_size = 1024client = socket(AF_INET, SOCK_STREAM)
client.connect(ip_port)while True:cmd = input('>>>').strip()  # 输入命令if not cmd: continue  # 输入为空,继续if cmd == 'quit': break  # 输入为 quit,退出client.send(bytes(cmd, encoding='utf-8'))  # 发送命令给服务端data_head = client.recv(4)  # 接收 4 个字节,包含报头长度data_head_size = struct.unpack('i', data_head)[0]  # 解压报头长度head_json = json.loads(client.recv(data_head_size).decode('utf-8'))		# 接收报头,并解压data_len = head_json['data_size']	# 取出报头内包含的信息,即数据大小# 根据报头数据长度对数据接收recv_size = 0  # 接收的数据长度total_data = b''  # 总共接收的数据while recv_size < data_len:  # 将接收的数据长度与报头数据长度比较data_recv = client.recv(buffer_size)  # 接收真实数据recv_size += len(data_recv)  # 接收的数据长度相加total_data += data_recvprint('命令执行结果是', total_data.decode('gbk'))
client.close()

相关内容

热门资讯

苗族的传统节日 贵州苗族节日有... 【岜沙苗族芦笙节】岜沙,苗语叫“分送”,距从江县城7.5公里,是世界上最崇拜树木并以树为神的枪手部落...
北京的名胜古迹 北京最著名的景... 北京从元代开始,逐渐走上帝国首都的道路,先是成为大辽朝五大首都之一的南京城,随着金灭辽,金代从海陵王...
世界上最漂亮的人 世界上最漂亮... 此前在某网上,选出了全球265万颜值姣好的女性。从这些数量庞大的女性群体中,人们投票选出了心目中最美...
长白山自助游攻略 吉林长白山游... 昨天介绍了西坡的景点详细请看链接:一个人的旅行,据说能看到长白山天池全凭运气,您的运气如何?今日介绍...
猫咪吃了塑料袋怎么办 猫咪误食... 你知道吗?塑料袋放久了会长猫哦!要说猫咪对塑料袋的喜爱程度完完全全可以媲美纸箱家里只要一有塑料袋的响...