释放小七猫的分享欲

做个 Socket 演示工具给对象教学用

categories: 技术 tags: 软件
@ 21/08/2025

做个 Socket 演示工具给对象教学用

前几天发了两篇 Socket 相关的文章,其实写那两篇内容相似的文章,是因为我对象下学期要给学生讲解网络方面的知识,所以顺便和她一起巩固巩固。

即便是用上了 gif 图,也还是觉得有些抽象,学生不一定能够看懂什么是“面向连接”和“无连接”。所以这几天写了个简单的小工具,可以用作学习演示。

代码开源地址:hansyou/socket-tool-python

项目结构

D:.
|   .gitignore
|   main.py
|   README.md
|
+---icon
|       icon.ico
|
\---sockets
        base_socket.py
        tcp_client.py
        tcp_server.py
        udp_client.py
        udp_server.py

项目结构很简单,main.py为入口文件,sockets/base_socket.py 为 socket 的基类,通过继承 base_socket,产生了另外四个类 tcp_client,udp_client,tcp_client,udp_client。分别放置在不同的文件里面。

核心代码解释

这里之讲解核心的逻辑代码,tkinter UI 代码不做讲解。

main.py

        # 连接列表
        self.connection_tree = ttk.Treeview(left_frame, show="tree")
        self.connection_tree.pack(fill=tk.BOTH, expand=True, pady=(5,0))
        self.connection_tree.heading("#0", text="活动连接")
        # 绑定双击事件来打开标签页
        self.connection_tree.bind("<Double-1>", self.open_connection_tab)

        # 绑定单击事件来激活已有的标签页
        self.connection_tree.bind("<Button-1>", self.open_connected_tab)

        # 右侧使用 Notebook 控件
        self.notebook = ttk.Notebook(main_frame)
        self.notebook.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

这段代码在主窗口左侧新建了一个连接列表 tree,用于显示已经创建好的 TCP/UDP 连接。使用了 ttk.Treeview 控件,它提供了树形结构的显示方式,非常适合用来管理多个网络连接。

关键组件说明:

  • self.connection_tree = ttk.Treeview(left_frame, show="tree"):创建树形视图控件,show="tree" 参数表示只显示树形结构而不显示表头列
  • self.connection_tree.heading("#0", text="活动连接"):设置树形视图的列标题为"活动连接"
  • self.connection_tree.bind("<Double-1>", self.open_connection_tab):绑定双击事件,用于新建或打开连接的标签页
  • self.connection_tree.bind("<Button-1>", self.open_connected_tab):绑定单击事件,用于激活已存在的标签页
  • self.notebook = ttk.Notebook(main_frame):创建右侧的标签页控件,用于显示不同连接的详细信息和交互界面

这种设计模式采用了典型的"主从界面"布局,左侧是连接列表(主),右侧是具体连接的操作区域(从)。用户可以通过双击连接列表中的项目来打开新的连接标签页,或者单击已打开的连接来快速切换到对应的标签页,提供了良好的用户体验。

base_socket.py

class BaseSocket:
    """所有 Socket 类型的基类,提供通用功能框架。"""
    def __init__(self, ip, port, log_callback):
        """
        初始化一个 Socket 实例。

        :param ip: IP 地址
        :param port: 端口号
        :param log_callback: 用于记录日志的回调函数
        """
        self.ip = ip
        self.port = port
        self.log_callback = log_callback
        self.socket = None
        self.running = False
        self.thread = None

    def start(self):
        """启动 socket,在一个新的守护线程中运行_run 方法。"""
        if not self.running:
            self.running = True
            self.thread = threading.Thread(target=self._run)
            self.thread.daemon = True  # 设置为守护线程,主程序退出时线程也退出
            self.thread.start()
            self.log_callback(f"[{self.__class__.__name__}] Started at {self.ip}:{self.port}")

    def stop(self):
        """停止 socket,关闭 socket 并等待线程结束。"""
        if self.running:
            self.running = False
            # 关闭 socket 会导致正在进行的阻塞操作(如 accept, recv)引发异常,从而退出循环
            if self.socket:
                self.socket.close()
            # 等待线程执行完毕
            if self.thread:
                self.thread.join()
            self.log_callback(f"[{self.__class__.__name__}] Stopped.")

    def _run(self):
        """主运行逻辑,由子类实现。例如监听、接收数据等。"""
        raise NotImplementedError("子类必须实现 _run() 方法。")

    def send(self, data):
        """发送数据的方法,由子类实现。"""
        raise NotImplementedError("子类必须实现 send() 方法。")

定义了一个 socket 的基类,采用了面向对象的设计模式,为所有 Socket 类型提供了统一的接口框架。这个基类的设计体现了以下 2 个原则:

  • start() 和 stop() 方法提供了完整的生命周期管理模板
  • _run() 和 send() 方法作为抽象方法,强制子类必须实现具体的业务逻辑

这种设计使得新增 Socket 类型变得非常简单,只需要继承 BaseSocket 并实现 _run() 和 send() 方法即可,大大提高了代码的可维护性和扩展性。

tcp_client.py

class TCPClient(BaseSocket):
    """TCP 客户端实现。"""
    def _run(self):
        """主运行逻辑:连接到服务器并持续接收数据。"""
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            # 尝试连接到指定的服务器
            self.socket.connect((self.ip, self.port))
            self.log_callback(f"[TCPClient] Connected to {self.ip}:{self.port}")

            # 循环接收数据
            while self.running:
                try:
                    # 阻塞等待接收数据
                    data = self.socket.recv(1024)
                    if not data:
                        # 如果接收到空数据,表示服务器已关闭连接
                        self.log_callback("[TCPClient] Connection closed by server.")
                        break
                    self.log_callback(f"[TCPClient] Received: {data.decode('utf-8')}")
                except OSError:
                    # 当 socket 被关闭时,recv 会抛出异常
                    break
        except ConnectionRefusedError:
            self.log_callback(f"[TCPClient] Connection refused by the server.")
        except Exception as e:
            self.log_callback(f"[TCPClient] An error occurred: {e}")
        finally:
            # 确保无论发生什么,最终都会调用 stop 来清理资源
            self.stop()

_run 函数是核心的代码,实现了服务器连接和接收数据功能。这里可以很明显地看出 TCP 的"面向连接"特性,也就是说,在服务器与客户端之间,必须先建立有线连接才能发送、接收数据。这确保了数据一定可以被正确接收到。

  1. 三次握手过程:
  • self.socket.connect((self.ip, self.port)) 这行代码背后实际上是 TCP 的三次握手过程
  • 客户端发送 SYN 包,服务器回应 SYN+ACK 包,客户端再发送 ACK 包
  • 只有在连接建立成功后,才能进行数据传输
  1. 可靠数据传输:
  • data = self.socket.recv(1024) 使用 TCP 流式套接字接收数据
  • TCP 提供了序列号、确认应答、重传机制,确保数据不会丢失、重复或乱序
  • if not data: 检查可以检测到连接的正常关闭(服务器发送 FIN 包)

通过这个 TCP 客户端的实现,学生可以直观地理解 TCP 协议最重要的两个的面向连接特性,以及它与 UDP 无连接协议的本质区别。

tcp_server.py

    def _run(self):
        """主运行逻辑:绑定、监听,并为每个新连接创建一个处理线程。"""
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.socket.bind((self.ip, self.port))
        self.socket.listen(5)
        self.log_callback(f"[TCPServer] Listening for connections on {self.ip}:{self.port}")

        while self.running:
            try:
                # 主线程阻塞在这里,等待新的客户端连接
                client_socket, client_address = self.socket.accept()

                # 如果服务器已停止,则不再接受新连接
                if not self.running:
                    break

                self.log_callback(f"[TCPServer] Accepted connection from {client_address}")

                # 存储新客户端的信息
                self.clients[client_socket] = client_address

                # 创建并启动一个新的守护线程来处理此客户端
                client_thread = threading.Thread(
                    target=self._handle_client,
                    args=(client_socket, client_address)
                )
                client_thread.daemon = True
                client_thread.start()

            except OSError:
                # 当主服务器 socket 被 self.stop() 关闭时,accept 会抛出 OSError
                self.log_callback("[TCPServer] Server socket closed, stopped accepting new connections.")
                break

在 server 的代码中,我们可以看到 TCP 服务器端的完整实现,它展示了 TCP 协议的服务器端特性:

被动监听与主动连接:

  • self.socket.bind((self.ip, self.port)):绑定服务器到指定的 IP 地址和端口
  • self.socket.listen(5):开始监听连接,参数 5 表示最大等待连接队列长度
  • client_socket, client_address = self.socket.accept():被动等待客户端连接,这是 TCP 服务器典型的阻塞式设计。

与 UDP 不同,TCP 服务器需要为每个客户端维护一个独立的连接状态,每个客户端都有自己独立的 client_socket,可以进行独立的数据传输。

这个 TCP 服务器实现很好地展示了面向连接协议的服务器端设计模式,学生可以通过这个例子理解 TCP 服务器如何处理多个并发连接,以及连接状态管理的重要性。

udp_client.py

    def _run(self):
        """
        主运行逻辑:创建 socket 并进入循环,以接收来自服务器的响应。
        """
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.log_callback(f"[UDPClient] Ready. Can send to {self.ip}:{self.port}")

        while self.running:
            try:
                # 阻塞等待接收数据
                data, addr = self.socket.recvfrom(1024)
                self.log_callback(f"[UDPClient] Received from {addr}: {data.decode('utf-8')}")
            except OSError:
                # 当 socket 被关闭时,recvfrom 会抛出异常,安全退出
                break

UDP 客户端的代码量少了很多,因为我们不需要处理"连接"的过程,直接被动接收服务器响应就行了。这很好地体现了 UDP 的"无连接"特性:

  1. 无需建立连接:
  • self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM):直接创建 UDP 套接字,不需要连接过程
  • 与 TCP 不同,UDP 没有三次握手的过程,可以直接发送数据
  • 这种无连接的特性使得 UDP 的通信开销更小,速度更快
  1. 不可靠性:
  • UDP 不保证数据包的到达顺序,也不保证数据包一定能够到达
  • 没有确认应答机制,发送方无法知道数据是否被正确接收
  • 这种不可靠性是 UDP 协议的固有特性,需要在应用层进行补充处理

通过这个 UDP 客户端的实现,学生可以直观地理解 UDP 协议的无连接特性,以及它与 TCP 面向连接协议的本质区别。UDP 的简单性和高效性使其在实时音视频传输、在线游戏等场景中得到广泛应用。

udp_server.py

    def _run(self):
        """主运行逻辑:绑定端口并持续接收 UDP 数据报。"""
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        # 允许地址重用
        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.socket.bind((self.ip, self.port))
        self.log_callback(f"[UDPServer] Listening on {self.ip}:{self.port}")

        while self.running:
            try:
                # 阻塞等待接收数据
                data, addr = self.socket.recvfrom(1024)
                # 记录收到的数据,并保存发送方的地址
                self.log_callback(f"[UDPServer] Received from {addr}: {data.decode('utf-8')}")
                self.last_client_address = addr
            except OSError:
                # 当 socket 被关闭时,recvfrom 会抛出异常
                break

UDP 服务端的代码量也少了很多,同样是减少了建立连接的代码,这充分展示了 UDP 协议的无连接特性和简单性:

无连接的服务模式:

  • self.socket.bind((self.ip, self.port)):只需要绑定端口,不需要监听连接
  • 与 TCP 服务器不同,UDP 服务器不需要 listen() 和 accept() 方法
  • 服务器直接接收来自任何客户端的数据包,无需预先建立连接

通过这个 UDP 服务器的实现,学生可以清楚地看到 UDP 协议的简单性和高效性。UDP 服务器的设计理念是"接收即处理",不需要复杂的连接管理,这使得它在某些特定场景下比 TCP 更具优势。同时,学生也能理解为什么 UDP 被称为"无连接"协议——因为它确实省略了所有与连接相关的复杂性。

UDP 与 TCP 服务器的对比

  • TCP 服务器需要为每个客户端维护连接状态,而 UDP 服务器不需要
  • TCP 服务器需要处理连接的建立和关闭,而 UDP 服务器直接处理数据
  • TCP 服务器通常需要多线程来处理并发连接,而 UDP 服务器单线程即可处理大量并发

程序运行示例

有一点比较关键:UDP Server 向 UDP Client 发送消息,无法被接收,需要等待 UDP Client 向 UDP Server 成功发送一条消息之后,两者才可以互相通信。这在 Gif 演示中也有体现。