首先放出一个 TCP/IP 的程序,这里是单线程服务器与客户端,在多线程一节会放上多线程的TCP/IP服务程序。
这里将服务端和客户端放到同一个程序当中,方便对比服务端与客户端的不同。
import sys
import socket
import struct
from argparse import ArgumentParser
'''
确定发送数据的 长度值 的编码的长度, 2*32 - 1, 最大数据表示为 4GB
'''
header_struct = struct.Struct('>I')
'''
按照定长接收数据
如果长度为0,则会跳过接收过程,返回空数据,不会报错
如果接收数据长度比length小,则报错。
'''
def recvall(sock, length):
data = b''
while len(data) < length:
more = sock.recv(length - len(data))
if not more:
raise EOFError('Expected {} bytes but only received '
'{} bytes before the socket closed'\
.format(length, len(data)))
data += more
return data
'''
按照固定长度前缀接收数据,先接收固定长度的数据,表示数据长度,然后按照长度信息接收后续全部数据并返回
'''
def get_block(sock):
data = recvall(sock, header_struct.size)
(data_length, ) = header_struct.unpack(data)
return recvall(sock, data_length)
'''
为数据添加固定字节长度的 数据长度信息并发送。message 为字节格式。
'''
def put_block(sock, message):
length = len(message)
sock.sendall(header_struct.pack(length))
sock.sendall(message)
'''
创建服务器,接收 (ip, port) 作为输入,循环接收所有可以接收的信息,返回固定内容。
'''
def server(address):
# 创建一个 socket 对象
# socket.AF_INET --- family 地址族, AF_UNIX(只能用于单一的Unix系统进程间通信), AF_INET, AF_INET6, 默认 AF_INET
# socket.SOCK_STREAM ---type 套接字类型, SOCK_STREAM (流式socket,用于TCP), SOCK_DGRAM (数据报 socket, 用于 UDP), SOCK_RAW, SOCK_SEQPACKET) 默认 SOCK_STREAM
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# setsocketopt(sock, level, optname, optval, optlen),若是用 实例sock.setsocketopt,则忽略第一个参数sock。
# level 指定控制套接字的层次,可以有 SOL_SOCKET:通用套接字;IPPROTO_IP: IP选项;IPPROTO_TCP:TCP选项
# optname 控制方式,每种控制层级下都有不同的控制方式,比方说 SOL_SOCKET 下的 SO_REUSEADDR:允许重用本地地址和端口
# optval 设置上述控制方式的值
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 将socket 绑定到特定的 ip + 端口, 用于服务器对外的连接地址
# 如果 ip 为空,表示 任意地址
sock.bind(address)
# listen 才决定程序用于服务器
# listen()在TCP套接字上的调用,会彻底转变该套接字的角色,此时该套接字只能用于 accept(),不会与任何特定的客户端连接
# listen()调用对套接字的改变是无法撤销的。
sock.listen(5)
print('Run this script in another window with "-c" to connect')
print('Now Server is listening at ', sock.getsockname())
while True:
# 接受 客户端连接,accept()会返回一个全新的套接字,该新建套接字负责管理对应的新建会话。
# sc 新创建的socket, 用于连接,连接套接字
# addr 远端的地址, ip + 端口
sc, addr = sock.accept()
print('Accepted connection from: ', addr)
print('Connection socket: ', sc)
# 此时可以调用 shutdown 功能,关闭某一方向上的连接。
# SHUT_WR 表示 本连接后续只接收, 不发送。
# SHUT_RD 表示本连接后续只发送,不接收
# SHUT_RDWR 关闭两个方向上的通信, 与 close()不同,
# 如果操作系统允许多个程序共用一个套接字,那么close()只是关闭单个进程对套接字的调用,其他进程仍然可以使用该套接字,
# 而 SHUT_RDWR 则是关闭套接字,所有的使用该套接字的进程都不可用。
# sc.shutdown(socket.SHUT_WR)
while True:
message = b''
while True:
more = get_block(sc)
# 判断more是否为空,
# 因为约定每次发送数据最后都要发送一个空数据最为结尾,只要没有接收到空数据,则表示数据没有完整接收
if not more:
break
message += more
print('From client: ', repr(message))
if message == 'exit'.encode('utf-8'):
break
message = b'Got your message, please continue.'
sys.stdout.flush() # 刷新输出
put_block(sc, message)
sc.close()
def client(address):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(address)
print('Now client connected to ', sock.getsockname())
print('Please input the data you want to send, input "exit" to exit.')
while True:
data = input('client: ')
message = data.encode('utf-8')
put_block(sock, message)
put_block(sock, b'') # 最后发送一个空信息,表示数据结尾。
if data == 'exit':
sock.close()
break
message = get_block(sock)
print('Server says: ', repr(message))
sys.stdout.flush()
if __name__ == '__main__':
choices = {'server': server, 'client': client}
parser = ArgumentParser(description='Transmit and receive blocks over TCP')
parser.add_argument('role', choices=choices, help='which role to play ?')
parser.add_argument('host', default='127.0.0.1', help='interface the server listens at: '
'host the client sends to.')
# metavar 用于显示帮助信息时的显示 , 不影响实际使用
parser.add_argument('-p', type=int, metavar='PORT', default=1060,
help='TCP port (default 1060)')
args = parser.parse_args()
function=choices[args.role]
function((args.host, int(args.p)))
- OSI (Open System Interconnect) 开放式系统互连,是ISO国际标准化组织在1985年提出的网络互联模型。OSI 通过分层的技术,将网络分为若干层,每一层实现不同的功能,每一层向上提供服务,向下提供应用,通过标准化的接口与协议,各司其职,完成整个通信过程。在实现通信的过程中,每一层(软件或者设备,见下图)与远端对等层进行虚拟通信(具体实现需要逐层向下至最下层的物理层进行连接),只需要考虑本层的协议与实现方法即可,称之为水平协议,垂直服务。
TCP/IP是因特网的通信协议,其参考OSI模型,也采用了分层的方式,对每一层制定了相应的标准。
-
OSI 参考模型与TCP/IP模型
OSI参考模型与TCP IP模型.png 网络设备间进行共享的基本单元是数据包 (packet),一个数据包是一串长度在几字节到几千字节间的字节串,数据包在物理层通常只有两个属性:包含的字节串数据以及目标传输地址,网卡负责发送并接收这样的数据包。
网际协议(IP)是为全世界通过互联网连接的计算机赋予统一地址系统的机制,它使得数据包能够从互联网的一端发送至另一端,如 130.207.244.244,为了便于记忆,常用主机名代替IP地址,例如 baidu.com。
- UDP/TCP, IP协议只负责尝试将每个数据包传输至正确的机器,但是两个独立的应用程序要维护一个会话的话,还需要两个额外的特性:
需要为两台主机间传送的大量数据包打上标签,这样就可以将表示网页的数据包和用于电子邮件的数据包以及其他应用的数据包分开,这一过程叫做多路复用 (multiplexing)。
对两台主机间独立传输的数据包流发生的任何错误,都需要进行修复。而丢失的数据包也需要进行重传,直到将其成功发送至目的地址。另外如果数据到到达时顺序错乱,则要将这些数据包重组回正确的顺序。最后,要丢弃重复的数据包,以保证数据流中的信息没有冗余。提供这些保证的特性叫做可靠传输(reliable transport)
UDP (User Datagram Protocol,用户数据报协议) 解决了上述第一个问题,通过端口号来实现了多路复用(用不同的端口区分不同的应用程序)但是使用UDP协议的网络程序需要自己处理丢包、重包和包的乱序问题。
TCP (Transmission Control Protocol,传输控制协议) 解决了上述两个问题,同样使用端口号实现了复用。
TCP 实现可靠连接的方法:
每个TCP数据包都有一个序列号,接收方通过该序列号将数据正确排序,并发现丢失的数据包,并请求进行重传。
TCP并不使用顺序的整数作为序列号,而是通过计数器记录发送的字节数。例如 如果一个包含1024字节的数据包的序列号为7200,那么下一个数据包的序列号就是8224.
初始序列号随机选择。
TCP无须等待响应就能一口气发送多个数据包,某一时刻同时传输的数据量叫做TCP窗口。
接收方的TCP可以通过控制发送方的窗口大小来减缓或暂停连接,这叫做流量控制(flow control)
- Soket是对TCP/IP协议的封装,Socket并不是协议,而是一个调用接口(API),socket 起源于Unix,Unix/Linux的基本哲学之一就是“一切接文件”,都可以用“打开open -> 读写 write/read -> 关闭close”模式来操作。socket是支持TCP/IP协议的网络通信的基本操作单元,是网络通信过程中端点的抽象表示,包含网络通信必须的 五种信息:连接时使用的协议,本地主机的IP地址,本地进程的协议端口,远端主机的IP地址,远端进程的协议端口。
socket通信模型及 TCP 通信过程如下两张图。
[图片上传失败...(image-6d947d-1610703914730)]
[图片上传失败...(image-30b472-1610703914730)]
- TCP套接字的含义:
TCP是面向连接的协议,所以就需要提前建立一个通道,由通信双发都锁定一个 IP+port 的通信地址,在服务端就需要有一个 bind,绑定一个特定的IP地址与端口号,用于接收客户端的连接,这个socket成为被动套接字(passive socket), 又称作 监听套接字 (listening socket)。服务器通过该套接字来接受连接请求,但是该套接字不能用于发送和接收任何数据,也不表示任何的网络会话。
客户端通过 connect 去连接服务器,这是客户端就有了一组 IP + Port,IP为本机地址,端口号默认随机,这时服务器端 通过 accept接收客户端的连接,并创建一个新的socket 用户通信。这一组 socket 称为主动套接字(active socket) 又叫作 连接套接字(connected socket),连接套接字只用于与该特定的远程主机进行通信,看上去就好像是Unix系统的管道或文件,可以将TCP的连接套接字传给另一个接收普通文件作为输入的程序,而该程序永远也不会知道它其实正在进行网络通信。
- 套接字的5个坐标:
socket.getaddrinfo(host, port, family, socktype, proto, flags)
返回: [(family, socktype, proto, cannonname, sockaddr), ] 由元组组成的列表。
family:表示socket使用的协议簇, AF_UNIX : 1, AF_INET: 2, AF_INET6 : 10。 0 表示不指定。
socktype: socket 的类型, SOCK_STREAM : 1, SOCK_DGRAM : 2, SOCK_RAW : 3
proto: 协议, 套接字所用的协议,如果不指定, 则为 0。 IPPROTO_TCP : 6, IPPRTOTO_UDP : 17
flags:标记,限制返回内容。 AI_ADDRCONFIG 把计算机无法连接的所有地址都过滤掉(如果一个机构既有IPv4,又有IPv6,而主机只有IPv4,则会把 IPv6过滤掉)
AI _V4MAPPED, 如果本机只有IPv6,服务却只有IPv4,这个标记会将 IPv4地址重新编码为可实际使用的IPv6地址。
AI_CANONNAME,返回规范主机名:cannonname。
getaddrinfo(None, 'smtp', 0, socket.SOCK_STREAM, 0, socket.AP_PASSIVE)
getaddrinfo('ftp.kernel.org', 'ftp', 0, 'socket.SOCK_STREAM, 0, socket.AI_ADDRCONFIG | socket.AI_V4MAPPED)
利用已经通信的套接字名提供给getaddrinfo
mysock = server_sock.accept()
addr, port = mysock.getpeername()
getaddrinfo(addr, port, mysock.family, mysock.type, mysock.proto, socket.AI_CANONNAME)
'''
对服务器连接测试
提供任意网络服务器的名字,得到能否连接的结果。
'''
import socket
import sys
import argparse
def connect_to(hostname_or_ip):
try:
# 利用 getaddrinfo 获取套接字五元组
# 然后根据这个五元组可以 创建 socket, connect 主机。
infolist = socket.getaddrinfo(
hostname_or_ip, 'www', 0, socket.SOCK_STREAM, 0,
socket.AI_ADDRCONFIG | socket.AI_V4MAPPED | socket.AI_CANONNAME,
)
except socket.gaierror as e:
print('Name service failure: ', e.args[1])
sys.exit(1)
info = infolist[0] # getaddrinfo 返回所有可用的五元组,所以要先获取其中一个元组
socket_args = info[:3] # 建立socket所需的三个元素 family, type, proto
address = info[4] # 地址元组(ip, port)
s = socket.socket(*socket_args)
try:
s.connect(address)
except socket.error as e:
print('Network failure: ', e.args[1])
else:
print('Success: host ', info[3], ' is listening on prot 80')
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Try connecting to port 80')
parser.add_argument('hostname', help='hostname that you want to contact')
connect_to(parser.parse_args().hostname)
TCP 数据发送模式:
由于 TCP 是发送流式数据,并且会自动分割发送的数据包,而且在 recv 的时候会阻塞进程,直到接收到数据为止,因此会出现死锁现象,及通信双方都在等待接收数据导致无法响应,或者都在发送数据导致缓存区溢出。所以就有了封帧(framing)的问题,即如何分割消息,使得接收方能够识别消息的开始与结束。
关于封帧,需要考虑的问题是, 接收方何时最终停止调用recv才是安全的?整个消息或数据何时才能完整无缺的传达?何时才能将接收到的消息作为一个整体来解析或处理。
半开连接,只涉及数据的发送,而不关注响应。在这种情况下,可以使用这种模式:发送方循环发送数据,直到所有数据都被传递给sendall为止,然后使用close()关闭套接字。接收方只需要不断的调用recv(),直到recv()最后返回一个空字符串(表示发送方已经关闭了套接字)为止。 sock.shutdown(socket.SHUT_WR)
模式一的变体,即在两个方向上都通过流发送信息,首先通过流在一个方向上发送信息,然后关闭该方向,然后在另一个方向上发送信息,然后关闭套接字。
使用定长消息,按照代码中的 recvall 函数,按照长度接收消息。
通过某些方法,使用特殊字符划分消息的边界,如果发送的是ASCII字符串,可以选择 '\0',或者 '\xff' 这样处于ASCII 字符范围之外的字符,然后循环接收消息直到收到边界字符。只有消息使用的字母表有限时,才能使用定界符机制。
在每个消息前加上其长度作为前缀,消息长度使用定长的二进制整数,struct 模块。 但是需要事先知道消息的长度,如果事先无法得知消息的长度,就不能用这个方法。
与模式5类似,但是不在是整个消息添加长度前缀,而是将数据拆分成多个数据块,每个数据块之前都加上长度前缀,抵达消息结尾时,发送发可以发送一个与接收方事先约定好的信号(比如数字0表示的长度字段),告知接收方,所有数据块已经发送完毕。
适用UDP的场景:
由于TCP每次连接与断开都需要有三次握手,若有大量连接,则会产生大量的开销,在客户端与服务器之间不存在长时间连接的情况下,适用UDP更为合适,尤其是客户端太多的时候。
第二种情况: 当丢包现象发生时,如果应用程序有比简单地重传数据聪明得多的方法的话,那么就不适用TCP了。例如,如果正在进行音频通话,如果有1s的数据由于丢包而丢失了,那么只是简单地不断重新发送这1s的数据直至其成功传达是无济于事的。反之,客户端应该从传达的数据包中任意选择一些组合成一段音频(为了解决这一问题,一个智能的音频协议会用前一段音频的高度压缩版本作为数据包的开始部分,同样将其后继音频压缩,作为数据包的结束部分),然后继续进行后续操作,就好像没有发生丢包一样。如果使用TCP,那么这是不可能的,因为TCP会固执地重传丢失的信息,即使这些信息早已过时无用也不例外。UDP数据报通常是互联网实时多媒体流的基础。
参考资料:
Python网络编程(第3版) [美] Brandon Rhodes John Goerzen 著, 诸豪文 译
SOI参考模型和TCP/IP参考模: https://zhuanlan.zhihu.com/p/248667559
Socket和Http通信原理:https://zhuanlan.zhihu.com/p/142650150
socket通信模型:https://blog.csdn.net/Galen_xia/article/details/110876144
setsockopt()函数功能介绍:https://www.cnblogs.com/eeexu123/p/5275783.html