同步操作将从 李莲花/community 强制同步,此操作会覆盖自 Fork 仓库以来所做的任何修改,且无法恢复!!!
确定后同步将在后台操作,完成时将刷新页面,请耐心等待。
这是一款基于Springboot的牛客论坛项目,项目实现功能:包括注册,登录,帖子,多级评论,点赞,关注,私信,搜索,系统通知,网站数据统计,热帖排行,敏感词过滤,全局异常处理,统一日志记录等。使用技术栈:SpringBoot,Springmvc,Mybatis,Spring Email,Spring Security,Mysql,redis,ElasticSearch,Kafka,Quartz
软件架构说明
User UserMapper user-mapper.xml MapperTests UserService DiscussPost DiscussPostMapper discusspost-mapper.xml MapperTests DiscussPostService
发送邮箱 要启用客户端SMTP服务,导入jar包,配置邮箱参数,使用JavaMailSender发送邮件,使用Thymeleaf发送HTML邮件 MailClient MailTests demo.html
注册功能 :提交注册数据(通过表单提交数据;服务端验证数据是否存在,邮箱是否已注册;服务端发送激活邮件),激活注册账号(点击邮件链接,访问服务端的激活服务)register.html CommunityUtil(生成随机字符串,MD5加密)UserService 写register()方法,实现密码加密,生成激活码,获取随机头像,激活邮件 activation.html LoginController 写register()方法, CommunityConstant 常量接口 让UserService实现这个接口 写activation()方法,让LoginController实现这个接口 写activation()方法, 生成验证码:KaptchaConfig 配置类 LoginController写getKaptcha()方法
登录功能 :验证账号,密码,验证码;成功时,生成登录凭证,发放给客户端;失败时,跳转回登录页 LoginTicket LoginTicketMapper(这里使用注解实现dao(代码简单时可用),上面都是使用xml文件)MapperTests UserService 写login()方法 LoginController 写login()方法 在CommunityConstant常量接口中加相应的常量 login.html
退出登录时将登录凭证改为失效状态 UserService 写logout()方法 LoginController 写logout()方法 显示登陆信息: 拦截器 :在请求开始时查询登录用户,在本次请求中持有用户数据,在模板视图上显示用户数据,在请求结束时清理用户数据 AlphaInterceptor 拦截器 WebMvcConfig 拦截器配置文件 UserService 写findLoginTicket()方法 HostHolder 持有用户信息,用于代替session对象 LoginTicketInterceptor 拦截器
账号设置: 上传文件 setting.html 配置资源上传路径 UserService 写updateHeader()方法 UserController 写uploadHeader()方法和getHeader()方法 检查登录状态 使用拦截器在方法前标注自定义注解,拦截所有请求,只处理带有该注解的方法。在用户未登录时无论如何也不能访问到一些功能 LoginRequired 自定义注解 (拦截器拦截的注解)LoginRequiredInterceptor拦截器 WebMvcConfig 拦截器配置文件
对前台传来的注册表单数据进行判重判空和数据库匹配后,如果注册成功,将用户信息存入数据库:注意:密码的存储是经过加盐和md5加密的,防止密码泄露。数据库存储了该用户的盐和加密的密码。然后发送激活邮件,用户点击链接则激活账号。 mailClient工具类封装了JavaMailSender进行邮箱激活,需要在配置文件中进行邮箱SMTP服务的配置。这里用子线程去发送邮件,防止卡顿时间过长。 邮箱里的激活链接即是通过"/activation/{userId}/{code}"访问路径修改用户的status字段,使其可用。 **
登录时检查信息正确性的逻辑和注册时基本一致,需要对账号进行非空、存在和激活的判断,对验证码进行判断,对密码进行非空和正确的判断,以及是否有rememberMe。登陆成功后生成LoginTicket存入数据库,记录了用户ID、ticket、过期时间等,ticket字段会被放入cookie中。 LoginTicket登录凭证之所以存入数据库,是考虑到了session在分布式环境下请求分发导致的会话状态无法保持的问题。 ** 验证码** 使用google提供的Kaptcha实现验证码,登录时要检查验证码,逻辑如下: 生成图片时存入session(后面用Redis优化):登录时从session中取值和表单值进行比对即可
状态保持 登录后需要进行状态保持,可以用前面提到的登录凭证+Interceptor+ThreadLocal实现。 拦截器在preHandle时检查cookies中是否有有效ticket,有的话就在当前请求中持有用户信息。HostHolder类封装了ThreadLocal< User >,ThreadLocal的目的:Tomcat服务器会使用独立线程去处理每个请求,因此需要隔离多请求多用户,防止信息混乱。 拦截器在postHandle时若发现该次请求中有用户信息,需要在 modelAndView中添加用户信息以保持状态。 在afterCompletion清空信息即可。
附:登录模块逻辑: 进入登录界面,随机生成一个字符串来标识这个将要登录的用户,将这个字符串短暂的存入 Cookie(60 秒); 动态生成验证码,并将验证码及标识该用户的字符串短暂存入 Redis(60 秒); 为登录成功(验证用户名、密码、验证码)的用户,随机生成登录凭证且设置状态为有效,并将登录凭证及其状态等信息永久存入 Redis,再在 Cookie 中存一份登录凭证; 使用拦截器在所有的请求执行之前,从 Cookie 中获取登录凭证,只要 Redis 中该凭证有效并在有效期内,本次请求就会一直持有该用户信息(使用 ThreadLocal 持有用户信息,保证多台服务器上用户的登录状态同步); 勾选记住我,则延长 Cookie 中登录凭证的有效时间; 用户登出,将凭证状态设为无效,并更新 Redis 中该登录凭证的相关信息
File文件上传,修改用户的头像链接使其可以通过url访问头像图片。图片存放位置可暂存本地,后改为云服务器。 修改头像(异步请求) 将用户选择的头像图片文件上传至七牛云服务器 修改密码
简单的权限管理 设置页面和修改头像请求显然必须登录才能使用,可以通过注解进行简单的权限管理: 自定义@LoginRequired注解类,并添加到需要权限的方法上,然后通过拦截器进行判定。在访问当前方法时若有该注解则必须是已登录状态
支持按照 “发帖时间” 显示 支持按照 “热度排行” 显示(Spring Quartz) 将热帖列表和所有帖子的总数存入本地缓存 Caffeine(利用分布式定时任务 Spring Quartz 每隔一段时间就刷新计算帖子的热度/分数 — 见下文,而 Caffeine 里的数据更新不用我们操心,它天生就会自动的更新它拥有的数据,给它一个初始化方法就完事儿)
分页 通过Page类封装分页逻辑:前台传来的current等参数通过controller的Page类参数进行封装,从而实现页面跳转。该模块可用于其他地方大量复用。
发布帖子(过滤敏感词),将其存入 MySQL 异步发送请求(使用ajax,网页能够将增量更新呈现在页面上,而不需要刷新整个页面),以通过提示框展示提示信息,发帖后台就是普通的crud。
基于Trie前缀树结构,查找效率高(消耗内存大),常应用于字符串检索,词频统计,字符串排序等。该模块可用于帖子、评论、私信等。 敏感词过滤器: 定义前缀树 根据敏感词,初始化前缀树 编写过滤敏感词的方法
评论部分前端的名称显示有些缺陷,有兴趣的小伙伴欢迎提 PR 解决 ~ 关于评论模块需要注意的就是评论表的设计,把握其中字段的含义,才能透彻了解这个功能的逻辑。 评论 Comment 的目标类型(帖子,评论) entityType 和 entityId 以及对哪个用户进行评论/回复 targetId 是由前端传递给 DiscussPostController 的 一个帖子的详情页需要封装的信息大概如下:
发布对帖子的评论(过滤敏感词),将其存入 MySQL 评论分为对帖子的评论(简称评论)和对评论的评论(简称回复)。 entityType+entityId指示评论的对象(是帖子还是评论,然后具体Id为多少),当entityType为评论,且该条评论为回复时有效,为0表示该条回复评论的是对帖子的评论,非0则是代表该条回复评论的是回复,另外注意,帖子entity中有个关于评论的冗余数据,因此有新评论产生时需要通过事务进行更新:
其中conversationId由fromId和toId拼接而成,小Id在前,如111_112,表示111和112之间的私信。status记录私信是否已读,当用户进入私信详情页面时,会更新未读私信状态为已读。
采用异步的方式发送私信,发送成功后自动刷新私信列表 查看私信详情时,将显示的私信设置成已读状态
JavaWeb的思想是异常尽量不处理,而是往上层抛给controller去处理。spring对此提供了简单支持,若有4xx或5xx异常,则返回的页面为/error包下对应的4xx.html或5xx.html。但我们有如下需求: 记录错误日志 对于异步/非异步提供友好提示 因此我们可以用@ControllerAdvice注解实现异常处理
我们需要知道哪些用户什么时候对什么方法进行了访问,因此最好能提供日志记录,一个解决思路是对于每个方法都进行硬编码记录日志,但这种思路显然违背了开闭原则,不利于扩展和维护。因此我们可以用AOP的思想解决问题,Spring对此提供了友好支持:(AOP面向切面编程,可以进一步提高编程效率) 切入时机可以是pointcut调用前(@Before),pointcut调用后(@After),环绕pointcut(@Around),return后(@AfterReturning),异常后(@AfterThrowing)。这里只需@Before即可。
点赞关注使用较频繁,是通过Redis实现的。我们在配置类中将RedisTemplate<String, Object>注入Spring容器,该Bean需要设置key和value的序列化方式(存入的有可能是对象,采用json格式进行序列化)。
点赞用Ajax实现,可对帖子也可对评论点赞,第一次点赞,第二次取消点赞(1:点过赞 0:未点过赞),并显示点赞状态。根据返回的点赞状态和点赞数量进行正确的局部更新。Service方法如下: 其中RedisKeyUtil是一个构造Redis key的工具类,生成的key是由各个字段用冒号隔开的(Redis的惯用key命名方式)。 我们通过set数据结构记录某个实体(如帖子)的点赞用户Id,此外用一个string数据结构记录某个用户获得的赞总数: 这两个数据结构的操作需要用到Redis的事务一并实现。
将点赞相关信息存入 Redis 的数据结构 set 中。其中,key 命名为 like:entity:entityType:entityId,value 即点赞用户的 id。比如 key = like:entity:2:246 value = 11 表示用户 11 对实体类型 2 即评论进行了点赞,该评论的 id 是 246 某个用户的获赞数量对应的存储在 Redis 中的 key 是 like:user:userId,value 就是这个用户的获赞数量
重构点赞功能:以用户为key,记录点赞数量,查询点赞数量
统计用户的关注数,粉丝数 关注的对象可以是用户、帖子、评论。和点赞的区别在于:key 中需要包含关注者和被关注者这两个变量。 若A关注了B,则A是B的粉丝,B是A得目标
Service层定义follow和unfollow等方法,试举一例: 你可以查看别人的关注列表,并对列表中的用户进行关注。 这里要用zset数据结构,在进行关注的人/粉丝列表显示的时候,可以根据关注时间进行排序显示。
若 A 关注了 B,则 A 是 B 的粉丝 Follower,B 是 A 的目标 Followee 关注的目标可以是用户、帖子、题目等,在实现时将这些目标抽象为实体(目前只做了关注用户) 将某个用户关注的实体相关信息存储在 Redis 的数据结构 zset 中:key 是 followee:userId:entityType ,对应的 value 是 zset(entityId, now) ,以关注的时间进行排序。比如说 followee:111:3 对应的value (20, 2020-02-03-xxxx),表明用户 111 关注了一个类型为 3 的实体即人(用户),关注的这个实体 id 是 20,关注该实体的时间是 2020-02-03-xxxx 同样的,将某个实体拥有的粉丝相关信息也存储在 Redis 的数据结构 zset 中:key 是 follower:entityType:entityId,对应的 value 是 zset(userId, now),以关注的时间进行排序
下面用Redis缓存进行了三处地方的性能优化。
代替session存储验证码 理由如下: 验证码需要频繁的访问和刷新,对性能要求较高 验证码只需要暂存,不需要长期保存 如未来涉及到分布式部署,能避免session共享的问题。
存储登录凭证 每次对网站的请求都会通过拦截器查询用户的登录凭证,因此考虑到凭证的时效性和经常性,可以改为用Redis存储凭证而不是用数据库存储。
存储用户信息 同上,每次对网站的请求都会通过拦截器获取用户的登录凭证,然后根据凭证获取用户信息以保持登录状态,访问频率非常高。因此对于findUserById这个方法,有必要做Redis缓存: 这样,整个拦截器所调用的方法就不会涉及到数据库了。 但用户信息的存储会涉及到Redis和MySQL的缓存不一致问题,需要解决: 1.优先从缓存中取值 2.取不到时初始化缓存数据 3.数据变更时清除缓存信息 关于缓存不一致问题,有很多解决方法,这里采用的是:当数据变更时,先更新数据库,再删除缓存。 当然无论是先更新数据库还是先删除缓存,都会有并发访问情况下的不一致问题和第二步操作失败的问题。 前一个问题可以采用延迟双删策略来解决。后一个问题可以用重试机制来解决
用Kafka做消息队列也能对系统进行优化。(Kafaka构建TB级异步消息系统) 原先Controller层的一些实现逻辑,可以转移到EventConsumer类中实现,享有消息队列异步削峰解耦的优势。例如系统通知的实现,本身和点赞、关注、评论的逻辑关联不强,且这些动作频繁发生,因此可以通过异步实现,提高性能。在例如后面用到的ES数据库的更新也可以用消息队列来实现。 触发事件:
**
至于系统消息的查看、列表等实现,则基本与私信的实现差不多。(数据库中from_id为1表示这不是普通私信而是系统通知) 显示评论,点赞,关注三种类型的通知 在页面头部显示所有未读消息的数量,也显示各种类型主题未读消息数量
Elasticsearch目前性能最好,最流行的分布式搜索引擎。支持对各种类型数据的检索,搜索速度非常快,可以提供实时的搜索服务,每秒可以处理PB级海量数据 Elasticsearch相当于特殊的数据库,搜索就是对这个数据库的搜索。 ElasticsearchService需要完成三个方法:save、delete和search,重点是search: 发布事件: 发布帖子时,通过消息队列将帖子异步地提交到 Elasticsearch 服务器 为帖子增加评论时,通过消息队列将帖子异步地提交到 Elasticsearch 服务器 搜索服务: 将帖子保存到Elasticsearch服务器 从 Elasticsearch 服务器搜索帖子 从 Elasticsearch 服务器删除帖子(当帖子从数据库中被删除时) 显示搜索结果: 在控制器中处理搜索请求,在HTML上显示搜索结果 类似的,置顶、加精也会触发发帖事件,就不再图里面画出来了。
Spring Security是一个专注于为java应用程序提供身份认证和授权的框架,它的强大之处在于它可以轻松拓展以满足自定义的需求。可以防止各种攻击,如会话固定攻击,点击劫持,csrf攻击等 废弃了之前采用拦截器实现的登录检查,使用 Spring Security框架来进行统一认证授权管理。Security底层原理蛮复杂的,我们这里对其进行简单的使用。 而用户认证方面,由于我们采用自定义的认证方式,因此无需采用Security提供的方式,但我们认证信息仍需要存到SecurityContext里,拦截器需要改进 认证信息会在preHandle加入,afterafterCompletion处删除,logout时也会删除。 关于认证信息何时删除的思考: 每次在afterCompletion时删除: Security是基于Filter的,如果每次在afterCompletion时删除,那么下次请求时首先到达Filter,由于没有认证信息,会被判定权限不够,直接跳转到登录页面,即使已经登录。 在afterCompletion时不删除,只在logout时删除: 如果这样虽然能保证权限与请求匹配,但是由于登录凭证会过期,用户信息会被清除,但认证信息却不会被清除,用户即使没登录也能访问,显然不合常理。 因此正确做法为afterafterCompletion处如果没有用户信息就删除认证信息,logout时也删除。
版主可以看到置顶,加精操作;管理员可以看到删除操作;点击置顶修改帖子的类型,点击加精,删除修改帖子的状态 Thymeleaf有对Security的支持,可以从SecurityContext从获得权限信息。从而赋予用户不同的权限(置顶、加精、删除)
独立访客 UV
使用Redis的HyperLogLog数据结构去实现,该数据结构的特点是占用内存很小,但会损失一定的统计精度。 使用拦截器,游客每次访问时Redis计入该ip。 统计区间UV:整理日期范围内的key,合并数据(union去重生成新数据),返回统计结果 日活跃用户 DAU
类似UV统计,只是DAU统计操作的是bitmap:统计区间内的DAU,整理日期范围内的key,or运算 权限管理(Spring Security)
使用Quartz实现热帖排行和更新,相比 JDK 的 ScheduledExecutorService 和 Spring 的 ThreadPoolTaskScheduler 的优势: Quartz 实现定时任务所依赖的参数是保存在数据库中,数据库只有一份,所以不会冲突。 而 ScheduledExecutorService 和 ThreadPoolTaskScheduler 是基于内存的,在分布式环境中,多台服务器会重复执行定时任务,产生冲突。 热帖排行和更新的实现逻辑如下: 帖子的score字段计算方法自定义为log(精华分+评论数 * 10+点赞数 * 2+收藏数*2)+(发布时间-牛客纪元)。 一旦涉及到上述score会变化的操作,如帖子被设为精华,或帖子有新的评论等,帖子id会被放入Redis的set中。 每隔一段时间执行定时任务,会从set中pop帖子id出来进行分数的刷新。 每次发生点赞(给帖子点赞)、评论(给帖子评论)、加精的时候,就将这些帖子信息存入缓存 Redis 中,然后通过分布式的定时任务 Spring Quartz,每隔一段时间就从缓存中取出这些帖子进行计算分数。 帖子分数/热度计算公式:分数(热度) = 权重 + 发帖距离天数
private void refresh(int postId) {
DiscussPost post = discussPostService.findDiscussPostById(postId);
//算分的时候发现被管理员删了
if(post == null) {
logger.error("待刷新帖子不存在: id = " + postId);
return;
}
//是否精华
boolean wonderful = post.getStatus() == 1;
//评论数量
int commentCount = post.getCommentCount();
//点赞数量
long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId);
//计算权重
double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2;
//分数 = 权重 + 距离天数
double score =
Math.log10(Math.max(w, 1)) + (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24);
//更新帖子分数
discussPostService.updateScore(postId, score);
//同步搜索数据
post.setScore(score);
elasticSearchService.saveDiscussPost(post);
}
热帖页面根据帖子的score进行排序。
使用wkhtmltopdf将牛客网html转换成pdf
头像上传到七牛云进行存储,spring对此提供了支持。
在实际的部署中,服务器缓存由于就在服务器本机内,因此对性能的提升相比Redis更高。 对于热帖等与用户状态无关的内容可以存到本地缓存中。本项目用Caffeine实现,主要缓存帖子列表和帖子总数。 需要定义两个缓存管理器,一个针对热帖列表,一个针对帖子总数,可以通过@PostConstruct初始化缓存 用JMeter做下小测试,30w数据的情况下,性能有数倍到数十倍的提升。
使用JMeter工具做压力测试,先在数据库里准备大量数据;在测试方法中比如我初始化30000条数据, 先进行不加缓存测试 双击启动bin目录下的jmeter.bat 添加-》线程-》线程组 填写线程名称,线程数,循环次数,持续时间等 添加-》取样器-》Http请求 填写服务器名称(ip),端口号,http请求方式,路径,编码类型等 添加-》定时器-》统一随机定时器 填写线程延迟属性(ms) 添加-》监听器-》聚合报告 在聚合报告看压力测试结果:主要看吞吐量那一栏(多测试几次,且要保证异常为0%) 然后加缓存进行测试 启用缓存,与上述一样进行压力测试,可以发现有缓存的吞吐量较高
需要修改一些路径以适配linux环境。为方便切换,可以使用两套配置文件。Spring允许项目中存在多套配置文件 (比如:开发时一套,部署时一套,测试时一套),通过一个开关来启用哪一套配置文件
整个项目部署到阿里云ECS上(2cpu/4g/CentOS),部署步骤为:
下载相关包:jdk,maven,MySQL、Redis、Kafka、Elasticsearch,Wkhtmltopdf,Tomcat,Nginx。 处理项目: 把项目部署到Tomcat下:Build -》Build Artifact-》community:war->Build,项目被打成war包丢到target包下, 将war包上传到tomcat的webapps文件夹中,部署成功。
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。