# (一)前言 {#一-前言}
在单体项目中,通过cookies和session就可以实现人员的认证。但是随着现在项目朝着分布式的方向发展,单体项目中的session认证方式似乎变得不可用了。以集群项目为例,我们会启动多个服务,而session是存在于执行当前服务的JVM中,所以访问第一个节点的认证信息是无法在第二个节点中使用的。
因此这篇文章就带你来聊聊分布式Session的处理方式。
# (二)单体Session认证项目 {#二-单体session认证项目}
我还是会通过一个项目来介绍今天的内容,为了节省篇幅,那些简单的代码我就用文字代替了,核心代码都会放上来,如果你在运行过程中遇到问题需要所有代码,请评论区回复。
首先我先简单搭建一个单体环境下的人员登陆认证系统。
# 2.1 新建一个SpringBoot项目 {#_2-1-新建一个springboot项目}
新建一个SpringBoot项目,把相关的依赖引入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--mybatis数据库相关依赖-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
在application.yml中配置服务端口,数据库连接方式以及mybatis的一些路径配置
server:
port: 8189
spring:
datasource:
url: jdbc:mysql://localhost:3306/mycoding?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
type-aliases-package:
mapper-locations: classpath:mapper/*.xml
# 2.2 创建实体类 {#_2-2-创建实体类}
这里创建一个需要用到的用户实体类User:
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class User implements Serializable {
private String username;
private String password;
private String levelId;
private String nickname;
private String phone;
private int status;
private Timestamp createTime;
}
顺便附带上用户表的数据库生成语句:
CREATE TABLE `user` (
`id` int(40) NOT NULL AUTO_INCREMENT,
`level_id` int(4) NOT NULL,
`username` varchar(100) NOT NULL,
`password` varchar(100) NOT NULL,
`nickname` varchar(100) NOT NULL,
`phone` varchar(100) NOT NULL,
`status` int(4) NOT NULL DEFAULT '1',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
# 2.3 编写登陆逻辑 {#_2-3-编写登陆逻辑}
首先来理一下这层逻辑,前端通过post请求传给后端用户名和密码,首先判断该用户的用户名和密码是否和数据库中的匹配,如果匹配的话,将当前用户信息塞入到session中,返回登陆成功的结果,否则就返回登陆失败。
首先我们写一个BaseController获取基本的request和response信息
public class BaseController {
public HttpServletRequest getRequest(){
return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
}
public HttpServletResponse getResponse(){
return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getResponse();
}
public HttpSession getHttpSession(){
return getRequest().getSession();
}
}
编写UserController继承BaseController,在这里实现登陆逻辑。
@RestController
@RequestMapping("/sso")
public class UserController extends BaseController {
@Autowired
private UserService userService;
@PostMapping("/login")
public CommonResult login(@RequestParam String username,@RequestParam String password){
//在数据库中判断用户是否存在
User user = userService.login(username, password);
//如果存在
if (user!=null){
getHttpSession().setAttribute("user",user);
return new CommonResult(ResponseCode.SUCCESS.getCode(),ResponseCode.SUCCESS.getMsg(),username+"登陆成功");
}
return new CommonResult(ResponseCode.USER_NOT_EXISTS.getCode(),ResponseCode.USER_NOT_EXISTS.getMsg(),"");
}
}
# 2.4 拦截器拦截未登录状态 {#_2-4-拦截器拦截未登录状态}
如何判断用户有没有登陆呢?通过拦截器就可以,我们需要拦截除了/sso下面的所有请求,新建一个配置类IntercepterConfiguration,拦截除了/sso下的所有请求。
@Configuration
public class IntercepterConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
List list=new ArrayList();
list.add("/sso/**");
registry.addInterceptor(authInterceptorHandler())
.addPathPatterns("/**")
.excludePathPatterns(list);
}
@Bean
public AuthInterceptorHandler authInterceptorHandler(){
return new AuthInterceptorHandler();
}
}
有了拦截器后我们还需要对拦截的请求进行处理,处理方式就是验证是否可以找到当前session,如果存在session就放行,说明已经登陆了,否则就给一个未登录的信息。
@Slf4j
public class AuthInterceptorHandler implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("进入拦截器");
if (!ObjectUtils.isEmpty(request.getSession().getAttribute("user"))){
return true;
}
response.setHeader("Content-Type","application/json");
response.setCharacterEncoding("UTF-8");
String result = new ObjectMapper().writeValueAsString(new CommonResult(ResponseCode.NEED_LOGIN.getCode(), ResponseCode.NEED_LOGIN.getMsg(), ""));
response.getWriter().println(result);
return false;
}
}
# 2.5 验证 {#_2-5-验证}
为了测试新建一个IndexController,从session中获取用户名并返回。
@RestController
public class IndexController extends BaseController{
@RequestMapping(value = "/index",method = RequestMethod.GET)
public CommonResult index(){
User user = (User) getHttpSession().getAttribute("user");
return new CommonResult(ResponseCode.SUCCESS.getCode(),ResponseCode.SUCCESS.getMsg(),user.getUsername());
}
}
启动项目,在postman中直接访问localhost:端口/index:
因为未登录,被拦截了,接下来先登陆:
登陆成功后,再访问localhost:端口/index:
至此,一个单体的登陆认证服务其实已经完成了。
# (三)如果项目变成集群部署呢? {#三-如果项目变成集群部署呢}
当用户使用量慢慢变多,发现服务器快撑不住了,于是领导一声令下,做集群!于是模拟了一个集群(两个节点测试一下),Idea配置中勾选允许多程序运行:
然后启动项目,修改server.port端口后再启动项目,此时就启动了两个项目了
我启动了两个项目,分别运行在8188和8189端口上,这个时候需要配nginx负载均衡,让每次请求轮询访问到两个节点。我这里就省略了,手动访问模拟nginx。
这个时候就出现了一个问题,我在8188端口上登陆后,认证成功了,但是一旦请求发给了8189端口,又需要再认证一次。两台服务器还没什么,如果有几十台服务器,那用户就需要登陆几十次。太不合理了。
# (四)分布式session {#四-分布式session}
解决上面这个问题的方法有许多:
1、比如nginx上请求策略改成ip匹配的策略,一个ip只会访问一个节点(一般不会采用)
2、又比如搭建一个统一认证服务,所有的请求先走统一认证(CAS等等)。我目前所做的项目用的是这种CAS的统一认证方式,不过比较繁琐,这里先不介绍。
3、今天介绍一种方便又实用的方式实现分布式Session--SpringSession。
SpringSession的原理很简单,不把session存放在JVM中了,而是存放在一个公共的地方。比如mysql、redis中。显然放在redis中是最高效的。
# 4.1 引入依赖 {#_4-1-引入依赖}
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
因为用到了redis,因此需要把redis的依赖也引入。
# 4.2 配置redis {#_4-2-配置redis}
还是通用的redis配置类,保证传输的序列化,这段通用的,直接复制过去就好了。
@Configuration
public class RedisConfiguration {
//自定义的redistemplate
@Bean(name = "redisTemplate")
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory factory){
//创建一个RedisTemplate对象,为了方便返回key为string,value为Object
RedisTemplate<String,Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
//设置json序列化配置
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer=new
Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper=new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance);
//string的序列化
StringRedisSerializer stringRedisSerializer=new StringRedisSerializer();
//key采用string的序列化方式
template.setKeySerializer(stringRedisSerializer);
//value采用jackson的序列化方式
template.setValueSerializer(jackson2JsonRedisSerializer);
//hashkey采用string的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
//hashvalue采用jackson的序列化方式
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
# 4.3 在配置文件中配置springsession {#_4-3-在配置文件中配置springsession}
在application.yml中增加session存储方式以及redis的ip端口等:
spring:
redis:
host: 192.168.78.128
port: 6379
session:
store-type: redis
# 4.4 开启springsession {#_4-4-开启springsession}
新建一个配置类RedisHttpSessionConfiguration,并设置一个最大存活的时间,这里设置为1小时
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisHttpSessionConfiguration {
}
# 4.5 验证 {#_4-5-验证}
现在只要登陆一次,就可以在两台集群上直接访问对应的index接口了。并且在redis中已经可以看到我们塞入的session了。
# (五)总结 {#五-总结}
以目前的技术来说,有许多方式来实现分布式Session。基本上的思路都是将session数据存放到一个统一的地方。我们下期再见!