2.3 Socket编程
除了基于HTTP等标准协议的Web应用,Internet上还有很大一部分应用是基于非公有协议的。无论使用哪种语言进行非标准协议的开发,都需要了解Socket编程的基本知识。本节学习Socket的概念及用Socket进行TCP、UDP开发的方法。
注意:本节介绍的Socket知识不仅可用于Python网络编程,同样适用于其他所有编程语言。
2.3.1 Socket基础
Socket原指“孔”或“插座”,它最初作为BSD UNIX的进程通信机制,通常被称作“套接字”。当然,Socket是一个通信链的句柄,如今已经是Windows和macOS等其他操作系统所共同遵守的网络编程标准,用于描述IP地址和端口,可以用来实现不同虚拟机或不同计算机之间的通信,当然也可以实现相同主机内的不同进程间的通信。Internet上的主机一般运行了多个服务软件,同时提供几种服务,每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应不同的服务。
在操作系统结构上,Socket为应用程序屏蔽了TCP/IP网络传输层及以下的网络细节,如图2.6所示。Socket为操作系统的用户空间提供网络抽象,开发者编写的网络程序都会直接或间接地用到Socket抽象。通过Socket抽象可以控制传输层协议TCP和UDP,甚至包括部分网络层协议,例如IP和ICMP。
图2.6 Socket抽象
注意:本书只涉及Socket的TCP和UDP编程。
Socket使用IP地址+端口+协议的三元组唯一标识一个通信链路。服务器端的一个通信链路可以对应于多个客户端,比如一个Web服务器的80端口可以同时服务大量的客户端。
2.3.2 实战演练:Socket TCP原语
用Socket进行网络开发需了解服务器和客户端的Socket原语,每个原语在不同的高级语言中都有相应的实现方式。TCP的Socket原语,如图2.7所示。所有基于TCP的Socket通信都遵循如图2.7所示的流程。下面解释每个原语的含义。
图2.7 TCP Socket原语
· socket():建立Socket对象。Socket是以类似文件系统的“打开、读写、关闭”的模式设计的,socket()原语相当于“打开”。socket()原语的参数通常包括使用的传输层协议类型、网络层地址类型等。
· bind():绑定。在参数中需要传入要绑定的IP地址和端口。IP地址必须是主机上的一个可用的地址(除了用0.0.0.0指定绑定所有的本机IP)。端口必须是一个该Socket协议未被占用的端口,比如当一个主机上的两个程序试图同时绑定到80端口时,只有一个程序能够成功。服务器端程序在listen()之前必须进行bind()操作,而客户端程序如果在connect()原语之前没有调用bind(),则系统会自动为该Socket分配一个未被占用的地址和端口。
技巧:当主机上存在多个IP时,绑定地址0.0.0.0可以监听所有这些可用的IP。
· listen():监听。只在服务器端有用,告诉操作系统开始监听之前绑定的IP地址和端口,可以在参数中指定允许排队的最大连接数量。
· connect():在客户端连接服务器。参数中需要指定服务器的地址和端口。调用connect()可能有两种结果,即与服务器端完成TCP 3次握手并建立连接或者连接服务器失败。
· accept():接收连接。只在服务器端有用,从监听到的连接中取出一个,并将其包装成一个新的Socket对象。这个新的Socket对象可被用于和相应的客户端进行通信。完成accept()标志着Socket已经完成了TCP链路建立阶段的3次握手。如果当前没有客户端连接请求,则accept()调用会阻塞等待。
· send():发送数据。服务器和客户端均可调用send()向对方发送数据,在send()的参数中传入要发送的数据,通过send()的返回值判断数据是否发送成功。
· recv():接收数据。服务器和客户端均可调用recv()从对方接收数据。如果Socket中没有消息可以读取,则在默认情况下recv()调用会被阻塞直到有消息到达;开发者也可以将Socket设置为非阻塞模式,使recv()以失败形式返回。
· close():关闭连接。通信中的任何一方可以调用close()发起关闭连接请求,另一方收到后也调用close()关闭连接。
【示例2-1】下面通过Python代码演示Socket编程方法,TCP服务器端的代码如下:
包socket封装了所有Python的原生Socket操作,代码中通过socket()、bind()、listen()的一系列调用实现了对指定端口的监听,通过accept()接收客户端的连接,当有客户端连接成功后将当前系统时间发送给客户端,并马上关闭连接。因为代码主体处于while循环中,所以程序将不断监听并一直运行。
注意:send()函数接收的参数为bytes类型,因此在调用该函数时需要将字符串参数通过.encode(‘utf-8’)方法转换为bytes类型。
与该服务器端的代码相对应的客户端的代码如下:
客户端通过connect()调用、连接服务器,连接成功后接收从服务器发来的数据,然后关闭连接、退出程序。
现在尝试服务器与客户端通信的执行效果,首先启动服务器程序:
服务器程序将进入等待连接状态。然后打开另外一个控制台,执行客户端程序如下:
从以上输出中已经可以看到服务器发送过来的当前时间,说明已经成功进行通信。同时,服务器窗口中可以看到如下输出结果:
注意:客户端的Socket端口号由系统自动分配。
2.3.3 实战演练:Socket UDP原语
UDP相对于TCP在传输层提供更少的控制,没有建立连接、断开连接等概念,所以基于UDP的Socket通信过程也比TCP稍微简单。在UDP中可以直接通过指定IP:Port进行数据收发。UDP Socket可以复用TCP中的socket()和bind()原语,除此之外,UDP属于自己的Socket原语如下。
· recvfrom ():从绑定的地址接收数据。
· sendto ():向指定的地址发送数据,在调用的参数中应该传入通信对端的地址和端口。
【示例2-2】UDP的Python服务器端的代码示例如下:
代码通过socket()和bind()调用绑定了本地所有地址的3434端口,通过socket()中的SOCK_DGRAM指定Socket使用UDP,在一个循环中不断地接收数据并打印。相应的UDP客户端的Python代码如下:
客户端直接调用sendto()向指定的地址发送数据。
与TCP类似,在启动客户端之前同样需要先运行服务器程序:
现在执行客户端程序,执行结果如下:
相应的服务器端执行结果,其中的客户端端口52156由客户端程序在调用sendto()时自动生成: