Java秒杀系统实践学习——实现用户登录
实现用户登录步骤:
数据库设计的字段主要是用户的手机号码、昵称、密码、salt、头像、注册时间、上次登录时间、登陆次数,详情如下:
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 COMMENT='秒杀用户表';
SET FOREIGN_KEY_CHECKS = 1;
主要是为了安全,防止密码泄露;第一次MD5是防止用户的明文密码在网络上传输,被别人抓包获取到密码;第二次的MD5是防止在数据库被盗后密码反向破解,保证密码不会泄露。
大体执行过程:用户输入登录信息提交登录后,会在前台实现第一次MD5加密,然后会将数据库中存储随的机salt拿出来和密码拼接进行第二次MD5加密,之后会去判断是否和数据库里二次MD5密码相同。
引入依赖:
commons-codec
commons-codec
org.apache.commons
commons-lang3
3.6
创建工具包写Md5工具类(Md5Util):
第一次MD5(明文+salt):采用明文+salt的的方法进步保证密码安全性,salt是固定的方便服务器的操作,代码如下所示:
private static final String SALT = "1a2b3c4d";
public static String md5(String src){
return DigestUtils.md5Hex(src);
}
//第一次MD5加密:明文+salt的混合拼接
public static String inputPassToFormPass(String inputPass){
String src = "" + SALT.charAt(0) + SALT.charAt(2)+ inputPass + SALT.charAt(5)+ SALT.charAt(4);
return md5(src);//如明文密码123456经过这个加密,被别人截获解读的结果会是12123456c3
}
第二次MD5(用户输入+随机salt):采用用户输入密码+随机的salt,salt是写入数据库的,代码如下:
//第二次MDS加密:用户输入密码+随机salt
public static String formPassToDbPass(String formPass, String salt){
String src = "" + salt.charAt(0) + salt.charAt(2)+ formPass + salt.charAt(5)+ salt.charAt(4);
return md5(src);//数据库被盗后解读的密码时一次明文加密的不是真正的密码
}
直接把明文两次MD5存入数据:
//直接将用户密码转换成数据库里密码
public static String inputPassToDbPass(String inputPass, String salt){
String formPass = inputPassToFormPass(inputPass);
String dbPass = formPassToDbPass(formPass, salt);
return dbPass;
}
具体实现:
在Controller包中创建LoginController类,主要包含两个方法:
to_login(通过thymeleat模板返回到src/main/resources/templates/login.html显示登录界面)
do_login(负责对提交的数据进行参数比较的操作);
login.html主要代码:
do_login()中的参数比较主要是判断手机号不为空和密码不为空(StringUtils.isEmpty()方法判断)
手机号格式是否正确(建立ValidatorUtil类来判断)代码详情如下:
do_login中的参数校验代码:
if(StringUtils.isEmpty(mobile)){
return CodeMsg.MOBILE_EMPTY;
}
if(StringUtils.isEmpty(password)){
return CodeMsg.PASSWORD_EMPTY;
}
if(!ValidatorUtil.isMobile(mobile)){
return CodeMsg.MOBILE_ERROR;
}
验证手机格式ValidatorUtil类:
public class ValidatorUtil {
private static Pattern MOBILE_PATTERN = Pattern.compile("1\\d{10}");
public static boolean isMobile(String mobile){
if(StringUtils.isEmpty(mobile)){
return false;
}
Matcher matcher = MOBILE_PATTERN.matcher(mobile);
return matcher.matches();
}
}
在MiaoshaUserService中判断判断手机号是否为空和验证密码
在LoginVo创建对应的成员变量,在MiaoshaUserDao中写Mapper通过注解的方式用sql语句来查mobile
优化代码,第一个优化将LoginController中do_login()的参数校验通过JSR303参数校验用注解的方式来实现。第二个优化是定义全局异常处理器将异常信息友好的显示给用户以及修改业务逻辑方法让其可以返回表达业务方法含义。
引入依赖:
org.mybatis.spring.boot
mybatis-spring-boot-validation
要实现登录,在要验证的参数前面加@valid注解也就是
@RequestMapping("/do_login")
@ResponseBody
public Result doLogin(@Valid LoginVo loginVo) {
log.info(loginVo.toString());
miaoshaUserService.login(loginVo);
return Result.success(CodeMsg.SUCCESS);
然后需要验证的变量上加注解验证不为空、验证长度和手机号格式
public class LoginVo {
@NotNull
@IsMobile
private String mobile;
@NotNull
@Length(min=32)
private String password;
验证手机号格式需要自定义一个验证器@IsMobile(参考注解@NotNull),创建IsMobile和IsMobileValidator类,代码如下:
IsMobile类:
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = IsMobileValidator.class )
public @interface IsMobile {
boolean required() default true;
String message() default "手机号码格式不正确";
Class>[] groups() default { };
Class extends Payload>[] payload() default { };
}
IsMobileValidator类:
public class IsMobileValidator implements ConstraintValidator {
private boolean required;
@Override
public void initialize(IsMobile isMobile) {
required = isMobile.required();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (!required && StringUtils.isEmpty(value)) {
return true;
}
return ValidatorUtil.isMobile(value);
}
}
为了将异常信息友好的显示在浏览器页面,定义GlobalExceptionHandler.类,添加exceptionHandler方法,通过@ExceptionHandler(value = Exception.class) 拦截所有的异常,方法体内先拦截绑定异常,返回具体错误和CodeMsg拼接完后返回,拼接调用接下来的CodeMsg类中的fillArgs方法,其他异常返回系统错误。
代码如下:
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(value = Exception.class)
public Result handleException(HttpServletRequest request, Exception ex){
ex.printStackTrace();
if(ex instanceof GlobalException){
GlobalException gex = (GlobalException)ex;
return Result.error(gex.getCm());
} else if(ex instanceof BindException){
BindException bex = (BindException)ex;
String message = bex.getAllErrors().get(0).getDefaultMessage();
return Result.error(CodeMsg.BIND_ERROR.fillArgs(message));
} else {
return Result.error(CodeMsg.SERVER_ERROR);
}
}
}
在MiaoshaUserServic类中login方法的返回值为CodeMsg类型,但是应该返回表达业务方法含义的方法,而不应该是CodeMsg类型。可以通过定义全局异常类GlobalException 进一步优化,将异常直接抛出去,交给异常处理器处理。
在GlobalException类中封装CondeMsg类型变量,供抛出时实例化使用;在GlobalExceptionHandler类修改exceptionHandler方法,增加处理GlobalException异常的逻辑。
public class GlobalException extends RuntimeException{
private static final long serialVersionUID = 1L;
private CodeMsg cm;
public GlobalException(CodeMsg cm){
this.cm = cm;
}
public CodeMsg getCm() {
return cm;
}
public static long getSerialVersionUID() {
return serialVersionUID;
}
public void setCm(CodeMsg cm) {
this.cm = cm;
}
}
在MiaoshaUserService类中修改login方法返回类型为boolean,方法体内的错误直接通过实例化GlobalException类将异常抛出去;LoginController中修改doLogin方法,优化登录逻辑。
优化后的MiaoshaUserService:
public boolean login(LoginVo loginVo){
if(loginVo == null){
//return CodeMsg.SERVER_ERROR;
throw new GlobalException(CodeMsg.SERVER_ERROR);
}
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
MiaoshaUser user = miaoshaUserDao.getById(Long.parseLong(mobile));
if(user == null){
//return CodeMsg.MOBILE_NOT_EXIST;
throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
}
String salt = user.getSalt();
String dbPass = user.getPassword();
String md5Pass = Md5Util.formPassToDbPass(password, salt);
if(!dbPass.equals(md5Pass)){
//return CodeMsg.PASSWORD_ERROR;
throw new GlobalException(CodeMsg.PASSWORD_ERROR);
}
return true;
优化后的登录逻辑:
@Controller
@RequestMapping("/login")
public class LoginController {
private static Logger log = LoggerFactory.getLogger(LoginController.class);
@Autowired
UserService userService;
@Autowired
MiaoshaUserService miaoshaUserService;
@RequestMapping("/to_login")
public String toLogin(){
return "login";
}
@RequestMapping("/do_login")
@ResponseBody
public Result doLogin(HttpServletResponse response, @Valid LoginVo loginVo) {
log.info(loginVo.toString());
//登录
miaoshaUserService.login(response,loginVo);
return Result.success(true);
}
分布式Session实现是将session单独放到一个缓存中,通过redis来管理。
思路:登陆成功后,通过UUIDUtil为用户生成token标识用户,写到cookie中,传递给客户端,客户端每次都上传这个 token,服务器端根据token获取到用户信息。
定义工具类uuid:
public class UUIDUtil {
public static String uuid(){
return UUID.randomUUID().toString().replace("-", "");
}
}
在MiaoshaUserService的login方法中添加:
//生成cookie
String token = UUIDUtil.uuid();
redisSevice.set(MiaoshaUserKey.token, token, user);
Cookie cookie = new Cookie(COOKIE_TOKEN_NAME, token);
cookie.setMaxAge(MiaoshaUserKey.token.expireSecconds());
cookie.setPath("/");
response.addCookie(cookie);
return true;
在MiaoshaUserService添加getByToke()获取user对象,通过addCookie()延长有效期
public MiaoshaUser getByToke(String token,HttpServletResponse response) {
if(StringUtils.isEmpty(token)){
return null;
}
//延长有效期
MiaoshaUser user = redisSevice.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
if(user != null){
addCookie(token, response, user);
}
return user;
}
// //延长有效期的实现,向缓存重新生成一个新cookie
private void addCookie(String token, HttpServletResponse response, MiaoshaUser user){
redisSevice.set(MiaoshaUserKey.token, token, user);
Cookie cookie = new Cookie(COOKIE_TOKEN_NAME, token);
cookie.setMaxAge(MiaoshaUserKey.token.expireSecconds());
cookie.setPath("/");
response.addCookie(cookie);
}
登录成功后跳转到商品列表,对应的Controller(根据上传cookie来获取用户信息)如下:
@Controller
@RequestMapping("/goods")
public class GoodsController {
private static Logger log = LoggerFactory.getLogger(GoodsController.class);
@Autowired
private MiaoshaUserService miaoshaUserService;
@RequestMapping("/to_list")
public String toList(Model model,
@CookieValue(name = MiaoshaUserService.COOKIE_TOKEN_NAME, required = false) String cookieToken,
// @RequestParam 是为了兼容默写手机端会把cookie信息放入请求参数中
@RequestParam(name = MiaoshaUserService.COOKIE_TOKEN_NAME, required = false) String paramToken) {
if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)){
return "/login/to_login";
}
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
MiaoshaUser miaoshaUser = miaoshaUserService.getByToke(token);
model.addAttribute("user", miaoshaUser);
return "goods_list";
}
}
代码优化:
登录后,用户的商品信息都需要在GoodsController中获取上传的cookie,进行参数校验,优化这一部分,在GoodsController的参数传递中直接传入user对象,可通过WebMvcConfigurerAdapter的addArgumentResolvers进行实现。
WebConfig类继承WebMvcConfigurerAdapter(重写addArgumentResolvers()):
package com.example.demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import java.util.List;
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter{
@Autowired
private UserArgumentResolver userArgumentResolver;
@Override
public void addArgumentResolvers(List argumentResolvers) {
argumentResolvers.add(userArgumentResolver);
}
}
UserArgumentResolver类(实现接口HandlerMethodArgumentResolver,解析user对象):
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver{
@Autowired
private MiaoshaUserService miaoshaUserService;
@Override
public boolean supportsParameter(MethodParameter parameter) {
Class> clazz = parameter.getParameterType();
return clazz == MiaoshaUser.class;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
String paramToken = request.getParameter(miaoshaUserService.COOKIE_TOKEN_NAME);
String cookieToken = getCookieValue(request, miaoshaUserService.COOKIE_TOKEN_NAME);
if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)){
return null;
}
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
return miaoshaUserService.getByToke(token, response);
}
private String getCookieValue(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if(cookies != null){
for(Cookie cookie : cookies){
if(cookie.getName().equals(cookieName)){
return cookie.getValue();
}
}
}
return null;
}
}
优化后GoodsController:
@Controller
@RequestMapping("/goods")
public class GoodsController {
private static Logger log = LoggerFactory.getLogger(GoodsController.class);
@Autowired
private MiaoshaUserService miaoshaUserService;
@Autowired
RedisSevice redisSevice;
@RequestMapping("/to_list")
public String toLogin(Model model,MiaoshaUser user){
model.addAttribute("user", user);
return "goods_list";
}
}