从头搭建一个在线聊天室(三)

今天是从头开始做一个在线聊天网站系类的第三部分,调整项目结构,增强功能。

第一部分可以看这里(链接) 第二部分可以看这里(链接

调整项目结构

随着我们项目功能越来越多,把所有的逻辑代码都写在一个文件里已经不太合适了,下面就通过 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. 放开非登陆也可进入聊天室

  1. 去掉 chat_room_list,join_chat_room,send_chat 和 chat 视图函数的登陆装饰器 @login_required
  2. 修改 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)
  1. 导航栏增加 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>
  1. 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)
  1. 修改 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 啊,感谢!

https://github.com/zhouwei713/online-chat

原文发布于微信公众号 - 萝卜大杂烩(zhouluoboluandun)

原文发表时间:2019-06-21

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券