项目篇:Echo论坛系统项目

一、登录注册模块

1、注册功能

1.1、注册流程图

 

1.2、注册代码
/**
     * 用户注册
     * @param user
     * @return Map<String, Object> 返回错误提示消息,如果返回的 map 为空,则说明注册成功
     */
    public Map<String, Object> register(User user) {
        Map<String, Object> map = new HashMap<>();

        if (user == null) {
            throw new IllegalArgumentException("参数不能为空");
        }
        if (StringUtils.isBlank(user.getUsername())) {
            map.put("usernameMsg", "账号不能为空");
            return map;
        }

        if (StringUtils.isBlank(user.getPassword())) {
            map.put("passwordMsg", "密码不能为空");
            return map;
        }

        if (StringUtils.isBlank(user.getEmail())) {
            map.put("emailMsg", "邮箱不能为空");
            return map;
        }

        // 验证账号是否已存在
        User u = userMapper.selectByName(user.getUsername());
        if (u != null) {
            map.put("usernameMsg", "该账号已存在");
            return map;
        }

        // 验证邮箱是否已存在
        u = userMapper.selectByEmail(user.getEmail());
        if (u != null) {
            map.put("emailMsg", "该邮箱已被注册");
            return map;
        }

        // 注册用户
        user.setSalt(CommunityUtil.generateUUID().substring(0, 5)); // salt
        user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt())); // 加盐加密
        user.setType(0); // 默认普通用户
        user.setStatus(0); // 默认未激活
        user.setActivationCode(CommunityUtil.generateUUID()); // 激活码
        // 随机头像(用户登录后可以自行修改)
        user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));
        user.setCreateTime(new Date()); // 注册时间
        userMapper.insertUser(user);

        // 给注册用户发送激活邮件
        Context context = new Context();
        context.setVariable("email", user.getEmail());
        // http://localhost:8080/echo/activation/用户id/激活码
        String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode();
        context.setVariable("url", url);
        String content = templateEngine.process("/mail/activation", context);
        mailClient.sendMail(user.getEmail(),"激活 Echo 账号", content);

        return map;
    }

1.3、性能优化

使用线程池完成异步发送邮箱 ,当有发送邮箱的需求的时候,可以在线程池中开启线程异步发送邮箱,使用@Async注解在方法上添加即可,并且在启动类添加@EnableAsync注解

2、登录模块

2.1、登录页面

2.2、登录验证码问题

        首先,登录的时候会随机生成验证码,如何把这个验证码和当前用户对应起来,实现验证码的校验呢?

        显然,由于这个时候用户还没有登录,我们是没有办法通过用户的 id 来唯一的对应它的验证码的。所以这个时候我们考虑生成一个随机的 id 来暂时的代替这个用户,将其id和对应的验证码暂时存入 Redis 中(60s)。并且在 Cookie中暂时存一份为这个用户生成的随机 id(60s)。

        其中生成验证码和进行校验分别是两个URL请求地址。

        这样,当用户点击登录按钮后,就会去 Redis中获取这个随机 id和验证码,去Cookie查询对应的验证码,判断用户输入的验证码是否一致。

2.3、登录认证并持有用户状态问题

        用户输入用户名和密码并且校验完验证码之后,就登录成功了,那我们如何在一次请求中去保存这个用户的状态?如何回显用户的信息呢?

做法可以设计一个类如下图:

        解释一下,每个用户登录成功后,我们都会为其生成一个随机的唯一的登录凭证实体类对象 LoginTicket(包含用户 id、登录凭证字符串 ticket、是否有效、过期时间),我们把这个登录凭证实体类对象永久的存储在 Redis 中(key 就是登录凭证字符串 ticket,value是LoginTicket),而Cookie保存的时候凭证类的凭证信息ticket。而所谓登录凭证的无效,就是指用户无请求访问,凭证类的过期时间不再刷新,当再次请求访问的时候,对比本地时间和凭证类时间判断是否过期。

        存储完 LoginTicket 后,我们就可以根据它来获取用户的状态了。我们定义了一个拦截器 LoginTicketInterceptor每次请求之前都会从 Cookie获取到 ticket,然后根据 ticket 去 Redis 中查看这个用户的登录凭证 LoginTicket 是否过期和是否有效,只有登录凭证有效且没有过期才会执行请求,不然就会跳转到登录界面。

        如果该用户的登录凭证有效且没有过期,那我们就可以在本次请求中持有这个用户的信息了。如何持有呢?这里我们考虑使用 ThreadLocal 保存用户信息,ThreadLocal 在每个线程中都创建了一个用户信息副本,也就是说每个线程都可以访问自己内部的用户信息副本变量,从而实现了线程隔离,来看下 HostHolder 类:

        所以将登录成功后要保存的信息为:

        将生成的凭证保存到Redis上,并且设置过期时间,置state为1,其中key为凭证,value为LoginTicket 类。

       然后每次请求首先经过拦截器,通过Cookie获取ticket凭证。凭借ticket凭证从Redis获取LoginTicket 类的信息。如果存在就将通过LoginTicket 类的用户id查询用户信息其保存到ThreadLocal中,否则拦截。

2.4、退出功能

        从Redis根据凭证删除LoginTicket 信息,执行ThreadLocal的remove()方法清空用户信息。

2.5、性能优化

        因为每次请求需要在拦截器中通过通过Cookie获取凭证,然后去Redis获取LoginTicket 类。如果通过验证则会每次去数据库查询用户信息,这样导致每一次请求访问都会去数据库查询造成巨大的访问压力。

        为了避免这种情况,所以拦截器首先去Redis查询用户信息,如果有则直接保存到ThreadLocal,否则再去数据库查询用户信息,再保存到Redis中。

2.6、流程
  • 用户登录 —> 生成登录凭证作为key存入 Redis,value是凭证类信息,Cookie 中存一份 ticket凭证
  • 每次执行请求之前,拦截器都会通过 Cookie 去 Redis 中查询该用户的登陆凭证是否过期和是否有效。点击记住我可以延长登录凭证的过期时间,用户退出则其登录凭证变为无效状态
  • 根据这个登录凭证对应的用户 id,去数据库中查询这个用户信息
  • 使用 ThreadLocal 在本次请求中一直持有这个用户信息
  • 优化点:每次请求前都需要去数据库查询这个用户信息,访问频率比较高,所以我们考虑把登录成功的用户信息在 Redis 中保存一会,拦截器每次查询前先去 Redis 中查询
 2.7、流程图

二、帖子模块

1、发布帖子

这里使用前端富文本编译器功能可以让用户可以上传图片和视频以及文字功能。

上传图片,删除图片,下载图片使用的是阿里云OSS功能

上传视频、删除视频,播放视频使用的是阿里云视频点播功能

如上图所示,帖子有分类模块,在发布帖子的时候可以选择分类的模块

如上图所示 

2、实体类

        这里保存着用户的id,文章内容、图片url、视频播放url、文字类型、帖子点赞统计、评论统计、状态、创建时间、修改时间等

3、效果展示

        使用MybatisPlus分页+阿里云OSS图片功能+阿里云视频点播功能,这样可以直观的展示最终内容。

4、热门帖子排行功能

论坛首页进去的内容按照热门进行排行的,如果要求分页显示热门度排行前10的帖子功能。

首先是实体类如下:

帖子表

点赞表

对于该帖子赞数的统计思路:

发布定时任务,定时通过该帖子的id在点赞表中查询到该帖子的点赞统计数量保存到数据库的帖子表中。

对于所有帖子,根据其点赞数量通过order by进行倒叙排序,并分页进行输出。

这样就可以获得热门帖子信息。

5、性能优化

Redis+Canal+MySQL binlog实现缓存一致性

如上图,因为帖子的访问量比较巨大,所以使用redis实现对热门帖子的缓存,但是帖子会存在数据库和缓存不一致问题,这里使用redis+MySQL的binlog实现缓存一致性

  • 首先在mysql中开启binlog日志。
  • 然后在linux部署canal中间件,在配置文件中配置mysql的ip地址、端口号以及用户名密码,启动canal服务。
  • 然后在Springboot中整合canal,使用canal-spring-boot-starter整合。
  • 编写监听器,实现EntryHandler接口,重写里面的增删改方法,一旦数据库发送改变时候,就可以监听MySQL的binlog,然后对缓存进行修改。

对于该项目而言,通过canal监听数据库的binlog,每当帖子发送修改的时候,就会通过canal去监听mysql的binlog,然后去通过更新redis里面的数据。

使用guava 布隆过滤器机制解决缓存穿透问题

在java项目中引入guava,并且设置配置类,进行误判率设置,一般为0.05。然后专门设置一个请求接口,用来给布隆过滤器进行填充数据,填充的数据是当前帖子的所有数据填充进去。

后来的请求访问当中如果能在布隆过滤器找到就直接返回数据,如果不能就从redis查找。

三、评论模块

1、帖子评论

如果直接点击帖子下面的评论,该评论为帖子评论 ,就会在数据库中标记为帖子评论,并且生成唯一评论标识符,关联该帖子的id

2、用户回复评论

如果在帖子点击回复功能,那么该评论为用户评论,就会在数据库中标记为用户评论,并且生成唯一评论标识符,关联该用户的id。

在评论中还存在某用户回复某用户这个功能。这个功能和上述一样,只需要前端根据当前评论id和保存的用户id就,并且使用分页功能按时间排序就可以实现该功能。

3、实体类

四、私信模块

1、效果展示

2、详细步骤

2.1、私信列表

        该功能显示目前有多少用户与其对话,并显示其他用户的信息,以及已读未读状态,包含列表功能 。

        对于如何查找该用户与哪一个用户直接进行过聊天,直接通过conversation_id字段进行匹配。因为该字段保存的时候两个用户id的字符串,中间用间隔号“_”来间隔,所以使用字符串截取获取两个用户id。

        为了避免重复统计,这里使用Set来去重,并且conversation_id的命名规则按照字典顺序,左边小右边大,比如102和101之间对话,就保存为"101_102"这种格式。这样可以根据模糊匹配从数据库拿到所有数据,然后保存到Set集合中,再获取里面的数据并进行字符串截取获取两个用户id,这样就能拿到对话用户的数量以及id。

每一次会话会保存发送方id,接收方id,会话状态,会话表示,以及会话时间。

2.2、详细对话

        在实体类有form_id和to_id,这两个id分别对应发送方form_id和接收方to_id。当获取到conversation_id时候,就可以根据这个conversation_id来获取双方的会话信息按照时间排序显示数据。

        比如两个用户id为101和102。对于当前等于用户101而言,自己发送一条消息,那么发送方是自己,接收方是102,反之亦然。这样就可以根据当前用户id来根据时间排序获取对话消息并以分页功能展示。

2.3、发送消息

        对于发送消息,在会话表根据字典排序保存当前登录用户id和对方会话用户id到conversation_id中,并保存发送方id和接收方id,以及status状态默认设置为0未读,发送消息的内容和发送时间。        

2.4、未读消息数量统计

        首先根据登录用户id通过conversation_id经过模糊匹配查询到与该用户会话相关数据。从得到数据中条件查询to_id为登录用户id并且status=0的数据,这样就可以获取相应的数据进行数量统计。

3、实体类

  • 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 - 已读
  • create_time:私信/系统通知的发送时间

五、点赞模块

1、Redis键值对设计

  • key: (String) 目标id和点赞用户id拼接而成, 分隔符为_
  • value: (HashMap) 存储点赞状态(0: 点赞 1:取消点赞)和更新时间的时间戳

key=目标id_点赞用户id

value={time: long}

不管是用户还是帖子,都这样设计。

2、统计点赞数量

这样有个好处就是如果统计帖子点赞数量或者该用户评论被赞数量,只需要通过redis模糊匹配目标id就可以知道被赞的数量

Set keys = redisTemplate.keys("目标id_" + "*");
int size = keys.size();

3、判断是否被赞 

如果要看该帖子或者该评论是否被该用户点赞,只需要通过查询key<目标id,用户id>获取其值判断是否有内容就知道是否被赞了

Set keys = redisTemplate.keys(RedisUtils.setKey(目标id, 用户id));
if(keys.size()==0){
System.out.println("未点赞");
}else{
System.out.println("已点赞");
}

4、点赞

根据key<目标id,用户id>,value<时间戳>保存到reids中

redisTemplate.opsForSet().add(RedisUtils.setKey(目标id,用户id),RedisUtils.setValue());

设计定时任务,过一段时间保存到mysql中,并统计表中统计点赞总数

5、取消点赞

可以直接删除key<目标id,用户id>,这样就会取消点赞,被赞目标总数量会下降1。

Boolean delete = redisTemplate.delete(RedisUtils.setKey("目标id","用户id"));
if (delete){
System.out.println("取消成功");
}else{
System.out.println("取消失败");
}

6、被赞人获取的总赞数

总赞数构成:帖子获赞总数+评论或者总数

在mysql实体类中查看点赞总数

这是RedisUtils实体类

public class RedisUtils {
    /**
     * 点赞设置key
     * @param Id1 目标id
     * @param Id2 点赞用户id
     * @return
     */
    public static String setKey(String Id1,String Id2){
        return Id1+"_"+Id2;
    }

    /**
     * 点赞设置value
     * 设置点赞时间戳
     * @return
     */
    public static Map<String,Long> setValue(){
        Map<String,Long> map=new HashMap<>();
        Instant instant = LocalDateTime.now().toInstant(ZoneOffset.ofHours(8));
        long millisecond = instant.toEpochMilli();
        map.put("time",millisecond);
        return map;
    }
}

7、实体类

7.1、赞数统计表

7.2、点赞信息表

六、系统通知模块

1、概述

系统通知是一个很常见且必要的需求,当发生点赞、关注、评论操作的时候,系统就会给相应的用户发送通知。

对于流量巨大的社交网站,系统通知的需求是非常庞大的,那如果只是和私信或者发帖功能一样单纯地用 Ajax 做个异步,显然是远远不够的。所以为了保证系统的性能,这里非常有必要使用消息队列(消息队列三大作用:解耦、异步、消峰),Echo 中选用的是 Kafka。

整体来看就两个需求,发送系统通知和显示系统通知:

1.1、发送系统通知:
  • A 给 B 点赞,给 B 发送 点赞 类型的系统通知(TOPIC_LIKE
  • A 给 B 点赞,给 B 发送 关注 类型的系统通知(TOPIC_FOLLOW
  • A 给 B 点赞,给 B 发送 评论 类型的系统通知(TOPIC_COMMNET

整体逻辑就是,当发生比如点赞操作的时候,就会触发消息队列的点赞事件,然后消费者消费这个事件,具体的消费逻辑就是往系统通知表里面插入一条数据(系统通知也使用私信那张表 message,不过系统通知的 from_id 在代码里写死了为 1,表示是系统发送出来的,所以这也就是为什么说大家在部署的时候一定要注意在 user 表中事先存储一个 id = 1 的用户)。

1.2、显示系统通知:
  • 系统通知列表(显示点赞、评论、关注三种类型的通知)
  • 系统通知详情(分页显示某一类型所包含的系统通知)
  • 显示未读消息数量

2、封装事件对象

消费者想要通过消费这个消息实现往数据库表 message 中插入一条记录的目的,那么这个消息或者说事件是不是就应该具备 message 表中的所有字段,或者说从消息中能够推出这些字段。

另外,MQ是发布订阅模型,一对多,消息以 Topic(主题)进行分类,生产者将消息发布到某个Topic 中,消费者可以订阅该 Topic。以点赞事件为例,看下图:

发送方为点赞、关注、评论操作时候,接收方为响应的用户

效果类似b站这样

当点赞评论关注的时候,就会发送数据,然后接收到的数据进行分类识别

代码:

/**
     * 消费评论、点赞、关注事件
     * @param record
     */
    @KafkaListener(topics = {TOPIC_COMMNET, TOPIC_LIKE, TOPIC_FOLLOW})
    public void handleMessage(ConsumerRecord record) {
        if (record == null || record.value() == null) {
            logger.error("消息的内容为空");
            return ;
        }
        Event event = JSONObject.parseObject(record.value().toString(), Event.class);
        if (event == null) {
            logger.error("消息格式错误");
            return ;
        }

        // 发送系统通知
        Message message = new Message();
        message.setFromId(SYSTEM_USER_ID);
        message.setToId(event.getEntityUserId());
        message.setConversationId(event.getTopic());
        message.setCreateTime(new Date());

        Map<String, Object> content = new HashMap<>();
        content.put("userId", event.getUserId());
        content.put("entityType", event.getEntityType());
        content.put("entityId", event.getEntityId());
        if (!event.getData().isEmpty()) { // 存储 Event 中的 Data
            for (Map.Entry<String, Object> entry : event.getData().entrySet()) {
                content.put(entry.getKey(), entry.getValue());
            }
        }
        message.setContent(JSONObject.toJSONString(content));

        messageService.addMessage(message);

    }

七、项目难点

1、使用布隆过滤器解决Redis的缓存穿透问题

这里使用的是使用guava来实现布隆过滤器

首先从guava进行判断数据是否存在,如果返回数据则说明过滤器有数据,如果不返回数据,说明布隆过滤器不存在该数据,则从redis进行数据的获取

猜你喜欢

转载自blog.csdn.net/weixin_55127182/article/details/131998298