本文章所有代码可从Github下载。地址:GitHub - YuRui1113/session-redis: Store session data to Redis and How to verify it.
Redis 是一种开源(BSD 许可)内存中数据结构存储,用作数据库、缓存、消息代理和流引擎。 Redis 提供数据结构,例如字符串、哈希、列表、集合、带有范围查询的排序集、位图、超级日志、地理空间索引和流。 Redis 具有内置复制、Lua 脚本、LRU 驱逐、事务和不同级别的磁盘持久性,并通过 Redis Sentinel 和 Redis Cluster 自动分区提供高可用性。
您可以对这些类型进行原子操作,例如附加到字符串、增加哈希值、将一个元素插入列表,计算集合的交、并、差,或获取排序集中排名最高的成员。
为了实现最佳性能,Redis 使用内存数据集。根据您的使用案例,Redis可以通过定期将数据集转储到磁盘或将每个命令附加到基于磁盘的日志来持久保存数据。如果您只需要功能丰富的网络内存缓存,您还可以禁用持久性。
Redis 支持异步复制,具有快速非阻塞同步和自动重新连接以及网络分割时部分重新同步的功能。
Redis同样包括:
您可以通过大多数编程语言使用 Redis。
Redis 采用 ANSI C 编写,适用于大多数POSIX系统,如Linux、*BSD 和 Mac OS X,无需外部依赖。Linux和OS X是Redis开发和测试最多的两个操作系统,我们建议使用Linux进行部署。Redis官方没有对Windows版本的支持,不建议在windows下使用Redis,所以官网没有 windows 版本可以下载。但是微软团队维护了开源的windows版本,只有 3.2 版本,可用于普通测试。本篇使用Windows上的Ubuntu虚拟机安装Redis。
安装虚拟机可参考我之前的文章Windows11下使用Hyper-V创建Ubuntu 22.04.3虚拟机。按此文章步骤即可准备好一台Ubuntu虚拟机,并且和主机是可以互连互通的,这样使主机上的应用可以访问后续在Ubuntu上安装的Redis。
在 Ubuntu 上打开命令行,并输入以下命令来安装Redis:
$ curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg$ echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list$ sudo apt-get update$ sudo apt-get install redis
安装完成后,修改Redis配置文件。 为此,请使用您选择的文本编辑器打开文件:
$ sudo vi /etc/redis/redis.conf
绑定当前机器IP‘192.168.137.40’到Redis(注意当前机器IP可能为其它,可以使用命令ip a或ifconfig查看当前机器IP):
在保护模式下,只能连接当前机器上的Redis。
默认情况下,受监督指令设置为 no。 但是,要将 Redis 作为服务进行管理,请将受监督指令设置为 systemd(Ubuntu 的 init 系统)。
保存redis.conf然后退出。
使用如下命令来设置Redis开机自启动:
$ sudo systemctl enable redis-server.service
每当您对 redis.conf 文件进行任何更改时,请重新加载或重新启动 Redis 服务。
$ sudo systemctl restart redis-server.service $ sudo systemctl status redis-server.service
如果服务未启动,则检查 Redis 日志:
/var/log/redis/redis-server.log
$ sudo tail /var/log/redis/redis-server.log
另外,可以不通过服务启动 Redis 服务器,如下所示:
$ sudo service redis-server start
可以使用如下命令验证Redis版本:
$ redis-server --version
使用如下命令打开防火墙端口6379,6379是Redis默认使用端口。
$ sudo ufw allow 6379
在 Windows 上,我们可以安装 Visual Studio Code 扩展来测试与 Redis 的连接以及访问Redis数据库。
安装后,您可以打开它并测试连接,如下所示:
以及访问Redis数据库
您可以通过连接 Redis CLI 来测试 Redis 服务器是否正在运行:
$ redis-cli127.0.0.1:6379> pingPONG
可以指定不同的主机名或者IP,使用-h参数。还可以指定不同的端口,使用-p参数。
$ redis-cli -h redis15.localnet.org -p 6390 PINGPONG
SET key value [NX|XX] [GET] [EX seconds|PX milliseconds|EXAT unix-time-seconds|PXAT unix-time-milliseconds|KEEPTTL]
创建一个新的键值对:
SET framework angular
GET key
读取对应键的值:
GET framework
SET命令可用来覆盖同样键的数据:
SET framework react
KEYS pattern
可以用以下命令获取所有的键:
KEYS *
Redis 为我们提供了一个名为 SETNX 的 SET 的非破坏性版本:
SETNX key value
当且仅当密钥尚不存在时,SETNX 会在内存中创建键值对。如果键已存在,Redis 会回复 0 表示存储键值对失败,回复 1 表示成功。Removing data
DEL key
使用DEL命令来删除数据:
DEL framework
FLUSHALL 命令可以从 Redis 中删除所有数据。
使用 Redis 创建密钥时,我们可以指定该密钥应在内存中存储多长时间。使用 EXPIRE 命令,我们可以在密钥上设置超时,并在超时到期后自动删除密钥:
EXPIRE key seconds
以下命令将创建一个要在 30 秒后删除的键值对:
SET notification "Anomaly detected"EXPIRE notification 30
Redis 提供了 TTL 命令,该命令告诉我们密钥在过期和删除之前还剩下多少秒:
TTL key
从 Redis 2.8 开始,TTL 返回:
剩余超时(以秒为单位)。
-2 如果密钥不存在(尚未创建或已删除)。
-1 如果密钥存在但未设置到期时间。
使用 SET 与再次创建密钥相同,所以对于Redis来说,这还涉及重置当前分配给它的任何超时。
List是一系列有序元素。例如,1 2 4 5 6 90 19 3 是数字列表。在 Redis 中,需要注意的是,列表是作为链表实现的。这对性能有一些重要影响。将元素添加到列表的头部和尾部是快速的,但在列表中搜索元素的速度较慢,因为我们没有对元素的索引访问权限(就像我们在数组中所做的那样)。
RPUSH key element [element ...]
创建一个键为engineers的 List:
RPUSH engineers "Alice"// 1RPUSH engineers "Bob"// 2RPUSH engineers "Carmen"// 3
每次我们插入元素时,Redis 都会在插入后回复 List 的长度。
LRANGE key start stop
要查看完整的列表,我们可以使用一个巧妙的技巧:从 0 转到它前面的元素 -1。
LRANGE engineers 0 -1Redis 返回:1) "Alice"2) "Bob"3) "Carmen"
索引 -1 将始终表示 List 中的最后一个元素。
LPUSH key element [element ...]
将 Daniel 插入工程师列表的前面:
LPUSH engineers "Daniel"// 4
现在有四名工程师。让我们验证顺序是否正确:
LRANGE engineers 0 -1Redis返回:1) "Daniel"2) "Alice"3) "Bob"4) "Carmen"
在RPUSH 和 LPUSH 的命令格式中看到,我们可以在每个命令中插入多个元素。
根据我们现有的工程师列表,运行以下命令:
RPUSH engineers "Eve" "Francis" "Gary"// 7
验证元素添加到列表中:
同样可以使用LPUSH命令:
LPUSH engineers "Hugo" "Ivan" "Jess"// 10
验证:
LRANGE 0 -1
Redis返回:
LLEN key
使用如下命令测试engineers列表长度:
LLEN engineers
Redis返回 (integer) 10。
LPOP key [count]
使用此命令从列表engineers中删除第一个元素 "Jess":
LPOP engineers
Redis返回 "Jess"。
RPOP key [count]
删除最后一个元素 "Gary":
RPOP engineers
Redis返回 "Gary".
在 Redis 中,Set 类似于 List,只是它不为其元素保留任何特定顺序,并且每个元素必须是唯一的。
SADD key member [member ...]
创建键为languages的set:
SADD languages "english"// 1SADD languages "spanish"// 1SADD languages "french"// 1
在每次添加成员时,Redis 将返回使用 SADD 命令添加的成员数,而不是 Set 的大小。下面命令将添加多个元素:
SADD languages "chinese" "japanese" "german"// 3
添加已存在元素:
SADD languages "english"// 0
SREM key member [member ...]
可以同时删除一个或多个元素:
SREM languages "english" "french"// 2SREM languages "german"// 0
SREM返回删除元素个数。
SISMEMBER key member
如果成员是 Set的一部分,则此命令返回 1;否则,它将返回 0:
SISMEMBER languages "spanish"// 1SISMEMBER languages "german"// 0
SMEMBERS key
下面命令返回键为languages的set里所有元素:
SMEMBERS languages
Redis 返回:
由于 Set 是无序的,因此 Redis 可以在每次调用时以任何顺序自由返回元素。他们无法保证元素的顺序。
SUNION key [key ...]
SUNION 的每个参数都代表一个 Set,我们可以将其合并到一个更大的 Set 中。请务必注意,任何重复的成员都将列出一次。
创建一个键为ancient-languages的Set:
SADD ancient-languages "greek"SADD ancient-languages "latin"SADD ancient-languages "chinese"SMEMBERS ancient-languages
使用下面命令合并Set:
SUNION languages ancient-languages
Redis返回:
在 Redis 中,Hash 是一种数据结构,用于将字符串键与字段值对进行映射。因此,哈希可用于表示对象。它们的键是哈希的名称,值表示字段名称字段值条目的序列。我们可以这样描述计算机对象:
computer name "MacBook Pro" year 2015 disk 512 ram 16
对象的属性定义为对象名称 computer 之后的“属性名称”和“属性值”序列。
HSET key field value [field value ...]
如果字段已存在,则修改字段值。
下面命令创建键为computer的hash:
HSET computer name "MacBook Pro"// 1HSET computer year 2015// 1HSET computer disk 512// 1HSET computer ram 16// 1
对于每个 HSET 命令,Redis 都会回复一个整数,如下所示:
1 如果字段是哈希中的新字段并且设置了值。
0 如果哈希中已存在字段并且值已更新。
修改字段值:
HSET computer year 2018// 0
HGET key field
获取year字段值:
HGET computer year
Redis返回 "2018"。
HGETALL key
下面命令返回键为computer的Hash所有字段和值:
返回:
HMSET key field value [field value ...]
下面命令创建键为tablet的hash:
HMSET tablet name "iPad" year 2016 disk 64 ram 4
HMGET key field [field ...]
下面命令返回disk和ram字段值:
HMGET tablet disk ram
Redis返回:
在 Redis 1.2 中引入的有序集合本质上是一个集合:它包含唯一的、不重复的字符串成员。但是,虽然 Set 的成员没有排序(Redis 可以在每次调用 Set 时以任何顺序自由返回元素),但 Sorted Set 的每个成员都链接到一个称为 score 的浮点值,Redis 使用该值来确定 Sorted Set 成员的顺序。由于排序集的每个元素都映射到一个值,因此它还具有类似于 Hash 的体系结构。
与有序集合交互的一些命令类似于我们用于普通集合的命令:替换 Set 命令中的 S 并将其替换为 Z。例如,SADD => ZADD。但是,也有两者独有的命令。
ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]
创建一个键为tickets的有序集:
ZADD tickets 100 HELP204// 1ZADD tickets 90 HELP004// 1ZADD tickets 180 HELP330// 1
ZADD返回添加元素数量。
ZRANGE key start stop [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES]
使用0到-1索引区间可获取所有元素:
ZRANGE tickets 0 -1Redis返回:1) "HELP004"2) "HELP204"3) "HELP330"
可以使用WITHSCORES参数来同时返回score:
ZRANGE tickets 0 -1 WITHSCORESRedis返回:1) "HELP004"2) "90"3) "HELP204"4) "100"5) "HELP330"6) "180"
可以看到元素是按score升序返回的.
Redis 在其官方网站上列出了最知名的客户端库。Jedis有多种替代品,但目前只有两种推荐的明星产品:lettuce和Redisson。
这两个客户端确实有一些独特的功能,比如线程安全、透明的重新连接处理和异步 API,这些都是 Jedis 所缺乏的。
然而,Jedis 很小,比其他两个快得多。此外,它是 Spring Framework 开发人员选择的客户端库,并且拥有这三个库中最大的社区。
下面我们通过创建Junit test用例来展示如何使用Jedis API。
为使用Jedis,我们只需在pom.xml中添加如下依赖:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>5.1.0</version></dependency>
@Test public void testJedisOperateString() { String key = "events/student/taylor"; String value = "1,3,5,7"; jedis.set(key, value); String cachedResponse = jedis.get(key); assertEquals(value, cachedResponse); }
@Test public void testJedisOperateList() { String key = "queue#tasks"; String value = "firstTask"; jedis.lpush(key, "firstTask"); jedis.lpush(key, "secondTask"); String task = jedis.rpop(key); assertEquals(value, task); }
@Test public void testJedisOperateSet() { String key = "nicknames"; jedis.sadd(key, "nickname#1"); jedis.sadd(key, "nickname#2"); jedis.sadd(key, "nickname#1"); Set<String> nicknames = jedis.smembers(key); assertEquals(2, nicknames.size()); boolean exists = jedis.sismember("nicknames", "nickname#1"); assertTrue(exists); }
nicknames元素个数为2,因为重复的元素nickname#1会被忽略。
@Test public void testJedisOperateHash() { jedis.hset("user#1", "name", "Peter"); jedis.hset("user#1", "job", "politician"); String name = jedis.hget("user#1", "name"); assertEquals("Peter", name); Map<String, String> fields = jedis.hgetAll("user#1"); String job = fields.get("job"); assertEquals("politician", job); }
@Test public void testJedisOperateSortedSet() { String key = "ranking"; String value = "firstTask"; Map<String, Double> scores = new HashMap<>(); scores.put("PlayerOne", 3000.0); scores.put("PlayerTwo", 1500.0); scores.put("PlayerThree", 8200.0); scores.entrySet().forEach(playerScore -> { jedis.zadd(key, playerScore.getValue(), playerScore.getKey()); }); String player = jedis.zrevrange(key, 0, 1).iterator().next(); assertEquals("PlayerThree", player); long rank = jedis.zrevrank(key, "PlayerOne"); assertEquals(1, rank); }
Spring Session 的目标是将会话管理从服务器中存储的 HTTP 会话限制中解放出来。
该解决方案可以轻松地在云中的服务之间共享会话数据,而无需绑定到单个容器(如 Tomcat)。此外,它还支持在同一浏览器中发送多个会话,并在标头中发送会话。
在本文中,我们将使用 Spring Session 来管理 Web 应用中的身份验证信息。虽然 Spring Session 可以使用 JDBC、Gemfire 或 MongoDB 持久化数据,但本篇将使用Redis作为存储Session的解决方案。
当前项目使用以下开发环境:
Extension Pack for JavaSpring Boot Extension Pack
LombokSpring Data RedisSpring SecuritySpring Web
按回车键后,系统提示选择保存项目文件夹,指定文件后按回车键创建项目完成。
按CTRL + SHIFT + P打开命令栏,选择Maven: Add a dependency,搜索并添加下列依赖:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>5.1.0</version> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> <version>3.2.0</version> </dependency>
spring.session.store-type设置为redis将会存储Session到Redis,项目配置如下:
server: port: 8080spring: data: redis: host: 192.168.137.40 port: 6379 session: store-type: redis redis: flush-mode: on_save namespace: spring:session
首先创建一个安全配置类。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
…
}
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
为避免暴露纯文本密码可以使用 Spring Boot CLI 对密码进行编码。命令如下:
spring encodepassword password
Spring Boot CLI将产生类似如下编码后的密码:
{bcrypt}a$lDUTwo3pS6bt7Iwv4oVmPuM3hfYNKdddurzv4xBpvzk31RS7LfS72
通常密码格式={编码器id}编码后的密码, 这里我们已经指定密码编码器为BCryptPasswordEncoder, 所以可以移除id:‘{bcrypt}’,直接使用编码后的密码。
创建用户名为’admin’和所在权限组为:"ADMIN", "USER"。
UserDetails admin = User.withUsername("admin") .password("a$lDUTwo3pS6bt7Iwv4oVmPuM3hfYNKdddurzv4xBpvzk31RS7LfS72") .roles("ADMIN", "USER") .build(); return new InMemoryUserDetailsManager(user, admin);
主要实现下列Spring安全配置:
只允许具有角色“ADMIN”的登录用户才能访问URL包含“api/v1/session”的API
使用basic认证
确保认证TOKEN存储到Session
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests( authorize -> authorize .requestMatchers("api/v1/session/**").hasRole("ADMIN") .anyRequest() .authenticated()) // HTTP Basic are stateless and won't store authentication token in session // Below code will make authentication token to be stored in Redis .httpBasic((basic) -> basic .addObjectPostProcessor(new ObjectPostProcessor<BasicAuthenticationFilter>() { @Override public <O extends BasicAuthenticationFilter> O postProcess(O filter) { filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository()); return filter; } })) // Avoid 401 issue for HTTP post and put method .csrf(csrf -> csrf.disable()); return http.build(); }
这里创建一个SessionController,包括三个API:
代码如下:
package com.taylor.sessionredis.controllers;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.PutMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpSession;@RestController@RequestMapping("api/v1/session")public class SessionController { @GetMapping("/hello") public String helloWorld(HttpServletRequest request) { return "Hello, World!"; } @GetMapping("/data/{key}") public String getSession(HttpSession session, @PathVariable String key) { Object sessionValue = session.getAttribute(key); System.out.println("[get]session id:" + session.getId()); if (sessionValue != null) { return sessionValue.toString(); } return "Session doesn't exist for key: " + key; } @PutMapping("/data/{key}") public ResponseEntity<String> setSession(HttpServletRequest request, @PathVariable String key, @RequestBody String value) { request.getSession().setAttribute(key, value); System.out.println("[set]session id:" + request.getSession().getId()); return ResponseEntity.ok("OK"); }}
Junit test测试类创建代码如下:
package com.taylor.sessionredis;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertTrue;import java.util.HashMap;import java.util.Map;import java.util.Set;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.boot.test.web.client.TestRestTemplate;import org.springframework.http.HttpEntity;import org.springframework.http.HttpHeaders;import org.springframework.http.HttpMethod;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import redis.clients.jedis.Jedis;@SpringBootTestclass SessionRedisApplicationTests { private Jedis jedis; private TestRestTemplate httpClient; private TestRestTemplate httpClientWithAuth; private static final String REDIS_HOST = "192.168.137.40"; private static final int REDIS_PORT = 6379; private static final String URL_TEST_SESSION = "http://localhost:8080/api/v1/session"; private static final String URL_TEST_SESSION_HELLO = URL_TEST_SESSION + "/hello"; private static final String URL_TEST_SESSION_DATA = URL_TEST_SESSION + "/data"; private static final String EXPECTED_API_RESULT_HELLO = "Hello, World!"; private static final String EXPECTED_API_RESULT_OK = "OK"; @BeforeEach public void init() { httpClient = new TestRestTemplate(TestRestTemplate.HttpClientOption.ENABLE_COOKIES); httpClientWithAuth = new TestRestTemplate("admin", "password", TestRestTemplate.HttpClientOption.ENABLE_COOKIES); jedis = new Jedis(REDIS_HOST, REDIS_PORT); jedis.flushAll(); } }
这里我们使用Junit来测试,需要验证下列测试步骤:
Junit test代码如下:
@Test public void testRedisControlsSession() { // Test Redis is empty at the beginning Set<String> redisKeys = jedis.keys("*"); assertEquals(0, redisKeys.size()); // Test authorization ResponseEntity<String> result = httpClientWithAuth.getForEntity(URL_TEST_SESSION_HELLO, String.class); assertEquals(EXPECTED_API_RESULT_HELLO, result.getBody()); // Login worked // Test authentication data stored in session by Redis redisKeys = jedis.keys("*"); assertTrue(redisKeys.size() > 0); // Redis is populated with session data // Get session info for next request String sessionCookie = result.getHeaders().get("Set-Cookie").get(0).split(";")[0]; HttpHeaders headers = new HttpHeaders(); headers.add("Cookie", sessionCookie); HttpEntity<String> httpEntitySession = new HttpEntity<>(headers); // Test accessing anonymous result = httpClient.getForEntity(URL_TEST_SESSION_HELLO, String.class); assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); // Test accessing anonymous but with authentication session data result = httpClient.exchange(URL_TEST_SESSION_HELLO, HttpMethod.GET, httpEntitySession, String.class); assertEquals(EXPECTED_API_RESULT_HELLO, result.getBody()); // clear all keys in Redis jedis.flushAll(); // Test accessing denied after sessions are removed in Redis result = httpClient.exchange(URL_TEST_SESSION_HELLO, HttpMethod.GET, httpEntitySession, String.class); assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); }
同样使用Junit来测试,需要验证下列测试步骤:
Junit test代码如下:
@Test public void testSetAndGetSession() { String sessionKey = "session-key"; String sessionValue = "session-value"; // Test manually storing data in session by Redis ResponseEntity<String> result = httpClientWithAuth.exchange(URL_TEST_SESSION_DATA + "/" + sessionKey, HttpMethod.PUT, new HttpEntity<>(sessionValue), String.class); assertEquals(EXPECTED_API_RESULT_OK, result.getBody()); String sessionCookie = result.getHeaders().get("Set-Cookie").get(0).split(";")[0]; HttpHeaders headers = new HttpHeaders(); headers.add("Cookie", sessionCookie); HttpEntity<String> httpEntity = new HttpEntity<>(headers); // Test retrieving data from session result = httpClientWithAuth.exchange(URL_TEST_SESSION_DATA + "/" + sessionKey, HttpMethod.GET, // Must include previous session info, or else server side can't know its // session object httpEntity, String.class); assertEquals(sessionValue, result.getBody()); }
谢谢观看!