使用 Python 构建聊天室应用程序
套接字编程 + Tkinter 图形用户界面
我最近深入探索了精彩纷呈的计算机网络世界。我开发的一个有趣的项目是一个简单的聊天室应用程序,它能促进不同客户端之间的实时消息传递。
在本教程的任何部分,您都可以参考我在 GitHub 上的源代码。本教程旨在介绍一些基本的网络理论,并提供实际的套接字编程经验。如果您已经掌握了相关的理论知识,请随意跳过这一部分!
先决条件
你需要掌握一些 Python 基础知识。除此之外,无需其他技能!在开发这个应用程序的过程中,你还将学习到计算机网络和客户端-服务器架构的基础知识。
我们将使用 Tkinter 和 sockets,它们都包含在 Python 标准库中。您无需安装任何东西!
客户端-服务器架构
客户端-服务器架构是一种基本的计算模型,其中客户端向服务器请求服务,而服务器处理这些请求并向客户端提供服务。
事实上,你的浏览器现在正作为客户端,通过超文本传输协议 (HTTP) 向 Medium 的网络服务器请求网络服务!Medium 的网络服务器会处理你的请求,并返回 HTML 文档、CSS 样式表、JavaScript 文件和图像,以便你的浏览器能够按预期显示网站内容。
此外,一台服务器可以服务多个客户端!在我们的应用中,我们希望多个客户端能够实时聊天。客户端软件会向聊天室服务器发送消息,聊天室服务器会将我们的消息广播给所有其他已连接的客户端。
协议,协议,协议
网络通信使用我们称之为协议栈的东西——它在更简单、更基本的通信之上构建更高层次、更复杂的通信。网络层可以用 OSI 模型和 TCP/IP 模型来表示。每一层网络都对应一组特定于该层的网络协议。
就本应用而言,我们无需关注许多底层协议。但我们需要知道,我们使用的是传输控制协议(TCP)。
TCP和UDP是传输层协议——它们控制着数据如何从一点发送到另一点。我们基于 TCP构建,这意味着我们无需关心数据如何发送,只需关心发送什么数据以及发送到哪里即可。
TCP 和 UDP 的主要区别在于,TCP 保证可靠传输,不会出现信息丢失、重复或乱序的情况。UDP 则不保证这一点,而是将丢包的处理留给应用层。此外,UDP 还要求服务器确认收到数据。
UDP 通常用于对时间要求较高的传输,在这种情况下,丢包比等待丢失的数据包重传更可取。例如,想象一下实时语音通话。如果某个数据包丢失了,等待该音频片段到达是没有意义的,因为等到它到达时,可能已经过时了。此外,即使没有该音频数据包,你也可能仍然能够将对话内容拼凑起来。
另一方面,TCP 会固执地不断重发丢失的音频片段,即使它早已过时而毫无用处。然而,对于大多数其他应用来说,TCP 的这一特性极其重要。在我们的聊天应用中,我们不希望处理丢包问题,因为我们始终希望收到完整无误的消息。
聊天室服务器
让我们从服务器脚本开始。首先,我们在.Server中定义一个类server.py。
#!/usr/bin/env python3
import threading
import socket
import argparse
import os
class Server(threading.Thread):
def __init__(self, host, port):
super().__init__()
self.connections = []
self.host = host
self.port = port
def run(self):
pass
穿线基础知识
请注意,我们使用多线程来允许多段代码并发运行。我们的Server类继承自 Python 的 ` Thread` 类,因此会创建一个线程。我们需要在 `Thread`方法中threading.Thread定义线程的逻辑。当对对象调用 `Thread` 方法时,线程将与主线程并行执行。Serverrun()start()Serverrun()
套接字基础知识
套接字是 IP 地址和端口号的组合。IP 地址用于标识主机。然而,一台主机可以同时运行多个不同的应用程序。你的操作系统 (OS) 如何知道 HTTP 响应是发往你的浏览器,而不是《使命召唤:现代战争》呢?端口号用于标识应该接收数据的特定应用程序。例如,《使命召唤:现代战争》使用 TCP 端口范围 27014–27050。
因此,两个网络设备之间的任何通信都需要一个套接字对:
Source (IP: Port Number) → Destination (IP: Port Number)
创建套接字
让我们开始定义线程逻辑。将以下内容添加到run()方法中。
def run(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self.host, self.port))
sock.listen(1)
print('Listening at', sock.getsockname())
首先,我们创建了一个socket.socket对象。socket它接受两个参数:地址族和套接字类型。AF_INET地址族用于 IP 网络。SOCK_STREAM套接字类型用于可靠的流控数据流,例如 TCP 提供的流控数据流。另一方面,UDP 需要基于数据包的套接字类型SOCK_DGRAM。
接下来,我们设置该SO_REUSEADDR选项。您可以在 Linux 手册页socket(7)中阅读更多关于套接字选项的信息。此选项允许服务器在旧连接关闭后使用相同的端口(通常情况下,您需要等待几分钟)。
然后,我们使用该函数bind()将socket对象绑定到服务器机器上的套接字地址。该bind()函数接受一个元组作为参数,格式如下:
(IP Address (str), Port Number (int))
请注意,一台机器可以有多个外部 IP 接口:您可以使用ifconfig终端中的命令(或ipconfigWindows 系统下的命令)找到您的 IP 接口。
您应该知道,我得到的第一个结果,也lo0就是接口,非常特殊。这是一个回环接口,只有运行在同一台机器上的其他程序才能访问它。它的 IP 地址是 127.0.0.1,并且仅在您的本地机器上有效。它的主机名为“ localhost ”,提供了一个安全的测试环境。
在bind()命令中,我们可以指定任何 IP 接口(甚至是环回接口!),也可以使用空字符串''作为通配符,表示我们愿意接收通过服务器任何网络接口到达的数据包。在我们的程序中,我们将把这个操作留给用户,稍后您就会看到。
最后,我们使用 `set`listen()来表明这是一个监听套接字。TCP 使用两种类型的套接字:监听套接字和连接套接字。调用listen()`set` 后,该套接字就变成了监听套接字,它只能通过握手来建立TCP 连接,而不能实际传输数据。每当有客户端连接时,我们都需要创建一个全新的套接字才能发送和接收数据。让我们继续:
接受连接
def run(self):
...
while True:
# Accept new connection
sc, sockname = sock.accept()
print('Accepted a new connection from {} to {}'.format(sc.getpeername(), sc.getsockname()))
# Create new thread
server_socket = ServerSocket(sc, sockname, self)
# Start new thread
server_socket.start()
# Add thread to active connections
self.connections.append(server_socket)
print('Ready to receive messages from', sc.getpeername())
我们创建了一个无限循环来监听新的客户端连接。该accept()调用会等待新客户端连接,连接成功后,返回一个新的已连接套接字以及该已连接客户端的套接字地址。
再介绍几个方法:getpeername()返回连接另一端的套接字地址(在本例中为客户端),而getsockname()返回套接字对象绑定的套接字地址。
我们需要一种方法与每个客户端进行通信,但同时,我们还需要监听来自其他潜在客户的新连接。我们通过以下方式实现这一点:ServerSocket每当有新客户端连接时,就创建一个新线程(稍后会定义),该线程与现有Server线程并行运行。服务器还需要一种方法来管理所有活动的客户端连接,因此它将活动连接以ServerSocket对象的形式存储在某个地方self.connections。
'广播'
我们的小型聊天室应用程序的工作原理如下:
-
客户端通过命令行或图形用户界面向服务器发送消息。
-
服务器接收并处理消息。
-
服务器将消息发送给所有其他已连接的客户端。
-
客户端将在命令行或图形用户界面中显示该消息。
让我们把步骤 3 中的“广播”功能添加到Server类中。我谨慎地使用“广播”这个词,因为在计算机网络的语境中,“广播”指的是一个完全不同的概念。我们实际上是在发送许多单播消息,这些消息是一对一地传输给每个连接的客户端。
class Server(threading.Thread):
...
def run(self):
...
def broadcast(self, message, source):
for connection in self.connections:
# Send to all connected clients except the source client
if connection.sockname != source:
connection.send(message)
请注意,这self.connections是一个表示活动客户端连接的对象列表ServerSocket。我们将在ServerSocket下面定义该类。
发送和接收
该ServerSocket课程将有助于与客户进行沟通。
#!/usr/bin/env python3
...
class ServerSocket(threading.Thread):
def __init__(self, sc, sockname, server):
super().__init__()
self.sc = sc
self.sockname = sockname
self.server = server
def run(self):
while True:
message = self.sc.recv(1024).decode('ascii')
if message:
print('{} says {!r}'.format(self.sockname, message))
self.server.broadcast(message, self.sockname)
else:
# Client has closed the socket, exit the thread
print('{} has closed the connection'.format(self.sockname))
self.sc.close()
server.remove_connection(self)
return
def send(self, message):
self.sc.sendall(message.encode('ascii'))
我们再次创建了一个无限循环。这次,我们不再监听新的连接,而是监听客户端发送的数据。
当recv()调用 `get()` 函数时,它会等待数据到达。如果没有数据可用,`get() recv()` 函数不会返回(它会“阻塞”),程序会暂停直到有数据到达。像 `get()`accept()和`get recv()()` 这样会让程序等待直到有新数据到达才能返回的调用,被称为阻塞调用。数据在网络上以字节串的形式发送和接收,因此需要分别使用 `get()` 和 `get()` 函数进行编码和encode()解码decode()。
recv()接受一个参数,bufsize该参数指定一次要接收的最大数据量。
send()另一方面,它将数据从套接字发送到与其连接的对等方。
这两种方法send()都recv()存在一个问题:由于出站或入站缓冲区几乎已满,可能会出现只发送或接收部分数据的情况,因此它会将能够发送的数据放入队列,而将剩余数据搁置。当返回结果时,如果实际上仍有部分数据未发送,就会出现问题。我们可以将其放入循环中,或者使用更简单的方法来表示“我想发送所有数据”。send()send()sendall()
关闭的套接字
你会注意到上面的代码中,我们可以判断客户端是否关闭了连接。当客户端套接字关闭时,它会立即recv()返回一个空字符串,这与到达文件末尾时的行为''类似。read()
因此,当我们看到它recv()返回一个空字符串(在 else 语句中)时,我们close()也断开连接的另一端,ServerSocket从活动连接列表中删除该线程,并结束该线程。
最后润色
#!/usr/bin/env python3
...
def exit(server):
while True:
ipt = input('')
if ipt == 'q':
print('Closing all connections...')
for connection in server.connections:
connection.sc.close()
print('Shutting down the server...')
os._exit(0)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Chatroom Server')
parser.add_argument('host', help='Interface the server listens at')
parser.add_argument('-p', metavar='PORT', type=int, default=1060,
help='TCP port (default 1060)')
args = parser.parse_args()
# Create and start server thread
server = Server(args.host, args.p)
server.start()
exit = threading.Thread(target = exit, args = (server,))
exit.start()
我们允许用户在命令行中指定主机地址和端口号,并可以在命令行中的任意位置输入“q”来终止程序。
现在你可以运行服务器脚本了!
聊天室客户端
如果没有客户端连接,服务器实际上不会执行任何操作。我们来创建一个client.py文件:
#!/usr/bin/env python3
import threading
import socket
import argparse
import os
class Send(threading.Thread):
def __init__(self, sock, name):
super().__init__()
self.sock = sock
self.name = name
def run(self):
while True:
message = input('{}: '.format(self.name))
# Type 'QUIT' to leave the chatroom
if message == 'QUIT':
self.sock.sendall('Server: {} has left the chat.'.format(self.name).encode('ascii'))
break
# Send message to server for broadcasting
else:
self.sock.sendall('{}: {}'.format(self.name, message).encode('ascii'))
print('\nQuitting...')
self.sock.close()
os._exit(0)
class Receive(threading.Thread):
def __init__(self, sock, name):
super().__init__()
self.sock = sock
self.name = name
def run(self):
while True:
message = self.sock.recv(1024)
if message:
print('\r{}\n{}: '.format(message.decode('ascii'), self.name), end = '')
else:
# Server has closed the socket, exit the program
print('\nOh no, we have lost connection to the server!')
print('\nQuitting...')
self.sock.close()
os._exit(0)
在编写服务器脚本的过程中,我希望我已经把大部分理论解释清楚了。这里也遵循同样的规则,只是我们多了一个发送线程,它会一直监听来自命令行的用户输入。我们稍后会添加图形用户界面(GUI),但它的逻辑基本相同。接收到的任何数据都会显示在客户端界面上,而发送的任何数据都会由服务器处理,然后广播给其他连接的客户端。
我们再次运用了多线程技术。这次是为了让发送和接收操作并行运行。这样,我们的聊天室就能实现实时互动(而不是在发送send()和recv()接收操作之间交替进行)。
正在连接服务器
#!/usr/bin/env python3
...
class Client:
def __init__(self, host, port):
self.host = host
self.port = port
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
def start(self):
print('Trying to connect to {}:{}...'.format(self.host, self.port))
self.sock.connect((self.host, self.port))
print('Successfully connected to {}:{}'.format(self.host, self.port))
print()
name = input('Your name: ')
print()
print('Welcome, {}! Getting ready to send and receive messages...'.format(name))
# Create send and receive threads
send = Send(self.sock, name)
receive = Receive(self.sock, name)
# Start send and receive threads
send.start()
receive.start()
self.sock.sendall('Server: {} has joined the chat. Say hi!'.format(name).encode('ascii'))
print("\rAll set! Leave the chatroom anytime by typing 'QUIT'\n")
print('{}: '.format(name), end = '')
客户端调用该connect()方法连接到服务器指定的套接字地址(同样以元组的形式)。如果指定的套接字尚未准备好接收连接(例如,您尚未运行服务器脚本),则调用connect()将失败。
最后润色
#!/usr/bin/env python3
...
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Chatroom Server')
parser.add_argument('host', help='Interface the server listens at')
parser.add_argument('-p', metavar='PORT', type=int, default=1060,
help='TCP port (default 1060)')
args = parser.parse_args()
client = Client(args.host, args.p)
client.start()
测试我们的命令行应用程序
我们构建了一个功能齐全的命令行聊天室。
你可以通过提供机器上的任何 IP 地址接口来运行服务器脚本(也可以使用主机名!)。如果你只打算在一台机器上进行测试,也可以直接使用“127.0.0.1”或“localhost”。
客户端脚本也适用同样的规则。打开一个单独的终端窗口并运行client.py。
打开三个或更多终端窗口,即可同时连接多个客户端!或者,您也可以使用连接到同一局域网的另一台机器运行程序client.py。如果机器位于不同的局域网,您的防火墙可能会阻止其运行。
在上面的截图中,我运行了三个终端窗口,其中两个客户端连接到同一个服务器。
创建图形用户界面
我们还没完全完成。命令行应用程序固然不错,但何不做一个图形用户界面(GUI)呢?我们将使用 Tkinter,它包含在 Python 标准库中。
我们只需要对代码做一些调整client.py,并添加一些代码来创建图形用户界面 (GUI)。您可以阅读 Tkinter 的文档了解更多信息,但这超出了本教程的范围。
完整client.py代码如下:
| #!/usr/bin/env python3 | |
| import threading | |
| import socket | |
| import argparse | |
| import os | |
| import sys | |
| import tkinter as tk | |
| class Send(threading.Thread): | |
| """ | |
| Sending thread listens for user input from the command line. | |
| Attributes: | |
| sock (socket.socket): The connected socket object. | |
| name (str): The username provided by the user. | |
| """ | |
| def __init__(self, sock, name): | |
| super().__init__() | |
| self.sock = sock | |
| self.name = name | |
| def run(self): | |
| """ | |
| Listens for user input from the command line only and sends it to the server. | |
| Typing 'QUIT' will close the connection and exit the application. | |
| """ | |
| while True: | |
| print('{}: '.format(self.name), end='') | |
| sys.stdout.flush() | |
| message = sys.stdin.readline()[:-1] | |
| # Type 'QUIT' to leave the chatroom | |
| if message == 'QUIT': | |
| self.sock.sendall('Server: {} has left the chat.'.format(self.name).encode('ascii')) | |
| break | |
| # Send message to server for broadcasting | |
| else: | |
| self.sock.sendall('{}: {}'.format(self.name, message).encode('ascii')) | |
| print('\nQuitting...') | |
| self.sock.close() | |
| os._exit(0) | |
| class Receive(threading.Thread): | |
| """ | |
| Receiving thread listens for incoming messages from the server. | |
| Attributes: | |
| sock (socket.socket): The connected socket object. | |
| name (str): The username provided by the user. | |
| messages (tk.Listbox): The tk.Listbox object containing all messages displayed on the GUI. | |
| """ | |
| def __init__(self, sock, name): | |
| super().__init__() | |
| self.sock = sock | |
| self.name = name | |
| self.messages = None | |
| def run(self): | |
| """ | |
| Receives data from the server and displays it in the GUI. | |
| Always listens for incoming data until either end has closed the socket. | |
| """ | |
| while True: | |
| message = self.sock.recv(1024).decode('ascii') | |
| if message: | |
| if self.messages: | |
| self.messages.insert(tk.END, message) | |
| print('hi') | |
| print('\r{}\n{}: '.format(message, self.name), end = '') | |
| else: | |
| # Thread has started, but client GUI is not yet ready | |
| print('\r{}\n{}: '.format(message, self.name), end = '') | |
| else: | |
| # Server has closed the socket, exit the program | |
| print('\nOh no, we have lost connection to the server!') | |
| print('\nQuitting...') | |
| self.sock.close() | |
| os._exit(0) | |
| class Client: | |
| """ | |
| Supports management of client-server connections and integration with the GUI. | |
| Attributes: | |
| host (str): The IP address of the server's listening socket. | |
| port (int): The port number of the server's listening socket. | |
| sock (socket.socket): The connected socket object. | |
| name (str): The username of the client. | |
| messages (tk.Listbox): The tk.Listbox object containing all messages displayed on the GUI. | |
| """ | |
| def __init__(self, host, port): | |
| self.host = host | |
| self.port = port | |
| self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| self.name = None | |
| self.messages = None | |
| def start(self): | |
| """ | |
| Establishes the client-server connection. Gathers user input for the username, | |
| creates and starts the Send and Receive threads, and notifies other connected clients. | |
| Returns: | |
| A Receive object representing the receiving thread. | |
| """ | |
| print('Trying to connect to {}:{}...'.format(self.host, self.port)) | |
| self.sock.connect((self.host, self.port)) | |
| print('Successfully connected to {}:{}'.format(self.host, self.port)) | |
| print() | |
| self.name = input('Your name: ') | |
| print() | |
| print('Welcome, {}! Getting ready to send and receive messages...'.format(self.name)) | |
| # Create send and receive threads | |
| send = Send(self.sock, self.name) | |
| receive = Receive(self.sock, self.name) | |
| # Start send and receive threads | |
| send.start() | |
| receive.start() | |
| self.sock.sendall('Server: {} has joined the chat. Say hi!'.format(self.name).encode('ascii')) | |
| print("\rAll set! Leave the chatroom anytime by typing 'QUIT'\n") | |
| print('{}: '.format(self.name), end = '') | |
| return receive | |
| def send(self, text_input): | |
| """ | |
| Sends text_input data from the GUI. This method should be bound to text_input and | |
| any other widgets that activate a similar function e.g. buttons. | |
| Typing 'QUIT' will close the connection and exit the application. | |
| Args: | |
| text_input(tk.Entry): A tk.Entry object meant for user text input. | |
| """ | |
| message = text_input.get() | |
| text_input.delete(0, tk.END) | |
| self.messages.insert(tk.END, '{}: {}'.format(self.name, message)) | |
| # Type 'QUIT' to leave the chatroom | |
| if message == 'QUIT': | |
| self.sock.sendall('Server: {} has left the chat.'.format(self.name).encode('ascii')) | |
| print('\nQuitting...') | |
| self.sock.close() | |
| os._exit(0) | |
| # Send message to server for broadcasting | |
| else: | |
| self.sock.sendall('{}: {}'.format(self.name, message).encode('ascii')) | |
| def main(host, port): | |
| """ | |
| Initializes and runs the GUI application. | |
| Args: | |
| host (str): The IP address of the server's listening socket. | |
| port (int): The port number of the server's listening socket. | |
| """ | |
| client = Client(host, port) | |
| receive = client.start() | |
| window = tk.Tk() | |
| window.title('Chatroom') | |
| frm_messages = tk.Frame(master=window) | |
| scrollbar = tk.Scrollbar(master=frm_messages) | |
| messages = tk.Listbox( | |
| master=frm_messages, | |
| yscrollcommand=scrollbar.set | |
| ) | |
| scrollbar.pack(side=tk.RIGHT, fill=tk.Y, expand=False) | |
| messages.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) | |
| client.messages = messages | |
| receive.messages = messages | |
| frm_messages.grid(row=0, column=0, columnspan=2, sticky="nsew") | |
| frm_entry = tk.Frame(master=window) | |
| text_input = tk.Entry(master=frm_entry) | |
| text_input.pack(fill=tk.BOTH, expand=True) | |
| text_input.bind("<Return>", lambda x: client.send(text_input)) | |
| text_input.insert(0, "Your message here.") | |
| btn_send = tk.Button( | |
| master=window, | |
| text='Send', | |
| command=lambda: client.send(text_input) | |
| ) | |
| frm_entry.grid(row=1, column=0, padx=10, sticky="ew") | |
| btn_send.grid(row=1, column=1, pady=10, sticky="ew") | |
| window.rowconfigure(0, minsize=500, weight=1) | |
| window.rowconfigure(1, minsize=50, weight=0) | |
| window.columnconfigure(0, minsize=500, weight=1) | |
| window.columnconfigure(1, minsize=200, weight=0) | |
| window.mainloop() | |
| if __name__ == '__main__': | |
| parser = argparse.ArgumentParser(description='Chatroom Server') | |
| parser.add_argument('host', help='Interface the server listens at') | |
| parser.add_argument('-p', metavar='PORT', type=int, default=1060, | |
| help='TCP port (default 1060)') | |
| args = parser.parse_args() | |
| main(args.host, args.p) |
完整server.py代码如下,供参考:
最终产品
运行程序server.py,client.py操作步骤与之前相同。输入姓名后,图形用户界面(GUI)就会弹出!
我这样设计是为了让您可以使用命令行或图形用户界面与应用程序交互,以便更容易进行调试并查看幕后发生的事情。
结论
就这些啦!希望您阅读这篇文章的体验和我写作的体验一样愉快。计算机网络的世界真的非常有趣,而这仅仅是冰山一角。
如果您有任何疑问,请随时在评论区留言。
文章来源:https://dev.to/zeyu2001/build-a-chatroom-app-with-python-44fa











