Java秒杀系统及优化---(2)

二、实现登陆功能

  • 数据库设计
  • 明文密码两次MD5处理
  • JSR303参数校验+全局异常处理器
  • 分布式Session

1、数据库设计(借用以往使用过的SQL)

CREATE TABLE `miaosha_user` (
  `id` bigint(20) NOT NULL COMMENT '用户ID,手机号码',
  `nickname` varchar(255) NOT NULL,
  `password` varchar(32) DEFAULT NULL COMMENT 'MD5(MD5(pass明文+固定salt) + salt)',
  `salt` varchar(10) DEFAULT NULL,
  `head` varchar(128) DEFAULT NULL COMMENT '头像,云存储的ID',
  `register_date` datetime DEFAULT NULL COMMENT '注册时间',
  `last_login_date` datetime DEFAULT NULL COMMENT '上次登陆时间',
  `login_count` int(11) DEFAULT '0' COMMENT '登陆次数',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

2、明文密码两次MD5处理

  • 用户端:pass = MD5(明文+固定Salt)
  • 服务端:pass = MD5(用户输入+随机Salt)

因为http在网络上传输数据是通过明文来传输的,也即是说,如果用户登陆时,若输入明文密码,不做任何处理,那么他的明文密码就会在网络上传输,如果其他人截取到数据包,那么他就可以得到你的明文密码,所以要对用户输入的密码做一次MD5,然后再把MD5的密码传输给服务端,这就是第一次MD5。服务端接收到来自客户端经过第一次MD5加密后的密码,并不是直接将其写入到数据库中,而是生成一个随机的Salt,然后跟用户输入的密码进行拼装,再做一次MD5,然后把MD5和Salt同时写入数据库中,这是第二次MD5.。

为什么这么做?

主要是为安全考虑。第一次MD5是为了防止用户的明文密码在网络上传输。

服务端的MD5(第二次MD5)主要是为了防止:假如我们的数据库被盗取,如果只做一次MD5,有一种叫彩虹表,可以反查,他可以根据你的MD5值反推出密码是多少,这样,就有可能盗取数据库之后,通过反查那个表,直接获得密码,所以一定要再做一次MD5,.这是为了双重保险。

2.1)首先要添加MD5的依赖

2.2)然后写一个MD5的工具类,添加主函数,进行简单测试

public class MD5Util {
    /**
     * md5 操作
     * @param src
     * @return
     */
    public static String md5(String src) {
        return DigestUtils.md5Hex(src);
    }

    /**
     * 这里为什么写死?如果不写死,服务端根本不知道这个串是什么东西
     */
    private static final String salt = "1a2b3c4d";

    /**
     * 第一次 md5,提高传输过程中的数据安全性
     * @param inputPass
     * @return
     */
    public static String inputPassFromPass(String inputPass) {
        String str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
        return md5(str);
    }

    /**
     * 第二次 md5,提高数据库中数据的安全性
     * @param formPass
     * @param salt
     * @return
     */
    public static String formPassToDBPass(String formPass, String salt) {
        String str = "" + salt.charAt(0) + salt.charAt(2) + formPass + salt.charAt(5) + salt.charAt(4);
        return md5(str);
    }

    public static String inputPassToDbPass(String input, String saltDB) {
        String formPass = inputPassFromPass(input);
        String dbPass = formPassToDBPass(formPass, saltDB);
        return dbPass;
    }

    public static void main(String[] args) {
        System.out.println(inputPassFromPass("123456"));
        System.out.println(formPassToDBPass(inputPassFromPass("123456"), "1a2b3c4d"));
        System.out.println(inputPassToDbPass("123456", "1a2b3c4d"));
    }
}

2.3)接着在数据库中添加一条记录

2.4)建立一个跟数据库用户表对应的pojo:SecKillUser

public class SecKillUser {
    private Long id;
    private String nickname;
    private String password;
    private String salt;
    private String head;
    private Date registerDate;
    private Date lastLoginDate;
    private Integer loginCount;
...

2.5)然后新建对应的SecKillUserDao

@Mapper
public interface SecKillUserDao {
    @Select("select * from miaosha_user where id = #{id}")
    public SecKillUser getById(@Param("id")long id);
}

2.6)再写对应得service:SecKillUserService

    @Service
    public class SecKillUserService {
        @Autowired
	private SecKillUserDao secKillUserDao;
	public SecKillUser getById(long id) {
		return secKillUserDao.getById(id);
	}
	public CodeMsg login(LoginVo loginVo) {
		if(loginVo == null) {
			return CodeMsg.SERVER_ERROR;
		}
		String mobile = loginVo.getMobile();
		String formPass = loginVo.getPassword();
		//判断手机号是否存在
		SecKillUser user = getById(Long.parseLong(mobile));
		if(user == null) {
			return CodeMsg.MOBILE_NOT_EXIST;
		}
		//验证密码
		String dbPass = user.getPassword();
		String slatDB = user.getSalt();
		String calcPass = MD5Util.formPassToDBPass(formPass, slatDB);
		if(!calcPass.equals(dbPass)) {
			return CodeMsg.PASSWORD_ERROR;
		}
		return CodeMsg.SUCCESS;
	}

    }

2.7)在这个过程中,为了方便,还写了一个VO:

public class LoginVo {
    private String mobile;

    private String password;
    
    public String getMobile() {
        return mobile;
    }
    public void setMobile(String mobile) {
        this.mobile = mobile;
    }
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }
    @Override
    public String toString() {
        return "LoginVo [mobile=" + mobile + ", password=" + password + "]";
    }
}

2.8)补充CodeMsg类的内容

public static CodeMsg SESSION_ERROR = new CodeMsg(500210, "Session不存在或者已经失效");
public static CodeMsg PASSWORD_EMPTY = new CodeMsg(500211, "登录密码不能为空");
public static CodeMsg MOBILE_EMPTY = new CodeMsg(500212, "手机号不能为空");
public static CodeMsg MOBILE_ERROR = new CodeMsg(500213, "手机号格式错误");
public static CodeMsg MOBILE_NOT_EXIST = new CodeMsg(500214, "手机号不存在");
public static CodeMsg PASSWORD_ERROR = new CodeMsg(500215, "密码错误");

2.9)写LoginController:

@Controller
@RequestMapping("/login")
public class LoginController {

    /**
     * 使用slf4j,因为它是一个接口,具体实现可以选择其他,非常方便
     */
    private static Logger log = LoggerFactory.getLogger(LoginController.class);

    @Autowired
    private SecKillUserService userService;

    @GetMapping("/to_login")
    public String toLogin() {
        return "login";
    }

    @PostMapping("/do_login")
    @ResponseBody
    public Result<Boolean> doLogin(LoginVo loginVo) {
        log.info(loginVo.toString());
        //参数校验
        String passinput = loginVo.getPassword();
        String mobile = loginVo.getMobile();
        if (StringUtils.isEmpty(passinput)) {
            return Result.error(CodeMsg.PASSWORD_EMPTY);
        }
        if (StringUtils.isEmpty(mobile)) {
            return Result.error(CodeMsg.MOBILE_EMPTY);
        }
        if (!ValidatorUtil.isMobile(mobile)) {
            return Result.error(CodeMsg.MOBILE_ERROR);
        }
        //登陆
        CodeMsg cm = userService.login(loginVo);
        if (cm.getCode() == 0) {
            return Result.success(true);
        } else {
            return Result.error(cm);
        }
    }
}

2.10)补上一个校验手机号的工具类:ValidatorUtil

2.11)写页面(这里直接使用原有的页面)

使用bootstrap来画页面,jquery-validation做form表单验证,用layer的js做弹框,用md5的js做md5

2.12)启动工程,进行测试。

登陆的功能基本完成!

3、JSR303参数校验+全局异常处理器

为什么要加入JSR303?

因为,在登陆中有参数校验,如果我们还有其他方法,每一个方法的开头都要做参数校验,那么造成大量的冗余代码(各种判空操作…),那么有没有更简单的方式呢?

此时就需要JSR303,现在已经是JSR的一个标准。

3.1)先引入依赖:

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

3.2)下面我们用JSR303来校验,改造我们的登陆代码:

(1)参数前加一个标签:@Valid LoginVo loginVo

@PostMapping("/do_login")
@ResponseBody
public Result<Boolean> doLogin(@Valid LoginVo loginVo) {
    log.info(loginVo.toString());
...

(2)在实体类中,需要加校验的字段上加注解

public class LoginVo {
	@NotNull
	@isMobile
	private String mobile;
	@NotNull
	@Length(min=32)
	private String password;
...

这里学习如何自定义校验器@isMobile

3.3)我们可以仿照@NotNull来定义@isMobile

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = { })
public @interface NotNull {
	String message() default "{javax.validation.constraints.NotNull.message}";
	Class<?>[] groups() default { };
	Class<? extends Payload>[] payload() default { };
	@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
	@Retention(RUNTIME)
	@Documented
	@interface List {
		NotNull[] value();
	}
}

(1)新建一个类

public class IsMobile {
}

(2)仿照@NotNull进行修改

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = { })
public @interface IsMobile {
}

(3)再添加:

public @interface IsMobile {
	//参数默认值,必须有,不可为空
	boolean required() default true;
	//若校验不通过,提示什么信息
	String message() default "手机号码格式有误!!!";
	Class<?>[] groups() default { };
	Class<? extends Payload>[] payload() default { };
}

这样做完是不是就可以了呢?

很明显,即便打上@IsMobile的注解,系统也并不知道如何来判断手机号是不是合法。

(4)还需要写一个类IsMobileValidator,来实现具体验证功能:

public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {

    private boolean required = false;

    @Override
    public void initialize(IsMobile constraintAnnotation) {
        required = constraintAnnotation.required();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(required) {
            return ValidatorUtil.isMobile(value);
        }else {
            //如果不是必须的
            if(StringUtils.isEmpty(value)) {
                return true;
            }else {
                return ValidatorUtil.isMobile(value);
            }
        }
    }
}

(5)修改注解

@Constraint(validatedBy = { })
public @interface IsMobile {

为:

@Constraint(validatedBy = {IsMobileValidator.class })
public @interface IsMobile {

这就是说,遇到注解@IsMobile,就去执行IsMobileValidator类的操作。

(6)修改LoginController:我们使用一个@Valid注解,替代了参数校验,简化了代码

    @PostMapping("/do_login")
    @ResponseBody
    public Result<Boolean> doLogin(@Valid LoginVo loginVo) {
        log.info(loginVo.toString());
        /*
        //参数校验
        String passinput = loginVo.getPassword();
        String mobile = loginVo.getMobile();
        if (StringUtils.isEmpty(passinput)) {
            return Result.error(CodeMsg.PASSWORD_EMPTY);
        }
        if (StringUtils.isEmpty(mobile)) {
            return Result.error(CodeMsg.MOBILE_EMPTY);
        }
        if (!ValidatorUtil.isMobile(mobile)) {
            return Result.error(CodeMsg.MOBILE_ERROR);
        }
        */
        //登陆
        CodeMsg cm = userService.login(loginVo);
        if (cm.getCode() == 0) {
            return Result.success(true);
        } else {
            return Result.error(cm);
        }
    }

(7)运行程序,测试自定义注解是否起作用:

{"timestamp":1547867695605,"status":400,"error":"Bad Request","exception":"org.springframework.validation.BindException","errors":[{"codes":["IsMobile.loginVo.mobile","IsMobile.mobile","IsMobile.java.lang.String","IsMobile"],"arguments":[{"codes":["loginVo.mobile","mobile"],"arguments":null,"defaultMessage":"mobile","code":"mobile"},true],"defaultMessage":"手机号码格式有误!!!","objectName":"loginVo","field":"mobile","rejectedValue":"35036356734","bindingFailure":false,"code":"IsMobile"}],"message":"Validation failed for object='loginVo'. Error count: 1","path":"/login/do_login"}

从以上来看,我们的自定义校验器已经起作用了,那么如何使得提示信息显得更友好呢?

此时就用到异常的拦截,也就是说,我们只要拦截到这样的绑定异常,然后输出一个错误信息,就可以了

3.4)如何自定义一个全局的异常拦截器呢?

(1)新建一个类:GlobalExceptionHandler

@ControllerAdvice  //切面
@ResponseBody    //为了方便输出,使用@ResponseBody
public class GlobalExceptionHandler {
    //这个类就相当于一个controller
    @ExceptionHandler(value=Exception.class)
    public Result<String> exceptionHandler(HttpServletRequest request, Exception e){
        if(e instanceof BindException) {
            BindException ex = (BindException)e;
            //为什么是一个数组,因为参数校验可能有很多错误,而不是一项
            List<ObjectError> errors = ex.getAllErrors();
            //为简单起见,我们只取第一个错误
            ObjectError error = errors.get(0);
            String msg = error.getDefaultMessage();
            return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
        }else {
            //如果不是一个绑定异常,则返回一个通用的服务端异常
            return Result.error(CodeMsg.SERVER_ERROR);
        }
    }
}

(2)在CodeMsg类中添加这个绑定异常

public static CodeMsg BIND_ERROR = new CodeMsg(500101, "参数校验异常:%s");

(3)因为异常绑定错误的错误码中,带有参数%s,所以要写一个带参数的错误码fillArgs,直接添加在CodeMsg类中:

    //可返回一个带参数的错误码
    public CodeMsg fillArgs(Object...args) {
        //原生的code
        int code = this.code;
        //将原生的msg拼接上参数,形成新的msg
        String message = String.format(this.msg, args);
        return new CodeMsg(code, message);
    }

(4)再进行测试:

完成!!!

这样一来,所有的方法都不需要做参数校验了,代码冗余低。

这样是很完美了吗?

当然不是,还有个小问题:

SecKillUserService中的public CodeMsg login(LoginVo loginVo) {...} 这个方法的返回值是CodeMsg,实际来说,我们的正常业务,不应该返回这么个对象,而是应该返回一个确切的表达我们这个方法的含义的一个对象。CodeMsg在这里并不合适。那么我们该怎么做呢?

3.5)我们定义一个全局的异常类GlobalException:

public class GlobalException extends RuntimeException {
    private static final long serialVersionUID = 2376744703700063097L;

    private CodeMsg cm;

    public GlobalException(CodeMsg cm) {
        super(cm.toString());
        this.cm = cm;
    }

    public CodeMsg getCm() {
        return cm;
    }
}
 

修改SecKillUserService中的login:最简单的做法是,出现异常时候直接抛异常。

    public SecKillUser getById(long id) {
        return secKillUserDao.getById(id);
    }

    public boolean login(LoginVo loginVo) {
//	public CodeMsg login(LoginVo loginVo) {
        // TODO Auto-generated method stub
        if(loginVo == null) {
            throw new GlobalException(CodeMsg.SERVER_ERROR);
//			return CodeMsg.SERVER_ERROR;
        }
        String mobile = loginVo.getMobile();
        String formPass = loginVo.getPassword();
        //判断手机号是否存在
        SecKillUser user = getById(Long.parseLong(mobile));
        if(user == null) {
            throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
//			return CodeMsg.MOBILE_NOT_EXIST;
        }
        //验证密码
        String dbPass = user.getPassword();
        String slatDB = user.getSalt();
        String calcPass = MD5Util.formPassToDBPass(formPass, slatDB);
        if(!calcPass.equals(dbPass)) {
            throw new GlobalException(CodeMsg.PASSWORD_ERROR);
//			return CodeMsg.PASSWORD_ERROR;
        }
//		return CodeMsg.SUCCESS;
        return true;
    }
 

在抛出异常之后,我们需要在GlobalExceptionHandler中做一个处理,很简单,。只需要加一个分支就可以。

    public Result<String> exceptionHandler(HttpServletRequest request, Exception e){
        if(e instanceof GlobalException) {
            GlobalException ex = (GlobalException)e;
            return Result.error(ex.getCm());
        }
        else if(e instanceof BindException) {
            //if(e instanceof BindException) {
            BindException ex = (BindException)e;
            //为什么是一个数组,因为参数校验可能有很多错误,而不是一项
            List<ObjectError> errors = ex.getAllErrors();
            //为简单起见,我们只取第一个错误
            ObjectError error = errors.get(0);
            String msg = error.getDefaultMessage();
            return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
        }else {
            //如果不是一个绑定异常,则返回一个通用的服务端异常
            return Result.error(CodeMsg.SERVER_ERROR);
        }
    }

修改LoginController中的doLogin方法

    @PostMapping("/do_login")
    @ResponseBody
    public Result<Boolean> doLogin(@Valid LoginVo loginVo){
        log.info(loginVo.toString());
        //登陆
        //CodeMsg cm = userService.login(loginVo);
        userService.login(loginVo);
        //如果出现异常,则直接抛出了,所有直接返回true就行
        return Result.success(true);
//		if(cm.getCode() == 0) {
//			return Result.success(true);
//		}else {
//			return Result.error(cm);
//		}
    }

现在,代码清爽了很多,现在对这些异常进行测试:

输入不存在的手机号:

输入错误的密码:

OK!

4、分布式Session

这个非常重要,因为秒杀功能是基础,我们实际应用中肯定不会只用一台应用服务器,肯定是多台服务器分布式部署。这个时候就有一个问题,用户的session如何处理,如果我们只是使用应用服务器提供的原生的session,显然没办法满足我们的需求,假如说用户第一个请求落到了第一台服务器上,但是第二个请求没有落到第一个服务器上,而是落到第二台服务器上了,那么用户的session信息全部丢失,针对这种情况,容器现在有很多处理方式,比如有的容器提供了叫做session同步,原生的session同步,也就是说他会自带一台服务器上的session同步到另一台服务器上,这样,在一个集群里面,你访问哪一台服务器,session都可以取到,但是这种方式在实际中应用的并不多,为什么呢?很明显,性能有问题,再一个就是实现起来比较复杂。你可以想象一下,假如说应用服务器是有20台或者更多,相互之间做一个同步,很可怕,那么我们真正使用当中是如何做的?

我们在登陆成功后,service层上,一般来说,会给用户生成一个类似于sessionId的一个东西:token,来标识这个用户,然后写到cookie当中,传递给客户端,客户端在随后的访问当中,都在cookie中上传这个token,服务端拿到token之后,就根据这个token,来取到用户对应的session信息,道理很简单,就跟容器实现原生的session是一样的。容器实现session也是通过生成一个sessionId,然后写到cookie当中,原理很简单,但是如果想不明白,赶紧还是挺复杂的,今天我们就来实现一下。

4.1)第一步,登陆成功之后要生成一个cookie,这里我们使用UUID,所以先创建一个UUID工具类。

public class UUIDUtil {
	public static String uuid() {
		//原生的uuid带‘-’,我们希望去掉它
		return UUID.randomUUID().toString().replace("-", "");
	}
}

我们要把token写入cookie当中,然后传递给客户端,但是我们要标识一下,你这个token对应的是哪一个用户,所以说,我们需要把这个用户信息,写到redis当中。放到第三方,这是常用做法,也是最高效的。

4.2)第二步修改SecKillUserService,添加redisDao

  • 生成token
  • 将token添加到redis
  • 新建cookie
  • 设置cookie的有效期
  • 设置cookie保存目录
  • 将cookie写入response,返回到客户端
    public boolean login(HttpServletResponse response, LoginVo loginVo) {
    //public boolean login(LoginVo loginVo) {
        if(loginVo == null) {
            throw new GlobalException(CodeMsg.SERVER_ERROR);
        }
        String mobile = loginVo.getMobile();
        String formPass = loginVo.getPassword();

        SecKillUser user = getById(Long.parseLong(mobile));
        if(user == null) {
            throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
        }
        //验证密码
        String dbPass = user.getPassword();
        String slatDB = user.getSalt();
        String calcPass = MD5Util.formPassToDBPass(formPass, slatDB);
        if(!calcPass.equals(dbPass)) {
            throw new GlobalException(CodeMsg.PASSWORD_ERROR);
        }

        String token = UUIDUtil.uuid();
        redisService.set(SecKillUserKey.token, token, user);
        Cookie cookie = new Cookie(COOKIE_NAME_TOKEN, token);
        //设置Cookie的有效期,为了和session保持一致,就设置成MiaoshaUserKey的有效期
        cookie.setMaxAge(SecKillUserKey.token.expireSeconds());
        //设置保存路径,为网站的根目录
        cookie.setPath("/");
        //这样生成完之后,我们只需要写道response中即可
        response.addCookie(cookie);
//		return CodeMsg.SUCCESS;

        return true;
    }

4.3)第三步,修改LoginController里面的doLogin,添加response参数

    @PostMapping("/do_login")
    @ResponseBody
    public Result<Boolean> doLogin(HttpServletResponse response, @Valid LoginVo loginVo){
        log.info(loginVo.toString());
        //登陆
        userService.login(response, loginVo);
        //如果出现异常,则直接抛出了,所有直接返回true就行
        return Result.success(true);

    }

4.4)登陆成功之后,需要跳转到商品列表页面,先写一个页面goods_list.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>商品列表</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p th:text="'hello:'+${user.nickname}" ></p>
</body>
</html>

4.5)然后再新建一个GoodsController类

@Controller
@RequestMapping("/goods")
public class GoodsController {

    @GetMapping("/to_list")
    public String list() {
        return "goods_list";
    }

}

4.6)测试:

提示登陆成功之后,出现如下:

后台日志:EL1007E: Property or field 'nickname' cannot be found on null

提示没找到 nickname,因为list中 user对象为空,

    @GetMapping("/to_list")
    public String list() {
        return "goods_list";
    }

我们先随便给他new个对象

    @GetMapping("/to_list")
    public String list(Model model) {
        model.addAttribute("user", new SecKillUser() );
        return "goods_list";
    }

我们再测试一下

跳转成功,由于user是新建的,并没有设置属性, 所以打印null

我们打个断点,再来看一下

 

response中存在我们设置的token,Max_Age也不为0,下面我们看看,跳转到商品列表页面时,请求头中是否携带token

OK,在跳转页面时,请求头中携带了我们之前设置好的token

4.7)既然他请求时可以带上cookie,那我们就能取到cookie

有时候为了兼容手机端,很多手机客户端,并不会把token放到cookie里面,传给我们的服务端,他们有时候直接放到参数里面来传,为了兼容这种情况,再从request中取一下cookie,在此,我们还要设置优先级,先在request中token,如果取不到,再去cookie中取token。有可能通过request传递,也有可能通过cookie传递,所以属性设置required=false

GoodsController中的list方法如下:

    public String list(Model model,
                       @CookieValue(value=SecKillUserService.COOKIE_NAME_TOKEN, required=false)String cookieToken,
                       @RequestParam(value=SecKillUserService.COOKIE_NAME_TOKEN, required=false) String paramToken) {
        if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
            return "login";
        }
        String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
        SecKillUser user = userService.getByToken(token);
        model.addAttribute("user", user);
        return "goods_list";
    }

SecKillUserService类中添加getByToken()方法:

    public SecKillUser getByToken(String token) {
        //public 的方法,一定要进行参数校验
        if(StringUtils.isEmpty(token)) {
            return null;
        }
        return redisService.get(SecKillUserKey.token, token, SecKillUser.class);
    }

4.8)测试:

好的,我们获得了用户,也就是成功取到session,成功实现把一个token映射成一个用户,这个分布式session,session并没有存到容器里面来,而是存到我们一个单独的缓存中,用一个redis单独管理我们的session,这就是所谓的分布式session。

以上实现了:根据服务端,把一个token,写入到cookie当中,然后呢,客户端在随后的访问当中,携带这个cookie,服务端就通过这个cookie里面的token,找到token对应的用户,但是这里还有个小问题,我们容器当中的session是这样的,如果说你在10点钟访问了,假如说你的session的有效期是30分钟,那么你的有效期应该到10点半,但如果10点10分又访问了,你的有效期应该到10点40,有效期是最后一次访问+有效期时间,我们如何来实现这个功能呢???

其实也很简单,我们在通过token来获取用户信息时,先不着急返回,先延长一下有效期,再返回。所谓延长,就是重新把缓存中的值进行设置,然后重新生成一个cookie,写出去就OK。

4.9)下面,先修改SecKillUserService:将添加cookie的操作抽取成方法,方便复用

        addCookie(response, user);

        return true;
    }

    private void addCookie(HttpServletResponse response, SecKillUser user) {
        String token = UUIDUtil.uuid();
        redisService.set(SecKillUserKey.token, token, user);
        Cookie cookie = new Cookie(COOKIE_NAME_TOKEN, token);
        //设置Cookie的有效期,为了和session保持一致,就设置成MiaoshaUserKey的有效期
        cookie.setMaxAge(SecKillUserKey.token.expireSeconds());
        //设置保存路径,为网站的根目录
        cookie.setPath("/");
        //这样生成完之后,我们只需要写道response中即可
        response.addCookie(cookie);
    }

4.10)修改SecKillUserService类中的getByToken方法,延长有效期:

    public SecKillUser getByToken(HttpServletResponse response, String token){
    //public SecKillUser getByToken(String token) {
        //public 的方法,一定要进行参数校验
        if(StringUtils.isEmpty(token)) {
            return null;
        }
        //return redisService.get(SecKillUserKey.token, token, SecKillUser.class);
        SecKillUser user = redisService.get(SecKillUserKey.token, token, SecKillUser.class);
        if(user != null) {
            //延长有效期
            addCookie(response, user);
        }
        return user;
    }

4.11)到目前为止,分布式session的功能已经实现,但是代码写的不够优雅。

例如:GoodsController中除了商品列表还有商品详情页,商品详情页,我们仍然需要判断用户是不是已经登陆,所以说,为了获取用户信息,我们想啊,我们最终要实现什么功能?

假如说,我们在方法参数上,直接把user注入进来:

    @GetMapping("/to_list")
    public String list(HttpServletResponse response, Model model,
//                       @CookieValue(value=SecKillUserService.COOKIE_NAME_TOKEN, required=false)String cookieToken,
//                       @RequestParam(value=SecKillUserService.COOKIE_NAME_TOKEN, required=false) String paramToken,
                       SecKillUser user) {
//        if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
//            return "login";
//        }
//        String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
//        SecKillUser user = userService.getByToken(response, token);
        model.addAttribute("user", user);
        return "goods_list";
    }

是不是会非常方便,如何实现这个功能呢?

很简单,第一步,我们是需要相当于实现一个叫ArgumentResolver

先新建一个WebConfig类

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
    /*
     * springmvc的controller中可以带很多参数,比如request,response,model。。。
     * 那这些都参数是怎么来的呢?值是谁给他赋的呢,就是addArgumentResolvers来赋值的,
     * 框架回调这个方法,往我们的controller方法中的参数进行赋值。
     * (non-Javadoc)
     * @see org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter#addArgumentResolvers(java.util.List)
     */
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        // TODO Auto-generated method stub
        super.addArgumentResolvers(argumentResolvers);
        argumentResolvers.add(//这里暂且空着,后面会补上);
    }
}

这里是要添加一个ArgumentResolvers,于是我们为

@GetMapping("/to_list")
public String list(HttpServletResponse response, Model model,SecKillUser user) {...}

中的user对象新建一个类,用来解析user对象

@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
    @Autowired
    private SecKillUserService userService;


    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        //获取参数类型
        Class<?> clazz = parameter.getParameterType();
        //SecKillUser,则返回true,如果不是返回false
        return clazz == SecKillUser.class;

    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        //获取http的request/response
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
        //获取token
        String paramToken = request.getParameter(SecKillUserService.COOKIE_NAME_TOKEN);
        String cookieToken = getCookieValue(request, SecKillUserService.COOKIE_NAME_TOKEN);
        if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
            return null;
        }
        String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
        return userService.getByToken(response, token);
    }

    private String getCookieValue(HttpServletRequest request, String cookieName) {
        //获得所有cookie
        Cookie[]  cookies = request.getCookies();
        for(Cookie cookie : cookies) {
            //遍历,如果和我们需要的cookie同名,则取值
            if(cookie.getName().equals(cookieName)) {
                return cookie.getValue();
            }
        }
        return null;
    }

}

然后把我们写好的UserArgumentResolver注册到我们的WebConfig中

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
    @Autowired
    private UserArgumentResolver userArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        super.addArgumentResolvers(argumentResolvers);
        argumentResolvers.add(userArgumentResolver);
    }
}

而此时GoodsController中的list方法变为

    @GetMapping("/to_list")
    public String list(Model model, SecKillUser user) {
        model.addAttribute("user", user);
        return "goods_list";
    }

十分清爽!

下面进行测试:

成功!!!

这样的话,就算我们获取session的方式改变了,只需要在UserArgumentResolver类下的resolveArgument方法下进行修改就可以了,其他的业务代码,不需要进行任何调整。

4.12)还有个小问题需要修改:

我们写了一个延长时间的函数,在SecKillUserService类中

    private void addCookie(HttpServletResponse response, SecKillUser user) {
        String token = UUIDUtil.uuid();
        redisService.set(SecKillUserKey.token, token, user);
        Cookie cookie = new Cookie(COOKIE_NAME_TOKEN, token);
        //设置Cookie的有效期,为了和session保持一致,就设置成MiaoshaUserKey的有效期
        cookie.setMaxAge(SecKillUserKey.token.expireSeconds());
        //设置保存路径,为网站的根目录
        cookie.setPath("/");
        //这样生成完之后,我们只需要写道response中即可
        response.addCookie(cookie);
    }

这里每次都会生成一个新的UUID,其实没有必要,我们只要第一次生成,以后更新它就是

	private void addCookie(HttpServletResponse response, String token, MiaoshaUser user) {
		//这里不用每次都生成新的token,只要第一次生成,后面更新就行
		//String token = UUIDUtil.uuid();
		redisService.set(MiaoshaUserKey.token, token, user);
		Cookie cookie = new Cookie(COOKIE_NAME_TOKEN, token);
		//设置Cookie的有效期,为了和session保持一致,就设置成MiaoshaUserKey的有效期
		cookie.setMaxAge(MiaoshaUserKey.token.expireSeconds());
		//设置保存路径,为网站的根目录
		cookie.setPath("/");
		//这样生成完之后,我们只需要写道response中即可
		response.addCookie(cookie);
	}

顺便把前面的代码也改一下:

        String token = UUIDUtil.uuid();
        addCookie(response, token, user);
//        addCookie(response, user);
        return true;
    }

好了,这就基本完成了。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值