前面我们开发了基于数据库操作的商品分类模块,为了提高存取效率我们决定将分类数据保存到基于内存存储的redis中,为此,本节我们把redis整合进来,一起进行下基础的实践吧。
准备工作
快速搭建单机redis
这里我们将采用docker-compose来快速部署单机服务,部署目录如下:
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自带的客户端。
控制台的使用
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
的条目设置了简单的字符串值,并获取后对其进行断言。然后我们又测试了下保存日期类型的值的情况。
可以看到库中的数据格式:
实际序列化为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
结构,再通过redisTemplate
的hash
操作进行保存。注意,这里存的key
,我们采用命名空间的写法:项目(模块):实体:主键
。然后我们通过
oprs.entries(key)
方法将hash
结构取出来,以Map
类型返回,并对其进行DTO
类型的转换,达到我们想要的结果。
这里存在一个问题,先看下redis保存的结构:
我们的Long
类型的pid
和id
字段在序列化为json的值后,类型丢了!这导致我们在反向转成DTO
时,工具报了如下异常:
解决办法很简单,只要我们将要序列化的基本类型也同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);
}
}
代码说明
这里我们直接使用了
JsonGenerator
的writeRaw(rawStr)
,对我们的基本类型进行携带类型的包装后,直接写入。
然后我们在RedisConfig
中做下配置:
...
simpleModule.addSerializer(Long.class,new LongJsonSerializer());
simpleModule.addSerializer(long.class,new LongJsonSerializer());
...
再测试,可以看到数据类型也持久化下来了:
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
,我们还可以携带pid
、level
等属性值。这样我们将通过
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);
}
最后我们把所有的单元测试完整跑一遍来结束本节,大家加油!