python编程 入门后学习笔记一 ——socket通信(1)
本节内容
1.socket简介
2.客户端/服务器模式
3.socket多连接处理
4.通过socket实现简单的ssh
1.socket简介
socket是进程通讯的一种方式,即调用这个网络库的API函数实现分布在不同主机相关进程之间的数据交换。
术语:
1)IP地址:依照TCP/IP协议分配给本地主机的网络地址。两个进程要进行通讯,任一进程首先需要知道通讯对方的位置,即对方的IP。
2)端口号:用来辨别本地通讯进程,一个本地的进程在通讯时均会占用一个端口号,不同的进程会有不同的端口号,所以,在通讯前必须要分配一个没有被访问的端口号。
3)连接:指两个进程间的通讯链路。
4)半相关:网络中可以用一个三元组在全局中唯一标识一个进程,(协议,本地地址,本地端口号)这样一个三元组,称作一个半相关(half-association),它指定连接的每半部分。
5)全相关:一个完整的网间进程通信需要由两个进程组成,并且只能使用同一种高层协议。也就是说,不可能通信的一端用TCP协议,而另一端用UDP协议。
所以,一个完整的网间通信需要一个五元组来标识,(协议,本地地址,本地端口号,远地地址,远地端口号)这样一个五元组,称作一个相关(association)。也就是说,两个协议相同的半相关才能组合成一个合适的相关,或完全指定组成一连接。
2.客户端/服务器模式
在TCP/IP网络应用中,通信的两个进程间相互作用的主要模式是客户端/服务器(Client/Server,C/S)模式,即客户端向服务器发出服务请求,服务器接收到请求后,提供相应的服务。
客户端/服务器模式的建立基于:
1)首先,建立网络的起因是网络中软硬件资源、运算能力和信息不均等,需要通过共享,使得拥有众多资源的主机提供服务,资源较少的客户请求服务。
2)其次,网间进程通信完全是异步的,相互通信的进程间既不存在父子关系,又不共享内存缓冲区。所以,需要一种机制为相互通信的进程间建立联系,为二者的数据交换提供同步,这就是基于客户端/服务器模式的TCP/IP。
服务器端:
服务器端的工作过程是首先服务器方需要先启动,并根据请求提供相应的服务:
1)打开一个通信通道并告知本地主机,它愿意在某一公认地址上的某端口(比如FTP,端口可能是21)接收客户请求;
2)等待客户请求到达该端口;
3)接收到客户端的服务请求时,处理该请求并发送应答信号(接收到并发服务请求,要去激活一个新的进程来处理这个客户请求——如Unix系统中用fork、exec,新进程处理此客户请求,并不对其它请求作出应答,服务完成后,关闭这个新进程与客户的通信链路,并终止);
4)返回第2)步,等待另一客户请求;
5)关闭服务器。
服务器端简单代码实现

#-*- coding:utf-8 -*- #Author:'Yang' #服务器端 import socket server = socket.socket() server.bind(("localhost",6969)) #绑定要监听的IP及端口 server.listen(3) #监听 print("等待客户端的连接...") conn,addr=server.accept() #等请求进来 #conn,就是客户端连过来而在服务器端为其生成的一个连接实例 print("新连接",addr) data=conn.recv(1024) print("收到客户端的消息:",data.decode()) conn.send(data.upper()) #大写后发送给客户端 server.close()
客户端:
1)打开一个通信通道,并连接到服务器所在主机的特定端口;
2)向服务器发服务请求报文,等待并接收应答;继续提出请求......
3)请求结束后关闭通信通道,并终止。
客户端简单代码实现

#-*- coding:utf-8 -*- #Author:'Yang' #客户端 import socket client=socket.socket() #默认family address=AF_INET是IPV4协议 client.connect(("localhost",6969)) client.send("hello!".encode("utf-8")) data=client.recv(1024) #1024字节,即1K print("收到服务器端的消息:",data.decode()) client.close()
注意:
1)客户端和服务器进程的作用是非对称的,所以,代码不同;
2)服务器进程一般是先启动的,只要系统运行,该服务进程一直存在,直到正常或强迫终止。
上述给出了服务器端、客户端简单的代码实现:先运行服务器端代码(即启动服务器);然后运行客户端代码(即启动客户端);最后由客户端发出请求,客户端给予应答(即连接建立后进行通信)。
虽然上面实现了简单通信,但是又一个问题:服务器端启动后,接收了(一次)客户端发来的数据,服务端就断开了,无法再继续进行通信;但是,在实际应用场景中,一个连接建立起来后,很可能需要进行来回多次的通信。
解决:改进服务器端,加上while 循环接收客户端的消息;改进客户端循环发送消息给服务器端。

#-*- coding:utf-8 -*- #Author:'Yang' #服务器端 import socket server = socket.socket() server.bind(("localhost",6969)) #绑定要监听的端口 server.listen(3) #监听 print("等待客户端的连接...") conn,addr=server.accept() #等请求进来 #conn,就是客户端连过来而在服务器端为其生成的一个连接实例 print("新连接",addr) while True: data=conn.recv(1024) print("收到客户端的消息:",data.decode()) conn.send(data.upper()) #大写后发送给客户端 server.close()

#-*- coding:utf-8 -*- #Author:'Yang' #客户端 import socket client=socket.socket() #默认family address=AF_INET是IPV4协议 client.connect(("localhost",6969)) while True: msg=input('>>:').strip() client.send(msg.encode("utf-8")) data=client.recv(1024) #1024字节,即1K print("收到服务器端的消息:",data.decode()) client.close()
运行一下,good job!可以实现多次交互通信了!
可是你会发现有来了个新的问题:
1)客户端输入为空时,服务器端和客户端就同时进入“无响应”状态
2)客户端一断开,服务器端也断开了:ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接。
先来解决1)客户端输入为空这个问题,既然知道是由于输入为空导致的“无响应”,那么只要稍加改进:在客户端发送及服务器端收到的数据都进行是否为空的判断。

#-*- coding:utf-8 -*- #Author:'Yang' #服务器端 import socket server = socket.socket() server.bind(("localhost",6969)) #绑定要监听的端口 server.listen(3) #监听 print("等待客户端的连接...") conn,addr=server.accept() #等请求进来 #conn,就是客户端连过来而在服务器端为其生成的一个连接实例 print("新连接",addr) while True: data=conn.recv(1024) if not data:break #判断客户端发送过来的数据是否为空,是则断开 print("收到客户端的消息:",data.decode()) conn.send(data.upper()) #大写后发送给客户端 print('out') server.close()

#-*- coding:utf-8 -*- #Author:'Yang' #客户端 import socket client=socket.socket() #默认family address=AF_INET是IPV4协议 client.connect(("localhost",6969)) while True: msg=input('>>:').strip() # 当输入为空的时候会卡住,所以此处加判断防止卡住 if len(msg)==0:#如果发送内容为空,则断开 break client.send(msg.encode("utf-8")) data=client.recv(1024) #1024字节,即1K print("收到服务器端的消息:",data.decode()) client.close()
那么还有另一个问题 2)客户端一断开,服务器端也断开了,怎么改进呢?
3.socket多连接处理
上面一小节中我们还留下了一个问题:客户端一断开,服务器端也断开了,也就是说以上通信连接仅适用于单个客户端,如果想要实现多个客户端的连接通信,代码如何进行改进呢?
conn,addr=server.accept() #接受并建立与客户端的连接
在socket_server_v2.1.py中看到上面这句代码了吗?之所以服务器端断了,就是因为这里阻塞了,除非有新的客户端连接再次进来。找到原因,那么我们就来进行改进:也就是说,只要在上一个客户端断开后,程序能再次回到 conn,addr=server.accept()这里,就可以让服务器端继续连接到下一个客户端了。

#-*- coding:utf-8 -*- #Author:'Yang' #服务器端 import socket server = socket.socket() #默认family address=AF_INET是IPV4协议 server.bind(("localhost",6969)) #绑定要监听的IP和端口 server.listen(3) #监听 print("等待客户端的连接...") while True: #接受多个客户端排队等待 conn,addr=server.accept() #接受并建立与客户端的连接 #conn,就是客户端连过来而在服务器端为其生成的一个连接实例 print("新连接",addr) while True: #接受一个客户端的多次通信 data=conn.recv(1024) #接收客户端的数据(请求),最大可收1024字节 #判断客户端发送过来的数据是否为空,是则断开 if not data: print("该客户端会话已断开") break print("收到客户端的消息:",data.decode()) conn.send(data.upper()) #大写后发送给客户端 server.close()

#-*- coding:utf-8 -*- #Author:'Yang' #客户端 import socket client=socket.socket() #默认family address=AF_INET是IPV4协议 client.connect(("localhost",6969)) while True: msg=input('>>:').strip() # 当输入为空的时候会卡住,所以此处加判断防止卡住 if len(msg)==0:#如果发送内容为空,则重新发 # continue break client.send(msg.encode("utf-8")) #发送数据给服务器端 data=client.recv(1024) #接收服务器端发送来的数据(应答),最大1024字节,即1K print("收到服务器端的消息:",data.decode()) client.close()
(在Linux下用python3运行通过,在windows下用python3尝试未能成功解决,欢迎补充)
注意:这里在linux下虽然通过,但是还是无法多个客户端同时跟服务器端通信,服务器端依然只能同时为一个客户端服务,其他客户段进来了,得排队(连接挂起)。
4.通过socket实现简单的ssh
上面仅仅实现了简单的发消息、收消息,其实socket还能做点更有意义的事:
可以实现简单的ssh,就是客户端连接上服务器后,让服务器执行命令,并返回结果给客户端。
名词解释:SSH (Secure Shell) 是一个允许两台电脑之间通过安全的连接进行数据交换的网络协议。

#-*- coding:utf-8 -*- #Author:'Yang' import socket import os server=socket.socket() server.bind(('localhost',9998)) server.listen(3) print("等待客户端的连接...") while True: conn,addr=server.accept() print('新连接:',addr) while True: cmd_res=conn.recv(2048) if not cmd_res: print("该客户端已断开") break print("执行指令:",cmd_res.decode()) data=os.popen(cmd_res.decode()).read() #接收字符串,执行结果也是字符串 if len(data)==0: data="cmd has no output..." conn.send(data.encode()) server.close()

#-*- coding:utf-8 -*- #Author:'Yang' import socket client=socket.socket() client.connect(('localhost',9998)) while True: cmd=input('>>:').strip() if len(cmd)==0:continue #如果发送命令为空,则重新发 client.send(cmd.encode('utf-8')) #发送命令给服务器端 data=client.recv(2048) #接收服务器端发送来的数据(应答),最大1024字节,即1K print(data.decode()) #打印命令执行结果 client.close()
这样,我们就实现了一个简单的ssh,但是多试了几个命令就发现了问题:
1)若执行的命令其返回结果的数据量比较大,会有结果返回不全的现象,当客户端执行下一条命令时,结果返回的还是上一条命令未执行完的部分,如果此时还有结果未返回,则执行新的命令,结果仍然返回未执行完的部分,....,依次类推,直至结果全部返回。
这是为什么?
因为我们的客户端写的是 client.recv(1024),也就是客户端一次最多只接收1024字节,如果服务器端返回的结果数据是大于1024字节,那么大于1024字节的部分客户端一次是接收不到的,这时,服务器就会把这部分的数据暂时存放在服务器的IO发送缓冲区,等客户端下次再接收数据时,就优先把这部分数据发送给客户端。
这时,你一定想到了一个解决办法:把客户端的 client.recv(1024)接收数据改大一点,比如 client.recv(10240)。
But,这是行不通的,因为socket每次接收和发送的数据都有最大数据量的限制,何况咱的网络宽带也是有限的,无法一次发太多。而且不同系统中,这个最大数据量的限制也是不同的。经测试的结果是,在Linux上最大一次可接收10MB左右的数据,不过官方的建议不超过8k,也就是8192字节。
所以,最好的解决方法是:循环收取,直到该命令的执行结果接收完。
问题是客户端怎么知道要循环接收多少次呢?
答案是只能通过服务器端在发送数据前主动告诉客户端:要发送多少数据给客户端,然后再开始发送数据。

#-*- coding:utf-8 -*- #Author:'Yang' import socket import os server=socket.socket() server.bind(('localhost',9998)) server.listen(3) print("等待客户端的连接...") while True: conn,addr=server.accept() print('新连接:',addr) while True: cmd_res=conn.recv(1024) if not cmd_res: print("该客户端已断开") break print("执行指令:",cmd_res.decode()) data=os.popen(cmd_res.decode()).read() #接收字符串,执行结果也是字符串 if len(data)==0: data="cmd has no output..." '''就是在此处添加代码,先发大小给客户端,再调用sendall,相当于重复循环调用conn.send,直至数据发送完毕''' print("服务器端即将发送给客户端的数据大小:",len(data.encode())) conn.send(str(len(data.encode())).encode()) #先发大小给客户端 conn.sendall(data.encode('utf-8')) #再发数据 server.close()

#-*- coding:utf-8 -*- #Author:'Yang' import socket client=socket.socket() client.connect(('localhost',9998)) while True: cmd=input('>>:').strip() if len(cmd)==0:continue #如果发送命令为空,则重新发 client.send(cmd.encode('utf-8')) #发送命令给服务器端 data_size=client.recv(1024) #接收命令结果的长度 print("客户端即将收到服务器端传来的数据大小:",data_size.decode()) #cmd命令返回结果数据 received_size=0 received_data=b'' while received_size!=int(data_size.decode()): data=client.recv(1024) #接收服务器端发送来的数据(应答),最大1024字节,即1K received_size +=len(data) #每次收到的,有可能小于1024 received_data +=data else: print("cmd res data is done.") print("已接收数据:",received_size) print(received_data.decode()) #打印命令执行结果 client.close()
Great job!这里记录下,在修改上述代码时,踩的两个坑!!!
坑一:
print("服务器端即将发送给客户端的数据大小:",len(data.encode()))
conn.send(str(len(data.encode())).encode()) #先发大小给客户端
这两句,一开始写成:
print("服务器端即将发送给客户端的数据大小:",len(data))
conn.send(str(len(data)).encode()) #先发大小给客户端,这里data内容是字符串
导致在windows中测试时,显示出接收的总数据始终比发送的数据大,为什么?调试才发现是有中文引入的原因。
看个简单的示例,就明白了:
>>> a='你好' >>> len(a) 2 >>> len(a.encode()) 6
事实是,服务器端即将发送给客户端的数据,在没encode()之前,一个中文字符的len长度是1,encode()之后(变成了bytes),一个中文字符的len长度是3;而实际客户端接收的数据data,是通过send发送过来的,默认是bytes,所以实际接收到的总数据字节大小看起来比服务器端即将发送给客户端的数据大。
坑二:在客户端输入下面的命令
>>:ipconfig
总是提示这句出错
print(data.decode()) #打印命令执行结果
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe7 in position 1023: unexpected end of data
但是,检查了好几遍编码并没有问题!那是为什么出错呢?
事实上,还是编码的问题!!!在send的时候,一个中文字符的3个bytes,正好被分为了两部分(两次发送),在decode的时候,自然就出错了!
>>> a='你好' >>> a.encode() b'\xe4\xbd\xa0\xe5\xa5\xbd' >>> b'\xe4\xbd\xa0'.decode() #正好是组成一个中文字符的3个字节 '你' >>> b'\xe4\xbd'.decode() #一个中文字符的前2个字节,报错 Traceback (most recent call last): File "<pyshell#44>", line 1, in <module> b'\xe4\xbd'.decode() UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 0-1: unexpected end of data
解决这个问题,只要等客户端收到服务端的完整数据后,再decode,就OK。
========================分割线===============================
请大家移步Linux,再运行
socket_server_ssh_v2.0.py
socket_client_ssh_v2.0.py
发现问题了没?多试几个命令后,报错了
这是什么问题???
粘包!
原因是服务器端在发送数据是连续调用了2次send。也就是说,在前一次send时,数据其实并没有被立刻发送到客户端,而是存在socket的发送缓冲区了,等缓冲区满了或者数据等待超时,数据才会被send到客户端。这样,好几次的小数据就拼成了一个大数据,发送到了客户端。这样发送数据的好处是:提高了IO利用率(一次性发送总比连发好几次效率高);但同时带来了“粘包”问题(2次或多次的数据粘在了一起统一发送了)。
比如,这里,服务器端返回的待发数据大小和后面的数据,粘在一块了。我们必须想办法把粘包分开,取出服务器端返回的待发数据大小的值。
怎么分开呢?我又没办法让缓冲区强制刷新把数据发给客户端。但我可以想办法,让缓冲区等待的时间超时,这样系统就不会等到缓冲区满了再发数据,而是直接就可以把数据发走。
改进方案:
在服务器端的两个send之间,加上time.sleep(0.5),让服务器sleep 0.5s就会造成缓冲区超时。在我们的示例中可能并不会有什么问题,但是如果在高级应用中,势必会影响到客户的等待时间。有没有更好的解决方案呢?
答案当然是有!想想socket的通信原理:服务器端每发送一次数据给客户端,就立刻等待客户端进行回应(即在服务器端调用conn.recv(1024)可获取这个回应),而在客户端没有回应时,conn.recv是接收不到数据的,即是阻塞的。这样就会造成,服务器端不会执行后面的conn.sendall的指令,直到客户端响应后,再发送命令结果时,缓冲区就已经被清空了,因为上一次的数据已经因超时被强制发到客户端了。

#-*- coding:utf-8 -*- #Author:'Yang' import socket import os import time server=socket.socket() server.bind(('localhost',9998)) server.listen(3) print("等待客户端的连接...") while True: conn,addr=server.accept() print('新连接:',addr) while True: cmd_res=conn.recv(1024) if not cmd_res: print("该客户端已断开") break print("执行指令:",cmd_res.decode()) data=os.popen(cmd_res.decode()).read() #接收字符串,执行结果也是字符串 if len(data)==0: data="cmd has no output..." '''就是在此处添加代码,先发大小给客户端,再调用sendall,相当于重复循环调用conn.send,直至数据发送完毕''' print("服务器端即将发送给客户端的数据大小:",len(data.encode())) conn.send(str(len(data.encode())).encode()) #先发大小给客户端 #time.sleep(0.5) wait_client_ack=conn.recv(1024) print("客户端回应:",wait_client_ack.decode()) conn.sendall(data.encode('utf-8')) #再发数据 print("数据已发送!") server.close()

#-*- coding:utf-8 -*- #Author:'Yang' import socket client=socket.socket() client.connect(('localhost',9998)) while True: cmd=input('>>:').strip() if len(cmd)==0:continue #如果发送命令为空,则重新发 client.send(cmd.encode('utf-8')) #发送命令给服务器端 data_size=client.recv(1024) #接收命令结果的长度 print("客户端即将收到服务器端传来的数据大小:",data_size.decode()) #cmd命令返回结果数据 client.send("已获悉待发数据大小,请发送数据".encode('utf-8')) received_size=0 received_data=b'' while received_size!=int(data_size.decode()): data=client.recv(1024) #接收服务器端发送来的数据(应答),最大1024字节,即1K received_size +=len(data) #每次收到的,有可能小于1024 received_data +=data else: print("cmd res data is done.") print("已接收数据:",received_size) print(received_data.decode()) #打印命令执行结果 client.close()
socket_client_ssh_v2.1.py: client.send("已获悉待发数据大小,请发送数据".encode('utf-8')) #回应服务器端发送的数据大小值
socket_server_ssh_v2.1.py: wait_client_ack=conn.recv(1024) #收到客户端(关于数据大小的接收情况)回应
客户端添加了上一次send后对服务器的回应,服务器端添加了等待客户端的回应,从而解决了粘包问题。