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 时间
需求:基于 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 编码。
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 协议会给它装上消息头。
问题一:TCP 为什么更可靠?
因为 TCP 的三次握手双向机制,保证校验了数据,TCP 接收端只有接收到 ack 后,才会断开连接,所以可靠。而 UDP 信息发出后,不会验证是否达到对方,所有不可靠。
问题二:接收端缓存中内容什么时候才会被清空?
只有当缓存中的内容全部 copy 到 socket 程序中才会清空。
情况一
发送端需要等缓冲区满才发送出去,造成粘包(接收端缓冲区大小大于第一次发送的数据大小(字节),并和后面发送的数据合到一起,等缓冲区满了之后才发送出去)。
服务端:
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 个字节第二次接收。
解决方案
产生粘包的主要原因是不知道发送端发送的数据长度,接收端不知道一次接收多少。我们可以自定义一个 报头,先将报头发过去,再将真实的数据长度与报头长度比较,这样就不会导致粘包了。
服务端:
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 个字节。
大致流程:
服务端:
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()