今天是从头开始做一个在线聊天网站系类的第三部分,调整项目结构,增强功能。
第一部分可以看这里(链接)
第二部分可以看这里(链接)
调整项目结构
随着我们项目功能越来越多,把所有的逻辑代码都写在一个文件里已经不太合适了,下面就通过 flask 的工厂模式,把项目代码拆分开。
首先来看下拆分后的项目结构:
main 中主要存放后台逻辑代码。
static 中存放 js,css 以及用到的图片等。
templates 中存放 HTML 模板。
models.py 中是数据库模型。
config.py 中是一些公共的配置信息。
manage.py 中是项目的启动信息。
下面我们分别来看看各个模块对应的代码
具体代码拆分
1. 配置信息
在 config.py 中,填入代码:
import os import redis basedir = os.path.abspath(os.path.dirname(__file__)) class Config: SECRET_KEY = 'hardtohard' SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'chat.sqlite3') @staticmethod def init_app(app): pass class DevelopmentConfig(Config): pass class TestingConfig(Config): pass class ProductionConfig(Config): pass config = { 'development': DevelopmentConfig, 'testing': TestingConfig, 'production': ProductionConfig, 'default': DevelopmentConfig }
2. 使用工厂函数
在 app/__init__.py 中填入代码:
from flask import Flask from flask_login import LoginManager from flask_sqlalchemy import SQLAlchemy from flask_bootstrap import Bootstrap from flask_socketio import SocketIO from config import config login_manager = LoginManager() login_manager.session_protection = 'strong' login_manager.login_view = 'main.login' db = SQLAlchemy() bootstrap = Bootstrap() socketio = SocketIO() def create_app(config_name): app = Flask(__name__) app.config.from_object(config[config_name]) config[config_name].init_app(app) socketio.init_app(app) login_manager.init_app(app) db.init_app(app) bootstrap.init_app(app) # 注册蓝本 from .main import main as main_blueprint app.register_blueprint(main_blueprint) return app
create_app 函数就是程序的工厂函数,它接受一个配置名的参数。
3. 使用蓝本
蓝本和程序类似,也可以定义路由。不同的是,在蓝本中定义的路由处于休眠状态,直到蓝本注册到程序上后,路由才真正成为程序的一部分。
在 main/__init__.py 中创建蓝本
from flask import Blueprint main = Blueprint('main', __name__) from . import views, forms
通过实例化一个 Blueprint 类对象可以创建蓝本。这个构造函数有两个必须指定的参数: 蓝本的名字和蓝本所在的包或模块。和程序一样,大多数情况下第二个参数使用 Python 的 __name__ 变量即可。
4. 修改 view 视图
对于视图函数,需要导入相关的包,同时由于使用了蓝本,原来用来装饰路由的 app.route 都要修改为 main.route,url_for 函数也需要增加 main 作用域,修改后的部分代码如下:
from flask import render_template, redirect, url_for, request from flask_login import login_required, login_user, logout_user, current_user from . import main from .. import db from .forms import LoginForm from ..models import User from config import config import time import json from ..socket_conn import socket_send pool = redis.ConnectionPool(host='redis-12143.c8.us-east-1-3.ec2.cloud.redislabs.com', port=12143, decode_responses=True, password='pkAWNdYWfbLLfNOfxTJinm9SO16eSJFx') r = redis.Redis(connection_pool=pool) @main.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user: login_user(user) return redirect(url_for('main.index')) return render_template('login.html', form=form) @main.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('main.chat', rname=rname)) else: return redirect(url_for('main.chat_room_list'))
5. 编写 socket 连接函数
在 models.py 的同级目录下创建 socket_conn.py 文件,添加代码如下:
from . import socketio from flask_socketio import emit @socketio.on('request_for_response', namespace='/testnamespace') def socket_send(data, user): emit("response", {"code": '200', "msg": data, "username": user}, broadcast=True, namespace='/testnamespace')
该函数供视图函数调用,广播 socket 消息。
6. 完成 forms 和 models
将原来的表单代码和数据库模型代码分别拷贝到这两个文件中
forms.py
from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired from flask_wtf import FlaskForm class LoginForm(FlaskForm): username = StringField('Username', validators=[DataRequired(), ]) password = PasswordField('Password', validators=[DataRequired()]) remember_me = BooleanField('Keep me logged in') submit = SubmitField('Log in')
models.py
from . import db from flask_login import UserMixin from flask import request import hashlib 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, name=None, size=100, default='identicon', rating='g'): if request.is_secure: url = 'https://secure.gravatar.com/avatar' else: url = 'http://www.gravatar.com/avatar' if name is not None: email = name + "@hihichat.com" else: 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)
这里把生成头像的函数整合优化了。
7. 修改模板
把 HTML 模板里的 url_for() 函数都增加 main.,再放置到 templates 下面即可。
8. 启动脚本
顶级文件夹中的 manage.py 文件用于启动程序。
import os from app import create_app, socketio app = create_app(os.getenv('FLASK_CONFIG') or 'default') if __name__ == '__main__': socketio.run(app, debug=True)
还是使用 socketio.run 的方式启动应用。
至此,代码拆分完毕。
功能增强
1. 新增用户
以前我们都是使用浏览器 URL 直接新增用户的,即函数 adduser,现在我们做一个简单的页面,来规范这个操作。
定义表单
class CreateUserForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) password = PasswordField('Password', validators=[DataRequired(), EqualTo('password2', message='Password must match.')]) password2 = PasswordField('Confirm password', validators=[DataRequired()]) submit = SubmitField('Create User') def validate_username(self, field): if User.query.filter_by(username=field.data).first(): raise ValidationError('Username already in use.')
定义了一个函数,来校验用户名是否重复。
修改原来的视图函数 adduser
@main.route('/adduser', methods=['GET', 'POST']) @login_required def adduser(): form = CreateUserForm() if form.validate_on_submit(): user = User(username=form.username.data, password=form.password.data) db.session.add(user) db.session.commit() return redirect(url_for('main.index')) return render_template('adduser.html', form=form)
还要再修改下 User 模型,因为当前保存的是明文密码,修改成使用 hash 存储。
@property def password(self): raise AttributeError('password is not a readable attribute') @password.setter def password(self, password): self.password_hash = generate_password_hash(password) def verify_password(self, password): return check_password_hash(self.password_hash, password)
分别设置密码的只读权限,以及 hash 计算和验证功能。
接下来编写 HTML 模板
{% 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('main.logout') }}">Logout</a></li> {% else %} <li><a href="{{ url_for('main.login') }}">Login</a></li> {% endif %} </ul> </div> </div> </div> {% endblock %} {% block content %} <div class="container"> <div class="page-header"> <h1>Hello, New 一个 User 吧!</h1> </div> {{ wtf.quick_form(form) }} </div> {% endblock %}
至此,一个简单的新增用户功能就好了。当然,我们还可以增加删除用户,重置密码等功能,这些的具体实现,都可以在文末的连接中找到哦,就不再赘述了。
2. 权限控制
我们其实并不希望所有人都能够创建聊天室,那么就要做一个简单的控制功能。
首先定义一个 permission 表,用来存储创建聊天室等权限,再定义一个用户和权限的关联关系表
class Permission(db.Model): id = db.Column(db.Integer, primary_key=True) permission_name = db.Column(db.String(64), unique=True, index=True) class RelUserPermission(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer) permission_id = db.Column(db.Integer)
然后我们还需要一个增加权限的表,以及一个用户列表页面
在 forms.py 中添加
class EditUserForm(FlaskForm): permission = SelectMultipleField('Permission', coerce=int) submit = SubmitField('Submit') def __init__(self, user, *args, **kwargs): super(EditUserForm, self).__init__(*args, **kwargs) self.permission.choices = [(per.id, per.permission_name) for per in Permission.query.order_by(Permission.permission_name).all()] self.user = user
定义了一个初始化函数,会获取到 Permission 表中的 name,id 等信息
接下来编写视图函数
@main.route('/listuser/', methods=['GET', 'POST']) @login_required def listuser(): user_list = User.query.all() return render_template('listuser.html', user_list=user_list) @main.route('/addper/', methods=['GET', 'POST']) @login_required def addper(): form = CreatePerForm() if form.validate_on_submit(): per = Permission(permission_name=form.permissionname.data) db.session.add(per) db.session.commit() return redirect(url_for('main.index')) return render_template('addper.html', form=form) @main.route('/edituser/<int:id>/', methods=['GET', 'POST']) @login_required def edituser(id): user = User.query.filter_by(id=id).first() form = EditUserForm(user=user) if form.validate_on_submit(): for p in form.permission.data: rup = RelUserPermission(user_id=id, permission_id=p) db.session.add(rup) db.session.commit() return redirect(url_for('main.index')) return render_template('edituser.html', form=form)
三个函数,分别是展示用户列表,增加权限,以及为用户添加权限。
然后再修改下 chat_room_list 函数,使得没有权限的用户不能展示创建聊天室的表单。
@main.route('/roomlist/', methods=["GET", 'POST']) @login_required def chat_room_list(): roomlist_tmp = r.keys(pattern='chat-*') roomlist = [] can_create = False create_room_id = Permission.query.filter_by(permission_name='createroom').first().id rel_user_id = RelUserPermission.query.filter_by(user_id=current_user.id).first() rel_permission = RelUserPermission.query.filter_by(user_id=current_user.id).first() if rel_permission and rel_user_id and create_room_id: rel_permission_id = rel_permission.permission_id if rel_permission_id == create_room_id: can_create = True for i in roomlist_tmp: i_str = str(i) istr_list = i_str.split('-', 1) roomlist.append(istr_list[1]) return render_template('chatroomlist.html', roomlist=roomlist, can_create=can_create)
这里主要是判断用户是否拥有 createroom 权限,其实还有一种更加简便,但是稍微有些绕的鉴权方式,可以在文末的链接中找到,大家也可以尝试下。
最后处理 HTML 表单
对于聊天室列表页面:
{% if can_create %} <form action="{{ url_for('main.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> {% endif %}
对于用户列表页面:
{% 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('main.logout') }}">Logout</a></li> {% else %} <li><a href="{{ url_for('main.login') }}">Login</a></li> {% endif %} </ul> </div> </div> </div> {% endblock %} {% block content %} <div class="container"> <div class="page-header"> <h1>Hello, 这里是所有的用户哦!</h1> </div> {% for user in user_list %} <a href="{{ url_for('main.edityouser', id=user.id) }}" class="btn btn-default" role="button">{{ user.username }}</a> {% endfor %} </div> {% endblock %}
这里为了方便起见,当点击用户时,就会跳转至编辑用户权限的页面。
现在,没有权限的用户,就不能看到创建聊天室的表单喽!
当前只增加了创建聊天室的权限,我们同样还可以创建是否有权限加入某个聊天室的权限,大家自己可以先实现下哦。
3.登陆优化
当前的登陆,只要用户名是正确的,不会验证密码,直接登陆成功,现在来处理下密码校验功能。其实也简单,我们在 User 模型中新增了一个函数 verify_password,只要登陆的时候,调用该函数来验证密码即可。
@main.route('/login/', methods=['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user is not None and user.verify_password(form.password.data): login_user(user) return redirect(url_for('main.index')) return render_template('login.html', form=form)
ok,密码错误的你,是没法再登陆了。
4. 放开非登陆也可进入聊天室
- 去掉 chat_room_list,join_chat_room,send_chat 和 chat 视图函数的登陆装饰器 @login_required
- 修改 chat_room_list,判断当前用户是否已经登陆
@main.route('/roomlist/', methods=["GET", 'POST']) def chat_room_list(): roomlist_tmp = r.keys(pattern='chat-*') roomlist = [] can_create = False create_room = Permission.query.filter_by(permission_name='createroom').first() if current_user.is_authenticated: # 判断用户是否登陆 rel_user_id = RelUserPermission.query.filter_by(user_id=current_user.id).first() rel_permission = RelUserPermission.query.filter_by(user_id=current_user.id).first() if rel_permission and rel_user_id and create_room: rel_permission_id = rel_permission.permission_id create_room_id = create_room.id if rel_permission_id == create_room_id: can_create = True for i in roomlist_tmp: i_str = str(i) istr_list = i_str.split('-', 1) roomlist.append(istr_list[1]) return render_template('chatroomlist.html', roomlist=roomlist, can_create=can_create)
- 导航栏增加 room list 入口
<ul class="nav navbar-nav"> <li><a href="/">Home</a></li> <li><a href="{{ url_for('main.chat_room_list') }}">Room List</a></li> </ul>
- chat 视图函数增加判断逻辑
@main.route('/chat/', methods=['GET', 'POST']) 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]))]) if current_user.is_authenticated: return render_template('chat.html', rname=rname, user_list=ulist, msg_list=msg_list) else: email = "youke" + "@hihichat.com" hash = hashlib.md5(email.encode('utf-8')).hexdigest() gravatar_url = 'http://www.gravatar.com/avatar/' + hash + '?s=40&d=identicon&r=g' return render_template('chat.html', rname=rname, user_list=ulist, msg_list=msg_list, g=gravatar_url)
- 修改 send_chat 视图
@main.route('/api/sendchat/<info>', methods=['GET', 'POST']) def send_chat(info): if current_user.is_authenticated: 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 else: return info
当前对于未登陆的用户(游客),直接回复游客发送的消息。
今天的分享就到这里了,在下次的分享中,我们会尝试增加自己训练的聊天机器人到系统中,这样就能让没有登陆的用户,也能愉快的耍起来了。
所有的代码,都已经上传到 GitHub 上了,喜欢的小伙伴还请给个 star 啊,感谢!