摘要

记载了「通往全栈之路上」的一则edx慕课作业:用flask_socketio实现一个粗糙的聊天室。
【honor code警告】如果你刚巧也注册了这门课,千万不要抄。

注意

如无法显示视频,可能被作为不安全脚本屏蔽。在浏览器地址栏里点击安全提示图标,允许运行不安全的脚本。

成品效果视频 @ 优酷:

这是哈佛继续教育学院开的的用Python和Javascript撸网络编程 第三个作业项目。

作业要求

做一个仿Slack概念的聊天应用。这次不需要用数据库了,但得

要实现以下功能:

  1. 能自定义用户名(不能和已有的重复)
  2. 能创建频道
  3. 能看频道列表
  4. 进频道能看到所有消息,但最多显示100条
  5. 能在频道中发送消息
  6. 能记住频道,下次回来能立即进去
  7. 延伸功能:比如删除自己的消息,上传附件等

开工

准备

  • 先要有Python(装了Anaconda),然后要装FlaskFlask-Socketio包(pip)。
  • 在环境变量里定义一个"SECRET_KEY”,否则flask_socketio会出错。

项目结构

源代码托管于Github

戳这里看源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
project2
|-- application.py
|-- flask.log
|--+ static
|  |--+ css
|  |  `-- style.css
|  `--+ js
|     |-- main.js
|     `-- chat.js
`--+ templates
   |-- _base.html
   |-- channel.html
   `-- channels.html

结构不复杂:

  • application.py是后端,所有后台功能代码都写在上面。也可以把自定义函数另外写在一个 .py里,import进来。
    • 记得export “application.py” 到环境变量FLASK_APP,便于后面直接运行flask run。在Windows cmd里,用set,PowerShell里用$Env:FLASK_APP=...
  • static文件夹放静态文件,css和js。
  • templates文件夹放各类html模板(html+jinja2语法写的宏)。

基础模板

_base.html是框架模板,后续其他页面模板都会套用(extend)它。

  • 样式主要靠bootstrap
  • body部分放了几个通用块(block),head, flash, disp, control, misc。用jinja2结构{% block xxx %}{% endblock %}来占位。
    • 块里面基本都没有进一步定义。只是给导航条加了点功能,如果当前线程有用户登着,就显示个注销按钮,否则就没有。
    • flash块比较特别,定义了一个比较通用的flash渲染宏,到时候只需要在后台的 .py文件里套用flash函数就能实现告警框。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<!-- templates/_base.html -->
<!DOCTYPE html>
<html lang='en'>
    <head>
        <meta CharacterSet='UTF-8'>
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{% block title %}{% endblock %}</title>
        <link rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css">
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css">
        <link rel="stylesheet" href="https://cdn.staticfile.org/font-awesome/4.7.0/css/font-awesome.css">
        <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
        <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
        <script src="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
        <script src="{{ url_for('static', filename='js/main.js') }}"></script>
        {% block script %}{% endblock %}
    </head>
    <body>
        <!-- head -->
        <nav id="navbar" class="navbar navbar-default" role="navigation">
            <div class="container-fluid">
                <div class="navbar-header">
                    <a class="navbar-brand mb-0" href="#">Chat</a>
                </div>
                {% if act_user is not none %}
                <form class="navbar-form navbar-right" action="{{ url_for('logout') }}" method="get">
                    <span>Welcome, {{ act_user }}.&nbsp;&nbsp;</span>
                    <button id="logout" class="btn btn-default btn-sm">Log out</button>
                </form>
                {% endif %}
            </div>
        </nav>
        <!-- flash -->
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                <div class="alert alert-{{ category }} alert-dismissable">
                    <button type="button" class="close" data-dismiss="alert">
                      &times;
                    </button>
                    {{ message }}
                </div>
                {% endfor %}
            {% endif %}
        {% endwith %}
        <!-- body -->
        <div class="container" id="elem_cont">
            {% block control %}
            {% endblock %}            
        </div>
        <div class="container" id="elem_disp">
            {% block disp %}
            {% endblock %}            
        </div>
        <div class="container" id="elem_misc">
            {% block misc %}
            {% endblock %}            
        </div>
        <!-- footer -->
        <nav class="navbar navbar-fixed-bottom" id="nav_footer" role="navigation">
            <div class="container" id="elem_foot">
                <footer class="navbar" id="footer">
                    <hr>
                    {% block botm_cont %}
                    {% endblock %}
                    <p>&copy; 2019 madlogos</p>
                </footer>
            </div>
        </nav>
    </body>
</html>

对应地,在application.py里定义一些基本代码,包括导入必要的包、设定好全局对象app和socketio。另外,再定义users和channels两个全局字典对象,用来将聊天相关信息存在服务端备用。由于不配置数据库,这两个全局字典就成了整个应用的存储底层。服务一关,所有数据就烟消云散了。

user是个比较扁平的字典,存用户名和最后一次访问的频道:

1
2
3
4
5
6
{
  '<user 1>': '<last visited channel of user 1>',
  '<user 2>': '<last visited channel of user 2>', 
  ...,
  '<user n>': '<last visited channel of user n>'
}

channels是比较复杂的嵌套字典,每个频道都绑一个字典,包含’created’、‘max_id’和双层字典’chats’:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  '<channel 1>': 
    {
      'created': '<creation time of channel 1>',
      'max_id': <max id of chats>,
      'chats': 
        {
          <id 1>: 
              {'user': '<post 1 user>', 'time': '<post 1 time>', 
               'msg': '<post 1 msg>'
              },
          <id 2>: 
              {'user': '<post 2 user>', 'time': '<post 2 time>', 
               'msg': '<post 2 msg>'
              },
          ...,
          <id n>: 
              {'user': '<post n user>', 'time': '<post n time>', 
               'msg': '<post n msg>'
              }
        }
    },
  ...
}

chats里包含的就是一条条消息,以id为键,包起’user’、‘time’和’msg’。

主路由”/“绑定函数index。假如当前session里有’act_user’,那么调用get_channels()函数(也就是跑去频道列表),否则跳转去login页。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# -*- coding: UTF-8 -*-
# application.py
import os
import datetime
import urllib.parse
from flask import Flask, flash, render_template, redirect, session, request, url_for, jsonify
from flask_socketio import SocketIO, emit
from jinja2 import Markup
import logging


app = Flask(__name__, static_folder="static")
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
app.config['SESSION_TYPE'] = 'filesystem'
socketio = SocketIO(app)


users = dict()
channels = dict()

@app.route("/")
def index():
    if session.get("act_user") is None:
        return redirect(url_for("login"))
    else:
        return get_channels()

最后在__main__里加一点代码,配置日志输出。运行python application.py时,会自动运行这部分。如果继续用flask run,这部分不会自动运行。还会报警告,WebSocket无法启用,用Workzeug跑Flask-SocketIO。这是因为新版的Flask在服务端功能做了简化,不再支持WebSocket。

注意

部署到生产环境时,要记得把app.debug设为False。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# application.py
if __name__ == '__main__':
    app.debug = True
    handler = logging.FileHandler("flask.log", encoding="UTF-8")
    handler.setLevel(logging.DEBUG)
    logging_format = logging.Formatter(
        '%(asctime)s - %(levelname)s - %(filename)s - %(funcName)s - %(lineno)s - %(message)s')
    handler.setFormatter(logging_format)
    app.logger.addHandler(handler)
    socketio.run(app)

登录

图 | 登陆页

一般,功能由html模板和python函数配合完成。由于这次的登录功能很简单,当前线程给自己随便起个用户名就行,所以把channels.html当成事实上的首页,在上面套个悬浮页来实现登录。

图 | 创建用户

channels.html模板代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<!-- templates/channels.html -->
{% extends "_base.html" %}

{% block title %}
Channels
{% endblock %}

{% block control %}
{% if act_user is none %}
<div>
    <button id="addName" class="btn btn-lg btn-primary" data-toggle="modal" data-target="#inputName">
        Enter your display name
    </button>
</div>
{% else %}
<form class="form-inline" action="{{ url_for('set_channels') }}" method="post">
    <div class="form-group">
        <label for="new_channel" class="sr-only">Add a channel</label>
        <input type="text" name="new_channel" class="form-control" placeholder="Create a new channel">
        <label for="add_channel" class="sr-only">Submit</label>
        <button id="add_channel" class="btn btn-md btn-primary">Submit</button>
    </div>
</form>
{% endif %}
{% endblock %}

{% block disp %}
{% if act_user is none %}
<div id="inputName" class="modal fade" role="dialog">
    <div class="modal-dialog">
        <!-- Modal content-->
        <div class="modal-content">
            <form action="{{ url_for('login') }}" method="post">
                <div class="modal-header">
                  <label for="displayName">Your display name</label>
                </div>
                <div class="modal-body">
                    <input type="text" id="displayName" name="displayName"
                     class="form-control validate" placeholder="Your display name">
                </div>
                <div class="modal-footer">
                  <button type="submit" class="btn btn-lg btn-primary">
                    Submit
                  </button>
                </div>
            </form>
        </div>
    </div>
</div>
{% else %}
<table id="last_channels" class="table table-striped table-hover table-responsive" cellspacing="0" width="80%">
    <thead>
        <tr><th class="th-sm">The latest channel you accessed</th></tr>
    </thead>
    <tbody>
        {% if last_visit is not none %}
            <tr><td><a href="channel/{{ last_visit }}">{{ last_visit }}</a></td></tr>
        {% endif %}
    </tbody>
</table>

<table id="tbl_channels" class="table table-striped table-hover table-responsive" cellspacing="0" width="80%">
    <thead>
        <tr>
            <th class="th-sm">Channel</th>
            <th class="th-sm">Created at</th>
        </tr>
    </thead>
    <tbody>
        {% if channels|length > 0 %}
            {% for channel in channels %}
            <tr>
                <td><a href="{{ url_for('get_channel', channel=channel['name']) }}">
                    {{ channel['name'] }}</a></td>
                <td>{{ channel['created'].strftime('%Y-%m-%d %H:%M:%S') }}</td>
            </tr>
            {% endfor %}
        {% endif %}
    </tbody>
</table>
{% endif %}
{% endblock %}

页面根据act_user的状态显示为两套。如果act_user是None,即当前用户没登录,那就显示一颗id为"addName"的按钮,target指向锚点"inputName”。inputName是当前页面上的一个modal对话框,里面包了个表单,套住一个名叫"displayName"的输入框。当用户填完屏显名,点submit按钮提交,触发login路由的POST事件。

如果act_user不是None,即当前用户已经登录,那就显示另一套内容:本user的前次访问频道,和全部频道列表。具体在频道列表里讲。

现在到application.py看看login路由定义了些什么。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# application.py
@app.route("/login", methods=['GET', 'POST'])
def login():
    if request.method == "GET":
        return get_channels()
    elif request.method == "POST":
        usernm = request.form.get("displayName")
        if usernm not in users.keys():
            flash(Markup(
                """<i class='fa fa-2x fa-info-circle'></i>
                New username %s created.""" % (usernm)), 'info')
            users[usernm] = None

        session['act_user'] = usernm
        return(redirect(url_for("get_channels")))

GET方法下,调用get_channels()函数,显示频道列表。如果用户没登陆,直接GET login呢?不要紧,这种情况下channels.html只会展示addName按钮和inputName表单。

而POST方法下(也就是提交了inputName表单后),判断一下displayName里填的名字是否已经在全局对象users里,没有的话就创建一个。完事后重定向到get_channels绑定的路由,也就是频道列表。

flash()函数

上面的Python代码用到了flash(text, type) 函数,它会发送一个flash请求到Flask前端,产生一个Bootstrap风格的告警。type只能是Bootstrap认识的"danger”, “warning”, “success”, “info"这类。

为了让告警显示图标,用到了FontAwesome(_base.html模板里已经引入)。直接把<i class="xxx”> flash到前端,无法解析出图标,需要包一个Markup(),以markup对象的形式传递,前端解析后自动交给fontawesome.js处理。

注销

有登录就有注销。_base.html里注销按钮已经绑定了logout路由,所以只要定义logout路由的后台绑定函数就行了。这里的注销也很简单,登出后清空session中的act_user对象,转跳回登录页。

1
2
3
4
5
6
7
8
# application.py
@app.route("/logout")
def logout():
    session.pop('act_user', None)
    flash(Markup(
        """<i class='fa fa-2x fa-check-square-o'></i>
        You have logged out."""), 'success')
    return redirect(url_for("index"))

频道列表

完成登录后,就进入频道列表,其实就是channels.html换了套内容呈现,包括前次访问的频道和全部频道列表。

图 | 频道列表

看一下后台python代码。分别对channels路由的GET和POST方法定义了两个函数get_channels()set_channels()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# application.py
@app.route("/channels", methods=['GET'])
def get_channels():
    app.logger.info(str(channels))
    last_visit=users.get(session.get("act_user"))
    if last_visit not in channels.keys():
        last_visit = None
    return render_template(
        "channels.html", act_user=session.get("act_user"), 
        channels=[{'name':k, 'created':channels[k]['created']} for k in channels], 
        last_visit=last_visit)


@app.route("/channels", methods=['POST'])
def set_channels():
    new_channel = request.form.get("new_channel").strip()
    if new_channel != "":
        if new_channel in channels.keys():
            flash(Markup(
                """<i class='fa fa-2x fa-warning'></i>
                Channel %s already exists.""" % (new_channel)), 
                'warning')
        else:
            flash(Markup(
                """<i class='fa fa-2x fa-check-square-o'></i>
                The new channel %s has been created.""" % (new_channel)),
                'success')
            channels[new_channel] = {"created": datetime.datetime.now(), 
                "max_id": 0, "chats":{}}
    else:
        flash(Markup(
            """<i class='fa fa-2x fa-warning'></i>
            Channel name cannot be empty."""), 'warning')
    return redirect(url_for("get_channels"))

GET方法下,从全局对象users里取到上次访问的频道名,存入last_visit。假如last_visit不在channels里,last_visit覆写为None。把全局channels的键和created值取出来拼成列表channels,再把这个channels连同last_visit都发送给channels.html模板解析渲染。回顾模板里的代码,如果last_visit或channels为None,对应的表格就只显示表头。

图 | 上次访问的频道

POST方法下,服务器从表单里提取"new_channel”。假如new_channel在全局对象channels里已经存在,就flash甩个错误警报。假如不存在,那就往channels里插入一个新字典(包含’created’、‘max_id’和’msg’字典),新频道就生成了。最后转跳回channels.html,实现刷新。

图 | 创建频道

频道明细

点击频道名,就进到频道明细。其实就是聊天室应用。和常规网络应用相比,它要解决两个特殊问题:

  1. 提交的信息要实时广播给其他所有用户,否则就不能算聊天室。
  2. 不能每次提交信息都刷新一次页面,否则用户就会崩溃,服务器负担也重。

这就需要使用socket和ajax。socket实现进程间通信,不再需要整包整包地收发HttpResponse,并刷新页面来响应。而ajax实现异步数据交换,基于socket交换数据后直接用Javascript在客户端更新网页中的特定内容。速度快,服务器负荷小。

channel.html模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<!-- templates/channel.html -->
{% extends "_base.html" %}

{% block title %}
Channel {{ channel }}
{% endblock %}

{% block script %}
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.11/handlebars.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/1.3.6/socket.io.min.js"></script>

<!-- handlebars template -->
<script id="chatPost" type="text/x-handlebars-template">
    <tr>
        {% raw -%}
        <td width="10%">
            {{#if same_user }}
                <span data-class="post_user" style='color:dodgerblue'>
                  {{ post_user }}
                </span>
            {{ else }}
                <span data-class="post_user" style='color:lightsalmon'>
                  {{ post_user }}
                </span>
            {{/if}}
        </td>
        <td width="20%">
            {{#if same_user }}
                <span data-class="post_time" style='color:dodgerblue'>
                  {{ post_time }}
                </span>
            {{ else}}
                <span data-class="post_time" style='color:lightsalmon'>
                  {{ post_time }}
                </span>
            {{/if}}
        </td>
        <td width="auto">
            {{#if same_user }}
                <span data-class="post_msg" style='color:dodgerblue'>
                  {{ post_msg }}
                </span>
                <button data-id="{{ post_id }}" data-class="del" style="float:right"
                 class="btn btn-sm btn-danger"">Delete</button>
            {{ else }}
                <span data-class="post_msg" style='color:lightsalmon'>
                  {{ post_msg }}
                </span>
            {{/if}}
        </td>
        {%- endraw %}
    </tr>
</script>

<script type="text/javascript">
    var act_user = decodeURI("{{ act_user }}");
    var act_channel = decodeURI("{{ channel }}");
    document.addEventListener('DOMContentLoaded', () => {
        document.querySelector("#msgTbl").innerHTML = format_chats({{ chats|tojson }}, '{{ act_user }}');
    });
</script>
<script type="text/javascript" src="{{ url_for('static', filename='js/chat.js') }}"></script>
{% endblock %}

{% block control %}
{% endblock %}

{% block disp %}
<table id="tbl_chat" class="table table-striped table-hover table-responsive" 
 cellspacing="0" width="80%">
    <caption>You are now in channel [{{ channel }}].</caption>
    <thead>
        <tr>
            <th class="th-sm">User</th>
            <th class="th-sm">Time</th>
            <th class="th-sm">Message</th>
        </tr>
    </thead>
    <tbody id="msgTbl">
    </tbody>
</table>
{% endblock %}

{% block botm_cont %}
<div class="container">
    <div class="form-group" id="inputMsg">
        <label for="msg" class="sr-only">Input your message</label>
        <textarea id="msg" name="msg" class="form-control" placeholder="Input your message" 
         rows="4" width="75%"></textarea>
        <label for="send" class="sr-only">Send</label>
        <button id="send" type="submit" class="btn btn-md btn-primary">
          Send (Shift+Enter)
        </button>
        <a href="/channels">&nbsp;&nbsp;&gt;&gt;&gt;Go back to channel list.</a>
    </div>
</div>
{% endblock %}

在channel.html模板中,script块里引入一大堆JS。比如用来动态生成HTML内容的handlebars.js,和处理socket的socket.io.js。disp块里定义了显示对话的表格"tbl_chat”,其中tbody留空,赋个id="msgTbl”。

handlebars模板有一些特殊的语法规范,比如要转义的部分需要加{% raw -%}…{%- endraw %} 而标签、控制结构用{{#if}}…{{else}}…{{/if}}。它能解析变量,动态合成HTML。上面代码里的handlebars模板主要是根据act_user和发帖人是否为同一人,显示为不同的颜色。

html模板里直接嵌入一段JS监听代码,当加载页面时,提取chats和act_user,填充到tbody(也就是#msgTbl)。就是这段:

1
2
3
4
5
var act_user = decodeURI("{{ act_user }}");
var act_channel = decodeURI("{{ channel }}");
document.addEventListener('DOMContentLoaded', () => {
    document.querySelector("#msgTbl").innerHTML = format_chats({{ chats|tojson }}, '{{ act_user }}');
});

注意

chats用tojson函数处理,把序列化的文本转成json。在Flask模板里,调用函数的形式是对象|方法,而不是传统的函数(参数)形式。

这里定义了两个变量:act_user和act_channel,把当前线程的用户名和当前频道从html模板传到后面引入的javascript里,也就是chat.js

这段JS代码用到了format_chats()函数。这是个自定义函数,从chat.js里加载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/* static/js/chat.js */
// template for chatPost
const template = Handlebars.compile(document.querySelector('#chatPost').innerHTML);

function format_chats(json_data, act_user=act_user){
    console.log(JSON.stringify(json_data));
    /* json_data is a dict */
    var output = '';
    Object.keys(json_data).forEach(function(key) {
        const rslt_date = new Date(json_data[key]['time']);
        const rslt = template({
            'post_id': key,
            'post_user': decodeURI(json_data[key]['user']),
            'post_time': format_date(rslt_date),
            'post_msg': decodeURI(json_data[key]['msg']),
            'same_user': decodeURI(json_data[key]['user']) == act_user});
        output += rslt;
    });
    return output;
};

template对象得先用Handlebars编译一下,绑定handlebars模板对象chatPost的innerHTML,把不同的变量(post_id, post_user…)json喂给template,就能编译产生一个个实例。

format_chats()负责将json数据套入channel.html模板 中的handlebars模板"chatPost"里,解析参数后生成相应的html代码。这个输入参数json结构是固定的,包含post_user、post_time、post_msg,也就是全局对象channels里每个channel中的chats字典。

要点

非常英明地用了decodeURI()encodeURI()函数,发到服务器的数据都先编码,接到服务器数据都先解码,这样用中文时就不会乱码了。

在格式化日期时,用到了另外两个函数format_date()lead_zero()。也定义在chat.js里。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* static/js/chat.js */
function format_date(date){
    const yr = date.getYear() + 1900;
    const mo = date.getMonth() + 1;
    const dt = date.getDate();
    const hr = date.getHours();
    const mi = date.getMinutes();
    const se = date.getSeconds(); 
    const ms = date.getMinutes();
    return yr + "-" + lead_zero(mo) + "-" + lead_zero(dt) + " " +
        lead_zero(hr) + ":" + lead_zero(mi) + ":" + lead_zero(se) +
        "." + lead_zero(ms, 3);
};

function lead_zero(num, digits=2){
    /* put zeros in front the num */
    return (Array(digits).join(0) + num).slice(-digits);
};

不这样处理也可以,Javascript会按默认的格式渲染日期。

回过头再看’channel/'路由的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# application.py
@app.route("/channel/<channel>", methods=['GET'])
def get_channel(channel):
    if session.get('act_user') is None:
        flash(Markup(
            """<i class='fa fa-2x fa-exclamation-circle'></i>
            You are not logged in."""), 'danger')
        return redirect(url_for("index"))

    users[session.get('act_user')] = channel
    return render_template(
        "channel.html", act_user=session.get("act_user"), channel=channel,
        chats=channels[channel]['chats'])

先校验当前用户是否登录。没问题的话就渲染channel.html模板了。服务器从全局对象channels里提取该频道的对话消息chats发给客户端。客户端用format_chats()把chats解析后,套进handlebars模板,再呈现到表格里。

发消息

图 | test1和test2在频道里聊天

当点击send,客户端就发一个"send msg"请求,把json{'user': encodeURI(act_user), 'time': post_time, 'msg': encodeURI(msg), 'channel': encodeURI(act_channel)} “发射”(socket.emit)到服务器,交给flask_socketio处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* static/js/chat.js */
document.addEventListener('DOMContentLoaded', () => {
    /* connect to socket */
    var socket = io.connect(location.protocol + "//" + document.domain + ":" + location.port);

    socket.on('connect', () => {
        document.querySelector("#send").onclick = () => {
            const msg = document.querySelector('#msg').value;
            const post_time = new Date();
            // console.log(msg);
            socket.emit('send msg', {'user': encodeURI(act_user), 'time': post_time, 
                'msg': encodeURI(msg), 'channel': encodeURI(act_channel)});
        };
    });

    socket.on('emit msg', data => {
        console.log('emit msg: ' + JSON.stringify(data));
        if (act_channel == data.channel){
            const posttime = new Date(data.time);
            const content = template(
                {'post_id': data.id, 'post_user': decodeURI(data.user), 
                 'post_time': format_date(posttime), 
                 'post_msg': decodeURI(data.msg), 
                 'same_user': decodeURI(data.user)==act_user});
            document.querySelector("#msgTbl").innerHTML += content;
            document.querySelector("#msg").value = '';
            /* scroll to the page bottom */
            window.scrollTo(0, document.body.scrollHeight);
        };
    });
});

服务器接到这个"send msg"请求后,怎么处理呢?看application.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# application.py
@socketio.on("send msg")
def emit_msg(data):
    # if msg is blank, do not emit
    if data['msg'] != '':
        channel = urllib.parse.unquote(data['channel'])
        chats = channels[channel]['chats']
        id = channels[channel]['max_id']
        chats[str(id)] = {'user': data['user'], 'time': data['time'], 
            'msg': data['msg']}
        channels[channel]['max_id'] += 1
        if len(chats) > 100:
            chats = chats[(len(chats)-100):]
        channels[channel]['chats'] = chats

        emit('emit msg', 
             {'id': id, 'user': data['user'], 'time': data['time'], 
              'msg': data['msg'], 'channel': channel}, broadcast=True)

假如"send msg"请求数据不为空,那就各种解析:解析出channel、chats(只保留最后100条),打包成{'id': id, 'user': data['user'], 'time': data['time'], 'msg': data['msg'], 'channel': channel}这样格式的json,发射(emit)回客户端。这里设broadcast=True,其他聊天室的客户端也都会通过广播机制接到这些数据,并通过JS实现页面内更新。

而JS中,socket.on('emit msg')部分的代码会将从服务器收到的广播数据套进handlebars模板里解析,再拼合成HTML填充到’#msgTbl’里,同时清空输入文本框,自动定位到页面底部。

要点

这里,加了一个判断。只有act_channel和从前端收到的data[‘channel’]相同,才渲染handlebars模板,更新页面。不加这条判断的话,就会发生灾难性“串台”现象,任何其他频道的新增消息,都会被广播到其他频道里。

图 | 不同频道不会'串台'

为了方便输入,设置为Shift+Enter发送消息。这需要一段键盘事件监听代码,只要msg文本框里出现shift+enter,就阻断默认动作,触发send的点击事件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* static/js/chat.js */
window.onload = function(){
    const msgArea = document.getElementById("msg");
    msgArea.addEventListener('keypress', evt => {
        if (evt.keyCode === 13 && evt.shiftKey) {
            evt.preventDefault()
            document.querySelector('#send').click();
        };
    });
};

删除消息

由于定义了same_user变量,因此handlebars在拼装时,会根据消息作者是否与当前用户相同,在对应的消息后加删除按钮。这就避免了误删。

图 | 只能删除自己发的消息

删除自己的消息是通过另一端监听代码实现的,原理很简单,定位target的父元素,调用remove()方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/* static/js/chat.js */
document.addEventListener("click", evt => {
    var socket = io.connect(location.protocol + "//" + document.domain + ":" + location.port);
    const tgt = evt.target;
    if (tgt.dataset.class === 'del'){
        const elem = tgt.parentElement.parentElement;
        elem.remove();
        socket.emit("del msg", {'channel': encodeURI(act_channel), 
                                'id': tgt.dataset.id});
    };
});

代码先通过parentElement定位到删除按钮所对应的这条消息,从页面中删除。同时,socket.emit一个字典{'channel': xxx, 'id': xxx}给服务器,告诉它要删除的消息是哪个频道、id是多少。

服务器端收到"del msg"请求后,收到的data就是个长度为2的字典。这样处理:

1
2
3
4
5
6
7
# application.py
@socketio.on("del msg")
def del_msg(data):
    channel = urllib.parse.unquote(data['channel'])
    chats = channels[channel]['chats']
    app.logger.info(str(chats) + "\nDel: " + str(data))
    channels[channel]['chats'].pop(data['id'])

先解码channel(因为发过来前先进行了encodeURI),直接从channels对象的当前频道字典里,把键等于data[‘id’]的字典整个pop掉。

其他

flash自动消失需要专门适配一些javascript。在main.js里,用jQuery实现。

1
2
3
4
5
6
7
8
9
/* static/js/main.js */
$(document).ready(function () {
    /* alert-dismissable dismiss automatically in 3s */
    window.setTimeout(function() {
        $(".alert-dismissable").fadeTo(1000, 0).slideUp(1000, function(){
            $(this).remove(); 
        });
    }, 3000);  
});

这就实现了这则应用的主要功能。

[完]


扫码关注

扫码关注我的的我的公众号