文章目录
- 一:Redis基础
- 二:Redis的Java客户端
- 三:Redis的企业应用
-
- 1.短信登录(Redis的存与取)
- 2.商户查询缓存
- 3.优惠卷秒杀
-
- A.全局唯一ID
- B.优惠卷秒杀下单
- C.超卖问题
- 悲观锁
- 乐观锁 - 使用版本号机制
- 乐观锁 - 模拟CAS操作
- D.一人一单
- 锁监视器(Lock Monitor)
- 分布式锁(Distributed Lock)
- E.分布式锁
- JVM垃圾回收与锁的关系
- 分析原子性问题
- call函数和pcall函数
- 步骤 1: 添加Redisson依赖
- 步骤 2: 配置RedissonClient
- 步骤 3: 实现业务逻辑的可重入性
- 总结
- 锁重试机制
- WatchDog(看门狗)机制
- 步骤 1: 添加Redisson依赖
- 步骤 2: 配置RedissonClient
- 步骤 3: 使用RedissonMultiLock
- 分析RedissonMultiLock
- F.Redis秒杀优化
- 1. 添加依赖
- 2. 配置Redis
- 3. Redis配置类
- 4. 秒杀服务
- 5. 控制器
- 6. 异常处理
- 7. 测试
- 1. 添加依赖
- 2. 数据库模型
- 3. 阻塞队列配置
- 4. 秒杀服务
- 5. 控制器
- 6. 启动类
- G.Redis消息队列实现异步秒杀
- 1. 添加依赖
- 2. 配置Redis
- 3. 数据库模型
- 4. Redis消息监听器
- 5. Redis消息发布者
- 6. 配置Redis消息监听器容器
- 7. 控制器
- 8. 测试
- 教程
- Spring Boot项目代码案例
- 4.达人探店
- 5.好友关注
- 6.附近的商户
- 7.用户签到
- 8.UV统计
- 9.Redis高级原理
一:Redis基础
1.Redis是什么
Redis是一种key-value结构的数据库。
key | value |
---|---|
id | 1001 |
name | 张三 |
age | 21 |
1001 | {“id”:1001,“name”:“张三”,“age”:21} |
这就是NoSql数据库
2.初识Redis
-
认识NoSQL
-
SQL:关系型数据库
-
NoSql:非关系型数据库:key-value/document(每个文件都是一个json)/graph(每个节点都是一个插入的内容)
-
S:(Structured结构化)有表的约束:比如用户表id name age三个字段,有Primarykey unique unsigned等约束,以及字段类型。我们插入的数据要符合表的约束,这种数据库就是结构化的.
-
Q:(Relational关联的):SQL有外键约束不同的表,如果想修改子表的结构,数据库不允许破坏结构。而NoSql数据库不会帮你维护这种关系
-
L:(SQL查询)
select id,name,age from tb_user where id=1
在关系型数据库中sql语言结构通用,但是在不同的nosql数据库中。redis会用get user:1
MongoDB会用db.users.find({_id:1})
,elasticsearch会用GET http://localhost:9200/users/1
-
事务:关系型需要实现(ACID特性)Nosql无法全部满足ACID
-
键值类型 Redis 文档类型 MongoDB 列类型 HBase Graph类型 Neo4j SQL NoSQL 数据结构 结构化的 非结构化的 数据关联 关联的 无关联的 查询方式 SQL查询 非SQL查询 事务特性 ACID BASE 存储方式 磁盘 内存 扩展性 垂直(不考虑分布式 数据都存在本机) 水平(哈希运算实现数据拆分 数据分配不同节点 分布式) 使用场景 1.数据结构固定 2.相关业务对数据安全性和一致性要求高 1.数据结构不固定 2.对安全些和一致性要求不高 3.对性能要求高
-
-
认识Redis
- Remote Dictionary Server 远程词典服务器。是基于内存的键值型NoSql数据库
- 键值(key-value) value支持多种不同数据结构
- 单线程:每个命令有原子性(命令执行单线程,网络连接多线程)
- 低延迟,速度快(基于内存,IO多路复用,良好编码)
- 支持数据持久化(数据存到硬盘里)
- 支持主从集群,分片集群
- 支持多语言客户端(Java/Python/C都可以操作redis)
-
安装Redis
houor@redis:~$ sudo apt update houor@redis:~$ sudo apt upgrade houor@redis:~$ sudo apt install redis-server houor@redis:~$ redis-cli --version
3.Redis的数据结构
A.通用命令
Redis是一个key-value的数据库,key一般是String类型,但是value的类型多种多样
String | hello world |
---|---|
Hash | {name:“Jack”,age:23} |
List | [A -> B -> C -> C] |
Set | {A,B,C} |
SortedSet | {A:1,B:2,C:3} |
GEO | {A:(120.3,30.5)} |
BitMap | 0110110101110101011 |
HyperLog | 0110110101110101011 |
help @generic //查询所有 我们看到一个指令不熟悉用法 就help 指令名
help keys//查询keys的解析
keys * //查询所有的key 生产环境不要用->Redis单线程->会阻塞服务
keys a*//查询所有以A开头的Key
help del //查询del解析
del name //删除key为name
mset k1 v1 k2 v2 k3 v3//在redis中插入三个键值对 分别是k1 k2 k3
del k1 k2 k3 k4//但是返回值是3 因为只有三个键存在 所以只删除存在的key
exists k1//检测k1是否存在
exists k1 k2 k3//检测k1 k2 k3是否存在
expire age 20//给key(是对一个已经创建的Key)设置有效期 有效期到期该key会被自动删除 例如短信验证码 单位为秒
ttl age //查询某个key的剩余有效期 -1为永久有效 -2为key已经删除
B.String类型
Redis中最简单的储存类型
其value是字符串,根据字符串格式不同,分为三类
- string:普通字符串
- int:整数类型:可以自增和自减
- float:浮点类型:可以做自增和自减
不管是哪种格式,底层都是 字节数组储存,编码方式不同。字符串最大空间不超过512m
KEY | VALUE |
---|---|
msg | hello world |
num | 10 |
score | 92.5 |
SET:添加或者修改已经存在的一个String类型键值对
set name '喜羊羊'
GET:根据key获取String类型的value
get name
MSET:批量添加多个String类型的键值对
mset k1 v1 k2 v2 k3 v3
MGET:根据多个key和获取多个String类型的value
MGET k1 k2 k3
INCR:令key对应的value+1,前提是其value为Integer编码
incr count
INCRBY:令整型的key自增并且指定步长
incrby count 2
INCRBYFLOAT:令浮点数自增并且指定步长
incrbyfloat countf 2.0
SETNX:添加一个String类型键值对 前提key不存在, 否则不执行
setnx valid 'valid'
SETEX:添加一个String类型键值对 并且指定有效期
setex ex 500 100 //后面的参数分别是:键 有效时间 值
C.Key的层级格式
Redis中,没有类似Mysql的Table概念,如何区别不同的类型的key呢?
储存用户和商品信息到redis,有一个用户id是1 一个商品id也是1
Redis的key允许有多个单词形成层级结构,多个单词用:隔开
项目名:业务名:类型:id
我们项目叫proName,有user和product两种不同类型的数据,可以定义key
- user相关的key: proName:user:1
- product相关的key: proName:product:1
KEY | VALUE |
---|---|
proName:user:1 | {“id”:1,“name”:“Jack”,“age”:21} |
proName:product:1 | {“id”:1,“name”:“小米11”,“price”:4999} |
D.Hash类型
Hash类型,也叫散列,其value是一个无序字典,类似Java中的HashMap结构
Key | field | value |
---|---|---|
proName:user:1 | name | Jack |
age | 21 | |
proName:user:2 | name | Rose |
age | 18 |
String结构将对象序列化为json字符串后存储,当需求修改对象某个字段时不方便
Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做crud
hset key field value:针对key的field赋值为value:
hset proName:user:1 name 'Jack'
hset proName:user:1 age 21
hget key field:针对某个key去取值
hget proName:user:1 name
hget proName:user:1 age 21
hmset:针对键k 给字段f赋值v hmset k f1 v1 k2 v2 f3 v3
hmset proName:user3 name Tom age 20
hmget 批量获取多个hash类型key的field hmget key f1 f2 f3
hmget proName:user3 name age
hgetall:获取一个hash类型的key中的所有值
hgetall proName:user3
Hkeys key:获取一个hash类型的key中所有的field
hkeys proName:user3
Hvals key:获取一个hash类型的的key所有的value
hvals proName:user3
hincrby:让一个hash类型key的字段值自增并且指定步长
hincrby proName:user3 age 3
hsetnx :添加一个hash类型的key 的field值,前提是该filed不存在,否则不执行
hsetnx proName:user3 address london
E.List类型
Redis中的List类型与Java中的LinkedList类似,看做是双向链表结构。支持正向检索,也支持反向检索
- 有序
- 元素可以重复
- 插删快 查询慢
lpush key element 向列表左边插入一个或者多个元素
lpush testage 18
lpop key:移除并且返回列表左侧第一个元素,如果没有就返回nil
lpop testage
rpush key element 向列表右边插入一个或者多个元素
rpop key 移除并且返回列表右侧第一个元素
lrange key star end
lpush agegroup 16 17 18 19 20 21
lrange agegroup 1 6
blpop brpop 与lpop和rpop类似 只是在没有元素时等待指定时间 而非直接返回nil
blpop agegroup
brpop agegroup
- 如何用List结构模拟一个栈
- 入口和出口在同一边
- 如何用List结构模拟一个队列
- 入口和出口在不同边
- 如何用List结构模拟阻塞队列
- 入口出口不在同一边
- 出队列采用blpop或者brpop
F.Set类型
Redis中的Set结构与java中的HashSet类似,可以看成value为null的HashMap,因为其也是hash表,因此具备和HashSet类似的特征
- 无序
- 元素不可重复
- 查找快
- 支持交集,并集,差集查询。
- 在社交性应用中普遍使用
Set集合常见命令:
SADD key member:向set中添加一个或多个元素
sadd set1 apple banana cherry
sadd set2 banana cherry date
SREM key member:移除set中的指定元素
srem set1 apple
srem set2 banana
SCARD key:返回set中元素的个数
scard set1
SIsMember key member:判断元素是否存在于set中
SisMember set1 apple
Smembers 获取set中所有元素
smembers set1
SInter key1 key2 求key1和key2的交集
sInter set1 set2
SDiFF key1 key2 求key1和key2的差集
sDiff set1 set2
场景题:
-
张三的好友:李四,王五,赵六
-
李四的好友:王五,麻子,二狗
- 计算张三的好友数量
- 计算张三和李四的共同好友
- 查询哪些人是张三的好友,不是李四的好友
- 查询张三和李四好友总共有哪些人
- 判断李四是否为张三的好友
- 判断张三是否是李四的好友
- 在张三的好友列表中移除李四
sadd zhangsan '李四' '王五' '赵六' sadd lisi '王五' '麻子' '二狗' scard zhangsan sinter zhangsan lisi sdiff zhangsan lisi smembers lisi smembers zhangsan sismember zhangsan '李四' sismember lisi '张三' srem zhangsan '李四'
G.SortedSet类型
Redis的SortedSet是一个可排序的set集合,与Java的TreeSet相似,但底层数据结构差别很大。SortedSet每一个元素都有score属性,可以基于score进行排序。底层是用跳表实现的(SkipList)
- 可排序
- 元素不重复
- 查询速度快
所以我们用SortedSet可排序特性实现排行榜功能
注意:这里的key可以理解成sql中的表
注意:rank和score不一样
ZADD key score member:添加一个或者多个元素到sortedset 如果存在就更新score值
zadd user 1 Tom
zadd user 2 Bob
zadd user 3 Peter
zadd age 1 1
zadd ss1 1 test1 2 test2 3 test3 4 test4
zadd ss2 5 test5 6 test6 7 test7 8 test8
ZREM key member 删除sorted set中一个指定元素
zrem user Tom
zscore key member 获取sorted set中指定元素的score值
zscore user Tom
zrank key member 获取指定元素的排名
zrank user Tom
zcard key 统计key这个sortedset中元素的个数
zcard user
zcount key min max 统计score在某个范围内所有元素的个数
zcount user 1 3
zincrby key number member 对key的member对象自增
zincrby hash 2 1
zrange key min max:按照score排序后 获取指定排名范围内的元素
zrange user 1 4
Zrangebyscore key min max 按照score排序后 获取指定score范围内的元素
zrangebyscore user 1 4
ZDIFF差集
zdiff ss1 ss2
Zinter交集
zinter ss1 ss2
Zunion并集。
zunion ss1 ss2
二:Redis的Java客户端
1.Jedis
A.引入依赖
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.7.0</version>
</dependency>
B.建立连接
private Jedis jedis
@BeforeEach//单元测试类
void setUp(){
//建立连接
jedis = new Jedis("192.168.150.101",6379);//虚拟机ip与端口号
//设置密码
jedis.auth("123321");
//选择库
jedis.select(0);
}
C.测试Jedis
@Test
void testString(){
//插入数据 方法名称就是redis命令名称
String result = jedis.set("name","张三");
System.out.println("Result="+result);
//获取数据
String name = jedis.get("name");
System.out.println("name="+name);
}
D.释放资源
@AfterEach
void tearDown(){
if(jedis!=null){
jedis.close();
}
}
2.Jedis连接池
Jedis对象本身线程不安全,频繁创建和销毁连接存在性能损耗。因此推荐使用Jedis连接池替代Jedis的直接连接方式
public class JedisConnectFactory{
private static final JedisPool jedisPool;
static{
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
//最大连接
jedisPoolConfig.setMaxTotal(8);
//最大空闲连接
jedisPoolConfig.setMaxIdle(8);
//最小空闲连接
jedisPoolConfig.setMinIdle(0);
//设置最长等待时间
jedisPoolConfig.setMaxWaitMillis(200);
jedisPool = new JedisPool(jedisPoolConfig,"192.168.150.101",6379,1000,"123321");
}
//获取Jedis对象
public static Jedis getJedis(){
return jedisPool.getResource;
}
}
3.SpringDataRedis
SpringData是Spring中数据操纵的模块,包含对各种数据库的集成。对Redis操作的模块,就叫SpringDataRedis.
- 提供了对不同客户端的整合:Lettuce与Jedis
- 提供了RedisTemplate统一API来操作Redis
- 支持Redis的发布订阅模型
- 支持Redis哨兵与Redis集群
- 支持基于Lettuce的响应式编程
- 支持基于JDK,JSON,字符串,Spring对象的数据序列化与反序列化
- 支持基于Redis的JDKCollection的实现
A.redisTemplate
API | 返回值类型 | 说明 |
---|---|---|
redisTemplate.opsForValue() | ValueOperations | 操作String类型数据 |
redisTemplate.opsForHash() | HashOperations | 操作Hash类型数据 |
redisTemplate.opsForList() | ListOperations | 操作List类型数据 |
redisTemplate.opsForSet() | SetOperations | 操作Set类型数据 |
redisTemplate.opsForZSet() | ZSetOperations | 操作SortedSet类型数据 |
redisTemplate | 通用命令 |
依赖导入
<!--Redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
<!--连接池依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<aritifactId>commons.pool2</aritifactId>
</dependency>
配置文件(application.properties)
# 服务器ip
spring.redis.host=192.168.11.128
# 端口
spring.redis.port=6379
# 第一个数据库存储
spring.redis.database=0
# 最大活跃数
spring.redis.jedis.pool.max-active=20
# 最大空闲数
spring.redis.jedis.pool.max-idle=10
# 最小空闲数
spring.redis.jedis.pool.min-idle=5
注入redisTemplate
@Autowired
private RedisTemplate redisTemplate;
编写测试
@SpringbootTest
public class RedisTest{
@Autowired
private RedisTemplate redisTemplate;
@Test
void testString(){
//插入一条String类型数据
redisTemplate.opsForValue.set("name","李四");
Object name = redisTemplate.opsForValue().get("name");
Systen.out.println("name"+name);
}
}
B.redisSerializer
或许,你在redis中发现从本地储存到redis中的value为"\xac\xed\x00\x05t\x00\aagetest"
似乎有一些乱码!我们引入序列化工具
实际上:redisTemplate 可以接收任意Object作为值写入Redis,但是会在写入前将Object序列化为字节形式,默认是采用JDK序列化,因此会形成
"\xac\xed\x00\x05t\x00\aagetest"
存在缺点:
- 可读性差
- 内存占用大
解决策略:
- 不使用JDK的默认序列化方式,我们要重写序列化方式
自定义RedisTemplate序列化方式
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
//创建RedisTemplate对象
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
//设置连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
//设置Json序列化工具
GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
//设置key的序列化
redisTemplate.setKeySerializer(RedisSerializer.string());
//设置value的序列化
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashKeySerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}
C.StringRedisTemplate
JSON序列化可以满足一定需求,但是存在问题,例如:
{
"@class":"com.heima.redis.pojo.User",
"name":"测试id",
"age":21
}
为了在反序列化时知道对象类型,json序列化器会将类的class类型写入json结果,存入Redis,会带来额外内存开销
为了节省内存空间,我们并不会使用json序列化器来处理value
而是统一使用String序列化器,要求只能存储String类型的key和value。当需求存储java对象,手动完成序列化与反序列化
流程:pojo类 ->手动序列化 成json 然后redisTemplate.opsForValue().set(“user”,jsonStr)
然后从redis拿 redisTemplate.opsForValue().get(“user”)然后得到json,之后把json反序列化为字节码中的javaben类
javabean类例子:
public class User{
private String name;
private Integer age;
}
jsonstr例子:
{
name:"Jack",
age:21
}
Spring默认提供了一个StringRedisTemplate类,它的key和value的序列化方式默认就是String方式。省去了我们自定义RedisTemplate的过程:
@Autowired
private StringRedisTemplate stringRedisTemplate;
//JSON工具
private static final ObjectMapper mapper = new ObjectMapper();
@Test
void testStringTemplate() throws JsonProcessingException{
//准备对象
User user = new User("测试id",19);
//手动序列化
String json = mapper.writeValueAsString(user);
//写入一条数据到redis
stringredisTemplate.opsForValue().set("user:200",json);
//读取数据
String val = stringRedisTemplate.opsForValue.get("user:200");
//反序列化
User user1 = mapper.readValue(val,User.class);
System.out.println(user1);
}
//哈希化
@Test
void testHash(){
stringRedisTemplate.opsForHash().put("user:400","name","测试id");
stringRedisTemplate.opsForHash().put("user:400","age","19");
Map<Object,Object> entries = stringRedisTemplate.opsForHash().entries("user:400");
System.out.println(entries);
}
三:Redis的企业应用
1.短信登录(Redis的存与取)
A.导入黑马点评项目
B.基于Session实现登录
B.1业务逻辑与概念扫盲
- 发送短信验证码
- 开始
- 提交手机号
- 校验手机号
- 符合:生成验证码
- 不符合:提交手机号
- 生成验证码
- 保存验证码在Session
- 发送验证码
- 短信验证码登录,注册
- 提交手机号与验证码
- 校验验证码:
- 符合:根据手机号查询用户
- 不符合:提交手机号与验证码
- 根据手机号查询用户信息
- 用户是否存在
- 存在->保存用户到Session
- 不存在->创建新的用户,填入手机号等信息,填入数据库
- 保存用户到Session
- 校验登录状态
- 请求并携带Cookie
- 从Session中获取用户
- 判断用户是否存在
- 存在:保存用户到ThreadLocal并放行
- 不存在:拦截
Session基于Cookie,每一个Session都有一个SessionID保存在浏览器的cookie中
1. 基于Session实现登录的案例
以下是一个简化的基于Session的登录流程案例,使用伪代码表示:
# 用户提交登录表单 POST /login # 请求包含用户名和密码 { "username": "user123", "password": "password123" } # 服务器端处理登录请求 def handle_login_request(request): username = request.POST['username'] password = request.POST['password'] # 验证用户名和密码(这里应该查询数据库并使用加密验证) if authenticate_user(username, password): # 创建一个新的会话 session_id = create_session(username) # 设置Session ID到Cookie中 response = set_cookie("session_id", session_id) # 登录成功,返回响应 return response_with_status_code(200, "Login successful") else: # 登录失败,返回错误信息 return response_with_status_code(401, "Invalid credentials") # 用户验证函数(伪代码) def authenticate_user(username, password): # 这里应该是数据库查询和密码加密验证的过程 return True # 创建会话函数(伪代码) def create_session(username): session_id = generate_unique_session_id() session_data = { "user_id": get_user_id_by_username(username), "username": username, "last_login": get_current_time() } store_session(session_id, session_data) return session_id # 存储会话函数(伪代码) def store_session(session_id, session_data): # 这里将会话数据存储到服务器的内存、数据库或分布式缓存中 pass # 生成唯一会话ID函数(伪代码) def generate_unique_session_id(): return "unique_session_id" # 设置Cookie函数(伪代码) def set_cookie(name, value): response = create_response_object() response.set_cookie(name, value) return response
2. 具体的Session内容
假设用户登录成功,以下是一个具体的Session内容示例:
{ "session_id": "unique_session_id", "data": { "user_id": 123, "username": "user123", "last_login": "2023-12-01T12:00:00Z", "is_authenticated": true } }
3. Session存储在哪里
在这个案例中,Session可以存储在以下位置:
- 服务器内存:服务器为每个会话分配一块内存区域。
- 数据库:服务器将Session信息存储在数据库的会话表中。
- 分布式缓存:如Redis或Memcached,适用于需要横向扩展的分布式系统。
4. 返回给前端的内容
当用户登录成功后,服务器返回给前端的内容可能如下:
- HTTP响应状态码:200 OK
- HTTP响应头:包含Set-Cookie头,用于设置Session ID的Cookie
- HTTP响应体:通常是一个JSON对象,包含登录成功的信息
HTTP/1.1 200 OK Set-Cookie: session_id=unique_session_id; Path=/; HttpOnly; Secure Content-Type: application/json { "message": "Login successful", "user": { "username": "user123" } }
客户端(通常是浏览器)会存储这个
session_id
Cookie,并在随后的请求中自动将其发送给服务器,以维持会话状态。
ThreadLocal相关:
- 线程安全:ThreadLocal为每个线程提供了一个独立的变量副本,从而避免了多线程并发访问共享资源时的线程安全问题。在Web应用中,服务器通常为每个请求创建一个线程,通过ThreadLocal可以保证每个请求的用户信息是独立的。
- 数据隔离:由于ThreadLocal保证了变量的线程独立性,因此它可以确保每个线程(即每个用户请求)的数据不会互相干扰。这对于保持用户会话数据和状态是非常重要的。
- 方便获取:将用户信息存储在ThreadLocal中后,在任何地方都可以方便地获取到当前线程对应的用户信息,而不需要通过参数传递的方式,简化了代码逻辑。
- 性能优化:与全局变量相比,ThreadLocal减少了锁的使用,因为每个线程访问的是自己独立的变量副本,从而提高了系统的性能。
以下是具体的应用场景说明:
- 用户会话管理:当一个用户登录系统后,其登录信息(如用户ID、权限等)可以存储在ThreadLocal中。在随后的请求处理过程中,系统可以从ThreadLocal中直接读取这些信息,而不需要每次都去查询数据库或访问外部存储。
- 事务管理:在事务处理中,ThreadLocal可以用来存储事务的上下文信息,确保事务的连贯性和一致性。
- 日志记录:在记录日志时,ThreadLocal可以用来保存日志相关的上下文信息,如用户操作轨迹等。
B.2实现-发送短信验证码
-
分析点击[发送验证码]后发出的请求
RequestURL:http://localhost:8080/api/user/code?phone=13456789001 RequestMethod:POST
说明 请求方式 POST 请求路径 /user/code 请求参数 phone,电话号码 返回值 无 -
分析UserController节选验证码部分
//修改前 @PostMapping("code") public Result sendCode(@RequestParam("phone")String phone,HttpSession session){ //TODO 发送短信验证码并且保存验证码 return Result.fail("功能未完成") }
//修改后 @PostMapping("code") public Result sendCode(@RequestParam("phone")String phone,HttpSession session){ //发送短信验证码并且保存验证码 return userService.sendCode(phone,session) }
-
实现UserService及其实现类
public interface IUserService extends IService<User>{ Result sendCode(String phone,HttpSession session); }
public class UserServiceImpl extends ServiceImpl<UserMapper,User> implements IUserService{ @Override public Result sendCode(String phone,HttpSession session){ //1.校验手机号 if(RegexUtils.isPhoneInvalid(phone)){ //2.如果不符合,返回错误信息 return Result.fail("手机号格式错误"); } //3.符合,生成验证码(6位) String code = RandomUtil.randomNumbers(6);// //4.保存验证码到session session.setAttribute("code",code); //5.发送验证码 log.debug("发送验证码成功,验证码:{}",code); //6.返回Ok return Resut.ok();//这里ok本质是我们重新封装的 } }
B.3实现短信验证码登录和注册
经过B2.在前端点击发送验证码,可以在NetWork中观察到
RequestPayLoad有Json 下面的code和phone都是我输入的内容
{
code:"123321",
phone:13456789001
}
说明 | |
---|---|
请求方式 | POST |
请求路径 | /user/login |
请求参数 | phone:电话号码;code:验证码 |
返回值 | 无 |
-
UserController中完善Login功能
//完善功能前 @PostMapping("/login") public Result login(@RequestBody LoginFormDTO loginFormDTO,HttpSession session){ //TODO 实现登录功能 return Result.fail("功能未完成"); }
@Data public class LoginFormDTO{ private String phone; private String code; private String password;//考虑密码登录情况 }
//完善功能后 @PostMapping("/login") public Result login(@RequestBody LoginFormDTO loginForm,HttpSession session){ //实现登录功能 return UserService.login(loginForm,session); }
-
UserService与其实现类
public interface IUserService extends IService<User>{ Result sendCode(String phone,HttpSession session); Result login(LoginFormDTO loginForm,HttpSession session); }
@Override public Result login(LoginFormDTO loginForm,HttpSession session){ //1.校验手机号 String phone = loginForm.getPhone(); if(RegexUtils.isPhoneInvalid(phone)){ return Result.fail("手机号格式错误"); //1.2:如果不一致返回错误信息 } //2.校验验证码 Object cacheCode = session.getAttribute("code"); String code = loginForm.getCode(); if(cacheCode==null||!cacheCode.toString().equals(code)){ //3.不一致报错 return Result.fail("验证码错误"); } //4.一致,根据手机号查询用户 User user = query().eq("phone",phone).one(); //5.判断用户是否存在 if(user==null){ //6.不存在,创建新用户并且保存 user = createUserWithPhone(phone); } //7.保存用户信息到session session.setAttribute("user",user); return Result.ok(); } private User createUserWithPhone(String phone){ //1.创建用户 User user = new User(); user.setPhone(phone); user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10)); save(user) return user; }
在Java代码中,
save(user)
和query()
这样的方法调用通常来自以下几种情况:-
save(user):
- 来自继承的基类或接口: 在很多框架中,比如MyBatis-Plus,存在一个通用的Service基类,其中包含了CRUD(创建、读取、更新、删除)操作的方法。
save(user)
可能是继承自这样的基类或实现了某个接口,其中定义了save
方法。 - 来自注入的服务: 有可能
save
方法是某个服务类中的方法,该服务类被注入到当前类中,以便进行数据持久化操作。 - 来自工具类: 有时,这些方法可能来自于一个工具类,专门用于处理数据库操作。
示例代码如下:
// 假设存在一个BaseService类 public class UserService extends BaseService<User> { // ... 其他方法 ... public boolean save(User user) { // 实现保存用户的逻辑 return true; } }
- 来自继承的基类或接口: 在很多框架中,比如MyBatis-Plus,存在一个通用的Service基类,其中包含了CRUD(创建、读取、更新、删除)操作的方法。
-
query().eq(“phone”, phone).one():
- 来自MyBatis-Plus等ORM框架: MyBatis-Plus是一个MyBatis的增强工具,它提供了很多链式操作方法,例如
query()
通常是对Mapper
接口方法的增强调用,用于构建查询条件。eq
是等于(Equal)的缩写,用于添加等于条件,one
表示查询并返回单个结果。 - 来自自定义的查询工具: 在某些情况下,
query()
可能是一个自定义的查询工具类或方法,它内部构建SQL语句或调用数据库API来执行查询。
示例代码如下:
// 假设存在一个Query类用于构建查询 public class Query { // 构建查询条件 public Query eq(String column, Object value) { // 添加等于条件 return this; } // 执行查询并返回单个结果 public User one() { // 实现查询逻辑 return new User(); } }
下面是MyBatis-Plus中
query()
方法的一般用法: - 来自MyBatis-Plus等ORM框架: MyBatis-Plus是一个MyBatis的增强工具,它提供了很多链式操作方法,例如
// 假设UserMapper继承了BaseMapper接口 public interface UserMapper extends BaseMapper<User> { // ... 其他方法 ... } // 在服务层中,可以这样使用 @Service public class UserService { @Autowired private UserMapper userMapper; public User queryUserByPhone(String phone) { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("phone", phone); return userMapper.selectOne(queryWrapper); } }
在这个例子中,
QueryWrapper
是MyBatis-Plus提供的一个条件构造器,用于生成SQL的where条件,selectOne
是MyBatis-Plus提供的方法,用于查询并返回单个结果。
总之,save(user)
和query().eq("phone", phone).one()
是典型的数据持久化操作方法,它们通常来源于ORM框架提供的接口或自定义的数据库操作类。在具体实现中,需要查看该类是如何与数据库交互的,以及这些方法的具体实现细节。 -
B.4登录校验:
在B.3实现后,当验证码正确时,并且账号没有注册时候,会在数据库中注册新的账号。但是:我们的前台页面没有跳转,登录校验就是解决这个问题的
黑马点评服务,有若干个Controller,如果每一个Controller都做一次用户登录校验,那么冗余代码。我们在访问Controller前,加入拦截器。让所有请求经过拦截器后才能访问Controller。
但也带来问题:用户信息交互在拦截器,那我Controller拿不到Session,如何对传递数据到mapper去增删查改呢。
策略:拦截器会向不同的Controller发送ThreadLocal,ThreadLocal就储存了Session等信息
1.拦截器:处于Utils包下
public class LoginInterceptor implements HandlerInterceptor{
// 在业务处理器处理请求之前被调用
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
//1.获取session
HttpSession session = request.getSession();
//2.获取在session中的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if(user==null){
//4.不存在就拦截
response.setStatus(401);
return fasle;
}
//5.存在就保存信息到ThreadLocal中
UserHolder.saveUser((User) user)
//6.放行
return true;
}
// 在业务处理器处理请求完成之后,生成视图之前执行
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)
throws Exception{
//不工作 在本项目中
}
// 在DispatcherServlet完全处理完请求之后被调用,可用于清理资源
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception{
//移除用户
UserHolder.removeUser();
}
}
2.配置拦截器
拦截器写好了,但是没有生效,因此需要配置拦截器
public class MvcConfig implements WebMvcConfigurer{
@Override
public void addInterceptors(InterceptorRegistery registry){
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",//获取验证码的接口当然不能拦截
"/user/login",//登录的接口拦截了,那怎么登录呢
//上述为不应该被拦截的RESTful接口
);
}
}
从请求中提取
sessionId
并在HttpServletRequest
中携带HttpSession
的功能是由 Web 服务器(如 Apache Tomcat、Jetty、Undertow 等)和 Servlet 容器(通常是 Java Servlet API 的一部分)共同实现的。以下是这个过程的工作原理:
- 会话创建:
- 当用户首次访问一个需要跟踪会话的 Web 应用程序时,Web 服务器会创建一个新的
HttpSession
对象。- 这个
HttpSession
对象会被分配一个唯一的标识符,称为sessionId
。- 发送
sessionId
到客户端:
- 服务器会在 HTTP 响应中通过
Set-Cookie
头部将sessionId
发送给客户端。- 客户端(通常是浏览器)会存储这个
sessionId
,并在后续的请求中通过Cookie
头部将其发送回服务器。- 请求中的
sessionId
提取:
- 当客户端发送请求时,Web 服务器会检查请求头中的
Cookie
字段,以查找与该会话关联的sessionId
。- 如果找到了
sessionId
,服务器会使用这个标识符在服务器的会话管理器中查找对应的HttpSession
对象。- 绑定
HttpSession
到HttpServletRequest
:
- 一旦找到
HttpSession
对象,服务器会将其绑定到HttpServletRequest
对象上。- 这样,Servlet 可以通过调用
HttpServletRequest
的getSession()
方法来访问HttpSession
。
以下是这个过程的关键参与者:
- Web 服务器:负责处理 HTTP 请求和响应,维护会话状态,以及发送和接收
sessionId
。- Servlet 容器:实现了 Java Servlet API,提供了
HttpServletRequest
和HttpSession
等接口,以及它们之间的交互逻辑。- Servlet:当请求到达时,Servlet 容器会调用 Servlet 的方法(如
doGet
、doPost
等),并提供HttpServletRequest
对象。
以下是代码示例中可能涉及的几个关键步骤:// 服务器创建会话并设置sessionId HttpSession session = request.getSession(); String sessionId = session.getId(); // 服务器通过Set-Cookie发送sessionId到客户端 response.addCookie(new Cookie("JSESSIONID", sessionId)); // 客户端在后续请求中携带sessionId // 以下是请求头中可能包含的Cookie字段 Cookie: JSESSIONID=sessionIDValue // 服务器从请求中提取sessionId,并在请求处理期间绑定HttpSession HttpSession session = request.getSession(); // 这会查找与sessionId关联的会话
总的来说,这个功能是由 Web 服务器和 Servlet 容器自动处理的,不需要开发者手动实现
sessionId
的发送和接收。开发者只需要通过HttpServletRequest
的 API 来使用会话功能。
B.5实现对用户信息的隐藏
在B.4中,我们可以找到访问./me时的json包 很明显:此处暴露了用户的个人信息
后端返回给前端的内容:不必要包含密码等信息,如何做删去这些不必的内容呢?
{ createTime:"", icons:"", id:5, nickName:"user_xxxxx", password:"", phone:"165489465", updateTime:"2024-7-22-1" }
1.分析无法实现隐藏的根源
在UserService的Login部分:
session.setAttribute("user",user);
我们将整个user都放入了session.而整个user中包含了密码,因此返回给前端结果包含了密码
2.解决策略
因此需要设计一个部分对象避免返回敏感数据,仅仅返回基本数据。
session.setAttribute("user",BeanUtil.copyProperties(user,UserDTO.class))
//BeanUtil.copyProperties是将前者的属性拷贝到后者的bean中取
//接下来需要将项目中其他返回user的部分,替换成UserDTO
@Data
public class UserDTO{
private Long id;
private String nickName;
private String icon;
//发现了:这个bean里面没有密码等对象 因此我们用BeanUtil拷贝user过来,发现密码是没有对应字段的,自然无法拷贝,被保留的就是非敏感字段了
}
C.集群的Session共享问题
session共享问题:多台Tomcat不共享session储存空间,当请求切换到不同Tomcat服务,会导致数据丢失的问题。
**最初解决策略:**让tomcat支持拷贝功能,让其他的Tomcat能够拷贝已存在数据的Tomcat中数据。问题:存在延迟,因此不采用
衍生思考:
- 新的方案要满足:
- 数据共享
- 内存存储
- key-value结构
我们找到了版本答案——Redis
- Redis优势
- Redis是中间件,因此多台Tomcat可以访问我们的Redis集群,解决数据共享问题
- Redis是基于内存存储的数据库,解决内存存储问题
- Redis还是key-value结构的非关系型数据库,满足key-value需求
D.基于Redis实现共享Session登录
D.1业务逻辑梳理
-
发送短信验证码
- 开始
- 提交手机号
- 校验手机号
- 符合:生成验证码
- 不符合:提交手机号
- 生成验证码
- 保存验证码在:Redis:注意(phone:138797984 )
- 发送验证码
key value phone:138384111438 9527 保存验证码步骤,不再将验证码存在Session中,而是存在Redis中,且key为 Phone:手机号 value为生成的验证码
验证码和手机号都应该是String类型
-
短信验证码登录,注册
- 提交手机号与验证码
- 校验验证码:
- 符合:根据手机号查询用户
- 不符合:提交手机号与验证码
- 根据手机号查询用户信息
- 用户是否存在
- 存在->保存用户到Redis(用户对象用hash类型->方便对 对象具体的值进行修改)
- 不存在->创建新的用户,填入手机号等信息,填入Redis (要用随机token去给用户的字段赋值),并且存到数据库,
- 保存用户到Redis,返回token给客户端(为用token去给Redis对象中字段赋值做前提)
KEY | Value | Value |
---|---|---|
field | value | |
heima:user:1 | name | Jack |
age | 21 | |
heima:user:2 | name | Rose |
age | 18 |
- 校验登录状态
- 请求并携带Token(注意:不是Session)
- 从Redis(用Token去取Redis中的值)中获取用户
- 判断用户是否存在
- 存在:保存用户到ThreadLocal并放行
- 不存在:拦截
D.2实现-发送短信验证码
public class UserServiceImpl extends ServiceImpl<UserMapper,User> implements IUserService{
@Resource
private StringRedisTemplate stringRedisTempalte; //我们要引入Redis替代Session,就得引入这个集成操作Redis的对象
@Override
public Result sendCode(String phone,HttpSession session){
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//3.符合,生成验证码(6位)
String code = RandomUtil.randomNumbers(6);//
//4.保存验证码到session
/*session.setAttribute("code",code);*/
//4.保存验证码到Redis
stringRedisTemplate.opsForValue().set("login:code:"+phone,code,LOGIN_CODE_TTL,TimeUnit.MINUTES);
/*注意 login:code是显示层级结构
2(LOGIN_CODE_TTL)是值 TimeUnit.MINUTES是单位 表示这个验证码在redis中的有效期为2分钟 类似于setex的命令 或 set key value ex 120
login:code:可以替换为LOGIN_CODE_KEY 前提是你定义RedisConstants*/
//5.发送验证码
log.debug("发送验证码成功,验证码:{}",code);
//6.返回Ok
return Resut.ok();//这里ok本质是我们重新封装的
}
}
public class RedisConstants{
public static final String LOGIN_CODE_KEY="login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final Long LOGIN_USER_KEY ="login:token:";
public static final Long LOGIN_USER_TTL = 30L;
}
D.3实现短信验证码登录和注册
@Override
public Result login(LoginFormDTO loginForm,HttpSession session){
//1.校验手机号
String phone = loginForm.getPhone(