论坛系统
项目介绍
本项目是一套前后端不分离的论坛系统,基于目前主流 Java Web 技术栈(SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + …),包含了帖子、评论、私信、系统通知、点赞、关注、搜索、用户设置、数据统计等模块。
可访问地址:吃瓜论坛
相关技术
项目结构
后端
本项目后端采用 MVC 模式,使用现在流行的 SpringBoot 框架。
entity
- User:数据库模型,对应表 user
- Comment:数据库模型,对应表 comment
- DiscussPost:数据库模型,对应表 discuss_post
- Message:数据库模型,对应表 message
- Page:分页模型
- Event:事件模型(Spring Kafka)
- LoginTicket:登录凭证
dao
- CommentMapper:控制 Comment 的相关数据库操作
- DiscussPostMapper:控制 DiscussPost 的相关数据库操作
- MessageMapper:控制 Message 的相关数据库操作
- UserMapper:控制 User 的相关数据库操作
- DiscussPostRepository:控制 Elasticsearch 服务器上 DiscussPost 的相关操作
这些都为操作接口,操作数据库的文件放在resources/mapper
目录下:
service
- CommentService:Comment 相关的业务逻辑
- UserService:User 相关的业务逻辑
- MessageService:Message 相关的业务逻辑
- DiscussPostService:DiscussPost 相关的业务逻辑
- FollowService:关注相关的业务逻辑
- LikeService:点赞相关的业务逻辑
- ElasticsearchService:Elasticsearch 搜索相关的业务逻辑
- DataService:数据统计相关的业务逻辑
controller
UserController:控制 User 相关的业务流程
CommentController:控制 Comment 相关的业务流程,比如添加评论等
DiscussPostController:控制 DiscussPost相关的业务流程,比如添加帖子、进入帖子详情页、置顶帖子、加精帖子等
MesaageController:控制 Message 相关的业务流程,比如进行私信列表、发送私信、发送系统通知等
LikeController:控制点赞相关的业务流程,比如点赞、取消点赞等
FollowController:控制关注相关的业务流程,比如关注、取消关注、进入某个用户的关注列表等
SearchController:控制搜索相关的业务流程,比如进入搜索界面等
DataController:控制数据统计相关的业务流程,比如进入统计界面、统计网站 DAU、统计网站 uv 等
IndexController:控制首页相关的业务流程,比如进入首页、进入 500/404 错误界面等
LoginController:控制登录注册相关的业务流程,比如登录、登出、注册、激活用户等
advice:
- ExceptionAdvice:处理服务端异常(500)
interceptor:拦截器
MessageInterceptor:获取未读私信/系统通知的数量
LoginTicketInterceptor:检查凭证状态,若凭证有效则在本次请求中持有该用户信息
DataInterceptor:统计网站 UV 和 DAU
util
- CommunityUtil:通用工具类。比如生成随机字符串、md5 加密、将服务端返回的消息封装成 JSON 格式的字符串等
- CookieUtil:从 request 中获取指定 name 的 cookie
- HostHolder:使用 ThreadLocal 持有用户信息(多线程),用于代替 session 对象,实现 session any where
- MailClient:发送激活邮件(用于注册)
- RedisKeyUtil:生成 Redis 的 key
- SensitiveFilter:敏感词过滤器。对应的敏感词文本文件就是
sensitive-words.txt
event
这个包主要是提供给 Spring Kafka 使用的:
- EventProducer:事件的生产者(将事件发布到指定的主题)
- EventConsumer:事件的消费者
quartz
- PostScoreRefreshJob:每隔一段时间刷新帖子分数(热度)
config
- KaptchaConfig:Kaptcha(验证码)相关配置
- QuartzConfig:Spring Quartz 相关配置
- RedisConfig:Redis 相关配置
- SecurityConfig:Spring Security 相关配置
- WebMvcConfig:拦截器相关配置
ascept
- ServiceLogAspect:使用 AOP实现统一日志记录
前端
前端静态资源
存放在resources/static目录下
页面模板
error:错误界面模板
mail:邮件模板
activation.html:激活邮件模板
forget.html:找回密码邮件模板(暂未使用)
forget.html:忘记密码页面模板(暂未使用)
operate-result.html:操作结果页模板
login.html:登录页模板
register.html:注册页模板
index.html:首页(帖子列表页模板)
discuss-detail.html:帖子详情页模板
profile.html:个人主页模板
my-post.html:我的帖子模板
my-reply.html:我的回复页模板
followee-detail.html:关注列表模板
follower-detail.html:粉丝模板
letter.html:私信列表模板
letter-detail.html:私信详情页模板
notice.html:系统通知页模板
notice-detail.html:系统通知详情页模板
search.html:搜索结果页模板
setting.html:账号设置页模板
data.html:数据统计模板
数据库设计
本项目数据库设计并不复杂,需要我们手动设计的只有四张表:
用户表:
user
帖子表:
discuss_post
评论表:
comment
私信表:
message
用户表
各个字段的含义:
id:用户的唯一标识
username:用户名
password:存储加盐加密后的密码
salt:随机生成的盐,用于密码的加盐加密
email:邮箱
type:用户类型
- 0 - 普通用户(用户注册默认是普通用户)
- 1 - 超级管理员:具有删除帖子、访问数据统计界面的权限
- 2 - 版主:具有置顶、加精帖子权限
status:用户状态
- 0 - 未激活(默认):用户点击注册后未点击邮箱中的激活链接进行验证,就会处于这个状态。未激活的用户同样无法正常使用某些功能比如发表帖子等
- 1 - 已激活:用户点击邮箱中的激活链接进行验证成功,就会将状态从未激活改成已激活
activation_code:激活码。用户点击注册后,随机生成一串激活码,则在本地环境下:
http://localhost:8080/greatecommunity/activation/用户id/激活码
成为该用户的激活链接;在服务器上:
http://122.112.244.115/activation/用户id/激活码
成为该用户的激活链接。点击该激活链接则激活用户。激活的逻辑,就是检查一下这个链接中的用户 id 和激活码是否和数据库中存储的一样。
帖子表
各个字段的含义:
id:帖子的唯一标识
user_id:发表该帖子的用户的 id
title:帖子标题
content:帖子内容
type:帖子类型
- 0 - 普通帖子(默认)
- 1 - 置顶帖子
status:帖子状态
- 0 - 正常(默认)
- 1 - 精华:为帖子加精可以使其在热度计算中得到一定的加分
- 2 - 拉黑:管理员删除帖子后,就将这个帖子的状态设置为拉黑
create_time:帖子发表时间
comment_count:帖子的评论数量(因为会频繁的显示帖子的信息,比如创建时间、创建人、评论数量、点赞数量等,创建时间和创建人信息这张表中已经有了,所以此处再将评论数量存进来就好。可能会有同学会问啥不把点赞数量也缓存到帖子表中,因为点赞数量是存在 Redis 中的,获取点赞数量咱连数据库都不用进的,还费劲在这存一份干啥)
score:热度 / 分数(用于按照热度排行帖子)
评论表
id:评论/回复的唯一标识
user_id:用户 id(哪个用户发布了这个评论/回复)
entity_type:实体类型(表示这条 comment 是针对哪个类型的,如果是针对帖子的,那么这个 comment 就是评论;如果是针对评论的,那么这条 comment 就是回复)
entity_id:实体的 id(如果是对帖子的评论,就存储帖子的 id;如果是对评论的回复,就存储评论的 id;还有对回复的回复,存储的仍然是所属评论的 id。也就是说,「某个帖子下的所有评论,它们的 entity_id 都是这个帖子的 id。某条评论下的所有回复,它们的 entity_id 都是这条评论的 id」。)
target_id:目标用户 id(表示这条评论/回复是针对哪个用户的。比如用户 admin 发了一个帖子,用户 master 评论了这个帖子,那么这里的 target_id 存储的就是用户 admin 的 id。)
content:评论/回复的内容
status:评论/回复状态
- 0 - 正常(默认)
- 1 - 禁用(暂未使用)
create_time:评论/回复发布时间
消息表
这张表存储用户之间的私信消息,也存储系统通知,不同的是,系统通知的 from_id 特定为 1。用于发送系统通知的角色(用户) SYSTEM
已内置。
id:私信/系统通知的唯一标识
from_id:私信/系统通知的发送方 id
to_id:私信/系统通知的接收方 id
conversation_id:标识两个用户之间的对话。比如用户 id 112 给 113 发消息,或者 113 给 112 发消息,这两个会话的
conservation_id
都是112_113
。这样,通过这个字段我们就能查出来 112 和 113 之间的私信往来了。当然,这个字段是冗余的,我们可以通过 from_id 和 to_id 推演出来,但是有了这个字段方便后面的查询等操作content:私信/系统通知的内容
status:私信/系统通知的状态
- 0 - 未读(默认)
- 1 - 已读
- 2 - 删除(暂未使用)
create_time:私信/系统通知的发送时间
开发过程
开发社区首页
搭建基本环境
构建 SpringBoot 的 maven 项目,引入 mysql 和 mybatis 依赖。
在 application.properties
配置文件中:
- 关闭 thymeleaf 缓存
- 配置数据库,设置基本连接信息、最大线程数,最小空闲线程数,最大空闲时间等
- mybatis,设置 mapper 文件的位置、实体类包名、使用主键等
创建 community 数据库和数据库表。
用户相关操作:
- 创建对应 user 表的 User 实体类
- 创建 UserMapper 接口,使用
@Mapper
注解 - 创建 user-mapper.xml,重复 sql 语句可以写在
<sql id = "xxx">
标签,通过<include refid="xxx"/>
引用。
开发社区首页(discuss_post 表)
功能拆分:开发社区首页,显示前 10 个帖子。开发分页组件,分页显示所有帖子。
用到的表是 discuss_post 数据库表,包括帖子 id、发帖人 id、标题、内容、类型、状态、发帖时间、评论数量(为了提高效率,避免关联查询,因此冗余存储)、分数(用于进行热度排名)。
开发数据层
帖子相关操作:
- 创建对应 discuss_post 表的 DisscussPost 实体类。
- 创建 DisscussPostMapper 接口,使用
@Mapper
注解。- 分页查询中用户 id 是可选参数,通过动态 SQL 选择,如果为 0 就不使用,在开发用户个人主页查询用户发帖记录时需要使用。
- 如果只有一个参数,并且在动态 SQL 的
<if>
里使用,必须使用@Param
加别名。
- 创建
disscusspost-mapper.xml
。where status != 2
拉黑的帖子不展现。<if test="userId!=0">
userID 为 0 时不使用,按照类型,发帖时间排序。
开发业务层
创建 DiscussPostService 类,可以分页查询帖子和帖子数量。
创建 UserService 类,实现根据 id 查询用户功能,因为显示帖子时不显示用户 id,而是显示用户名。
开发视图层
把静态资源 css、html、img、js 放到 static 目录下。
把模板 mail、site、index.html 放到 template 目录下。
创建 HomeController,getIndexPage
方法,用 map 集合把帖子和用户封装到一起。
修改 index.html
,使用 <th:text="${map.xxx.xxx}"
动态替换。
【问题】使用帖子关联查询用户时,给查询用户的 findUserById
方法传入了帖子的 getId
方法,应该是 getUserId
方法。
开发分页组件
创建 Page 实体类,封装分页信息,包括当前页码、显示限制、帖子总数、查询路径等。显示的起始页不能小于 1,最大页不能超过 total。
在 index.html
中,当 page.rows > 0
时显示分页信息。
如果 page.current
等于 1 或 page.total
,代表是首页或末页,此时不能点击上一页和下一页,用 disabled
属性实现。
开发注册登录模块
发送邮件
在QQ邮箱打开 SMTP 服务。
引入 spring-boot-starter-mail
依赖。
在配置文件配置主机、端口、发送邮箱、授权码等。
创建 MailClient 类,调用 JavaMailSender 发送邮件。
使用 thymeleaf 发送 HTML 邮件,调用 TemplateEngine 把信息封装到 HTML 模板。
【问题】发送邮件成功但没接收到,在垃圾箱中可找到。
注册功能
把 register.html 地址关联到首页的注册 href 属性。
设置域名、创建 CommunityUtil 工具类,在工具类创建生产随机字符串和 MD5 加密方法。
创建 LoginController,创建 getRegisterPage
方法,跳转注册页面。
在 UserService 中创建 register
方法,判断注册信息合规后插入数据库,发送激活邮件。
在 LoginController 创建 register
方法,调用 UserService 的 register
方法。
创建接口 CommunityConstant,定义激活码的三种状态,成功、重复、失败,让 UserService 和 LoginController 实现该接口。
点击激活邮件的 url 【本地服务器的url】后,服务器通过 LoginController 的 activation
方法查询数据库用户,如果 url 中的激活码和设置的一样,就把用户 status 改为 1。
生成验证码
在 pom.xml
导入 kaptcha 的 jar 包。
创建配置类 KaptchaConfig,设置验证码的大小、范围、长度等。
在 LoginController 类新增 getKaptcha
方法生成验证码图片。
在 login.html
中,将刷新验证码的链接绑定 refresh_kaptcha
方法,通过 id 选择器获取 img 组件,重新访问 getKaptcha
方法生成验证码图片。
【问题】由于访问同一个生成验证码路径,需要在 url 参数加上一个随机数字,保证会重新请求获取新图片。
登录退出功能(login_ticket 表)
登录成功时,需要生成一个登录凭证发送给客户端。凭证可以在多个业务中连续地验证用户的登陆状态,凭证信息存储在 login_ticket 数据库表中,status 的 0 和 1 表示有效和无序,expire 表示过期时间。
创建对应 login_ticket 表的 LoginTicket 实体类,对应 login_ticket 数据库表。
创建 LoginTicketMapper 接口,通过 @Insert
、@Select
、@Update
注解来插入、查询、更新凭证。
在 UserServce
- 创建
login
方法,验证账户合规后将凭证信息插入数据库,添加登录凭证到 map 中。 - 创建
logout
方法,将对应凭证设为无效。
在 LoginController
- 创建
login
方法,判断验证码正确后调用 UserServce 的login
方法,如果 map 包含 ticket 代表登录成功,重定向跳转首页,否则添加错误信息并跳回登录页。 - 创建
logout
方法,判断验证码正确后调用 UserServce 的logout
方法,跳转至登录页。
在 login.html
绑定登录链接,index.html
绑定退出登录链接。
【问题】登录成功后,创建了凭证,但忘记将凭证信息插入数据库。
显示登录信息
创建 CookieUtil 工具类,通过 name 查询对应 cookie 的 value。
在 UserService 中新增 findLoginTicket
方法,根据 ticket 查询 LoginTicket。
创建 HostHolder 类用来模拟 session 的功能,利用 ThreadLocal 实现,存储用户信息。
创建 LoginTicketInterceptor 拦截器,实现 HandlerInterceptor 接口。
- 在
preHandle
方法中通过 CookieUtil 的getValue
方法查询是否有凭证 cookie,如果有则通过 UserService 的findloginTicket
方法查询用户 ID,再通过用户 ID 查询用户。最后将用户放入 hostHolder 中。 - 在
postHandle
方法中通过 hostHolder 的get
方法获取用户,并将其存入视图中。 - 在
afterCompletion
方法中清除 hostHolder 中存放的用户信息。
创建 WebMvcConfig 配置类,实现 WebMvcConfigurer接口,配置 LoginTicketInterceptor,拦截除了静态资源之外的所有路径。
上传头像
在 UserService 新增 updateHeader
方法,更改指定用户的头像。
创建 UserController
新增
getSettingPage
方法访问账户设置setting.html
,并在index.html
的账号设置按钮关联该链接。新增
uploadHeader
方法更新用户头像,如果上传出现错误将错误信息存在 Model 对象中。如果没有错误,生成一个文件对象 dest,利用 MultipartFile 接口的
transferTo
方法将用户上传文件导入 dest,并从 hostHolder 中取出用户,更新用户的头像路径。新增
getHeader
方法获取用户头像,利用文件输入流读取图片数据,利用 HttpServletResponse 的字节输出流再进行输出。
调整 setting.html
的 form 表单, method=”post”,enctype=”multipart/form-data”,并设置提交路径。
修改密码
在 UserService 中新增 changePassword
方法,判断原密码是否正确,正确则修改密码并返回 1,否则返回 0。
在 UserController 中新增 changePassword
方法,根据 UserService 的 changePassword
方法的返回值判断原密码是否成功修改,封装为 JSON 数据并返回。
在 setting.html
中
- 首先在前端判断两次输入的新密码是否一致,如果不一致不允许点击提交并显示错误信息。
- 利用 ajax 向 UserController 的
changePassword
方法发送 POST 请求,得到 JSON 数据并解析,如果状态码为 0 提示错误,如果状态码为 1 弹出修改成功提示。
【问题】js 的虚拟路径问题,需要加上 ../
。
【问题】使用 ajax 请求时,表单按钮类型必须是 button,不能是 submit,否则 405 报错。
【问题】使用 ajax 请求时,Controller 中方法的返回值必须是 JSON 数据,并且需要加上 @ResponseBody
。
【问题】使用 ajax 请求时,回调函数需要先对返回的 JSON 数据进行解析再使用。
检查登录状态
利用拦截器,实现只处理带有自定义注解的方法,防止用户在未登录情况下通过 url 访问没有权限的页面。
创建 @LoginRequired
自定义注解,作用范围在方法上,有效期为运行时。
在 UserController 中需要在登录状态下调用的方法,访问设置页面、修改密码、上传头像等加上自定义注解。
创建 LoginRequiredInterceptor 拦截器,在 preHandle
方法中判断方法是否加了 @LoginRequired
注解,如果加了注解并且此时从 hostHolder 中获取不到用户则拒绝访问。
在 WebMvcConfig 配置类配置 LoginRequiredInterceptor,拦截除了静态资源之外的所有路径。
开发核心功能
敏感词过滤
利用字典树数据结构解决。
创建 SensitiveFilter 类
- 创建静态内部类 TrieNode ,通过 boolean 类型的结束符判断是否匹配到关键字尾部。
- 利用
@PostConstruct
注解,在构造方法执行后初始化字典树。 - 添加
filter
方法,利用双指针进行匹配,过滤敏感词。
发布帖子
引入 fastjson 依赖,在 CommunityUtil 中新增 getJSONString
方法封装 JSON 信息。
在 DisscussPostMapper 接口新增 insertDiscussPost
方法,并在 disscusspost-mapper.xml
配置 insert 语句。
在 DiscussPostService 新增 addDiscussPost
方法调用 DisscussPostMapper 的 insertDiscussPost
方法,其中需要进行对标题内容和发帖内容进行 HTML 转义以及过滤敏感词。
创建 DiscussPostController 类,新增 addDiscussPost
方法,调用 DiscussPostService 的 addDiscussPost
方法发帖。
在 index.html
中为发帖按钮绑定函数,利用 Ajax 向 DiscussPostController 的 addDiscussPost
方法发送 POST 请求。
显示帖子内容
在 DisscussPostMapper 接口新增 selectDiscussPostById
方法,在 disscusspost-mapper.xml
配置 select 语句。
在 DiscussPostService 新增 findDiscussPostById
方法调用 DisscussPostMapper 的 selectDiscussPostById
方法。
在 DiscussPostController 新增 getDiscussPost
方法,调用 DiscussPostService 的 findDiscussPostById
方法查询帖子内容,将 DiscussPost 对象和 User 对象(通过 userId 查询,不在 DAO 层关联查询)数据存放到 Model 对象,返回模板 discuss-detail
。
在 discuss-detail.html
取出 Model 对象存放的数据绑定到对应组件显示。
显示评论(comment 表)
创建 comment 表对应的实体类 Comment。
创建 CommentMapper 接口
- 新增
selectCommentsByEntity
方法,根据实体查询一页的评论数据。 - 新增
selectCountByEntity
方法,根据实体查询评论的数量。 - 在
comment-mapper.xml
配置 select 语句。
创建 CommentService 类
- 新增
findCommentByEntity
方法,调用 CommentMapper 的selectCommentByEntity
方法。 - 新增
findCommentCount
方法,调用 CommentMapper 的selectCountByEntity
方法。
在 DiscussPostController 的 getDiscussPost
方法中增加查询帖子评论和回复的逻辑,将结果存储在 Model 对象。
添加评论
在 CommentMapper 接口新增 insertComment
方法,添加评论数据,在 comment-mapper
配置对应 sql。
在 DiscussPostMapper 接口新增 updateCommentCount
方法,增加评论数量,在 discusspost-mapper
配置对应 sql。
在 DiscussPostService 类新增 updateCommentCount
方法,调用 DiscussPostMapper 的 updateCommentCount
方法。
在 CommentService 类新增 addComment
方法,调用 CommentMapper 的 insertComment
新增评论,并调用 DiscussPostService 的 updateCommentCount
更新评论数量,使用 @Transactional
注解保证事务。
创建 CommentController 类,新增 addComment
方法,从 hostHolder 获取用户信息,然后调用 CommentService 的 addComment
方法添加评论。
显示私信列表 (message 表)
创建对应 message 表的实体类 Message。
创建 MessageMapper 接口,增加查询会话列表、会话数量、私信列表、私信数量、未读私信数量等方法,在 message-mapper.xml
中配置对应的 sql。
创建 MessageService,调用 MessageMapper 中的方法。
创建 MessgaeController
- 新增
getLetterList
方法,将会话列表信息存储到 Model 对象,返回letter
视图。 - 新增
getLetterDetail
方法,将每个会话具体的私信信息存储到 Model 对象,返回letter-datail
视图。
发送私信
在 MessageMapper
- 新增
insertMessage
方法插入私信记录,在message-mapper.xml
配置 insert 语句。 - 新增
updateMessgae
方法修改私信状态,在message-mapper.xml
配置 update 语句,利用 foreach 动态 sql。
在 MessageService
- 新增
addMessage
发送私信方法,过滤敏感词后,调用 MessageMapper 的insertMessage
。 - 新增
readMessage
方法读取信息,调用MessageMapper 的updateMessgae
更新私信的状态为 1。
在 MessageController
- 新增
getLetterIds
方法,将私信集合中未读私信的 id 添加到 List 集合并返回,在getLetterDetail
方法调用该方法设置已读。 - 新增
sendLetter
发送私信方法,设置私信信息后调用 MessageService 的addMessage
发送。
统一异常处理
在 HomeController 中增加 getErrorPage
方法,返回错误页面。
创建 ExceptionAdvice 类
- 加上
@ControllerAdvice
注解,表示该类是 Controller 的全局配置类。 - 创建
handleException
方法,加上@ExceptionHandler
注解,该方法在 Controller 出现异常后调用,处理捕获异常。如果是异步请求返回一个 JSON 数据,否则重定向至 HomeController 的getErrorPage
方法。
统一日志处理
在 pom.xml
引入 aspectj 的依赖。
创建 ServiceLogAspect 类,添加 @Aspect
切面注解,配置切入点表达式,拦截所有 service 包下的方法,利用 @Before
记录日志。
Redis
点赞
创建 RedisKeyUtil 工具类
- 定义分隔符
:
以及实体获得赞的 key 前缀常量like:entity
。 - 新增
getEntityLikeKey(int entityType,int entityId)
方法,通过实体类型和实体 id 生成对应实体获得赞的 key。
创建业务层的 LikeService 类
- 注入 RedisTemplate 实例。
- 新增
like
点赞方法,首先通过 RedisKeyUtil 工具类的getEntityLikeKey
方法获得实体点赞的 key,然后通过 RedisTemplate 对象对 set 集合的isMember
方法查询 userId 是否存在于对应 key 的 set 集合中,如果存在则移除出点赞的用户集合,如果不存在则添加到点赞的用户集合。 - 新增
findEntityLikeCount
方法查询实体的点赞数量,通过调用 set 集合的size
方法查询元素个数。 - 新增
findEntityLikeStatus
方法查询某用户对某实体的点赞状态,逻辑如like
方法,通过 set 集合的isMember
方法实现。
创建表现层的 LikeController 类
- 注入 LikeService 和 HostHolder 实例。
- 新增
like
点赞方法,调用业务层的like
方法进行点赞、调用findEntityLikeCount
和findEntityLikeStatus
查询点赞数量和点赞状态,封装到 map 集合,然后通过工具类封装成 JSON 数据返回。
(更新首页帖子点赞数量)在表现层的 HomeController 类
- 注入 LikeService 实例。
- 在
getIndexPage
方法在通过 LikeService 类的方法获得点赞数量,存储到 map 集合。
收到的赞
对点赞功能进行重构
在 RedisUnitl 工具类
- 新增用户获得赞 key 的前缀常量
like:user
- 新增
getUserLikeKey(int userId)
方法,通过用户 id 生成对应用户获得赞的 key。
在 LikeService 中
- 重构
like
方法,在参数列表中加入 entityUserId 表示被点赞用户的 id,用来更新用户的被点赞数量。- 通过 RedisTemplate 对象的
execute
方法实现事务,保证被点赞用户点和点赞用户的数据更新一致。通过isMember
方法查询用户的点赞状态,之后通过mutli
方法开启事务。 - 当用户已点赞时,调用
remove
方法将当前用户从点赞用户的集合中移除,调用decrement
方法将被点赞用户的被点赞数减 1;当用户未点赞时,调用add
方法将当前用户添加到点赞用户的集合,调用increment
方法将被点赞用户的被点赞数加 1。
- 通过 RedisTemplate 对象的
- 增加
findUserLikeCount
方法,以用户 id 作为 key,调用get
方法查询用户所获得的点赞数。
在 LikeController 中给 like
方法增加 entityUserId 参数即可。
关注
在 RedisUnitl 工具类
- 新增用户关注实体(帖子、评论、用户等)和粉丝(用户)的前缀常量
followee
和follower
- 新增
getFolloweeKey(int userId, int entityType)
方法,通过用户 id 和实体类型生成用户关注实体的 key。 - 新增
getFollowerKey(int entityType, int entityId)
方法,通过实体类型和实体 id 生成实体用户粉丝的 key。
创建业务层的 FollowService 类
- 新增
follow
方法,当用户关注某实体时,- 调用
add
方法将当前实体 id 和时间作为 value 和 score加入用户的关注集合。 - 调用
add
方法将当前用户 id 和时间作为 value 和 score 加入实体的粉丝集合。
- 调用
- 新增
unfollow
方法,当用户取消关注某实体时,- 调用
remove
方法将当前实体从用户的关注集合移除。 - 调用
remove
方法将用户从实体的粉丝集合移除。
- 调用
个人主页
在业务层的 FollowService 类
- 新增
findFolloweeCount
方法,调用 zset 的zcard
方法查询某用户关注的实体数量。 - 新增
findFollowerCount
方法,调用 zset 的zcard
方法查询某实体的粉丝数量。 - 新增
hasFollowed
方法,根据 zset 的zscore
方法返回值查询当前用户是否关注某实体。
在 UserController 中新增 getProfilePage
方法获取个人主页。
- 调用 LikeService 的
findUserLikeCount
查询用户获赞数,并添加到 Model 中。 - 调用 FollowService 的
findFolloweeCount
、findFollowerCount
、hasFollowed
方法分别查询关注数量、粉丝数量、用户是否关注三项信息并添加到 Model 对象中存储。
关注列表和粉丝列表
在业务层的 FollowService 类
- 新增
findFollowees
方法,查询用户关注列表,主要通过 zset 的reverseRange
获取 value 即关注用户的 userId,再查询出其 user,之后通过score
获取关注时间,存入 map 集合,将 map 添加到 list 列表返回。 - 新增
findFollowers
方法,查询用户粉丝列表,主要通过 zset 的reverseRange
获取 value 即粉丝的 userId,再查询出其 user,之后通过score
获取关注时间,存入 map 集合,将 map 添加到 list 列表返回。
在表现层的 FollowController 类
- 新增
getFollowees
方法,获取关注列表,存入 Model 对象。 - 新增
getFollowers
方法,获取粉丝列表,存入 Model 对象。
优化登录模块
存储验证码
在 RedisUntil 工具类
- 新增验证码前缀常量
kaptcha
- 新增
getKaptchaKey
方法,通过一个用户凭证(由于未登录,利用 cookie 实现)获得对应验证码的 key 值(利用 string 存储验证码)。
在表现层的 LoginController 类
- 重构
getKaptcha
方法,将验证码存入 redis,key 值是当前随机生成的一个字符串,同时将该字符串存入 cookie。 - 重构
login
方法,从 cookie 中获得随机字符串,生成验证码的 key 值,然后获取对应的 value 值即验证码。
存储登录凭证
在 RedisUntil 工具类
- 新增登录凭证前缀常量
ticket
- 新增
getTicketKey
方法,通过字符串获得登录凭证的对应 key 值(利用 string 存储)。
在业务层的 UserService 类
- 重构
login
方法,将登录凭证存入 redis 中。 - 重构
logout
方法,先从 redis 中获取登录凭证对象,将状态设为无效再重新存储进 redis。 - 重构
findLoginTicket
方法,根据 ticket 字符串获得对应登录凭证的 key,然后从 redis 查询登录凭证。
缓存用户信息
在 RedisUntil 工具类
- 新增用户前缀常量
user
- 新增
getUserKey
方法,通过用户 id 获得用户的对应 key 值(利用 string 存储)。
在业务层的 UserService 类
- 新增
getCache
,从缓存获取用户信息。 - 新增
initCache
,从 MySQL 查询用户信息并存入 redis。 - 新增
clearCache
,用户信息变更(更新头像,激活)时清除缓存。 - 重构
findUserById
方法,首先调用getCache
从缓存获取用户信息,如果获取为 null 则调用initCache
。
Kafka
发送系统通知
在 CommunityConstant 接口中新增三个常量,代表三个主题:评论、点赞、关注。
创建 Event 类,封装事件对象,包括主题、用户 id、实体类型、实体 id、实体用户 id 以及一个 map 集合存放其它信息。
触发事件
创建 EventProducer 事件生产者,新增 fireEvent(Event event)
方法,通过 Event 获取事件类型,并将其封装成 JSON 数据,然后调用注入的 KafkaTemplate 实例的 send 方法发送。
在 CommentController、LikeControler、FollowController 中注入 EventProducer 实例,分别重构 addComment
方法、like
方法、follow
方法,封装 Event 对象,然后调用 EventProducer 的fireEvent
方法发布通知。
消费事件
创建 EventConsumer 事件消费者,消费者是被动触发的。
- 注入 MessageService 实例。
- 增加
handleCommentMessage(ConsumerRecord record)
方法,通过@KafkaListener
注解,topic 包括了评论、点赞和关注。从 recored 中获取信息,封装成 Message 对象然后调用addMessage
方法插入数据库。
【问题】没有向数据库插入系统通知记录,原因是 ServiceLogAspect 类进行日志处理时要获取 ServletRequestAttributes 请求对象,Kafka 的消费事件是自动触发的,没有进行新的请求,产生了请求对象的空指针异常。
显示系统通知
通知列表
在 MessageMapper 接口中
- 新增
selectLatestNotice(int userId, String topic)
方法,查询某主题最新的通知。 - 新增
selectNoticeCount(int userId, String topic)
方法,查询某主题通知的数量。 - 新增
selectNoticeUnreadCount(int userId, String topic)
方法,查询未读通知的数量。 - 在
message-mapper.xml
配置三个方法的 sql 语句,其中查询未读通知时使用 if 动态语句,如果没有传入 topic 就查询未读总量。
在业务层的 MessageService 中
- 新增
findLatestNotice
方法,调用selectLatestNotice
方法查询最新通知。 - 新增
findNoticeCount
方法,调用selectNoticeCount
方法查询某主题通知的数量。 - 新增
findNoticeUnreadCount
方法,调用selectNoticeUnreadCount
方法查询未读通知的数量。
在表现层的 MessageController 中新增 getNoticeList
方法,获取通知列表
- 调用业务层 MessageService 的方法查询评论、点赞、关注的通知,将其封装在一个 HashMap 集合中然后添加到 Model 对象里。
- 调用业务层 MessageService 的方法查询私信和通知的总未读数量,添加到 Model 对象里。
- 返回
notice.html
页面。
显示通知详情
在 MessageMapper 接口新增 selectNotices
方法,查询某个主题的通知列表,在 message-mapper.xml
配置 SQL。
在业务层的 MessageService 中新增 findNotices
方法,调用 selectNotices
方法。
在表现层的 MessageController 中新增 getNoticeDetail
方法
- 调用
findNotices
方法获取通知列表详情,封装到 List 集合并存入 Model 对象。 - 从通知集合中获取 id 集合,调用
readMessage
方法将消息设为已读。 - 返回
notice-detail.html
页面。
显示未读通知总数
创建 MessageInterceptor 拦截器
- 注入 MessageService 实例和 HostHolder 实例。
- 重写
postHandle
方法,查询私信和通知的未读数量和,然后添加到 ModelAndView 对象。
在 WebConfig 中注入 MessageInterceptor 实例,并在 addInterceptors
方法中添加该拦截器。
注册功能实现
用户注册,输入用户名、密码和邮箱,密码需要加盐加密再存入数据库
1 | user.setSalt(CommunityUtil.generateUUID().substring(0, 5)); |
默认未激活的用户状态 status=0 也会存入数据库,当然,我们会为该注册用户随机生成一个唯一的激活码一并存入数据库
1 | user.setStatus(0); |
这个激活码的作用就是用来激活该用户的。在本地环境下:http://localhost:8080/activation/用户id/激活码
就是该用户的激活链接;在服务器上:服务器公网 IP 地址/activation/用户id/激活码
就是该用户的激活链接。点击该激活链接则激活对应的用户,也就是修改数据库中用户表的字段 status 为 1,未激活的用户无法正常使用某些功能比如发表帖子等。
激活链接的校验逻辑就是检查一下这个链接中的用户 id 和激活码是否和数据库中存储的一样
1 |
|
这个激活链接是如何发送给用户的呢?通过邮件。我们使用 Spring Mail 给这个用户的邮箱发送激活邮件,这个激活邮件中就包含该用户的激活链接
1 | // 激活邮件 |
对应的激活邮件模板在 /mail/activation.html
1 |
|
用户注册的时候会为该用户生成一个随机头像,这个头像的地址会被存入 user 表。这个随机头像的实现用的是牛客的一个头像库,包含了 1000 张头像图片,比如第 66 张图片的访问地址就是 http://images.nowcoder.com/head/66t.png
,所以我们随机生成 1000 以内的数字就好了:
1 | user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000))); |
注册整体逻辑:
登录认证和授权功能实现
验证码机制
使用google提供的Kaptcha实现验证码,登录时要检查验证码。
首先,登录的时候会随机生成验证码,如何把这个验证码和当前用户对应起来,实现验证码的校验呢?
显然,由于这个时候用户还没有登录,我们是没有办法通过用户的 id 来唯一的对应它的验证码的。所以这个时候我们考虑生成一个随机的 id 来暂时的代替这个用户,将其和对应的验证码暂时存入 Redis 中(60s)。并且在 Cookie 中暂时存一份为这个用户生成的随机 id(60s)。
当用户点击登录按钮后,就会去 Cookie 中获取这个随机 id,然后去 Redis 中查询对应的验证码,判断用户输入的验证码是否一致。
登录认证并持有用户状态
用户输入用户名和密码并且校验完验证码之后,就登录成功了,那如何在一次请求中去保存这个用户的状态?如何回显用户的信息呢?
为此,设计了一个 LoginTicket
类:
每个用户登录成功后,都会为其生成一个随机的唯一的登录凭证实体类对象 LoginTicket
(包含用户 id、登录凭证字符串 ticket、是否有效、过期时间),把这个登录凭证实体类对象 存储在 Redis 中(key 就是登录凭证字符串 ticket)。而所谓登录凭证的无效,就是指用户登出后,这个凭证就会被设置为无效状态;凭证的默认过期时间是 1000s。这段代码在 UserService
中:
并且,我们在 Cookie 中也同样存储了一份登录凭证的字符串 ticket,过期时间和 Redis 中的是一样的。点击记住我可以延长过期时间。这段代码在 LoginController
中:
存储完 LoginTicket
后,我们就可以根据它来获取用户的状态了。我们定义了一个拦截器 LoginTicketInterceptor
,每次请求之前都会从 Cookie 获取到 ticket,然后根据 ticket 去 Redis 中查看这个用户的登录凭证 LoginTicket
是否过期和是否有效,只有登录凭证有效且没有过期才会执行请求,不然就会跳转到登录界面。
如果该用户的登录凭证有效且没有过期,那么就可以在本次请求中持有这个用户的信息了。如果持有呢?一般来说可以使用 Session,但是 Session 无法在分布式存储中发挥有效的作用。详细来说就是:客户端发送一个请求给服务器,经过负载均衡后该请求会被分发到集群中多个服务器中的其中一个,由于不同的服务器可能含有不同的 Web 服务器,而 Web 服务器之间并不能发现其他 Web 服务器中保存的 Session 信息,这样,它就会再次重新生成一个 JSESSIONID,导致之前的状态丢失。
所以这里我们考虑使用 ThreadLocal
保存用户信息,ThreadLocal
在每个线程中都创建了一个用户信息副本,也就是说每个线程都可以访问自己内部的用户信息副本变量。
HostHolder
类:
关于拦截器做的事情:
1)在 Controller 执行之前:检查登录凭证状态,若登录凭证有效且未过期则在本次请求中持有该用户信息
2)在模板引擎之前:将用户信息存入 modelAndView,便于模板引擎调用
3)在 Controller 执行之后(即服务端对本次请求做出响应后):清理本次请求持有的用户信息(也就是 ThreadLocal
的 remove
,如果没有即时 remove
会导致 OOM)
性能优化
这里有一个点我们进行了稍微的优化。就是我们的拦截器在每次请求前通过 Cookie 去 Redis 中查询登录凭证 LoginTicket
然后获取到用户 id 后,需要去数据库中查询用户信息,然后才能在本次请求中持有用户信息。
显然,每次请求前都需要经过这个步骤,这个访问数据库的频率还是很频繁的。因此我们考虑把登录成功的用户信息在 Redis 中保存一会,拦截器每次查询前先去 Redis 中查询,如果 Redis 中没有再去查询数据库,然后写进 Redis。OK,我们来看看 findUserById
方法具体是怎么实现的:
缓存和数据库的一致性问题的话,使用的是旁路缓存模式,也就是先更新数据库,然后直接删除缓存中的数据。比如对于修改用户密码、修改用户头像、激活用户后用户 status 的改变等,这些涉及数据库表中字段更新的操作,都需要删除缓存:
为什么是直接删除缓存,而不是也相应的更新缓存呢?
因为在多线程的环境下,假设线程 A 更新了数据库中的某个字段为 1,如果在线程 A 提交之前,线程 B 又修改了这个字段为 2 并且先于线程 A 做了提交,那么线程 A 接下来提交的数据就是脏数据。直接删除缓存可以避免这个问题。
总的来说,这个认证流程是这样的:
- 用户登录 —> 生成登录凭证存入 Redis,Cookie 中存一份 key
- 每次执行请求都会通过 Cookie 去 Redis 中查询该用户的登陆凭证是否过期和是否有效。点击记住我可以延长登录凭证的过期时间,用户退出则其登录凭证变为无效状态
- 根据这个登录凭证对应的用户 id,去数据库中查询这个用户信息
- 使用 ThreadLocal 在本次请求中一直持有这个用户信息
- 优化点:每次请求前都需要去数据库查询这个用户信息,访问频率比较高,所以我们考虑把登录成功的用户信息在 Redis 中保存一会,拦截器每次查询前先去 Redis 中查询,然后缓存和数据库的一致性问题的话,使用的是旁路缓存模式,也就是先更新数据库,然后直接删除缓存中的数据。
授权
认证的话上面大家也看到了,是我们自己写的逻辑,跳过了 Spring Security,那我们就需要把我们自己做的逻辑认证的结果存入 SecurityContext
,以便于 Spring Security 进行授权:
getAuthorities
就是从数据库中获取某个用户的权限(用户的权限/类型 type
是存在数据库表中的)
自定义这些权限拥有访问哪些路径的权力,比如:
另外,还需要定义一下权限不够时需要做哪些处理,注意区分下异步请求和普通请求,对于异步请求我们返回一个 JSON 字符串,对于普通请求我们直接返回错误界面即可:
退出登录
Spring Security 底层会默认拦截 /logout
请求,进行退出处理,由于退出的逻辑我们也自己实现了(将该用户的 LoginTicket
状态设置为无效):
所以我们赋予 Spring Security 一个根本不存在的退出路径,使得程序能够执行到我们自己编写的退出代码:
发帖功能实现
Ajax
首先,各位不妨想一想,在平常开发中,我们是怎么在前端跟后端之间进行数据交互的?
最常用最原始的,form 表单。通过 form 表单以 post/get 方式提交数据,当你点击 submit
按钮时,浏览器会把你在 input
里面输入的数据提交到 form 表单中的 action
这个路径。
但是这种方式在某些情况下,对用户来说并不友好。因为在进行提交时,页面会发生跳转或刷新,我给帖子点了个赞你页面还需要刷新一下?显然这样用户体验不好。
为此,Ajax 应运而生。Ajax 的全称是 Asynchronous JavaScript and XML(异步 JavaScript+XML),它并不是一种新的编程语言,而是一种使用现有标准的新方法。它依赖的是现有的 CSS/HTML/Javascript,而其中最核心的依赖是浏览器提供的 XMLHttpRequest
对象。这个对象为向服务器发送请求和解析服务器返回的响应提供了流畅的接口,使得浏览器可以发出 HTTP 请求与接收 HTTP 响应,实现在页面不刷新(局部刷新)的情况下和服务端进行数据交互。
Ajax 的工作原理大概是这样的:
同步、异步、阻塞、非阻塞
上面我们说到 Ajax 是异步的,那么所谓同步和异步,它们其实是两种不同的消息通信机制,我们以客户端(调用者)和服务端(被调用者)之间的通信为例:
- 同步,就是指客户端调用服务端的某个东西时,在没有得到调用结果之前,该调用就不会返回。也就是说客户端必须等到这个调用返回结果才能继续往后执行;
- 异步,和同步相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当客户端发送出了一个异步调用后,它不会立刻得到结果,而是在未来的某个时间,服务端通过状态、通知来通知客户端你的这个异步调用成功了,或者也可以通过回调函数来处理这个异步调用的返回结果。
举个通俗的例子:
你打电话问书店老板有没有某本书,如果是同步通信机制,书店老板会说,”您稍等,我查一下”,然后开始查啊查,等查好了(可能是 5 秒,也可能是一天)告诉你结果(返回结果);
而异步通信机制,书店老板直接告诉你 “我查一下啊,查好了打电话给你”,然后直接挂电话了(不返回结果)。然后查好了,他会主动打电话给你。在这里老板通过 “回电” 这种方式来回调。
另外,关于同步和异步,需要区别于阻塞和非阻塞,这几个概念经常容易混淆。阻塞和非阻塞关注的是客户端在等待调用结果时的状态:
- 阻塞调用,是指调用结果返回之前,客户端的当前线程会被挂起,这个调用线程只有在获取到服务端的调用结果之后才能继续运行;
- 非阻塞调用,就是说即使客户端的线程无法立即获取到服务端的调用结果,这个线程也不会被阻塞,它可以继续去做其他的事情。
还是上面的例子,你打电话问书店老板有没有某本书,如果是阻塞式调用,你会一直把自己 “挂起”,直到得到这本书有没有的结果;
如果是非阻塞式调用,你不管老板有没有告诉你,你自己就先去做别的事情了, 当然你也要偶尔过几分钟 check 一下老板这边有没有返回结果。
发帖功能解析
事实上,使用 JS 编写 Ajax 代码并不容易,因为不同的浏览器对 Ajax 的实现并不相同。这意味着我们必须编写额外的代码对浏览器进行测试。不过,jQuery 团队解决了这个难题,我们只需要一行简单的代码,就可以实现 Ajax 功能,这里就不再详细赘述了。本项目使用了 jQuery,那么发帖操作是如何发送异步请求的:
function(data)
就是回调函数,是 Ajax 在请求成功后自动调用的,参数 data 就是服务端返回的这个异步请求的值。
Ajax 会根据我们指定的 url /discuss/add
来找到对应的 Controller 方法,通过 id 选择器获取用户输入的数据,封装成 JSON 字符串发送过去(即帖子的标题和内容:{“title”: title, “content”: content}),这些数据会被自动传入到 Controller 的方法形参中。Controller 方法调用完成后,Ajax 会执行回调函数,获取 Controller 返回结果并执行相应操作。
使用 Ajax 异步提交代替传统的 form 表单提交的好处在于,使用异步方式与服务器通信,不需要打断用户的操作,具有更加迅速的响应能力,使得用户体验更好。
我们来看看 Controller 层的方法:
getJSONString
是我们自己写的一个工具类中的方法,通过阿里开源的 fastjson 将服务端返回的消息封装成 JSON 格式的字符串:
真正的发帖操作在 Service 层,其实就是一个插入数据库的操作,目前做的还比较简单,帖子的内容只能是普通的文本,后面会考虑支持 MarkDown 的。另外,这里有一个过滤敏感词的操作,涉及前缀树的设计与使用,后续会单开一篇文章详细讲解。
DiscussPostService.addDiscussPost
:
帖子列表与分页
帖子列表,也就是吃瓜论坛的首页,整体实现思路就是数据库利用limit 语句分页查询帖子,不过由于涉及到分页显示的问题,单独介绍。
dao层
DiscussPostMapper中定义接口:
各位可以看到selectDiscussPosts方法我们传入的参数比较多,适应性比较强,这样同一个功能的接口我们只需要写一个就行了。
为啥说它适应性比较强,我来解释一下:对于查询用户帖子这个功能,不仅仅只有首页有这个需求,在【个人中心-我的帖子】这个模块中也有这个需求。最简单的想法可能就是分别定义两个接口,一个用来根据用户id查询帖子,一个查询所有用户的帖子,对吧?这样一看,各位是不是觉得我们一个接口完成两个需求这样的设计就技高一筹了。
也就是说,我们不仅需要查询所有用户的帖子,还可能需要查询某一个特定用户的帖子。所以,在 selectDiscussposts 这个接口中我们传入一个动态的参数userId,为什么说它是动态的呢?因为在MyBatis中我们可以使用
SOL语句,我们来看看这个接口的具体实现你就知道了,以下代码片段详见 discusspost-mapper.xml:
解释一下上述代码的意思:
当传入的userld=0时分页查找所有用户的帖子。
当传入的userld!=0时分页查找该指定用户的帖子
另外,orderMode这个参数的是为了帖子按照时间排行还是按照热度排行而设计的。暂时我们不用管它,只需要知道orderMode=0的时候按照时间排行,orderMode=1的时候按照热度排行就行了。
当然,置顶的帖子不管是哪种排行类型那肯定仍然是置顶的,置顶帖的type=1,普通帖的type=0。所以各位也能看到orderby语句中对type的判断位居首位。
同样的,对于 selectdiscusspostrows 这个接口来说也是一样的,不仅首页需要查询帖子的总数,在【个人中心-我的帖子】这个模块中也有这个需求。所以我们传入的这个 userid 参数也同样是动态的,以下代码片段详见discusspost-mapper.xm1 :
封装分页模型
分页这个功能其实MyBatis拥有非常优秀的插件,这里自己写一个。
SQL中分页的语句:
1 | select * from tableName limit 5, 8; |
上面语句的意思是从索引5(第六条记录)开始查询,一共查询8条数据,也就是索引从5到12(第六条记录到第十三条记录)
分页、页码需要的信息有以下:
当前页码current(初始化为1)
单页显示的帖子数量上限(也就是上述写的SQL分页语句中的 limit)
当前页的起始索引(也就是上述写的SQL分页语句中的 offset),可以根据当前页码current和单页现实的帖子数量上限limit计算出来:
offset = current * limit -limit
一共有多少页(总页码total) 通过总数rows计算出来:
- 如果帖子总数是偶数:
total = rows / limit
- 如果帖子总数是奇数:
total = rows / limit + 1
- 如果帖子总数是偶数:
假设我们有100页的帖子,不能说分页栏直接从1显示到100吧,所以还需要知道分页栏的起始页码和终止页码:
起始页码可以通过当前页码current 来计算:
from = current -2
, 需要注意的是,起始页码最少是1,所以需要加个判断:from < 1?1 : from
to = current + 2
, 需要注意的是,终止页码不会超过总页码total,所以也需要加个判断:to > total ? total : to