第3章〓Spring Redis Template


视频讲解






视频讲解


RedisTemplate类可以用于简化Redis操作,是Spring Data Redis对Redis支持的核心类。它可以负责序列化和连接管理,用户无须关心此类细节。此外,该类提供了Redis操作视图(依据Redis命令分组),这些视图提供了丰富的通用接口,可以以程序的方式执行Redis命令。RedisTemplate一旦配置好就是线程安全的。

本章将围绕RedisTemplate类的连接建立、Redis操作视图、键绑定、序列化等内容展开。

3.1Java Redis客户端

为了能够在应用程序中使用Redis,通过Redis命令操作是不现实的。Redis提供了适配不同编程语言的客户端实现通过编程操作Redis。Java有很多优秀的Redis客户端,如Lettuce、Jedis、Redisson和JRedis等。表31列举了常用的Java Redis客户端的特性。


表31常用的Java Redis客户端的特性



客户端框架整合介绍


LettuceSpring Data RedisLettuce是基于Netty实现的,支持同步、异步和响应编程方式并且是线程安全的,支持Redis的哨兵模式、集群模式和流水线
JedisSpring Data Redis以Redis命令作为方法名称,学习成本低,简单实用。Jedis实例不是线程安全的,多线程环境下需要基于连接池来使用
Redisson/Redisson是分布式Redis客户端,底层使用Netty框架,支持Redis的哨兵模式、主从模式和单节点模式



使用Java操作Redis最常用的是使用Jedis客户端。如果在项目中使用了Jedis,但是后来决定弃用Jedis改用其他的Redis客户端就比较麻烦了。因为不同的Java Redis客户端是无法兼容的。Spring Data Redis是Spring Data模块的一部分,专门用来支持在Spring管理的项目中对Redis的操作。Spring Data Redis提供了Redis的Java客户端的抽象,在开发中可以忽略由于切换Redis客户端所带来的影响,而且它本身就属于Spring的一部分,比起单纯的使用Jedis更加稳定,管理起来更加自动化。

使用Spring Data Redis的首要任务之一是通过Spring容器连接到Redis。为此,需要创建一个Java连接器。无论开发者选择哪个Java Redis客户端,只需要使用一组Spring Data Redis API(在所有连接器中表现一致)。即使用org.springframework.data.redis.connection包中RedisConnection和RedisConnectionFactory接口。这两个接口用于处理和获取Redis的活动连接。

RedisConnection接口为应用程序与Redis通信提供了核心组件。因为它处理与Redis后端的通信。它还自动将底层连接库异常转换为与Spring一致的DAO异常层次结构,这样就可以在不更改任何代码的情况下切换连接器,因为操作语义保持不变。

RedisConnection对象是通过RedisConnectionFactory(工厂)创建的。此外,工厂充当PersistenceExceptionTranslator对象。这意味着一旦声明,PersistenceExceptionTranslator对象就允许开发者进行透明的异常转换——例如,通过使用@Repository注解和AOP进行异常转换。使用RedisConnectionFactory的最简单的方式就是通过Spring容器配置一个合适的连接器并将连接器注入给需要使用它的类。

Spring Data Redis主要有以下特性: 

(1)  提供了一个可以跨越多个客户端(如Jedis、Lettuce)的底层抽象连接包。

(2)  针对数据的序列化和反序列化,提供了多种方案供开发者选择。

(3)  提供了一个RedisTemplate类,该类对Redis的各种操作、异常转换和序列化都实现了高层封装。

(4)  支持Redis的哨兵模式和集群模式。

3.2创建Redis连接

针对不同的Redis客户端,使用程序连接Redis也有不同的方式。本节讲授如何利用Lettuce、Jedis客户端以及Redis Template类创建Redis连接。

3.2.1Lettuce

Lettuce是一个可扩展的线程安全Redis客户端,用于同步、异步和响应式使用。如果多个线程不使用阻塞和事务操作(如BLPOP和MULTI/EXEC),则它们可能共享一个连接。Lettuce是基于Netty构建的。Lettuce支持Redis的高级功能,如哨兵(Sentinel)、集群(Cluster)、流水线(Pipelining)、自动重新连接和Redis数据模型。

要利用Spring Data Redis配置Lettuce连接器,首先在Maven项目中引入相关依赖,内容如下: 



1<!-- Spring核心包 spring-context -->

2<dependency>

3<groupId>org.springframework</groupId>

4<artifactId>spring-context</artifactId>

5<version>5.3.18</version>

6</dependency>

7<dependency>

8<groupId>org.springframework.data</groupId>

9<artifactId>spring-data-redis</artifactId>

10<version>2.7.1</version>

11</dependency>

12<!-- Lettuce -->

13<dependency>








14<groupId>io.lettuce</groupId>

15<artifactId>lettuce-core</artifactId>

16<version>6.1.8.RELEASE</version>

17</dependency>






利用Lettuce配置Redis连接器有两种方案。第一,创建Lettuce连接工厂,通过连接工厂获取连接; 第二,直接用Lettuce创建Redis连接。

1.  Lettuce连接工厂

可以创建一个名为LettuceConfig的类来配置Lettuce连接工厂,代码如文件31所示。

【文件31】LettuceConfig.java




1@Configuration

2public class LettuceConfig {

3@Bean

4public LettuceConnectionFactory redisConnectionFactory() {

5return new LettuceConnectionFactory(

6 new RedisStandaloneConfiguration("127.0.0.1", 6379));

7}

8}







在创建了LettuceConnectionFactory实例后(第5、6行),可以调用该类的getConnection()方法获取Redis连接对象(org.springframework.data.redis.connection.RedisConnection)。同时,可以编写测试类查看获取的连接是否有效。测试代码如文件32所示。

【文件32】TestLettuce.java




1@RunWith(SpringJUnit4ClassRunner.class)

2@ContextConfiguration(classes= LettuceConfig.class)

3public class TestLettuce {

4@Autowired

5private LettuceConnectionFactory factory;

6@Test

7public void testLettuceConnectionFactory(){

8RedisConnection conn = factory.getConnection();

9//向Redis发送 PING 命令,并断言得到 PONG 

10Assert.assertEquals("PONG",conn.ping());

11}

12}







如文件32所示,第10行调用RedisConnectionCommands(RedisConnection接口的父接口)接口的ping()方法,用于向Redis发送PING命令(见1.4.2节PING命令部分)。

2.  Lettuce直接连接Redis

创建一个名为LettuceConnection的类,代码如文件33所示。

【文件33】LettuceConnection.java




1public class LettuceConnection {

2public static void main(String[] args) {

3//步骤1: 连接信息

4RedisURI redisURI = RedisURI.builder()

5.withHost("127.0.0.1")








6.withPort(6379)

7//.withPassword(new char[]{'a', 'b', 'c'})

8.withTimeout(Duration.ofSeconds(10))

9.build();

10//步骤2: 创建Redis客户端

11RedisClient client = RedisClient.create(redisURI);

12//步骤3: 建立连接

13StatefulRedisConnection<String, String> connection =

14 client.connect();

15//向Redis发送操作命令,相关代码略

16//关闭连接

17connection.close();

18client.shutdown();

19}

20}







3.2.2Jedis

Jedis是利用Java操作Redis的工具,功能类似于JDBC。Jedis是Redis官方推荐的Java客户端开发包。Jedis支持Redis命令、事务和流水线。配置Jedis连接器,首先要在项目中添加Jedis的依赖,内容如下: 



1<!-- Jedis -->

2<dependency>

3<groupId>redis.clients</groupId>

4<artifactId>jedis</artifactId>

5<version>3.8.0</version>

6</dependency>






利用Jedis作为Redis连接器有三种实现方案: Jedis连接工厂、Jedis直接连接和Jedis连接池。

1. Jedis连接工厂

创建一个JedisConfig类,代码如文件34所示。

【文件34】JedisConfig.java




1@Configuration

2public class JedisConfig {

3@Bean

4public JedisConnectionFactory redisConnectionFactory() {

5RedisStandaloneConfiguration config = 

6new RedisStandaloneConfiguration("127.0.0.1", 6379);

7return new JedisConnectionFactory(config);

8}

9}







如文件34所示,在获取了JedisConnectionFactory实例后(第7行),可以调用该类的getConnection()方法获取Redis连接。



图31Jedis直接连接Redis


2.  Jedis直接连接

所谓直接连接是指Jedis在每次发送Redis操作命令前都会新建TCP连接,使用后再断开连接,如图31所示。对于频繁访问Redis的场景这种方案,显然不是高效的使用方式。

利用Jedis直接创建Redis连接非常简单,只需创建Jedis的实例并指定相关参数即可。下述代码描述了图31的完整过程。



1//1.建立连接

2Jedis jedis = new Jedis("localhost",6379);

3//2.发送Redis操作命令

4jedis.set("username","zhangsan");

5//3.返回获取的操作结果

6String username = jedis.get("username");

7//4.关闭连接 

8jedis.close();







3. Jedis连接池

在生产环境中,从提升性能的角度考虑,一般使用连接池方式管理Jedis连接。具体做法是将所有的Jedis连接对象预先存放在连接池中,每次连接Redis时,只需要从连接池中借用活动连接,用后再将连接归还给连接池,如图32所示。



图32Jedis连接池


客户端连接Redis使用的协议是TCP。直接连接的方式每次都需要建立TCP连接,而连接池方式是可以预先创建好Redis连接,所以每次只需从连接池中借用即可。而借用和归还操作都是在本地进行的,只有少量的并发同步开销,这个开销远远小于新建TCP连接的开销。另外,直接连接方式无法限制Jedis对象的个数,在极端情况下会造成连接泄露,而连接池方式可以有效地保护和控制资源的使用。表32给出了两种方式各自的优势和劣势。


表32Jedis直接连接方式和连接池方式对比



优点缺点


直接连接简单方便,适用于少量长期连接的场景(1) 存在每次新建、关闭TCP连接的开销; 

(2) 资源无法控制,极端情况会出现连接泄露; 

(3) Jedis对象线程不安全
连接池(1) 无须每次连接都生成Jedis对象,降低开销; 

(2) 保护和控制资源的使用使用相对麻烦,尤其在资源管理上需要很多参数来保证,一旦规划不合理就会出现问题



创建Redis连接池代码如文件35所示。

【文件35】JedisConnectionPool.java




1public class JedisConnectionPool {

2private static final JedisPool jedisPool;

3static{

4 GenericObjectPoolConfig jedisPoolConfig = 

5new GenericObjectPoolConfig();

6 //连接池中的最大连接数

7 jedisPoolConfig.setMaxTotal(8);

8 //连接池中的最大空闲连接数

9 jedisPoolConfig.setMaxIdle(8);

10//连接池中的最少空闲连接数

11jedisPoolConfig.setMinIdle(0);

12//当连接资源耗尽后,调用者的最大等待时间,单位: 毫秒。-1表示永不超时

13jedisPoolConfig.setMaxWait(Duration.ofMillis(200));

14jedisPool = 

15new JedisPool(jedisPoolConfig,"127.0.0.1",6379,1000);

16}

17//从连接池中借用连接

18public static Jedis getJedis(){

19return jedisPool.getResource();

20}

21}







3.2.3RedisTemplate

目前,Jedis客户端在编程实施方面存在以下一些不足。

(1) 连接管理无法自动化,连接池的设计缺少必要的容器支持。

(2) 数据操作需要关注序列化和反序列化,因为Jedis的客户端API接受的数据类型为String和Byte,对结构化数据(JSON、XML、POJO等)操作需要额外的支持。

(3) 事务操作为硬编码。

(4) 对于发布订阅功能缺乏必要的设计模式支持,对于开发者而言需要关注的内容太多。

RedisTemplate类(org.springframework.data.redis.core.RedisTemplate)是Spring Data Redis中对Jedis API的高度封装。Spring Data Redis相对于Jedis来说可以方便地更换Redis的Java客户端。与Jedis相比,Spring Data Redis进行了如下改进。

(1) 连接池自动管理,提供了一个高度封装的RedisTemplate类。

(2) 针对Jedis客户端大量的API进行了归类封装。遵循Redis命令参考(见Redis官方网站)中的分组,RedisTemplate提供包含丰富的通用子接口的操作视图。这些视图可用于针对特定类型或特定键(通过键绑定接口)进行操作。

(3) 由容器封装并控制事务操作。

(4) 针对数据的序列化和反序列化提供了多种可选择的序列化器(RedisSerializer)。

(5) 基于设计模式和JMS(Java Message Service,Java消息服务)开发思路,将发布订阅的编程接口进行了封装,使开发更加便捷。

(6) RedisTemplate是线程安全的。

本书的后续案例将使用RedisTemplate来实现编程执行Redis操作。要获取RedisTemplate实例,首先要创建连接工厂对象,再借助连接工厂构建RedisTemplate。如果操作的值是String类型,也可以使用RedisTemplate类的子类StringRedisTemplate。由于程序中要频繁使用RedisTemplate对象,可以将其设置为Spring管理的Bean,然后由Spring将该对象注入需要的地方。代码如文件36所示。

【文件36】RedisTemplateConfig.java




1package com.example.redis.template;

2//import部分略

3@Configuration

4public class RedisTemplateConfig {

5@Bean

6publicRedisConnectionFactory redisConnectionFactory() {

7RedisStandaloneConfiguration rsc = 

8new RedisStandaloneConfiguration();

9rsc.setHostName("127.0.0.1");

10rsc.setDatabase(0);

11rsc.setPort(6379);

12return new JedisConnectionFactory(rsc);

13}

14@Bean

15public StringRedisTemplate redisTemplate(@Autowired 

16RedisConnectionFactory rcf){

17StringRedisTemplate template = new StringRedisTemplate();

18template.setConnectionFactory(rcf);

19return template;

20}

21}







从2.0版本开始,Spring Data Redis已经不推荐直接显式设置连接的信息了,一方面为了使配置信息与建立连接工厂解耦,另一方面抽象出Standalone、Sentinel和RedisCluster三种模式的环境配置类与一个统一的Jedis客户端连接配置类(用于配置连接池和SSL(Secure Socket Layer,安全套接字层)连接),这样可以更加灵活、方便地根据实际业务场景需要来配置连接信息。文件36以Standalone方式为例,展示了在不使用连接池的情况下,如何实例化RedisTemplate。首先创建RedisStandaloneConfiguration实例并设置参数(第7~11行),然后根据该配置实例来初始化Jedis连接工厂(第12行)。

文件36的配置使用的是直接连接Redis的方式,即每次需要时都会创建新的连接。当并发量剧增时,这会带来性能上的开销,同时由于没有对连接数进行限制,可能使服务器崩溃导致无法响应。所以一般会建立连接池,事先初始化一组连接,供需要Redis连接的线程取用。采用连接池方式需要更改文件36的第5~13行,具体如下: 



1@Bean

2public RedisConnectionFactory redisConnectionFactory() {

3JedisPoolConfig jpc = new JedisPoolConfig();

4jpc.setMaxTotal(8);

5jpc.setMaxIdle(8);

6jpc.setMinIdle(0);








7jpc.setMaxWait(Duration.ofMillis(200));

8//Redis连接配置

9RedisStandaloneConfiguration redisStandaloneConfiguration=

10new RedisStandaloneConfiguration();

11//设置Redis服务器的IP

12redisStandaloneConfiguration.setHostName("127.0.0.1");

13//设置Redis服务器的端口号

14redisStandaloneConfiguration.setPort(6379);

15//连接的数据库

16redisStandaloneConfiguration.setDatabase(0);

17//JedisConnectionFactory配置jedisPoolConfig

18JedisClientConfiguration.JedisClientConfigurationBuilder 

19jedisClientConfiguration = JedisClientConfiguration.builder();

20//指定连接池

21jedisClientConfiguration.usePooling().poolConfig(jpc);

22//创建工厂对象

23RedisConnectionFactory factory=new JedisConnectionFactory(

24 redisStandaloneConfiguration,jedisClientConfiguration.build());

25return factory;

26}







在默认情况下,Redis服务器在启动时会创建16个数据库,编号从0到15。不同的应用可以连接到不同的数据库上。上述代码的第16行通过setDatabase()方法选择编号为0的数据库。此外,Spring Data Redis提供的采用连接池创建RedisTemplate对象的方式并不优雅。如果要采用连接池创建RedisTemplate对象,推荐使用Spring Boot。

RedisTemplate默认使用Java的序列化程序。通过RedisTemplate写入或读取的任何对象都是通过Java序列化和反序列化的。可以通过org.springframework.data.redis.serializer包中提供的接口更改默认的序列化机制的设置,内容可见3.12节。

3.3Spring操作Redis字符串

在创建了RedisTemplate(或StringRedisTemplate)对象后,可以编程执行Redis操作了。Redis可以存取多种不同类型的数据,其中有5种基础数据类型: 字符串、列表、哈希、集合和有序集合。此外,还有流、地理空间数据、位图等。RedisTemplate对基础数据类型的大部分操作都是借助表33中的方法和子接口完成的。


表33RedisTemplate操作基础数据类型的主要方法和子接口



方法子接口描述


opsForValue()ValueOperations<K,V>操作字符串类型的条目
opsForList()ListOperations<K,V>操作列表类型的条目
opsForSet()SetOperations<K,V>操作集合类型的条目
opsForHash()HashOperations<K,V>操作哈希类型的条目
opsForZSet()ZSetOperations<K,V>操作有序集合类型的条目
opsForStream()StreamOperations<K,HK,HM>执行流操作命令的接口
opsForHyperLogLog()HyperLogLogOperations<K,V>操作超级日志
opsForGeo()GeoOperations<K,M>操作地理空间数据类型的条目



要操作Redis字符串,需要调用RedisTemplate类的opsForValue()方法创建ValueOperations子接口对象,再调用ValueOperations子接口的相关方法。本节介绍ValueOperations子接口(org.springframework.data.redis.core.ValueOperations<K,V>)中的主要方法的使用。

(1) 方法原型: void set(K key,V value); 功能: 设置键key的值value; 对应Redis命令: SET。

(2) 方法原型: @Nullable V get(Object key); 功能: 返回键key的值,当键的值不存在或在流水线(或事务)中使用该方法时,返回null; 对应Redis命令: GET。

【例31】利用键user保存值hello,redis。可以通过ValueOperations子接口提供的set(String key,String value)方法实现,代码如文件37所示。

【文件37】MyRedisStringTest.java




1@RunWith(SpringJUnit4ClassRunner.class)

2@ContextConfiguration(classes= RedisTemplateConfig.class)

3public class MyRedisStringTest {

4@Autowired 

5private StringRedisTemplate redis; 

6@Test

7public void testEx1() {

8redis.opsForValue().set("user","hello,redis");

9String str = redis.opsForValue().get("user"); 

10assertEquals("hello,redis",str);

11}

12}







如文件37所示,本节的案例由于操作的值都是String类型的,因此采用了RedisTemplate类的子类StringRedisTemplate。StringRedisTemplate对象在文件36的第14~20行完成实例化,并注入测试类中(第4、5行)。表33中的子接口对象可以通过RedisTemplate或StringRedisTemplate创建。即通过调用RedisTemplate的opsForValue()方法获得子接口引用(第8行),再调用子接口中对应的方法完成字符串操作。第8行调用set()方法向Redis中以user为键,写入值hello,redis。第9行通过键user取出对应的值。

(3) 方法原型: void set(K key,V value,long timeout,TimeUnit unit); 功能: 设置键key的值value和过期超时时长timeout; 对应Redis命令: SETEX。

【例32】以name为键将值Tom写入Redis,并设置写入键值的有效时长为10秒。测试代码如文件38所示。

【文件38】例32测试代码




1@Test

2public void testEx2() throws Interrupted Exception {

3 redis.opsForValue().set("name","Tom",10,TimeUnit.SECONDS);

4 Thread.sleep(11000);


5 String str = redis.opsForValue().get("name");

6 assertEquals("Tom",str);

7}








图33例32测试代码运行结果


在设置name键值后,等待10秒再去获取name键的值,get()方法将返回null,测试代码运行结果如图33所示。

(4) 方法原型: void set(K key,V value,long offset); 功能: 对于键key所存储的字符串; 从指定偏移量offset开始用给定值value替代对应Redis命令: SETRANGE。

【例33】向Redis中以key为键存入字符串hello,world,随后将该字符串替换为hello,redis。测试代码如文件39所示。

【文件39】例33测试代码




1@Test

2public void testEx3(){

3redis.opsForValue().set("key","hello,world");

4redis.opsForValue().set("key","redis",6);

5String str = redis.opsForValue().get("key");

6assertEquals("hello,redis",str);

7}







(5) 方法原型: @Nullable Boolean setIfAbsent(K key,V value); 功能: 如果缺少键key,则设置以键key保存字符串值value; 对应的Redis命令: SETNX。

【例34】以abs为键,调用setIfAbsent()方法保存字符串absent,保存后将值改为present。测试代码如文件310所示。

【文件310】例34测试代码




1@Test

2public void testEx4(){

3assertTrue(redis.opsForValue().setIfAbsent("abs","absent"));

4assertFalse(redis.opsForValue().setIfAbsent("abs","present"));

5}







如文件310所示,如果Redis中不存在键abs,则第3行代码执行成功。第4行准备将键abs的值修改为present。由于键abs已存在,因此此次更改失败。

(6) 方法原型: void multiSet(Map<? extends K,? extends V>map); 功能: 使用元组中提供的键值对将多个键设置为多个值; 对应的Redis命令: MSET。

(7) 方法原型: @Nullable List<V>multiGet(Collection<K>keys); 功能: 返回所有给定键的值,值按键的请求顺序返回; 对应的Redis命令: MGET。

【例35】将字符串aaa、bbb和ccc一次性存入Redis,这三个字符串对应的键分别为multi1、multi2和multi3,再通过各自的键将它们取出。测试代码如文件311所示。

【文件311】例35测试代码




1@Test

2public void testEx5(){

3Map<String, String> map = new HashMap<String, String>();

4map.put("multi1","aaa");

5map.put("multi2","bbb");

6map.put("multi3","ccc");

7redis.opsForValue().multiSet(map);

8








9List<String> list = new ArrayList<String>();

10list.add("multi1");

11list.add("multi2");

12list.add("multi3");

13List<String> values = redis.opsForValue().multiGet(list);

14values.forEach(System.out::println);

15}







(8) 方法原型: @Nullable V getAndSet(K key,V value); 功能: 设置键key的值并返回其旧值; 对应的Redis命令: GETSET。

【例36】应用getAndSet()方法。测试代码如文件312所示。

【文件312】例36测试代码




1@Test

2public void testEx6(){

3 redis.opsForValue().set("getset","test-11");

4 String str = redis.opsForValue().getAndSet("getset","test-22");

5 assertEquals("test-11",str);

6}







(9) 方法原型: @Nullable Long increment(K key,long delta); 功能: 将存储在键key下的字符串的整数值按增量delta递增,如果key指定的值不存在,那么key的值会先被初始化为0,然后再执行递增; 对应的Redis命令: INCRBY。

【例37】点赞是社交网络中最常用的功能。本例模拟社交网络中对作品的点赞功能,并输出当前作品获赞的数量。测试代码如文件313所示。

【文件313】例37测试代码




1@Test

2public void testEx7(){

3boolean laud_flag = true;

4Long l = 0L;

5if(laud_flag)

6l = redis.opsForValue().increment("articleId",1);

7else

8l = redis.opsForValue().increment("articleId",-1);

9System.out.println(l);

10}







将点赞数量存入Redis,以作品Id(标识符属性,字符串类型)为键。如果用户点赞,则变量laud_flag取值为true,对应的点赞数增加1; 反之,用户取消点赞,变量laud_flag取值为false,对应的点赞数减1。

(10) 方法原型: @Nullable Boolean setBit(K key,long offset,boolean value); 功能: 对键key所存储的字符串值,设置或清除指定偏移量上的位; 对应的Redis命令: SETBIT。

(11) 方法原型: @Nullable Boolean getBit(K key,long offset); 功能: 获取键对应值的ASCII码在offsest处的值; 对应的Redis命令: GETBIT。

【例38】利用键bit存入字符串a,并利用位运算将该字符串改为b。测试代码如文件314所示。
【文件314】例38测试代码




1@Test

2public void testEx8(){

3redis.opsForValue().set("bit","a");

4assertTrue(redis.opsForValue().getBit("bit",7));

5redis.opsForValue().setBit("bit",6,true);

6redis.opsForValue().setBit("bit",7,false);

7assertEquals("b",redis.opsForValue().get("bit"));

8}







上述测试代码第3行以bit为键存入字符串a。字符'a'的ASCII码是97,其二进制形式为01100001。这样,第4行获取第7位的ASCII码值,得到二进制1。1代表true,0代表false。因此,第4行结果应为true。第5、6行分别将字符'a'的ASCII码的第6、7位设置为1和0,这样键bit对应的ASCII码被改为01100010,即字符'b'的ASCII码。

目前的软件系统(包括电商和社交网络)经常有这样的需求: 根据用户提供的手机号码发送验证码,实现登录。下面的案例模拟实现手机验证码的发送功能。

【例39】利用Redis实现模拟手机验证码登录之验证码发送功能。对于验证码的发送,通常有如下要求: 

(1) 发送的手机验证码几分钟内(本例设定为1分钟)有效。

(2) 每天向每个手机号码发送的验证码的次数有限(本例设定为24小时内最多发送3次)。

为实现上述两项要求,可以利用Redis保存两个值,并设置它们的生存时间: 一个用于保存发送给用户的验证码,生存时长为1分钟; 另一个用来保存给用户发送验证码的次数,生存时间为24小时。测试代码如文件315所示。

【文件315】ShortMessageSender.java




1public class ShortMessageSender {

2public String sendCode(StringRedisTemplate template,String phone){

3String codeKey = phone+"_CODE";

4String countKey = phone +"_COUNT";

5Integer count = 0;

6try {

7count = Integer.parseInt(

8template.opsForValue().get(countKey));

9} catch(NumberFormatException nfe) {

10count = 0;

11}

12if(count>2) {

13 System.out.println("24小时内发送次数已达3次,24小时后重试");

14 return "retry";

15}

16Boolean hasCodeKey = template.hasKey(codeKey);

17if(hasCodeKey){

18 Long codeTTL = template.getExpire(codeKey);

19 System.out.println("验证码1分钟内有效,请在"+codeTTL+

20"秒之后再次发送");

21 return "tip";








22}

23 String code = RandomUtil.randomNumbers(6);

24 System.out.println("CODE IS : "+code);

25 template.opsForValue().set(codeKey,code,60, TimeUnit.SECONDS);

26 long timeout = 24*60*60;

27 if(count!=0)

28timeout = template.getExpire(countKey);

29template.opsForValue().set(countKey,String.valueOf(count+1),

30timeout,TimeUnit.SECONDS);

31System.out.println("验证码发送成功");

32return "success";

33}

34}







如文件315所示,对于用户提交的手机号码,以参数phone传入sendCode()方法,并省去了对手机号码合法性的校验。第3、4行分别以手机号码附带后缀的形式定义了两个键,这两个键对应的值分别为验证码和发送验证码的次数。第7、8行试图从Redis中获取发送验证码的次数,当Redis中不存在键countKey时,将记录验证码发送次数的变量count计为0(第9~11行)。第23行采用RandomUtil工具随机生成包含6位数字的验证码。要使用RandomUtil工具,需要在pom.xml文件中添加相关依赖: 



1<!--hutool-->

2<dependency>

3<groupId>cn.hutool</groupId>

4<artifactId>hutool-all</artifactId>

5<version>5.8.20</version>

6</dependency>







第25行将验证码保存到Redis,以便用户提交验证码后进行比对。同时,设置了验证码在Redis中的保存时间为60秒。第29行用同样的方法将验证码的发送次数保存到Redis,同时设置该值在Redis中的保存时间为24小时。

随后,模拟向号码为15612345678的手机发送验证码,测试代码如文件316所示。

【文件316】TestSMSVerification.java




1@RunWith(SpringJUnit4ClassRunner.class)

2@ContextConfiguration(classes= RedisTemplateConfig.class)

3public class TestSMSVerification {

4@Autowired

5private StringRedisTemplate template;

6

7@Test

8public void testSender(){

9ShortMessageSender sender = new ShortMessageSender();

10sender.sendCode(template,"15612345678");

11}

12}







测试代码运行结果如图34所示,再次运行测试代码,结果如图35所示。



图34文件316测试代码运行结果




图35再次运行测试代码的结果




3.4Spring操作Redis列表

在Redis中,列表类型是按照元素插入的顺序排序的字符串列表。可以向列表的头部(左边)或尾部(右边)添加、删除元素。操作Redis列表的方法与操作Redis字符串的方法类似,要调用RedisTemplate类的opsForList()方法创建ListOperations子接口对象,再调用ListOperations子接口的相关方法。本节介绍ListOperations子接口(org.springframework.data.redis.core.ListOperations<K,V>)中的主要方法的使用。

(1) 方法原型: @Nullable Long leftPush(K key,V value); 功能: 将一个或多个值value插入列表key的表头,特点是先进后出,可以作为栈使用; 返回值: 执行插入操作后的列表长度; 对应的Redis命令: LPUSH。

(2) 方法原型: @Nullable Long rightPush(K key,V value); 功能: 将一个或多个值value插入列表key的表尾,特点是先进先出,可以作为队列使用; 返回值: 执行插入操作后的列表长度; 对应的Redis命令: RPUSH。 

(3) 方法原型: @Nullable List<V>range(K key,long start,long end); 功能: 获取指定范围内的元素列表,参数start和end分别代表开始索引和结束索引。索引从左到右分别为0到N-1,从右到左为-1到-N。并且,end参数包含了自身。对应的Redis命令: LRANGE。

【例310】将数字1~10以strs为键保存到Redis中,并倒序输出。测试代码如文件317所示。
【文件317】例310测试代码




1@Test

2public void testEx9() {

3for(int i=1;i<11;i++)

4redis.opsForList().leftPush("strs",String.valueOf(i));

5redis.opsForList().range("strs", 0, -1)

6.forEach(e -> System.out.print(e+" "));

7}








图36例310测试代码
运行结果


执行测试代码,运行结果如图36所示。从控制台输出可见leftPush()方法实现了栈操作。读者可自行修改代码,实现正序输出。

(4)  方法原型: @Nullable Long rightPushAll(K key,Collection<V>values); 功能: 将一组值插入列表key的尾部; 返回值: 执行插入后的列表长度; 对应的Redis命令: RPUSH。

(5) 方法原型: @Nullable Long leftPushAll(K key,Collection<V>values); 功能: 将一组值插入列表key的头部; 返回值: 执行插入后的列表长度; 对应的Redis命令: LPUSH。

【例311】将数字1~10以strs为键批量保存到Redis中,并正序输出。测试代码如文件318所示。

【文件318】例311测试代码




1@Test

2public void testEx10() {

3List<String> strs = new ArrayList<String>();

4for(int i=1;i<11;i++)

5strs.add(String.valueOf(i));

6redis.opsForList().rightPushAll("strs",strs);

7redis.opsForList().range("strs", 0, -1)

8.forEach(System.out::println);

9}







(6) 方法原型: void trim(K key,long start,long end); 功能: 修剪列表,使其保留start到end之间的值; 对应的Redis命令: LTRIM。

【例312】修剪例310中的strs列表,保留6~10。测试代码如文件319所示。

【文件319】例312测试代码




1@Test

2public void testEx11() {

3System.out.println(redis.opsForList().range("strs",0,-1));

4redis.opsForList().trim("strs",5,-1);

5System.out.println(redis.opsForList().range("strs",0,-1));

6}








图37例312测试代码运行结果


测试代码运行结果如图37所示。

(7)  方法原型: @Nullable V leftPop(K key); 功能: 移除并返回列表key中的第一个元素; 对应的Redis命令: LPOP。

(8)  方法原型: @Nullable V rightPop(K key); 功能: 移除并返回列表key中的最后一个元素; 对应的Redis命令: RPOP。

【例313】利用Redis List数据类型实现栈和队列。测试代码如文件320所示。

【文件320】例313测试代码




1@Test

2public void testEx12(){

3String s = "abcde";

4for(int i=0;i<s.length();i++)

5redis.opsForList().rightPush("letters",








6 String.valueOf(s.charAt(i)));

7System.out.println("the current list:"

8 +redis.opsForList().range("letters",0,-1));

9System.out.println("As a stack");

10for(int i=0;i<5;i++)

11//Stack

12System.out.println(redis.opsForList().rightPop("letters"));

13//Queue

14//System.out.println(redis.opsForList().leftPop("letters"));

15}






如文件320所示,要实现栈,只要保证数据在列表的同一端执行插入和删除操作。为使字母入栈顺序与字母表序一致,采用了rightPush()方法(第5、6行)在右侧执行插入操作。同时,在右侧执行删除操作(第12行)。测试代码运行结果如图38所示。


图38例313测试代码运行结果



(9)  方法原型: @Nullable V move(K sourceKey,RedisListCommands.Direction from,K destinationKey,RedisListCommands.Direction to); 功能: 自动返回并删除存储在列表sourceKey中的第一个或最后一个元素(表头或表尾,取决于from参数),并将该元素推送到列表destinationKey的第一个或最后一个元素(表头或表尾,取决于to参数),该方法需要Redis 6.2.0及以上版本支持; 对应的Redis命令: LMOVE。

【例314】向list1中添加三个值,分别为one、two和three,随后将three和one从list1中移除,并分别移入list2的表头和表尾。测试代码如文件321所示。

【文件321】例314测试代码




1@Test

2public void testEx13() {

3String[] s = {"one","two","three"};

4redis.opsForList().rightPushAll("list1",s);

5redis.opsForList().move("list1", RedisListCommands.Direction.RIGHT

6,"list2",RedisListCommands.Direction.LEFT);

7redis.opsForList().move("list1",RedisListCommands.Direction.LEFT

8,"list2",RedisListCommands.Direction.RIGHT);

9System.out.println(redis.opsForList().range("list1",0,-1));

10System.out.println(redis.opsForList().range("list2",0,-1));

11}








图39例314测试代码
运行结果


上述代码的第5、6行调用move()方法,首先将list1中的尾部元素three删除,随后将其移入list2的头部。类似地,第7、8行调用move()方法将list1中的头部元素one删除,随后将其移入list2的尾部。运行此测试代码,结果如图39所示。

Redis通常用作消息传递服务器,用于处理后台作业或其他类型的消息传递任务。一种简单的消息队列形式通常是将值推送到生产者端的列表中,等待消费者端使用RPOP(使用轮询)命令使用该值。然而,这种消息队列并不可靠,因为消息可能会丢失。例如,网络存在传输问题的情况,或者如果消费者在收到消息后不久崩溃,但消息尚未被处理。

LMOVE(或BLMOVE用于阻塞变体)命令提供了一种避免此问题的方法: 消费者获取消息,同时将其推送到待处理列表中。一旦消息被处理,消费者将使用LREM命令从处理列表中删除消息。还可以使用另一个客户端监视处理列表中的项目是否保留太长时间,并在需要时将这些超时的项目再次推送到消息队列中。

此外,利用move()方法还可以实现访问N个元素列表中的每个元素,而无须使用LRANGE命令将完整列表从服务器传输到客户端。

【例315】利用move()方法遍历列表cirList。测试代码如文件322所示。

【文件322】例315测试代码




1@Test

2public void testEx14() {

3String[] s = {"one","two","three"};

4redis.opsForList().rightPushAll("cirList",s);

5Long size = redis.opsForList().size("cirList");

6for(int i=0;i<size;i++)

7System.out.println(redis.opsForList()

8.move("cirList",RedisListCommands.Direction.RIGHT

9,"cirList",RedisListCommands.Direction.LEFT));

10}








图310例315测试代码
运行结果


其中,第5行调用size()方法获取列表cirList中元素的个数。其方法原型为@Nullable Long size(K key)。运行此测试代码,结果如图310所示。

(10)  方法原型: void set(K key,long index,V value); 功能: 在索引index处设置列表元素的值; 对应的Redis命令: LSET。

(11)  方法原型: @Nullable V index(K key,long index); 功能: 获取列表key中索引为index的元素的值。对应的Redis命令: LINDEX。

【例316】将例312中结果列表的最后一个元素的值改为100,并获取修改后的元素值。测试代码如文件323所示。

【文件323】例316测试代码




1@Test

2public void testEx15() {

3redis.opsForList().set("strs",4,"100");

4String n = redis.opsForList().index("strs",4);

5assertEquals("100",n);

6}







本章将Redis操作中的值都设定为String类型。因此,在上述代码的第3行,更改的新值也以字符串的形式表示。

(12)  方法原型: @Nullable Long remove(K key,long count,Object value); 功能: 根据参数count的值,移除列表中与参数value相等的元素。count的值可以是以下几种: 

① count>0:从表头开始向表尾搜索,移除值与value相等的元素,数量为count。

② count<0:从表尾开始向表头搜索,移除值与value相等的元素,数量为count的绝对值。

③ count=0:移除表中所有值与value相等的值。

④ 返回值: 被移除的元素的数量。因为不存在的key被视作空表,所以当key不存在时,该方法返回0。对应的Redis命令: LREM。

【例317】移除列表中的重复值。测试代码如文件324所示。

【文件324】例317测试代码




1@Test

2public void testEx16() {

3String[] s = {"hello","hello","foo","hello"};

4redis.opsForList().rightPushAll("lrm",s);

5System.out.println(redis.opsForList().range("lrm",0,-1));

6redis.opsForList().remove("lrm",-2,"hello");

7System.out.println(redis.opsForList().range("lrm",0,-1));

8}








图311例317测试代码
运行结果

运行此测试代码,结果如图311所示。

社交网络(或电商系统)中经常有这样的需求,用户可以查看浏览内容的历史记录。如果只是要求保留用户的浏览记录,则可以用列表来实现。

【例318】Id为101的用户某时段的浏览记录为{a.html,b.html,...,g.html},要求将用户最近的5条浏览记录保留3天。测试代码如文件325所示。

【文件325】例318测试代码




1@Test

2public void testViewed(){

3String[] pages = {"a.html","b.html","c.html","d.html","e.html",

4"f.html","g.html"};

5 //保留最近5条浏览记录

6int viewed_page_counter = 5;

7int offset = pages.length-viewed_page_counter;

8for(int i=0;i<pages.length;i++) {

9if(i<offset) {

10template.opsForList().rightPush("101:20241011:viewed", 

11pages[i]);

12template.opsForList().leftPop("101:20241011:viewed");

13} else {

14template.opsForList().leftPush("101:20241011:viewed",

15pages[i]);

16template.expire("101:20241011:viewed",3*24*60,

17TimeUnit.MINUTES);

18}

19}

20System.out.println("浏览记录为: ");

21List<String> viewedPages = template.opsForList().range(

22"101:20241011:viewed",0,-1);

23viewedPages.forEach(System.out::println);

24}







测试代码运行结果如图312所示。



图312例318测试代码运行结果



3.5Spring操作Redis哈希

几乎所有的编程语言都提供了哈希类型。Redis的哈希类型值是一个键值对结构,形如value={{field1,value1},...,{fieldn,valuen}},因此哈希类型特别适合存储对象。Redis是以键值对的形式存储数据的。Redis键值对和哈希类型二者的关系可以用图313表示。



图313Redis键值对和哈希
类型的关系


如图313所示,普通哈希类型数据<name,Tom>与<age,28>以键user:1存储在Redis中。其映射关系在Redis中叫作字段值(fieldvalue),注意这里的值是指字段(field)对应的值,不是键对应的值。操作Redis哈希的方法与操作Redis字符串的方法类似,要调用RedisTemplate类的opsForHash()方法创建HashOperations子接口对象,再调用HashOperations子接口的相关方法。本节介绍HashOperations子接口(org.springframework.data.redis.core.HashOperations<K,V>)中的主要方法的使用。

(1) 方法原型: void put(H key,HK hashKey,HV value); 功能: 将哈希key中的字段hashKey的值设置为value; 对应的Redis命令: HSET。

(2) 方法原型: @Nullable HV get(H key,Object hashKey); 功能: 从哈希key中根据字段hashKey取出值; 对应的Redis命令: HGET。

(3) 方法原型: Map<HK,HV>entries(H key); 功能: 根据key获取整个哈希存储的值; 对应的Redis命令: HGETALL。

(4) 方法原型: Set<HK>keys(H key); 功能: 获取哈希key的所有字段名的集合; 对应的Redis命令: HKEYS。

(5) 方法原型: List<HV>values(H key); 功能: 获取哈希key的所有字段的值; 对应的Redis命令: HVALS。

(6) 方法原型: Long size(H key); 功能: 返回哈希中字段的数量; 对应的Redis命令: HLEN。

(7) 方法原型: Boolean hasKey(H key,Object hashKey); 功能: 判断哈希key中给定的字段hashKey是否存在; 对应的Redis命令: HEXISTS。

【例319】Redis哈希基础操作1。要求: ①将哈希数据<name:Tom>、<age:26>、<class:3>存储到哈希rHash中; ②返回rHash中字段的数量; ③取出年龄值; ④取出rHash中存储的全部值; ⑤取出全部字段名; ⑥取出全部字段值; ⑦判断rHash中是否存在age字段和ttt字段。测试代码如文件326所示。

【文件326】例319测试代码




1@Test

2public void testEx17() {

3//①

4template.opsForHash().put("rHash", "name", "Tom");

5template.opsForHash().put("rHash", "age", "26");

6template.opsForHash().put("rHash", "class", "6");

7//②

8assertEquals(3, template.opsForHash().size("rHash").intValue());

9//③

10 assertEquals("26", template.opsForHash().get("rHash", "age"));

11//④

12System.out.println("stored Hash:" +

13 template.opsForHash().entries("rHash"));

14//⑤

15System.out.println("fields:" +

16 template.opsForHash().keys("rHash"));

17//⑥

18System.out.println("values:" +

19template.opsForHash().values("rHash"));

20//⑦

21assertTrue(template.opsForHash().hasKey("rHash", "age"));

22assertFalse(template.opsForHash().hasKey("rHash", "ttt"));

23}








图314例319测试代码运行结果


运行上述测试代码,结果如图314所示。

(8) 方法原型: Cursor<Map.Entry<HK,HV>>scan(H key,ScanOptions options); 功能: 用于增量迭代哈希key中的键值对,参数ScanOptions是用于SCAN命令的选项,目前的常量值为NONE(对扫描模式不做限制); 对应的Redis命令: HSCAN。

该命令支持增量迭代,即每次执行都只会返回少量元素,所以该命令可以用于生产环境,而不会出现像KEYS命令带来的问题: 当KEYS命令被用于处理一个大的数据库时,可能会阻塞服务器达数秒之久。在对键进行增量式迭代的过程中,键可能会被修改,所以增量式迭代命令只能对被返回的元素提供有限的保证。

(9) 方法原型: Long increment(H key,HK hashKey,long delta); 功能: 为哈希key中的字段hashKey的值加上增量delta。增量也可以为负数,相当于对给定字段进行减法操作。如果key不存在,则会创建一个新的哈希并执行该方法。如果域hashKey不存在,那么在执行该方法前,字段的值被初始化为0。对一个存储字符串值的字段执行该方法将造成一个错误。对应的Redis命令: HINCRBY。

由于内部序列化器的设置不同,因此此方法要求配合StringRedisTemplate类使用。

(10) 方法原型: void putAll(H key,Map<?extends HK,?extends HV>m); 功能: 将多个字段值对同时设置到哈希key中。执行此方法会覆盖哈希中已存在的字段。如果key不存在,则创建一个空的哈希并执行该方法; 对应的Redis命令: HMSET。

(11) 方法原型: List<HV>multiGet(H key,Collection<HK>hashKeys); 功能: 返回哈希key中,一个或多个给定字段hashKeys的值。如果给定的字段不存在于哈希,则返回一个nil值。对应的Redis命令: HMGET。

(12) 方法原型: Long delete(H key,Object...hashKeys); 功能: 删除哈希key中一个或多个指定的字段hashKeys。对应的Redis命令: HDEL。

【例320】Redis哈希基础操作2。要求: ①使用scan()方法遍历例319中的哈希rHash,并输出其全部值; ②将age字段的值增加1; ③将哈希数据<name:Bob>、<age:28>、<class:2>一次性加入哈希rHash2中; ④从哈希rHash中取出name字段和age字段的值; ⑤删除哈希rHash中的字段name。测试代码如文件327所示。

【文件327】例320测试代码




1@Test

2public void testEx18 () {

3//①

4template.opsForHash().put("rHash", "name", "Tom");

5template.opsForHash().put("rHash", "age", "26");

6template.opsForHash().put("rHash", "class", "6");

7System.out.println("all the values in rHash are:");

8Cursor<Map.Entry<Object,Object>> cursor =

9template.opsForHash().scan("rHash", ScanOptions.NONE);

10cursor.forEachRemaining(entry -> System.out.println(

11entry.getKey()+":"+entry.getValue()));

12//②

13//需注入StringRedisTemplate

14assertEquals("27",template.opsForHash().increment(

15"rHash","age",1).toString());

16//③

17Map<String,Object> tempMap = new HashMap<String,Object>();

18tempMap.put("name","Bob");

19tempMap.put("age","28");

20tempMap.put("class","2");

21template.opsForHash().putAll("rHash2",tempMap);

22System.out.println("rHash:"

23+template.opsForHash().entries("rHash"));

24System.out.println("rHash2:"

25+template.opsForHash().entries("rHash2"));

26//④

27List<Object> ks = new ArrayList<Object>();

28ks.add("name");

29ks.add("age");

30System.out.println("values for field name and age for rHash:");

31System.out.println(template.opsForHash().multiGet("rHash",ks));

32//⑤

33template.opsForHash().delete("rHash","name");

34System.out.println("name field has been removed. Now rHash looks

35like this: "+template.opsForHash().entries("rHash"));

36}







运行上述测试代码,结果如图315所示。



图315例320测试代码运行结果	


目前的软件系统(包括电商和社交网络)经常有这样的需求: 根据用户提供的手机号码发送验证码,实现登录。下面的例子在例39的基础上模拟手机验证码的登录验证功能。

【例321】模拟手机验证码登录功能,用户提交手机上收到的验证码并完成登录验证。对于验证成功的用户,将其登录信息保存到Redis中,测试代码如文件328所示。

【文件328】UserLogin.java




1public class UserLogin {

2public String login(StringRedisTemplate template, String phone,

3String userCode){

4String codeKey = phone+"_CODE";

5String cacheCode = template.opsForValue().get(codeKey);

6if(cacheCode == null || ! cacheCode.equals(userCode)) {

7System.out.println("验证码错误");

8return "FAIL";

9}

10User user = new User();

11user.setUserId(Integer.valueOf(133));

12user.setName("admin");

13user.setPhone(phone);

14String token = UUID.randomUUID().toString();

15Map<String,Object> map = BeanUtil.beanToMap(user,

16new HashMap<>(), CopyOptions.create()

17.setIgnoreNullValue(true)

18.setFieldValueEditor((fieldName,fieldValue)

19->fieldValue.toString()));

20System.out.println(map);

21String tokenKey = phone+"_"+token;

22template.opsForHash().putAll(tokenKey,map);

23template.expire(tokenKey,30, TimeUnit.MINUTES);

24template.delete(codeKey);

25return "OK";

26}

27}







如文件328所示,本例在例39基础上完成登录验证任务。第4行指定Redis中缓存的已发送给用户的验证码的键codeKey。第5行根据codeKey获取发送给用户的验证码。第6~9行将已发送给用户的验证码和用户提交的验证码进行比对,若比对失败则报告错误并退出。第10~13行先实例化User类(代码见本书配套源代码),再对其属性分别赋值。这些操作用来模拟通过手机号在数据库中检索用户的相关信息。第14行生成随机令牌,用来保存用户会话(Session)信息。第15~19行将User类的对象user转换为Map,目的是准备将Map对象存入Redis哈希中。其中的BeanUtil是hutool提供的工具类,引入的依赖见例39。第22行将用户信息存入Redis哈希。第23行设置用户信息的过期时间。用户登录验证成功,删除缓存的用户验证码(第24行)。

本例中,在用户登录验证成功后,将用户信息写入Redis。在Web应用系统开发中,用户登录验证成功后,通常将用户信息写入Session。当Web应用系统部署在一台Tomcat服务器上时,这样做是可行的。而工程上,为应对大量的并发请求,往往将Web应用系统部署到Tomcat集群上。而一个Session对象不能在多个Tomcat上使用,这样用户登录信息只能保存在一个Tomcat上。因为集群的存在,用户的请求会被分配到不同的Tomcat上处理。这样,当登录过的用户再次向系统发送请求时,请求可能会被分配到没有保存用户信息的Tomcat。这时,该Tomcat就会要求用户重新登录,这样就会极大降低用户的体验度。并且,Tomcat集群中也会保存大量冗余的用户登录信息,造成资源浪费。本例中,将用户的登录信息保存到Redis中就是对上述问题的一个解决方案。一方面,Tomcat集群可以从Redis中获取用户的登录信息,实现数据共享; 另一方面,由于Redis具有良好的读写性能,可以从容应对众多用户登录时带来的大量的并发请求。同时,本例给出的解决方案还有一个不足,就是第23行中设置了用户信息的保存时间为30分钟。如果用户的操作时长超过30分钟,会因为Redis中缓存的信息过期而被要求重新登录。这个问题需要其他技术手段来解决,此处不再详述。

随后,测试用户的登录验证功能,在文件316的基础上增加一个测试用例,测试代码如文件329所示。

【文件329】增加的测试用例




1@Test

2public void testLogin(){

3UserLogin userLogin = new UserLogin();

4userLogin.login(template,"15612345678","******");

5}







测试时,需要先接收验证码。因此,要先执行文件316中的测试用例,将验证码填写到文件329第4行的“*”处。如果验证成功,则可利用Redis客户端查看Redis中保存的用户信息。Redis中保存的用户信息及程序运行结果如图316所示。



图316Redis中保存的用户信息及程序运行结果


3.6Spring操作Redis集合

与列表类型数据相似,集合类型数据也可以在同一个键下存储一个或多个元素。与列表类型不同的是,集合中不允许有重复元素,并且集合中的元素是无序的,不能通过索引获取元素。Redis的集合是字符串类型元素的无序集合。Redis除了支持集合内的增、删、改、查操作,还支持多个集合的交、并、差运算。

操作Redis集合的方法与操作Redis字符串的方法类似,要调用RedisTemplate类的opsForSet()方法创建SetOperations子接口对象,再调用SetOperations子接口的相关方法。本节介绍SetOperations子接口(org.springframework.data.redis.core.SetOperations<K,V>)中的主要方法的使用。

(1) 方法原型: @Nullable Long add(K key,V...values); 功能: 向集合key中添加元素,并返回添加的个数; 对应的Redis命令: SADD。

(2) 方法原型: @Nullable Boolean move(K key,V value,K destKey); 功能: 将元素value从集合key移动到集合destKey; 对应的Redis命令: SMOVE。

(3) 方法原型: @Nullable Set<V>members(K key); 功能: 返回集合key中的所有成员,不存在的key被视为空集合; 对应的Redis命令: SMEMBERS。

(4) 方法原型: @Nullable List<V>pop(K key,long count); 功能: 从集合key中删除并返回count个随机成员; 对应的Redis命令: SPOP。

(5) 方法原型: @Nullable Long remove(K key,Object...values); 功能: 移除集合key中的一个或多个元素value,不存在的元素value会被忽略。当key不是集合类型时,返回一个错误; 对应的Redis命令: SREM。

(6) 方法原型: @Nullable Long size(K key); 功能: 返回集合key的基数(集合中元素的数量); 对应的Redis命令: SCARD。

(7) 方法原型: @Nullable Boolean isMember(K key,Object o); 功能: 检测集合key是否包含元素o; 对应的Redis命令: SISMEMBER。

(8) 方法原型: Cursor<V>scan(K key,ScanOptions options); 功能: 支持增量式迭代集合中的元素; 对应的Redis命令: SSCAN。

【例322】给定两个集合ball和ball2,对两个集合执行下述操作: ①向集合ball中添加4个元素,分别为football、volleyball、basketball和pingpong; ②输出集合ball中的所有元素; ③迭代输出集合ball中的元素; ④将集合ball中的元素pingpong移至集合ball2中,检查集合ball中元素的个数,并检查元素basketball是否属于集合ball; ⑤从集合ball中随机取出两个元素并输出; ⑥将元素pingpong从集合ball2中移除并输出集合ball2的剩余元素。测试代码如文件330所示。

【文件330】例322测试代码




1@Test

2public void testEx19() {

3//①

4String[] s = {"football","volleyball","basketball","pingpong"};

5template.opsForSet().add("ball",s);








6//②

7System.out.println("the members returned by members() method:");

8System.out.println(template.opsForSet().members("ball"));

9//③

10System.out.println("----------------");

11System.out.println("the members returned by scan() method:");

12template.opsForSet().scan("ball", ScanOptions.NONE)

13 .forEachRemaining(System.out::println);

14//④

15template.opsForSet().move("ball","pingpong","ball2");

16assertEquals(3,template.opsForSet().size("ball").intValue());

17assertTrue(template.opsForSet().isMember("ball","basketball"));

18//⑤

19System.out.println("----------------");

20System.out.println("obtain 2 items randomly from ball set");

21System.out.println(template.opsForSet().pop("ball",2));

22//⑥

23System.out.println("----------------");

24System.out.println("remove pingpong from ball2 set");

25template.opsForSet().remove("ball2","pingpong");

26System.out.println("the remaining elements in ball2 set :"

27+template.opsForSet().members("ball2"));

28}







运行上述测试代码,运行结果如图317所示。



图317例322测试代码运行结果


(9) 方法原型: @Nullable Set<V>intersect(K key,Collection<K>otherKeys); 功能: 求集合key与其他多个集合otherKeys的交集,不存在的key被视为空集。该方法的另外两种重载形式: @Nullable Set<V>intersect(K key,K otherKey)和@Nullable Set<V>intersect(Collection<K>keys); 对应的Redis命令: SINTER。

(10) 方法原型: @Nullable Long intersectAndStore(K key,K otherKey,K destKey); 功能: 求集合key与集合otherKey的交集,并将产生的交集元素存入集合destKey中。如果集合destKey已经存在,则将其覆盖。集合destKey可以是集合key本身。该方法的另外两种重载形式: @Nullable Long intersectAndStore(K key,Collection<K>otherKeys,K destKey)和@Nullable Long intersectAndStore(Collection<K>keys,K destKey); 对应的Redis命令: SINTERSTORE。

(11) 方法原型: @Nullable Set<V>union(K key,K otherKey); 功能: 求集合key与集合otherKey的并集,不存在的key被视为空集。该方法的另外两种重载形式: @Nullable Set<V>union(K key,Collection<K>otherKeys)和@Nullable Set<V>union(Collection<K>keys); 对应的Redis命令: SUNION。

(12) 方法原型: @Nullable Long unionAndStore(K key,K otherKey,K destKey); 功能: 求集合key和集合otherKey的并集,并将产生的并集元素存入集合destKey中。如果集合destKey已经存在,则将其覆盖。集合destKey可以是集合key本身。该方法的另外两种重载形式: @Nullable Long unionAndStore(K key,Collection<K>otherKeys,K destKey)和@Nullable Long unionAndStore(Collection<K>keys,K destKey); 对应的Redis命令: SUNIONSTORE。

(13) 方法原型: @Nullable Set<V>difference(K key,K otherKey); 功能: 求集合key与集合otherKey的差集,不存在的key被视为空集。该方法的另外两种重载形式: @Nullable Set<V>difference(K key,Collection<K>otherKeys)和@Nullable Set<V>difference(Collection<K>keys); 对应的Redis命令: SDIFF。

(14) 方法原型: @Nullable Long differenceAndStore(K key,K otherKey,K destKey); 功能: 求集合key和集合otherKey的差集,并将产生的差集元素存入集合destKey中。如果集合destKey已经存在,则将其覆盖。集合destKey可以是集合key本身。该方法的另外两种重载形式: @Nullable Long differenceAndStore(K key,Collection<K>otherKeys,K destKey)和@Nullable Long differenceAndStore(Collection<K>keys,K destKey); 对应的Redis命令: SDIFFSTORE。

【例323】在利用Redis存储社交关系时,可能会有如下需求: ①在微博中zhangsan有一批好友,lisi有另外一批好友,现需要查询zhangsan和lisi的共同好友; ②要得到zhangsan、lisi和wangwu关注的所有公众号; ③要得到zhangsan关注的但lisi和wangwu没有关注的公众号。测试代码如文件331所示。

【文件331】例323测试代码




1@Test

2public void testEx20(){

3//准备好友数据

4template.opsForSet().add("zhangsan-friend",

5"friend1","friend2","friend3");

6template.opsForSet().add("lisi-friend",

7"friend1","friend3","friend4");

8//①

9System.out.println("Mutual friends of zhangsan & lisi:"

10+template.opsForSet().intersect(

11"zhangsan-friend","lisi-friend"));

12//准备公众号数据

13template.opsForSet().add("zhangsan-concern",

14"pub1","pub2","pub3","pub4");

15template.opsForSet().add("lisi-concern",

16"pub1","pub2","pub5","pub6");

17template.opsForSet().add("wangwu-concern",








18"pub1","pub7","pub8","pub9");

19//②

20System.out.println("All concerns are :");

21List<String> list = new ArrayList<String>();

22list.add("lisi-concern");

23list.add("wangwu-concern");

24template.opsForSet().union("zhangsan-concern",list).iterator()

25.forEachRemaining(e-> System.out.print(e+" "));

26System.out.println();

27//③

28template.opsForSet().differenceAndStore("zhangsan-concern",

29list,"zhangsanonly");

30System.out.println("only zhangsan's concern :"

31+template.opsForSet().members("zhangsanonly"));

32}







如文件331所示,第4~7行准备zhangsan和lisi的好友数据。两者的共同好友可以通过求解两者好友集合的交集得到(第9~11行)。第13~18行准备zhangsan、lisi和wangwu的关注公众号数据。要得到三个人关注的所有公众号,只要求解三者关注公众号集合的并集即可。在求解并集时,调用的是union()方法的一种重载形式: @Nullable Set<V>union(K key,Collection<K>otherKeys)(第24行)。最后遍历结果集合,输出并集的全部元素(第25、26行)。要得到只有zhangsan关注的公众号,只需求解zhangsan关注的公众号集合与其他两人关注的公众号集合的差集即可(第28、29行)。运行此测试代码,运行结果如图318所示。



图318例323测试代码运行结果


3.7Spring操作Redis有序集合

有序集合(Sorted set,也称ZSet)同集合有一定的相似性,也是字符串类型元素的集合,并且都不能出现重复元素。在有序集合里,每个数据都会对应一个double类型的参数score(分数)。Redis正是按照分数来为集合中的元素进行升序排列。有序集合的元素是唯一的,但分数却可以重复。表34给出了Redis列表、集合和有序集合的异同点。


表34Redis列表、集合和有序集合的异同点



数据结构是否允许重复元素是否有序有序实现方式应用场景


列表是是索引(下标)时间轴、消息队列等
集合否否无标签、社交网络等
有序集合否是分数排行榜、社交网络等



操作Redis有序集合的方法与操作Redis字符串的方法类似,要调用RedisTemplate类的opsForZSet()方法创建ZSetOperations子接口对象,再调用ZSetOperations子接口的相关方法。本节介绍ZSetOperations子接口(org.springframework.data.redis.core.ZSetOperations<K,V>)中的主要方法的使用。

3.7.1对单个集合的操作

(1) 方法原型: @Nullable Boolean add(K key,V value,double score); 功能: 将一个或多个元素value及其分数score加入有序集合key中。如果某个元素value已经是有序集合的成员,那么更新这个元素value的分数score,并通过重新插入这个元素来保证其在正确的位置上。分数score可以是整数值或双精度浮点数。如果有序集合key不存在,则创建一个空的有序集并执行add()方法。当key存在但不是有序集合类型时,返回一个错误。该方法的另外一种重载形式: @Nullable Long add(K key,Set<ZSetOperations.TypedTuple<V>>tuples); 返回值: 被成功添加的新成员的数量,不包括那些被更新的、已经存在的成员; 对应的Redis命令: ZADD。

(2) 方法原型: @Nullable Set<V>range(K key,long start,long end); 功能: 返回有序集合在指定区间[start,end]内的成员; 下标参数start和stop都以0表示有序集合的第一个成员,以1表示有序集合的第二个成员,以此类推。也可以使用负数下标,以-1表示最后一个成员,-2表示倒数第二个成员,以此类推。超出范围的下标并不会引起错误。例如,当start的值比有序集合的最大下标还要大或start>end时,该方法只是简单地返回一个空列表。另外,假如参数end的值比有序集的最大下标还要大,那么Redis将end当作最大下标来处理。对应的Redis命令: ZRANGE。

(3) 方法原型: @Nullable Set<ZSetOperations.TypedTuple<V>>rangeWithScores(K key,long start,long end); 功能: 返回有序集合key的下标在指定区间[start,end]内的成员对象,其中有序集成员按分数值递增顺序排列; 对应的Redis命令: ZRANGE。

(4) 方法原型: @Nullable default Set<V>reverseRangeByLex(K key,RedisZSetCommands.Range range); 功能: 从有序集合key中获取range范围内的具有反向字典顺序的所有元素; 其中的RedisZSetCommands.Range对应的Redis命令: ZREVRANGEBYLEX。

(5) 方法原型: @Nullable Set<ZSetOperations.TypedTuple<V>>popMax(K key,long count); 功能: 从有序集合key中返回并移除count个分数最大的元素。

(6) 方法原型: @Nullable Long zCard(K key); 功能: 获取有序集合key的成员数; 对应的Redis命令: ZCARD。

(7) 方法原型: @Nullable Long removeRange(K key,long start,long end); 功能: 移除有序集合key中指定排名区间[start,end]内的所有成员。下标参数start和end都以0表示有序集合的第一个成员,1表示有序集合的第二个成员,以此类推。也可以使用负数下标,-1表示最后一个成员,-2表示倒数第二个成员,以此类推。返回值: 被移除的成员数量; 对应的Redis命令: ZREMRANGEBYRANK。

【例324】有序集合基本操作1(模拟学生成绩管理)。要求: ①向有序集合students中添加4个元素,元素名字分别为SuXun、SuShi、SuZhe、HanYu,分数分别为80.0、81.0、81.0、88.0; ②输出students集合中的全部元素; ③将students中的元素按分数由低到高排序并输出; ④将students中的元素按分数由高到低排列,在分数相同的情况下,按反字母表序排列; ⑤取出分数最高的元素; ⑥删除students集合中的剩余元素。测试代码如文件332所示。

【文件332】例324测试代码




1@Test

2public void testEx21() {

3ZSetOperations.TypedTuple<String> tuple1 = 

4new DefaultTypedTuple<String>("SuXun",80.0);

5ZSetOperations.TypedTuple<String> tuple2 = 

6new DefaultTypedTuple<String>("SuShi",81.0);

7ZSetOperations.TypedTuple<String> tuple3 = 

8new DefaultTypedTuple<String>("SuZhe",81.0);

9ZSetOperations.TypedTuple<String> tuple4 = 

10new DefaultTypedTuple<String>("HanYu",88.0);

11Set<ZSetOperations.TypedTuple<String>> t = 

12new HashSet<ZSetOperations.TypedTuple<String>>();

13t.add(tuple1);t.add(tuple2);

14t.add(tuple3);t.add(tuple4);

15//①

16template.opsForZSet().add("students",t);

17//②

18System.out.println(template.opsForZSet()

19 .range("students",-4,-1));

20//③

21System.out.println(template.opsForZSet()

22.rangeWithScores("students",0,-1));

23//④

24RedisZSetCommands.Range range = new RedisZSetCommands.Range();

25System.out.println(template.opsForZSet()

26.reverseRangeByLex("students", range.gte("H")));

27//⑤

28System.out.println(template.opsForZSet().popMax("students",1));

29 

30assertEquals(3,template.opsForZSet() 

31.zCard("students").intValue());

32//⑥

33template.opsForZSet().removeRange("students",0,-1);

34System.out.println(template.opsForZSet()

35.range("students",0,-1));

36}








图319例324测试代码运行结果


其中,文件332的第26行调用reverseRangeByLex()方法。该方法用于获取满足非分数的排序取值。这个排序只有在分数相同的情况下才能使用,如果有不同的分数则返回值不确定。运行此测试代码,运行结果如图319所示。


(8) 方法原型: @Nullable Long count(K key,double min,double max); 功能: 返回分数在[min,max]区间内的成员个数; 对应的Redis命令: ZCOUNT。

(9) 方法原型: @Nullable Long remove(K key,Object...values); 功能: 移除有序集合key中的一个或多个成员,不存在的成员将被忽略。当key存在但不是有序集合类型时,返回一个错误。返回被成功移除的成员的数量,不包括被忽略的成员。对应的Redis命令: ZREM。

(10) 方法原型: @Nullable Long rank(K key,Object o); 功能: 返回有序集合key中指定成员o的排名,其中有序集合成员按分数值递增顺序排列; 对应的Redis命令: ZRANK。

(11) 方法原型: @Nullable Long reverseRank(K key,Object o); 功能: 返回有序集合key中指定成员o的排名,其中有序集合成员按分数值递减顺序排列; 对应的Redis命令: ZREVRANK。

(12) 方法原型: @Nullable Set<ZSetOperations.TypedTuple<V>>reverseRangeWithScores(K key,long start,long end); 功能: 返回有序集合key在指定区间[start,end]内的成员对象,其中有序集合成员按分数值递减顺序排列; 对应的Redis命令: ZREVRANGEBYSCORE。

(13) 方法原型: @Nullable Double score(K key,Object o); 功能: 获取指定成员的score值; 对应的Redis命令: ZSCORE。

(14) 方法原型: @Nullable Set<V>distinctRandomMembers(K key,long count); 功能: 从有序集合key中随机获取count个不同元素,该方法需要Redis 6.2.0及以上版本支持; 对应的Redis命令: ZRANDMEMBER。

(15) 方法原型: @Nullable Double incrementScore(K key,V value,double delta); 功能: 将有序集合key中值为value的元素的分数增加增量delta; 对应的Redis命令: ZINCRBY。

(16) 方法原型: Cursor<ZSetOperations.TypedTuple<V>>scan(K key,ScanOptions options); 功能: 使用光标(Cursor)在有序集合key上迭代元素(包括元素成员和元素分数); 对应的Redis命令: ZSCAN。

【例325】有序集合基本操作2(模拟视频网站的一些基础操作)。要求: ①向有序集合zset中添加三个元素,元素名字分别为zset1、zset2和zset3,分数分别为9.9、9.6、9.1; ②对zset中的元素按分数升序排列并输出排序结果; ③计算分数在9.3分以上的元素数量; ④从集合zset中随机抽取一个元素,并获取其排名及分数; ⑤将zset1的分数提高到10.1。测试代码如文件333所示。

【文件333】例325测试代码




1@Test

2public void testEx22() {

3ZSetOperations.TypedTuple<String> objectTypedTuple1 = 

4new DefaultTypedTuple<String>("zset-1", 9.9);

5ZSetOperations.TypedTuple<String> objectTypedTuple2 = 

6new DefaultTypedTuple<String>("zset-2", 9.6);

7ZSetOperations.TypedTuple<String> objectTypedTuple3 = 

8new DefaultTypedTuple<String>("zset-3", 9.1);

9Set<ZSetOperations.TypedTuple<String>> tuples = 

10new HashSet<ZSetOperations.TypedTuple<String>>();

11tuples.add(objectTypedTuple1);

12tuples.add(objectTypedTuple2);

13tuples.add(objectTypedTuple3);

14//①








15template.opsForZSet().add("zset", tuples);

16//②

17tuples = template.opsForZSet().reverseRangeWithScores(

18"zset", 0, -1);

19Cursor cursor = template.opsForZSet()

20.scan("zset", ScanOptions.NONE);

21cursor.forEachRemaining(System.out::println);

22//③

23assertEquals(2, template.opsForZSet()

24.count("zset", 9.3, 10.0).intValue());

25//④

26Set set = template.opsForZSet().distinctRandomMembers("zset", 1);

27Iterator<String> iterator = set.iterator();

28iterator.forEachRemaining(item -> System.out.println(item 

29+ " 排名: " + String.valueOf(template.opsForZSet()

30.rank("zset", item).intValue() + 1)

31 + " 分数: " + template.opsForZSet().score("zset", item)));

32//⑤

33assertEquals(10.1, template.opsForZSet()

34.incrementScore("zset", "zset-1", 0.2).doubleValue(), 0.01);

35}







运行测试代码,运行结果如图320所示。



图320例325测试代码运行结果


3.7.2对多个集合的操作

(1) 方法原型: @Nullable Long unionAndStore(K key,Collection<K>otherKeys,K destKey,RedisZSetCommands.Aggregate aggregate,RedisZSetCommands.Weights weights); 功能: 计算有序集合key和otherKeys的并集,并将结果存储于集合destKey中。其中,参数aggregate表示并集选项,默认值为SUM,表示结果集合destKey中元素的分数为该元素在各集合中分数的和; 参数aggregate的另两个值为MAX和MIN,分别表示结果集合destKey中的元素的分数为该元素在各集合中分数的最大和最小值。参数weights用于指定若干乘数因子,参与并集运算的每个集合中的元素的分数与指定的乘法因子相乘,得到该元素在结果集合中的分数,该参数的默认值为1。对应的Redis命令: ZUNIONSTORE。

(2) 方法原型: @Nullable Set<ZSetOperations.TypedTuple<V>>unionWithScores(K key,Collection<K>otherKeys,RedisZSetCommands.Aggregate aggregate,RedisZSetCommands weights); 功能: 计算两个有序集合的并集,其中参数aggregate和weights的含义同(1),该方法需要Redis 6.2.0及以上版本支持; 对应的Redis命令: ZUNION。

(3) 方法原型: @Nullable Long intersectAndStore(K key,Collection<K>otherKeys,K destKey,RedisZSetCommands.Aggregate aggregate,RedisZSetCommands.Weights weights); 功能: 计算有序集合key和otherKeys的交集,并将结果存储于集合destKey中,其中参数aggregate和weights的含义同(1); 对应的Redis命令: ZINTERSTORE。

(4) 方法原型: @Nullable Set<ZSetOperations.TypedTuple<V>>intersectWithScores(K key,Collection<K>otherKeys,RedisZSetCommands.Aggregate aggregate,RedisZSetCommands Weights weights); 功能: 计算两个有序集合的交集,其中参数aggregate和weights的含义同(1); 该方法需要Redis 6.2.0及以上版本支持; 对应的Redis命令: ZINTER。

(5) 方法原型: @Nullable Long differenceAndStore(K key,Collection<K>otherKeys,K destKey); 功能: 求解有序集合key和otherKeys的差集,并将结果存放于集合destKey中,该方法需要Redis 6.2.0及以上版本支持; 对应的Redis命令: ZDIFFSTORE。

(6) 方法原型: @Nullable Set<ZSetOperations.TypedTuple<V>>differenceWithScores(K key,Collection<K>otherKeys); 功能: 计算两个有序集合的差集,该方法需要Redis 6.2.0及以上版本支持; 对应的Redis命令: ZDIFF。

【例326】公司决定调整岗位工资。目前的岗位情况如下: LiBai、XinQiji为创作岗位,岗位工资分别为2300、2100; YueFei、XinQiji为管理岗位,岗位工资分别为3300和3700。要求: ①查看只供职于创作岗位的人员及其岗位工资; ②查看供职于多个岗位的人员及其最高档位的岗位工资; ③对所有人员的岗位工资普遍调整,创作岗位人员在原岗位工资基础上翻倍,管理岗位人员在原岗位工资基础上上浮20%,对兼任多个岗位的人员,岗位工资按各自岗位工资调整细则调整后就高,输出岗位工资调整后的所有人员的岗位工资。测试代码如文件334所示。

【文件334】例326测试代码




1@Test

2public void testEx23(){

3ZSetOperations.TypedTuple<String> p1 = 

4new DefaultTypedTuple<String>("LiBai", 2300.0);

5ZSetOperations.TypedTuple<String> p2 = 

6new DefaultTypedTuple<String>("XinQiji", 2100.0);

7Set<ZSetOperations.TypedTuple<String>> ptuples = 

8new HashSet<ZSetOperations.TypedTuple<String>>();

9ptuples.add(p1);ptuples.add(p2);

10template.opsForZSet().add("poet",ptuples);

11ZSetOperations.TypedTuple<String> m1 = 

12new DefaultTypedTuple<String>("YueFei", 3300.0);

13ZSetOperations.TypedTuple<String> m2 = 

14new DefaultTypedTuple<String>("XinQiji", 3700.0);

15Set<ZSetOperations.TypedTuple<String>> mtuples = 

16new HashSet<ZSetOperations.TypedTuple<String>>();

17mtuples.add(m1);mtuples.add(m2);

18template.opsForZSet().add("general",mtuples);

19List<String> sets = new ArrayList<String>();

20sets.add("general");

21//①

22System.out.println("创作岗位人员: ");

23template.opsForZSet().differenceAndStore("poet",sets,"res");

24System.out.println(template.opsForZSet()








25.rangeWithScores("res",0,-1));

26//②

27System.out.println("兼任多个岗位人员: ");

28System.out.println(template.opsForZSet().intersectWithScores(

29"poet",sets,RedisZSetCommands.Aggregate.MAX));

30//③

31System.out.println("普调后的岗位工资: ");

32template.opsForZSet().unionAndStore("poet",sets,"person",

33RedisZSetCommands.Aggregate.MAX,

34RedisZSetCommands.Weights.of(2.0,1.2));

35System.out.println(template.opsForZSet()

36.rangeWithScores("person",0,-1));

37}







其中,文件334的第3~20行为准备数据阶段。对于要求①,通过计算集合间的差集可求解(第23行)。对于要求②,只要求解两类人员集合的交集即可。第28、29行在调用intersectWithScores()方法求解时,指定了参数aggregate的值为MAX。对于要求③,基本思路是求解并集,但在执行并集操作前,需要将两个集合中的元素的分数分别与两个乘法因子相乘,第34行调用了RedisZSetCommands.Weights类的静态方法of()为每个参与并运算的集合分别设置乘法因子。其中,of()方法的原型如下: 

public static RedisZSetCommands.Weights of(double... weights)


运行测试代码,运行结果如图321所示。



图321例326测试代码运行结果


3.8Spring操作HyperLogLog

HyperLogLog(超级日志,以下简称HLL)是Redis 2.8.9版本增加的数据结构,它可以在不保存集合元素的情况下进行集合基数(集合中元素的个数)的统计。在Redis中,每个HLL键只需要花费12KB内存,就可以计算接近264个不同元素的基数。HLL的工作原理是HLL概率算法。HLL的统计规则是基于概率的,不会非常准确,其标准差为081%。这个误差在工程上是完全可以接受的。

对于HLL的应用,通常有这样的场景: 如要统计今天连接到网站的IP地址的数量,同一个IP多次访问计数为1。此问题可抽象化: 如原始的数据记录为{1,1,2,3,3,3,3,4,4,5},去重后的数据访问记录集合为Visitors={1,2,3,4,5},Visitors集合的基数为5。对于计算集合基数的问题,一般可采用集合类型(Java和Redis中都有集合类型)。对于数据量小的应用场景来说,这是可行的。但是随着数据量增大,集合只会无限扩张,最后变得很庞大,占用大量内存空间。此时,可以用HLL来解决问题。因为HLL只会根据输入元素来计算基数,而不会存储元素本身。

操作HLL的方法与操作Redis字符串的方法类似,要调用RedisTemplate类的opsForHyperLogLog()方法创建HyperLogLogOperations子接口对象,再调用HyperLogLogOperations子接口的相关方法。本节介绍HyperLogLogOperations<K,V>子接口(org.springframework.data.redis.core.HyperLogLogOperations<K,V>)中的主要方法的使用。

(1)  方法原型: Long add(K key,V...values); 功能: 将给定的一个或多个值values添加到键key。返回值: 至少有一个值添加到键key时返回1,否则返回0; 在流水线或事务中使用时返回null。对应的Redis命令: PFADD。

(2) 方法原型: void delete(K key); 功能: 删除给定的键key。

(3) 方法原型: Long size(K...keys); 功能: 获取键keys中当前元素的数量,在流水线或事务中使用时返回null。对应的Redis命令: PFCOUNT。

(4) 方法原型: Long union(K destination,K...sourceKeys); 功能: 将键sourceKeys的所有值合并到键destination中; 对应的Redis命令: PFMERGE。

【例327】分别以u1和u2为键,向HyperLogLog各存入10000000(1000万)个范围在[0,99999]的随机整数。输出u1和u2中元素的个数,并将u1和u2合并为u,输出u中元素的个数。最后删除u1、u2和u。测试代码如文件335所示。

【文件335】例327测试代码




1@Test

2public void testHyperLogLogOperations() {

3HyperLogLogOperations<String, String> hyperLogLog =

4 template.opsForHyperLogLog();

5String[] r = new String[5000];

6String[] s = new String[5000];

7for(int i=0;i<10000000;i++) {

8int p = i % 5000;

9r[p] = String.valueOf((int)(Math.random() * 100000));

10if(p == 4999)

11hyperLogLog.add("u1",r);

12}

13for(int j=0;j<10000000;j++){

14int q = j % 5000;

15s[q] = String.valueOf((int)(Math.random() * 100000));

16if(q == 4999)

17hyperLogLog.add("u2",s);

18}

19System.out.println("the size of u1 is "+hyperLogLog.size("u1"));

20System.out.println("the size of u2 is "+hyperLogLog.size("u2"));

21

22hyperLogLog.union("u","u1","u2");

23System.out.println("the size of union is "+hyperLogLog.size("u"));

24/*

25hyperLogLog.delete("u1");

26hyperLogLog.delete("u2");








27hyperLogLog.delete("u");

28*/

29}








图322运行测试代码前Redis
的内存占用情况


在运行此测试代码前,可利用info memory命令查看Redis的内存占用情况,如图322所示。

运行测试代码后,控制台输出如图323所示。从输出结果可知,HLL能够实现元素的自动去重,效果和集合类似。此外,union()方法(第22行)将两个HLL进行了合并,并且去掉了重复的元素,然后返回不重复元素个数。再次使用info memory命令检查Redis的内存占用情况,如图324所示。从代码运行前后的内存占用情况来看,向Redis中写入两千万条数据后(97781490B,约93.25MB),内存占用由725368B增加至771352B,只增加了44.9KB。



图323例327测试代码运行结果




图324运行测试代码后Redis的内存占用情况



HLL的一项重要应用就是计数器。对于Web应用程序来讲,持续收集信息是一件非常重要的事。例如,已知网站在5分钟内得到11000次点击,数据库在5秒内处理了200次写入和600次读取请求。这些数据都是非常重要的。因为通过在一段时间内持续地记录这些信息,可以了解网站流量的增减情况,进而根据这些数据预测何时需要对服务器进行升级,从而防止系统因为负载增加而宕机。对于网站来说,与流量相关的指标点有UV(Unique Visitor,限定时段内同一客户端多次访问计为1次)、IP(用户的IP地址,限定时段内同一IP地址多次访问计为1次)等。

【例328】要求记录0~24时访问网站的IP地址的数量(同一IP地址多次访问计为1次),记录保留24小时。如果选用集合作为存储IP地址的数据类型,则会因为IP地址数量的增加导致集合占用的内存空间不断增大。本例只需要统计去重后的IP地址的数量,而无须记录每个IP地址,因此可以采用HLL来完成此任务。测试代码如文件336所示。

【文件336】例328测试代码




1@Test

2public void testIPCounter(){

3HyperLogLogOperations<String, String> hLLOps =

4template.opsForHyperLogLog();

5String remoteAddr = "",backAddr = "",date_key="";

6Random random = new Random();

7StringBuilder ipBuilder = new StringBuilder();

8for(int j = 0; j < 10; j++) {

9for (int i = 0; i < 4; i++) {

10ipBuilder.append(random.nextInt(256));

11if (i != 3)

12ipBuilder.append(".");

13}

14if( j < 8 ) {








15remoteAddr = ipBuilder.toString();

16if(j==1)

17backAddr = remoteAddr;

18}

19else

20remoteAddr = backAddr;

21System.out.println("Remote IP : "+remoteAddr);

22date_key = new SimpleDateFormat(

23"yyyy-MM-dd HH:mm").format(System.currentTimeMillis());

24hLLOps.add(date_key,remoteAddr);

25template.expire(date_key,24,TimeUnit.HOURS);

26ipBuilder.setLength(0);

27}

28System.out.println("IP 地址数: "+hLLOps.size(date_key));

29}








图325例328测试代码运行结果

如文件336所示,第9~20行随机产生10个IP地址。为模拟同一IP地址多次访问,第16~17行将某次(本例为第2次)产生的IP地址做备份,随后将该备份地址作为第9次和第10次的IP地址(第19、20行)。第22、23行将当前系统时间作为HLL的键,第24行将IP地址保存到HLL中,执行去重计数。第25行设定记录在Redis中保存的时长。该测试代码的运行结果如图325所示。


3.9Spring操作Redis位图

位图是通过计算机存储数据的最小单位——位来表示某个元素对应的值或者状态的方法。一个位的值或者是0,或者是1,也就是说一个位只能表示两种状态。Redis位图是字符串类型的扩展,可以将字符串视为位向量,进而对一个或多个字符串执行逐位操作。例如,字符串"big",字母b、i和g对应的ASCII码分别为0x62(01100010B)、0x69(01101001B)和0x67(01100111B),则字符串"big"在Redis的存储情况为: 



011000100110100101100111

'b'
'i'
'g'


由于位图是字符串类型的扩展,因此位图类型的数据也可以使用字符串类型的命令,主要是SET和GET。可以通过SETBIT命令设置各位的值,来保存字符串。如,在Redis中执行下述命令: 

redis> SETBIT bk 0 0

(integer) 0

redis> SETBIT bk 1 1

(integer) 0

redis> SETBIT bk 2 1

(integer) 0

redis> SETBIT bk 3 0

(integer) 0

redis> SETBIT bk 4 0

(integer) 0

redis> SETBIT bk 5 0

(integer) 0

redis> SETBIT bk 6 1

(integer) 0

redis> SETBIT bk 7 0

(integer) 0

redis> GET bk

"b"

SETBIT命令的格式为: SETBIT key offset value。功能: 针对key存储的字符串值,设置或清除指定偏移量offset上的位,位的设置或清除取决于value值,即1或0。当key不存在时,会创建一个新的字符串。而且这个字符串的长度会伸展,直到可以满足指定的偏移量offset(0≤offset<232),在伸展过程中,新增的位的值被设置为0。如果想要设置位图的非零初值,一种方式就是将每个位逐个设置为0或1,但是这种方式比较麻烦; 另一种方式是可以直接使用SET命令存储一个字符串。如准备设置一个8位长度的位图的初值,其中的第2、3、4位为1,其余位为0,即初值为00111000,此初值为字符'8'的ASCII码。因此,可通过下述命令完成初值设置。

redis> set mk "8"

OK

redis> GETBIT mk 2

(integer) 1

redis> GETBIT mk 3

(integer) 1

redis> GETBIT mk 4

(integer) 1

redis> GETBIT mk 1

(integer) 0


GETBIT命令的格式为: GETBIT key offset。功能: 返回key对应的字符串在offset位置的位,当offset大于值的长度时,返回0; 当key不存在时,可以认为value为空字符串,此时offset肯定大于空字符串长度,返回0。Redis中位图相关的常用命令如表35所示。


表35Redis中位图相关的常用命令



命令含义


GETBIT key offset返回以键(key)存储的字符串值中偏移量(offset)处的位值
SETBIT key key offset value设置或清除键(key)处存储的字符串值中的偏移位。根据值(value可以是1或0)设置或清除位
BITCOUNT key [start end]获取位图指定范围中位值为1的个数,如果不指定start与end,则取所有
BITOP op destKey key1 
[key2...]执行多个BitMap的AND(交集)、OR(并集)、NOT(非)、XOR(异或)操作并将结果保存在destKey中



位图基于位进行存储,所以具有节省空间、操作快速、方便扩容等优点。位图有很多应用场景,如: 

(1) 记录用户在线状态,只需要一个键,将用户id作为偏移量。如果用户在线就设置为1,不在线就设置为0,3亿用户只需要36MB的空间。命令示例如下: 

$status = 1;

$redis->setBit('online', $uid, $status);

$redis->getBit('online', $uid);


(2) 统计活跃用户数,使用时间作为缓存的键,用户id作为偏移量。如果当日活跃过就设置为1,通过简单的计算就可得到用户在某时段的活跃情况。命令示例如下: 

$status = 1;

$redis->setBit('active_20220708', $uid, $status);

$redis->setBit('active_20220709', $uid, $status); 

$redis->bitOp('AND', 'active', 'active_20220708', 'active_20220709');


(3) 用户签到。假设某网站有1000万用户,平均每人每年签到次数为10次,如果用关系数据库保存签到数据的话,1年将产生1亿条签到数据。这样就需要保存和处理大量的意义不大的数据。如果将用户每日签到信息保存到Redis中,则以位图形式处理就会节约很多空间。如果用户签到以1表示,未签到则以0表示。在执行按月统计用户签到信息时,只需要32位数据,每个用户4B即可保存。1000万用户一年的签到数据仅仅480MB。如,12月前10天某用户的签到情况如下: 除12月3日、4日、7日和9日没有签到,其余已签到。签到数据可以用位图表示如下: 


1100110101

1211221231241251261271281291210


要统计该用户在12月前10天签到的总天数,只要计算位图结构中1的数量即可。处理用户签到程序的代码如文件337所示。

【文件337】TestBitmapOperations.java




1@RunWith(SpringJUnit4ClassRunner.class)

2@ContextConfiguration(classes= RedisTemplateConfig.class)

3public class TestBitmapOperations {

4@Autowired

5private StringRedisTemplate template;

6

7@Test

8public void testAttendance() throws NoSuchAlgorithmException {

9String uid = "1001";

10LocalDateTime now = LocalDateTime.now();

11String date =

12now.format(DateTimeFormatter.ofPattern("yyyyMM"));

13String key = uid+":"+date;

14SecureRandom random = SecureRandom.getInstance("SHA1PRNG");

15random.setSeed(2L);

16for(int i=0;i<31;i++) {

17int a = random.nextInt(2);

18template.opsForValue().setBit(key, i, a==1);

19System.out.print(a+" ");

20}

21List<Long> result = template.opsForValue().bitField(key,

22BitFieldSubCommands.create().get(BitFieldSubCommands

23.BitFieldType.unsigned(31)).valueAt(0));








24Long num= result.get(0);

25int count = 0;

26while (num>0) {

27//让这个数字与1做与运算,得到数字的最后一个位判断这个数字是否为0

28if ((num & 1) == 1)

29//如果为1,则表示签到1次

30count++;

31num >>>= 1;

32}

33System.out.println("num = "+count);

34}

35}







如文件337所示,第2行指定了元数据的配置类为RedisTemplateConfig.class(代码可参考文件36)。第9~13行设定存入Redis中的键的格式为“用户id:年月”。第14、15行随机产生1、0两个数字,用来模拟用户签到数据。第16~20行将模拟的签到数据(按每月31天计算)存入Redis的位图中,同时将签到数据输出到控制台,以便核对。第21~34行进行当月签到次数的统计,即计算位图中数字1出现的次数。其中,第22、23行获取本月的全部签到数据,其返回结果是一组十进制数字。随后,用这个数字的每一位与数字1做与运算,根据与运算的结果判定当天是否签到(第27~33行)。最后,经过与运算得到的1的个数即为签到的天数。程序运行结果如图326所示。



图326签到统计程序运行结果 


3.10键绑定操作子接口

前面几节分别介绍了 ValueOperations、ListOperations等操作接口。这些操作接口有一个共性,在进行某个具体操作时,都需要指定键,例如,使用这些操作接口添加值时,如下: 



1SetOperations -> add(K key, V... values)

2ZSetOperations -> add(K key, Set<ZSetOperations.TypedTuple<V>> tuples)

3ValueOperations -> set(K key, V value)

4ListOperations -> leftPush(K key, V value)







其实在整个操作过程中,可能并没有更换键,只是反复对一个键进行设置、取值、删除操作。于是,RedisTemplate类提供了一套便捷操作子接口——键绑定操作子接口,这些子接口提前将某个键和操作接口进行绑定。这样,在使用键绑定操作子接口进行操作时,就不需要传递键了。RedisTemplate类提供的键绑定子接口如表36所示。


表36RedisTemplate类提供的键绑定子接口




子接口说明


BoundGeoOperations地理空间数据键绑定操作
BoundHashOperations哈希键绑定操作
BoundKeyOperations键绑定操作
BoundListOperations列表键绑定操作
BoundSetOperations集合键绑定操作
BoundValueOperations字符串(或值)键绑定操作
BoundZSetOperations有序集合键绑定操作


下面通过一些示例演示怎样利用键绑定操作子接口操作哈希、列表、集合、字符串、有序集合数据类型。

【例329】利用键绑定子接口操作键为boundvalue的字符串类型(String)的数据。分别执行添加、修改和追加值操作。测试代码如文件338所示。

【文件338】例329测试代码




1@Test

2public void testValueBoundOperations() throws InterruptedException{

3BoundValueOperations<String,String> ops =

4 template.boundValueOps("bound-value");

5//添加值

6ops.set("value1");

7System.out.println(ops.get());

8//修改值

9ops.set("value2");

10System.out.println(ops.get());

11//追加值

12ops.append(" append");

13System.out.println(ops.get());

14ops.set("value3", Duration.ofMillis(3000));

15System.out.println(ops.get());

16Thread.sleep(5000);

17System.out.println(ops.get());

18}








图327例329测试代码运行结果


如文件338所示,第14行在设置值value3的同时,指定了该值在Redis中的缓存时长。第16行设置当前程序挂起5秒,等待键boundvalue对应的值value3过期,第17行再次获取键boundvalue对应的值。运行此测试代码,运行结果如图327所示。

【例330】利用键绑定子接口操作键为boundlist的列表类型(List)的数据。分别执行添加、修改和删除操作。测试代码如文件339所示。

【文件339】例330测试代码




1@Test

2public void testListBoundOperations() {

3BoundListOperations<String, String> ops =

4template.boundListOps("bound-list");

5//添加

6ops.leftPush("value1");








7ops.rightPush("value2");

8ops.rightPushAll("value3","value4");

9System.out.println(ops.range(0,-1));

10//修改

11ops.set(0,"new value");

12System.out.println(ops.range(0,-1));

13//删除

14ops.remove(1,"value3");

15System.out.println(ops.range(0,-1));

16}








图328例330测试代码运行结果


运行此测试代码,运行结果如图328所示。

【例331】利用键绑定子接口操作键为boundhash的哈希类型的数据。分别执行添加、修改和扫描操作。测试代码如文件340所示。

【文件340】例331测试代码




1@Test

2public void testHashBoundOperations() {

3BoundHashOperations<String, String,String> ops =

4 template.boundHashOps("bound-hash");

5//添加

6ops.put("k1","v1");

7Map<String,String> map = new HashMap<>();

8map.put("k2","v2");map.put("k3","100");

9ops.putAll(map);

10System.out.println(ops.entries()); System.out.println("--------");

11//修改

12ops.increment("k3",10);

13System.out.println("k3 = "+ops.get("k3"));

14ops.delete("k1");

15System.out.println(ops.entries()); System.out.println("--------");

16//扫描

17Cursor<Map.Entry<String,String>> cursor =

18ops.scan(ScanOptions.NONE);

19cursor.forEachRemaining(entry-> System.out.println(

20entry.getKey()+"="+entry.getValue()));

21}







运行此测试代码,运行结果如图329所示。



图329例331测试代码运行结果


【例332】利用键绑定子接口操作键为boundset的集合类型的数据。分别执行添加、移除和扫描操作。测试代码如文件341所示。

【文件341】例332测试代码




1@Test

2public void testSetBoundOperations() {

3BoundSetOperations<String,String> ops =

4template.boundSetOps("bound-set");

5ops.add("v1","v2","v3","v4","v5");

6System.out.println(ops.members());

7System.out.println("--------");

8ops.remove("v3");

9Cursor<String> cursor = ops.scan(ScanOptions.NONE);

10if(cursor != null)

11cursor.forEachRemaining(e -> System.out.print(e+" "));

12}








图330例332测试代码运行结果


运行此测试代码,运行结果如图330所示。

【例333】利用键绑定子接口操作键为boundzset的有序集合类型的数据。分别执行添加、移除和扫描操作。测试代码如文件342所示。

【文件342】例333测试代码




1@Test

2public void testZSetBoundOperations() {

3BoundZSetOperations<String, String> ops =

4template.boundZSetOps("bound-zset");

5ops.add("GuanYu",32.0D); 

6ops.add("LiuBei",21.9D);

7ops.add("ZhangFei",41.0D);

8ops.add("CaoCao",100.0D);

9System.out.println(ops.range(0,-1));

10ops.remove("CaoCao");

11System.out.println("--------");

12Cursor<ZSetOperations.TypedTuple<String>> tups=

13ops.scan(ScanOptions.NONE);

14tups.forEachRemaining(tup-> System.out.println(

15tup.getValue()+"="+tup.getScore()));

16}








图331例333测试代码运行结果


运行此测试代码,运行结果如图331所示。

在电商或社交网络中,经常有自动补全的需求。用户在文本框输入的文字时,会自动弹出一个下拉框,给用户提供候选词,方便输入。例如,在百度主页输入“大学”两个字,会有相应的候选词出现,如图332所示。

关于自动补全这个功能,可以使用Redis的有序集合实现。有序集合会默认将存入的字符串按分数进行升序排列。如果存入的字符串的分数相等,则按照字符串中字母的字典序升序排列。如存入分数相同的三个字符串"aab"、"abb"、"aaba",则这三个字符串在有序集合中的顺序为"aab"、"aaba"、"abb"。这样,就可以把提供给用户的候选词存放到有序集合中,并且保持这些词的分数相同。那么,要查找带有前缀为abc的词,就是查找前缀介于abbz和abd之间的词,如图333所示,即锁定图中p和q的位置。



图332百度主页提供的候选词




图333有序集合中存放的候选词


要锁定p和q的位置,以查找前缀为abc的词(不包括abc本身)为例,只需要向有序集合中插入abc`和abc{两个元素。因为在ASCII编码中,排在字符a前面的字符是反引号(`)字符,排在字符z后面的是左花括号({)字符。即abc`会排在所有拥有abca前缀的词的前面,而位于所有拥有abbz前缀的词的后面。同理,abc{会位于所有拥有abcz前缀的词的后面,而位于所有拥有abd前缀的词的前面。两个元素进入有序集合后,就会占据图333中p和q指向的位置,如图334所示。



图334有序集合中插入两个元素后的情况


随后,通过p和q,即abc`和abc{在有序集合中的位置便可确定提供给用户的候选词集合。

【例334】准备一组候选词,根据用户输入的单词前缀给出最多10个候选词,实现自动补全功能。测试代码如文件343所示。

【文件343】AutoCompletion.java




1public class AutoCompletion {

2public Set<String> doAutoCompletion(StringRedisTemplate template,

3String prefix){

4String[] names = {

5 "astra","astrid","astrix","athena","athene","atlanta",

6 "barb","barbara","barbe","barbey","barrie","barry",

7 "caden","cadesa","cafesse","cagey","caril","carine"








8};

9BoundZSetOperations<String, String> ops =

10template.boundZSetOps("words");

11for(int i=0;i<names.length;i++)

12ops.add(names[i],0);

13String p = prefix+"`";

14String q = prefix+"{";

15ops.add(p,0);

16ops.add(q,0);

17Long begin_index = ops.rank(p);

18Long end_index = ops.rank(q);

19if(end_index-begin_index>10)

20end_index = begin_index+11;

21Set<String> tips = ops.range(begin_index+1,end_index-1);

22ops.remove(p);

23ops.remove(q);

24return tips;

25}

26}







如文件343所示,第4~8行将一组候选词存放到names数组中。第9、10行绑定操作有序集合的键words。第11、12行将names数组中存放的候选词加入有序集合,同时设定所有元素的分数为0。这就意味着所有加入有序集合的候选词会按照字母表顺序升序排列。第13、14行根据用户输入的词语前缀生成两个定位符p和q。第15、16行将p、q加入有序集合。第17、18行获取两个定位符在有序集合中的下标(位置),这样,在两个定位符之间的元素组成了返回给用户的候选词集合。第21行从有序集合中取出一定数量的候选词,随后删除两个定位符(第22、23行)。

根据文件343中给出的候选词,编写测试代码,从候选词中查找前缀为ast的单词作为提供给用户的候选词(最多取出10个)。测试代码如文件344所示。

【文件344】TestAutoCompletion.java 




1@RunWith(SpringJUnit4ClassRunner.class)

2@ContextConfiguration(classes = RedisTemplateConfig.class)

3public class TestAutoCompletion {

4@Autowired

5private StringRedisTemplate template;

6

7@Test

8public void testAutoCompletion(){

9AutoCompletion app = new AutoCompletion();

10Set<String> tips = app.doAutoCompletion(template,"ast");

11tips.forEach(System.out::println);

12}

13}







运行测试代码,从文件343提供的候选词中选择出含有ast前缀的词,测试结果如图335所示。



图335自动补全功能测试结果


3.11RedisTemplate类的通用方法

RedisTemplate类除了提供Redis常用数据类型的操作接口外,还提供了操作缓存的方法,如删除键、判断键是否存在、键设置过期时间、键移动等方法。本节将介绍这些方法。

(1) 方法原型: List<RedisClientInfo>getClientList(); 功能: 获取已连接的客户端的信息。

(2) 方法原型: Boolean expire(K key,long timeout,TimeUnit unit); 功能: 设置指定键key的生存时间。

(3) 方法原型: Boolean persist(K key); 功能: 移除指定键key的过期时间; 对应的Redis命令: PERSIST。 

(4) 方法原型: void rename(K oldKey,K newKey); 功能: 重命名键; 对应的Redis命令: RENAME。

(5) 方法原型: Boolean hasKey(K key); 功能: 判断键key是否存在; 对应的Redis命令: EXISTS。

(6) 方法原型: Boolean delete(K key); 重载形式: Long delete(Collection<K>keys); 功能: 删除指定的键key(s); 对应的Redis命令: DEL。

【例335】应用上述方法完成以下要求: ①获取客户端的信息; ②将键expirekey的过期时间设置为3秒; ③移除expirekey的过期时间; ④将键nokey重命名为haskey,并判断haskey和nokey是否存在; ⑤删除键deletekey。测试代码如文件345所示。

【文件345】例335测试代码




1@Test

2public void testCommonOperations1() {

3//①获取客户端连接信息

4System.out.println("---- exercise 1 ----");

5List<RedisClientInfo> list = template.getClientList();

6if(list != null)

7list.forEach(System.out::println);

8//②设置过期时间

9System.out.println("---- exercise 2 ----");

10template.opsForValue().set("expire-key","data");

11template.expire("expire-key", Duration.ofMillis(3000));

12System.out.println("expire-key exprired within "

13+template.getExpire("expire-key")+" ms");








14//③清理过期时间

15System.out.println("---- exercise 3 ----");

16template.persist("expire-key");

17System.out.println("expire="+template.getExpire("expire-key"));

18//④重命名键

19System.out.println("---- exercise 4 ----");

20template.opsForValue().set("no-key","value");

21template.rename("no-key","has-key");

22//⑤判断键是否存在

23System.out.println("---- exercise 5 ----");

24template.opsForValue().set("has-key","data");

25Boolean flag = template.hasKey("has-key");

26if(flag != null && flag)

27System.out.println("键has-key存在");

28else

29System.out.println("键has-key不存在");

30System.out.println(template.hasKey("no-key")?"键no-key存在":

31"键no-key不存在");

32//⑥删除键

33System.out.println("---- exercise 6 ----");

34template.opsForValue().set("delete-key","value");

35System.out.print("删除前:  ");

36System.out.println(template.hasKey("delete-key")?

37"delete-key存在":"delete-key不存在");

38template.delete("delete-key");

39System.out.print("删除后:  ");

40System.out.println(template.hasKey("delete-key")?

41"delete-key存在":"delete-key不存在");

42}







如文件345所示,本例使用的template是RedisTemplate<String,String>的实例,其配置代码可参考文件36。运行此测试代码,控制台的输出如图336所示。



图336例335测试代码运行后控制台的输出


(7) 方法原型: Set<K>keys(K pattern); 功能: 查找与给定模式匹配的所有键; 对应的Redis命令: KEYS。

其中,关于参数pattern的定义有以下几种情况: 

① 问号(?)匹配符: 表示仅匹配一个字母,如h?llo可匹配hello、hallo和hxllo等。

② 星号(*)匹配符: 表示匹配0个或多个字母,如h*llo可匹配hllo和heeeello等。

③ 列表([])匹配: 表示仅匹配列表中的一个字符,如h[ae]llo可匹配hello和hallo,但不能匹配hillo。也可以在列表中指定一个有序序列的首尾字符,则可以匹配包括首尾字符在内的一个字符,如h[ab]llo可匹配hallo和hbllo。此外,可以用“^”符号结合列表符号“[”和“]”实现排除某个字符,如h[^e]llo可匹配hallo、hbllo等,但不能匹配hello。

此外,方法原型定义中的返回值类型Set中的泛型K与RedisTemplate<K,V>中定义的泛型K类型一致。在本节中,已将泛型K定义为String。

【例336】将<CaoCao,v1>、<CaoPi,v21>、<CaoZhang,v22>和<CaoXiong,v23>分别加入Redis。要求: ①找到Cao开头的所有键及其值; ②找到Cao开头的跟随两个字母的键及其值; ③找到Cao开头的随后为P或C的键及其值; ④找到以Cao开头的随后为X或Z,并以g为结尾的键及其值; ⑤找到以Cao开头的随后不含字母C的键及其值。测试代码如文件346所示。

【文件346】例336测试代码




1@Test

2public void testCommonOperations2() {

3template.opsForValue().set("CaoCao","v1");

4template.opsForValue().set("CaoPi","v21");

5template.opsForValue().set("CaoZhang","v22");

6template.opsForValue().set("CaoXiong","v23");

7//1-1 pattern 1 "*"

8template.keys("Cao*").iterator().forEachRemaining(e->

9System.out.println(e+" : "+template.opsForValue().get(e)));

10//1-2 pattern 2 "?"

11template.keys("Cao??").iterator().forEachRemaining(e->

12System.out.println(e+" : "+template.opsForValue().get(e)));

13//1-3 pattern 3 "[PC]*"

14template.keys("Cao[PC]*").iterator().forEachRemaining(e->

15System.out.println(e+" : "+template.opsForValue().get(e)));

16//1-4 pattern 4 "Cao[X-Z]???g" or "Cao[X-Z]*"

17template.keys("Cao[X-Z]*").iterator().forEachRemaining(e->

18System.out.println(e+" : "+template.opsForValue().get(e)));

19//1-5 pattern 5 "Cao[^C]*"

20template.keys("Cao[^C]*").iterator().forEachRemaining(e->

21System.out.println(e+" : "+template.opsForValue().get(e)));

22}







运行此测试代码,控制台的输出如图337所示。



图337例336测试代码运行后控制台的输出


(8) 方法原型: K randomKey(); 功能: 从键空间中随机返回一个键; 对应的Redis命令: RANDOMKEY。

(9) 方法原型: DataType type(K key); 功能: 确定键存储的数据的类型; 对应的Redis命令: TYPE。

【例337】向key0~key7共8个键中存入8个随机选择的字符串,再从此8个键中随机取出3个键,并判断对应的值的类型。测试代码如文件347所示。

【文件347】例337测试代码




1@Test

2public void testCommonOperations3() {

3for(int i=0;i<8;i++)

4template.opsForValue().set(

5"key"+i,UUID.randomUUID().toString());

6for(int j=0;j<3;j++){

7String rk = template.randomKey();

8System.out.println(rk+":"+template.opsForValue().get(rk));

9System.out.println("type is:"+ template.type(rk).name());

10}

11}







运行此测试代码,控制台的输出如图338所示。



图338例337测试代码运行后控制台的输出


(10) 方法原型: Boolean move(K key,int dbIndex); 功能: 将键key移动到由参数dbIndex指定的数据库; 对应的Redis命令: MOVE; Redis默认配置中共有16个数据库,索引为0~15。在Redis命令提示符下可以用 

select dbIndex 

命令切换不同的数据库。不同数据库之间的数据没有任何关联,甚至可以存在相同的键。

(11) 方法原型: byte[]dump(K key); 功能: 执行数据备份; 对应的Redis命令: DUMP。该方法可执行Redis的转储命令并返回结果。Redis使用非标准的序列化机制并包含校验和信息,因此该方法返回原始字节,而没有使用ValueSerializer进行反序列化。可使用转储的返回值(byte[])作为要备份的值的参数。

(12) 方法原型: public void restore(K key,byte[] value,long timeToLive,TimeUnit unit,boolean replace); 功能: 执行Redis还原命令。其中,参数key指定要还原的键,即目标键; 参数value指定转储对象返回的要还原的值; 参数timeToLive指定目标键的到期时间,0表示无到期时间; 参数replace指定为true表示替换可能存在的值,默认值false。对应的Redis命令: RESTORE。

【例338】完成如下两个要求: ①将movekey键移动到索引为4的数据库; ②将backupkey键备份到backupkeybak键,并设置键backupkeybak的生存时间为30分钟。测试代码如文件348所示。

【文件348】例338测试代码




1@Test

2public void testCommonOperations4() {

3template.opsForValue().set("move-key","data");

4System.out.println("移动前"+template.opsForValue()

5 .get("move-key"));

6template.move("move-key",4);

7System.out.println("移动后"+template.opsForValue()

8.get("move-key"));

9template.opsForValue().set("backup-key","this is important");

10byte[] backup = template.dump("backup-key");

11if(backup != null) {

12template.restore("backup-key-bak", backup, 30,

13TimeUnit.MINUTES);

14System.out.println("backup data: " +

15template.opsForValue().get("backup-key-bak"));

16}

17}







执行移动操作后(第6行),movekey从0号数据库被移动到4号数据库。当再次执行获取值操作时(第7、8行),返回null。此时,在Redis客户端执行select 4命令,可以在4号数据库找到被移动过来的movekey键: 

redis> select 4

OK

redis[4]> keys *

1) "move-key"



图339例338测试代码运行
后控制台的输出


运行此测试代码,控制台的输出如图339所示。

(13) 方法原型: List<V>sort(SortQuery<K>query); 功能: 对要查询的元素进行排序; 对应的Redis命令: SORT。

【例339】对给定的8位散文家(SuXun,HanYu,Wang'anShi,SuShi,LiuZongYuan,SuZhe,OuYangXiu,ZengGong)按名字的字母序升序排列,输出排序后的前三位。测试代码如文件349所示。

【文件349】例339测试代码




1@Test

2public void testCommonOperations3() {

3String sortKey = "sortKey";

4String[] names = {"SuXun","HanYu","Wang'anShi","SuShi",

5"LiuZongYuan","SuZhe","OuYangXiu","ZengGong"};








6String[] marks= {"1009","768","1021","1037",

7"773","1039","1007","1019"};

8template.delete(sortKey);

9if (!template.hasKey(sortKey)) {

10for (int i=0; i<8; i++) {

11template.boundSetOps(sortKey).add(String.valueOf(i));

12String hashKey = "hash" + i,

13pid = String.valueOf(i),

14pname = names[i],

15pmark = marks[i];

16template.boundHashOps(hashKey).put("id", pid);

17template.boundHashOps(hashKey).put("name", pname);

18template.boundHashOps(hashKey).put("mark", pmark);

19System.out.printf("%s:{\"_id\":%s,\"Name\":

20%s,\"Mark\",%s}\n",hashKey, pid, pname, pmark);

21}

22}

23SortQuery<String> sortQuery = SortQueryBuilder.sort(sortKey)

24.by("hash*->name")

25.alphabetical(true)

26.limit(new SortParameters.Range(0,3))

27.order(SortParameters.Order.ASC)

28.get("hash*->id")

29.get("hash*->name")

30.get("hash*->mark").build();

31System.out.println("---- ----");

32List<String> list = template.sort(sortQuery);

33for(int j=0;j<list.size();j+=3)

34System.out.printf("{\"ID\": %s, \"Name\": %s, \"Mark\", %s}\n",

35list.get(j), list.get(j+1), list.get(j+2));

36}







排序运行原理可以了解Redis的SORT命令(见1.4.1节)。运行此测试代码,控制台的输出如图340所示。



图340例339测试代码运行后控制台的输出


在RedisTemplate中,定义了几个execute()方法,这些方法是RedisTemplate的核心方法。RedisTemplate中很多其他方法均是通过调用execute()方法来执行具体的操作。表37列举了execute()方法的6种重载形式。


表37RedisTemplate定义的execute()方法



方 法 原 型说明


<T> T  execute(RedisCallback<T> action)在Redis连接中执行给定的操作
<T> T  execute(RedisCallback<T> action,
boolean exposeConnection)在Redis连接中执行给定的操作,参数exposeConnection表示是否要暴露当前连接,如果为true,那么就可以在回调函数中使用当前连接对象
<T> T  execute(RedisCallback<T> action,
boolean exposeConnection,boolean pipeline)在连接中执行给定的操作,参数exposeConnection的含义同上,参数pipeline表示是否开启流水线(见5.3节)
<T> T  execute(RedisScript<T> script,List
<K> keys,Object... args)
执行给定的Redis脚本(Redis Script)
<T> T  execute(RedisScript<T> script,
RedisSerializer<?> argsSerializer,

RedisSerializer<T> resultSerializer,

List<K> keys,Object... args)
执行给定的Redis脚本,使用提供的RedisSerializer序列化脚本参数和结果
<T> T  execute(SessionCallback<T> session)执行Redis会话




使用RedisTemplate直接调用opsFor**()方法来操作Redis时,每执行一条命令时都要重新获取一个连接,因此很耗资源。可以调用execute()方法,让一个连接直接执行多次Redis操作语句。

【例340】利用一个Redis连接完成字符串数据的存取操作。测试代码如文件350所示。

【文件350】TestExecuteMethod.java




1@RunWith(SpringJUnit4ClassRunner.class)

2@ContextConfiguration(classes = ExecuteMethodConfig.class)

3public class TestExecuteMethod {

4@Autowired

5private RedisTemplate<String,String> template;

6@Test

7public void testExecuteByRedisCallback(){

8template.execute((RedisCallback<Object>) connection -> {

9connection.stringCommands().set("key".getBytes(),

10 "hello,redis".getBytes());

11byte[] res = connection.stringCommands()

12 .get("key".getBytes());

13System.out.println(new String(res));

14return null;

15});

16}

17}







如文件350所示,第8行指定传递给execute()方法的参数为RedisCallback(接口)类型的对象。而RedisCallback接口中只定义了一个doInRedis(RedisConnection connection)方法。因此,第9~12行可调用RedisConnection接口中定义或继承的方法。RedisConnection接口代表了一个与Redis服务器的连接,它是各种Redis客户端(或驱动程序)的抽象。并且,RedisConnection接口继承了RedisStringCommands、RedisKeyCommands等众多Redis操作接口,可以编程形式完成绝大部分的Redis操作。第9行调用RedisConnection接口的stringCommands()方法获取RedisStringCommands接口类型对象,并调用RedisStringCommands接口的Boolean set(byte[]key,byte[]value)和byte[]get(byte[]key)方法完成字符串存取操作。运行此代码,控制台输出hello,redis字符串。

【例341】利用一个Redis会话完成字符串数据的存取操作。测试代码如文件351所示。

【文件351】例341测试代码




1@Test

2public void testExecuteBySessionCallback(){

3template.execute(new SessionCallback<Object>(){

4public String execute(RedisOperations operations) 

5 throws DataAccessException {

6BoundValueOperations<String, String> bops =

7operations.boundValueOps("key3");

8bops.set("hello,world");

9System.out.println(bops.get());

10return null;

11}

12});

13}







运行此测试代码,控制台输出hello,world字符串。用于执行Redis脚本的execute()方法的案例见5.4.3节。

3.12序列化和反序列化

虽然Redis支持各种数据类型,但Redis中存储的数据只有字节。因此,要利用Redis存储Java程序中的对象,就要将对象转换为字节数组或字符串再保存到Redis中。将对象转换为可传输(或可存储)的字节序列或字符串的过程称为序列化; 将字节序列或字符串还原为对象的过程称为反序列化。进行序列化就是为了对象能够通过网络传输和跨平台存储。不同的计算机系统能够识别和处理的数据的通用格式是二进制数据(或纯文本数据)。因此,需要将对象按照一定规则转换为字节数组或字符串才能实现跨平台传输和存储的目的,这就是序列化。当需要对象时,再按这个规则把对象还原出来,这就是反序列化。作为键值型数据库,Redis写入数据时,可以分别指定键和值的序列化机制。此外,还可以使用Redis哈希类型来实现更复杂的结构化对象映射,Spring Data Redis提供了将Java对象映射到哈希的各种策略。本节将分别介绍键值序列化机制和对象哈希序列化机制。

3.12.1内置序列化器

将Java对象存储到Redis中时,需要进行序列化操作,如将Java对象序列化为JSON字符串。此时,Redis中保存的内容为序列化后的JSON字符串。同样,如果要将序列化后的JSON字符串从Redis中取出,再转换为存储前的Java对象则需要反序列化操作,将JSON字符串转换为Java对象。在Spring Data Redis中,Java对象和二进制数据之间的转换(反之亦然)可利用org.springframework.data.redis.serializer包(以下简称为serializer包)中提供的接口或类处理。

1.  序列化器

该包提供了两种类型的序列化器。

(1) 基于RedisSerializer接口的双向序列化器。

(2) 使用RedisElementReader接口和RedisElementWriter接口的元素读写器。

以上两种序列化器的主要区别是RedisSerializer接口主要序列化为字节数组(byte[]),而读写器使用字节缓冲区(ByteBuffer)。

2.  RedisSerializer接口的实现类

在Spring Data Redis中,serializer包提供了以下几个RedisSerializer接口的实现类。

(1) JdkSerializationRedisSerializer类: 使用JDK的序列化器,将对象通过ByteArrayOutputStream类和ByteArrayInputStream类进行序列化和反序列化,最终Redis中将存储字节序列。该类是RedisCache类和RedisTemplate类默认的序列化器。限制: 被序列化的类需要实现Serializable接口,而且Redis中存储的数据很不直观,序列化后的内容为十六进制数字或乱码。

(2) StringRedisSerializer类: 在键或值为字符串时,该类根据指定的字符集将字符串转换为字节序列(byte[]),也可以执行字节序列到字符串的反序列化。该类通过String类的String.getBytes(Charset charset)和String(byte[]byte,Charset charset)方法实现序列化和反序列化,是最轻量级和最高效的序列化器。

(3) Jackson2JsonRedisSerializer类: 使用Jackson和Jackson Databind ObjectMapper读取和写入JSON的序列化器。该序列化器可用于绑定到类型化的Bean或非类型化的HashMap实例。注意,空对象被序列化为空数组,反之亦然。

(4)  GenericJackson2JsonRedisSerializer类: 使用Jackson实现的序列化器。该类实现Java对象到JSON字符串的序列化,以及JSON字符串到Java对象的反序列化。使用该类执行序列化时,会保存序列化的对象的包名和类名,反序列化时以包名和类名作为标识就可以还原成指定的对象。

(5) GenericToStringSerializer类: 该序列化器使用Spring的ConversionService,使用默认的字符集UTF8将对象转换为字符串,从而完成序列化。反之亦然。

(6) OxmSerializer类: 可实现对象与XML之间的相互转换。使用此序列化器,编程将会有些难度,而且效率最低,不建议使用。此外,该序列化器需要SpringOXM模块的支持。

3.  RedisTemplate序列化相关的方法

RedisTemplate类提供的与序列化相关的方法如表38所示。


表38RedisTemplate类提供的与序列化相关的方法




方 法 原 型说明


RedisSerializer<?> getDefaultSerializer()获取当前模板(RedisTemplate)默认的序列化器

setDefaultSerializer(RedisSerializer<?> serializer)设置用于当前模板的默认序列化器
RedisSerializer<?> getHashKeySerializer()返回哈希键序列化器
void setHashKeySerializer(RedisSerializer<?> hashKeySerializer)设置哈希键序列化器
RedisSerializer<?> getHashValueSerializer()返回哈希值序列化器
void setHashValueSerializer(RedisSerializer<?> hashValueSerializer)设置哈希值序列化器
RedisSerializer<?> getKeySerializer()返回当前模板使用的键序列化器
void setKeySerializer(RedisSerializer<?> serializer)设置当前模板使用的键序列化器
RedisSerializer<String> getStringSerializer()返回字符串序列化器
void setStringSerializer(RedisSerializer<String> stringSerializer)设置字符串序列化器
RedisSerializer<?> getValueSerializer()返回当前模板使用的值序列化器
void setValueSerializer(RedisSerializer<?> serializer)设置当前模板使用的值序列化器



4.  RedisSerializer接口的实现类的应用

下面结合例子介绍serializer包提供的几个RedisSerializer接口的实现类的应用。

(1) JdkSerializationRedisSerializer。

JdkSerializationRedisSerializer序列化器是RedisCache类和RedisTemplate类默认的序列化器,采用Java语言的序列化机制。该序列化器将对象保存成二进制格式,执行序列化的效率不是最差的,但结果的可读性较差。下面的例子演示如何利用JdkSerializationRedisSerializer序列化器将一个对象进行序列化。

第一步,创建一个类Barrel,代码如文件352所示。

【文件352】Barrel.java




1@Data

2public class Barrel implements Serializable {

3private String material;

4private double capacity;

5//此处省略了toString()方法

6}







第二步,配置RedisTemplate,代码如文件353所示。

【文件353】RedisSerializerConfig.java




1@Configuration

2public class RedisSerializerConfig {

3//RedisConnectionFactory配置元数据定义省略,代码见文件3-6

4@Bean

5public RedisTemplate<String,Object> redisTemplate(

6RedisConnectionFactory factory) {

7RedisTemplate<String,Object> redisTemplate = 

8new RedisTemplate<>();

9redisTemplate.setConnectionFactory(factory);

10//指定值的默认的序列化器JdkSerializationRedisSerializer

11redisTemplate.setValueSerializer(RedisSerializer.java());

12return redisTemplate;

13}

14}







第三步,编写测试代码,如文件354所示。

【文件354】TestSerializer.java




1@RunWith(SpringJUnit4ClassRunner.class)

2@ContextConfiguration(classes= RedisSerializerConfig.class)

3public class TestSerializer {

4@Autowired

5private RedisTemplate<String, Object> template;

6@Test

7public void testJdkSerializer(){

8Barrel barrel = new Barrel();

9barrel.setMaterial("plastic");

10barrel.setCapacity(60);

11template.opsForValue().set("jdk",barrel);

12System.out.println(template.opsForValue().get("jdk"));

13}

14}








图341Redis中保存的内容1

运行此测试代码后,将Barrel对象序列化后保存到Redis。控制台的输出为Barrel{material='plastic', capacity=60.0}。利用RedisInsight客户端查看Redis保存的内容,如图341所示。

(2) Jackson2JsonRedisSerializer。

为了解决图341中出现的乱码问题,可以替换默认的序列化接口实现机制。例如,使用Jackson2JsonRedisSerializer序列化器。Jackson2JsonRedisSerializer是可以使用Jackson读取和写入JSON的序列化器。当要存储的值为字符串类型时,也可以采用StringRedisSerializer序列化器。本例采用Jackson2JsonRedisSerializer对Barrel对象进行序列化。

第一步,修改RedisTemplate类的默认序列化器,可利用表38中的setKeySerializer()方法和setValueSerializer()方法分别指定键和值的序列化器,以替代默认的序列化器。部分代码如文件355所示。

【文件355】RedisSerializerConfig.java




1@Bean

2public RedisTemplate<String,Object> redisTemplate(

3 RedisConnectionFactory factory) {

4RedisTemplate<String,Object> template = new RedisTemplate<>();

5template.setConnectionFactory(factory);

6template.setKeySerializer(RedisSerializer.string());

7template.setValueSerializer(new 

8Jackson2JsonRedisSerializer< Object>(Object.class));

9template.afterPropertiesSet();

10return template;

11}







如文件355所示,第6行指定键的序列化器为StringRedisSerializer,字符编码为默认的UTF8,第7、8行指定值的序列化器为Jackson2JsonRedisSerializer。



图342Redis中保存的内容2


第二步,引入jacksondatabind依赖。当前的Jackson2JsonRedisSerializer并不要求持久化类(本例中为Barrel类)显式实现Serializable接口。运行文件354的测试代码后,控制台的输出为{material=plastic, capacity=60.0},利用RedisInsight客户端查看Redis保存的内容,如图342所示。

(3) GenericJackson2JsonRedisSerializer。

该序列化器可以将Java对象序列化为JSON字符串,以及JSON字符串反序列化为Java对象。要使用GenericJackson2JsonRedisSerializer序列化器,可对文件355稍作修改,部分代码如文件356所示。

【文件356】RedisSerializerConfig.java部分修改




1@Bean

2public RedisTemplate<String,Object> redisTemplate(

3RedisConnectionFactory factory) {

4RedisTemplate<String,Object> redisTemplate = 

5 new RedisTemplate<>();

6redisTemplate.setConnectionFactory(factory);

7redisTemplate.setKeySerializer(RedisSerializer.string());

8redisTemplate.setValueSerializer(RedisSerializer.json());

9redisTemplate.afterPropertiesSet();

10return redisTemplate;

11}







GenericJackson2JsonRedisSerializer序列化器并不要求持久化类显式实现Serializable接口。运行文件354中的测试代码后,控制台输出为Barrel{material='plastic',capacity=60.0},利用RedisInsight客户端查看Redis保存的内容,如图343所示。对比控制台输出和Redis中保存的内容可知,与StringRedisSerializer和Jackson2JsonRedisSerializer序列化器不同,在从Redis中获取键对应的值时,GenericJackson2JsonRedisSerializer序列化器执行了反序列化操作。由JSON字符串反序列化为Java对象时,要求持久化类(本例中为Barrel类)提供公有的无参数构造方法。



图343Redis中保存的内容3


3.12.2HashMapper接口

HashMapper接口是Spring Data Redis提供的实现Java对象和Redis Hash(Redis的哈希类型)之间转换的核心接口。Redis Hash一般具有如下结构: 

Key:{

 filed: value,

 filed: value,

 filed: value,

 ....

}


这个结构和Java中的对象非常相似,但是不能按照Java对象的结构直接存储进Redis Hash。因为Java对象中的字段(field)是可以嵌套的,而Redis Hash不支持嵌套结构。为此,Spring Data Redis提供了将Java对象映射到Redis Hash的三种策略。

(1) 直接映射。可以使用HashOperations接口和相关的序列化程序,如Jackson2JsonRedisSerializer,进行直接映射。

(2) 使用Redis仓库。Redis仓库可以应用定制的映射策略转换和存储Redis Hash中的域对象,并且可以使用辅助索引(见8.5节)。

(3) 使用HashMapper接口和HashOperations接口。

本节主要介绍上述方法(3)执行Java对象与Redis Hash的映射。Spring Data Redis提供了HashMapper接口的三个实现类,分别为BeanUtilsHashMapper、ObjectHashMapper和Jackson2HashMapper。下面结合例子分别介绍ObjectHashMapper类和Jackson2HashMapper类的使用方法。

1.  ObjectHashMapper类

电商网站中经常有这样的需求: 记录用户注册时填写的基础信息及其注册地址信息,实现这一需求的具体步骤如下。

第一步,创建两个持久化类Person和Address,分别封装用户的基础信息和注册地址信息,代码如文件357和文件358所示。

【文件357】Person.java




1@Data

2public class Person {

3private String personId;

4private String firstname;

5private String lastname;

6private Address address;

7private LocalDateTime localDateTime;

8}







【文件358】Address.java




1@Data

2public class Address {

3private String city;

4private String country;

5//此处省略了带参数的构造方法

6}







第二步,编写配置类,代码如文件359所示。

【文件359】HashMapperConfig.java




1public class HashMapperConfig {

2//此处省略了连接工厂的配置,内容见文件3-6

3@Bean

4public RedisTemplate<String,Map<byte[],byte[]>>

5 hashMapperRedisTemplate(








6RedisConnectionFactory factory) {

7RedisTemplate<String, Map<byte[],byte[]>> template = 

8 new RedisTemplate<>();

9template.setConnectionFactory(factory);

10//设置键序列化方式

11template.setKeySerializer(RedisSerializer.string());

12//设置简单类型值的序列化方式

13template.setValueSerializer(RedisSerializer.byteArray());

14//设置哈希类型键的序列化方式

15template.setHashKeySerializer(RedisSerializer.byteArray());

16//设置哈希类型值的序列化方式

17template.setHashValueSerializer(RedisSerializer.byteArray());

18template.afterPropertiesSet();

19return template;

20}

21}







如文件359所示,第11行将键的序列化器指定为StringRedisSerializer,默认的字符编码为UTF8。第12~17行分别将字符串类型值、哈希键和哈希值的序列化器指定为ByteArrayRedisSerializer,该序列化器是一个只使用字节数组(byte[])的原始序列化器(RedisSerializer),代码如下: 



1package org.springframework.data.redis.serializer;

2enum ByteArrayRedisSerializer implements RedisSerializer<byte[]> {

3INSTANCE;

4

5@Nullable

6@Override

7public byte[] serialize(@Nullable byte[] bytes) 

8 throws SerializationException {

9return bytes;

10}

11

12@Nullable

13@Override

14public byte[] deserialize(@Nullable byte[] bytes) 

15 throws SerializationException {

16return bytes;

17}

18}







第三步,建立person对象与Redis Hash的映射,编写的测试代码如文件360所示。

【文件360】TestHashMapper.java




1@RunWith(SpringJUnit4ClassRunner.class)

2@ContextConfiguration(classes= HashMapperConfig.class)

3public class TestHashMapper {

4@Autowired

5private RedisTemplate<String, Map<byte[],byte[]>> redisTemplate;

6








7private final HashMapper<Object, byte[], byte[]> hashMapper = 

8new ObjectHashMapper();

9

10@Test

11public void save(){

12Person person = new Person();

13person.setPersonId("person-address");

14person.setFirstname("Simth");

15person.setLastname("Qiong");

16person.setLocalDateTime(LocalDateTime.now());

17person.setAddress(new Address("DaLian","China"));

18Map<byte[], byte[]> map = hashMapper.toHash(person);

19HashOperations<String,byte[],byte[]> ops =

20redisTemplate.opsForHash();

21ops.putAll(person.getPersonId(),map);

22map.entrySet().iterator().forEachRemaining(entry ->

23 System.out.println(new String(entry.getKey())+"="+

24 new String(entry.getValue())));

25}

26}







如文件360所示,第11行开始的测试用例是实现将person对象映射为Redis Hash并存入Redis。因此,需要调用HashMapper接口的toHash()方法。该方法的原型为: 

Map<K,V> toHash(T object)


其功能是将Java对象(本例中为person对象)转换为可以被Redis Hash使用的映射(第18行)。同时,指定了Redish Hash的字段和值的类型均为byte[](字节数组)。与此对应的是第7、8行,在创建ObjectHashMapper的实例时,指明HashMapper的三个泛型,第一个为Java对象的类型,第二个和第三个为Redis Hash中的字段和值的类型。第19~21行将person对象映射成的map对象存入Redis,并且约定,该map对象的键的类型为String。作为测试,第22~24行将转换后的map对象的内容在控制台输出。运行此测试代码后,控制台输出和Redis中保存的内容分别如图344和图345所示。



图344控制台输出的内容




图345Redis中保存的内容4



第四步,验证Redis Hash向Java对象的(反向)映射。可调用HashMapper接口的fromHash()方法实现(反向)映射。该方法的原型为: 

T fromHash(Map<K,V> hash)


可在文件360的基础上增加一个测试用例,代码如下: 



1@Test

2public void find() {

3HashOperations<String,byte[],byte[]> ops =

4 redisTemplate.opsForHash();

5Map<byte[],byte[]> map = ops.entries("person-address");

6Person person = (Person)hashMapper.fromHash(map);

7System.out.println(new Gson().toJson(person));

8}







运行此测试用例,可在控制台看到以JSON字符串形式输出的person对象,如图346所示。



图346控制台输出的JSON字符串


2.  Jackson2HashMapper类

Jackson2HashMapper 类通过使用Faster XML Jackson为Java对象提供Redis Hash映射。Jackson2HashMapper可以将顶级属性映射为哈希字段名,还可以选择将结构扁平化。扁平化是指为所有嵌套属性创建单独的哈希项(字段和值),并尽可能将复杂类型解析为简单类型。以文件357定义的持久化类为例,数据在非扁平化映射中的显示方式如表39所示。


表39数据在非扁平化映射中的显示方式



字段值


firstnameJon
lastnameSnow
address{ "city" : "Castle Black", "country" : "The North" }
localDateTime20180102T12:13:14



经过扁平化处理后,显示方式如表310所示。


表310数据在扁平化映射中的显示方式



字段值


firstnameJon
lastnameSnow
address.cityCastle Black
address.countryThe North
localDateTime20180102T12:13:14



对于文件357定义的Person类的对象,也可以用Jackson2JsonRedisSerializer类完成其与Redis Hash的映射。

第一步,可以修改文件359,配置RedisTemplate的相应的序列化器。部分代码如文件361所示。

【文件361】HashMapperConfig.java的部分修改




1@Bean

2public RedisTemplate<String, Map<String,Object>>

3hashMapperRedisTemplate(RedisConnectionFactory factory) {

4RedisTemplate<String,Map<String,Object>> redisTemplate = 

5 new RedisTemplate<>();

6redisTemplate.setConnectionFactory(factory);

7// 设置键序列化方式

8redisTemplate.setKeySerializer(RedisSerializer.string());

9redisTemplate.setValueSerializer(new

10Jackson2JsonRedisSerializer<Object>(Object.class));

11redisTemplate.setHashKeySerializer(new

12Jackson2JsonRedisSerializer<Object>(Object.class));

13redisTemplate.setHashValueSerializer(new

14Jackson2JsonRedisSerializer<Object>(Object.class));

15redisTemplate.afterPropertiesSet();

16return redisTemplate;

17}







第二步,执行person对象到Redis Hash的映射,部分测试代码如文件362所示。

【文件362】部分测试代码




1@Test

2public void save(){

3Person person = new Person();

4person.setPersonId("person-address");

5person.setFirstname("Simth");

6person.setLastname("Qiong");

7person.setLocalDateTime(LocalDateTime.now());

8person.setAddress(new Address("DaLian","China"));

9HashMapper<Object, String, Object> hashMapper = 

10new Jackson2HashMapper(true);

11Map<String, Object> map = hashMapper.toHash(person);

12HashOperations<String,String,Object> ops =

13redisTemplate.opsForHash();

14ops.putAll(person.getPersonId(),map);

15map.entrySet().iterator().forEachRemaining(entry ->

16System.out.println(entry.getKey()+"="+entry.getValue()));

17}







如文件362所示,第9、10行实例化Jackson2HashMapper映射器,该映射器的构造方法有布尔型参数,取值为true意味着采用扁平化方式处理数据; 反之取值为false。此外,Jackson2HashMapper类实现的HashMapper<K,T,V>接口中,已将K、T和V三个泛型指定为Object、String、Object。即该映射器的定义为: 



1public class Jackson2HashMapper implements HashMapper<Object, String,

2Object>, HashObjectReader<String, Object> {

3……

4}







因此,实例化该映射器时必须沿用该映射器的泛型定义。为了便于处理LocalDateTime类型的数据,需要在pom.xml文件中增加依赖: 



1<dependency>

2<groupId>com.fasterxml.jackson.datatype</groupId>

3<artifactId>jackson-datatype-jsr310</artifactId>

4<version>2.13.3</version>

5</dependency>







第三步,执行此测试代码,控制台的输出如图347所示。



图347控制台的输出


第四步,执行Redis Hash到person对象的映射。部分测试代码如文件363所示。

【文件363】 部分测试代码




1@Test

2public void find() {

3HashOperations<String,String,Object> ops =

4redisTemplate.opsForHash();

5Map<String,Object> map = ops.entries("person-address");

6HashMapper<Object, String, Object> hashMapper = 

7new Jackson2HashMapper(true);

8Person person = (Person)hashMapper.fromHash(map);

9System.out.println(new Gson().toJson(person));

10}







注意,为保证反序列化能够成功执行,需要Person类和Address类都提供公有的无参数构造方法。

3.13小结

本章着重介绍了Spring Data Redis提供的RedisTemplate类。RedisTemplate类是在RedisConnection接口(org.springframework.data.redis.connection.RedisConnection)基础上,将Redis操作进行了更高层次的封装。由于RedisTemplate来自于Spring,因此可以在程序中利用IoC、AOP等Spring的特性优雅地、简单地操作Redis。本章涉及的操作Redis的常用类和接口的关系如图348所示。

对于操作Redis,可以使用Lettuce、Jedis、Redisson等客户端,也可以利用Spring框架的RedisConnectionFactory接口创建与Redis的连接,再使用RedisTemplate类操作Redis。图348只列举了RedisTemplate类的一些方法。如果在Redis中保存的键和值都是字符串类型,也可以使用RedisTemplate类的子类StringRedisTemplate。



图348操作Redis的常用类和接口的关系


作为Spring Data Redis提供的模板类,RedisTemplate提供了大量的API用以封装Redis操作。如,数据的序列化和反序列化操作、执行Lua脚本(见5.4.2节)、操作Redis分片集群(见7.3.3节)等。同时RedisTemplate类还提供了操作字符串、列表、哈希等数据结构的专属操作接口。可以说,RedisTemplate类是使用Spring开发Redis应用的首选。