当前位置:   article > 正文

基于 SpringBoot3 仿牛客论坛项目代码及踩坑总结_springboot仿牛客论坛项目实战

springboot仿牛客论坛项目实战

介绍

一个基本功能完整的论坛项目。项目主要功能有:基于邮件激活的注册方式,基于 MD5 加密与加盐的密码存储方式,登陆功能加入了随机验证码的验证。实现登陆状态的检查、为游客和已登录用户展示不同界面与功能。实现不同用户的权限控制和网站数据统计(UV、DAU),管理员可以查看网站数据统计和网站监控信息。支持用户上传头像,实现发布帖子、评论帖子、热帖排行、发送私信与敏感词过滤等功能。实现了点赞关注与系统通知功能。支持全局搜索帖子信息的功能。

项目仓库地址:https://github.com/SageSang/community.git

核心功能具体实现

  1. 通过对登录用户颁发登录凭证,将登陆凭证存进 Redis 中来记录登录用户登录状态,使用拦截器进行登录状态检查,使用 Spring Security 实现权限控制,解决了 http 无状态带来的缺陷,保护需登录或权限才能使用的特定资源。
  2. 使用 ThreadLocal 在当前线程中存储用户数据,代替 session 的功能便于分布式部署。在拦截器的 preHandle 中存储用户数据并构建用户认证的结果存入 SecurityContext,在 postHandle 中将用户数据存入 Model,在 afterCompletion 中清理用户数据。
  3. 使用 Redis 的集合数据类型来解决踩赞、相互关注功能,采用事务管理,保证数据的正确,采用“先更新数据库,再删除缓存”策略保证数据库与缓存数据的一致性。采用 Redis 存储验证码,解决性能问题和分布式部署时的验证码需求。采用 Redis 的 HyperLogLog 存储每日 UV、Bitmap 存储 DAU,实现网站数据统计的需求。
  4. 使用 Kafka 作为消息队列,在用户被点赞、评论、关注后以系统通知的方式推送给用户,用户发布或删除帖子后向 elasticsearch 同步,wk 生成长图后将长图上传至云服务器,对系统进行解耦、削峰。
  5. 使用 elasticsearch + ik 分词插件实现全局搜索功能,当用户发布、修改或删除帖子时,使用 Kafka 消息队列去异步将帖子给 elasticsearch 同步。
  6. 使用分布式定时任务 Quartz 定时计算帖子分数,来实现热帖排行的业务功能。
  7. 对频繁需要访问的数据,如用户信息、帖子总数、热帖的单页帖子列表,使用 Caffeine 本地缓存 + Redis 分布式缓存的多级缓存,提高服务器性能,实现系统的高可用。

核心技术

  • Spring Boot、SSM
  • Redis、Kafka、Elasticsearch
  • Spring Security、Quartz、Caffeine

项目亮点

  • 项⽬构建在 Spring Boot+SSM 框架之上,并统⼀的进⾏了状态管理、事务管理、异常处理;
  • 利⽤ Redis 实现了点赞和关注功能,单机可达 5000TPS;
  • 利⽤ Kafka 实现了异步的站内通知,单机可达 7000TPS;
  • 利⽤ Elasticsearch 实现了全⽂搜索功能,可准确匹配搜索结果,并⾼亮显示关键词;
  • 利⽤ Caffeine+Redis 实现了两级缓存,并优化了热⻔帖⼦的访问,单机可达 8000QPS。
  • 利⽤ Spring Security 实现了权限控制,实现了多重⻆⾊、URL 级别的权限管理;
  • 利⽤ HyperLogLog、Bitmap 分别实现了 UV、DAU 的统计功能,100 万⽤户数据只需*M 内存空间;
  • 利⽤ Quartz 实现了任务调度功能,并实现了定时计算帖⼦分数、定时清理垃圾⽂件等功能;
  • 利⽤ Actuator 对应⽤的 Bean、缓存、⽇志、路径等多个维度进⾏了监控,并通过⾃定义的端点对数据库连接进⾏了监控。

在这里插入图片描述

软件架构

软件版本

JDK 17.0.6 + apache-maven-3.9.1 + Spring Boot 3.1.0 + Spring Security 6.1.0 + Redis7.0.11 3 主 3 从集群 + kafka_2.12-2.4.1 集群 + Elasticsearch 7.17.10 + kaptcha2.3.2(验证码工具) + wkhtmltopdf(长图生成工具) + MySQL 8.0.32

技术栈

SpringBoot + MyBatis + Spring Email + Kaptcha + Redis + Kafka + Elasticsearch + Spring Security + Quartz + wkhtmltopdf + caffeine + Spring Boot Actuator

sql 文件介绍

在 init-sql 中有数据库建表脚本:

  1. init_schema.sql --> 建表 sql
  2. init_data.sql --> 初始化数据库数据 SQL
  3. tables_mysql_innodb.sql --> quarter 定时任务表 SQL

部署教程

  1. 拉取项目
  2. 创建 community 数据库,根据建表语句建好需要的表
  3. 准备好 Redis 集群、Kafka 集群、Elasticsearch 及 ik 分词插件、wk 环境
  4. 根据自己机器在 application-develop.properties 中修改数据源、SpringMail、Redis 集群、Kafka 集群、Elasticsearch、wk 路径、七牛云 url 的配置项
  5. 依次启动 Redis 集群,Zookeeper,kafka 集群,Elasticsearch
  6. 启动项目即可完成部署

项目中遇到的问题总结

Redis 中序列化问题

在 IDEA 中新增配置类:

@Configuration
public class RedisConfig {

    /**
     * *redis序列化的工具定置类,下面这个请一定开启配置
     * *127.0.0.1:6379> keys *
     * *1) “ord:102” 序列化过
     * *2)“\xaclxedlxeelx05tixeelaord:102” 野生,没有序列化过
     * *this.redisTemplate.opsForValue(); //提供了操作string类型的所有方法
     * *this.redisTemplate.opsForList();// 提供了操作List类型的所有方法
     * *this.redisTemplate.opsForset(); //提供了操作set类型的所有方法
     * *this.redisTemplate.opsForHash(); //提供了操作hash类型的所有方认
     * *this.redisTemplate.opsForZSet(); //提供了操作zset类型的所有方法
     * param LettuceConnectionFactory
     * return
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
        RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(lettuceConnectionFactory);
        // 设置key序列化方式string
        redisTemplate.setKeySerializer(RedisSerializer.string()); // RedisSerializer.string() 等价于 new StringRedisSerializer()

        // 设置value的序列化方式json,使用GenericJackson2JsonRedisSerializer替换默认序列化
        redisTemplate.setValueSerializer(RedisSerializer.json()); // RedisSerializer.json() 等价于 new GenericJackson2JsonRedisSerializer()

        // 设置hash的key的序列化方式
        redisTemplate.setHashKeySerializer(RedisSerializer.string());

        // 设置hash的value的序列化方式
        redisTemplate.setHashValueSerializer(RedisSerializer.json());

        // 使配置生效
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

如果在 Reids 命令行中,可以在启动命令后加-raw来解决序列化问题,例如:

redis-cli -a 123456 -p 6379 -c -raw
  • 1

Redis 的分布式锁

浅谈 Redis 的 setNX 分布式锁redisconnection.setnx叁柚木的博客-CSDN 博客

Elasticsearch6.x 与 Redis 启动冲突问题

报错:java.lang.IllegalStateException: availableProcessors is already set to [4], rejecting [4]

原因:SpringBoot 的 spring-boot-starter-data-redis 默认是以 lettuce 作为连接池的, 而在 lettuce,elasticsearch transport 中都会依赖 netty, 二者的 netty 版本不一致,不能够兼容。NettyRuntime 类中有下面的方法,启动的时候 Redis 和 ElasticSearch 都会调用,然后就会报下面绿字错误。即 Redis 先设置好了 availableProcessors 处理器,es 又来设置,系统就会认为重复了,就不会启动。

是由 es 调用这段代码所产生的错误!在 es 底层代码 Netty4Utils 类中能看到下面代码,只要调用了红框内的代码,因为 Redis 已经初始化过 availavleProcessors 了,所以不为 0,则 es 就会报错。

解决方案:es 中的处理比较狭隘,别人也可以依赖 netty 呀,所以我们可以修改源码位置留的开关,来达到不报错的目的。这个开关可以在在启动类初始化的时候进行配置,设置为 false 后,就会跳过下面会报错的检查了。

启动类初始化的时候进行配置来解决问题:
在这里插入图片描述
还有一种解决方法,直接使用 es7.x ,升级 es7.x 后不会遇到这个问题了。当然,es7 与 es6 的操作差距很大,有很多变化。我采用的是使用 es7 来解决这个问题。

Spring Security 中权限不能生效

官网文档地址:Persisting Authentication :: Spring Security

  • 原因

    springsecurity 持久化分为两个步骤:

    1. 在运行前,SecurityContextHolder 从 SecurityContextRepository 中读取 SercurityContext
    2. 运行结束后,SecurityContextHolder 将修改后的 SercurityContext 再存入 SecurityContextRepository 中,以便下次访问

    而在 springsecurity6.1.0 中使用 SecurityContextHolder 更改 SercurityContext 时,没有上述的第二步,即虽然更改了但是没有保存,下次访问时无法识别更改的内容。

    故需要在更改后自己手动保存 SercurityContext 到 securityContextRepository 中(持久化认证)

  • 修改过程

    // 在SecurityConfig中增加配置SecurityContextRepository
    @Bean
    public SecurityContextRepository securityContextRepository() {
        return new HttpSessionSecurityContextRepository();
    }
    
    // 在LoginTicketInterceptor中注入这个Bean
    @Autowired
    private SecurityContextRepository securityContextRepository;
    
    // 在LoginTicketInterceptor中preHandle里,修改Context内容后增加保存SercurityContext
    SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
    
    securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

退出账户后访问需要登录的功能会显示没有权限而不是没有登录

  • 原因分析

    这是因为在退出的时候也只是清理了 SecurityContextHolder,而认证信息已经存在了 session 里,没有被清理(securityContextRepository 是基于 session 的)

  • 解决措施一(不优雅)

    在 logout 里清理 SecurityContextHolder 后,给浏览器的 response 里增加一个对应访问认证信息的 cookie,赋予随机值,覆盖掉原本的 cookie,让浏览器无法访问原本的信息

    Cookie cookie = new Cookie("JSESSIONID", CommunityUtil.generateUUID());
    response.addCookie(cookie);
    
    • 1
    • 2
  • 解决措施二

    在自定义的 logout 功能里调用 LogoutHandler 彻底地清理授权信息。

    参考文档地址:https://docs.spring.io/spring-security/reference/servlet/authentication/logout.html#creating-custom-logout-endpoint

    具体做法:

    1. 在 SecurityConfig 中配置一个 LogoutHandler

      @Bean
        public SecurityContextLogoutHandler securityContextLogoutHandler() {
          return new SecurityContextLogoutHandler();
        }
      
      • 1
      • 2
      • 3
      • 4
    2. 在 LoginController 里注入 securityContextLogoutHandler(代码略)

    3. 修改我们的 logout 功能,调用 securityContextLogoutHandler

      @GetMapping("/logout")
        public String logout(@CookieValue("ticket") String ticket, HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
          userService.logout(ticket);
          // 加入下面这一句
          securityContextLogoutHandler.logout(request, response, authentication);
          return "redirect:/login";
        }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7

Redis 集群合并多个 HyperLogLog 统计数据的时候报错

即执行 redisTemplate.opsForHyperLogLog().union(unionKey, redisKey2, redisKey3, redisKey4); 时报错。

Java 上报错:org.springframework.dao.InvalidDataAccessApiUsageException: All keys must map to same slot for pfmerge in cluster mode

查了很久没有找到有关报错的讨论,于是在 redis 命令行上用命令 PFmerge test:hll:union test:hll:02 test:hll:03 复刻 IDEA 上的操作,也报错了。

Redis 命令行上报错:CROSSSLOT Keys in request don’t hash to the same slot

终于找到原因了,由于不在一个哈希槽的数据不能一起操作,这是为集群的安全性着想。我们可以使用 {} 来解决问题。

在启用集群模式的集群上创建由多密钥操作使用的密钥时,请使用哈希标签将密钥强制放入同一哈希槽中。当密钥包含“{…}”这种样式时,只有大括号“{”和“}”之间的子字符串得到哈希以获得哈希槽。

例如,密钥 {user1}:myset{user1}:myset2 被哈希到相同的哈希槽,因为只有大括号“{”和“}”内的字符串,即“user1”,用于计算哈希槽。

关于版本的坑

Elasticsearch

es7 中废除了ElasticsearchTemplate ,需要使用 RestHighLevelClient 来操作。具体见:SpringBoot3 整合 ElasticSearch7 示例springboot 集成 elasticsearch7叁柚木的博客-CSDN 博客

Spring Security

Spring Security 6.1.0 中废除了 WebSecurityConfigurerAdapter。

Spring Security in Spring Boot 3 - Stack Overflow

查询光放文档获得解决方案:

@Configuration
@EnableWebSecurity
public class SecurityConfig implements CommunityConstant {

    /**
     * 静态资源不做认证
     *
     * @return
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().requestMatchers("/resources/**");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 授权
        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers(
                                "/user/setting",
                                "/user/upload",
                                "/discuss/add",
                                "/comment/add/**",
                                "/letter/**",
                                "/notice/**",
                                "/like",
                                "/follow",
                                "/unfollow"
                        )
                        .hasAnyAuthority(
                                AUTHORITY_USER,
                                AUTHORITY_ADMIN,
                                AUTHORITY_MODERATOR
                        )
                        .anyRequest()
                        .permitAll()
        );

        // 权限不够的时候处理
        http.exceptionHandling((exceptionHandling) ->
                exceptionHandling
                        .authenticationEntryPoint(new AuthenticationEntryPoint() {
                            // 没有登陆
                            @Override
                            public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                                String xRequestedWith = request.getHeader("x-requested-with");
                                if ("XMLHttpRequest".equals(xRequestedWith)) {
                                    response.setContentType("application/plain;charset=utf-8");
                                    PrintWriter writer = response.getWriter();
                                    writer.write(CommunityUtil.getJSONString(403, "请您先登陆呢~"));
                                } else {
                                    response.sendRedirect(request.getContextPath() + "/login");
                                }
                            }
                        })
                        .accessDeniedHandler(new AccessDeniedHandler() {
                            // 权限不足
                            @Override
                            public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                                String xRequestedWith = request.getHeader("x-requested-with");
                                if ("XMLHttpRequest".equals(xRequestedWith)) {
                                    response.setContentType("application/plain;charset=utf-8");
                                    PrintWriter writer = response.getWriter();
                                    writer.write(CommunityUtil.getJSONString(403, "你没有访问此功能的权限!"));
                                } else {
                                    response.sendRedirect(request.getContextPath() + "/denide");
                                }
                            }
                        })
        );

        // Security 底层默认会拦截 /logout 请求,进行退出的处理。
        // 我们覆盖它默认的逻辑,才能执行我们自己退出的代码
        http.logout((logout) ->
                logout.logoutUrl("/securitylogout")
        );

        return http.build();
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82

Quartz

刚开始引入的时候突然报 There is no DataSource named 'null’的错误

然后把注释去掉就又能正常执行,一度认为自己哪里写错了,对着视频看了挺久还是没法解决

然后就想到了配置的问题,配置了数据源还是报错找了好久才想到会不会是版本问题,把代码贴一份到以前 2.5 以下的工程里面又能正常执行 orz

2.6.0 spring 以上需把配置数据源实现的 class 从 org.quartz.impl.jdbcjobstore.JobStoreTX 改为 org.springframework.scheduling.quartz.LocalDataSourceJobStore。

# Quartz
spring.quartz.job-store-type=jdbc
spring.quartz.scheduler-name=communityScheduler
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
#spring.quartz.properties.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX 老版本的设置,2.5.6之后的版本改为下面的配置项了。
spring.quartz.properties.org.quartz.jobStore.class=org.springframework.scheduling.quartz.LocalDataSourceJobStore
spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate
spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool
spring.quartz.properties.org.quartz.threadPool.threadCount=5
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

如果有帮助到大家的话,留下你的赞和收藏呗 ~

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/笔触狂放9/article/detail/299242
推荐阅读
相关标签
  

闽ICP备14008679号