我要实现的私信功能会是很简单的。当你访问一个用户的资料页面,会有一个给该用户发送私信的链接。这个链接会将你带到一个带有接收消息web表单的的新页面。要读取发送给你的邮件,顶部导航栏会有一个新的“Messages”链接,这个链接会将你带到一个结构与index和explore页面相似的页面,但它会显示其他用户发送给你的消息而不是显示博客帖子。
以下几节描述了实现此功能所采取的步骤。
私信的数据库支持
第一个任务是拓展数据库,使其支持私信。这是新的Message模型:
app/models.py
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 ''.format(self.body)
这个模型类与Post模型类似,唯一的不同是这里有两个user外键,一个是发送者,一个是接收者。User模型可以为这两个用户获取关系,新增一个新字段表示用户最后阅读私信是什么时候:
app/models.py
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,是因为这样一来我可以用和用在博客帖子一样的逻辑来渲染这些消息。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模板来在web页面上渲染这个表单:
app/templates/send_message.html
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
{{ _('Send Message to %(recipient)s', recipient=recipient) }}
{{ wtf.quick_form(form) }}
{% endblock %}
接下来我将添加一个新的 /send_message/
app/main/routes.py
from app.main.forms import MessageForm
from app.models import Message
# ...
@bp.route('/send_message/', 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 %}
{% endif %}
查看私信
这个功能的第二大部分是查看私信。为此我将添加了另一个路由 /messages,其工作方式和index和explore页面差不多,包括对分页的完全支持:
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 字段更新为当前时间。这将把所有发送给此用户的消息标记为已读。然后我在Message中查询消息的列表,按照时间戳从新到旧排序。由于帖子和消息页面看起来会差不多,所以我决定在此处重用POSTS_PER_PAGE配置项,但当然,如果页面是不同的,那么为消息页面添加单独的配置变量也是可以的。这个页面的分页逻辑和之前我用在帖子上的相同,所以这一切对你来说都应该是熟悉的。
上面的视图函数以渲染新模板文件 /app/templates/messages.html 结束,下面你能看到:
app/templates/messages.html
{% extends "base.html" %}
{% block app_content %}
{{ _('Messages') }}
{% for post in messages %}
{% include '_post.html' %}
{% endfor %}
{% endblock %}
这里我用了另一个技巧。Post和Message实例的结构很相似,除了Message有个额外的recipient关系(由于其值永远是当前用户,所以我不需要在消息页面中显示它)。所以我决定重用 app/templates/_post.html 子模板来渲染私信页面。由于这个原因,所以这个模板使用了奇怪的for循环 for post in messages,这样字幕版中所有对post的引用也对messages有效。
我在导航栏添加了新链接“Message”,使用户可以访问新的视图函数:
app/templates/base.html
{% if current_user.is_anonymous %}
...
{% else %}
{{ _('Messages') }}
...
{% endif %}
这个功能现在完成了,但是这些更改也带来了一些新的文本,这些内容需要合并到语言翻译中:
(venv) $ flask translate update
现在私信功能已经实现了,但当然,还不能提醒用户是否有未读的消息。用Bootstrap部件可以实现在基模板的导航栏上渲染此消息:
app/templates/base.html
...
{{ _('Messages') }}
{% set new_messages = current_user.new_messages() %}
{% if new_messages %}
{{ new_messages }}
{% endif %}
...
这里,我在模板中直接调用了User模型中的new_message方法,将这个数字存储在模板变量new_messages中。如果这个变量非零,我就将包含此数字的徽章添加到Messages链接旁。这是它在页面上的样子:
上一节中介绍的显示通知的方法很简单,但它的缺点是只有在加载新页面时才会显示该标记。如果用户停留在页面上很长时间阅读内容而不点击任何链接,除非用户点击了一个新链接加载了新页面,否则在此期间出现的新消息都不会显示。
为了使这个应用对用户来说更可用,我想要徽章自己更新维度消息的数量,无需用户点击链接来加载新页面。问题是,在上一节的解决方案中,徽章只有在此页面被载入时新消息数量非零时才会渲染。更方便是在导航栏中始终包含徽章,当新消息计数为零时将其标记为隐藏。使用JavaScript可以很容易的显现徽章:
app/templates/base.html
{{ _('Messages') }}
{% set new_messages = current_user.new_messages() %}
{{ new_messages }}
页面总总是包括徽章,当new_messages为非零时,visibility CSS属性设置为可见,为零时,设置为隐藏。我也在包含徽章的元素中添加了id属性,使jQuery的$('#message_count')选择器可以方便的找到此元素。
接下来,编写一个简短的JavaScript函数来更新此徽章的数字:
app/templates/base.html
...
{% block scripts %}
{% endblock %}
这个新的 set_message_count() 函数会设置徽章元素中的消息数字,并调整其可见性,数量为零时隐藏,否则可见。
现在剩下的是添加一个机制,使客户端接收用户未读消息数量的定期更新。当有更新时,客户端会调用set_message_count() 函数使用户知道有更新了。
事实上有两个方法可以使服务端将更新传递给客户端,你可能已经猜到了,两者都有优缺点,因此选择哪一个很大程度上取决于项目。在第一个方法中,客户端通过发送匿名请求来定期向服务端请求更新。来自此请求的响应是一个更新列表,客户端可以使用它来更新页面的不同元素,如未读消息计数徽章。第二个方法依赖客户端和服务端之前一种特殊类型的连接,其允许服务端自由的向客户端推送数据。请注意,无论采用哪种方法,我都希望将通知视为泛型实体,以便可以拓展此框架,以支持除未读消息消息徽章之外的其他类型的事件。
第一个解决方案易于实现。我要做的只是向应用程序添加一个新路由,比如说 /notifications,其返回一个JSON类型的通知列表。然后,客户端应用程序将遍历通知列表,并对每个页面应用必要的更改。这个解决方案的缺点是在实际事件和通知之间会有延迟,因为客户端将按照固定的时间间隔请求通知列表。比如,如果客户端每10秒请求一次通知,那么一个通知会有最多10秒的延迟。
第二个解决方案要求在协议层级进行更改,因为HTTP没有任何规定,服务端无需客户端的请求就可以向客户端发送数据。到目前位置,除HTTP之外,实现服务端发送消息的最常见方法是拓展服务以支持WebSocket连接。WebSocket是一个与HTTP不同的协议,在服务端和客户端之间建立一个永久的连接。服务端和客户端都可以随时向对方发送数据,而无需对方的请求。这种机制的优点是每当客户端发生有意思的事件,服务端都可以发送通知,而不会出现任何延迟。缺点是WebSocket要求比HTTP还要复杂的设置,因为服务端需要与每个客户端保持永久连接。假设一个服务器有四个工作进程,通常可以服务几百个HTTP客户端,因为HTTP中的连接是短暂的,而且不断被被回收。同样一个服务能够处理四个WebSocket客户端,但在绝大多数情况下,这是不够的。由于这一限制,WebSocket应用通常是围绕着匿名服务设计的,因为这个服务在管理大量工作和活动连接式效率更高。
好消息是不管你用哪种方法,在客户端中,你会有一个用于更新列表的回调函数。因此我可以从第一个解决方式开始,它更容易实现,之后如果我发现它不够好,迁移到到WebSocket服务,它可以配置为调用用样的客户端回调。在我看来,对于这种类型的应用,第一种解决方案实际上是可以接受的。基于WebSocket的实现适用于需要接近零延迟传递更新的应用。
以防你好奇,Twitter也使用第一种方法实现它们的导航栏通知。Facebook使用其的一种变体叫做long polling,它解决了直接轮询的一些限制,同时仍在使用HTTP请求。Stack Overflow和Trello这两个网站用WebSocket实现它们的通知。你可以通过查看浏览器调试器的Newwork选项卡来找到任何站点上的背景活动类型。
因此让我们继续实现轮询方案。首先,我添加一个新的模型来追踪所有用户的通知,以及User模型中的关系。
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时间戳和一条数据。timestamp字段从time.time()函数获取默认值。每种通知的payload都不同,因此我将其些微JSON字符串格式,允许我写入列表,字典或者单个值如数字或字符串。我还添加了get_data()方法,因而调用者我虚担心JSON反序列化。
这些更改需要被包含在新的数据库迁移中:
(venv) $ flask db migrate -m "notifications"
(venv) $ flask db upgrade
接下来我将新增的Message和Notification模型添加到shell context中,当我以flask shell命令启动shell时,模型类会被自动导入:
microblog.py
# ...
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}
我也将在User模型中添加一个名为add_notification()的辅助方法,以使其便于使用这些对象:
app/models.py
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/', 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()
# ...
# ...
我需要通知用户的第二个地方是当用户转到messages页面时,此时的未读计数归零:
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
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])
这个函数相当简单,它返回一个带有用户通知列表的JSON数据。每条通知都是有三个元素的字典,通知名称、与通知有关的附加数据(如消息计数)和时间戳。通知按其创建时间顺序排序,从久到进。
我不想客户端收到重复的通知,所以我给了他们选择,可以只请求自给定时间起的通知,since选项可以包含在请求URL的查询字符串中,值为浮点数类型的unix时间戳。如果URL包含此参数,则只返回再次时间之后发生的通知。
完成此功能的最后一道工作是实现客户端的轮询。执行此操作的最佳位置是基模板,这样所有的页面都自动继承此行为:
app/templates/base.html
...
{% block scripts %}
此函数包含在模板条件中,因为我只想在用户登录时才对新消息进行轮询。多于未登录的用户,模板将不包括此函数。
你已经在第20章见过了jQuery的$(function(){...})模式。这是在加载页面后将函数注册执行的方法。对于此功能,我要在页面加载上做的是配置一个常规的计时器来获取用户的通知。你也见过了JavaScript的setTimeout()函数,在给定的时间后允许函数。setInterval()函数使用和setTimeout()一样的参数,但它不会只触发一次计时器,而是不断地调用回调函数。在本例中,我将时间间隔设置为10秒(以毫秒为单位),所以我将看到的徽章更新频率是每分钟六次。
这个关联间隔计时器的安舒为新的通知路由发出Ajax请求,在其完成的回调中遍历通知列表。当收到名为unread_message_count的通知时,通过调用上面定义的函数和通知给定的计数调整消息计数徽章。
我处理since参数的方式可能使人困惑。首先,我将此参数初始化为0。请求URL中总是包含此函数,但我不能像之前一样,用Flask的url_for()生成查询字符串,因为url_for()只在服务端运行一次,并且我需要since参数来动态地更新。第一次,请求将被发送到/notifications?since=0,但一旦我收到通知,我将since更新为其时间戳。由于我总是接收自上次通知以来发生的通知,确保了我不会收到重复的通知。还需要注意的是,我在间隔函数之外声明了since变量,因为我不希望其为局部变量,我希望在所有的调用中都使用相同的变量。
搞清楚它最简单的方法是用两个不同的浏览器。分别用不同的用户登录Microblog。然后从一个浏览器向另一个用户发送一条或多条消息。另一个浏览器的导航栏应该会更新,显示你10秒内发送的消息数量。当你点击Messages链接后未读消息的数量会归零。