1.课程介绍
1.1技术点介绍
1.2 课程介绍
2.学习目标
3.如何设计一个秒杀系统
秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端”读“数据,或者让他们读更少的数据;并发写的处理原则也一样,他要求我们在数据库层面独立出来一个库,做特殊的处理,另外,我们还要针对系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
秒杀的整体架构可以概括为”稳,准,快“几个关键字。
所谓”稳“就是整个系统架构要满足高可用,流量符合预期时肯定要稳,就是超出预期也同样不能掉链子,保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这是最基本的前提。
”准“就是秒杀10台iPhone,那就只能成交10台,多一台少一台都不行。一旦库存不对,那平台就要承担损失,所以”准“就是要求保证数据的一致性。
”快“是说系统的性能足够高,否则你怎么支撑这么大的流量?不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。
所以从技术角度”稳,准,快“,就对应了架构上的高可用,一致性和高性能的要求
- 高性能 秒杀设计大量的并发读和并发写,因此支持高并发访问这点非常关键,对应的方案比如动静分离方案,热点的发现与隔离,请求的肖峰与分层过滤,服务端的极致优化。
- 一致性 秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为”拍下减库存“”付款减库存“以及预扣等几种,在大并发更新的过程中都要保证数据的准确性,其难度可想而知
- 高可用 现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,还要设计PlanB来兜底,以便在最坏情况发生时仍然能够从容应对。
4.项目搭建
4.1 pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.xxx</groupId>
<artifactId>seckill-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>seckill-demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- thymeleaf模板-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- web框架-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- mybatis-plus插件-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<!-- lombok插件-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
4.2 配置文件
spring:
#thymeleaf配置
thymeleaf:
#关闭缓存
cache: false
#数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql:///seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username:
password:
hikari:
#连接池名
pool-name: DataHikariCP
#最小空闲连接数
minimum-idle: 5
# 空闲连接存活最大时间,默认600000(10分钟)
idle-timeout: 1800000
#最大连接数,默认10
maximum-pool-size: 10
#从连接池返回的连接自动提交
auto-commit: true
#连接最大存活时间,0表示永久存活,默认1800000(30分钟)
max-lifetime: 1800000
#连接超时时间,默认30000(30秒)
connection-timeout: 30000
#测试连接是否可用的查询语句
connection-test-query: SELECT 1
#Mybatis-plus配置
mybatis-plus:
#配置Mapper.xml映射文件
mapper-locations: classpath*:/mapper/*Mapper.xml
#配置MyBatis数据返回类型别名(默认别名是类名)
type-aliases-package: com.xxx.seckill.pojo
#MyBatis SQL打印(方法接口所在的包,不是Mapper.xml所在的包)
logging:
level:
com.xxx.seckill.mapper: debug
4.3 启动类
package com.xxx.seckill;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.xxx.seckill.pojo")
public class SeckillDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SeckillDemoApplication.class, args);
}
}
4.4 测试接口
package com.xxx.seckill.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* 测试
*/
@Controller
@RequestMapping("/demo")
public class DemoController {
/**
* 功能描述:测试页面跳转
* @return
*/
@RequestMapping("/hello")
public String hello(Model model){
model.addAttribute("name", "xxxx");
return "hello";
}
}
4.5 测试页面
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>测试</title>
</head>
<body>
<p th:text="'hello'+${name}"></p>
</body>
</html>
5 分布式会话
5.1 实现登录功能
5.1.1 两次MD5加密
5.1.1.1 为什么要两次MD5加密?
第一次MD5加密因为客户端传输给服务器,为了使明文不爆露在网络传输过程中。
服务器接收到客户端传过来的MD5加密的密码第二次MD5加密,将二次加密过后的密码和盐存入到数据库中,即便数据库中的数据泄露,能拿到数据库中的二次加密后的密码,也无法获取到用户的明文密码。
5.1.1.2 MD5加密的依赖
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.7</version>
</dependency>
5.1.1.3 MD5Util实现
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.stereotype.Component;
/**
* MD5工具类
*/
@Component
public class MD5Util {
public static String md5(String src){
//DigestUtils生成MD5密码
return DigestUtils.md5Hex(src);
}
private static final String salt = "1a2b3c4d";
/**
* 对输入的密码进行第一次加密
* @param inputPass
* @return
*/
public static String inputPassToFromPass(String inputPass){
//对密码进行混淆
String str = salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
return md5(str);
}
/**
* 二次加密:对客户端传入到服务器的密码进行二次加密。
* @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(salt);
}
/**
* 对明文密码进行两次加密,第二次加密指定盐,返回加密后的密文。
* @param inputPass
* @param salt
* @return
*/
public static String inputPassToDBPass(String inputPass, String salt){
String fromPass = inputPassToFromPass(inputPass);
String dbPass = formPassToDBPass(fromPass, salt);
return dbPass;
}
}
5.1.2 逆向工程
使用MyBatis-Plus插件协助生成
MyBatis-Plus官方网站:MyBatis-Plus
5.1.2.1 新建项目添加依赖
<!-- mybatis-plus依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<!-- mybatis-plus-generator代码生成器依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.4.1</version>
</dependency>
<!-- freemarker模板引擎依赖-->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.28</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
<scope>provided</scope>
</dependency>
5.1.2.2 官网测试代码
package com.xxx.generator;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
/**
* 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
*/
public class CodeGenerator {
/**
* <p>
* 读取控制台内容
* </p>
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotBlank(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
//作者
gc.setAuthor("initialize liu");
//是否要打开输出目录
gc.setOpen(false);
// gc.setSwagger2(true); 实体属性 Swagger2 注解
mpg.setGlobalConfig(gc);
// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://localhost:3306/seckill?useUnicode=true&useSSL=false&characterEncoding=utf8");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername("");
dsc.setPassword("");
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
pc.setModuleName(scanner("模块名"));
pc.setParent("com.xxx.generator")
.setEntity("pojo");
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};
// 如果模板引擎是 freemarker
String templatePath = "/templates/mapper.xml.ftl";
// 如果模板引擎是 velocity
// String templatePath = "/templates/mapper.xml.vm";
// 自定义输出配置
List<FileOutConfig> focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
// 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
/*
cfg.setFileCreate(new IFileCreate() {
@Override
public boolean isCreate(ConfigBuilder configBuilder, FileType fileType, String filePath) {
// 判断自定义文件夹是否需要创建
checkDir("调用默认方法创建的目录,自定义目录用");
if (fileType == FileType.MAPPER) {
// 已经生成 mapper 文件判断存在,不想重新生成返回 false
return !new File(filePath).exists();
}
// 允许生成模板文件
return true;
}
});
*/
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);
// 配置模板
TemplateConfig templateConfig = new TemplateConfig();
//配置自定义输出模板
// .setEntity("templates/entity2.java")
// .setMapper("templates/mapper2.java")
// .setService("templates/service2.java")
// .setServiceImpl("templates/serviceImpl2.java")
// .setController("template/controller2.java");
// 配置自定义输出模板
//指定自定义模板路径,注意不要带上.ftl/.vm, 会根据使用的模板引擎自动识别
// templateConfig.setEntity("templates/entity2.java");
// templateConfig.setService();
// templateConfig.setController();
templateConfig.setXml(null);
mpg.setTemplate(templateConfig);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
//数据库表映射到实体的命名策略
strategy.setNaming(NamingStrategy.underline_to_camel);
//数据库表字段映射到实体的命名策略
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
// strategy.setSuperEntityClass("你自己的父类实体,没有就不用设置!");
//lombok模型
strategy.setEntityLombokModel(true);
//生成@RestController控制器
// strategy.setRestControllerStyle(true);
// 公共父类
// strategy.setSuperControllerClass("你自己的父类控制器,没有就不用设置!");
// 写于父类中的公共字段
// strategy.setSuperEntityColumns("id");
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
strategy.setControllerMappingHyphenStyle(true);
//表前缀
strategy.setTablePrefix("t_");
mpg.setStrategy(strategy);
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
mpg.execute();
}
}
5.1.2.3 使用步骤
-
运行main程序
image.png -
查看生成目录
image.png - 将生成的目录结构拷贝到seckill项目
5.1.3 登录功能
5.1.3.1 HTML
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
<script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}">
<script type="text/javascript" th:src="@{/jquery-validation/jquery.validate.min.js}"></script>
<script type="text/javascript" th:src="@{/jquery-validation/localization/messages_zh.min.js}"></script>
<script type="text/javascript" th:src="@{/layer/layer.js}"></script>
<script type="text/javascript" th:src="@{/js/md5.min.js}"></script>
<script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<form name="loginForm" id="loginForm" method="post" style="width:50%; margin:0 auto">
<h2 style="text-align:center; margin-bottom: 20px">用户登录</h2>
<div class="form-group">
<div class="row">
<label class="form-label col-md-4">请输入手机号码</label>
<div class="col-md-5">
<input id="mobile" name="mobile" class="form-control" type="text" placeholder="手机号码" required="true"/>
<!-- minlength="11" maxlength="11"-->
</div>
<div class="col-md-1">
</div>
</div>
</div>
<div class="form-group">
<div class="row">
<label class="form-label col-md-4">请输入密码</label>
<div class="col-md-5">
<input id="password" name="password" class="form-control" type="password" placeholder="密码" required="true"/>
<!-- minlength="6" maxlength="16"-->
</div>
</div>
</div>
<div class="row">
<div class="col-md-5">
<button class="btn btn-primary btn-block" type="reset" onclick="reset()">重置</button>
</div>
<div class="col-md-5">
<button class="btn btn-primary btn-block" type="submit" onclick="login()">登录</button>
</div>
</div>
</form>
</body>
<script>
function login(){
$("#loginForm").validate({
submitHandler: function(form){
doLogin();
}
});
}
function doLogin(){
g_showLoading();
var inputPass = $("#password").val();
var salt = g_passsword_salt;
var str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
var password = md5(str);
$.ajax({
url: "/login/doLogin",
type: "POST",
data: {
mobile: $("#mobile").val(),
password: password
},
success: function (data){
layer.closeAll();
if(data.code == 200) {
layer.msg("成功");
}else{
layer.msg(data.message);
}
},
error: function (){
layer.closeAll();
}
});
}
</script>
</html>
5.1.3.2 Controller
package com.xxx.seckill.controller;
import com.xxx.seckill.service.IUserService;
import com.xxx.seckill.vo.LoginVo;
import com.xxx.seckill.vo.RespBean;
import com.xxx.seckill.vo.RespBeanEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* 登录
*
*
*/
@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {
@Autowired
private IUserService userService;
/**
* 功能描述: 跳转到登录页面
* @return
*/
@RequestMapping("/toLogin")
public String toLogin(){
return "login";
}
/**
* 功能描述:成功返回结果
*/
@RequestMapping("/doLogin")
@ResponseBody
public RespBean doLogin(LoginVo loginVo){
//打日志
log.info("()" + loginVo);
return userService.doLogin(loginVo);
}
public static RespBean success(Object obj){
return new RespBean(RespBeanEnum.SUCCESS.getCode(), RespBeanEnum.SUCCESS.getMessage(), obj);
}
/**
* 功能描述:失败返回结果
*/
public static RespBean error(RespBeanEnum respBeanEnum){
return new RespBean(respBeanEnum.getCode(), respBeanEnum.getMessage(), null);
}
/**
* 功能描述:失败的返回结果
* @param respBeanEnum
* @param obj
* @return
*/
public static RespBean error(RespBeanEnum respBeanEnum, Object obj){
return new RespBean(respBeanEnum.getCode(), respBeanEnum.getMessage(), obj);
}
}
5.1.3.3 Service
package com.xxx.seckill.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xxx.seckill.mapper.UserMapper;
import com.xxx.seckill.pojo.User;
import com.xxx.seckill.service.IUserService;
import com.xxx.seckill.util.MD5Util;
import com.xxx.seckill.util.ValidatorUtil;
import com.xxx.seckill.vo.LoginVo;
import com.xxx.seckill.vo.RespBean;
import com.xxx.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.thymeleaf.util.StringUtils;
/**
* <p>
* 服务实现类
* </p>
*
* @author initialize liu
* @since 2022-07-05
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Autowired
private UserMapper userMapper;
/**
* 登录验证
* @param loginVo
* @return
*/
@Override
public RespBean doLogin(LoginVo loginVo) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
//参数校验
if(StringUtils.isEmpty(mobile)||StringUtils.isEmpty(password)){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
if(!ValidatorUtil.isMobile(mobile)){
return RespBean.error(RespBeanEnum.MOBILE_ERROR);
}
//根据手机号获取用户
User user = userMapper.selectById(mobile);
if(null == user){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
if(!MD5Util.formPassToDBPass(password, user.getSlat()).equals(user.getPassword())){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
return RespBean.success();
}
}
5.1.3.4 Mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xxx.seckill.pojo.User;
/**
* <p>
* Mapper 接口
* </p>
*
* @author initialize liu
* @since 2022-07-05
*/
public interface UserMapper extends BaseMapper<User> {
}
5.1.3.5 手机号验证类,响应实体
/**
* 手机号码效验
*/
public class ValidatorUtil {
private static final Pattern mobile_pattern = Pattern.compile("[1]([3-9])[0-9]{9}$");
public static boolean isMobile(String mobile){
if(StringUtils.isEmpty(mobile)){
return false;
}
Matcher matcher = mobile_pattern.matcher(mobile);
return matcher.matches();
}
}
import lombok.Data;
/**
* 登录参数
*/
@Data
public class LoginVo {
private String mobile;
private String password;
}
@Getter
@ToString
@AllArgsConstructor
public enum RespBeanEnum {
//通用
SUCCESS(200, "SUCCESS"),
ERROR(500, "服务端异常"),
//登录模块
LOGIN_ERROR(500210, "用户名或密码不正确"),
MOBILE_ERROR(500211, "手机号码格式不正确");
private final Integer code;
private final String message;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RespBean {
private long code;
private String message;
private Object obj;
public static RespBean success(){
return new RespBean(RespBeanEnum.SUCCESS.getCode(), RespBeanEnum.SUCCESS.getMessage(), null);
}
public static RespBean error(RespBeanEnum errorEnum){
return new RespBean(errorEnum.getCode(), errorEnum.getMessage(), null);
}
}
5.2 参数效验优化
5.2.1 优化原因
代码冗余
@Override
public RespBean doLogin(LoginVo loginVo) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
//参数校验
// if(StringUtils.isEmpty(mobile)||StringUtils.isEmpty(password)){
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
// }
//
// if(!ValidatorUtil.isMobile(mobile)){
// return RespBean.error(RespBeanEnum.MOBILE_ERROR);
// }
//根据手机号获取用户
User user = userMapper.selectById(mobile);
if(null == user){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
if(!MD5Util.formPassToDBPass(password, user.getSlat()).equals(user.getPassword())){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
return RespBean.success();
}
5.2.2 引入validator进行参数优化
<!-- validation组件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
5.2.2.1 Controller借助@Valid完成参数优化
关于@Valid的使用在学习springBoot的时候,学习过。
/**
* 功能描述:成功返回结果
*/
@RequestMapping("/doLogin")
@ResponseBody
public RespBean doLogin(@Valid LoginVo loginVo){
//打日志
log.info("()" + loginVo);
return userService.doLogin(loginVo);
}
5.2.2.2 LoginVo视图实体,添加自定义注解
/**
* 登录参数
*/
@Data
public class LoginVo {
@NotNull
@IsMobile
private String mobile;
@NotNull
@Length(min = 32)
private String password;
}
5.2.2.3 IsMobile注解效验mobile
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class}) //约束条件
public @interface IsMobile {
boolean required() default true;
String message() default "手机号格式错误";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
5.2.2.4 IsMobileValidator约束条件
mport com.xxx.seckill.util.ValidatorUtil;
import org.thymeleaf.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
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 constraintValidatorContext) {
if(required){
//调用自定义的验证工具
return ValidatorUtil.isMobile(value);
}else{
if(StringUtils.isEmpty(value)){
return true;
}else {
return ValidatorUtil.isMobile(value);
}
}
}
}
5.2.3 手机号验证
client传输错误的手机号,服务器的日志中会有提醒。
2022-07-06 14:59:11.985 WARN 10420 --- [nio-8080-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors<EOL>Field error in object 'loginVo' on field 'mobile': rejected value [11111111111]; codes [IsMobile.loginVo.mobile,IsMobile.mobile,IsMobile.java.lang.String,IsMobile]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [loginVo.mobile,mobile]; arguments []; default message [mobile],true]; default message [手机号格式错误]]
注意:这时候提示的错误只是在服务器端的日志中,错误没有被捕获并发送给client.
5.3 异常处理
系统中异常包括:编译时异常和运行时异常RuntimeException,前者通过捕获异常从而获取异常信息,后者主要通过规范代码开发,测试通过手段减少运行时异常的发生,在开发中,不管是dao层,service层,controller层都可能抛出异常,在Springmvc中,能将所有的类型异常处理从个各处理过程解耦出来,既保证相关处理过程的功能较单一,也实现了异常信息的统一处理和维护。
SpringBoot全局异常处理方式主要有两种:
- 使用@ControllerAdvice和@ExceptionHandler注解
- 使用ErrorController类来实现
区别:
- @ControllerAdvice方式只能处理控制器抛出的异常,此时请求已经进入控制器中。
- ErrorController类方式可以处理所有的异常,包括未进入控制器的错误,比如404,401等错误
- 如果应用中两者共同存在,则@ControllerAdvice方式处理控制器抛出的异常,ErrorController类方式处理未进入控制器的异常。
- @ControllerAdvice方式可以定义多个拦截方法,拦截不同的异常类,并且可以获取抛出的异常信息,自由度更大。
这里提供的只是异常处理的一种方式。
5.3.1 定义全局异常
import com.xxx.seckill.vo.RespBeanEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 全局异常处理类
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GlobalException extends RuntimeException{
private RespBeanEnum respBeanEnum;
}
5.3.2 定义全局异常处理方式
@RestControllerAdvice: 处理Controller过程中抛出的异常,切面的方式实现。
import com.xxx.seckill.vo.RespBean;
import com.xxx.seckill.vo.RespBeanEnum;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理类
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public RespBean ExcpeitonHandler(Exception e){
if(e instanceof GlobalException){
GlobalException ex = (GlobalException) e;
return RespBean.error(ex.getRespBeanEnum());
}else if(e instanceof BindException){
BindException ex = (BindException) e;
RespBean respBean = RespBean.error(RespBeanEnum.BIND_ERROR);
respBean.setMessage("参数效验异常:" + ex.getBindingResult().getAllErrors().get(0).getDefaultMessage());
return respBean;
}
return RespBean.error(RespBeanEnum.ERROR);
}
}
5.3.3 UserService调整
@Override
public RespBean doLogin(LoginVo loginVo) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
//根据手机号获取用户
User user = userMapper.selectById(mobile);
if(null == user){
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
if(!MD5Util.formPassToDBPass(password, user.getSlat()).equals(user.getPassword())){
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
return RespBean.success();
}
5.4 Cookie完善登录功能
5.4.1 登录成功后,保存Cookie到Session
@Override
public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
//根据手机号获取用户
User user = userMapper.selectById(mobile);
if(null == user){
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
}
if(!MD5Util.formPassToDBPass(password, user.getSlat()).equals(user.getPassword())){
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
}
//生成cookie
String ticket = UUIDUtil.uuid();
request.getSession().setAttribute(ticket, user);
CookieUtil.setCookie(request, response, "userTicket", ticket);
return RespBean.success();
}
5.4.2 HTML跳转的商品列表
function doLogin(){
g_showLoading();
var inputPass = $("#password").val();
var salt = g_passsword_salt;
var str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
var password = md5(str);
$.ajax({
url: "/login/doLogin",
type: "POST",
data: {
mobile: $("#mobile").val(),
password: password
},
success: function (data){
layer.closeAll();
if(data.code == 200) {
layer.msg("成功");
window.location.href = "/goods/toList";
}else{
layer.msg(data.message);
}
},
error: function (){
layer.closeAll();
}
});
}
5.4.3 GoodsController处理
/**
* 商品
*/
@Controller
@RequestMapping("/goods")
public class GoodsController {
/**
* 跳转到商品列表页面
* @param session
* @param model
* @param ticket
* @return
*/
@RequestMapping("/toList")
public String toList(HttpSession session, Model model, @CookieValue("userTicket") String ticket){
if(StringUtils.isEmpty(ticket)){
return "login";
}
User user = (User)session.getAttribute(ticket);
if(null == user){
return "login";
}
model.addAttribute("user", user);
return "goodsList";
}
}
5.4.4 CookieUtil工具
/**
* 生成UUID
*/
public class UUIDUtil {
public static String uuid(){
return UUID.randomUUID().toString().replace("-", "");
}
}
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
/**
* Cookie工具类
*/
public final class CookieUtil {
/**
* 得到Cookie的值,不编码
* @param request
* @param cookieName
* @return
*/
public static String getCookieValue(HttpServletRequest request, String cookieName){
return getCookieValue(request, cookieName, false);
}
/**
* 得到Cookie的值
* @param request
* @param cookieName
* @param isDecoder
* @return
*/
public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder){
Cookie[] cookieList = request.getCookies();
if(cookieList == null || cookieName == null){
return null;
}
String retValue = null;
try{
for(int i = 0; i < cookieList.length; i++){
if(cookieList[i].getName().equals(cookieName)){
if(isDecoder){
retValue = URLDecoder.decode(cookieList[i].getValue(), "UTF-8");
}else{
retValue = cookieList[i].getValue();
}
break;
}
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return retValue;
}
/**
* 得到Cookie的值
*/
public static String getCookieValue(HttpServletRequest request, String cookieName, String encodeString){
Cookie[] cookieList = request.getCookies();
if(cookieList == null || cookieName == null){
return null;
}
String retValue = null;
try{
for(int i = 0; i < cookieList.length; i++){
if(cookieList[i].getName().equals(cookieName)){
retValue = URLDecoder.decode(cookieList[i].getValue(), encodeString);
break;
}
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return retValue;
}
/**
* 设置Cookie的值,不设置生效时间,默认浏览器关闭即失效,也不编码
* @param request
* @param response
* @param cookieName
* @param cookieValue
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue){
setCookie(request, response, cookieName, cookieValue, -1);
}
/**
* 设置Cookie的值,在指定时间内生效,但不编码
* @param request
* @param response
* @param cookieName
* @param cookieValue
* @param cookieMaxage
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage){
setCookie(request, response, cookieName, cookieValue, cookieMaxage, false);
}
/**
* 设置Cookie的值,不设置生效时间,但编码
* @param request
* @param response
* @param cookieName
* @param cookieValue
* @param isEncode
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, boolean isEncode){
setCookie(request, response, cookieName, cookieValue, -1, isEncode);
}
/**
* 设置Cookie的值,,在指定时间内生效,编码参数
* @param request
* @param response
* @param cookieName
* @param cookieValue
* @param cookieMaxage
* @param isEncode
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, boolean isEncode){
doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode);
}
/**
* 设置Cookie的值,在指定时间内生效,编码参数(指定编码)
* @param request
* @param response
* @param cookieName
* @param cookieValue
* @param cookieMaxage
* @param encodeString
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, String encodeString){
doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, encodeString);
}
/**
* 删除Cookie带cookie域名
* @param request
* @param response
* @param cookieName
*/
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String cookieName){
doSetCookie(request, response, cookieName, "", -1, false);
}
/**
* 设置Cookie的值,并使其在指定时间内生效
* @param request
* @param response
* @param cookieName
* @param cookieValue
* @param cookieMaxage
* @param isEncode
*/
private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, boolean isEncode){
try{
if(cookieValue == null){
cookieValue = "";
} else if(isEncode){
cookieValue = URLEncoder.encode(cookieValue, "utf-8");
}
Cookie cookie = new Cookie(cookieName, cookieValue);
if(cookieMaxage > 0)
cookie.setMaxAge(cookieMaxage);
if(null != request){
//设置域名的cookie
String domainName = getDomainName(request);
System.out.println(domainName);
if(!"locallhost".equals(domainName)){
cookie.setDomain(domainName);
}
}
cookie.setPath("/");
response.addCookie(cookie);
}catch (Exception e){
e.printStackTrace();
}
}
/**
* 设置Cookie的值,并使其在指定时间内生效,指定编码格式
* @param request
* @param response
* @param cookieName
* @param cookieValue
* @param cookieMaxage
* @param encodeString
*/
private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, String encodeString){
try{
if(cookieValue == null){
cookieValue = "";
}else{
cookieValue = URLEncoder.encode(cookieValue, encodeString);
}
Cookie cookie = new Cookie(cookieName, cookieValue);
if(cookieMaxage > 0){
cookie.setMaxAge(cookieMaxage);
}
if(null != request){ //设置域名的cookie
String domainName = getDomainName(request);
System.out.println(domainName);
if(!"localhost".equals(domainName)){
cookie.setDomain(domainName);
}
}
cookie.setPath("/");
response.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
/**
* 得到cookie的域名
* @param request
* @return
*/
private static final String getDomainName(HttpServletRequest request){
String domainName = null;
//通过reqeust对象获取访问的url地址
String serverName = request.getRequestURL().toString();
if(serverName == null || serverName.equals("")){
domainName = "";
}else{
//将url地址转换为小写
serverName = serverName.toLowerCase();
//如果url地址是以http://开头,将http://截取
if(serverName.startsWith("http://")){
serverName = serverName.substring(7);
}
int end = serverName.length();
//判断url地址是否包含"/"
if (serverName.contains("/")){
//得到第一个"/"出现的位置
end = serverName.indexOf("/");
}
//截取
serverName = serverName.substring(0, end);
//根据"."进行分割
final String[] domains = serverName.split("\\.");
int len = domains.length;
if(len > 3){
//www.xxx.com.cn
domainName = domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
}else if(len <= 3 && len > 1){
//xxx.com or xxx.cn
domainName = domains[len - 2] + "." + domains[len - 1];
}else {
domainName = serverName;
}
}
if(domainName != null && domainName.indexOf(":") > 0){
String[] ary = domainName.split("\\:");
domainName = ary[0];
}
return domainName;
}
}