• 基于Redis的Bitmap位图配合前端组件实现用户签到功能


    博客:https://www.mintimate.cn
    Mintimate’s Blog,只为与你分享

    一位B站粉丝,问我后端Java和前端Vue,如何实现一个简单的签到功能,在吃了顿大餐后,顺便也把主要过程分享一下。

    如果一个系统,想要实现签到功能,相信大多数人的第一反应都是Redis或者MySQL数据库。而使用Redis的Bitmap位图,主要是对资源的利用比较小,接下来就来详解一下啦。

    为什么使用位图

    位图,其实就是基于位的映射。BitMap 的基本原理就是用一个bit 位来存放某种状态,适用于大规模数据,但数据状态又不是很多的情况。通常是用来判断某个数据存不存在的

    举个例子,我们用bit的0和1来作为签到状态的有无,那么8天的签到数据就是8bit(1B),1个月的数据就是4B左右,也就是一个月一个用户的签到数据为4字节(4B)。

    一个用户一个月的签到数据

    前置依赖

    总体上,我们将前后端分别部署在腾讯云的服务器上,中间件使用Redis进行签到信息的持久化存储,需要注意⚠️,Redis设置的有效期,我们设置为永不过期。

    后端

    这里介绍一下生产开发的环境,首先是后端:

    • JDK版本:ZuluOpenJDK 11
    • Maven骨架
    • Redis

    我这里使用Maven进行项目依赖包的管理,并使用了SpringBoot自带的Redis依赖驱动:

    
     <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-data-redisartifactId>
    dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    对Redis进行序列化:

    /**
     * Redis设置
     */
    @Configuration
    public class RedisConfig {
        @Bean
        public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
            RedisTemplate<String, Object> template = new RedisTemplate<>();
            template.setConnectionFactory(factory);
            Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
            ObjectMapper om = new ObjectMapper();
            om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
            om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                    ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
            jackson2JsonRedisSerializer.setObjectMapper(om);
            StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
            // key采用String的序列化方式
            template.setKeySerializer(stringRedisSerializer);
            // hash的key也采用String的序列化方式
            template.setHashKeySerializer(stringRedisSerializer);
            // value序列化方式采用jackson
            template.setValueSerializer(jackson2JsonRedisSerializer);
            // hash的value序列化方式采用jackson
            template.setHashValueSerializer(jackson2JsonRedisSerializer);
            template.afterPropertiesSet();
            return template;
        }
    }
    
    • 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

    并且,我们创建一个签到的工具包,方便我们调用:

    签到工具包

    前端

    而对于前端,我使用的目前还是Vue2,并且使用组件Buefy的日期:

    buefy的日期组件

    Redis签到

    我们使用Redis的Bitmap进行签到,使用org.springframework.data.redis.core包下的opsForValue进行签到信息映射;

    方法结构

    其中,公共方法:

    • isSigned:传入用户Key和校验签到日期,判断是否有签到。
    • daSign:传入用户信息和需要签到的日期,返回签到结果(连续签到天数等)
    • monthSigned:传入用户Key和校验签到月份,返回当月签到情况详情。

    而签到的信息,我们使用日期工具包构建用户的签到结果集合key,并设置Bitmap数值。

    构建用户的签到key:

    /**
     * 构建 Redis Key - user:sign:userId:yyyyMM
     *
     * @param userId 用户ID
     * @param date   日期
     * @return
     */
    private String buildSignKey(String userId, Date date) {
        return String.format("img2d_user_daily_sign:%s:%s", userId,
                DateUtil.format(date, "yyyyMM"));
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    实际上,就是构建用户的Redis的key:

    比如:2022年5月,用户雪花ID为1452998090465296386的key:

    用户的key

    而Redis内存储的value就是我们的Bitmap数据。

    日期工具包

    首先,在正式构建业务逻辑前,我们需要设计几个日期工具包的方法包,首先是用户获取当前的时间:

        /**
         * 获取日期
         *
         * @param dateStr yyyy-MM-dd
         * @return
         */
        private Date getDate(String dateStr) {
            return Objects.isNull(dateStr) ?
                    new Date() : DateUtil.parseDate(dateStr);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    DateUtil是我自己写的日期方法:

     /**
         * 格式化日期
         *
         * @param StrDate
         * @return
         */
        public static Date parseDate(String StrDate) {
            // e.g. 获得2022年02月15日 的Date对象
            DateFormat dateFormat1 = new SimpleDateFormat("yyyy-MM-dd");
            Date myDate1 = null;
            try {
                myDate1 = dateFormat1.parse(StrDate);
            } catch (ParseException e) {
                e.printStackTrace();
            }
            return myDate1;
        }
    
        /**
         * 格式化日期
         *
         * @param date
         * @param format
         * @return
         */
        public static String format(Date date, String format) {
            // 获得2009年06月01日 的Date对象
            DateFormat dateFormat1 = new SimpleDateFormat(format);
            String myDate1 = dateFormat1.format(date);
    
            return myDate1;
        }
    
    • 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

    这样,就可以获取当天时间的yyyy-MM-dd格式了。当然,我们使用Bitmap进行数据存储,就需要判断签到月份有几个天数,进而生成Bitmap类型的String(Redis内,Bitmap本质使用String进行存储),所以在DateUtil工具包内追加:

        /**
         * 根据日期获取日期所在月份的天数
         *
         * @param date
         * @return
         */
        public static int dayOfMonth(Date date) {
            Calendar calendar = Calendar.getInstance();
            calendar.setTime(date);
            return calendar.get(Calendar.DATE);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    最后的结果:

    工具包结果

    用户签到

    我们使用刚刚构建的工具包,记得完成签到业务,并且可以进行补签:

        /**
         * 用户签到,可以补签
         *
         * @param userId  用户ID
         * @param dateStr 查询的日期,默认当天 yyyy-MM-dd
         * @return 连续签到次数和总签到次数
         */
        public Map<String, Object> doSign(String userId, String dateStr) {
            Map<String, Object> result = new HashMap<>();
            // 获取日期
            Date date = getDate(dateStr);
            // 获取日期对应的天数,多少号
            int day = DateUtil.dayOfMonth(date) - 1; // 从 0 开始
            // 构建 Redis Key
            String signKey = buildSignKey(userId, date);
            // 查看指定日期是否已签到
            if (isSigned(userId,dateStr)) {
                result.put("message", "当前日期已完成签到,无需再签");
                result.put("code", 400);
                return result;
            }
            // 签到
            redisTemplate.opsForValue().setBit(signKey, day, true);
            // 根据当前日期统计签到次数
            Date today = new Date();
            // 统计连续签到次数
            int continuous = getContinuousSignCount(userId, today);
            // 统计总签到次数
            long count = getSumSignCount(userId, today);
            result.put("message", "签到成功");
            result.put("code", 200);
            result.put("continuous", continuous);
            result.put("count", count);
            return result;
        }
    
    • 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

    我这里并没有封装结果集,所以使用Map进行回传。

    连续判断

    如何判断用户连续签到几天呢?有一个简单的方法:位移计算。

    /**
         * 统计连续签到次数
         *
         * @param userId 用户ID
         * @param date   查询的日期
         * @return
         */
        private int getContinuousSignCount(String userId, Date date) {
            // 获取日期对应的天数,多少号,假设是 31
            int dayOfMonth = DateUtil.dayOfMonth(date);
            // 构建 Redis Key
            String signKey = buildSignKey(userId, date);
            // e.g. bitfield user:sign:5:202103 u31 0
            BitFieldSubCommands bitFieldSubCommands =
                    BitFieldSubCommands.create()
                    .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
                    .valueAt(0);
            // 获取用户从当前日期开始到 1 号的所有签到状态
            List<Long> list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);
            if (list == null || list.isEmpty()) {
                return 0;
            }
            // 连续签到计数器
            int signCount = 0;
            long v = list.get(0) == null ? 0 : list.get(0);
            // 位移计算连续签到次数
            for (int i = dayOfMonth; i > 0; i--) {// i 表示位移操作次数
                // 右移再左移,如果等于自己说明最低位是 0,表示未签到
                if (v >> 1 << 1 == v) {
                    // 用户可能当前还未签到,所以要排除是否是当天的可能性
                    // 低位 0 且非当天说明连续签到中断了
                    if (i != dayOfMonth) break;
                } else {
                    // 右移再左移,如果不等于自己说明最低位是 1,表示签到
                    signCount++;
                }
                // 右移一位并重新赋值,相当于把最低位丢弃一位然后重新计算
                v >>= 1;
            }
            return signCount;
        }
    
    • 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

    再写一个方法,方便我们调用:

        /**
         * 统计总签到次数
         *
         * @param userId 用户ID
         * @param date   查询的日期
         * @return
         */
        private Long getSumSignCount(String userId, Date date) {
            // 构建 Redis Key
            String signKey = buildSignKey(userId, date);
            // e.g. BITCOUNT user:sign:5:202103
            return (Long) redisTemplate.execute(
                    (RedisCallback<Long>) con -> con.bitCount(signKey.getBytes())
            );
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    最后结果:

    最后结果

    签到详情

    这里我们还需获取月份对应的签到详情,我们可以这样:

        public String monthSigned(String userId,String dateStr){
            // 获取日期
            Date date = getDate(dateStr);
            String signKey = buildSignKey(userId, date);
            // 获取日期对应的天数,多少号,假设是 31
            int dayOfMonth = DateUtil.dayOfMonth(date);
            BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
                    .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
                    .valueAt(0);
            // 获取月份datOfMonth到1号的所有签到状态
            // (也就是:如果签到情况为003,则显示3;签到情况为1003,则显示1003)
            List<Long> list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);
            String total=Long.toBinaryString(list.get(0));
            return total;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    需要注意List list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);获取的数值,是会去除前面的零。

    效果

    我们编写一个测试类,打印输出试试看:

    测试代码

    运行后:

    运行输出

    Redis内存储:

    Redis内的存储效果

    到此,后端的Redis就写好了。

    前端渲染

    后端怎么设计API,前端怎么请求API数据,这类基础方法,这里就不再赘述。直接处理,前端怎么渲染签到天数。

    我们这里根据后端写的代码,请求的月份签到,可以直接用前文的签到详情获取。

    数据处理

    因为,我们获取的数据,会自动忽略前面的零,举个例子,二月份我们只在15号签到,那么我们在2022-02-15这天获取的数据“签到详情”就是:

    获取的详情

    因为15号前并没有签到,全部为0,获取的数据就只有1了。

    相对的,前端就需要给1前面补零:

    const today = new Date().getDate()
    for (let len = (dateList + "").length; len < today; len = dateList.length) {
                dateList = "0" + dateList;
    }
    
    • 1
    • 2
    • 3
    • 4

    当然,我这个是只统计当前日期所在月份当天前的签到情况,如果你想改成历史统计,注意修改代码。

    之后,就是一段0和1组成的数据,比如:

    000000000000001
    
    • 1

    数据渲染

    我们使用Buefy的日期组件:

            <b-datepicker
              class="is-centered"
              expanded
              inline
              v-model="date"
              :events="events"
              :min-date="new Date()"
              :max-date="new Date()"
              indicators="bars"
            >
            b-datepicker>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    使用效果:

    组件效果

    在将刚刚的数据处理后结果二次处理:

    for (let [index, value] of dateList.split("").entries()) {
      if (value == 1) {
       if ((index + 1) == today) {
         this.isDisabled = true
       }
       this.events.push({
        date: new Date(thisYear, thisMonth, index + 1),
        type: 'is-success'
      })
     }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    最后效果:

    签到效果

    END

    到此,我们的签到功能就设计好啦~~是不是还是挺简单的。

    当然,有更好的完善方法,就要看自己的业务需求进行更改了。其实Bitmap位图,在布隆过滤器里用的更频繁,有机会也和大家分享一下。

  • 相关阅读:
    计算机毕业设计选什么题目好?springboot 高校学生综合测评管理系统
    Kubernetes创建Service访问Pod
    你还在 Docker 中跑 MySQL?恭喜你,可以下岗了!
    Spring IOC
    熊孩子说“你没看过奥特曼”,赶紧用Python学习一下,没想到
    MySQL1
    牛客小白月赛 61 E 排队
    pytorch-神经网络-手写数字分类任务
    计算机毕业设计(40)java毕设作品之在线购物网站销售系统
    JVM8 元空间
  • 原文地址:https://blog.csdn.net/weixin_43890033/article/details/126021300