Redis 基础 - 短信验证码登录

本文摘要
先简单列出用session方式。然后提出session方式的问题,并简单换为用Redis的方式。最后通过优化来解决一些小问题。

Redis基础 - 基本类型及常用命令

Redis基础 - Java客户端

基于session实现短信登陆的简单流程

发送验证码

前端把手机号传给服务端,后端经过校验后,生成验证码并存入到session中,并通过第三方平台给用户手机发短信验证码。

登陆/注册

前端把登陆用的手机号和刚才接收的验证码传给服务端,后端经过校验后,若没毛病就用手机号去查用户表,没有用户的话给他注册,若有用户,就算是登陆成功。注册/登陆成功后,把用户的部分信息(除去敏感信息)放到session中。
1)校验手机号:是否规范,是否是刚才收到验证码的那个手机号。
2)校验验证码:放入session的验证码与前台传入的验证码比较。

查看用户登陆状态

前台调用服务端提供的相关API,因为请求会带上cookie,cookie里面有sessionid,后端通过sessionID取出相关用户信息。

用拦截器实现

一般来说,这个逻辑写在拦截器。但controller的有些部分可能会也要用到结果,所以有必要把拦截器的结果传给controller里,但要注意线程的安全,所以用ThreadLocal解决,即拦截器里搞到用户信息之后,可以把他保存在ThreadLocal,因为ThreadLocal是线程域对象,每一个进入Tomcat的请求都是一个独立的线程,所以ThreadLocal会在线程内开辟一个内存空间,去保存对应的用户,这样的话每个线程相互不干扰。所以到了controller后从ThreadLocal里取出用户即可。

代码示例:
LoginInterceptor.java

public class LoginInterceptor implements HandlerInterceptor {
	@Override
	public boolean preHandle(request,response,) {
		// 进入controller之前处理
		HttpSession session = request.getSession();
		Object user = session.getAttribute("user");
		if(user == null) {
			// session中用户信息不存在,返回401状态码
			response.setStatus(401);
			return false;
		}
		// 如果session中存在用户信息,用户信息保存到ThreadLocal。由于是保存在当前线程里面的,所以不需要key。
		UserHolder.saveUser((User)user);
		return true;
	}
	@Override
	public void afterCompletion(request,response,) {
		// 业务执行完毕后,销毁用户信息,避免内存泄露
		UserHolder.removeUser();
	}
}

UserHolder.java

public class UserHolder {

	pviate static final ThreadLocal<User> tl = new ThreadLocal<>();
	
	public static void saveUser(User user) {
		t1.set(user);
	}
	public static User getUser() {
		returun tl.get();
	}
	public static void removeUser() {
		tl.remove();
	}
}

MvcConfig.java

@Configuration
public class MvcConfig implements WebMvcConfigurer {

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/user/login");
	}
}

网友之言

网友1:为啥要使用ThreadLocal?
网友2:方便同一个线程可以重复使用user。

集群的session共享问题以及对应的解决方案

像上面的那样玩,在集群时会出现问题。因为多台Tomcat并不共享session存储空间,当请求切换到不同的Tomcat服务时导致数据丢失的问题。

session的替代方案需要满足的条件

  • 数据共享,这是最重要的,就是因为不共享才导致了刚才的问题。
  • 内存存储,因为session是内存存储,所以读写效率非常高。
  • key、value结构

解决方案 - 使用Redis

因为Redis是Tomcat以外的存取方案,所以任何一台Tomcat都能访问到Redis,所以就能实现数据共享。而且Redis是内存存储,性能非常强,读写延时基本都是微妙级别。Redis也是key、value结构,所以结构也简单,用Redis替代session,势在必行。

用Redis实现登陆/注册的简单流程

发送验证码

生成验证码后,以前是保存在session,现在要保存在Redis,数据类型可以用string。

session有一个特点是每一个不同的浏览器发请求时都有一个独立的session,也就是说Tomcat内部维护着很多的session,那么不同浏览器携带着手机号来的时候,都是自己独立的session,所以他们都能用"code"作为key比如 session.setAttribute("code",code),因为互相之间不干扰。

但Redis是共享的内存空间,所以key不能简简单单的用"code",因为来不同的请求的时候,由于服务端只有一个Redis,所以一个人来的时候"code"=手机验证码1,另一人来的时候又"code"=手机验证码2,就出现覆盖的现象。所以,要确保每一个不同的手机号来做验证时保存的Key都得是不一样的,所以直接用手机号当做key也行。这样将来登陆时也方便得多,直接用手机号[key]和验证码[value]去Redis找验证码并比较验证码即可。

登陆/注册

一系列校验后,如果用户存在(或给她注册后),就把用户信息保存到Redis。

在这里也要考虑两个问题,第一考虑数据类型的选择,第二是考虑到key的问题。此时要保存的是用户的对象,所以如果不考虑内存问题,而且数据量较少的话,可以用string类型的JSON之类的,不过从优化的角度考虑的话,推荐使用hash类型。第二关于key,要保证唯一,还有方便客户端将来可以携带的那种key,这里推荐用随机的token为key存储用户数据。

查看是否已登陆

用session时,因为Tomcat自动把sessionid写到浏览器的cookie里,所以每次请求带了cookie就带了sessionid,所以根据sessionid查找session可以找到用户,也就是说这里的sessionid就是你的登陆凭证。

但现在用的是Redis,所以登陆凭证不再是sessionid了,这里的登陆凭证就是token了。即,以后用户请求访问时,都要带上token。由于这个token不会被Tomcat自己写到浏览器,所以只能我们手动的把token返回给客户端,那么以后客户端请求都会携带这个token了,那么后端可以基于token从Redis获取用户信息。

比如前端可以用代码编写把token放到sessionStorage(浏览器的一种存储方式)。

...
// 保存用户信息到session
sessionStorage.setItem("token",token)
...

而每次请求时都要携带token。

...
// request拦截器,将用户Token作为请求头放入头中
let token = sessionStorage.getItem("token");
axios.interceptors.request.use(
	config => {
		if(token) config.headers['authorization'] = token
		return config;
	}
)
...

这样以来以后凡是axios发起的请求(所有的Ajax请求),都会携带authorization这个头。将来在服务端获取authorization这个请求头,拿到token,从而实现对登陆的验证。

网友之言

用户信息的key不能以手机号的理由之一是把这玩意保存到前端浏览器会有泄露的风险。

代码示例:

UserServiceImpl.java

@Resource
private StringRedisTemplate stringRedisTemplate;

// 发送验证码的部分
@Override
public Result sendCode(String phone) {
	// 1,校验手机号
	// 2,如果不符合,返回错误信息
	// 3,如果符合,生成验证码,变量code
	// 4,保存验证码到Redis(为了防止其他业务也有可能会用手机号当做key,所以前面最好加个前缀,
	/*由于这里是登陆时发送验证码,所以可以用login:code来当做前缀。
	然后第二个是这里需要设置验证码的有效期,如果不加有效期,
	那以后每个人都登陆发验证码,终有一天,Redis内存会被沾满。
	比如这里设置为2分钟【参数3,参数4】。当然,建议设置为常量。)*/
	stringRedisTemplate.opsForValue().set(
			"login:code:"+phone,
			code, 
			2,
			TimeUnit.MINUTES);
	// 5,发送验证码
	// 6,返回
	return Result.ok();
}
// 短信登陆/注册
@Override
public Result login(参数列表) {
	//1,校验手机号
	//2,如果不符合,返回错误信息
	
	//3,从Redis获取验证码并校验
	String cacheCode = stringRedisTemplate.opsForValue().get("login:code:"+phone);
	
	//4,与请求中的验证码比较不一致的话,报错
	//5,一致时,根据手机号查询用户表
	//6,用户没有的话,注册,并用户信息存到Redis
	//7,有的话,用户信息存到Redis
	
	//7.1,生成token,作为登陆令牌
	String token = UUID.randomUUID().toString(true);// true:不带中线的
	
	//7.2,将user对象转为hashMap存储(可以用BeanUtil工具类[cn.hutool.core.bean 需要在pom.xml引入依赖]把User转换为HashMap)
	Map<String,Object> map = BeanUtil.beanToMap(userDTO);
	
	//7.3,存储数据到Redis(token也加前缀,可定义为常量。也要加上有效期。)
	stringRedisTemplate.opsForHash().putAll("login:token:"+token,map);
	
	//7.4,设置token的有效期设置为30分钟。
	/*但这里的有效期是,只要登陆开始,过了30分钟,Redis必然会把这个干掉。
	所以不管用户访问还是不访问,30分钟后,一定会被干掉。
	应该像session那样,只要用户不断的访问,就要不断的更新token的
	有效期,那么怎么知道用户有没有访问呢,网友:拦截器。
	即只要访问拦截器,就说明有请求,每次访问时,更新有效期。
	具体在下面的3)改。
	*/
	stringRedisTemplate.expire("login:token:"+token, 30, TimeUnit.MINUTES);
	
	//8,返回token到客户端
	return Result.ok(token);
}

查看是否已登陆
LoginInterceptor.java

public class LoginInterceptor implements HandlerInterceptor {

 

	/* 由于LoginInterceptor 不是由spring创建对象,
	而是我们自己手动创建的对象,所以不能用@Resource 或 @Autowired之类的方式注入。
	下面的MvcConfig.java也会有相应的改动。
	(网友1:把拦截器加一个Component注解就行了。)*/

	private StringRedisTemplate stringRedisTemplate;

	public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
		this.stringRedisTemplate = stringRedisTemplate;
	}
	
	@Override
	public boolean preHandle(request,response,) {
		// 进入controller之前处理
		// 1,获取请求头中的token
		String token = request.getHeader("authorization");// 刚才前端代码中token放到了authorization
		
		if (StrUtil.isBlank(token)) {
			// 空的话说明未登陆
			response.setStatus(401);
			return false;
		}

		// 2,基于token去获取Redis中的用户
		Map<Object,Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:" + token);

		// 3,判断用户是否存在
		if(userMap.isEmpty()) {
			// 4,不存在时,拦截,返回401状态码
			response.setStatus(401);
			return false;
		}

		// 5,将查询到的hash数据转为UserDTO对象
		//(参数1:要转换的map,参数2:转换成什么bean,参数3:是否忽略异常 false 否)
		UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

		// 6,若存在,保存用户信息到ThreadLocal
		UserHolder.saveUser(userDTO);

		// 7,刷新token的有效期
		stringRedisTemplate.expire("login:token:"+token, 30, TimeUnit.MINUTES);

		// 8,放行
		return true;
	}
	
	@Override
	public void afterCompletion(request,response,) {
		// 业务执行完毕后,销毁用户信息,避免内存泄露
		UserHolder.removeUser();
	}
}

UserHolder.java

public class UserHolder {
	pviate static final ThreadLocal<User> tl = new ThreadLocal<>();

	public static void saveUser(User user) {
		t1.set(user);
	}
	public static User getUser() {
		returun tl.get();
	}
	public static void removeUser() {
		tl.remove();
	}
}

MvcConfig.java

@Configuration
public class MvcConfig implements WebMvcConfigurer {
	// 这个类是用了@Configuration,所以会被spring来构建对象,所以这里可以用依赖注入
	@Resource
	private StringRedisTemplate stringRedisTemplate;

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		// 加到LoginInterceptor的构造参函数数中
		registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns("/user/login");

	}
}

运行之后,因为这里使用的是stringRedisTemplate,这玩意要求key value都是string类型,在这里转map时,由于UserDTO的id字段是long类型,所以出现转string的错误java.lang.Long cannot be cast to java.lang.String
所以转map的时候,要确保UserDTO里的字段值都要以string的形式存储到map里。第一个笨方法是不用工具类,可以自己new一个map,然后一个一个干入到map里。第二个方法是,还是用BeanUtil这个工具,比如使用有三个参数的:

// Map map = BeanUtil.beanToMap(userDTO); 改为如下:

Map<String,Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(), 
CopyOptions.create().setIngnoreNullValue(true)).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString());

网友之言

网友1:我还是自己new吧。
网友2:我选择笨方法。
网友3:我选择使用JSON

登陆拦截器的优化

目前登陆拦截器的执行逻辑如下:

  • 获取用户提交的头文件中的token
  • 根据这个token查询Redis中的用户(不存在则拦截,存在则继续)
  • 把用户信息保存到ThreadLocal
  • 刷新token有效期
  • 放行

但有一个问题,并不是所有的请求都会经过这个拦截器,她拦截的只是需要登陆校验的路径,即不是拦截一切。

所以这就导致比如说一个用户只访问不需要登陆的页面,比如首页或文章详情页之类的,这样的话由于不经过拦截器,所以不会刷新token的有效期,这样的话,30分钟以后,用户的登陆就得被干掉了。

可以在原有的拦截器基础上,在他的前面再加一个新的拦截器1,这样的话用户请求先进入拦截器1,然后再进入拦截器(原来的拦截器,以下称为拦截器2)。
让拦截器1拦截一切路径,那么在拦截器1里做刷新token有效期的动作,即除了2号的不存在则拦截,存在则继续之外,其余的部分都放到拦截器1上,即拦截器1并不会进行真正的拦截,只是主要做刷新有效期的工作。这样的话,一切请求都会触发更新token有效期了。当然,没登陆时,由于没有token,所以在拦截器1里直接放行即可,接着在拦截器2里做相应判断即可。

新建拦截器1代码示例:

RefreshTokenInterceptor.java

private StringRedisTemplate stringRedisTemplate;

public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
	this.stringRedisTemplate = stringRedisTemplate;
}

@Override
public boolean preHandle(request,response,) {
	// 进入controller之前处理
	// 1,获取请求头中的token
	String token = request.getHeader("authorization");// 刚才前端代码中token放到了authorization
	if (StrUtil.isBlank(token)) {
		// 空的话直接放行到第二个拦截器
		return true;
	}

	// 2,基于token去获取Redis中的用户
	Map<Object,Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:" + token);

	// 3,判断用户是否存在
	if(userMap.isEmpty()) {
		// 空的话直接放行到第二个拦截器
		return true;
	}

	// 5,将查询到的hash数据转为UserDTO对象(参数1:要转换的map,
	// 参数2:转换成什么bean,参数3:是否忽略异常 false 否)
	UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

	// 6,若存在,保存用户信息到ThreadLocal
	UserHolder.saveUser(userDTO);

	// 7,刷新token的有效期
	stringRedisTemplate.expire("login:token:"+token, 30, TimeUnit.MINUTES);

	// 8,放行
	return true;
}

@Override
public void afterCompletion(request,response,) {
	// 业务执行完毕后,销毁用户信息,避免内存泄露
	UserHolder.removeUser();
}

拦截器2代码示例:

LoginInterceptor.java

public class LoginInterceptor implements HandlerInterceptor {
	@Override
	public boolean preHandle(request,response,) {
		// 判断是否需要拦截(即判断ThreadLocal中是否有用户)
		if (UserHolder.getUser() == null) {
			// 没有,需要拦截,设置状态码
			response.setStatus(401);
			return false;
		}
		// 有用户,则放行
		return true;
	}

	@Override
	public void afterCompletion(request,response,) {
		// 业务执行完毕后,销毁用户信息,避免内存泄露
		UserHolder.removeUser();
	}
}

然后再MvcConfig配置刚才新加的拦截器1,代码示例:

MvcConfig.java

@Configuration
public class MvcConfig implements WebMvcConfigurer {
	// 这个类是用了@Configuration,所以会被spring来构建对象,所以这里可以用依赖注入
	@Resource
	private StringRedisTemplate stringRedisTemplate;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		// 加到LoginInterceptor的构造参函数数中
		// registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns("/user/login");
		// 改为如下,把stringRedisTemplate干掉
		registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/user/login").order(1);
		// 新家的拦截器1(她需要stringRedisTemplate),默认是addPathPatterns("/**")
		registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
	}
}

默认情况下,拦截器的order都是0,所以根据配置的顺序而执行。为了严谨,也可以给每个拦截器设置order,值越小越先执行。

你可能感兴趣的:(Redis,Redis,Redis基础,Redis用户登陆,Redis替代session)