欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

The Flask Mega-Tutorial 之 Chapter 8: Followers

程序员文章站 2022-07-14 15:41:40
...

小引

社交网往往有相互关注的特性,本节即添加“Followers”特性。
重点是调整 db,使之能够追踪 who is following whom。



Database Relationships Revisited

  • 理想的情况是,对每个 user 都能维护一个 list,里面包括它的两类 users (即 followersfollowed),但 Relational Database 没有这种 list 类型。
  • Database 只储存 users 的表,所以须找到一种 relationship 类型,能够表征(model) Userfollowers / followed 之间的 link


Representing Followers

Relationship

  • Many-to-Many: followers - followed
  • Self-referential: followers & followed belong to the same class User.

Association Table: followers

  • followers 表中,只存储了两类 ForeignKey,都指向 User

The Flask Mega-Tutorial 之 Chapter 8: Followers



Database Model Representation

1、创建辅助表 followers

app / models.py: association table

followers = db.Table('followers',
    db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
    db.Column('followed_id', db.Integer, db.ForeignKey('user.id'))
)

注:由于作为辅助表的 followers 中,除了外键,没有其他数据,所以没有使用 model class 的方式定义,而是之间采用 db.Table() 的方式定义。

Since this is an auxiliary table that has no data other than the foreign keys, I created it without an associated model class.

2、创建 Many-to-Manyrelationship

app / models.py: many-to-many followers relationship

class User(UserMixin, db.Model):
    # ...
    followed = db.relationship(
        'User', 
        secondary=followers,
        primaryjoin=(followers.c.follower_id == id),
        secondaryjoin=(followers.c.followed_id == id),
        backref=db.backref('followers', lazy='dynamic'), 
        lazy='dynamic')
  • User: 因为是 self-referential 关系,故关系的另一边也是 ‘User’(relationship 名字用 Class 名,而非 table 名)
  • secondary: configures the association table for this relationship
  • primaryjoin: indicates the condition that links the left side entity (the follower user) with the association table。 其中 followers.c.follower_id 指的是 association table 的 follower_id 列。

  • secondaryjoin: indicates the condition that links the right side entity (the followed user) with the association table.

  • backref: 定义逆向关系(defines how this relationship will be accessed from the right side entity)。从左侧看,其追踪的 users 属于 followed;从被追踪的 users来看,则追踪者属于 followers
  • lazy: sets up the query to not run until specifically requested, 同于 posts 中设置的 dynamic(one-to-many)。


注: SQLAlchemy tables 如果未定义成 models,则符号 “c” 是这类表的一种属性。 对这类表,其所有的字段或列,均视为属性 “c” 的子属性(sub-attributes)。

The “c” is an attribute of SQLAlchemy tables that are not defined as models. For these tables, the table columns are all exposed as sub-attributes of this “c” attribute.

3、迁移数据库并升级

(venv) $ flask db migrate -m "followers"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'followers'
  Generating /home/miguel/microblog/migrations/versions/ae346256b650_followers.py ... done

(venv) $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 37f06a334dbf -> ae346256b650, followers



Adding and Removing “follows”

  • 依赖SQLAlchemy ORM,以及定义的 User 的 followed relationship,则在 user之间(前提是已在db存储)可以像 list 一样实现操作:
user1.followed.append(user2)
user1.followed.remove(user2)
  • 为提高复用性,封装成 Usermethods

app / models.py: add and remove followers

class User(UserMixin, db.Model):
    #...

    def follow(self, user):
        if not self.is_following(user):
            self.followed.append(user)

    def unfollow(self, user):
        if self.is_following(user):
            self.followed.remove(user)

    def is_following(self, user):
        return self.followed.filter(
            followers.c.followed_id == user.id).count() > 0
  1. 相较于 filter_by(),其中 filter() 更加底层,可以包含任意的 filtering 条件语句;
    格式上,filter() 的条件语句更完整严格,须指明 table1.field1== table2.field2,并为”==”。
  2. filter_by(),can only check for equality to a constant value,并且条件语句更简单。


注:尽可能将app的逻辑从路由函数分离出来,放到 models 或其他辅助classes及模块中,因为这样便于后续的 unit testing。

It is always best to move the application logic away from view functions and into models or other auxiliary classes or modules, because as you will see later in this chapter, that makes unit testing much easier.



Obtaining the Posts from Followed Users

因为最终我们希望在 /index 页展示出 followed 的 users 的 posts。
所以写好 followed & followers 的relationship(即 db 支持已完成设置)后,考虑如何获取 followed 的 users 的 posts。

  • 方法一:最容易想到的方法是,首先利用user.followed.all() 来获取 a list of followed users,然后查询每一个 user 的 posts,最终将获取到的所有 posts 并入(merg)到 单个 list 中并按日期排序。
  • 弊端一:如果某个 user 追踪的人数 n 过大(followed is very large),则须先执行 n 次 单个 followed user 的 posts 查询,之后须将返回的 n 个 list of posts 进行merge 和 sorting。
  • 弊端二:因为最终 Home 页会进行页码编订(pagination),首页只会显示最新的若干条 posts,设置显示更多内容的链接。这种情形下,除非我们先获取所有 posts 并按日期进行排序,否则无法知晓追踪的 users 的最新消息。

    As a secondary problem, consider that the application’s home page will eventually have pagination implemented, so it will not display all the available posts but just the first few, with a link to get more if desired. If I’m going to display posts sorted by their date, how can I know which posts are the most recent of all followed users combined, unless I get all the posts and sort them first? This is actually an awful solution that does not scale well.



  • 方法二:因为 Relational Database 擅长 merging & sorting,所以不将 query 放到 app 中,而是交给 db 来执行。db 通过索引(index),可以更高效地进行 query & sorting。语句如下:

app / models.py: followed posts query

class User(db.Model):
    #...
    def followed_posts(self):
        return Post.query.join(
            followers, (followers.c.followed_id == Post.user_id)).filter(
                followers.c.follower_id == self.id).order_by(
                    Post.timestamp.desc())

对 Post 进行 query,结构为 Post.query.join(...).filter(...).order_by(...)。思路为:将 post 表中的 user_id 视为 followers 表中 followed_id 一方,先将所有匹配情况拿到(得到所有被追踪的 users 发布的所有 posts),然后筛选被某个特定 user 追踪的 users 发布的posts,最后按 post 的日期排序。

1、首先,将 post 表与 followers 表进行 join,条件是followers.c.followed_id == Post.user_id(待查询 posts 属于 followed,所以 followed_id;如果待查询 posts 属于 follower,则 follower_id)。

  • 若 followed_id 存在,但 user_id 不存在,则此 user 未发布 post。
  • 若 followed_id 不存在,但 user_id 存在,则此 user 未被 追踪。
  • 若多条 followed_id 匹配,则多人追踪此 user。
  • 若多条 user_id 匹配,则此 user 发布多条 posts。

2、然后,进行 filtering。上步获得的是 followers 表中记录的所有 users 追踪的所有 followed 的 users 的 posts,为得到某个 user 的所有 followed 的users 的 posts, 则筛选条件为followers.c.follower_id == self.id

3、按 Post 的 timestamp 进行 倒序排布,即最新的在最上方。


Combining Own and Followed Posts

app / models.py : followed posts + user’s own posts.

    def followed_posts(self):
        followed = Post.query.join(
        followers, (followers.c.followed_id == Post.user_id)).filter(
                followers.c.follower_id == self.id)
        own = Post.query.filter_by(user_id=self.id)
        return followed.union(own).order_by(Post.timestamp.desc())

Unit Testing the User Model

为保证以后改变 app 的其他部分后,某些复杂的功能模块仍能正常工作,最好的方式就是写一组自动的测试 case,以后每次有改动,则 re-run 这组 case,看看是否正常工作。

microblog / tests.py: user model unit tests.

from datetime import datetime, timedelta
import unittest
from app import app, db
from app.models import User, Post

class UserModelCase(unittest.TestCase):
    def setUp(self):
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://'
        db.create_all()

    def tearDown(self):
        db.session.remove()
        db.drop_all()

    def test_password_hashing(self):
        u = User(username='susan')
        u.set_password('cat')
        self.assertFalse(u.check_password('dog'))
        self.assertTrue(u.check_password('cat'))

    def test_avatar(self):
        u = User(username='john', email='aaa@qq.com')
        self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/'
                                         'd4c74594d841139328695756648b6bd6'
                                         '?d=identicon&s=128'))

    def test_follow(self):
        u1 = User(username='john', email='aaa@qq.com')
        u2 = User(username='susan', email='aaa@qq.com')
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        self.assertEqual(u1.followed.all(), [])
        self.assertEqual(u1.followers.all(), [])

        u1.follow(u2)
        db.session.commit()
        self.assertTrue(u1.is_following(u2))
        self.assertEqual(u1.followed.count(), 1)
        self.assertEqual(u1.followed.first().username, 'susan')
        self.assertEqual(u2.followers.count(), 1)
        self.assertEqual(u2.followers.first().username, 'john')

        u1.unfollow(u2)
        db.session.commit()
        self.assertFalse(u1.is_following(u2))
        self.assertEqual(u1.followed.count(), 0)
        self.assertEqual(u2.followers.count(), 0)

    def test_follow_posts(self):
        # create four users
        u1 = User(username='john', email='aaa@qq.com')
        u2 = User(username='susan', email='aaa@qq.com')
        u3 = User(username='mary', email='aaa@qq.com')
        u4 = User(username='david', email='aaa@qq.com')
        db.session.add_all([u1, u2, u3, u4])

        # create four posts
        now = datetime.utcnow()
        p1 = Post(body="post from john", author=u1,
                  timestamp=now + timedelta(seconds=1))
        p2 = Post(body="post from susan", author=u2,
                  timestamp=now + timedelta(seconds=4))
        p3 = Post(body="post from mary", author=u3,
                  timestamp=now + timedelta(seconds=3))
        p4 = Post(body="post from david", author=u4,
                  timestamp=now + timedelta(seconds=2))
        db.session.add_all([p1, p2, p3, p4])
        db.session.commit()

        # setup the followers
        u1.follow(u2)  # john follows susan
        u1.follow(u4)  # john follows david
        u2.follow(u3)  # susan follows mary
        u3.follow(u4)  # mary follows david
        db.session.commit()

        # check the followed posts of each user
        f1 = u1.followed_posts().all()
        f2 = u2.followed_posts().all()
        f3 = u3.followed_posts().all()
        f4 = u4.followed_posts().all()
        self.assertEqual(f1, [p2, p4, p1])
        self.assertEqual(f2, [p2, p3])
        self.assertEqual(f3, [p3, p4])
        self.assertEqual(f4, [p4])

if __name__ == '__main__':
    unittest.main(verbosity=2)
  • 引入unittest,作为 class UserModelCase 的 基类(unittest.TestCase
  • setUp()tearDown() ,是 unit testing framework 的两类特殊方法,分别在每个 test 开始之前/结束之后执行。
  • setUp()里面,app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://',使SQLAlchemy 在测试中使用 in-memory SQLite database.
  • 写了四个测试:test_password_hashing(), test_avatar(), test_follow(), test_follow_posts()
  • db 完成操作后,记得 db.session.commit()
(venv)~/Flask_microblog $ python tests.py 
[2018-06-13 10:31:14,328] INFO in __init__: Microblog startup
test_avatar (__main__.UserModelCase) ... ok
test_follow (__main__.UserModelCase) ... ok
test_follow_posts (__main__.UserModelCase) ... ok
test_password_hashing (__main__.UserModelCase) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.245s

OK

Integrating Followers with the Application

1、写路由函数

app / routes.py: follow and unfollow routes.

@app.route('/follow/<username>')
@login_required
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('User {} not found.'.format(username))
        return redirect(url_for('index'))
    if user == current_user:
        flash('You cannot follow yourself!')
        return redirect(url_for('user', username=username))
    current_user.follow(user)
    db.session.commit()
    flash('You are following {}!'.format(username))
    return redirect(url_for('user', username=username))

@app.route('/unfollow/<username>')
@login_required
def unfollow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('User {} not found.'.format(username))
        return redirect(url_for('index'))
    if user == current_user:
        flash('You cannot unfollow yourself!')
        return redirect(url_for('user', username=username))
    current_user.unfollow(user)
    db.session.commit()
    flash('You are not following {}.'.format(username))
    return redirect(url_for('user', username=username))
  • 均为 @login_required
  • 首先判断,是否存在 user = User.query.filter_by(username=username).first()
  • user 不存在,则 flash 提示, 并定向至 ‘index’
  • user 存在,且为 current_user (即当前登录用户,通过 @login.user_loader 导入),则无法执行 follow 或 unfollow,flash 提示后,定向至 ‘user’(即个人界面,url_for('user', username=username)
  • user 存在,且不为 curent_user,则可以执行 follow 或者 unfollow (注:均对 user 执行,而不是 username);执行后,均需 db.session.commit()

2、更新模板 user.html

app / templates / user.html: user profile 页面 添加 follow 、 unfollow 链接

        ...
        <h1>User: {{ user.username }}</h1>
        {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
        {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}

        <p>{{ user.followers.count() }} followers, {{ user.followed.count() }} following.</p>

        {% if user == current_user %}
        <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>

        {% elif not current_user.is_following(user) %}
        <p><a href="{{ url_for('follow', username=user.username) }}">Follow</a></p>

        {% else %}
        <p><a href="{{ url_for('unfollow', username=user.username) }}">Unfollow</a></p>

        {% endif %}
        ...
  • last_seen 下面添加 数量标定

<p>{{ user.followers.count() }} followers, {{ user.followed.count() }} following.</p>

  • 如果 logged-in user 浏览自己的网页,则 仍旧显示“Edit”链接。
  • 如果 logged-in user 浏览尚未 follow 的 user 的 ‘user’ (profile),则显示 “Follow” 链接。
  • 如果 logged-in user 浏览已经 follow 的 user 的 ‘user’ (profile),则显示 “Unfollow” 链接。


示例: 拿两个已注册的用户 Kungreye 和 Susan (注意大小写),来测试 follow 及 unfollow 。

  1. 登录 Kungreye (‘/index’)
    The Flask Mega-Tutorial 之 Chapter 8: Followers

  2. 进入 Profile 页面 (‘/user/Kungreye’),可以看到是 o followers, 0 following
    The Flask Mega-Tutorial 之 Chapter 8: Followers

  3. 更改 addr bar 为 “~/user/Susan”,则进入 Susan 的 Profile。
    同样显示 o followers, 0 following,但对于current_user (即Kungreye)来讲,此 user (即 Susan)尚未被 followed,所以显示 “Follow” 链接。
    The Flask Mega-Tutorial 之 Chapter 8: Followers

  4. 追踪 Susan (点击 Follow 后)后,Susan 显示有 1 follower, 0 following.
    The Flask Mega-Tutorial 之 Chapter 8: Followers

  5. 返回 Kungreye (点击导航条 Profile,链接至 current_user (即 logged-in 的 Kungreye)的 Profile 页面)。 可以发现 0 follower, 1 following

    参照 base.html 中 Profile 的源码 <a href="{{ url_for('user', username=current_user.username) }}">Profile</a>

The Flask Mega-Tutorial 之 Chapter 8: Followers

以上。