springboot整合redis基础实践

2023年 10月 6日 51.5k 0

前面我们开发了基于数据库操作的商品分类模块,为了提高存取效率我们决定将分类数据保存到基于内存存储的redis中,为此,本节我们把redis整合进来,一起进行下基础的实践吧。

准备工作

快速搭建单机redis

这里我们将采用docker-compose来快速部署单机服务,部署目录如下:

image.png

docker-compose.yml:

version: "3.8"
services:
  redis:
    # 设置自启动
    restart: always
    container_name: redis
    image: redis:6.2.6
    ports:
      - 16379:6379
    volumes: 
      - ./data:/data
      - ./redis.conf:/etc/redis/redis.conf
    # 设置执行的程序
    entrypoint: 
      - redis-server 
      - /etc/redis/redis.conf

外部的redis配置,redis.conf

#保护模式,默认是yes,只允许本地客户端连接
protected-mode no
# 容器内的端口号
port 6379
timeout 0
save 900 1 
save 300 10
save 60 10000
rdbcompression yes
dbfilename dump.rdb
dir /data
appendonly no
appendfsync no
# 设置密码
requirepass 123456

启动容器并做些简单的练习

# 在路径/usr/local/docker/redis下执行
# 赋予权限 实现持久化
chmod -R 777 ./data
docker-compose up -d
# 查看启动日志
docker-compose logs redis
# 进入容器操作
docker exec -it redis /bin/bash
redis-cli -h 127.0.0.1 -p 6379 -a 123456
# 简单操作
127.0.0.1:6379> set a 123
127.0.0.1:6379> get a
"123"
# 退出redis命令行
quit
# 退出容器
exit

# 停止再启动容器或者restart,登录查看发现之前操作的数据还在,说明volume有效
docker-compose stop
docker-compose start

客户端连接

这里我们使用idea自带的客户端。

image.png

image.png

控制台的使用

image.png

image.png

spring boot整合

引入依赖

dependencies {
    ...

    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

应用配置

application.yml

spring:
  ...
  redis:
    host: 192.168.1.113
    port: 16379
    password: 123456
    database: 0 # 要操作的库
    # 也可以使用lettuce,配置都一样,注意加入依赖:org.apache.commons:commons-pool2
    jedis:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
        max-wait: 1000

序列化配置

package com.xiaojuan.boot.config;

import ...

@Configuration
@ConditionalOnProperty("spring.redis.host")
public class RedisConfig {

    /**
     * redisTemplate默认序列化使用的jdkSerializeable, 存储二进制字节码, 为了增加可读性和可维护性,自定义序列化方式
     * @param factory
     * @return
     */
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate template = new RedisTemplate();
        template.setConnectionFactory(factory);

        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        SimpleModule simpleModule = new SimpleModule();
        // 自定义date的序列化/反序列化方式
        simpleModule.addSerializer(Date.class,new DateJsonSerializer());
        simpleModule.addDeserializer(Date.class,new DateJsonDeserializer());
        objectMapper.registerModule(simpleModule);
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.WRAPPER_ARRAY);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        RedisSerializer stringSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringSerializer);
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        return template;
    }
}

默认spring boot的redis模块会对redis的值采用Java类型的二进制存储形式,这不方便查看,因此我们将其配置为json格式的序列化结果。针对日期类型我们提供定制的序列化和反序列化方式:

日期的格式定义,精确到毫秒:

package com.xiaojuan.boot.common.consts;

public interface FormatConst {
    String DATE_JSON_FORMAT = "yyyy-MM-dd HH:mm:ss.SSS";
}

序列化实现:

package com.xiaojuan.boot.common.support.jackson;

import ...

public class DateJsonSerializer extends JsonSerializer {

    @Override
    public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeString(DateFormatUtils.format(value, FormatConst.DATE_JSON_FORMAT));
    }

    @Override
    public void serializeWithType(Date value, JsonGenerator g, SerializerProvider provider, TypeSerializer typeSer) throws IOException {
        WritableTypeId typeIdDef = typeSer.writeTypePrefix(g,
                typeSer.typeId(value, JsonToken.VALUE_STRING));
        serialize(value, g, provider);
        typeSer.writeTypeSuffix(g, typeIdDef);
    }
}

反序列化实现:

package com.xiaojuan.boot.common.support.jackson;

import ...

public class DateJsonDeserializer extends JsonDeserializer {

    @SneakyThrows
    @Override
    public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        String s = p.readValueAs(String.class);
        return DateUtils.parseDate(s, FormatConst.DATE_JSON_FORMAT);
    }

}

RedisTemplate使用

我们将采用单元测试的形式对RestTemplate提供的常用的API进行一一的实践。

单个值存取

package com.xiaojuan.boot.redis;

import ...

@SpringBootTest
@Slf4j
public class RedisTest {

    @Resource
    private RedisTemplate redisTemplate;

    @BeforeEach
    public void initDb() {
        flushdb();
    }

    @SneakyThrows
    @Test
    public void testOprSingleValue() {
        ValueOperations operations = redisTemplate.opsForValue();
        operations.set("msg", "hello, redis!");
        assertThat(operations.get("msg")).isEqualTo("hello, redis!");

        Date date = DateUtils.parseDate("2023-10-01 21:00:00", "yyyy-MM-dd HH:mm:ss");
        operations.set("date", date);
        assertThat(operations.get("date")).isEqualTo(date);
    }

    private void flushdb() {
        redisTemplate.execute((RedisCallback) connection -> {
            connection.flushDb();
            return "ok";
        });
    }

}

注意这里的redis当前库(0号库)仅用作测试,因此我们可以大胆的在执行用例前先清库,但这个方式也不是很优雅,后面我们将介绍redis隔离单元测试的testcontainers方式。

这里我们为一个key为msg的条目设置了简单的字符串值,并获取后对其进行断言。然后我们又测试了下保存日期类型的值的情况。

可以看到库中的数据格式:

image.png

实际序列化为json字符串为一个数组形式,其中包含了原始的Java类型以及格式化后的日期字符串。

对象存取

在使用redis保存对象时,如果对象中有字段需要经常更新,更好的存储形式为hash结构,即,将对象中的属性作为hash的key,这相比于整体保存一个对象,而仅仅为了更新对象中某个属性的值,而对一个对象的数据整体覆盖更新,执行效率会很低。

看下RedisTemplate相关API的操作实践:

@Test
public void testOprObj() {
    CategoryInfoDTO categoryInfo = new CategoryInfoDTO();
    categoryInfo.setId(1L);
    categoryInfo.setPid(0L);
    categoryInfo.setName("海鲜");
    categoryInfo.setLevel(1);
    categoryInfo.setOrderNum(1);
    categoryInfo.setCreateTime(new Date());
    categoryInfo.setUpdateTime(new Date());

    Map categoryInfoMap = ObjectMapUtil.bean2Map(categoryInfo, true);
    // 将对象保存为hash结构
    HashOperations oprs = redisTemplate.opsForHash();
    oprs.putAll("mall:category:1", categoryInfoMap);
    // 获取保存的hash结构
    Map map = oprs.entries("mall:category:1");
    // 把hash结构对应的map类型转成DTO类型
    CategoryInfoDTO category = ObjectMapUtil.map2Bean(map, new CategoryInfoDTO());
    assertThat(category.getName()).isEqualTo("海鲜");
}

代码说明

这里我们首先将DTO类型通过工具转成Map结构,再通过redisTemplatehash操作进行保存。注意,这里存的key,我们采用命名空间的写法:项目(模块):实体:主键

然后我们通过oprs.entries(key)方法将hash结构取出来,以Map类型返回,并对其进行DTO类型的转换,达到我们想要的结果。

这里存在一个问题,先看下redis保存的结构:

image.png

我们的Long类型的pidid字段在序列化为json的值后,类型丢了!这导致我们在反向转成DTO时,工具报了如下异常:

image.png

解决办法很简单,只要我们将要序列化的基本类型也同Date类型一样,保存下来,反序列化时类型就不会丢了。

接下来实现下自定义的基础类型(这里为Long类型)序列化器:

package com.xiaojuan.boot.common.support.jackson;

import ...

public class LongJsonSerializer extends JsonSerializer {

    @Override
    public void serialize(Long value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        String raw = "["java.lang.Long", "" + value + ""]";
        gen.writeRaw(raw);
    }
}

代码说明

这里我们直接使用了JsonGeneratorwriteRaw(rawStr),对我们的基本类型进行携带类型的包装后,直接写入。

然后我们在RedisConfig中做下配置:

...
simpleModule.addSerializer(Long.class,new LongJsonSerializer());
simpleModule.addSerializer(long.class,new LongJsonSerializer());
...    

再测试,可以看到数据类型也持久化下来了:

image.png

pipeline批处理

批量保存

如果我们要保存一个对象列表,可以对列表进行一一的遍历,然后依次调用之前我们保存对象的方式,但这种做法会与redis服务器交互多次,为了提高效率,我们可以采用pipeline的批处理方式,只需要提交一次。

@Test
public void testBatchSave() {
    List data = prepareData();
    String keyTemplate = "mall:category:%s";
    redisTemplate.executePipelined(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            data.forEach(item -> {
                Map map = ObjectMapUtil.bean2Map(item, true);
                operations.opsForHash().putAll(String.format(keyTemplate, item.getId()), map);
            });
            log.info("batch save data");
            return null;
        }
    });
}

说明

通过executePipelined方法,可以将要执行的redis操作命令存入一个缓冲的管道中,并进行批量的提交,以减少与服务器的通信次数,从而提高执行效率。

批量查找

现在我们有一个需求:请从redis中查找所有的一级分类,并以DTO列表的形式返回。

分析

redis支持对key的通配符形式的scan机制,我们可以基于此对我们要保存的实体的key进行命名空间的设计:比如除了分类的id,我们还可以携带pidlevel等属性值。

这样我们将通过scan相关的API的调用得到key列表,再基于key列表进行pipeline的批量查询操作,岂不快哉!

完整的实现:

@Test
public void testBatchOpr() {
    List data = prepareData();
    // 命名空间的设计形式:项目:分类:主键:层级
    String keyTemplate = "mall:category:%s:%s";
    redisTemplate.executePipelined(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            data.forEach(item -> {
                Map map = ObjectMapUtil.bean2Map(item, true);
                operations.opsForHash().putAll(String.format(keyTemplate, item.getId(), item.getLevel()), map);
            });
            log.info("batch save data");
            return null;
        }
    });

    // scan模糊查找
    List keys = new ArrayList();
    // 这里指定层级为1级,对主键进行通配
    String keyPattern = "mall:category:*:1";
    redisTemplate.execute((RedisCallback) connection -> {
        // 指定扫描的通配符模式和要扫描的数量(实际这里扫描的分类数据量并不会很大)
        Cursor cursor = connection.scan(new ScanOptions.ScanOptionsBuilder().match(keyPattern).count(Integer.MAX_VALUE).build());
        cursor.forEachRemaining(item -> {
            keys.add(RedisSerializer.string().deserialize(item));
        });
        return keys;
    });
    log.info("获得一级分类的key列表:{}", keys);

    // 批量查询
    List items = redisTemplate.executePipelined((RedisCallback) redisConnection -> {
        for (String key : keys) {
            redisConnection.hashCommands().hGetAll(key.getBytes());
        }
        return null;
    });

    List categories = new ArrayList();
    for (Object item : items) {
        // todo 这里可以按照排序字段orderNum排序,排序实现逻辑省略
        categories.add(ObjectMapUtil.map2Bean((Map) item, new CategoryInfoDTO()));
    }

    assertThat(categories.size()).isEqualTo(2);

}

最后我们把所有的单元测试完整跑一遍来结束本节,大家加油!

image.png

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论