最近在Flask Web Development作者博客看到第二版Flask Mega-Tutorial已在2017年底更新,现翻译给大家参考,希望帮助大家学习flask。
这是Flask Mega-Tutorial系列的第二十一章,我将添加一个私人消息功能,以及在导航栏中显示的用户通知,而无需刷新页面。
供您参考,以下是本系列文章的列表。
- 第1章:Hello, World!
- 第2章:模板
- 第3章:Web表单
- 第4章:数据库
- 第5章:用户登录
- 第6章:配置文件页面和头像
- 第7章:错误处理
- 第8章:关注与被关注
- 第9章:分页
- 第10章:电子邮件支持
- 第11章:整容
- 第12章:日期和时间
- 第13章:I18n和L10n
- 第14章:Ajax
- 第15章:大型应用程序结构
- 第16章:全文搜索
- 第17章:在Linux上部署
- 第18章:在Heroku上部署
- 第19章:Docker容器上的部署
- 第20章:一些JavaScript Magic
- 第21章:用户通知(本文)
- 第22章:后台工作
- 第23章:应用程序编程接口(API)
注意1:如果您正在寻找本教程的旧版本,请在此处。
注意2:如果您想在此博客上支持我的工作,或者只是没有耐心等待每周的文章,我将提供完整的本教程版本,打包成电子书或视频集。欲了解更多信息,请访问courses.miguelgrinberg.com。
在本章中,我想继续致力于改善我的Microblog应用的用户体验。有一个广泛应用的功能是向用户显示警报或通知。 社交应用通常会通过在顶部导航栏中显示带有数字的小徽章显示这些通知来让您知道有新的提及(@)或私有消息。 虽然这是最明显的用法,但通知模式还可以应用于许多其他类型的应用程序,以通知用户需要注意的事情。
为了向您展示构建用户通知所涉及的技术,我需要扩展Microblog。因此在本章的第一部分中,我将构建一个用户消息传递系统,它允许任何用户发送私有消息给另一个用户。 这实际上比听起来更简单,通过它,我们可以很好地复习核心的Flask实践,并告诉您Flask到底能在简单、高效和有趣的方面做到什么程度。 一旦消息系统就位,我就会讨论一些方法来实现显示未读消息计数的通知标志。
私人信息
我将要实现的私人消息功能将非常简单。当您访问用户的个人主页时,会显示一个可以向该用户发送私有消息链接。该链接会将您带到一个新页面,在该页面中,Web表单将接收消息。要阅读发送给您的消息,页面顶部的导航栏将具有一个新的“消息”链接,它会将您带到与主页或发现页面相似的页面,而不是显示博客文章,它将显示其他用户向您发送的消息。
以下各节描述了我为实现此功能而采取的步骤。
对私人消息的数据库支持
第一项任务是扩展数据库以支持私人消息。这是一个新Message
模型:
app / models.py:Message
模型。
class Message(db.Model):
id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey('user.id'))
recipient_id = db.Column(db.Integer, db.ForeignKey('user.id'))
body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
def __repr__(self):
return '<Message {}>'.format(self.body)
该模型类与Post
模型相似,唯一的区别是有两个用户外键,一个用于发送人,一个用于接收人。该User
模型可以获取这两个用户的关系,以及一个新字段,该字段指示用户最后一次阅读其私人消息的时间:
app / models.py:User模型中的私人消息支持。
class User(UserMixin, db.Model):
# ...
messages_sent = db.relationship('Message',
foreign_keys='Message.sender_id',
backref='author', lazy='dynamic')
messages_received = db.relationship('Message',
foreign_keys='Message.recipient_id',
backref='recipient', lazy='dynamic')
last_message_read_time = db.Column(db.DateTime)
# ...
def new_messages(self):
last_read_time = self.last_message_read_time or datetime(1900, 1, 1)
return Message.query.filter_by(recipient=self).filter(
Message.timestamp > last_read_time).count()
这两个关系将返回给定用户发送和接收的消息,并且在关系的Message
一侧将添加author
和recipient
回调引用。 我之所以使用author
回调而不是更适合的sender
,是因为通过使用author
,我可以使用我用于用户动态的相同逻辑渲染这些消息。 last_message_read_time
字段将存储用户最后一次访问消息页面的时间,并将用于确定是否有比此字段更新时间戳的未读消息。 new_messages()
辅助方法实际上使用这个字段来返回用户有多少条未读消息。 在本章结束时,我将在页面顶部的导航栏中将此数字用作漂亮的徽章。
这样就完成了数据库更改,现在是时候生成新的迁移并使用它来升级数据库了:
(venv) $ flask db migrate -m "private messages"
(venv) $ flask db upgrade
发送私信
接下来,我将设计发送消息。我将需要一个简单的Web表单来接受消息:
app / main / forms.py:私人消息表单类。
class MessageForm(FlaskForm):
message = TextAreaField(_l('Message'), validators=[
DataRequired(), Length(min=0, max=140)])
submit = SubmitField(_l('Submit'))
我还需要在网页上渲染此表单的HTML模板:
app / templates / send_message.html:发送私人消息HTML模板。
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>{{ _('Send Message to %(recipient)s', recipient=recipient) }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %}
接下来,我将添加一个新的/send_message/<recipient>路由来处理私人消息的实际发送:
app / main / routes.py:发送私人消息路由。
from app.main.forms import MessageForm
from app.models import Message
# ...
@bp.route('/send_message/<recipient>', methods=['GET', 'POST'])
@login_required
def send_message(recipient):
user = User.query.filter_by(username=recipient).first_or_404()
form = MessageForm()
if form.validate_on_submit():
msg = Message(author=current_user, recipient=user,
body=form.message.data)
db.session.add(msg)
db.session.commit()
flash(_('Your message has been sent.'))
return redirect(url_for('main.user', username=recipient))
return render_template('send_message.html', title=_('Send Message'),
form=form, recipient=recipient)
我认为该视图函数中的逻辑应该是不言而喻的。通过向Message
数据库添加新实例,可以简单地执行发送私人消息的操作。
将所有内容联系在一起的最后一项更改是在用户个人主页中添加了指向上述路由的链接:
app / templates / user.html:在用户个人主页中发送私人消息链接。
{% if user != current_user %}
<p>
<a href="{{ url_for('main.send_message',
recipient=user.username) }}">
{{ _('Send private message') }}
</a>
</p>
{% endif %}
查看私人留言
此功能的第二大部分是查看私人消息。为此,我将在/messages中添加另一条路由,该路由的工作方式与索引和浏览页面的方式非常相似,包括对分页的全面支持:
app / main / routes.py:查看消息路由。
@bp.route('/messages')
@login_required
def messages():
current_user.last_message_read_time = datetime.utcnow()
db.session.commit()
page = request.args.get('page', 1, type=int)
messages = current_user.messages_received.order_by(
Message.timestamp.desc()).paginate(
page, current_app.config['POSTS_PER_PAGE'], False)
next_url = url_for('main.messages', page=messages.next_num) \
if messages.has_next else None
prev_url = url_for('main.messages', page=messages.prev_num) \
if messages.has_prev else None
return render_template('messages.html', messages=messages.items,
next_url=next_url, prev_url=prev_url)
我在此视图功能中要做的第一件事是使用当前时间更新User.last_message_read_time
字段。。 这会将发送给该用户的所有消息标记为已读。 然后,我查询消息模型以获得消息列表,并按照最近的时间戳进行排序。我决定在这里复用POSTS_PER_PAGE
配置项,因为带有帖子和消息的页面看起来非常相似,但是如果页面有所不同,则为消息添加单独的配置变量可能是有意义的。分页逻辑与我在帖子中使用的逻辑相同,因此您应该都熟悉。
上面的视图函数通过渲染一个新的/app/templates/messages.html模板文件结束,您可以在下面看到:
app / templates / messages.html:查看消息HTML模板。
{% extends "base.html" %}
{% block app_content %}
<h1>{{ _('Messages') }}</h1>
{% for post in messages %}
{% include '_post.html' %}
{% endfor %}
<nav aria-label="...">
<ul class="pager">
<li class="previous{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}">
<span aria-hidden="true">←</span> {{ _('Newer messages') }}
</a>
</li>
<li class="next{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}">
{{ _('Older messages') }} <span aria-hidden="true">→</span>
</a>
</li>
</ul>
</nav>
{% endblock %}
在这里,我采取了另一个小技巧。 我注意到除了Message
具有额外的recipient
关系(我不需要在消息页面中显示,因为它总是当前用户),Post
和Message
实例具有几乎相同的结构。 所以我决定复用app/templates/_post.html子模板来渲染私有消息。 出于这个原因,这个模板使用了奇怪的for循环for post in messages
,以便私有消息的渲染也可以套用到子模板上。
为了使用户能够访问新的视图函数,导航页面将获得一个新的“消息”链接:
app / templates / base.html:导航栏中的“消息”链接。
{% if current_user.is_anonymous %}
...
{% else %}
<li>
<a href="{{ url_for('main.messages') }}">
{{ _('Messages') }}
</a>
</li>
...
{% endif %}
该功能现已完成,但是作为所有这些更改的一部分,在一些地方添加了一些新文本,这些文本需要合并到语言翻译中。第一步是更新所有语言目录:
(venv) $ flask translate update
然后,app/translations中的每种语言都需要使用新的翻译来更新其messages.po文件。您可以在GitHub代码库中找到此项目的西班牙语翻译,也可以在下载zip文件中找到。
静态消息通知徽章
现在已经实现了私人消息功能,但是还没有通过任何渠道告诉用户有私有消息等待阅读。导航栏上的未读消息标志的最简单实现可以使用Bootstrap badge小部件渲染到基础模板中:
app / templates / base.html:导航栏中的静态消息通知标志。
...
<li>
<a href="{{ url_for('main.messages') }}">
{{ _('Messages') }}
{% set new_messages = current_user.new_messages() %}
{% if new_messages %}
<span class="badge">{{ new_messages }}</span>
{% endif %}
</a>
</li>
...
在这里,我直接从模板调用上面添加到User
模型的new_messages()
方法,并将该数字存储在new_messages
模板变量中。然后,如果该变量为非零值,我只需在“消息”链接旁边添加带有数字的徽章。这是页面上的外观:
动态消息通知徽章
上一节介绍的解决方案是显示通知的一种不错且简单的方法,但是它的缺点是只有在加载新页面时才显示徽章。如果用户花费大量时间在一页上阅读内容而不单击任何链接,则直到用户最终单击链接并加载新页面后,这段时间内出现的新消息才会显示。
为了使该应用程序对我的用户更有用,我希望徽标可以自己更新未读邮件的数量,而无需用户单击链接并加载新页面。上一节解决方案的一个问题是,仅当加载页面时的消息计数为非零时,才会将徽章渲染给页面。真正更方便的是始终将徽标包含在导航栏中,并在消息计数为零时将其标记为隐藏。 这样可以很容易地使用JavaScript显示徽章:
app / templates / base.html:一个JavaScript友好的未读邮件标志。
<li>
<a href="{{ url_for('main.messages') }}">
{{ _('Messages') }}
{% set new_messages = current_user.new_messages() %}
<span id="message_count" class="badge"
style="visibility: {% if new_messages %}visible
{% else %}hidden {% endif %};">
{{ new_messages }}
</span>
</a>
</li>
在此版本的徽章中,我总是将其包括在内,但当new_messages
非零时,CSS属性visibility
设置为visible
;否则设置为hidden
。 我还为表示徽章的元素添加了一个id
属性,以便使用 jQuery选择器$('#message_count')
来简化这个元素的选取。接下来,我可以编写一个简短的JavaScript函数,以将该标志更新为新的数字:
app / templates / base.html:导航栏中的静态消息通知标志。
...
{% block scripts %}
<script>
// ...
function set_message_count(n) {
$('#message_count').text(n);
$('#message_count').css('visibility', n ? 'visible' : 'hidden');
}
</script>
{% endblock %}
此新set_message_count()
函数将设置徽章元素中的消息数量,并调整可见性,以便当计数为0时隐藏徽章,非零时可见。
向客户端发送通知
现在剩下的是添加一种机制,客户端可以通过该机制定期接收有关用户拥有的未读消息数量的更新。当发生这些更新时,客户端将调用set_message_count()
函数以使用户知道该更新。
实际上有两种方法可以让服务器将这些更新告知客户端,而且您可能会猜到,这两种方法各有优点和缺点,因此选择哪种方法很大程度上取决于项目。在第一种方法中,客户端通过发送异步请求来定期向服务器请求更新。来自此请求的响应是更新列表,客户端可以使用该列表来更新页面的不同元素,例如未读消息计数标记。第二种方法要求客户端和服务器之间的特殊连接类型,以允许服务器将数据自由地推送到客户端。请注意,无论采用哪种方法,我都希望将通知视为通用实体,以便我可以扩展此框架以支持除未读消息标志之外的其他类型的事件。
第一个解决方案最大的优点是易于实现。我需要做的就是向应用程序添加另一条路由,例如/notifications,它返回JSON格式的通知列表。然后客户端应用遍历通知列表,并对每个通知页面进行必要的更改。该解决方案的缺点是,在实际事件与其通知之间将存在延迟,因为客户端将定期请求通知列表。例如,如果客户端每10秒请求一次通知,则通知最多可以延迟10秒收到。
第二种解决方案需要在协议级别上进行更改,因为HTTP没有服务器主动向客户端发送数据的任何规定。到目前为止,实现服务器推送消息的最常见方法是扩展服务器以支持除HTTP之外的WebSocket连接。WebSocket是与HTTP不同的协议,可在服务器和客户端之间建立永久连接。服务器和客户端都可以随时将数据发送给另一方,而无需另一方的要求。这种机制的优势在于,只要发生客户端感兴趣的事件,服务器就可以毫无延迟地发送通知。缺点是WebSocket需要比HTTP更复杂的设置,因为服务器需要与每个客户端保持永久连接。想象一下,例如,一个具有四个worker进程的服务器通常可以为数百个HTTP客户端提供服务,因为HTTP中的连接是短暂的,并且不断被回收。同一台服务器只能处理四个WebSocket客户端,在绝大多数情况下,这将是不够的。出于此限制,WebSocket应用通常围绕异步服务器进行设计,因为这种服务器在管理大量worker和活动连接方面效率更高。
好消息是,无论使用哪种方法,在客户端您都会有一个回调函数,它将被更新列表调用。因此,我可以从第一个解决方案开始,该解决方案易于实现,然后,如果发现不足,请迁移到WebSocket服务器,该服务器可以配置为调用相同的客户端回调。我认为,对于这种类型的应用,第一个解决方案实际上是可以接受的。基于WebSocket的实现对于需要以近零延迟传递更新的应用程序很有用。
这里有一些业界的类似案例。Twitter也使用的是第一种导航栏通知的方法;Facebook使用称为长轮询的HTTP变体,它解决了直接轮询的一些限制,同时仍然使用HTTP请求;Stack Overflow和Trello这两个站点使用WebSocket来实现通知机制。您可以通过查看浏览器调试器的“Network”选项卡来查找任何网站上发生的后台活动请求。
因此,让我们继续实施轮询解决方案。首先,我将添加一个新模型来跟踪所有用户的通知以及用户模型中的关系。
app / models.py:通知模型。
import json
from time import time
# ...
class User(UserMixin, db.Model):
# ...
notifications = db.relationship('Notification', backref='user',
lazy='dynamic')
# ...
class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), index=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
timestamp = db.Column(db.Float, index=True, default=time)
payload_json = db.Column(db.Text)
def get_data(self):
return json.loads(str(self.payload_json))
通知将会有一个名称,一个关联的用户,一个Unix时间戳和一个有效载荷。 时间戳默认从time.time()
函数中获取。 每种类型的通知都会有所不同,所以我将它写为JSON字符串,因为这样可以编写列表,字典或单个值(如数字或字符串)。 为了方便,我添加了get_data()
方法,以便调用者不必操心JSON的反序列化。
这些更改需要包含在新的数据库迁移中:
(venv) $ flask db migrate -m "notifications"
(venv) $ flask db upgrade
为了方便起见,我将把新的Message
和Notification
模型添加到shell上下文中,以便当我使用flask shell
命令启动shell时,会自动为我导入model类:
microblog.py:将Message模型添加到shell上下文中。
# ...
from app.models import User, Post, Notification, Message
# ...
@app.shell_context_processor
def make_shell_context():
return {'db': db, 'User': User, 'Post': Post, 'Message': Message,
'Notification': Notification}
我还将用户模型中添加一个辅助方法add_notification()
在,以更轻松地使用这些对象:
app / models.py:Notification模型。
class User(UserMixin, db.Model):
# ...
def add_notification(self, name, data):
self.notifications.filter_by(name=name).delete()
n = Notification(name=name, payload_json=json.dumps(data), user=self)
db.session.add(n)
return n
此方法不仅为用户添加通知给数据库,还确保如果具有相同名称的通知已存在,则会首先删除该通知。 我将要使用的通知将被称为unread_message_count
。 如果数据库已经有一个带有这个名称的通知,例如值为3,则当用户收到新消息并且消息计数变为4时,我就会替换旧的通知。
在任何未读消息数改变的地方,我需要调用add_notification()
,以便我更新用户的通知,这样的地方有两处。 首先,在send_message()
视图函数中,当用户收到一个新的私有消息时:
app / main / routes.py:更新用户通知。
@bp.route('/send_message/<recipient>', methods=['GET', 'POST'])
@login_required
def send_message(recipient):
# ...
if form.validate_on_submit():
# ...
user.add_notification('unread_message_count', user.new_messages())
db.session.commit()
# ...
第二个地方是用户转到消息页面时,未读计数需要归零:
app / main / routes.py:查看消息路由。
@bp.route('/messages')
@login_required
def messages():
current_user.last_message_read_time = datetime.utcnow()
current_user.add_notification('unread_message_count', 0)
db.session.commit()
# ...
现在,所有针对用户的通知都保留在数据库中,我可以添加一条新路由,客户端可以使用该新路由来检索已登录用户的通知:
app / main / routes.py:通知视图函数。
@bp.route('/messages')
@login_required
def messages():
current_user.last_message_read_time = datetime.utcnow()
current_user.add_notification('unread_message_count', 0)
db.session.commit()
# ...
这是一个相当简单的函数,它返回带有用户通知列表的JSON负载。每个通知都以字典的形式给出,其中包含三个元素,即通知名称、通知有关的其他数据(例如消息计数)和时间戳。通知将按照其创建的顺序(从最旧到最新)进行传递。
我不希望客户重复发送通知,所以我给他们提供了一个选项,只请求给定时间戳之后产生的通知。 since
选项可以作为浮点数包含在请求URL的查询字符串中,其中包含开始时间的unix时间戳。 如果包含此参数,则只有在此时间之后发生的通知才会被返回。
完成此功能的最后一部分是在客户端实现实际轮询。 最好的做法是在基础模板中实现,以便所有页面自动继承该行为:
app / templates / base.html:轮询通知视图函数。
from app.models import Notification
# ...
@bp.route('/notifications')
@login_required
def notifications():
since = request.args.get('since', 0.0, type=float)
notifications = current_user.notifications.filter(
Notification.timestamp > since).order_by(Notification.timestamp.asc())
return jsonify([{
'name': n.name,
'data': n.get_data(),
'timestamp': n.timestamp
} for n in notifications])
此函数包含在有条件的模板中,因为我只想在用户登录时才轮询新消息。对于未登录的用户,将不包括此函数。
您已经在第二十章中看到了jQuery的$(function() { ...})
模式。 这是注册一个函数在页面加载后执行的方式。 对于这个功能,我需要在页面加载时做的是设置一个定时器来获取用户的通知。 您还看到了setTimeout()
JavaScript函数,它在等待特定时间之后运行作为参数给出的函数。 setInterval()
函数使用与setTimeout()
相同的参数,但不是一次性触发定时器,而是定期调用回调函数。 本处,我的间隔设置为10秒(以毫秒为单位),所以我将以每分钟大约六次的频率查看通知是否有更新。
利用定期计时器和Ajax,该函数轮询新通知路由,并在其完成回调中迭代通知列表。 当收到名为unread_message_count
的通知时,通过调用上面定义的函数和通知中给出的计数来调整消息计数徽章。
我处理since
参数的方式可能会令人困惑。 我首先将这个参数初始化为0。 参数总是包含在请求URL中,但是我不能像以前那样使用Flask的url_for()
来生成查询字符串,因为一次请求中url_for()
只在服务器上运行一次,而我需要since
参数动态更新多次。 第一次,这个请求将被发送到 /notifications?since=0 ,但是一旦我收到通知,我就会将since
更新为它的时间戳。 这可以确保我不会收到重复的内容,因为我总是要求收到自我上次看到的通知以来发生的新通知。 同样重要的是要注意,我在interval函数外声明since
变量,因为我不希望它是局部变量,我想要在所有调用中使用相同的变量。
最简单的测试方法是使用两种不同的浏览器A和B。 在两个浏览器上使用不同的用户登录Microblog。 然后从A浏览器向B浏览器上的用户发送一个或多个消息。 B浏览器的导航栏应更新为显示您在10秒钟内发送的消息数量。 而当您点击消息链接时,未读消息数重置为零。
原文链接:https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xxi-user-notifications