-
Flask 教程 第二十一章:用户通知
这是Flask Mega-Tutorial系列的第二十一章,我将添加一个私有消息功能,它将在导航栏中显示用户通知,而且无需刷新页面就可以自动更新。
在本章中,我想继续致力于改进Microblog应用程序的用户体验。 有一个广泛应用的功能是向用户显示警报或通知。 社交应用通常会通过在顶部导航栏中显示带有数字的小徽章显示这些通知来让你知道有新的提及(@)或私有消息。 虽然这是最明显的用法,但通知模式还可以应用于许多其他类型的应用程序,以通知用户需要注意的事项。
为了向你展示构建用户通知所涉及的技术,我需要扩展Microblog。因此在本章的第一部分中,我将构建一个用户消息传递系统,它允许任何用户发送私有消息给另一个用户。 这实际上比听起来更简单,通过它,我们可以很好地复习核心的Flask实践,并告诉你Flask到底能在简单,高效和有趣的方面做到什么程度。 一旦消息系统就位,我就会讨论一些方法来实现显示未读消息计数的通知标志。
本章的GitHub链接为:Browse, Zip, Diff.
私有消息
我要实现的私有消息功能非常简单。 当你访问用户的个人主页时,会显示一个可以向该用户发送私有消息链接。 该链接将带你进入一个新的页面,在新页面中,可以在Web表单中发送消息。 要阅读发送给你的消息,页面顶部的导航栏将会有一个新的“消息”链接,它会将你带到与主页或发现页面相似的页面,但不会显示用户动态,它会显示其他用户发送给你的消息。
以下小节介绍了实现此功能所需的各个步骤。
私有消息的数据库支持
第一项任务是扩展数据库以支持私有消息。 这是一个新的Message
模型:
app/models.py:Message模型。
1 class Message(db.Model): 2 id = db.Column(db.Integer, primary_key=True) 3 sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) 4 recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) 5 body = db.Column(db.String(140)) 6 timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) 7 8 def __repr__(self): 9 return '<Message {}>'.format(self.body)
这个模型类与Post
模型相似,唯一的区别是有两个用户外键,一个用于发信人,另一个用于收信人。 User
模型可以获得这两个用户的关系,以及一个新字段,用于指示用户最后一次阅读他们的私有消息的时间:
app/models.py:User模型对私有消息的支持。
1 class User(UserMixin, db.Model): 2 # ... 3 messages_sent = db.relationship('Message', 4 foreign_keys='Message.sender_id', 5 backref='author', lazy='dynamic') 6 messages_received = db.relationship('Message', 7 foreign_keys='Message.recipient_id', 8 backref='recipient', lazy='dynamic') 9 last_message_read_time = db.Column(db.DateTime) 10 11 # ... 12 13 def new_messages(self): 14 last_read_time = self.last_message_read_time or datetime(1900, 1, 1) 15 return Message.query.filter_by(recipient=self).filter( 16 Message.timestamp > last_read_time).count()
这两个关系将返回给定用户发送和接收的消息,并且在关系的Message
一侧将添加author
和recipient
回调引用。 我之所以使用author
回调而不是更适合的sender
,是因为通过使用author
,我可以使用我用于用户动态的相同逻辑渲染这些消息。 last_message_read_time
字段将存储用户最后一次访问消息页面的时间,并将用于确定是否有比此字段更新时间戳的未读消息。 new_messages()
辅助方法实际上使用这个字段来返回用户有多少条未读消息。 在本章的最后,我将把这个数字作为页面顶部导航栏中的一个漂亮的徽章。
完成了数据库更改后,现在是时候生成新的迁移并使用它升级数据库了:
1 (venv) $ flask db migrate -m "private messages" 2 (venv) $ flask db upgrade
发送一条私有消息
下一步设计发送消息。我需要一个简单的Web表单来接收消息:
app/main/forms.py:私有消息表单类。
1 class MessageForm(FlaskForm): 2 message = TextAreaField(_l('Message'), validators=[ 3 DataRequired(), Length(min=0, max=140)]) 4 submit = SubmitField(_l('Submit'))
而且我还需要在网页上呈现此表单的HTML模板:
app/templates/send_message.html:发送私有消息HTML模板。
1 {% extends "base.html" %} 2 {% import 'bootstrap/wtf.html' as wtf %} 3 4 {% block app_content %} 5 <h1>{{ _('Send Message to %(recipient)s', recipient=recipient) }}</h1> 6 <div class="row"> 7 <div class="col-md-4"> 8 {{ wtf.quick_form(form) }} 9 </div> 10 </div> 11 {% endblock %}
接下来,我将添加一个新的/send_message/路由来处理实际发送的私有消息:
app/main/routes.py:发送私有消息的视图函数。
1 from app.main.forms import MessageForm 2 from app.models import Message 3 4 # ... 5 6 @bp.route('/send_message/<recipient>', methods=['GET', 'POST']) 7 @login_required 8 def send_message(recipient): 9 user = User.query.filter_by(username=recipient).first_or_404() 10 form = MessageForm() 11 if form.validate_on_submit(): 12 msg = Message(author=current_user, recipient=user, 13 body=form.message.data) 14 db.session.add(msg) 15 db.session.commit() 16 flash(_('Your message has been sent.')) 17 return redirect(url_for('main.user', username=recipient)) 18 return render_template('send_message.html', title=_('Send Message'), 19 form=form, recipient=recipient)
这个视图函数中的逻辑显而易见。 发送私有消息的操作只需在数据库中添加一个新的“消息”实例即可。
将所有内容联系在一起的最后一项更改是在用户个人主页中添加上述路由的链接:
app/templates/user.html:个人主页中添加发送私有消息的链接。
1 {% if user != current_user %} 2 <p> 3 <a href="{{ url_for('main.send_message', 4 recipient=user.username) }}"> 5 {{ _('Send private message') }} 6 </a> 7 </p> 8 {% endif %}
查看私有消息
这个功能的第二大部分是查看私有信息。 为此,我添加另一条路由/messages,该路由与主页和发现页面非常相似,包括分页的完全支持:
app/main/routes.py:查看消息视图函数。
1 @bp.route('/messages') 2 @login_required 3 def messages(): 4 current_user.last_message_read_time = datetime.utcnow() 5 db.session.commit() 6 page = request.args.get('page', 1, type=int) 7 messages = current_user.messages_received.order_by( 8 Message.timestamp.desc()).paginate( 9 page, current_app.config['POSTS_PER_PAGE'], False) 10 next_url = url_for('main.messages', page=messages.next_num) \ 11 if messages.has_next else None 12 prev_url = url_for('main.messages', page=messages.prev_num) \ 13 if messages.has_prev else None 14 return render_template('messages.html', messages=messages.items, 15 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模板。
1 {% extends "base.html" %} 2 3 {% block app_content %} 4 <h1>{{ _('Messages') }}</h1> 5 {% for post in messages %} 6 {% include '_post.html' %} 7 {% endfor %} 8 <nav aria-label="..."> 9 <ul class="pager"> 10 <li class="previous{% if not prev_url %} disabled{% endif %}"> 11 <a href="{{ prev_url or '#' }}"> 12 <span aria-hidden="true">←</span> {{ _('Newer messages') }} 13 </a> 14 </li> 15 <li class="next{% if not next_url %} disabled{% endif %}"> 16 <a href="{{ next_url or '#' }}"> 17 {{ _('Older messages') }} <span aria-hidden="true">→</span> 18 </a> 19 </li> 20 </ul> 21 </nav> 22 {% endblock %}
在这里,我采取了另一个小技巧。 我注意到除了Message
具有额外的recipient
关系(我不需要在消息页面中显示,因为它总是当前用户),Post
和Message
实例具有几乎相同的结构。 所以我决定复用app/templates/_post.html子模板来渲染私有消息。 出于这个原因,这个模板使用了奇怪的for循环for post in messages
,以便私有消息的渲染也可以套用到子模板上。
要让用户访问新的视图函数,导航页面需要生成一个新的“消息”链接:
app/templates/base.html:导航栏中的消息链接。
1 {% if current_user.is_anonymous %} 2 ... 3 {% else %} 4 <li> 5 <a href="{{ url_for('main.messages') }}"> 6 {{ _('Messages') }} 7 </a> 8 </li> 9 ... 10 {% endif %}
该功能现已完成,但作为所有更改的一部分,还有一些新的文本被添加到几个位置,并且需要将这些文本合并到语言翻译中。 第一步是更新所有的语言目录:
(venv) $ flask translate update
然后,app/translations中的每种语言都需要使用新翻译更新其messages.po文件。 你可以在本项目的GitHub代码库中找到西班牙语翻译,或者直接下载zip文件。
静态消息通知徽章
现在私有消息功能已经实现,但是还没有通过任何渠道告诉用户有私有消息等待阅读。导航栏上的未读消息标志的最简单实现可以使用Bootstrap badge小部件渲染到基础模板中:
app/templates/base.html:导航栏的静态消息通知徽章。
1 ... 2 <li> 3 <a href="{{ url_for('main.messages') }}"> 4 {{ _('Messages') }} 5 {% set new_messages = current_user.new_messages() %} 6 {% if new_messages %} 7 <span class="badge">{{ new_messages }}</span> 8 {% endif %} 9 </a> 10 </li> 11 ...
在这里,我直接从模板中调用上面添加到User模型中的new_messages()
方法,并将该数字存储在new_messages
模板变量中。 然后,如果该变量不为零,我只需添加带有该数字的徽章到消息链接后面即可。 以下是这个页面的外观:
动态消息通知徽章
上一节介绍的解决方案是一种简单的常规方式来显示通知,但它有一个缺点,即徽章仅在加载新页面时刷新。 如果用户花费很长时间阅读一个页面上的内容而没有点击任何链接,那么在该时间内出现的新消息将不会显示,直到用户最终点击链接并加载新页面。
为了让这个应用程序对我的用户更有用,我希望徽章自行更新未读消息的数量,而用户不必点击链接并加载新页面。 上一节的解决方案的一个问题是,当加载页面时消息计数为非零时,徽章才在页面中渲染。 更方便的是始终在导航栏中包含徽章,并在消息计数为零时将其标记为隐藏。 这样可以很容易地使用JavaScript显示徽章:
app/templates/base.html:使用JavaScript渲染的友好未读消息徽章。
1 <li> 2 <a href="{{ url_for('main.messages') }}"> 3 {{ _('Messages') }} 4 {% set new_messages = current_user.new_messages() %} 5 <span id="message_count" class="badge" 6 style="visibility: {% if new_messages %}visible 7 {% else %}hidden {% endif %};"> 8 {{ new_messages }} 9 </span> 10 </a> 11 </li>
使用此版本的徽章时,我总是将其包含在内,但当new_messages
非零时,visibility
CSS属性设置为visible
;否则设置为hidden
。 我还为表示徽章的元素添加了一个id
属性,以便使用$('#message_count')
jQuery选择器来简化这个元素的选取。
接下来,我编写一个简短的JavaScript函数,将该徽章更新为最新的数字:
app/templates/base.html:导航栏中的动态消息通知徽章
1 ... 2 {% block scripts %} 3 <script> 4 // ... 5 function set_message_count(n) { 6 $('#message_count').text(n); 7 $('#message_count').css('visibility', n ? 'visible' : 'hidden'); 8 } 9 </script> 10 {% 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:通知模型。
1 import json 2 from time import time 3 4 # ... 5 6 class User(UserMixin, db.Model): 7 # ... 8 notifications = db.relationship('Notification', backref='user', 9 lazy='dynamic') 10 11 # ... 12 13 class Notification(db.Model): 14 id = db.Column(db.Integer, primary_key=True) 15 name = db.Column(db.String(128), index=True) 16 user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 17 timestamp = db.Column(db.Float, index=True, default=time) 18 payload_json = db.Column(db.Text) 19 20 def get_data(self): 21 return json.loads(str(self.payload_json))
通知将会有一个名称,一个关联的用户,一个Unix时间戳和一个有效载荷。 时间戳默认从time.time()
函数中获取。 每种类型的通知都会有所不同,所以我将它写为JSON字符串,因为这样可以编写列表,字典或单个值(如数字或字符串)。 为了方便,我添加了get_data()
方法,以便调用者不必操心JSON的反序列化。
这些更改需要包含在新的数据库迁移中:
1 (venv) $ flask db migrate -m "notifications" 2 (venv) $ flask db upgrade
为了方便,我将新增的Message
和Notification
模型添加到shell上下文,这样我就可以直接在用flask shell
命令启动的解释器中使用这两个模型了。
microblog.py: 添加Message和Notification模型到shell上下文。
1 # ... 2 from app.models import User, Post, Notification, Message 3 4 # ... 5 6 @app.shell_context_processor 7 def make_shell_context(): 8 return {'db': db, 'User': User, 'Post': Post, 'Message': Message 9 'Notification