[Python] 多用户,多房间全双工聊天室

需求描述


创建一个多用户,多房间的全双工聊天室。

多用户,多房间的意思是可以有多个聊天室,每个聊天室里可以有多个用户,并且用户可以通过输入房间号进入聊天室。
全双工的意思是聊天室中的用户在接收其他用户的信息的同时,也能发送信息给其他用户。而不用等待一个用户发送完信息,等其他用户接收到之后,才能允许下个用户再次发送信息。

Python I/O多路复用


全双工功能的实现,可以通过多线程,I/O多路复用等方式,我在这边采用了I/O多路复用方案。
Python的select模块提供三种I/O多路复用的具体实现——select,poll,epoll,我在这里选用select.select(下面用select代替)。

select会监听socket或者文件描述符的I/O状态变化,并返回变化的socket或者文件描述符对象

select(rlist, wlist, xlist[, timeout]) -> (rlist, wlist, xlist)

这是Python select方法的原型,接收4个参数
rlist:list类型,监听其中的socket或者文件描述符是否变为可读状态,返回那些可读的socket或者文件描述符组成的list
wlist:list类型,监听其中的socket或者文件描述符是否变为可写状态,返回那些可写的socket或者文件描述符组成的list
xlist:list类型,监听其中的socket或者文件描述符是否出错,返回那些出错的socket或者文件描述符组成的list
timeout:设置select的超时时间,设置为None代表永远不会超时,即阻塞。

注意:Python的select方法在Windows和Linux环境下的表现是不一样的,Windows下它只支持socket对象,不支持文件描述符(file descriptions),而Linux两者都支持。

Linux下,可以通过sys.stdin标准输入流获取用户的输入,而sys.stdin就是一个文件描述符。
所以可以用下面的代码来获取用户输入

rlist, wlist, xlist = select.select( [sys.stdin], [], [] )
print rlist[0].readline()

由于只监听了sys.stdin,当用户输入之后,只会返回sys.stdin对象,可以通过readline方法来获取用户输入的内容。

聊天室服务端


服务端要完成三件事:

  1. 接收多个客户端的连接
  2. 管理用户的聊天室分组
  3. 将一个客户端输入的消息广播到他所在聊天室的所有其他客户端

第一件事,定义一个list类型变量_current_in_list来表示监听多个socket连接的可读事件,利用上面说的select来处理I/O多路复用,代码如下:

rlist, wlist, xlist = select.select(_current_in_list, [], [])

当select返回时,说明rlist上有可读的socket了,这里又有两种情况:
1.如果返回的是service socket(服务器创建的socket,用来监听客户端是否连接的),表示有新的客户端连接了,调用socket.accept()方法获取新的客户端socket对象和地址(ip和port组成的元组),将新的客户端socket加入到_current_in_list。
2.如果返回的是其他socket(客户端socket),表示有客户端发送数据到服务端了,调用socket.recv()方法获取数据。

为了实现用户分组,我规定每个客户端在连接服务器之前都要先输入聊天室的房间号,并且每次发送到服务器的数据都要带上房间号,最后定义了一个dict类型的变量_room用来存储用户和房间的对应关系,客户端传递的房间号就是_room的key,而它的value则是一个客户端socket的列表。数据格式如下:

<RID:111>Welcome to Chat Room</RID:111>

对于接收到的数据,首先通过正则表达式检查是否符合规定,然后提取房间号和用户发送的消息。
判断是否新加入到聊天室的用户,如果是则发送广播通知聊天室的其他用户有新人加入,否则发送用户消息给聊天室的其他用户。关键代码如下

rgx_message = CONFORM_MSG.match(raw_message)
if rgx_message:
    room_id = rgx_message.group(1)
    message = rgx_message.group(2)
    if sock not in _room.setdefault(room_id, []):
        _room[room_id].append(sock)
        broadcast_message(room_id, sock, '\n[%s:%s] entered room.\n'\
                                                 % sock.getpeername())
    else:
        broadcast_message(room_id, sock, \
                             "\n<" +str(sock.getpeername()) + ">" + message)

根据房间号将消息广播给聊天室中除发送用户之外的所有其他用户

def broadcast_message(room_id, sock, message):
    for member in _room[room_id]:
        if member is not sock:
            try:
                member.send(message)
            except socket.error:
                member.close()
                 _current_in_list .remove(member)
                _room[room_id].remove(member)

如果发送报错,可能socket已经被关闭,所以将它从_current_in_list_room中删除,因为socket已经被关闭了,但还保留在_current_in_list中,select会报错。

完整的聊天室服务端代码如下:

import socket
import select
import re

HOST = "localhost"
PORT = 9898
ADDR = (HOST, PORT)
BUFSIZE = 1024

CONFORM_MSG = re.compile(r'^<RID:(\d+)>([\s\S]*?)</RID:\1>')


_service_socket = socket.socket()
_service_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
_service_socket.bind(ADDR)
_service_socket.listen(10)

_current_in_list = [_service_socket]
_room = dict()

def broadcast_message(room_id, sock, message):
    for member in _room[room_id]:
        if member is not sock:
            try:
                member.send(message)
            except socket.error:
                member.close()
                 _current_in_list .remove(member)
                _room[room_id].remove(member)


def main():
    while True:
        rlist, wlist, xlist = select.select(_current_in_list, [], [])

        for sock in rlist:
            if sock is _service_socket:
                client, addr = sock.accept()
                _current_in_list.append(client)
                print "Client (%s:%s) connected." % addr

            else:
                try:
                    raw_message = sock.recv(BUFSIZE)
                    if raw_message:
                        rgx_message = CONFORM_MSG.match(raw_message)
                        if rgx_message:
                            room_id = rgx_message.group(1)
                            message = rgx_message.group(2)
                            if sock not in _room.setdefault(room_id, []):
                                _room[room_id].append(sock)
                                broadcast_message(room_id, sock, '\n[%s:%s] entered room.\n'\
                                                                         % sock.getpeername())
                            else:
                                broadcast_message(room_id, sock, \
                                                "\n<" +str(sock.getpeername()) + ">" + message)
                        else:
                            print "Invalid format message,", raw_message
                except socket.error:
                    print "Client (%s, %s) is offline" % sock.getpeername()
                    sock.close()
                    _current_in_list .remove(member)
                    for room_id, socks in _room.iteritems():
                        for _ in socks:
                            if _ is sock:
                                _room[room_id].remove(_)
                                break
                        else:
                            continue
                        break


if __name__ == '__main__':
    main()

聊天室客户端


客户端也要实现三个功能:

  1. 确定房间号
  2. 根据规定的协议规则组合房间号和消息并发送给服务器
  3. 接收服务器广播的消息

客户端相对服务端的代码逻辑来的简单,房间号直接用raw_input来让用户输入获取。

用到了select I/O多路复用来实现全双工,_current_in_list中加入sys.stdin和socket,一旦用户输入或者socket接到服务器广播的消息,就返回rlist。
遍历rlist,如果是socket就通过socket.recv()接收广播消息,如果是sys.stdin则通过sys.stdin.readline()从标准输入流中获取用户输入的消息。

完整的客户单代码:

import socket
import select
import sys

HOST = "localhost"
PORT = 9898
ADDR = (HOST, PORT)
BUFSIZE = 1024

_current_in_list = [sys.stdin]

def prompt():
    sys.stdout.write('<You> ')
    sys.stdout.flush()

def gen_message(room_id, raw_message):
    return '<RID:{}>{}</RID:{}>'.format(room_id, raw_message, room_id)


def main():
    room_id = raw_input('<Room ID> ')

    client_socket = socket.socket()
    client_socket.settimeout(2)

    try:
        client_socket.connect(ADDR)
        _current_in_list.append(client_socket)

        # notify all room's user that new client is entered
        client_socket.send(gen_message(room_id, ''))
    except socket.error:
        print "Unable to connect"
        sys.exit()

    print 'Connected to remote host. Start sending messages'
    prompt()

    while True:
        rlist, wlist, xlist = select.select(_current_in_list, [], [])
        for sock in rlist:
            if sock is client_socket:
                message = sock.recv(BUFSIZE)
                if not message:
                    print '\nDisconnected from chat server.'
                    sys.exit()
                else:
                    sys.stdout.write(message)
                    prompt()
            else:
                raw_message = sys.stdin.readline()
                client_socket.send(gen_message(room_id, raw_message))
                prompt()


if __name__ == '__main__':
    main()
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,695评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,569评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,130评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,648评论 1 297
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,655评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,268评论 1 309
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,835评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,740评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,286评论 1 318
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,375评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,505评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,185评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,873评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,357评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,466评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,921评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,515评论 2 359

推荐阅读更多精彩内容

  • 大纲 一.Socket简介 二.BSD Socket编程准备 1.地址 2.端口 3.网络字节序 4.半相关与全相...
    VD2012阅读 2,362评论 0 5
  • http://python.jobbole.com/85231/ 关于专业技能写完项目接着写写一名3年工作经验的J...
    燕京博士阅读 7,583评论 1 118
  • 普羅維登斯阅读 230评论 0 0
  • “唰唰”生锈的细小刀片在岳庭手中,刀背将他的手指的红色长痕磨的光滑,当笔铅露出一些时岳庭便停止削铅笔,这截铅笔还没...
    卿诚1阅读 276评论 0 0
  • 《最后一课》是石首市南岳中学高三年级毕业X班班主任郑春明老师撰写并朗诵。曾在首都师大《教育与艺术》刊载...
    袁本立阅读 905评论 0 4