首先我们来看下一这个类似局域网QQ群聊的基本功能。登录的时候需要用户输入服务器的IP地址以及自己的用户名,这样你在一个群里面就可以看到每句话都是谁发的。还有一个比较重要的功能就是可以实时的看到在线好友列表,这个在线好友列表的存在大大(其实增加的程序也不是很多)的增加了整个程序的复杂度。如果只是消息的转发,这个太好做了。
一个完整的QQ群聊天室应该是有一个比较好看的GUI,所以本例中的GUI采用了比较好学的wxPython。wxPython是一个比较简单的GUI框架,在这里我给一个比较简单的wxPython的教程资料。同样这里面我也会给出我的源代码。
sokect编程算是python的知识了,这里面单独拿出来说主要是想说说select。一开始我是这样设计的在服务器端给一个连接进来的客户端一个线程去接收客户端发送的消息,但是这样会造成程序在退出的时候很多线程无法退出的问题。然后想了各种各样的办法,但是最后在网上看到一句话顿时让我醒悟————多线程会增加系统的不稳定性。所以我就果断放弃了socket的多线程编程转而使用了select技术。
下面是实现上图的登录界面代码:
#!/usr/bin/env python
# -*- coding:utf-8 -*-
import wx
from ChatFrame import *
class LogInDialog(wx.Dialog):
"""docstring for LogInDialog"""
def __init__(self, parent, ID, title):
super(LogInDialog, self).__init__(
parent, -1, title, wx.DefaultPosition, wx.Size(480, 270))
self.Center()
panel = wx.Panel(self, -1)
# 添加两个label
wx.StaticText(panel, -1, 'ServerIP:', pos=(140, 80))
wx.StaticText(panel, -1, 'Name:', pos=(140, 120))
# 输入IP地址的文本框
self.serverIPText = wx.TextCtrl(
panel, -1, '192.168.1.101:3000', pos=(210, 76), size = (120, 30))
# 输入name的文本框
self.nameEdit = wx.TextCtrl(
panel, -1, 'cyril', pos=(210, 116), size = (120, 30))
# 确认按钮
self.logInBtn = wx.Button(panel, wx.ID_OK, 'Log In', pos=(280, 220))
# 取消按钮
self.cancleBtn = wx.Button(
panel, wx.ID_CANCEL, 'Cancle', pos=(370, 220))
class ClientApp(wx.App):
"""docstring for ClientApp"""
# wxpython 程序启动会首先运行OnInit
def OnInit(self):
logInDlg = LogInDialog(None, -1, 'Log in')
while True:
# 登录窗口显示
result = logInDlg.ShowModal()
# 按下登录按钮
if result == wx.ID_OK:
# 聊天主界面
self.frame = MainFrame(None, -1, logInDlg.nameEdit.Value)
# 判断是否与服务器连接成功,如果成功就显示主界面
if self.frame.connect(logInDlg.serverIPText.Value):
self.SetTopWindow(self.frame)
self.frame.Show()
break
# 退出程序
if result == wx.ID_CANCEL:
break
# 销毁对话框
logInDlg.Destroy()
return True
if __name__ == '__main__':
# wxPython的框架
app = ClientApp(0)
app.MainLoop()
上面的代码都已经注释的差不多了,这里面也没什么可多说的。主要是wxPython的东西,构建了一个如上图的登录的GUI。整个程序会先运行OnInit
函数,在确认按钮的时候做了是否连接成功的检测。这个检测程序是怎么实现的不要急下面会给出具体实现。如果有不明白的地方可以看看前面我给的wxPython的资料。
在看主聊天界面,我们先来看看比较重要的一个类————socket线程。有读者就奇怪了,之前不是说多线程不好采用了select了,怎么现在又冒出一个socket的线程呢?其实是这样的,这个线程是必须做的,为什么呢?因为我们的主线程是GUI线程,如果我们把socket程序放在GUI线程中必将导致GUI线程阻塞的。我们这里面说的不使用多线程是指我们指用一个socket线程,不是我们接收一个线程,发送一个线程。这个在后面服务器端就更加明显了。
#!/usr/bin/env python
# -*- coding:utf-8 -*-
import wx
import fcntl
import struct
import socket
import threading
import select
import random
import sys
#获取本地地址,这里面是在Linux环境下的获取方式,windows下请百度
def get_ip_address(ifname):
skt = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
print skt
pktString = fcntl.ioctl(
skt.fileno(), 0x8915, struct.pack('256s', ifname[:15]))
print pktString
ipString = socket.inet_ntoa(pktString[20:24])
return ipString
#socket线程
class Chat(threading.Thread):
"""docstring for Chat"""
def __init__(self, frame):
super(Chat, self).__init__()
#聊天界面frame
self.frame = frame
#监听连接的tcp客户端,用来发送命令
self.tcpSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
#用于发送聊天内容的udp
self.udpSocket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
#获取本地IP地址,这里面我是使用了无线网wlan0,可以用ifconfig查看
self.localIP = get_ip_address('wlan0')
#产生一个随机数作为udp的端口号
self.udpPort = random.randint(3000, 6000)
#为udp绑定地址
self.udpSocket.bind((self.localIP, self.udpPort))
#select的可读入参数
self.listSocket = [self.tcpSocket, self.udpSocket]
def run(self):
self.sendInfo()
while self.listSocket:
print 'waiting event'
#select函数 返回self.listSocket里面可读的对象
rlist, wlist, elist = select.select(self.listSocket, [], [])
for sock in rlist:
#如果是tcp 表示我们接收到是命令
if sock is self.tcpSocket:
command = sock.recv(1024)
if command:
self.parse(command)
else:
break
else: #udp 则是我们的聊天内容
try:
data, addr = sock.recvfrom(1024)
self.appendMsg(data)
except Exception, e:
break
self.tcpSocket.close()
self.udpSocket.close()
def connect(self, addr):
'''给登录界面判断是否连接成功'''
try:
host, port = addr.split(':')
print host, port
self.serverTcpPort = int(port)
self.serverUdpAddr = (host, self.serverTcpPort + 1)
self.tcpSocket.connect((host, int(port)))
self.tcpSocket.setblocking(False)
print 'connect Server'
return True
except Exception, e:
print "Can't connect Server"
return False
def parse(self, command):
'''用于解析服务器的命令'''
key, value = command.split(':')
if key == 'listName':
listName = value.split(';')
listName.remove('')
self.refreshListView(listName)
def sendInfo(self):
'''向服务器发送客户机的名称'''
self.tcpSocket.sendall(
'connectInfo:' + self.frame.name + ',' + self.localIP + '%'
+ str(self.udpPort))
def sendExit(self):
'''向服务器发送退出消息'''
self.tcpSocket.sendall('exit:' + self.frame.name)
def sendMessage(self, msg):
'''给GUI调用的,表示用户向服务器发送消息'''
self.udpSocket.sendto(msg, self.serverUdpAddr)
def refreshListView(self, listName):
'''刷新在线好友列表,由于是多线程所以是用wx.CallAfter,让GUI线程去执行'''
wx.CallAfter(self.frame.refreshListView, listName)
def appendMsg(self, msg):
'''接收到的消息追加到聊天窗口中'''
wx.CallAfter(self.frame.appendMsg, msg)
同样,上面的代码我已经非常详细的注释了。这里面我再大体说一下思路,每一个客户端和服务器有一个tcp的连接和一个udp的连接。tcp主要负责发送和解析一下命令,比如说客户机的名称和在线好友列表的命令。这里面还要注意一点就是这些命令解析之后需要对GUI做相应的更新。但是有过C#编程经历的读者就会知道,在其他线程里面更新GUI的控件是有问题要用到代理。wxPython是使用wx.CallAfter
,原理是发出一个消息给GUI线程,让GUI线程去执行相应的函数。
主界面其实也是比较简单的,主要是界面的构建。
class MainFrame(wx.Frame):
"""Chat的聊天主界面"""
def __init__(self, parent, ID, title):
super(MainFrame, self).__init__(
parent, -1, title, wx.DefaultPosition, wx.Size(640, 480))
self.Center()
self.name = title
panel = wx.Panel(self, -1)
#界面的创建
#聊天内容文本框
self.chatContext = wx.TextCtrl(
panel, -1, '', size=(500, 360), style=wx.TE_MULTILINE | wx.TE_READONLY)
#发送消息文本框
self.sendText = wx.TextCtrl(
panel, -1, size=(500, 80), style=wx.TE_MULTILINE)
self.sendText.SetPosition((0, 370))
#在线好友列表
self.listClient = wx.ListCtrl(panel, -1, style=wx.LC_REPORT)
self.listClient.SetSize((120, 480))
self.listClient.SetPosition((510, 0))
self.listClient.InsertColumn(0, 'ID')
self.listClient.InsertColumn(1, 'Name')
#按钮
self.sendBtn = wx.Button(panel, -1, 'Send', pos=(300, 450))
self.exitBtn = wx.Button(panel, -1, 'Exit', pos=(410, 450))
#事件绑定
self.Bind(wx.EVT_BUTTON, self.OnSendMessage, self.sendBtn)
self.Bind(wx.EVT_CLOSE, self.OnClose)
def OnSendMessage(self, event):
'''发送消息'''
print 'sendMessage'
self.chat.sendMessage(self.name + ':' + self.sendText.Value)
def connect(self, addr):
'''给登录界面判断是否连接成功'''
self.chat = Chat(self)
if self.chat.connect(addr):
self.chat.start()
return True
else:
return False
def refreshListView(self, listName):
'''刷新在线好友列表'''
self.listClient.DeleteAllItems()
i = 0
for name in listName:
self.listClient.InsertStringItem(i, str(i))
self.listClient.SetStringItem(i, 1, name)
i += 1
def appendMsg(self, msg):
'''追加接收到消息'''
self.chatContext.AppendText(msg + '\n')
def OnClose(self, event):
'''程序退出'''
self.chat.sendExit()
self.chat.listSocket = []
self.chat.tcpSocket.shutdown(socket.SHUT_RDWR)
sys.exit()
上面代码值得注意的一点是,在程序退出的时候也就是OnClose
函数中调用了shutdown函数。主要是让socket线程产生异常,而在socket线程中以及对异常进行了处理。从而解决了线程无法退出的尴尬。
服务器这边的界面就相对比较简单了,所以我们重点来看看它的socket监听线程。这里面所谓的没有使用多线是指服务就只有一个socket线程管理了所以客户端的tcp和udp的连接。
#!/usr/bin/env python
# -*- coding:utf-8 -*-
import wx
import socket
import threading
import select
import fcntl
import struct
import sys
import random
# 获取本地IP
def get_ip_address(ifname):
skt = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
print skt
pktString = fcntl.ioctl(
skt.fileno(), 0x8915, struct.pack('256s', ifname[:15]))
print pktString
ipString = socket.inet_ntoa(pktString[20:24])
return ipString
class ListenClient(threading.Thread):
"""Socket 线程"""
def __init__(self, frame):
threading.Thread.__init__(self)
# 用字典来存储客户端的信息,key:客户端的名字,value:客户端的IP地址
self.clientInfo = {}
# 服务器界面frame
self.frame = frame
def run(self):
'''线程执行的程序'''
self.buildServer()
while self.listSocket:
#有数据可读的时候,select返回(可能描述不是很准确)
rlist, wlist, elist = select.select(self.listSocket, [], [])
if not (rlist or wlist or elist):
print 'time out'
break
for sock in rlist:
#如果是tcpServer表示有新的客户端向我们发送连接请求了
if sock is self.tcpServer:
print 'connecting ...'
try:
client, addr = sock.accept()
print 'connect from', addr
client.setblocking(False)
#将新的客户端添加到listSocket,这样就可以被select检测了
self.listSocket.append(client)
except Exception, e:
print 'exit threading'
break
elif sock is self.udpServer:
#udp 表示用户发送的聊天消息
data, addr = sock.recvfrom(1024)
print 'recvfrom ', data, ' from', addr
#将消息发送到所以客户机上,转发
for key, value in self.clientInfo.items():
sock.sendto(data, value)
else:
#tcpClient 就是客户端发送的命令
command = sock.recv(1024)
print 'command:', command
#当command 为空的时候 表示客户端tcp已经断开连接了
if command:
self.parse(command)
else:
self.listSocket.remove(sock)
print 'out threading'
self.tcpServer.close()
def buildServer(self):
'''服务器端口绑定'''
print 'launch threading'
# tcp服务器建立
self.tcpServer = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.tcpServer.setblocking(False)
self.tcpServer.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
localIP = get_ip_address('wlan0')
serverPort = int(self.frame.portText.Value)
# 服务器地址绑定
self.tcpServer.bind((localIP, serverPort))
self.tcpServer.listen(0)
# udp的端口是tcp端口号+1
self.udpPort = serverPort + 1
self.udpServer = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.udpServer.bind((localIP, self.udpPort))
self.udpServer.setblocking(False)
# select 输入可读参数,意思是当这个列表里面任何一个元素可读,select就有返回值
self.listSocket = [self.tcpServer, self.udpServer]
def sendListName(self):
'''向客户端发送在线好友列表'''
listName = 'listName:'
for name in self.clientInfo:
listName += name + ';'
print listName
count = len(self.listSocket)
for i in range(2, count):
self.listSocket[i].sendall(listName)
def refreshListView(self):
'''更新在线好友列表'''
wx.CallAfter(self.frame.refreshListView, self.clientInfo)
def parse(self, command):
'''解析命令'''
key, value = command.split(':')
if key == 'connectInfo':
name, addr = value.split(',')
print name, addr
host, port = addr.split('%')
self.clientInfo[name] = (host, int(port))
print self.clientInfo
self.sendListName()
self.refreshListView()
if key == 'exit':
del self.clientInfo[value]
self.sendListName()
self.refreshListView()
这部分代码是服务器的主要代码,socket线程来管理所以与服务器相连的客户端同时还在不停的监听是否用新客户端连接进来。其中原理同客户端那边大体类似就是多了一个服务器tcp监听,同样是有select来监听的。这些命令都是自定义的,大部分都是用过key:value这样的形式的。所以在解析的时候只要分割一下字符串就能解析出来了。
服务器这边界面还是相对比较简单的。
class MainFrame(wx.Frame):
"""docstring for MainFrame"""
def __init__(self, parent, ID, title):
super(MainFrame, self).__init__(
parent, ID, title, wx.DefaultPosition, size=(380, 600))
self.listenThread = None
# 界面构建
self.Center()
panel = wx.Panel(self, -1)
wx.StaticText(panel, -1, 'port:', pos=(16, 20))
self.portText = wx.TextCtrl(panel, -1, '3000', pos=(60, 16))
self.openServerBtn = wx.Button(panel, -1, 'Open', pos=(160, 16))
self.listCtrl = wx.ListCtrl(panel, -1, style=wx.LC_REPORT)
self.listCtrl.SetPosition((16, 80))
self.listCtrl.SetSize((300, 400))
self.listCtrl.InsertColumn(0, 'ID')
self.listCtrl.InsertColumn(1, 'Name')
self.listCtrl.SetItemCount(100)
# 事件绑定
self.Bind(wx.EVT_CLOSE, self.OnClose)
self.Bind(wx.EVT_BUTTON, self.OnOpen, self.openServerBtn)
def OnOpen(self, event):
'''打开或关闭服务器'''
if self.openServerBtn.Label == 'Open':
self.openServerBtn.SetLabel('Close')
# 启动socket线程
self.listenThread = ListenClient(self)
self.listenThread.start()
else:
self.openServerBtn.SetLabel('Open')
# 退出socket线程
# 退出while循环
self.listenThread.listSocket = []
#触发异常退出socket线程
self.listenThread.tcpServer.shutdown(socket.SHUT_RDWR)
self.listenThread = None
def OnClose(self, event):
'''程序退出'''
if self.listenThread:
self.listenThread.listSocket = []
self.listenThread.tcpServer.shutdown(socket.SHUT_RDWR)
sys.exit()
def refreshListView(self, list):
'''刷新好友列表'''
self.listCtrl.DeleteAllItems()
i = 0
for name in list:
self.listCtrl.InsertStringItem(i, str(i))
self.listCtrl.SetStringItem(i, 1, name)
i += 1
class ServerApp(wx.App):
"""wxPython的App"""
def OnInit(self):
frame = MainFrame(None, -1, 'Server')
frame.Show()
return True
if __name__ == '__main__':
app = ServerApp(0)
app.MainLoop()