本文是基于乐字节的秒杀系统总结出来的笔记,纯属个人兴趣,视频原文链接:视频链接
项目源码地址:项目地址
系统介绍
秒杀,对我们来说,都不是一个陌生的东西。每年的双11,618以及时下流行的直播等等。秒杀然而,这对于我们系统而言是一个巨大的考验。那么,如何才能更好地理解秒杀系统呢?我觉得作为一个程序员,你首先需要从高维度出发,从整体上思考问题。在我看来,秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
其实,秒杀的整体架构可以概括为“稳、准、快”几个关键字。就是整个系统架构要满足高可用,流量符合预期时肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提。然后就是“准”,就是秒杀 10 台 iPhone,那就只能成交 10 台,多一台少一台都不行。一旦库存不对,那平台就要承担损失,所以“准”就是要求保证数据的一致性。最后再看“快”,“快”其实很好理解,它就是说系统的性能要足够高,否则你怎么支撑这么大的流量呢?不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。
所以从技术角度上看“稳、准、快”,就对应了我们架构上的高可用、一致性和高性能的要求。
- 高性能。 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。对应的方案比如动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化。
- 一致性。 秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为“拍下减库存”“付款减库存”以及预扣等几种,在大并发更新的过程中都要保证数据的准确性,其难度可想而知。
- 高可用。 现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 PlanB 来兜底,以便在最坏情况发生时仍然能够从容应对。
前置介绍
- 基于Springboot+MybatisPlus+Redis+Rabitmq的高并发商品秒杀系统。
- MybatisPlus:MyBatis-Plus简称MP,是国内的针对MyBatis制作的一个增强框架,对原生MyBatis无侵入,只做增强,目的是可以简化简单的CRUD操作,提高开发效率。简单的CRUD完全不需要写SQL语句,也不必编写持久层接口,仅仅需要继承JpaRepository接口即可。
- Redis是一个高速缓存数据库,是一种key-value(键值对)形式的存储系统,非关系型数据库。Redis的数据 是放在内存里的,所以读写会很快,Redis才能实现持久化(两种实现方式)。
- RabbitMQ是由Erlang语言开发,基于AMQP协议(Advanced Message Queuing Protocol 高级消息队列协议)实现的消息队列,它是一种应用程序之间的通信方法,消息队列在实际开发应用中有着非常广泛的使用。
环境搭建
1.依赖注入
首先,在idea新建一个java项目,利用spring Initializr创建一个spring工程,在依赖那里勾选web、thymeleaf、mysql、lombok等依赖。
<!-- 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>
<!--lombok 依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- test组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
2.修改配置文件application.yml
spring:
#thymeleaf配置
thymeleaf:
#关闭缓存
cache: false
prefix: classpath:/static/web/
#数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: root
hikari:
# 连接池名
pool-name: DateHikariCP
# 最小空闲连接数
minimum-idle: 5
# 空闲连接存活最大时间,默认600000(10分钟)
idle-timeout: 180000
# 最大连接数,默认10
maximum-pool-size: 10
# 从连接池返回的连接的自动提交
auto-commit: true
# 连接最大存活时间,0表示永久存活,默认1800000(30分钟)
max-lifetime: 180000
# 连接超时时间,默认30000(30秒)
connection-timeout: 30000
# 测试连接是否可用的查询语句
connection-test-query: SELECT 1
前期开发准备
1.逆向工程
逆向工程简单来说就是,我们先创建好了数据库,然后根据数据库使用Mybatis-Plus的生成器代码自动帮我们生成我们需要的类:controller、service、mapper等等,不需要我们自己再手动配置,省去了不少麻烦。
首先添加Mybatis-Plus依赖和代码生成器依赖,这两个依赖都可以在Mybatis-Plus官网中找到。由于此代码生成器使用的是freemarker模板引擎,因此也需要将freemarker的模板引擎的依赖一并导入。
1.1 依赖注入
<!-- mybatis-plus依赖 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
<!-- 代码生成器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.4.0</version>
</dependency>
<!-- freemarker模板引擎 -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.30</version>
</dependency>
1.2 修改配置文件application.yml
在配置文件中添加Mybatis-plus配置信息
#Mybatis-plus配置
mybatis-plus:
#配置Mapper映射文件
mapper-locations: classpath*:/mapper/*Mapper.xml
#配置MyBatis数据返回类型别名(默认别名是类名)
type-aliases-package: com.yang.seckill.pojo
#Mybatis SQL 打印(方法接口所在的包,不是Mapper.xml所在的包)
logging:
level:
com.yang.seckill.mapper: debug
1.3 代码生成器
public class CodeGenerator {
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("yang");
//打开输出目录
gc.setOpen(false);
//xml开启 BaseResultMap
gc.setBaseResultMap(true);
//xml 开启BaseColumnList
gc.setBaseColumnList(true);
//日期格式,采用Date
gc.setDateType(DateType.ONLY_DATE);
mpg.setGlobalConfig(gc);
// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia" +"/Shanghai");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("root");
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
pc.setParent("com.yang.seckill")
.setEntity("pojo")
.setMapper("mapper")
.setService("service")
.setServiceImpl("service.impl")
.setController("controller");
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
Map<String,Object> map = new HashMap<>();
map.put("date1","1.0.0");
this.setMap(map);
}
};
// 如果模板引擎是 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/" +
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/entity.java")
.setMapper("templates/mapper.java")
.setService("templates/service.java")
.setServiceImpl("templates/serviceImpl.java")
.setController("templates/controller.java");
templateConfig.setXml(null);
mpg.setTemplate(templateConfig);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
//strategy.setSuperEntityClass("你自己的父类实体,没有就不用设置!");
strategy.setEntityLombokModel(true);
//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();
}
}
2.数据库创建
- 数据库创建
create database seckill;
- 用户表
CREATE TABLE t_user(
`id` BIGINT(20) NOT NULL COMMENT '用户ID shoujihaoma',
`nickname` VARCHAR(255) not NULL,
`pasword` VARCHAR(32) DEFAULT NULL COMMENT 'MD5二次加密',
`slat` VARCHAR(10) DEFAULT NULL,
`head` VARCHAR(128) DEFAULT NULL COMMENT '头像',
`register_date` datetime DEFAULT NULL COMMENT '注册时间',
`last_login_date` datetime DEFAULT NULL COMMENT '最后一次登录时间',
`login_count` int(11) DEFAULT '0' COMMENT '登录次数',
PRIMARY KEY(`id`)
);
- 商品表
create table `t_goods`(
`id` BIGINT(20) not null AUTO_INCREMENT COMMENT '商品id',
`goods_name` VARCHAR(16) DEFAULT NULL COMMENT '商品名称',
`goods_title` VARCHAR(64) DEFAULT NULL COMMENT '商品标题',
`goods_img` VARCHAR(64) DEFAULT NULL COMMENT '商品图片',
`goods_detail` LONGTEXT COMMENT '商品描述',
`goods_price` DECIMAL(10, 2) DEFAULT '0.00' COMMENT '商品价格',
`goods_stock` INT(11) DEFAULT '0' COMMENT '商品库存,-1表示没有限制',
PRIMARY KEY(`id`)
)ENGINE = INNODB AUTO_INCREMENT = 3 DEFAULT CHARSET = utf8mb4;
- 订单表
CREATE TABLE `t_order` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '订单ID',
`user_id` BIGINT(20) DEFAULT NULL COMMENT '用户ID',
`goods_id` BIGINT(20) DEFAULT NULL COMMENT '商品ID',
`delivery_addr_id` BIGINT(20) DEFAULT NULL COMMENT '收获地址ID',
`goods_name` VARCHAR(16) DEFAULT NULL COMMENT '商品名字',
`goods_count` INT(20) DEFAULT '0' COMMENT '商品数量',
`goods_price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品价格',
`order_channel` TINYINT(4) DEFAULT '0' COMMENT '1 pc,2 android, 3 ios',
`status` TINYINT(4) DEFAULT '0' COMMENT '订单状态,0新建未支付,1已支付,2已发货,3已收货,4已退货,5已完成',
`create_date` datetime DEFAULT NULL COMMENT '订单创建时间',
`pay_date` datetime DEFAULT NULL COMMENT '支付时间',
PRIMARY KEY(`id`)
)ENGINE = INNODB AUTO_INCREMENT=12 DEFAULT CHARSET = utf8mb4;
- 秒杀商品表
CREATE TABLE `t_seckill_goods`(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀商品ID',
`goods_id` BIGINT(20) NOT NULL COMMENT '商品ID',
`seckill_price` DECIMAL(10,2) NOT NULL COMMENT '秒杀家',
`stock_count` INT(10) NOT NULL COMMENT '库存数量',
`start_date` datetime NOT NULL COMMENT '秒杀开始时间',
`end_date` datetime NOT NULL COMMENT '秒杀结束时间',
PRIMARY KEY(`id`)
)ENGINE = INNODB AUTO_INCREMENT=3 DEFAULT CHARSET = utf8mb4;
- 秒杀订单表
CREATE TABLE `t_seckill_order` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀订单ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`order_id` BIGINT(20) NOT NULL COMMENT '订单ID',
`goods_id` BIGINT(20) NOT NULL COMMENT '商品ID',
PRIMARY KEY(`id`)
)ENGINE = INNODB AUTO_INCREMENT=3 DEFAULT CHARSET = utf8mb4;
3.工具类
3.1 2次MD5加密
为了提高用户密码的安全性,密码从前端传入后端,再从后端传入数据库的过程中要经历两次加密过程。此项目中,前端页面js代码已经写好了,在前端已经历一次加密过程,在后端的代码也有两次加密过程,只不过第一次加密的方式与前端代码的加密方式相同,此处只是为了测试加密的准确性,第二次加密是将前端传入后端的密码再次进行加密的过程,此密码将存入数据库,并保留加密的盐值,为后续的密码验证作铺垫。在正式生成中,盐值往往是随机生成的,此项目仅作为学习为主,因此设置所以的盐值为静态盐值。
使用MD5加密,首先要注入相关的依赖:
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
2次MD5加密:
/**
* MD5工具类
*/
@Component
public class MD5Util {
public static String md5(String src){
return DigestUtils.md5Hex(src);
}
public static final String salt = "1a2b3c4d";
/**
* 第一次加密:前端输入的密码加密转为后端密码
* @param inputPass 前端输入的密码
* @return 第一次加密后的密码
*/
public static String inputPassToBackendPass(String inputPass){
String str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4) ;
return md5(str);
}
/**
* 第二次加密:后端加密后的密码再次加密存入数据库
* @param backendPass 后端加密后的密码
* @param salt 存入数据库的盐值
* @return 第二次加密后的密码
*/
public static String backendPassToDBPass(String backendPass, String salt){
String str = "" + salt.charAt(0) + salt.charAt(2) + backendPass + salt.charAt(5) + salt.charAt(4);
return md5(str);
}
/**
* 直接前端输入的密码经过2次加密后存入数据库的密码
* @param inputPass 前端输入的密码
* @param salt 存入数据库的盐值
* @return 存入数据库的密码
*/
public static String inputPassToDBPass(String inputPass, String salt){
String backendPass = inputPassToBackendPass(inputPass);
String dbPass = backendPassToDBPass(backendPass,salt);
return dbPass;
}
public static void main(String[] args) {
System.out.println("前端进行加密后的密码:" + inputPassToBackendPass("123456"));
System.out.println("后端传入数据库进行加密后的密码:" + backendPassToDBPass(inputPassToBackendPass("123456"),"1a2b3c4d"));
System.out.println("经过2次加密后的密码:" + inputPassToDBPass("123456","1a2b3c4d"));
}
}
3.2 电话号码校验类
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();
}
}
3.3 异常枚举
为什么会出现异常?比如,用户在进行注册时可能会产生用户名被占用的错误,这时需要抛出一个异常。
- 公共返回对象枚举
创建一个公共返回对象枚举,列出项目测试过程中可能遇到的异常情况,因项目刚开始,所以只罗列了登录过程中可能出现的异常,后续业务中所出现的异常将后续添加。
/**
* 公共返回对象枚举
*/
@Getter
@ToString
@AllArgsConstructor
public enum RespBeanEnum {
//通用
SUCCESS(200,"SUCCESS"),
ERROR(500,"服务端异常"),
//登录模块
LOGIN_ERROR(500218,"用户名或密码错误"),
MOBILE_ERROR(500211,"手机号码格式错误"),
//绑定异常
BIND_ERROR(500212,"参数校验异常");
private final Integer code;
private final String message;
}
- 公共返回对象
设置公共返回对象成功与失败的方法。
/**
* 公共返回对象
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RespBean {
private long code;
private String message;
private Object obj;
/**
* 成功返回结果
* @return
*/
public static RespBean success(){
return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBeanEnum.SUCCESS.getMessage(),null);
}
/**
* 成功返回结果
* @return
*/
public static RespBean success(Object obj){
return new RespBean(RespBeanEnum.SUCCESS.getCode(), RespBean.success().getMessage(),obj);
}
/**
* 失败返回结果
* @return
*/
public static RespBean error(RespBeanEnum respBeanEnum){
return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),null);
}
/**
* 失败返回结果
* @return
*/
public static RespBean error(RespBeanEnum respBeanEnum, Object obj){
return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),obj);
}
}
3.4 生成用户工具类
在后面进行压力测试的时候,要生成很多用户对接口进行访问,此工具类能够按照生成规则生成大量用户信息:
/**
* 生成用户工具类
*/
public class UserUtil {
private static void createUser(int count) throws Exception {
List<User> users = new ArrayList<>(count);
//生成用户
for (int i = 0; i < count; i++) {
User user = new User();
user.setId(13000000000L + i);
user.setLoginCount(1);
user.setNickname("user" + i);
user.setRegisterDate(new Date());
user.setSlat("1a2b3c4d");
user.setPassword(MD5Util.inputPassToDBPass("123456", user.getSlat()));
users.add(user);
}
System.out.println("create user");
// //插入数据库
Connection conn = getConn();
String sql = "insert into t_user(login_count, nickname, register_date, slat, password, id)values(?,?,?,?,?,?)";
PreparedStatement pstmt = conn.prepareStatement(sql);
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
pstmt.setInt(1, user.getLoginCount());
pstmt.setString(2, user.getNickname());
pstmt.setTimestamp(3, new Timestamp(user.getRegisterDate().getTime()));
pstmt.setString(4, user.getSlat());
pstmt.setString(5, user.getPassword());
pstmt.setLong(6, user.getId());
pstmt.addBatch();
}
pstmt.executeBatch();
pstmt.close();
conn.close();
System.out.println("insert to db");
//登录,生成userTicket
String urlString = "http://localhost:8080/login/doLogin";
File file = new File("C:\\Users\\Administrator\\Desktop\\config.txt");
if (file.exists()) {
file.delete();
}
RandomAccessFile raf = new RandomAccessFile(file, "rw");
file.createNewFile();
raf.seek(0);
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
URL url = new URL(urlString);
HttpURLConnection co = (HttpURLConnection) url.openConnection();
co.setRequestMethod("POST");
co.setDoOutput(true);
OutputStream out = co.getOutputStream();
String params = "mobile=" + user.getId() + "&password=" + MD5Util.inputPassToBackendPass("123456");
out.write(params.getBytes());
out.flush();
InputStream inputStream = co.getInputStream();
ByteArrayOutputStream bout = new ByteArrayOutputStream();
byte buff[] = new byte[1024];
int len = 0;
while ((len = inputStream.read(buff)) >= 0) {
bout.write(buff, 0, len);
}
inputStream.close();
bout.close();
String response = new String(bout.toByteArray());
ObjectMapper mapper = new ObjectMapper();
RespBean respBean = mapper.readValue(response, RespBean.class);
String userTicket = ((String) respBean.getObj());
System.out.println("create userTicket : " + user.getId());
String row = user.getId() + "," + userTicket;
raf.seek(raf.length());
raf.write(row.getBytes());
raf.write("\r\n".getBytes());
System.out.println("write to file : " + user.getId());
}
raf.close();
System.out.println("over");
}
private static Connection getConn() throws Exception {
String url = "jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai";
String username = "root";
String password = "root";
String driver = "com.mysql.cj.jdbc.Driver";
Class.forName(driver);
return DriverManager.getConnection(url, username, password);
}
public static void main(String[] args) throws Exception {
createUser(500);
}
}
3.5 Json工具类
Json工具类内部有众多方法,有将对象转换成json字符串、将字符串转换为对象等方法:
/**
* Json工具类
*/
public class JsonUtil {
private static ObjectMapper objectMapper = new ObjectMapper();
/**
* 将对象转换成json字符串
*
* @param obj
* @return
*/
public static String object2JsonStr(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
//打印异常信息
e.printStackTrace();
}
return null;
}
/**
* 将字符串转换为对象
*
* @param <T> 泛型
*/
public static <T> T jsonStr2Object(String jsonStr, Class<T> clazz) {
try {
return objectMapper.readValue(jsonStr.getBytes("UTF-8"), clazz);
} catch (JsonParseException e) {
e.printStackTrace();
} catch (JsonMappingException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/**
* 将json数据转换成pojo对象list
* <p>Title: jsonToList</p>
* <p>Description: </p>
*
* @param jsonStr
* @param beanType
* @return
*/
public static <T> List<T> jsonToList(String jsonStr, Class<T> beanType) {
JavaType javaType = objectMapper.getTypeFactory().constructParametricType(List.class, beanType);
try {
List<T> list = objectMapper.readValue(jsonStr, javaType);
return list;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
用户注册功能
由于此项目使用了Mybatis-Plus,故只需用逆向工程自动生成Mapper层、Service层以及Controller层,简单的CRUD完全不需要写SQL语句,也不必编写持久层接口,因此开发过程中只需自行编写业务层及控制层逻辑。
1.异常规划
异常不能用RuntimeException,太笼统了,开发者没办法第一时间定位到具体的错误类型上,我们可以定义具体的异常类型来继承这个异常。
- 全局异常GlobalException
定义全局异常类GlobalException继承RuntimeException,前面已经写了异常类枚举,只需在全局异常类GlobalException中引入RespBeanEnum,后续出现的异常情况再在RespBeanEnum类中添加。
/**
* 全局异常
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GlobalException extends RuntimeException{
private RespBeanEnum respBeanEnum;
}
- 全局异常处理类
/**
* 全局异常处理类
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public RespBean ExceptionHandler(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);
}
}
2.业务层
@Autowired
private UserMapper userMapper;
/**
* 注册
* @param registerVo 用户
* @return
*/
@Override
public RespBean register(RegisterVo registerVo) {
String id = registerVo.getId();
String password = registerVo.getPassword();
String rePassword = registerVo.getRePassword();
//判断输入的参数是否为空
if (StringUtils.isEmpty(id)||StringUtils.isEmpty(password)||StringUtils.isEmpty(rePassword)){
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
}
//判断电话号码格式是否正确
if (!ValidatorUtil.isMobile(id)){
throw new GlobalException(RespBeanEnum.MOBILE_ERROR);
}
//判断两次密码是否一致
if (!password.equals(rePassword)){
throw new GlobalException(RespBeanEnum.REGISTER_PASSWORD_ERROR);
}
//查询数据库中是否已经存在该用户
User result = userMapper.selectById(id);
if(result != null){
throw new GlobalException(RespBeanEnum.REGISTER_USER_EXIT_ERROR);
}
User user = new User();
String md5Password = MD5Util.inputPassToDBPass(password,"1a2b3c4d");
Long uId = Long.valueOf(id);
//补充用户信息
user.setId(uId);
user.setPassword(md5Password);
user.setNickname("user"+uId);
user.setSlat("1a2b3c4d");
user.setRegisterDate(new Date());
user.setLoginCount(1);
//将用户信息添加进数据库
userMapper.insert(user);
System.out.println("注册成功!");
return RespBean.success();
}
3.控制层
@Controller
@RequestMapping("/register")
@Slf4j
public class RegisterController {
@Autowired
private IUserService userService;
@RequestMapping("/reg")
@ResponseBody
public RespBean register(RegisterVo registerVo) {
System.out.println(registerVo);
return userService.register(registerVo);
}
}
4.前端页面
4.1 熟悉Ajax
-
什么是ajax?
这是jQuery封装的一个函数,称为$.ajax()函数,通过对象调用ajax()函数用来异步加载相关的请求.依靠的是JavaScript提供的一个对象:XHR(全称XmlHttpResponse) -
ajax()函数的语法结构:
1.使用ajax()时需要传递一个方法体作为方法的参数来使用(一对大括号就是一个方法体);
2.ajax接受多个参数时,参数与参数之间使用”,“分割;
3.每一组参数之间使用”:”进行分割;
4.参数的组成部分一个是参数的名称(不能随便定义),另一个是参数的值(必须用字符串来表示);
5.参数的声明顺序没有要求。 -
基本结构:
$.ajax({
url: "",
type: "",
data: "",
dataType: "",
success: function() {
},
error: function() {
}
});
- ajax()函数参数的含义:
4.1 前端js请求
$("#btn-reg").click(function () {
$.ajax({
url: "/register/reg",
type: "POST",
data: $("#regForm").serialize(),
success: function (data) {
if (data.code === 200) {
alert("注册成功");
} else {
alert("注册失败:" + data.message);
}
},
error: function (xhr) {
alert("注册时产生未知错误!" + xhr.message);
}
});
})
用户登录功能
1.Cookie工具类
- 什么是cookie?
因为HTTP协议是无状态的,即服务器不知道用户上一次做了什么,这严重阻碍了交互式Web应用程序的实现。在典型的网上购物场景中,用户浏览了几个页面,买了一盒饼干和两饮料。最后结帐时,由于HTTP的无状态性,不通过额外的手段,服务器并不知道用户到底买了什么。为了做到这点,就需要使用到Cookie了。服务器可以设置或读取Cookies中包含信息,借此维护用户跟服务器会话中的状态。
Cookie(复数形态:Cookies),是指某些网站为了辨别用户身份、进行session跟踪而储存在用户本地终端上的数据(通常经过加密)。
Cookie是由服务端生成的,发送给客户端(通常是浏览器)的。Cookie总是保存在客户端中,按在客户端中的存储位置,可分为内存Cookie和硬盘Cookie:
- 内存Cookie由浏览器维护,保存在内存中,浏览器关闭后就消失了,其存在时间是短暂的。
- 硬盘Cookie保存在硬盘里,有一个过期时间,除非用户手工清理或到了过期时间,硬盘Cookie不会被删除,其存在时间是长期的。所以,按存在时间,可分为非持久Cookie和持久Cookie。
2. 作用
Cookie的根本作用就是在客户端存储用户访问网站的一些信息。典型的应用有:
- 记住密码,下次自动登录;
- 购物车功能;
- 记录用户浏览数据,进行商品(广告)推荐。
3. 工作原理
-
创建Cookie:
当用户第一次浏览某个使用Cookie的网站时,该网站的服务器就进行如下工作:
①该用户生成一个唯一的识别码(Cookie id),创建一个Cookie对象;
②默认情况下它是一个会话级别的cookie,存储在浏览器的内存中,用户退出浏览器之后被删除。如果网站希望浏览器将该Cookie存储在磁盘上,则需要设置最大时效(maxAge),并给出一个以秒为单位的时间(将最大时效设为0则是命令浏览器删除该Cookie);
③将Cookie放入到HTTP响应报头,将Cookie插入到一个 Set-Cookie HTTP请求报头中。
④发送该HTTP响应报文。 -
设置存储Cookie:
浏览器收到该响应报文之后,根据报文头里的Set-Cookied特殊的指示,生成相应的Cookie,保存在客户端。该Cookie里面记录着用户当前的信息。 -
发送Cookie:
当用户再次访问该网站时,浏览器首先检查所有存储的Cookies,如果某个存在该网站的Cookie(即该Cookie所声明的作用范围大于等于将要请求的资源),则把该cookie附在请求资源的HTTP请求头上发送给服务器。 -
读取Cookie:
服务器接收到用户的HTTP请求报文之后,从报文头获取到该用户的Cookie,从里面找到所需要的东西。
4. 缺陷
- Cookie会被附加在每个HTTP请求中,所以无形中增加了流量。
- 由于在HTTP请求中的Cookie是明文传递的,所以安全性成问题。(除非用HTTPS)
- Cookie的大小限制在4KB左右。对于复杂的存储需求来说是不够用的。
5. 添加cookie工具类:
package com.yang.seckill.utils;
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工具类
*
* @author yang
* @since 2022
*/
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
* @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的值,
*
* @param request
* @param cookieName
* @return
*/
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的值 不设置生效时间默认浏览器关闭即失效,也不编码
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue) {
setCookie(request, response, cookieName, cookieValue, -1);
}
/**
* 设置Cookie的值 在指定时间内生效,但不编码
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue, int cookieMaxage) {
setCookie(request, response, cookieName, cookieValue, cookieMaxage, false);
}
/**
* 设置Cookie的值 不设置生效时间,但编码
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue, boolean isEncode) {
setCookie(request, response, cookieName, cookieValue, -1, isEncode);
}
/**
* 设置Cookie的值 在指定时间内生效, 编码参数
*/
public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
String cookieValue, int cookieMaxage, boolean isEncode) {
doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode);
}
/**
* 设置Cookie的值 在指定时间内生效, 编码参数(指定编码)
*/
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域名
*/
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response,
String cookieName) {
doSetCookie(request, response, cookieName, "", -1, false);
}
/**
* 设置Cookie的值,并使其在指定时间内生效
*
* @param cookieMaxage cookie生效的最大秒数
*/
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 (!"localhost".equals(domainName)) {
cookie.setDomain(domainName);
}
}
cookie.setPath("/");
response.addCookie(cookie);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 设置Cookie的值,并使其在指定时间内生效
*
* @param cookieMaxage cookie生效的最大秒数
*/
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 (Exception e) {
e.printStackTrace();
}
}
/**
* 得到cookie的域名
*/
private static final String getDomainName(HttpServletRequest request) {
String domainName = null;
// 通过request对象获取访问的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;
}
}
UUID工具类
package com.yang.seckill.utils;
import java.util.UUID;
/**
* UUID工具类
*
* @author zhoubin
* @since 1.0.0
*/
public class UUIDUtil {
public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}
2.业务层
@Autowired
private UserMapper userMapper;
/**
* 登录
* @param loginVo 登录参数
* @param request
* @param response
* @return
*/
@Override
public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
//检测登录输入是否合格
if (StringUtils.isEmpty(mobile) || StringUtils.isEmpty(password)){
//return RespBean.error(RespBeanEnum.LOGIN_ERROR);
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
}
if (!ValidatorUtil.isMobile(mobile)){
//return RespBean.error(RespBeanEnum.MOBILE_ERROR);
throw new GlobalException(RespBeanEnum.MOBILE_ERROR);
}
//根据手机号获取用户
User user = userMapper.selectById(mobile);
if (null == user){
//return RespBean.error(RespBeanEnum.LOGIN_ERROR);
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
}
//判断密码是否正确
if (!MD5Util.backendPassToDBPass(password,user.getSlat()).equals(user.getPassword())){
//return RespBean.error(RespBeanEnum.LOGIN_ERROR);
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
}
//生成cookie
String ticket = UUIDUtil.uuid();
//将用户信息存放到session
request.getSession().setAttribute(ticket,user);
CookieUtil.setCookie(request,response,"userTicket",ticket);
return RespBean.success();
}
3.控制层
@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {
@Autowired
private IUserService userService;
/**
* 登录功能
* @param loginVo
* @return
*/
@RequestMapping("/doLogin")
@ResponseBody
public RespBean doLogin(LoginVo loginVo,
HttpServletRequest request,
HttpServletResponse response){
return userService.doLogin(loginVo,request,response);
}
}
4.前端页面
$("#btn-login").click(function () {
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: $("#id").val(),
password: password
},
success: function (data) {
if (data.code === 200) {
alert("登录成功");
} else {
console.log(data.code);
alert("登录失败:" + data.message);
}
},
error: function (xhr) {
alert("登录时产生未知错误!" + xhr.message);
}
})
})
完善用户登录功能
1.分布式session
之前的代码在我们之后一台应用系统,所有操作都在一台Tomcat上,没有什么问题。当我们部署多台系统,配合Nginx的时候会出现用户登录的问题。
原因:由于 Nginx 使用默认负载均衡策略(轮询),请求将会按照时间顺序逐一分发到后端应用上。也就是说刚开始我们在 Tomcat1 登录之后,用户信息放在 Tomcat1 的 Session 里。过了一会,请求又被 Nginx 分发到了 Tomcat2 上,这时 Tomcat2 上 Session 里还没有用户信息,于是又要登录。
解决方案: Session复制
1.1 注入依赖
添加spring data依赖:
<!-- spring data redis 依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- commons-pool2 对象池依赖 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
1.2 修改配置文件application.yml
在application.yml配置文件中新增redis配置:
#redis配置
redis:
#服务器地址
host: 127.0.0.1
#端口
port: 6379
#数据库
database: 0
#超时时间
timeout: 10000ms
lettuce:
pool:
#最大连接数,默认8
max-active: 8
#最大连接阻塞等待时间,默认-1
max-wait: 10000ms
#最大空闲连接,默认8
max-idle: 8
#最小空闲连接,默认0
min-idle: 0
1.3 Redis配置类
新建一个Redis配置类RedisConfig,实现key的序列化,若不做配置,存入的数据将是二进制数据,若只是存session倒无所谓,而其存入的是一个用户对象,有必要进行序列化处理。
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
//key序列化
redisTemplate.setKeySerializer(new StringRedisSerializer());
//value序列化
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
//hash类型 key序列化
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
//hash类型 value序列化
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
//注入连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}
}
1.4 用户信息存入Redis
在业务层UserServiceImpl中注入前面写好的Redis配置redisTemplate,在业务层UserServiceImpl类的登录方法中,原本的登录逻辑是将用户信息存入session,现在保持其他业务逻辑不变,将用户信息由存入session改为存入redis中:
//将用户信息存放到session
//request.getSession().setAttribute(ticket,user);
//将用户信息存入redis中
redisTemplate.opsForValue().set("user:" + ticket,user);
在IUserService类中新建一个根据cookie获取用户的getUserByCookie方法:
/**
* 根据cookie获取用户
* @param userTicket
* @return
*/
User getUserByCookie(String userTicket,HttpServletRequest request, HttpServletResponse response);
去业务层实现上面的getUserByCookie方法:
/**
* 根据cookie获取用户
* @param userTicket
* @param request
* @param response
* @return
*/
@Override
public User getUserByCookie(String userTicket,HttpServletRequest request, HttpServletResponse response) {
if(StringUtils.isEmpty(userTicket)){
return null;
}
User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
if (user != null){
CookieUtil.setCookie(request,response,"userTicket",userTicket);
}
return user;
}
在控制层GoodsController类中注入IUserService对象,修改商品跳转页功能toList:
//商品跳转页
@RequestMapping("/toList")
public String toList(HttpServletRequest request, HttpServletResponse response, Model model, @CookieValue("userTicket") String ticket){
if (StringUtils.isEmpty(ticket)){
return "login";
}
//根据ticket获取用户
User user = userService.getUserByCookie(ticket,request,response);
if (user == null){
return "login";
}
//将用户对象传到前端页面
model.addAttribute("user",user);
return "goodsList";
}
2.优化登录功能
按照目前的写法来说,登录完成之后,后续作的每一步操作都需要判断用户是否登录,每一个接口都要判断ticket有没有,根据这个ticket去redis里面获取信息,再判断用户是否存在,如果这些步骤都没有问题,才会真正去执行该接口想要执行的方法。如果每个接口都要去写这些步骤,那么就会很麻烦,使得项目显得很臃肿、繁琐。解决方法:在接口方法那里传入一个User对象的参数,在对User对象参数进行接收之前就对其进行了非空校验,若校验通过,则让其传入此User对象参数。
2.1 自定义用户参数
新建一个自定义用户参数的配置类UserArgumentResolver,实现HandlerMethodArgumentResolver接口:
// 自定义用户参数
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
@Autowired
private IUserService userService;
// 校验是否为User类,假如返回true,才会执行下面的resolveArgument方法
@Override
public boolean supportsParameter(MethodParameter parameter) {
Class<?> clazz = parameter.getParameterType();
return clazz == User.class;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
// 通过webRequest获取HttpServletRequest类的request
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
// 通过webRequest获取HttpServletResponse类的response
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
String ticket = CookieUtil.getCookieValue(request, "userTicket");
if (StringUtils.isEmpty(ticket)){
return null;
}
return userService.getUserByCookie(ticket,request,response);
}
}
可以看到,这个接口有两个方法,supportsParameter和resolveArgument。
方法supportsParameter很好理解,返回值是boolean类型,它的作用是判断Controller层中的参数,是否满足条件,满足条件则执行resolveArgument方法,不满足则跳过。
而resolveArgument方法呢,它只有在supportsParameter方法返回true的情况下才会被调用。用于处理一些业务,将返回值赋值给Controller层中的这个参数。
因此呢,我们可以将HandlerMethodArgumentResolver理解为是一个参数解析器,我们可以通过写一个类实现HandlerMethodArgumentResolver接口来实现对Controller层中方法参数的修改。
2.2 MVC配置类
新建一个WebConfig配置类实现WebMvcConfigurer接口,把我们编写的这个类,注册到配置文件中去:
// MVC配置类
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Autowired
private UserArgumentResolver userArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
2.3 简化控制层代码
经过前面的自定义配置类后,可以减少传入参数的数量,大大简化了控制层的业务逻辑,例如在控制层的GoodsController类的toList方法中,只需要传入一个User参数,就可代替前面所用的HTTP响应参数,即可简化后面的业务判断和业务处理。
//商品跳转页
@RequestMapping("/toList")
public String toList(Model model, User user){
//将用户对象传到前端页面
model.addAttribute("user",user);
model.addAttribute("goodsList",goodService.findGoodsVo());
return "goodsList";
}
商品列表页
由于前面的项目中已经把项目所需的数据库表都建立好了,所以只需运行之前准备好的代码生成器,就可以得到每个表所对应的实体类、业务层、服务处、控制层以及xml文件。
1.实体类Vo
在商品列表页goodsList.html中,需要我们展示商品名称,商品图片,商品原价,秒杀价,库存数量和商品详情,而这些信息分别存放在两个不同的数据库表商品表和秒杀商品表中,代码生成器只能帮我们生成对应的数据库表,因此需要自己新建一个实体类GoodsVo,来关联以上需要展示的信息。
//商品返回对象
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoodsVo extends Goods {
/**
* 秒杀价格
*/
private BigDecimal seckillPrice;
/**
* 库存数量
*/
private Integer stockCount;
/**
* 秒杀开始时间
*/
private Date startDate;
/**
* 秒杀结束时间
*/
private Date endDate;
}
2.持久层
在持久层GoodsMapper类中定义一个获取商品列表的方法findGoodsVo:
/**
* 获取商品列表
* @return
*/
List<GoodsVo> findGoodsVo();
去到GoodsMapper.xml中编写获取商品列表的sql语句:
<select id="findGoodsVo" resultType="com.yang.seckill.vo.GoodsVo">
SELECT
g.id,
g.goods_name,
g.goods_title,
g.goods_img,
g.goods_detail,
g.goods_price,
g.goods_stock,
sg.seckill_price,
sg.stock_count,
sg.start_date,
sg.end_date
FROM
seckill.t_goods g
LEFT JOIN
seckill.t_seckill_goods AS sg
ON
g.id = sg.goods_id
</select>
3.业务层
在业务层IGoodsService类中定义一个获取商品列表的方法findGoodsVo,并在GoodsServiceImpl类中实现该方法:
/**
* 获取商品列表
* @return
*/
List<GoodsVo> findGoodsVo();
@Autowired
private GoodsMapper goodsMapper;
/**
* 获取商品列表
* @return
*/
@Override
public List<GoodsVo> findGoodsVo() {
return goodsMapper.findGoodsVo();
}
4.控制层
@Autowired
private IGoodsService goodService;
//商品跳转页
@RequestMapping("/toList")
public String toList(Model model, User user){
//将用户对象传到前端页面
model.addAttribute("user",user);
model.addAttribute("goodsList",goodService.findGoodsVo());
return "goodsList";
}
5.前端页面
5.1 完善配置类
正常来说,约定大于配置,如果有配置文件,又有配置类,则配置类是优先加载的,而先前在配置类中实现了WebMvcConfigurer接口,没有做任何处理时,则默认在此处寻找静态资源,像图片这种资源可能无法加载,则配置类WebConfig中实现添加静态资源路径的addResourceHandlers方法,配置静态资源的存放路径。
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
5.2 商品列表前端页面
前端使用了thymeleaf模板,因此使用的时候要特别注意资源文件的路径。
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>商品列表</title>
<!-- jquery -->
<script type="text/javascript" src="/js/jquery.min.js"></script>
<!-- bootstrap -->
<link rel="stylesheet" type="text/css" href="/bootstrap/css/bootstrap.min.css"/>
<script type="text/javascript" src="/bootstrap/js/bootstrap.min.js"></script>
<!-- layer -->
<script type="text/javascript" src="/layer/layer.js"></script>
<!-- common.js -->
<script type="text/javascript" src="/js/common.js"></script>
</head>
<body>
<div class="panel panel-default">
<div class="panel-heading">秒杀商品列表</div>
<table class="table" id="goodslist">
<tr>
<td>商品名称</td>
<td>商品图片</td>
<td>商品原价</td>
<td>秒杀价</td>
<td>库存数量</td>
<td>详情</td>
</tr>
<tr th:each="goods,goodsStat : ${goodsList}">
<td th:text="${goods.goodsName}"></td>
<td><img th:src="@{${goods.goodsImg}}" width="100" height="100"/></td>
<td th:text="${goods.goodsPrice}"></td>
<td th:text="${goods.seckillPrice}"></td>
<td th:text="${goods.stockCount}"></td>
<td><a th:href="'/goodsDetail.htm?goodsId='+${goods.id}">详情</a></td>
</tr>
</table>
</div>
</body>
</html>
商品详情页
1.持久层
在GoodsMapper类中新定义一个通过商品id获取商品详情的方法findGoodsVoByGoodsId:
/**
* 获取商品详情
* @return
* @param goodsId
*/
GoodsVo findGoodsVoByGoodsId(Long goodsId);
去到GoodsMapper.xml中编写获取商品详情的sql语句:
<select id="findGoodsVoByGoodsId" resultType="com.yang.seckill.vo.GoodsVo">
SELECT
g.id,
g.goods_name,
g.goods_title,
g.goods_img,
g.goods_detail,
g.goods_price,
g.goods_stock,
sg.seckill_price,
sg.stock_count,
sg.start_date,
sg.end_date
FROM
seckill.t_goods g
LEFT JOIN
seckill.t_seckill_goods AS sg
ON
g.id = sg.goods_id
WHERE
g.id = #{goodsId}
</select>
2.业务层
在service包下的IGoodsService接口中定义一个获取商品详情的方法findGoodsVoByGoodsId,并在GoodsServiceImpl类中实现该方法:
/**
* 获取商品详情
* @param goodsId
* @return
*/
GoodsVo findGoodsVoByGoodsId(Long goodsId);
/**
* 获取商品详情
* @param goodsId
* @return
*/
@Override
public GoodsVo findGoodsVoByGoodsId(Long goodsId) {
return goodsMapper.findGoodsVoByGoodsId(goodsId);
}
3.控制层
/**
* 跳转商品详情页
* @return
*/
@RequestMapping("/toDetail/{goodsId}")
public String toDetail(Model model, User user, @PathVariable Long goodsId){
model.addAttribute("user",user);
model.addAttribute("goods",goodService.findGoodsVoByGoodsId(goodsId));
return "goodsDetail";
}
4.前端页面
想要跳转至商品详情页,正常的逻辑是先经过商品列表页,点击“详情”实现跳转,因此,需要把goodsList.html中的跳转页面
<td><a th:href="'/goodsDetail.htm?goodsId='+${goods.id}">详情</a></td>
改为:
<td><a th:href="'/goods/toDetail/'+${goods.id}">详情</a></td>
接着引入商品详情的前端页面:
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>商品详情</title>
<!-- jquery -->
<script type="text/javascript" src="/js/jquery.min.js"></script>
<!-- bootstrap -->
<link rel="stylesheet" type="text/css" href="/bootstrap/css/bootstrap.min.css"/>
<script type="text/javascript" src="/bootstrap/js/bootstrap.min.js"></script>
<!-- layer -->
<script type="text/javascript" src="/layer/layer.js"></script>
<!-- common.js -->
<script type="text/javascript" src="/js/common.js"></script>
</head>
<body>
<div class="panel panel-default">
<div class="panel-heading">秒杀商品详情</div>
<div class="panel-body">
<span th:if="${user eq null}"> 您还没有登录,请登陆后再操作<br/></span>
<span>没有收货地址的提示。。。</span>
</div>
<table class="table" id="goods">
<tr>
<td>商品名称</td>
<td colspan="3" th:text="${goods.goodsName}"></td>
</tr>
<tr>
<td>商品图片</td>
<td colspan="3"><img th:src="@{${goods.goodsImg}}" width="200" height="200"/></td>
</tr>
<tr>
<td>秒杀开始时间</td>
</tr>
<tr>
<td>商品原价</td>
<td colspan="3" th:text="${goods.goodsPrice}"></td>
</tr>
<tr>
<td>秒杀价</td>
<td colspan="3" th:text="${goods.seckillPrice}"></td>
</tr>
<tr>
<td>库存数量</td>
<td colspan="3" th:text="${goods.stockCount}"></td>
</tr>
</table>
</div>
</body>
<script>
</script>
</html>
完善商品详情页-秒杀倒计时和秒杀按钮
1.控制层
倒计时后端的业务处理都在GoodsController类的toDetail方法中进行处理,因此这里没有涉及到业务层的业务逻辑:
/**
* 跳转商品详情页
* @return
*/
@RequestMapping("/toDetail/{goodsId}")
public String toDetail(Model model, User user, @PathVariable Long goodsId){
model.addAttribute("user",user);
GoodsVo goodsVo = goodService.findGoodsVoByGoodsId(goodsId);
Date startDate = goodsVo.getStartDate();
Date endDate = goodsVo.getEndDate();
Date nowDate = new Date();
//秒杀状态
int secKillStatus = 0;
//秒杀倒计时
int remainSeconds = 0;
//秒杀未开始
if (nowDate.before(startDate)){
remainSeconds =(int) ((startDate.getTime()-nowDate.getTime())/1000);
}else if (nowDate.after(startDate)){ //秒杀已结束
secKillStatus = 2;
remainSeconds = -1;
}else { //秒杀进行中
remainSeconds = 0;
secKillStatus = 1;
}
model.addAttribute("secKillStatus",secKillStatus);
model.addAttribute("remainSeconds",remainSeconds);
model.addAttribute("goods",goodsVo);
return "goodsDetail";
}
2.前端页面
在“秒杀开始时间”后面添加以下内容:
<td th:text="${#dates.format(goods.startDate,'yyy-MM-dd HH:mm:ss')}"></td>
<td id="seckillTip">
<input type="hidden" id="remainSeconds" th:value= "${remainSeconds}">
<span th:if="${secKillStatus eq 0}">秒杀倒计时:<span id="countDown" th:text="${remainSeconds}"></span>秒</span>
<span th:if="${secKillStatus eq 1}">秒杀进行中</span>
<span th:if="${secKillStatus eq 2}">秒杀已结束</span>
</td>
<td>
<form id="secKillForm" method="post" action="/secKill/doSecKill">
<input type="hidden" name="goodsId" th:value="${goods.id}">
<button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>
</form>
</td>
javascrit处理脚本:
$(function () {
countDown();
});
function countDown() {
var remainSeconds = $("#remainSeconds").val();
var timeout;
//秒杀未开始
if (remainSeconds > 0){
//秒杀未开始,按钮置灰
$("#buyButton").attr("disabled",true);
timeout = setTimeout(function () {
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if (remainSeconds == 0) { //秒杀进行中
$("#buyButton").attr("disabled",false);
if (timeout){
//清除倒计时
clearTimeout(timeout);
}
$("#seckillTip").html("秒杀进行中");
}else {
$("#buyButton").attr("disabled",true);
$("#seckillTip").html("秒杀已结束");
}
}
完善商品详情页-秒杀功能
1.业务层
现在service包下的IOrderService接口中定义一个秒杀的方法secKill:
/**
* 秒杀
* @param user
* @param goods
* @return
*/
Order secKill(User user, GoodsVo goods);
然后再在OrderServiceImpl类中实现该方法的业务逻辑:
@Autowired
private ISeckillGoodsService seckillGoodsService;
@Autowired
private OrderMapper orderMapper;
@Autowired
ISeckillOrderService seckillOrderService;
/**
* 秒杀
* @param user
* @param goods
* @return
*/
@Override
public Order secKill(User user, GoodsVo goods) {
//秒杀商品表减库存
SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goods.getId()));
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
seckillGoodsService.updateById(seckillGoods);
//生成订单
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goods.getId());
order.setDeliveryAddrId(0L);
order.setGoodsName(goods.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
order.setOrderChannel(1);
order.setStatus(0);
order.setCreateDate(new Date());
orderMapper.insert(order);
//生成秒杀订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setUserId(user.getId());
seckillOrder.setOrderId(order.getId());
seckillOrder.setGoodsId(goods.getId());
seckillOrderService.save(seckillOrder);
return order;
}
2.控制层
判断库存: 秒杀前先判断一下秒杀商品的库存是否为正整数,若不是,则返回“库存不足”的提示,页面跳转到“secKillFail”中;
判断是否重复抢购: 秒杀的定义是限时抢购,为了防止出现黄牛这种情况,规定每个用户只能抢购一件同类型的秒杀商品,若判断该用户重复抢购,则返回“该商品每人限购一件”的提示,页面跳转至“secKillFail”中。
@Controller
@RequestMapping("/secKill")
public class SecKillController {
@Autowired
private IGoodsService goodsService;
@Autowired
private ISeckillOrderService seckillOrderService;
@Autowired
private IOrderService orderService;
@RequestMapping("/doSecKill")
public String doSecKill(Model model, User user, Long goodsId){
if (user == null){
return "login";
}
model.addAttribute("user",user);
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goods.getStockCount() < 1){
model.addAttribute("error", RespBeanEnum.EMPTY_STOCK.getMessage());
return "secKillFail";
}
//判断是否重复抢购
SeckillOrder seckillOrder = seckillOrderService.getOne(new QueryWrapper<SeckillOrder>().eq("user_id", user.getId()).eq("goods_id", goodsId));
if (seckillOrder != null){
model.addAttribute("error",RespBeanEnum.REPEAT_ERROR.getMessage());
return "secKillFail";
}
Order order = orderService.secKill(user,goods);
model.addAttribute("order",order);
model.addAttribute("goods",goods);
return "orderDetail";
}
}
3.前端页面
3.1 失败跳转页secKillFail.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
秒杀失败:<p th:text="${error}"></p>
</body>
</html>
3.2 订单详情页orderDetail.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>订单详情</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<!-- jquery -->
<script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
<!-- bootstrap -->
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}" />
<script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
<!-- layer -->
<script type="text/javascript" th:src="@{/layer/layer.js}"></script>
<!-- common.js -->
<script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
<div class="panel-heading">秒杀订单详情</div>
<table class="table" id="order">
<tr>
<td>商品名称</td>
<td th:text="${goods.goodsName}" colspan="3"></td>
</tr>
<tr>
<td>商品图片</td>
<td colspan="2"><img th:src="@{${goods.goodsImg}}" width="200" height="200" /></td>
</tr>
<tr>
<td>订单价格</td>
<td colspan="2" th:text="${order.goodsPrice}"></td>
</tr>
<tr>
<td>下单时间</td>
<td th:text="${#dates.format(order.createDate, 'yyyy-MM-dd HH:mm:ss')}" colspan="2"></td>
</tr>
<tr>
<td>订单状态</td>
<td >
<span th:if="${order.status eq 0}">未支付</span>
<span th:if="${order.status eq 1}">待发货</span>
<span th:if="${order.status eq 2}">已发货</span>
<span th:if="${order.status eq 3}">已收货</span>
<span th:if="${order.status eq 4}">已退款</span>
<span th:if="${order.status eq 5}">已完成</span>
</td>
<td>
<button class="btn btn-primary btn-block" type="submit" id="payButton">立即支付</button>
</td>
</tr>
<tr>
<td>收货人</td>
<td colspan="2">XXX 18012345678</td>
</tr>
<tr>
<td>收货地址</td>
<td colspan="2">上海市浦东区世纪大道</td>
</tr>
</table>
</div>
</body>
</html>
缓存
为什么要做缓存?因为QPS的瓶颈最大其实就是对于数据库的操作,这里完全可以把数据库的操作提取出来放进缓存中,但不是任何数据都适合放入缓存的,一般来说,做缓存的基本上都是那种需要频繁被读取、变更很少的数据,因为放入到缓存,还得考虑到缓存和数据库的一致性问题。
1.页面缓存
页面缓存,这种缓存技术一般用于不会经常变动信息,并且访问次数较多的页面,这样就不用每次都动态加载。因为本项目页面全部用的是thymeleaf模板,当用户发送请求时需要从服务器端把整个数据全部放到对应的浏览器去展示,其传输量是比较大的,而且还要去数据库进行查询操作等,则完全可以把其放入redis中做缓存。
本项目需要做页面缓存的是商品列表页和商品详情页,因为对于一个商城项目来说,商品列表页会展示成千上万条数据,而每个商品都要有商品详情信息,若将这些需要渲染到前端的数据连同页面模板一起加载到前端页面上,这对于服务器的压力无疑是巨大的,因此要将这些前端页面放入到redis中做缓存。
由于用redis做缓存,thymeleaf做为模板引擎,所以在GoodsController类中注入RedisTemplate和ThymeleafViewResolver:
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ThymeleafViewResolver thymeleafViewResolver;
/**
* 商品跳转页(缓存商品列表页)
* @param model
* @param user
* @return
*/
@RequestMapping(value = "/toList", produces = "text/html;charset=utf-8")
@ResponseBody
public String toList(Model model, User user, HttpServletRequest request, HttpServletResponse response){
//Redis中获取页面,如果不为空,直接返回页面
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String)valueOperations.get("goodsList");
if (!StringUtils.isEmpty(html)){
return html;
}
//将用户对象传到前端页面
model.addAttribute("user",user);
model.addAttribute("goodsList",goodService.findGoodsVo());
//Redis中获取页面,如果为空,手动渲染,存入Redis并返回页面
WebContext webContext = new WebContext(request,response,request.getServletContext(),request.getLocale(),model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext);
if (!StringUtils.isEmpty(html)){
valueOperations.set("goodsList",html,60, TimeUnit.SECONDS);
}
return html;
}
/**
* 跳转商品详情页
* @return
*/
@RequestMapping(value = "/toDetail/{goodsId}", produces = "text/html;charset=utf-8")
@ResponseBody
public String toDetail(Model model, User user, @PathVariable Long goodsId, HttpServletRequest request, HttpServletResponse response){
ValueOperations valueOperations = redisTemplate.opsForValue();
//Redis中获取页面,如果不为空,直接返回页面
String html = (String) valueOperations.get("goodsDetail:"+goodsId);
if (!StringUtils.isEmpty(html)){
return html;
}
model.addAttribute("user",user);
GoodsVo goodsVo = goodService.findGoodsVoByGoodsId(goodsId);
Date startDate = goodsVo.getStartDate();
Date endDate = goodsVo.getEndDate();
Date nowDate = new Date();
//秒杀状态
int secKillStatus = 0;
//秒杀倒计时
int remainSeconds = 0;
//秒杀未开始
if (nowDate.before(startDate)){
remainSeconds =(int) ((startDate.getTime()-nowDate.getTime())/1000);
}else if (nowDate.after(startDate)){ //秒杀已结束
secKillStatus = 2;
remainSeconds = -1;
}else { //秒杀进行中
remainSeconds = 0;
secKillStatus = 1;
}
model.addAttribute("secKillStatus",secKillStatus);
model.addAttribute("remainSeconds",remainSeconds);
model.addAttribute("goods",goodsVo);
//Redis中获取页面,如果为空,手动渲染,存入Redis并返回页面
WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail",webContext);
if (!StringUtils.isEmpty(html)){
valueOperations.set("goodsDetail:"+goodsId,html,60,TimeUnit.SECONDS);
}
return html;
}
2.对象缓存
对象缓存相比页面缓存是更细粒度的缓存。在实际项目中, 不会大规模使用页面缓存,对象缓存就是当用到用户数据的时候,可以直接从缓存中取出;比如更新用户密码、根据token来获取用户缓存对象。
本项目之前已经对User对象进行了缓存,但是没有设置相应的缓存失效时间,若数据库数据更新后,没有对缓存中的对象进行相应的数据更新,则在客户端中获取到的依旧是存在缓存中的旧数据,这旧与数据库中的数据相冲突了,因此这里只针对对象缓存的应用来进行实现,也就是更新用户密码的功能。在更新密码的方法中先清除在redis中缓存的旧密码,然后再缓存入新的密码,则实现了缓存与数据库的同步更新。
2.1 密码更新-业务层
首先在IUserService接口中定义一个更新密码的updatePassword方法:
/**
* 更新密码
* @return
*/
RespBean updatePassword(String userTicket, String password, HttpServletRequest request, HttpServletResponse response);
然后去UserServiceImpl类中实现这个方法:
/**
* 更新用户密码
* @param userTicket
* @param password
* @param request
* @param response
* @return
*/
@Override
public RespBean updatePassword(String userTicket, String password, HttpServletRequest request, HttpServletResponse response) {
User user = getUserByCookie(userTicket, request, response);
if (user == null){
throw new GlobalException(RespBeanEnum.MOBILE_NOT_EXIT);
}
user.setPassword(MD5Util.inputPassToDBPass(password,user.getSlat()));
int result = userMapper.updateById(user);
if (1==result){
//删除redis
redisTemplate.delete("user:"+userTicket);
return RespBean.success();
}
return RespBean.error(RespBeanEnum.PASSWORD_UPDATE_FAIL);
}
2.2 密码更新-控制层
/**
* 更新用户密码
* @param updatePassword
* @param request
* @param response
* @return
*/
@RequestMapping("/updatePassword")
@ResponseBody
public RespBean updatePassword(String updatePassword, HttpServletRequest request, HttpServletResponse response){
String ticket = CookieUtil.getCookieValue(request,"userTicket");
RespBean password = userService.updatePassword(ticket, updatePassword, request, response);
return password;
}
由于本项目单纯的只是注重秒杀功能,因此不会可以去多理会类似密码更新这种细节功能,也没有相应的前端页面,所以只能自行去做接口测试。
页面静态化
静态化技术:所谓的静态化技术就是 将查询好的数据填充到模板中,然后将生成的html写入到指定的文件中。页面静态化就是使用静态化技术生成html页面。网页静态化技术是为了减轻数据库的访问压力,比较适合大规模且相对变化不太频繁的数据,另外网页静态化还有利于SEO。
与缓存技术相对比:缓存技术适用于小规模的数据,以及一些经常变动的数据。
本项目此前的页面缓存,缓存的是整个页面,数据量还是相当大的,所以用静态化技术将前后端进行分离,将拆分出来的页面给前端,利用浏览器的缓存,再用Ajax来进行前后端数据的交互,以减少页面传输量。
1.商品详情页面静态化
1.1 实体类Vo
新建一个详情返回对象DetailVo类:
//详情返回对象
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DetailVo {
private User user;
private GoodsVo goodsVo;
private int secKillStatus;
private int remainSeconds;
}
1.2 控制层
改写GoodsController类的跳转商品详情页方法toDetail:
/**
* 跳转商品详情页
* @return
*/
@RequestMapping("/toDetail/{goodsId}")
@ResponseBody
public RespBean toDetail(User user, @PathVariable Long goodsId){
GoodsVo goodsVo = goodService.findGoodsVoByGoodsId(goodsId);
Date startDate = goodsVo.getStartDate();
Date endDate = goodsVo.getEndDate();
Date nowDate = new Date();
//秒杀状态
int secKillStatus = 0;
//秒杀倒计时
int remainSeconds = 0;
//秒杀未开始
if (nowDate.before(startDate)){
remainSeconds =(int) ((startDate.getTime()-nowDate.getTime())/1000);
}else if (nowDate.after(endDate)){ //秒杀已结束
secKillStatus = 2;
remainSeconds = -1;
}else { //秒杀进行中
remainSeconds = 0;
secKillStatus = 1;
}
DetailVo detailVo = new DetailVo();
detailVo.setUser(user);
detailVo.setGoodsVo(goodsVo);
detailVo.setSecKillStatus(secKillStatus);
detailVo.setRemainSeconds(remainSeconds);
return RespBean.success(detailVo);
}
1.3 前端页面
改写前端页面goodsDetail.html,删除使用thymeleaf的痕迹:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>商品详情</title>
<!-- jquery -->
<script type="text/javascript" src="/js/jquery.min.js"></script>
<!-- bootstrap -->
<link rel="stylesheet" type="text/css" href="/bootstrap/css/bootstrap.min.css"/>
<script type="text/javascript" src="/bootstrap/js/bootstrap.min.js"></script>
<!-- layer -->
<script type="text/javascript" src="/layer/layer.js"></script>
<!-- common.js -->
<script type="text/javascript" src="/js/common.js"></script>
</head>
<body>
<div class="panel panel-default">
<div class="panel-heading">秒杀商品详情</div>
<div class="panel-body">
<span id="userTip"> 您还没有登录,请登陆后再操作<br/></span>
<span>没有收货地址的提示。。。</span>
</div>
<table class="table" id="goods">
<tr>
<td>商品名称</td>
<td colspan="3" id="goodsName"></td>
</tr>
<tr>
<td>商品图片</td>
<td colspan="3"><img id="goodsImg" width="200" height="200"/></td>
</tr>
<tr>
<td>秒杀开始时间</td>
<td id="startTime"></td>
<td>
<input type="hidden" id="remainSeconds">
<span id="seckillTip"></span>
</td>
<td>
<button class="btn btn-primary btn-block" type="button" id="buyButton" onclick="doSecKill()">立即秒杀
<input type="hidden" name="goodsId" id="goodsId">
</button>
</td>
</tr>
<tr>
<td>商品原价</td>
<td colspan="3" id="goodsPrice"></td>
</tr>
<tr>
<td>秒杀价</td>
<td colspan="3" id="seckillPrice"></td>
</tr>
<tr>
<td>库存数量</td>
<td colspan="3" id="stockCount"></td>
</tr>
</table>
</div>
</body>
</html>
JavaScript:
$(function () {
// countDown();
getDetails();
});
//获取商品详情
function getDetails() {
var goodsId = g_getQueryString("goodsId");
$.ajax({
url:'/goods/toDetail/'+goodsId,
type:'GET',
success:function (data) {
if (data.code === 200){
render(data.obj);
}else {
layer.msg("客户端请求出错");
}
},
error:function () {
layer.msg("客户端请求出错");
}
});
}
//页面渲染
function render(detail) {
var user = detail.user;
var goods = detail.goodsVo;
var remainSeconds = detail.remainSeconds;
if(user){
$("#userTip").hide();
}
$("#goodsName").text(goods.goodsName);
$("#goodsImg").attr("src",goods.goodsImg);
$("#startTime").text(new Date(goods.startDate).format("yyy-MM-dd HH:mm:ss"));
$("#remainSeconds").val(remainSeconds);
$("#goodsPrice").text(goods.goodsPrice);
$("#seckillPrice").text(goods.secKillPrice);
$("#stockCount").text(goods.stockCount);
countDown();
}
2.秒杀静态化
2.1 修改application.yml配置
在application.yml中添加静态资源处理的配置:
spring:
#静态资源处理
web:
resources:
#启动默认静态资源
add-mappings: true
cache:
cachecontrol:
#缓存相应时间,单位秒
max-age: 3600
chain:
#资源链启动缓存
cache: true
#启动资源链
enabled: true
#启用压缩资源(gzip,brotli)解析,默认禁用
compressed: true
static-locations: classpath:/static/
2.2 控制层
改写SecKillController类中的doSecKill方法:
/**
* 秒杀
* @param model
* @param user
* @param goodsId
* @return
*/
@RequestMapping(value = "/doSecKill", method = RequestMethod.POST)
@ResponseBody
public RespBean doSecKill(Model model, User user,Long goodsId){
if (user == null){
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goods.getStockCount() < 1){
model.addAttribute("error", RespBeanEnum.EMPTY_STOCK.getMessage());
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
//判断是否重复抢购
SeckillOrder seckillOrder = seckillOrderService.getOne(new QueryWrapper<SeckillOrder>().eq("user_id", user.getId()).eq("goods_id", goodsId));
if (seckillOrder != null){
model.addAttribute("error",RespBeanEnum.REPEAT_ERROR.getMessage());
return RespBean.error(RespBeanEnum.REPEAT_ERROR);
}
Order order = orderService.secKill(user,goods);
return RespBean.success(order);
}
2.3 前端Ajax
在goodsDetail.html中添加Ajax请求:
//商品秒杀
function doSecKill() {
var goodsId = g_getQueryString("goodsId");
$.ajax({
url: '/secKill/doSecKill',
type: 'POST',
data:{
// goodsId: $("#goodsId").val()
goodsId:goodsId
},
success:function (data) {
if (data.code === 200){
alert("成功");
window.location.href="/web/orderDetail.html?orderId="+data.obj.id;
}else {
layer.msg(data.message);
}
},
error:function () {
layer.msg("客户端请求错误");
}
})
}
3.订单详情静态化
3.1 实体类Vo
新建一个订单详情返回对象的实体类OrderDetailVo:
//订单详情返回对象
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderDetailVo {
private Order order;
private GoodsVo goodsVo;
}
3.2 服务层
在IOrderService接口中定义一个获取订单详情的方法detail:
/**
* 订单详情
* @param orderId
* @return
*/
OrderDetailVo detail(Long orderId);
并在OrderServiceImpl类中实现该方法:
/**
* 订单详情
* @param orderId
* @return
*/
@Override
public OrderDetailVo detail(Long orderId) {
if (orderId == null){
throw new GlobalException(RespBeanEnum.ORDER_NOT_EXIT);
}
Order order = orderMapper.selectById(orderId);
GoodsVo goodsvo = goodsService.findGoodsVoByGoodsId(order.getGoodsId());
OrderDetailVo detail = new OrderDetailVo();
detail.setOrder(order);
detail.setGoodsVo(goodsvo);
return detail;
}
3.3 控制层
@Controller
@RequestMapping("/order")
public class OrderController {
@Autowired
private IOrderService orderService;
@RequestMapping("/detail")
@ResponseBody
public RespBean detail(User user,Long orderId){
if (user == null){
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
OrderDetailVo orderDetail = orderService.detail(orderId);
return RespBean.success(orderDetail);
}
}
3.4 前端页面
改写前端页面orderDetail.html:
<!DOCTYPE HTML>
<html>
<head>
<title>订单详情</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<!-- jquery -->
<script type="text/javascript" src="/js/jquery.min.js"></script>
<!-- bootstrap -->
<link rel="stylesheet" type="text/css" href="/bootstrap/css/bootstrap.min.css" />
<script type="text/javascript" src="/bootstrap/js/bootstrap.min.js"></script>
<!-- layer -->
<script type="text/javascript" src="/layer/layer.js"></script>
<!-- common.js -->
<script type="text/javascript" src="/js/common.js"></script>
</head>
<body>
<div class="panel panel-default">
<div class="panel-heading">秒杀订单详情</div>
<table class="table" id="order">
<tr>
<td>商品名称</td>
<td id="goodsName" colspan="3"></td>
</tr>
<tr>
<td>商品图片</td>
<td colspan="2"><img id="goodsImg" width="200" height="200" /></td>
</tr>
<tr>
<td>订单价格</td>
<td colspan="2" id="goodsPrice"></td>
</tr>
<tr>
<td>下单时间</td>
<!-- <td id="createDate" th:text="${#dates.format(order.createDate, 'yyyy-MM-dd HH:mm:ss')}" colspan="2"></td>-->
<td id="createDate" colspan="2"></td>
</tr>
<tr>
<td>订单状态</td>
<td id="status">
<!-- <span th:if="${order.status eq 0}">未支付</span>-->
<!-- <span th:if="${order.status eq 1}">待发货</span>-->
<!-- <span th:if="${order.status eq 2}">已发货</span>-->
<!-- <span th:if="${order.status eq 3}">已收货</span>-->
<!-- <span th:if="${order.status eq 4}">已退款</span>-->
<!-- <span th:if="${order.status eq 5}">已完成</span>-->
</td>
<td>
<button class="btn btn-primary btn-block" type="submit" id="payButton">立即支付</button>
</td>
</tr>
<tr>
<td>收货人</td>
<td colspan="2">XXX 18012345678</td>
</tr>
<tr>
<td>收货地址</td>
<td colspan="2">上海市浦东区世纪大道</td>
</tr>
</table>
</div>
</body>
</html>
JavaScript:
$(function () {
getOrderDetail();
});
//获取商品详情
function getOrderDetail() {
var orderId = g_getQueryString("orderId");
$.ajax({
url:'/order/detail',
type:'GET',
data:{
orderId:orderId
},
success:function (data) {
if (data.code === 200){
render(data.obj);
}else {
layer.msg(data.message);
}
},
error:function () {
layer.msg("客户端请求错误")
}
})
}
function render(detail) {
var goods = detail.goodsVo;
var order = detail.order;
$("#goodsName").text(goods.goodsName);
$("#goodsImg").attr("src",goods.goodsImg);
$("#goodsPrice").text(order.goodsPrice);
$("#createDate").text(new Date(order.createDate).format("yyy-MM-dd HH:mm:ss"));
var status = order.status;
var statusText = "";
switch (status) {
case 0:
statusText = "未支付";
break;
case 1:
statusText = "待发货";
break;
case 2:
statusText = "已发货";
break;
case 3:
statusText = "已收货";
break;
case 4:
statusText = "已退款";
break;
case 5:
statusText = "已完成";
break;
}
$("#status").text(statusText);
}
4.解决库存超卖
首先要去数据库中添加唯一的索引,用户id+商品id联合的唯一索引,解决一个用户秒杀同种商品多次的情况:
alter table t_seckill_order add unique index(user_id,goods_id);
然后改写OrderServiceImpl类的秒杀方法secKill,此处逻辑有一个细小的优化:把秒杀订单的信息存到redis中去,方便判断是否重复抢购的时候去查询,因为每次判断是否重复抢购都需要去数据库中进行查询。
/**
* 秒杀
* @param user
* @param goods
* @return
*/
@Override
@Transactional
public Order secKill(User user, GoodsVo goods) {
//秒杀商品表减库存
SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goods.getId()));
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
boolean result = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>().setSql
("stock_count = stock_count - 1").eq("goods_id", goods.getId()).gt("stock_count", 0));
if (!result){
return null;
}
//生成订单
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goods.getId());
order.setDeliveryAddrId(0L);
order.setGoodsName(goods.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
order.setOrderChannel(1);
order.setStatus(0);
order.setCreateDate(new Date());
orderMapper.insert(order);
//生成秒杀订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setUserId(user.getId());
seckillOrder.setOrderId(order.getId());
seckillOrder.setGoodsId(goods.getId());
seckillOrderService.save(seckillOrder);
//把秒杀订单的信息存到redis中去
redisTemplate.opsForValue().set("order:"+user.getId()+":"+goods.getId(),seckillOrder);
return order;
}
然后是改写控制层的方法doSecKill:
/**
* 秒杀
* @param model
* @param user
* @param goodsId
* @return
*/
@RequestMapping(value = "/doSecKill", method = RequestMethod.POST)
@ResponseBody
public RespBean doSecKill(Model model, User user,Long goodsId){
if (user == null){
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goods.getStockCount() < 1){
model.addAttribute("error", RespBeanEnum.EMPTY_STOCK.getMessage());
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
//判断是否重复抢购
SeckillOrder seckillOrder =(SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goods.getId());
if (seckillOrder != null){
model.addAttribute("error",RespBeanEnum.REPEAT_ERROR.getMessage());
return RespBean.error(RespBeanEnum.REPEAT_ERROR);
}
Order order = orderService.secKill(user,goods);
return RespBean.success(order);
}
RabbitMQ配置
由于秒杀活动会在短时间内访问量急剧增加,所以后面的项目过程会使用到RabbitMQ消息队列,对消息进行异步处理,即把消息放入消息中间件中,等到需要的时候再去处理,当消息队列满了就拒绝响应,跳转到错误页面,这样就可以使得系统不会因为超负载而崩溃。
1.依赖注入
在pom.xml中添加RabbitMQ的依赖:
<!-- AMQP依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
2.修改配置文件application.yml
在配置文件application.yml中添加rabitmq配置:
#rabitmq配置
rabbitmq:
#服务器
host: 127.0.0.1
#用户名
username: guest
#密码
password: guest
#虚拟主机
virtual-host: /
#端口
port: 5672
listener:
simple:
#消费者最小数量
concurrency: 10
#消费者最大数量
max-concurrency: 10
#限制消费者每次只处理一条消息,处理完再继续下一条消息
prefetch: 1
#启动时是否默认启动容器,默认true
auto-startup: true
#被拒绝时重新进入队列
default-requeue-rejected: true
template:
retry:
#发布重试,默认false
enabled: true
#重试时间,默认1000ms
initial-interval: 1000ms
#重试最大次数,默认3次
max-attempts: 3
#重试最大时间间隔,默认10000ms
max-interval: 10000ms
#重试的间隔乘数。比如配2.0,第一次就等10s,第二次就等20s,第三次就等40s
multiplier: 1
3.RabbitMQ配置类
在config包下面新建一个rabbitmq配置类RabbitMQTopicConfig类:
/**
* rabbitmq配置类-Topic模式
*/
@Configuration
public class RabbitMQTopicConfig {
private static final String QUEUE = "secKillQueue";
private static final String EXCHANGE = "secKillExchange";
@Bean
public Queue queue(){
return new Queue(QUEUE);
}
@Bean
public TopicExchange topicExchange(){
return new TopicExchange(EXCHANGE);
}
@Bean
public Binding binding(){
return BindingBuilder.bind(queue()).to(topicExchange()).with("secKill.#");
}
}
性能优化
1.减少数据库的访问
减少数据库访问的具体操作:
- 系统初始化的时候,就把商品库存数量加载到Redis里,当我们收到秒杀请求时,就可以去redis中预减库存,在短时间内不会去访问数据库,减少了访问数据库的次数。
- 当redis里面库存不足的时候,直接返回,例如商品数量只有10个,在redis里面扣完10个之后,不管后面从第11个开始还是到哪里,它都不会对数据库进行操作了,也是在redis扣完直接返回了,这对系统的性能来说也是一个很大的提升了。
- 假如库存量是足够的,那么把秒杀的请求封装成一个对象发送给rabbitmq,因为最终还是要去生成订单的,可以把此过程异步出去,将前面的大量请求快速处理掉,后面再用消息队列慢慢处理。
- 异步出去后,要返回“排队中”, 因为不能确定是否下单成功,所以不能返回成功或者失败。如果返回“排队中”之后,那么真正封装出去的异步消息就会正常出队, 然后生成订单,减少库存(数据库)。
- 因为入队时返回的是“排队中”,最终还是要告诉用户一个结果,所以要在客户端做轮询,判断是否真的秒杀成功。
1.1 redis预减库存
首先,让SecKillController类继承InitializingBean接口,并实现其afterPropertiesSet方法,可以在系统初始化,把商品库存数量加载到redis:
/**
* 系统初始化,把商品库存数量加载到redis
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
List<GoodsVo> list = goodsService.findGoodsVo();
if (CollectionUtils.isEmpty(list)){
return;
}
list.forEach(goodsVo -> {
redisTemplate.opsForValue().set("secKillGoods:"+goodsVo.getId(),goodsVo.getStockCount());
EmptyStockMap.put(goodsVo.getId(),false);
}
);
}
然后,在SecKillController类的doSecKill方法中将redis中的库存按照secKillGoods的id实现递减,进而完成预减库存的操作:
//内存标记,减少redis访问
if (EmptyStockMap.get(goodsId)){
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
//预减库存
Long stock = valueOperations.decrement("secKillGoods:" + goodsId);
if (stock < 0){
//递加操作防止redis库存变为负数
valueOperations.increment("secKillGoods:" + goodsId);
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
1.2 RabbitMQ秒杀操作
新建一个SecKillMessage秒杀信息类,用来存放用户信息和商品id,然后秒杀消息类作为消息对象发送到RabbitMQ队列中,实现秒杀下单操作。SecKillMessage秒杀信息类:
/**
* 秒杀信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SecKillMessage {
private User user;
private Long goodsId;
}
然后,在rabitmq包下新建一个消息发送者类MQSender :
/**
* 消息发送者
*/
@Service
@Slf4j
public class MQSender {
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 发送秒杀消息
* @param message 秒杀消息
*/
public void sendSecKillMessage(String message){
log.info("发送消息:"+message);
rabbitTemplate.convertAndSend("secKillExchange","secKill.message",message);
}
}
然后,在在SecKillController类的doSecKill方法中添加下单操作,发送秒杀信息给rabitmq队列:
SecKillMessage secKillMessage = new SecKillMessage(user, goodsId);
//发送秒杀信息给rabitmq队列
mqSender.sendSecKillMessage(JsonUtil.object2JsonStr(secKillMessage));
//接收为0,返回“排队中”
return RespBean.success(0);
再在rabbitmq包下新建一个消息消费者类MQReceiver ,用来接收秒杀信息完成下单操作:
/**
* 消息消费者(接收者)
*/
@Service
@Slf4j
public class MQReceiver {
@Autowired
private IGoodsService goodsService;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private IOrderService orderService;
/**
* 下单操作
*/
@RabbitListener(queues = "secKillQueue")
public void receive(String message){
log.info("接收到的消息:"+message);
SecKillMessage secKillMessage = JsonUtil.jsonStr2Object(message, SecKillMessage.class);
Long goodsId = secKillMessage.getGoodsId();
User user = secKillMessage.getUser();
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
if (goodsVo.getStockCount()<1){
return;
}
//判断是否重复抢购
SeckillOrder seckillOrder =
(SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (seckillOrder != null){
return ;
}
//下单操作
orderService.secKill(user,goodsVo);
}
}
1.3 客户端轮询秒杀结果
因为入队时返回的是“排队中”,最终还是要告诉用户一个结果,所以要在客户端做轮询,判断是否真的秒杀成功。
在ISeckillOrderService接口中定义一个获取秒杀结果的方法getResult:
/**
* 获取秒杀结果
* @param user
* @param goodsId
* @return orderId:成功;-1:秒杀失败;0:排队中
*/
Long getResult(User user, Long goodsId);
然后去SeckillOrderServiceImpl类中实现该方法:
@Autowired
private SeckillOrderMapper seckillOrderMapper;
@Autowired
private RedisTemplate redisTemplate;
/**
* 获取秒杀结果
* @param user
* @param goodsId
* @return orderId:成功;-1:秒杀失败;0:排队中
*/
@Override
public Long getResult(User user, Long goodsId) {
SeckillOrder seckillOrder = seckillOrderMapper.selectOne(new QueryWrapper<SeckillOrder>().eq("user_id", user.getId()).eq("goods_id", goodsId));
if (null != seckillOrder){
return seckillOrder.getOrderId();
}else if (redisTemplate.hasKey("isStockEmpty:"+goodsId)){ //判断redis是否还有库存
return -1L;
}else {
return 0L;
}
}
注意,判断redis是否还有库存时,要去OrderServiceImpl类的秒杀方法secKill中设置redis的key:“isStockEmpty:”:
if (seckillGoods.getStockCount()<1){
//判断是否还有库存
valueOperations.set("isStockEmpty:"+goods.getId(),"0");
return null;
}
然后再SecKillController类中新定义一个获取秒杀结果的方法getResult:
/**
* 获取秒杀结果
* @param user
* @param goodsId
* @return orderId:成功;-1:秒杀失败;0:排队中
*/
@RequestMapping(value = "/result",method = RequestMethod.GET)
@ResponseBody
public RespBean getResult(User user,Long goodsId){
if (user == null){
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
Long orderId = seckillOrderService.getResult(user,goodsId);
return RespBean.success(orderId);
}
最后,在goodsDetail.html中获取轮询请求:
//商品秒杀
function doSecKill() {
var goodsId = g_getQueryString("goodsId");
$.ajax({
url: '/secKill/doSecKill',
type: 'POST',
data:{
goodsId:goodsId,
path:path
},
success:function (data) {
if (data.code === 200){
alert("成功");
// window.location.href="/web/orderDetail.html?orderId="+data.obj.id;
getResult(goodsId);
}else {
layer.msg(data.message);
}
},
error:function () {
layer.msg("客户端请求错误");
}
})
}
function getResult(goodsId) {
g_showLoading(); //加载的动画
$.ajax({
url:"/secKill/result",
type:"GET",
data:{
goodsId:goodsId,
},
success:function (data) {
if (data.code === 200){
var result = data.obj;
if (result<0){
layer.msg("对不起,秒杀失败!");
}else if (result === 0){
setTimeout(function () {
getResult(goodsId);
},50)
}else {
layer.confirm("恭喜您,秒杀成功!是否查看订单?",{btn:["确定","取消"]},
function () {
window.location.href= "/web/orderDetail.html?orderId=" + result ;
},
function () {
layer.close();
}
)
}
}
},
error:function () {
layer.msg("客户端请求错误");
}
})
}
2.Redis实现分布式锁
作为分布式锁实现过程中的共享存储系统,Redis可以使用键值对来保存锁变量,在接收和处理不同客户端发送的加锁和释放锁的操作请求。那么,键值对的键和值具体是怎么定的呢?我们要赋予锁变量一个变量名,把这个变量名作为键值对的键,而锁变量的值,则是键值对的值,这样一来,Redis就能保存锁变量了,客户端也就可以通过Redis的命令操作来实现锁操作。
想要实现分布式锁,必须要求Redis有互斥的能力。可以使用SETNX命令,其含义是SET IF NOT EXIST,即如果key不存在,才会设置它的值,否则什么也不做。两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。
在这里,要用到redis的分布式锁来优化redis的预减库存操作。
2.1 编写lua脚本
在application.yml的同级目录中新建一个stock.lua文件:
if(redis.call("exists",KEYS[1]) == 1 ) then
local stock = tonumber(redis.call("get",KEYS[1]));
if(stock>0)then
redis.call("incrby",KEYS[1],-1);
return stock;
end;
return 0;
end;
2.2 添加Redis配置类配置
在Redis配置类RedisConfig中配置lua脚本的执行路径:
@Bean
public DefaultRedisScript<Long> script(){
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
//stock.lua脚本和application.yml同级目录
redisScript.setLocation(new ClassPathResource("stock.lua"));
redisScript.setResultType(Long.class);
return redisScript;
}
2.3 redis优化预减库存
在SecKillController类中注入RedisScript,并将预减库存的语句从:
Long stock = valueOperations.decrement("secKillGoods:" + goodsId);
改为:
Long stock = (Long) redisTemplate.execute(script, Collections.singletonList("secKillGoods:" + goodsId), Collections.EMPTY_LIST);
安全优化
1.秒杀接口地址隐藏
某些人能够提前获取到秒杀接口的地址,并通过脚本或者工具不停地去点击秒杀接口,这样的刷新速度是远高于普通用户的,同时也会给服务器造成很大的压力。
隐藏秒杀接口的地址:秒杀开始的时候,并不会直接调用秒杀接口,而是去获取真正的秒杀接口的地址,并且根据每个用户秒杀的商品的不同,它的地址是不一样的,
通过接口去获取秒杀接口的地址,再根据秒杀接口的地址去进行秒杀,这样能够避免某些人提前准备好秒杀接口,通过准备好的脚本或者工具不停地去点击。
1.1 业务层
先在IOrderService接口中定义两个方法,createPath和checkPath,分别表示获取秒杀地址和校验秒杀地址:
/**
* 获取秒杀地址
* @param user
* @param goodsId
* @return
*/
String createPath(User user, Long goodsId);
/**
* 校验秒杀地址
* @param user
* @param goodsId
* @param path
* @return
*/
boolean checkPath(User user, Long goodsId, String path);
然后去OrderServiceImpl类中实现这两个方法:
/**
* 获取秒杀地址
* @param user
* @param goodsId
* @return
*/
@Override
public String createPath(User user, Long goodsId) {
String str = MD5Util.md5(UUIDUtil.uuid() + "123456");
redisTemplate.opsForValue().set("secKillPath:" + user.getId() + ":"+goodsId,str,60, TimeUnit.SECONDS);
return str;
}
/**
* 校验秒杀地址
* @param user
* @param goodsId
* @param path
* @return
*/
@Override
public boolean checkPath(User user, Long goodsId, String path) {
if (user == null || goodsId<0 || StringUtils.isEmpty(path)){
return false;
}
String redisPath = (String) redisTemplate.opsForValue().get("secKillPath:" + user.getId() + ":" + goodsId);
return path.equals(redisPath);
}
1.2 控制层
然后去到控制层SecKillController类中定义一个获取秒杀地址的接口方法:
/**
* 获取秒杀地址
* @param user
* @param goodsId
* @return
*/
@RequestMapping(value = "/path", method = RequestMethod.GET)
@ResponseBody
public RespBean getPath(User user, Long goodsId){
if (user == null){
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
String path = orderService.createPath(user,goodsId);
return RespBean.success(path);
}
修改秒杀方法doSecKill的接口请求为:
@RequestMapping(value = "/{path}/doSecKill", method = RequestMethod.POST)
再在秒杀方法中添加校验秒杀地址的过程:
boolean check = orderService.checkPath(user,goodsId,path);
if (!check){
return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
}
1.3 前端js请求
将“立即秒杀”按钮触发的函数绑定为获取秒杀接口地址的函数getSecKillPath:
<button class="btn btn-primary" type="button" id="buyButton" onclick="getSecKillPath()">立即秒杀
js代码:
//获取秒杀接口地址
function getSecKillPath() {
var goodsId = g_getQueryString("goodsId");
var captcha = $("#captcha").val();
g_showLoading();
$.ajax({
url:'/secKill/path',
type:'GET',
data:{
goodsId:goodsId,
captcha:captcha
},
success:function (data) {
if (data.code === 200){
var path = data.obj;
doSecKill(path);
}else {
layer.msg(data.message);
}
},
error:function () {
layer.msg("客户端请求错误")
}
})
}
//商品秒杀
function doSecKill(path) {
var goodsId = g_getQueryString("goodsId");
$.ajax({
url: '/secKill/' + path + '/doSecKill',
type: 'POST',
data:{
goodsId:goodsId,
path:path
},
success:function (data) {
if (data.code === 200){
alert("成功");
// window.location.href="/web/orderDetail.html?orderId="+data.obj.id;
getResult(goodsId);
}else {
layer.msg(data.message);
}
},
error:function () {
layer.msg("客户端请求错误");
}
})
}
2.秒杀验证码
单纯的依靠隐藏秒杀地址这一操作依旧是无法防止所有脚本,因此,这里需要添加验证码来区分真人和脚本,同时也能拉长秒杀的时间跨度,减少单位时间内接口的访问量。
2.1 添加依赖
在pom.xml中添加验证码的依赖:
<!--验证码依赖-->
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
2.2 生成验证码
在SecKillController类中定义一个生成验证码的方法verifyCode:
@RequestMapping(value = "/captcha", method = RequestMethod.GET)
public void verifyCode(User user, Long goodsId, HttpServletResponse response){
if (user == null || goodsId<0){
throw new GlobalException(RespBeanEnum.REQUEST_ILLEGAL);
}
//设置请求头为输出图片的类型
response.setContentType("image/jpg");
response.setHeader("Program","No-cache");
response.setHeader("Cache-Control","No-cache");
response.setDateHeader("Expires",0);
//生成验证码,将结果放入redis
ArithmeticCaptcha captcha = new ArithmeticCaptcha(130,32,3);
redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId,captcha.text(),300, TimeUnit.SECONDS);
try {
captcha.out(response.getOutputStream());
} catch (IOException e) {
log.info("验证码生成失败",e.getMessage());
}
}
然后去到前端goodsDetail.html页面添加验证码的输入框及接口请求:
<div class="row">
<div class="form-inline">
<img id="captchaImg" width="130" height="32" onclick="refreshCaptcha()" style="display: none"/>
<input id="captcha" class="form-control" style="display: none">
<button class="btn btn-primary" type="button" id="buyButton" onclick="getSecKillPath()">立即秒杀
<input type="hidden" name="goodsId" id="goodsId">
</button>
</div>
</div>
function refreshCaptcha() {
var goodsId = g_getQueryString("goodsId");
$("#captchaImg").attr("src","/secKill/captcha?goodsId=" + goodsId +"&time=" + new Date());
}
验证码只有在秒杀进行中才会出现,因此,秒杀开始前和秒杀结束后都必须隐藏起来。
2.3 校验验证码
在IOrderService接口中定义一个校验验证码的方法checkCaptcha:
/**
* 校验验证码
* @param user
* @param goodsId
* @param captcha
* @return
*/
boolean checkCaptcha(User user, Long goodsId, String captcha);
然后去OrderServiceImpl类中实现该方法:
/**
* 校验验证码
* @param user
* @param goodsId
* @param captcha
* @return
*/
@Override
public boolean checkCaptcha(User user, Long goodsId, String captcha) {
if (StringUtils.isEmpty(captcha) || user == null || goodsId <0){
return false;
}
String redisCaptcha = (String) redisTemplate.opsForValue().get("captcha:" + user.getId() + ":" + goodsId);
return captcha.equals(redisCaptcha);
}
因为输入验证码之后是获取秒杀接口地址的操作,因此,在SecKillController类的getPath方法中添加一个入参String captcha,然后再方法体中进行验证码的校验:
boolean check = orderService.checkCaptcha(user,goodsId,captcha);
if (!check){
return RespBean.error(RespBeanEnum.ERROR_CAPTCHA);
}
3.接口限流
什么是接口限流?对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机。
为什么要接口限流?在面临高并发的抢购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大的压力。大量的请求抢购成功时需要调用下单的接口,过多的请求打到数据库会对系统的稳定性造成影响。
常用的接口限流算法有计数器算法、漏桶算法和令牌桶算法,本项目中使用的算法为计数器算法。计数器是最简单的限流算法,其核心是维护一个单位时间内的计数器 counter,根据 counter 判断是否需要执行限流策略。如单位时间已经过去,则将 counter 重置为零,进入下一轮的计数周期。此算法无论在单机还是分布式环境下实现都非常简单,分布式环境下可借助 Redis 的原子自增命令 incr 来实现。
举个简单的例子,假设对于接口 A 来说,系统的处理能力只能支持它1分钟的访问次数不能超过100,那么可以采用如下做法:
设置一个接口计数器 counter,每当一个请求过来首先确定该请求与当前计数器 counter 的第 1个请求的间隔时间是否超过1分钟,超过则直接重置计数器即可,否则 counter 就加1当一个请求进入后 counter 的值大于了100,那么说明单位时间内请求量过多,后面的请求就拒绝掉,这样就完成了限流。
3.1 自定义注解@AccessLimit
在config包下自定义一个注解类AccessLimit,里面有3个成员变量second、maxCount及needLogin,此变量在方法前注释,限制了单位时间内接口的最大访问次数:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
int second();
int maxCount();
boolean needLogin() default true;
}
3.2 拦截器
拦截器可以说相当于是个过滤器:就是把不想要的或不想显示的内容给过滤掉。拦截器可以抽象出一部分代码可以用来完善原来的方法。同时可以减轻代码冗余,提高重用率。比如在登入一个页面时,如果要求用户密码、权限等的验证,就可以用自定义的拦截器进行密码验证和权限限制。对符合的登入者才跳转到正确页面。这样如果有新增权限的话,不用在action里修改任何代码,直接在interceptor里修改就行了。
拦截器的执行流程:
- 程序先执行preHandle()方法,如果该方法的返回值为true,则程序会继续向下执行处理器中的方法,否则将不再向下执行;
- 在业务处理器(即控制器Controller类)处理完请求后,会执行postHandle()方法,然后会通过DispatcherServlet向客户端返回响应;
- 在DispatcherServlet处理完请求后,才会执行afterCompletion()方法。
在config包下自定义一个拦截器AccessLimitInterceptor :
@Component
public class AccessLimitInterceptor implements HandlerInterceptor {
@Autowired
private IUserService userService;
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
User user = getUser(request,response);
UserContext.setUser(user);
if (handler instanceof HandlerMethod){
HandlerMethod hm = (HandlerMethod) handler;
//在方法上寻找注解
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
if (accessLimit == null){
return true;
}
int second = accessLimit.second();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
//返回的是完整的url,包括Http协议,端口号,servlet名字和映射路径,但它不包含请求参数
String key = request.getRequestURI();
if (needLogin){
if (user == null){
render(response, RespBeanEnum.SESSION_ERROR);
return false;
}
key+=":"+user.getId();
}
ValueOperations valueOperations = redisTemplate.opsForValue();
Integer count = (Integer) valueOperations.get(key);
if (count == null){
valueOperations.set(key,1,second, TimeUnit.SECONDS);
}else if (count<maxCount){
valueOperations.increment(key);
}else {
render(response,RespBeanEnum.ACCESS_LIMIT_REACHED);
return false;
}
}
return true;
}
/**
* 构建返回对象
* @param response
* @param
*/
private void render(HttpServletResponse response, RespBeanEnum respBeanEnum) throws IOException {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
PrintWriter out = response.getWriter();
RespBean respBean = RespBean.error(respBeanEnum);
out.write(new ObjectMapper().writeValueAsString(respBean));
out.flush();
out.close();
}
/**
* 获取当前登录用户
* @param request
* @param response
* @return
*/
private User getUser(HttpServletRequest request, HttpServletResponse response) {
String ticket = CookieUtil.getCookieValue(request,"userTicket");
if (StringUtils.isEmpty(ticket)){
return null;
}
return userService.getUserByCookie(ticket,request,response);
}
}
然后,将拦截器获取到的用户存放到ThreadLocal中,ThreadLocal是一个本地线程副本变量工具类,主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰。说人话就是,ThreadLocal在每个线程都创建副本,每个线程可以访问自己的副本,线程之间相互不影响。则在config包下新建一个UserContext类:
public class UserContext {
private static ThreadLocal<User> userHolder = new ThreadLocal<User>();
public static void setUser(User user){
userHolder.set(user);
}
public static User getUser(){
return userHolder.get();
}
}
写完拦截器后,要把拦截器放在MVC中使用。在WebConfig类中实现addInterceptors方法,在方法中添加拦截器:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(accessLimitInterceptor);
}
在拦截器中获取到用户后,就不需要在UserArgumentResolver类中的resolveArgument方法中获取用户了:
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
// // 通过webRequest获取HttpServletRequest类的request
// HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
// // 通过webRequest获取HttpServletResponse类的response
// HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
//
// String ticket = CookieUtil.getCookieValue(request, "userTicket");
//
// if (StringUtils.isEmpty(ticket)){
// return null;
// }
//
// return userService.getUserByCookie(ticket,request,response);
return UserContext.getUser();
}