整体技术栈
- redis 应用
- flask_socketio 的使用
- websocket 简单应用
应用 redis
我这里使用 redis 来作为后端数据存储工具。大家如果有自己的 redis 服务器当然是最好了,如果没有的话,推荐下在线的 redis 免费应用 redislabs,大家可以自行体验下,https://redislabs.com/
下面连接到 redis 服务器并打开连接池
pool = redis.ConnectionPool(host='redis-12143.c8.us-ea.ec2.cloud.redislabs.com', port=17143, decode_responses=True, password='pkAWNdYWfbLLfNOfxTJinm9SO1') r = redis.Redis(connection_pool=pool)
redis 中数据结构及用法如下:
- chat-{ChatRoomName},聊天室及加入的用户,zset 类型
- msg-{ChatRoomName},每个聊天室对应的消息,zset 类型
当前结构比较简单,暂时只定义了两个域,分别用来存储聊天室和消息。
完善 chat 视图功能
在上一部分中,chat 视图函数仅仅是返回了一个 HTML 页面,并没有任何功能逻辑,现在要完善下。最新的代码如下:
@app.route('/chat', methods=['GET', 'POST']) @login_required def chat(): rname = request.args.get('rname', "") ulist = r.zrange("chat-" + rname, 0, -1) messages = r.zrange("msg-" + rname, 0, -1, withscores=True) msg_list = [] for i in messages: msg_list.append([json.loads(i[0]), time.strftime("%Y/%m/%d %p%H:%M:%S", time.localtime(i[1]))]) return render_template('chat.html', rname=rname, user_list=ulist, msg_list=msg_list)
其中 rname 是其他函数传值过来的,我们后面再说。
r.zrange() 函数就是从 redis 中取出对应聊天室的用户列表和历史聊天记录,最后就是把相关的信息返回到模板中。
创建及加入聊天室
在 chat 视图中,我们传入了一个 rname 字段,这个字段就是当创建或者加入聊天室时,需要传递过来的。
创建聊天室
@app.route('/createroom', methods=["GET", 'POST']) @login_required def create_room(): rname = request.form.get('chatroomname', '') if r.exists("chat-" + rname) is False: r.zadd("chat-" + rname, current_user.username, 1) return redirect(url_for('chat', rname=rname)) else: return redirect(url_for('chat_room_list'))
判断聊天室名称是否存在,如果不存在,则将当前用户在 redis 中创建并跳转至 chat 函数;否则跳转至聊天室列表页面。
加入聊天室
@app.route('/joinroom', methods=["GET", 'POST']) @login_required def join_chat_room(): rname = request.args.get('rname', '') if rname is None: return redirect(url_for('chat_room_list')) r.zadd("chat-" + rname, current_user.username, time.time()) return redirect(url_for('chat', rname=rname))
这里是从前端获取到聊天室名称(rname),并将当前用户名加入到对应的聊天室中。
到这里,redis 中的聊天室就处理完成了,下面再来看看其他的一些辅助功能。
一些辅助功能
一、聊天室列表
既然有加入聊天室的功能,那么就要提供一个列表供用户选择聊天室。
后台逻辑代码:
@app.route('/roomlist', methods=["GET", 'POST']) @login_required def chat_room_list(): roomlist_tmp = r.keys(pattern='chat-*') roomlist = [] for i in roomlist_tmp: i_str = str(i, encoding='utf-8') istr_list = i_str.split('-', 1) roomlist.append(istr_list[1]) return render_template('chatroomlist.html', roomlist=roomlist)
比较简单,到 redis 中拿到所有以“chat-”开头的 key 值,然后处理成列表返回到前端即可。
前台页面代码:
{% extends "bootstrap/base.html" %} {% import "bootstrap/wtf.html" as wtf %} {% block title %}Flasky{% endblock %} {% block navbar %} <div class="navbar navbar-inverse" role="navigation"> <div class="container"> <div class="navbar-header"> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"> <span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> <a class="navbar-brand" href="/">Flasky</a> </div> <div class="navbar-collapse collapse"> <ul class="nav navbar-nav"> <li><a href="/">Home</a></li> </ul> <ul class="nav navbar-nav navbar-right"> {% if current_user.is_authenticated %} <li><a href="{{ url_for('logout') }}">Logout</a></li> {% else %} <li><a href="{{ url_for('login') }}">Login</a></li> {% endif %} </ul> </div> </div> </div> {% endblock %} {% block content %} <div class="container"> <div class="page-header"> <h1>Hello, {{ current_user.username }}!</h1> </div> <div class="page-header"> {% for i in roomlist %} <p>{{ i }} <a href="{{ url_for('join_chat_room', rname=i) }}" class="btn btn-default" role="button">Join This Room</a></p> {% endfor %} </div> <form action="{{ url_for('create_room') }}" method="POST" class="comment-form"> <div class="form-group comment-form-author"> <label for="chatroomname">Chat Room Name <span class="required">*</span></label> <input class="form-control" id="chatroomname" name="chatroomname" type="text" value="" size="30" aria-required='true' /> </div> <div class="form-group comment-form-comment"> <label for="description">Chat Room Description <span class="required">*</span></label> <textarea class="form-control" id="description" name="description" cols="45" rows="6"></textarea> </div> <button name="submit" type="submit" id="submit" class="btn btn-primary" value="Submit Comment">Create Room</button> </form> </div> {% endblock %}
就是循环渲染列表数据,和一个创建聊天室的表单。
二、退出操作
当用户退出登陆时,我们当前也希望该用户同时退出聊天室,所以修改 logout 函数如下:
@app.route('/logout') @login_required def logout(): rname = request.args.get("rname", "") r.zrem("chat-" + rname, current_user.username) logout_user() return redirect(url_for('login'))
从前端拿到聊天室的名字,并在 redis 的对应 zset 中删除当前用户。
三、用户头像
为了聊天室的美观,不同用户需要拥有不同的头像,这里还是使用 gravatar 这个免费的头像服务。
在 User 模型中添加代码:
class User(UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, index=True) password = db.Column(db.String(64)) avatar_hash = db.Column(db.String(32)) def gravatar(self, size=100, default='identicon', rating='g'): if request.is_secure: url = 'https://secure.gravatar.com/avatar' else: url = 'http://www.gravatar.com/avatar' email = self.username + "@hihichat.com" myhash = self.avatar_hash or hashlib.md5(email.encode('utf-8')).hexdigest() return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(url=url, hash=myhash, size=size, default=default, rating=rating) def new_gravatar(self, name, size=100, default='identicon', rating='g'): url = 'http://www.gravatar.com/avatar' email = name + "@hihichat.com" myhash = hashlib.md5(email.encode('utf-8')).hexdigest() return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(url=url, hash=myhash, size=size, default=default, rating=rating)
两个 gravatar 函数,一个是给当前用户使用的,另一个用来处理给定名称的头像生成。这里偷懒了,没有合成一个通用的函数,后面再优化吧。关于 gravatar 头像的具体用法,可以直接查看官网。
消息推送逻辑
下面就开始编写最主要的消息推送逻辑。
我采用的技术是 websocket,这样节省了使用 Ajax 轮询带来的额外开销。而且 flask 框架也有很好的 websocket 相关的扩展库供我们使用,即 flask-sokcetio。
首先安装好 flask_socketio 模块,然后引入并初始化
from flask_socketio import SocketIO, emit socketio = SocketIO() app = Flask(__name__) socketio.init_app(app)
编写一个 socket 发送消息的函数
def socket_send(data, user): emit("response", {"code": '200', "msg": data, "username": user}, broadcast=True, namespace='/testnamespace') socketio.on_event('request_for_response', socket_send, namespace='/testnamespace')
其中 request_for_response,response 和 testnamespace 都需要和前端代码相对应。request_for_response 是用来接收前端传递到后端的消息,response 是后端传递消息到前端时的标识,而 namespace 则类似于作用域的概念,相互传递的消息都仅仅作用在 testnamespace 这个 namespace 中。
前端 JavaScript 代码:
//websocket var websocket_url = 'http://' + document.domain + ':' + location.port + '/testnamespace'; var socket = io.connect(websocket_url); //发送消息到后端 socket.emit('request_for_response',{'param':'{{rname}}'}); //监听回复的消息 socket.on('response',function(data){ var myDate = new Date(); var myTime = myDate.toLocaleString(); var msg = data.msg; var username = data.username; var currentuser = '{{ current_user.username }}'; console.log(currentuser); if ( currentuser == username ) { username = '你'; }; var hash = md5(username + "@hihichat.com"); var htmlData2 = '<div class="msg_item fn-clear">' + ' <div class="uface"><img src="http://www.gravatar.com/avatar/' + hash + '?s=40&d=identicon&r=g" width="40" height="40" alt=""/></div>' + ' <div class="item_right">' + ' <div class="msg">' + msg + '</div>' + ' <div class="name_time">' + username + ' · ' + myTime +'</div>' + ' </div>' + '</div>'; $("#message_box").append(htmlData2); $('#message_box').scrollTop($("#message_box")[0].scrollHeight + 20); });
关于更多的 websocket 用法,大家可以自行查找相关资料,这里就不做过多介绍了。
最后,编写接收聊天内容的 API
@app.route('/api/sendchat/<info>', methods=['GET', 'POST']) @login_required def send_chat(info): rname = request.form.get("rname", "") body = {"username": current_user.username, "msg": info} r.zadd("msg-" + rname, json.dumps(body), time.time()) socket_send(info, current_user.username) return info
将接收到的聊天内容插入到对应的 redis 中(msg-*),然后调用 websocket 函数,广播刚刚收到的消息到所有已经连接的 socket 客户端。
效果图展示
登陆页面:
index 页面:
聊天室列表页面:
聊天室页面:
TODO
聊天室的大体功能已经完成了,但是还有很多不完善的地方,当然,bug 也挺多的,后面再逐步完善。
- 1. 增加聊天机器人
- 2. 支持非登陆用户聊天
- 3. 逻辑优化
- 4. bug 修复
完整代码,会在完善后再提供出来,感谢阅读!