论坛构建
介绍如何使用 Redis 去构建一个论坛
V2EX
主要功能
用户账号 创建帖子、回复帖子、为帖子投票
为帖子分类,查看属于特定分类的帖子
将多个分类(category)归类至某个标签(tab),查看属于特定标签的帖子
每日热议帖子排行榜
基于算法的帖子推荐
创建用户账号
用户账号示例
账号注册
注册 V2EX 账号要求输入用户名、密码和电子邮件地址,并且用户名和电子邮件地址必须是未被使用的,这和微博的账号注册要求一样。
因此,我们可以重用之前的UniqueSet 类来保证用户名和电子邮件地址的唯一性,并重用之前的 User 类来创建论坛账号。
创建帖子
帖子示例
一个帖子的构成部分:
分类
标题
用户的投票数量
作者
发布时间
点击量
内容
TAG
创建帖子
程序会为用户创建的每个帖子分配一个唯一的帖子 ID ,然后将帖子 ID 、发布时间、点击量、标题、作者的 ID、内容、分类、TAG 等信息储存到格式为 bbs::topic:: 的散列键里面。
HMSET bbs::topic::131853 id 131853
time 1410165226
click_counter 0
title “WordPress 4.0 \“Benny\” 正式版发布……”
author_id 48771
content “WordPress 4.0 简体中文版现已开放下载,……”
category “WordPress”
tags JSON([“WordPress”, “简体”, “benny”])
其中 tags 属性为 JSON 列表。
查看帖子
V2EX 使用了两种方式来展示帖子,一种是按照分 类(category)来展示,另一种是按标签(tag)来展示,接下来我们将逐一实现这两种展示帖子的方式。
根据分类展示帖子
WordPress 分类,以及该分类属下的帖子
分类页面会根据帖子最后一次被回复的时间来排序帖子。
储存分类帖子
为了储存同一分类的帖子,并按照帖子最后一次被回复的 时间有序地排列帖子,程序会 为每个分类创建一个键名为 bbs::category::的有序集合,其中有序集合的元素 为帖子的 ID ,而元素的分值 则是帖子最后一次被回复的 UNIX 时间戳。
例子,将最后一次回复 时间为 1410165226 ,ID 为 131853 的帖子归到 WordPress 分类里面:
ZADD bbs::category::WordPress 1410165226 131853
因为同一分类的帖子会被有序地储存起来,所以程序只要 对有序集合调用 ZREVRANGE 命令,就可以按照回复时间从新到旧的顺序,取出属于同一分类的多个帖子。
我们可以将这些针对分类的操作抽象为 Category 类。
Category 类 API
Category 类使用示例
>>> wordpress_category = Category(client, “WordPress”)
>>> wordpress_category.include_topic(131853)
>>> wordpress_category.is_included(131853)
True
>>> wordpress_category.count_topic()
148
按照标签展示帖子
标签功能
标签功能的实现
实现标签功能需要两个步骤:
-
记录标签和分类之间的关系。 举个例子,程序要记住,“程序员”、“Linux”、“node.js”这些分类都属于“技术”标签。
-
记录标签属下的帖子,当用户点击某个标签时,展示该标签属下的各个帖子。
举个例子,帖子 132312 属于“程序员”分类,而该分类又属于“技术”标签,所以程序应该在用户点击 “技术”页面时,展示出帖子 132312 。
标签功能的实现
为了记录标签和分类之间的关系,程序会使用一个 键名为 bbs::tab::::member 的集合来储存所有属于 name 标签的分类,集合中的每个元素都是一个分 类名。
比如 bbs::tab::技术::member 这个集合就包含了 “程序员”、“Linux”、“node.js” 等分类的名字,通过这个集合,程序可以知道哪些分 类属于“技术”标签。
另一方面,为了记录标签属下的帖子,程序会使用一个 键名为 bbs::tab::::topic_list 的有序集合来储存属于 name 标签的所有帖子,其中有序集合的元素 为帖子的 ID ,元素的分值为帖子最后一次被回复时的 UNIX 时间戳。
比如帖子 132312 属于“程序员”分类,而该分类又属于“技术”标签,所以程序会在创建该帖子时,执行以下命令,将该帖子添加到“技术”标签的帖子列表里面:
ZADD bbs::tab::技术::topic_list 1410165226 132312
Tab 类 API
Tab 类使用示例
>>> tech_tab = Tab(client, “技术”)
>>> tech_tab.add_member(“程序员”)
>>> tech_tab.add_member(“node.js”)
>>> tech_tab.add_member(“Linux”)
>>> tech_tab.is_member(“程序员”)
True
>>> tech_tab.include_topic(“程序员”, 132312)
回复帖子
回复示例
用户可以对帖子进行回复。
每条回复会至少会包含作者、回复时间和回复内容这些信息。
并且每个帖子都需要一个列表来储存所有回复。
这和我们之前为了实现微博评论而创建的 Comment 类 和 CommentList 类的需求基本相同,所以只要对这两个类进行一些简单的修改,就可以重用它们了。
为帖子投票
投票示例
在之前介绍集合的时候,我们就说过怎样使用集合来实现投票功能,并在课后练习中实现了可以投支持票和反对票的UpDownVote 类。
只要对 UpDownVote 类稍作修改,就可以直接重用这个类的代码了。
每日热议帖子排行
每日热议帖子排行榜示例
每日热议帖子排行榜展示了每天回复数量最多的帖子, 这个排行榜每天更新一次。
为了实现这个排行榜,程序需要使用一个 键名为 bbs::reply_day_rank 的有序集合,其中有序集合的元素 为帖子的 ID ,而元素的分值则是帖子在当前被回复的数量。
每当 ID 为 N 的帖子新增一条回复时,程序就执行以下命令来增加帖子在排行榜中的回复数量:ZINCRBY bbs::reply_day_rank N 1
并且程序会为 bbs:reply_day_rank 设置生存时间,让它在一天之后自动过期,并自动创建新榜单。 这可以通过修改并重用之前介绍过的 DayRank 类来实现。
ReplyDayRank 类 API
>>> rank = ReplyDayRank(client)
>>> rank.incr_reply_count(10086) # ID 为 10086 的帖子获得了新回复,为它的计数值增一
>>> rank.incr_reply_count(12345) # ID 为 12345 的帖子获得了新回复,为它的计数值增一
>>> rank.incr_reply_count(99999) # ID 为 99999 的帖子获得了新回复,为它的计数值增一
>>> rank.get_top(10) # 返回今日回复排名前十的帖子
帖子推荐系统
帖子推荐示例
R2 标签是根据算法来推荐帖子的。
算法对帖子进行评分时,考虑的因素可能有:用户的贡献值、发 布时间、用户的投票数、用户的回复数量,等等。
实现推荐系统的方法:
- 选择一个推荐算法,用于计算帖子的评分。
- 计算各个帖子的评分,并以有序的方式展示它们。
帖子推荐系统的实现
为了实现帖子系统,我们选择了 Reddit 使用的推荐算法( reddit_algorithm.py),这个算法进行评分时考 虑的是帖子的发布时间、获得的支持票数量以及反 对票数量,这个算法用于计算评分的函数为:hot(ups, downs, date)
算法的具体解释可以在这篇博客里面找到: http://www.ruanyifeng.com/blog/2012/03/ranking_algorithm_reddit.html
推荐标签包含的帖子仍然使用有序集合来 储存,其中有序集合的元素 为帖子的 ID ,而元素的分值则是算法为帖子计算出的评分。
当用户创建一个帖子的时候,或者有用户对帖子进行投票之后,我们就调用 how 函数来更新帖子的评分。
我们将帖子推荐系统的相关操作抽象为 TopicRecommand 类。
TopicRecommand 类 API
>>> recommand = TopicRecommand(client)
>>> recommand.update_rank(10086, 10, 3, 1410173496)
>>> recommand.paging(1, 10)
复习
各项功能以及它们的实现方式
unique_set.py
# encoding: utf-8
class UniqueSet:
def __init__(self, client, key):
self.client = client
self.key = key
def add(self, element):
self.client.sadd(self.key, element)
def is_include(self, element):
return self.client.sismember(self.key, element)
id_generator.py
# coding: utf-8
class IdGenerator:
"""
使用字符串键实现的自增唯一 ID 生成器。
"""
def __init__(self, client, key):
"""
设置储存 ID 生成器当前值的键。
"""
self.client = client
self.key = key
def init(self, n):
"""
初始化生成器的值,需要在系统正式运行之前调用,用于保留少于等于 n 的 ID 号码。
"""
# 如果键 key 已经有值,那么说明 gen() 已经执行过
# 为了防止产生重复的 ID ,程序不能执行这个 init() 操作
if self.client.get(self.key) is not None:
raise Exception
self.client.set(self.key, n)
def gen(self):
"""
生成一个新的唯一 ID 。
"""
new_id = self.client.incr(self.key)
return int(new_id)
user.py
# encoding: utf-8
from id_generator import IdGenerator
def make_user_key(uid):
return 'bbs::user::' + str(uid)
def make_email_to_uid_key():
return 'bbs::email_to_uid'
class User:
"""
用户相关操作。
"""
def __init__(self, client):
self.client = client
def create(self, name, email, password):
"""
创建一个新的用户,创建前需要确保输入的用户名和邮箱未被使用。
"""
# 储存用户信息
new_id = IdGenerator(client, "bbs::user_id").gen()
key = make_user_key(new_id)
self.client.hmset(key, {
'id': new_id,
'name': name,
'email': email,
'password': password
})
# 关联用户 email 与 ID
self.client.hset(make_email_to_uid_key(), email, new_id)
# 返回用户 ID 作为函数的值
return new_id
def get_by_id(self, uid):
"""
根据给定的用户 id ,获取用户的详细信息。
"""
key = make_user_key(uid)
return self.client.hgetall(key)
def try_login(self, email, password):
"""
尝试使用指定的邮箱和密码进行登录,成功时返回用户信息,失败时返回 None 。
"""
# 根据输入的邮箱,获取用户 ID
uid = self.client.hget(make_email_to_uid_key(), email)
# 如果找不到与邮箱对应的用户 ID ,那么说明这个邮箱未注册过账号
if uid is None:
return None
# 根据用户 ID ,获取用户信息,并进行密码对比
# 邮箱不用对比,因为用户信息就是根据邮箱来查找的
user_info = self.get_by_id(uid)
if user_info['password'] == password:
return user_info
topic.py
# encoding: utf-8
import json
from time import time
from id_generator import IdGenerator
ID_GENERATOR_KEY = 'bbs::topic_ids'
def make_topic_key(topic_id):
return 'bbs::topic::' + str(topic_id)
class Topic:
"""
帖子相关操作。
"""
def __init__(self, client):
self.client = client
def create(self, title, author_id, content, category, *tags):
"""
创建一个新帖子。
"""
topic_id = IdGenerator(client, ID_GENERATOR_KEY).gen()
topic_hash = make_topic_key(topic_id)
info = {
'id': topic_id,
'time': time(),
'click_counter': 0,
'title': title,
'author_id': author_id,
'content': content,
'category': category,
'tags': json.dumps(tags)
}
self.client.hmset(topic_hash, info)
return topic_id
def get_by_id(self, topic_id):
"""
根据给定的帖子 id ,查找并返回帖子的详细信息。
"""
topic_hash = make_topic_key(topic_id)
return self.client.hgetall(topic_hash)
category.py
# encoding: utf-8
from time import time as current_time
def make_category_key(name):
return 'bbs::category::' + name
class Category:
"""
创建一个分类列表来记录所有属于某个分类的文章。
"""
def __init__(self, client, name):
self.client = client
self.category_zset = make_category_key(name)
def include_topic(self, topic_id):
"""
将指定的文章添加到当前分类里面。
"""
# 文章按照添加的时间来排序。
self.client.zadd(self.category_zset, topic_id, current_time())
def is_included(self, topic_id):
"""
检查指定的文章是否为当前分类的文章。
"""
# 当 topic_id 作为元素存在于 ZSET 时,它的分值不为 None
return self.client.zscore(self.category_zset, topic_id) is not None
def count_topic(self):
"""
返回分类目前包含的文章数量。
"""
return self.client.zcard(self.category_zset)
def paging(self, n, count):
"""
按时间从新到旧的顺序,以 count 篇文章为一页,返回当前分类第 n 页上的文章。
"""
start_index = (n-1)*count
end_index = n*count-1
return self.client.zrevrange(self.category_zset, start_index, end_index)
tab.py
# encoding: utf-8
from category import Category
from time import time as current_time
def make_tab_member_key(name):
return 'bbs::tab::' + name + '::member'
def make_tab_list_key(name):
return 'bbs::tab::' + name + 'topic_list'
# 使用 Tab 来记录所有分类属下文章的另一种做法是,直接对各个分类的文章列表进行并集计算:
# 这种实现可以避免每个 Tab 单独维护一个文章列表,缺点是并集计算比较耗时,
# 所以为了高效起见,这里还是使用单独维护一个文章列表的做法。
class Tab:
"""
一个标签可以包含任意多个分类,并按照发布时间先后,记录被包含分类属下的文章。
"""
def __init__(self, client, name):
self.client = client
# 储存标签属下分类的集合
self.tab_member_set = make_tab_member_key(name)
# 储存标属下分配的文章的有序集合
self.tab_topic_list = make_tab_list_key(name)
def add_member(self, category_name):
"""
将指定分类添加到当前标签中。
"""
self.client.sadd(self.tab_member_set, category_name)
def is_member(self, category_name):
"""
检查指定分类是否属于当前标签。
"""
return self.client.sismember(self.tab_member_set, category_name)
def get_all_member(self):
"""
返回标签包含的所有分类。
"""
return self.client.smembers(self.tab_member_set)
def count_member(self):
"""
返回标签包含的分类数量。
"""
return self.client.scard(self.tab_member_set)
def include_topic(self, category_name, topic_id):
"""
将属于 category 分类的文章添加到当前标签的时间线里面。
"""
# 先检查文章是否属于 category 分类
# 然后检查 category 分类是否属于当前标签
# 只有两个条件都通过时,才将文章添加到当前时间线
category = Category(self.client, category_name)
if self.is_member(category_name) and category.is_included(topic_id):
self.client.zadd(self.tab_topic_list, topic_id, current_time())
def paging(self, n, count):
"""
按时间从新到旧的顺序,以 count 篇文章为一页,返回当前标签第 n 页上的文章。
"""
start_index = (n-1)*count
end_index = n*count-1
return self.client.zrevrange(self.tab_topic_list, start_index, end_index)
def count_topic(self):
"""
返回标签目前包含的文章数量。
"""
return self.client.zcard(self.tab_topic_list)
comment.py
# encoding: utf-8
from time import time
from id_generator import IdGenerator
ID_GENERATOR_KEY = 'bbs::comment_ids'
def make_comment_key(comment_id):
return 'bbs::comment::' + str(comment_id)
class Comment:
"""
帖子评论相关操作。
"""
def __init__(self, client):
self.client = client
def create(self, author_id, content):
"""
创建一个新的评论。
"""
comment_id = IdGenerator(client, ID_GENERATOR_KEY).gen()
comment_hash = make_comment_key(comment_id)
info = {
'id': comment_id,
'author_id': author_id,
'content': content,
'time': time()
}
self.client.hmset(comment_hash, info)
return comment_id
def get_by_id(self, comment_id):
"""
根据评论 id ,查找并返回评论的详细信息。
"""
comment_hash = make_comment_key(comment_id)
return self.client.hgetall(comment_hash)
comment_list.py
# encoding: utf-8
def make_comment_list_key(topic_id):
return 'bbs::topic::' + str(topic_id) + '::comments'
class CommentList:
"""
创建一个列表来记录某一帖子下的所有评论(的 ID)。
"""
def __init__(self, client, topic_id):
"""
设置被评论的帖子的 ID 。
"""
self.client = client
self.topic_id = topic_id
self.comment_list = make_comment_list_key(topic_id)
def push(self, comment_id):
"""
将评论推入到评论列表里面。
"""
self.client.lpush(self.comment_list, comment_id)
def count(self):
"""
返回帖子目前已有的评论数量。
"""
return self.client.llen(self.comment_list)
def paging(self, number, count):
"""
以分页形式返回帖子的评论。
"""
start_index = (number-1)*count
end_index = number*count-1
return self.client.lrange(self.comment_list, start_index, end_index)
topic_vote.py
# encoding: utf-8
from redis import WatchError
def make_vote_up_key(topic_id):
return 'bbs::topic::' + str(topic_id) + '::vote_up'
def make_vote_down_key(topic_id):
return 'bbs::topic::' + str(topic_id) + '::vote_down'
class TopicVote:
"""
对帖子进行投票,可以投支持票也可以投反对票。
"""
def __init__(self, client, topic_id):
"""
设置要被投票的帖子。
"""
self.client = client
self.vote_up_set = make_vote_up_key(topic_id)
self.vote_down_set = make_vote_down_key(topic_id)
def is_voted(self, user_id):
"""
检查用户是否已经对这个帖子投过票。
"""
return self.client.sismember(self.vote_up_set, user_id) or \
self.client.sismember(self.vote_down_set, user_id)
def vote_up(self, user_id):
"""
用户对这个帖子投支持票。
"""
if not self.is_voted(user_id):
self.client.sadd(self.vote_up_set, user_id)
def vote_down(self, user_id):
"""
用户对这个帖子投反对票。
"""
if not self.is_voted(user_id):
self.client.sadd(self.vote_down_set, user_id)
def undo(self, user_id):
"""
撤销用户的投票。
"""
self.client.srem(self.vote_up_set, user_id)
self.client.srem(self.vote_down_set, user_id)
def vote_up_count(self):
"""
返回帖子目前获得的支持票数量。
"""
return self.client.scard(self.vote_up_set)
def vote_down_count(self):
"""
返回帖子目前获得的反对票数量。
"""
return self.client.scard(self.vote_down_set)
reply_day_rank.py
# encoding: utf-8
from datetime import datetime, timedelta
def today_remaind_seconds():
"""
返回当天剩余的秒数。
"""
now = datetime.now()
tomorrow = now + timedelta(days=1) - timedelta(hours=now.hour, minutes=now.minute,seconds=now.second)
return (tomorrow-now).seconds
DAY_RANK_KEY = 'bbs::reply_day_rank'
class ReplyDayRank:
"""
按照帖子获得的回复数量,对帖子进行排行。
排行榜每天更新一次。
"""
def __init__(self, client):
self.key = DAY_RANK_KEY
self.client = client
def incr_reply_count(self, topic_id):
"""
对指定帖子在当天的回复数量增一,每次帖子有新回复出现时调用。
"""
# 为计数器增一
self.client.zincrby(self.key, topic_id, 1)
# 如果键未设置生存时间或过期时间,那么将它的生存时间设置为当天剩下的秒数
if self.client.ttl(self.key) is None:
self.client.expire(self.key, today_remaind_seconds())
def get_top(self, n, show_score=False):
"""
返回当天回复数量排在前 n 位的帖子。
"""
return self.client.zrevrange(self.key, 0, n-1, withscores=show_score)
reddit_algorithm.py
# Reddit 所使用的排序算法
# 详细信息请参考: http://www.ruanyifeng.com/blog/2012/03/ranking_algorithm_reddit.html
from datetime import datetime, timedelta
from math import log
epoch = datetime(1970, 1, 1)
def epoch_seconds(date):
"""Returns the number of seconds from the epoch to date."""
td = date - epoch
return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000)
def score(ups, downs):
return ups - downs
def hot(ups, downs, date):
"""The hot formula. Should match the equivalent function in postgres."""
s = score(ups, downs)
order = log(max(abs(s), 1), 10)
sign = 1 if s > 0 else -1 if s < 0 else 0
seconds = epoch_seconds(date) - 1134028003
return round(order + sign * seconds / 45000, 7)
topic_recommand.py
# encoding: utf-8
import reddit_algorithm
from datetime import datetime
def make_recommand_list_key():
return 'bbs::recommand::list'
class TopicRecommand:
"""
根据帖子获得的投票数量以及帖子的发布时间,对帖子进行评分,
并根据评分对帖子进行排序。
"""
def __init__(self, client):
self.client = client
self.recommand_list = make_recommand_list_key()
def update_rank(self, topic_id, up_vote, down_vote, post_time):
"""
根据文章的投票数和发布时间,更新文章在推荐列表中的排位。
"""
# 因为算法只接受 datetime 格式的发布时间,而我们记录的文章发布时间是 UNIX 时间戳
# 所以这里需要将 UNIX 时间戳转换为 Python 的 datetime 时间
post_time_in_datetime_format = datetime.fromtimestamp(post_time)
# 使用推荐算法计算出帖子的分值
score = reddit_algorithm.hot(up_vote, down_vote, post_time_in_datetime_format)
# 为帖子设置新的分值,并更新帖子在推荐列表中的排位
self.client.zadd(self.recommand_list, topic_id, score)
def paging(self, n, count):
"""
按时间从新到旧的顺序,以 count 篇文章为一页,返回推荐列表第 n 页上的文章。
"""
start_index = (n-1)*count
end_index = n*count-1
return self.client.zrevrange(self.recommand_list, start_index, end_index)
topic_click_counter.py
# encoding: utf-8
def make_topic_key(topic_id):
return 'bbs::topic::' + str(topic_id)
class TopicClickCounter:
"""
帖子的点击计数器。
"""
def __init__(self, client, topic_id):
"""
设置要记录点击数量的帖子。
"""
self.client = client
self.topic_id = topic_id
self.topic_hash = make_topic_key(topic_id)
def incr(self, increment=1):
"""
对计数器执行自增操作。
"""
return self.client.hincrby(self.topic_hash, 'click_counter', increment)
def get(self):
"""
获取帖子当前的点击数量。
"""
return self.client.hget(self.topic_hash, 'click_counter')