本文环境: Python 3.11.3
本文假定读者已了解 Python 基本概念
本文欢迎转载,请注明原作者以及原链接

一、Socket 为何物?

详细讲需要涉及到 TCP/UDP底层概念、OSI参考模型,本段仅作 Socket 简单介绍,详细请看这篇 知乎文章

百度百科 对 Socket 的定义:

套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。

Socket 套接字 通信的基本过程:

  1. 服务器启动并绑定到特定的IP地址和端口。
  2. 客户端创建一个 Socket,并指定要连接的服务器的IP地址和端口号。
  3. 客户端向服务器发起连接请求。
  4. 服务器接受连接请求,并建立连接。
  5. 客户端和服务器之间可以进行数据传输。
  6. 通信结束后,客户端和服务器关闭连接。

举一个简单易懂的例子:
想象你和一个朋友之间进行电话通话。在通话过程中,你是一个客户端,而你的朋友是一个服务器。

  1. 你首先拿起电话,拨打了你朋友的号码(目标IP地址和端口号),这就相当于你的客户端发起了连接请求。
  2. 你的朋友听到电话铃声,你的朋友可以选择接听(接受连接),挂机(拒绝连接)或者朋友有事没接(连接超时)
  3. 当你的朋友接听了电话,通话开始后,你可以向你的朋友传达消息(发送数据),你的朋友也可以回复你的消息(接收和发送数据),这就是数据在客户端和服务器之间传输的过程。
  4. 当通话结束时,你或者你的朋友挂断电话,连接关闭。

在这个例子中:

  1. 电话号码就像是IP地址,它唯一地标识了你朋友的位置。
  2. 电话通话的建立和结束过程类似于 Socket 连接的建立和关闭。
  3. 通话过程中的对话就是数据在客户端和服务器之间传输的过程。

面向网络的 Socket 有3种类型,各对应不同的应用模式

  • 流套接字 (SOCK_STREAM)

    用于提供面向连接、可靠的数据传输用途,并保证数据能够实现无差错、无重复送,并按发送方的顺序接收。流套接字使用了 TCP 协议,由于建立 TCP 连接需要经历三次握手,断开需要四次挥手才算断开,如果数据有误还会要求客户端重新发送,这就使得流套接字实现可靠的数据服务,但是传输效率相对较低。主要用于需要稳定性和可靠性的服务,例如 FTP 和 HTTP 。
  • 数据报套接字 (SOCK_DGRAM)

    与流套接字不同,数据报套接字基于无连接的传输方式,即 UDP 。由于 UDP 面向无连接,并且可以随时发送数据,因此不能保证数据传输的可靠性,数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据,需要用户在代码中做一定的处理。但 UDP 的处理本身非常高效,并且也不像 TCP 那样必须点对点连接,可以一对多或者多对一,因此 UDP 被广泛用于数据量较少的通信如 DNS,视频/音频直播,在线游戏等。
  • 原始套接字 (SOCK_RAW)

    原始套接字与标准套接字(即前面介绍的两种)的区别在于:原始套接字可以直接读写没有经过任何处理的IP数据包,而流套接字只能读取TCP协议的数据,数据报套接字只能读取UDP协议的数据。因此,如果要访问其他协议发送的数据必须使用原始套接。

二、创建一个 Socket

Python 中内置有 Socket 包,可以很方便的创建 Socket 服务器和向服务器建立连接

(1)Socket TCP 服务端

首先,我们需要在我们的 Python 文件中导入 socket 包,并创建一个 socket 对象

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

在以上代码中,我们需要传入2个 socket 包内置的变量:

  1. socket.AF_INET

    Socket 的家族名,Socket 有2种类型:基于文件和面向网络的
    这里传入的 AF_INET 即创建的是面向网络的 Socket

    (1) 基于文件:了解更多基于文件的 Socket 请见这篇 CSDN博客,简而言之与面向网络 Socket 不同的是,基于文件的 Socket 只是将应用层数据从一个进程拷贝到另一个进程。

    (2) 面向网络:从缩写 AF_INET 即可看出来,是面向 Internet 的套接字,另一个地址家族 AF_INET6 用于第6版因特网协议(IPv6)寻址。
  2. soekct.SOCK_STREAM

    上文已经介绍过,SOCK_STREAM 创建的是基于 TCP 的套接字,如果想要创建 UDP 则直接把 STREAM 换成 DGRAM 即可,RAW 同理。

接下来,我们需要将我们的 Socket 对象绑定(即监听)到一个本地主机的一个端口上:

server.bind(("127.0.0.1", 8888))

bind() 方法要求传入一个由 监听地址 和 端口 组成的 元组 Tuple 对象,这里提供的地址和端口就是我们服务器监听的地址和端口。当然,只监听肯定是不行的,由于我们使用了 SOCK_STREAM,我们需要主动的去接受客户端发起的 Socket 连接。在主动开始监听客户端的连接之前,我们需要先调用 listen() 来开始监听,否则就不能接收客户端连接请求。

server.listen()
while True:
    client, address = server.accept()
    print("Client Connected from :" + str(address))

当调用 accept() 方法时,除非后台有等待连接的客户端,不然你的代码会被阻塞直到接收到新的连接请求。请求被接受后,这个方法会返回2个数值,这里我使用2个变量名,client 是当前服务器与该客户端建立连接的 Socket,address 是包含了客户端IP地址和端口的元组对象。由于元组对象不能直接打印,我们需要先使用 str() 将其转换成 字符串

连接肯定不能只停留在建立阶段,我们需要接收来自客户端的数据,或者向客户端发送数据:

    client.send("Hello! I'm the server!".encode("UTF-8"))
    message = client.recv(1024).decode("UTF-8")
    print("Message from client: " + message)

这里,我们如果想要向客户发送数据,需要使用 send() 方法,但是其只接受 Bytes 对象的输入,我们需要将我们要发送的消息编码成 Bytes 对象,这里我使用了 UTF-8 编码

看向下一行,要接收来自客户端的消息,需要使用 recv() 方法,这里我们需要传入一个整数,代表我们想要从缓冲区读取多少数据,由于其返回的是一个 Bytes 对象,我们需要对其进行解码,这里作为服务器,我假设客户端使用 UTF-8 编码消息

然后,我们将来自客户端的消息打印到控制台上。

连接接受了,数据也都传输完了,接下来我们就应该断开客户端的连接了:

    client.close()
    print("Client Disconnected from :" + str(address))

调用客户端套接字对象的 close() 方法可以断开客户端的连接。

至此,一个简单的 Socket 服务器就编写完毕了,代码运行后,进入无限循环,当有客户端主动连接时,接受来自客户端的连接,并客户端发送一条 "Hello! I'm the server!" 消息后,就等待客户端的消息,当接收到消息后,将其打印在控制台上,并断开与客户端的连接。

import socket

server = socket.socket()
server.bind(("127.0.0.1", 8888))
server.listen()
while True:
    client, address = server.accept()
    print("Client Connected from :" + str(address))
    client.send("Hello! I'm the server!".encode("UTF-8"))
    message = client.recv(1024).decode("UTF-8")
    print("Message from client: " + message)
    client.close()
    print("Client Disconnected from :" + str(address))

(2)Socket TCP 客户端

服务端编写完了,接下来就是客户端了。
和服务端一样,我们先导入 socket 包,并创建好 socket 对象:

import socket

client = socket.socket()

与服务端不同的是,我们在创建 Socket 对象的时候不需要传入任何变量,直接创建即可
接下来,和服务端监听端口一样,我们要调用 connect() 方法,并传入一个由服务端地址和端口构成的元组对象,来连接到指定的服务端:

client.connect(("127.0.0.1", 8888))

当代码执行完这一条时,Socket 连接就已经建立,服务端已经向我们发送了一条消息,我们和服务端接收消息一样,使用 recv() 方法来接收服务端发送的消息,将其解码后打印在控制台中:

message = client.recv(1024).decode("UTF-8")
print("Message from server: " + message)

当然,我们也要向服务端发送一条消息,并断开连接:

client.send("Hello server, i'm the client!".encode("UTF-8"))
client.close()

至此,我们客户端编写完了,以下是完整代码:

import socket

client = socket.socket()

client.connect(("127.0.0.1", 8888))
message = client.recv(1024).decode("UTF-8")
print("Message from server: " + message)
client.send("Hello server, i'm the client!".encode("UTF-8"))
client.close()

(3)运行结果

客户端:

Message from server: Hello! I'm the server!

Process finished with exit code 0

服务端:
与上面客户端不一样,服务端并没有退出,而是在等待新的连接请求。

Client Connected from :('127.0.0.1', 8822)
Message from client: Hello server, i'm the client!
Client Disconnected from :('127.0.0.1', 8822)

三、注意事项

1. 数据传输格式

如前文所述,Socket 要发送和接收到的数据都是二进制 Bytes 格式,因此所有的数据都需要进行编码和解码,实践上通常使用 UTF-8 编码以保证通用性,当然也可以使用其他编码方式,例如GBK等。
若要了解更多关于 Python Bytes 对象和常见编码方式,请见 Python 学习笔记 (一) 中第七小节。

2. 缓冲区大小

在前文在接收数据的时候,我们需要使用 recv(1024) 这个函数去接收数据。
你可能会认为这是实时的从 Socket 中读取接收到的数据,但其实并不是,Socket 接收到的数据会暂存在缓冲区,等待用户的读取。上文代码的意思是我们从 Socket 缓冲区读取 1024B 的数据,即 1KB。
Python 中默认接收和发送的缓冲区大小均为 65536B,即 64KB
如果需要传输大量数据,需要考虑修改缓冲区大小或者循环接收/发送。
以下是修改缓冲区大小的代码:

import socket

sock = socket.socket()

# 设置接收缓冲区大小为8192
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8192)

# 设置发送缓冲区大小为8192
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 8192)

3. 安全通信

在本地做测试时,我们可以直接发送明文,但是如果要在不安全的网络中传输比较敏感的数据(例如账户名和密码),最好还是对数据进行一点加密,以防止被有心人所利用。
关于 Socket TLS/SSL 加密通信的相关内容,我会在 Socket 进阶 中提到,敬请期待。

4. 资源回收

当客户端的 Socket 被服务器主动或者因异常(连接超时/中断等)而关闭,我们应该主动调用 close() 函数去主动关闭 Socket,避免系统资源的占用。并且,Python 中的 Socket 对象并不能复用,如果被关闭后想要重新发起连接,或者连接到一个新的服务器,都必须创建一个新的 Socket 对象。

一般人类
最后更新于 2024-04-15