电商项目中,在单服务器时,用户登录时将用户信息设置到 session
中,获取用户信息从 session
中获取,退出时从 session
中删除即可。
但在搭建 Tomcat
集群后,就需要考虑 Session
共享问题,可通过单点登录解决方案实现,这里主要有两种方法,一种是通过 Redis + Cookie
自己实现,另一种是借助 Spring Session
框架解决。
Redis+Cookie 实现
单点登录的思路
用户登录:
- 首先验证用户密码是否正确,并返回用户信息;
- 使用
uuid
或session.getId
生成唯一id(token)
,设置到cookie
中,将其写给客户端; - 将用户信息(
user
对象)转换为json
格式; - 以
key=token
,value=(user 的 json 格式)
,写到redis
中,并设置过期时间;
退出登录:
- 用户请求时会携带
cookie
,从cookie
中获取到token
; - 从请求中获取到
cookie
,将其过期时间设置为0
,再写入到响应中,即删除了token
; - 再从
redis
中删除token
;
获取用户信息:
- 从请求携带的
cookie
中获取到token
; - 根据
token
在redis
中查询相应的user
对象的json
串; - 将
json
串转换为user
对象;
Redis 连接池及工具类
由于 token
和 user
对象都会存储在 redis
中,所以这里封装一个 redis
的连接池和工具类。
首先,封装一个 redis
连接池,每次直接从连接池中获取 jedis
实例即可。
public class RedisPool {
private static JedisPool jedisPool;
private static String redisIP = PropertiesUtil.getProperty("redis.ip", "192.168.23.130");
private static Integer redisPort = Integer.parseInt(PropertiesUtil.getProperty("redis.port", "6379"));
// 最大连接数
private static Integer maxTotal = Integer.parseInt(PropertiesUtil.getProperty("redis.max.total", "20"));
// 最大的 idle 状态的 jedis 实例个数
private static Integer maxIdle = Integer.parseInt(PropertiesUtil.getProperty("redis.max.idle", "10"));
// 最小的 idle 状态的 jedis 实例个数
private static Integer minIdle = Integer.parseInt(PropertiesUtil.getProperty("redis.min.idle", "2"));
// 在 borrow 一个 jedis 实例时,是否要进行验证操作
private static Boolean testOnBorrow = Boolean.parseBoolean(PropertiesUtil.getProperty("redis.test.borrow", "true"));
// 在 return 一个 jedis 实例时,是否要进行验证操作
private static Boolean testOnReturn = Boolean.parseBoolean(PropertiesUtil.getProperty("redis.test.return", "true"));
static {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(maxTotal);
config.setMaxIdle(maxIdle);
config.setMinIdle(minIdle);
config.setTestOnBorrow(testOnBorrow);
config.setTestOnReturn(testOnReturn);
jedisPool = new JedisPool(config, redisIP, redisPort, 1000*2);
}
public static Jedis getJedis() {
return jedisPool.getResource();
}
public static void returnJedis(Jedis jedis) {
jedis.close();
}
}
复制代码
然后,再将其封装成一个工具类,基本操作就是从 redis
连接池中获取 jedis
实例,进行 set/get/expire
等操作,然后将其放回到 redis
连接池中。
@Slf4j
public class RedisPoolUtil {
// exTime 以秒为单位
public static Long expire(String key, int exTime) {
Jedis jedis = null;
Long result = null;
try {
jedis = RedisPool.getJedis();
result = jedis.expire(key, exTime);
} catch (Exception e) {
log.error("expire key:{}, error", key, e);
}
RedisPool.returnJedis(jedis);
return result;
}
public static Long del(String key) {
Jedis jedis = null;
Long result = null;
try {
jedis = RedisPool.getJedis();
result = jedis.del(key);
} catch (Exception e) {
log.error("del key:{}, error", key, e);
}
RedisPool.returnJedis(jedis);
return result;
}
public static String get(String key) {
Jedis jedis = null;
String result = null;
try {
jedis = RedisPool.getJedis();
result = jedis.get(key);
} catch (Exception e) {
log.error("get key:{}, error", key, e);
}
RedisPool.returnJedis(jedis);
return result;
}
public static String set(String key, String value) {
Jedis jedis = null;
String result = null;
try {
jedis = RedisPool.getJedis();
result = jedis.set(key, value);
} catch (Exception e) {
log.error("set key:{}, value:{}, error", key, value, e);
}
RedisPool.returnJedis(jedis);
return result;
}
// exTime 以秒为单位
public static String setEx(String key, String value, int exTime) {
Jedis jedis = null;
String result = null;
try {
jedis = RedisPool.getJedis();
result = jedis.setex(key, exTime, value);
} catch (Exception e) {
log.error("setex key:{}, value:{}, error", key, value, e);
}
RedisPool.returnJedis(jedis);
return result;
}
}
复制代码
JsonUtil 工具类
将 user
对象存储在 redis
中,需要转换为 json
格式,从 redis
中获取 user
对象,又需要转换为 user
对象。这里封装一个 json
的工具类。
JsonUtil
工具类主要使用 ObjectMapper
类。
bean
类转换为String
类型,使用writerValueAsString
方法。String
类型转换为bean
类,使用readValue
方法。
@Slf4j
public class JsonUtil {
private static ObjectMapper objectMapper = new ObjectMapper();
static {
// 序列化时将所有字段列入
objectMapper.setSerializationInclusion(JsonSerialize.Inclusion.ALWAYS);
// 取消默认将 DATES 转换为 TIMESTAMPS
objectMapper.configure(SerializationConfig.Feature.WRITE_DATES_AS_TIMESTAMPS, false);
// 忽略空 bean 转 json 的错误
objectMapper.configure(SerializationConfig.Feature.FAIL_ON_EMPTY_BEANS, false);
// 所有日期样式统一
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// 忽略 在 json 字符串中存在,在 java 对象中不存在对应属性的情况
objectMapper.configure(DeserializationConfig.Feature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
public static <T> String obj2Str(T obj) {
if (obj == null) { return null; }
try {
return obj instanceof String ? (String) obj : objectMapper.writeValueAsString(obj);
} catch (Exception e) {
log.warn("Parse Object to String error", e);
return null;
}
}
public static <T> String obj2StrPretty(T obj) {
if (obj == null) { return null; }
try {
return obj instanceof String ? (String) obj :
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj);
} catch (Exception e) {
log.warn("Parse Object to String error", e);
return null;
}
}
public static <T> T str2Obj(String str, Class<T> clazz) {
if (StringUtils.isEmpty(str) || clazz == null) {
return null;
}
try {
return clazz.equals(String.class) ? (T)str : objectMapper.readValue(str, clazz);
} catch (Exception e) {
log.warn("Parse String to Object error", e);
return null;
}
}
public static <T> T str2Obj(String str, TypeReference<T> typeReference) {
if (StringUtils.isEmpty(str) || typeReference == null) {
return null;
}
try {
return typeReference.getType().equals(String.class) ? (T)str : objectMapper.readValue(str, typeReference);
} catch (Exception e) {
log.warn("Parse String to Object error", e);
return null;
}
}
public static <T> T str2Obj(String str, Class<?> collectionClass, Class<?> elementClass) {
JavaType javaType = objectMapper.getTypeFactory().constructParametricType(collectionClass, elementClass);
try {
return objectMapper.readValue(str, javaType);
} catch (Exception e) {
log.warn("Parse String to Object error", e);
return null;
}
}
}
复制代码
CookieUtil 工具类
登录时需要将 token
设置到 cookie
中返回给客户端,退出时需要从 request
中携带的 cookie
中读取 token
,设置过期时间后,又将其设置到 cookie
中返回给客户端,获取用户信息时,获取用户信息时,需要从 request
中携带的 cookie
中读取 token
,在 redis
中查询后获得 user
对象。这里呢,也封装一个 cookie
的工具类。
在 CookieUtil
中:
readLoginToken
方法主要从request
读取Cookie
;writeLoginToken
方法主要设置Cookie
对象加到response
中;delLoginToken
方法主要从request
中读取Cookie
,将其maxAge
设置为0
,再添加到response
中;
@Slf4j
public class CookieUtil {
private static final String COOKIE_DOMAIN = ".happymmall.com";
private static final String COOKIE_NAME = "mmall_login_token";
public static String readLoginToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
log.info("read cookieName:{}, cookieValue:{}", cookie.getName(), cookie.getValue());
if (StringUtils.equals(COOKIE_NAME, cookie.getName())) {
log.info("return cookieName:{}, cookieValue:{}", cookie.getName(), cookie.getValue());
return cookie.getValue();
}
}
}
return null;
}
public static void writeLoginToken(HttpServletResponse response, String token) {
Cookie cookie = new Cookie(COOKIE_NAME, token);
cookie.setDomain(COOKIE_DOMAIN);
cookie.setPath("/");
// 防止脚本攻击
cookie.setHttpOnly(true);
// 单位是秒,如果是 -1,代表永久;
// 如果 MaxAge 不设置,cookie 不会写入硬盘,而是在内存,只在当前页面有效
cookie.setMaxAge(60 * 60 * 24 * 365);
log.info("write cookieName:{}, cookieValue:{}", cookie.getName(), cookie.getValue());
response.addCookie(cookie);
}
public static void delLoginToken(HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (StringUtils.equals(COOKIE_NAME, cookie.getName())) {
cookie.setDomain(COOKIE_DOMAIN);
cookie.setPath("/");
// maxAge 设置为 0,表示将其删除
cookie.setMaxAge(0);
log.info("del cookieName:{}, cookieValue:{}", cookie.getName(), cookie.getValue());
response.addCookie(cookie);
return;
}
}
}
}
}
复制代码
具体业务
登录时验证密码后:
CookieUtil.writeLoginToken(response, session.getId());
RedisShardedPoolUtil.setEx(session.getId(), JsonUtil.obj2Str(serverResponse.getData()), Const.RedisCacheExtime.REDIS_SESSION_EXTIME);
复制代码
退出登录时:
String loginToken = CookieUtil.readLoginToken(request);
CookieUtil.delLoginToken(request, response);
RedisShardedPoolUtil.del(loginToken);
复制代码
获取用户信息时:
String loginToken = CookieUtil.readLoginToken(request);
if (StringUtils.isEmpty(loginToken)) {
return ServerResponse.createByErrorMessage("用户未登录,无法获取当前用户信息");
}
String userJsonStr = RedisShardedPoolUtil.get(loginToken);
User user = JsonUtil.str2Obj(userJsonStr, User.class);
复制代码
SessionExpireFilter 过滤器
另外,在用户登录后,每次操作后,都需要重置 Session
的有效期。可以使用过滤器来实现。
public class SessionExpireFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException { }
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String loginToken = CookieUtil.readLoginToken(httpServletRequest);
if (StringUtils.isNotEmpty(loginToken)) {
String userJsonStr = RedisShardedPoolUtil.get(loginToken);
User user = JsonUtil.str2Obj(userJsonStr, User.class);
if (user != null) {
RedisShardedPoolUtil.expire(loginToken, Const.RedisCacheExtime.REDIS_SESSION_EXTIME);
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
@Override
public void destroy() { }
}
复制代码
还需要在 web.xml
文件中进行配置:
<filter>
<filter-name>sessionExpireFilter</filter-name>
<filter-class>com.mmall.controller.common.SessionExpireFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>sessionExpireFilter</filter-name>
<url-pattern>*.do</url-pattern>
</filter-mapping>
复制代码
此方式的缺陷
redis + cookie
方式实现的单点登录对代码侵入性比较大;- 客户端必须启用
cookie
,而有些浏览器不支持cookie
; Cookie
设置domain
时必须统一,服务器也必须统一域名方式;
Spring Session 实现
Spring Session
是 Spring
的项目之一,它提供了创建和管理 Server
HTTPSession
的方案。并提供了集群 Session
功能,默认采用外置的 Redis
来存储 Session
数据,以此来解决 Session
共享的问题。
Spring Session
可以无侵入式地解决 Session
共享问题,但是不能进行分片。
Spring Session 项目集成
1、引入 Spring Session pom
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>1.2.2.RELEASE</version>
</dependency>
复制代码
2、配置 DelegatingFilterProxy
<filter>
<filter-name>springSessionRepositoryFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSessionRepositoryFilter</filter-name>
<url-pattern>*.do</url-pattern>
</filter-mapping>
复制代码
3、配置 RedisHttpSessionConfiguration
<bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
<property name="maxInactiveIntervalInSeconds" value="1800" />
</bean>
复制代码
4、配置 JedisPoolConfig
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
<property name="maxTotal" value="20" />
</bean>
复制代码
5、配置 JedisSessionFactory
<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" >
<property name="hostName" value="192.168.23.130" />
<property name="port" value="6379" />
<property name="database" value="0" />
<property name="poolConfig" ref="jedisPoolConfig" />
</bean>
复制代码
6、配置 DefaultCookieSerializer
<bean id="defaultCookieSerializer" class="org.springframework.session.web.http.DefaultCookieSerializer">
<property name="cookieName" value="SESSION_NAME" />
<property name="domainName" value=".happymmall.com" />
<property name="useHttpOnlyCookie" value="true" />
<property name="cookiePath" value="/" />
<property name="cookieMaxAge" value="31536000" />
</bean>
复制代码
业务代码
用户登录时:
session.setAttribute(Const.CURRENT_USER, response.getData());
复制代码
退出登录时:
session.removeAttribute(Const.CURRENT_USER);
复制代码
获得用户信息时:
User user = (User) session.getAttribute(Const.CURRENT_USER);
复制代码