
以电商网站常见的秒杀场景为例,SpringBoot整合Spring MVC、Mybatis、Redis、RabbitMQ等技术来实现高并发秒杀系统。
该系统包含常规用到的SSM技术,还包含了基于Redis的分布式Session、基于Redis的数据缓存、基于Redis的页面缓存、基于RabbitMQ的秒杀限流等。
一、系统架构 1.系统架构基于SpringBoot开发,SpringBoot主要作用就是为整合各种框架提供自动配置,实际起作用的依然是Spring MVC、Spring 、MyBatis、Redis、RabbitMQ等技术。
本系统使用Thymeleaf作为视图模板技术,并使用jQuery作为JS工具库来动态地更新页面。
本系统采用严格的Java EE应用结构,主要有如下几层:
MVC框架:
Spring框架的作用:
MyBatis的作用:
Redis的作用:
RabbitMQ的作用:
本系统大致可以分为两个模块:
业务逻辑通过UserService和MiaoshaService两个业务逻辑组件实现,使用这两个业务逻辑组件来封装Mapper组件(DAO组件)
系统以业务逻辑组件作为Mapper组件的门面,封装这些Mapper组件,业务逻辑组件底层依赖于这些Mapper组件,向上实现系统的业务逻辑功能。
本系统主要有如下4个Mapper对象:
本系统提供了如下两个业务逻辑组件:
本系统提供了如下两个消息组件:
本系统还提供了一个操作Redis的组件:FkRedisUtil,该组件基于Spring Data Redis实现
二、项目搭建本系统会用如下框架和技术:
三、领域对象层4.0.0 org.springframework.boot spring-boot-starter-parent 2.4.2 org.crazyit miaosha 1.0-SNAPSHOT miaosha UTF-8 11 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-thymeleaf org.springframework.boot spring-boot-starter-amqp org.springframework.boot spring-boot-starter-data-redis org.apache.commons commons-pool2 2.9.0 org.mybatis.spring.boot mybatis-spring-boot-starter 2.1.4 mysql mysql-connector-java org.apache.commons commons-lang3 3.11 commons-codec commons-codec 1.15 org.springframework.boot spring-boot-devtools true org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-maven-plugin
通过使用MyBatis对领域对象执行持久化操作,可以避免使用传统的JDBC方式来操作数据库。
通过利用MyBatis提供的SQL Mapping支持,从而允许程序使用面向对象的方式来操作关系数据库,保证了软件开发过程以面向对象的方式进行,即面向对象分析、面向对象设计、面向对象编程。
1.设计领域对象面向对象分析,是指根据系统需求提取应用中的对象,将这些对象抽象成类,再抽取出需要持久化保存的类,这些需要持久化保存的类就是领域对象。
该系统并没有预先设计数据库,而是完全从面向对象分析开始设计了如下领域对象类。
本系统一共包含如下5个领域对象类。
从数据库的角度来看,上面5个领域对象类所对应的数据表存在关联关系。
user_inf表的建表语句:
-- 秒杀用户表 drop table if exists user_inf; create table user_inf ( user_id bigint primary key comment '手机号码作为用户ID', nickname varchar(255) not null, password varchar(32) comment '保存加盐加密后的密码:MD5(密码, salt)', salt varchar(10), head varchar(128) comment '头像地址', register_date datetime comment '注册时间', last_login_date datetime comment '上次登录时间', login_count int comment '登录次数' ) comment='秒杀用户表';
insert into user_inf values (13500008888, 'fkjava', '7fb8f847c09ad032fbf3e3b9fcd2101f', '0p9o8i', null, curdate(), curdate(), 1), (13500006666, 'fkit', '7fb8f847c09ad032fbf3e3b9fcd2101f', '0p9o8i', null, curdate(), curdate(), 1), (13500009999, 'crazyit', '7fb8f847c09ad032fbf3e3b9fcd2101f', '0p9o8i', null, curdate(), curdate(), 1);
item_inf表的建表语句:
-- 商品表 drop table if exists item_inf; create table item_inf ( item_id bigint primary key auto_increment comment '商品ID', item_name varchar(255) comment '商品名称', title varchar(64) comment '商品标题', item_img varchar(64) comment '商品的图片', item_detail longtext comment '商品的详情介绍', item_price decimal(10,2) comment '商品单价', stock_num int comment '商品库存,-1表示没有限制' ) comment='商品表';
insert into item_inf values (1, 'Java讲义', '行销几十万册,成为海峡两岸读者之选,赠送20+小时视频、源代码、课件、面试题,微信交流答疑群', 'books/java.png', '1)作者提供用于学习和交流的配套网站及作者亲自在线的微信群、QQ群。
2)《疯狂Java讲义》历时十年沉淀,现已升级到第5版,经过无数Java学习者的反复验证,被包括北京大学在内的大量985、211高校的优秀教师引荐为参考资料、选作教材。
3)《疯狂Java讲义》曾翻译为中文繁体字版,在宝岛台湾上市发行。
4)《疯狂Java讲义》屡获殊荣,多次获取电子工业出版社的“畅销图书”、“长销图书”奖项,作者本人也多次获得“优秀作者”称号。仅第3版一版的印量即达9万多册。', 139.00, 2000); insert into item_inf values (2, '轻量级Java Web企业应用实战——Spring MVC+Spring+MyBatis整合开发', '源码级剖析Spring框架,适合已掌握Java基础或学完疯狂Java讲义的读者,送配套代码、100分钟课程。进微信群', 'books/javaweb.png', '《轻量级Java Web企业应用实战――Spring MVC+Spring+MyBatis整合开发》不是一份“X天精通Java EE开发”的“心灵鸡汤”,这是一本令人生畏的“砖头”书。1. 内容实际,针对性强本书介绍的Java EE应用示例,采用了目前企业流行的开发架构,严格遵守Java EE开发规范,而不是将各种技术杂乱地糅合在一起号称Java EE。读者参考本书的架构,完全可以身临其境地感受企业实际开发。2.框架源代码级的讲解,深入透彻 本书针对Spring MVC、Spring、MyBatis框架核心部分的源代码进行了讲解,不仅能帮助读者真正掌握框架的本质,而且能让读者参考优秀框架的源代码快速提高自己的技术功底。本书介绍的源代码解读方法还可消除开发者对阅读框架源代码的恐惧,让开发者在遇到技术问题时能冷静分析问题,从框架源代码层次找到问题根源。3.丰富、翔实的代码,面向实战本书是面向实战的技术图书,坚信所有知识点必须转换成代码才能最终变成有效的生产力,因此本书为所有知识点提供了对应的可执行的示例代码。代码不仅有细致的注释,还结合理论对示例进行了详细的解释,真正让读者做到学以致用。', 139.00, 2300); insert into item_inf values (3, 'Android讲义', 'Java语言实现,安卓经典之作,stormzhang刘望舒柯俊林启舰联合力荐,曾获评CSDN年度具有技术影响力十大原创图书', 'books/android.png', '
miaosha_item表的建表语句:
-- 秒杀商品表 drop table if exists miaosha_item; create table miaosha_item ( miaosha_id bigint primary key auto_increment comment '秒杀的商品表', item_id bigint comment '商品ID', miaosha_price decimal(10,2) comment '秒杀价', stock_count int comment '库存数量', start_date datetime comment '秒杀开始时间', end_date datetime comment '秒杀结束时间', foreign key(item_id) references item_inf(item_id) ) comment='秒杀商品表';
insert into miaosha_item values (1, 1, 1.98, 8, adddate(curdate(), -1), adddate(curdate(), 3)); insert into miaosha_item values (2, 2, 2.98, 8, adddate(curdate(), -1), adddate(curdate(), 2)); insert into miaosha_item values (3, 3, 3.98, 8, adddate(curdate(), -3), adddate(curdate(), -1)); insert into miaosha_item values (4, 4, 4.98, 8, adddate(curdate(), 1), adddate(curdate(), 5)); insert into miaosha_item values (5, 5, 5.98, 8, adddate(curdate(), -1), adddate(curdate(), 2)); insert into miaosha_item values (6, 6, 6.98, 8, adddate(curdate(), -1), adddate(curdate(), 2));
order_inf表语句:
-- 订单表 drop table if exists order_inf; create table order_inf ( order_id bigint primary key auto_increment, user_id bigint comment '用户ID', item_id bigint comment '商品ID', item_name varchar(255) comment '冗余的商品名称,用于避免多表连接', order_num int comment '购买的商品数量', order_price decimal(10,2) comment '购买价格', order_channel tinyint comment '渠道:1、PC, 2、Android, 3、iOS', order_status tinyint comment '订单状态,0新建未支付, 1已支付,2已发货, 3已收货, 4已退款,5已完成', create_date datetime comment '订单的创建时间', pay_date datetime comment '支付时间', foreign key(user_id) references user_inf(user_id), foreign key(item_id) references item_inf(item_id) ) comment='订单表';
miaosha_order表语句:
-- 秒杀订单表 drop table if exists miaosha_order; create table miaosha_order ( miaosha_order_id bigint primary key auto_increment, user_id bigint comment '用户ID', order_id bigint comment '订单ID', item_id bigint comment '商品ID', unique key(user_id, item_id), foreign key(user_id) references user_inf(user_id), foreign key(order_id) references order_inf(order_id), foreign key(item_id) references item_inf(item_id) ) comment='秒杀订单表';
该SQL语句针对miaosha_order表的user_id和item_id两列的组合定义了唯一约束,这就限制了用户不能对同一个商品进行重复秒杀。
应用开始启动时,系统中没有订单信息,因此order_inf表和miaosha_order表中没有任何数据。
2.创建领域对象类本系统使用MyBatis操作数据库,不过MyBatis并不是真正的ORM框架,只是一个结果集映射框架,因此本系统所需要的领域对象只是一些简单的数据类。
User类:
import java.util.Date;
public class User
{
private Long id;
private String nickname;
private String password;
private String salt;
private String head;
private Date registerDate;
private Date lastLoginDate;
private Integer loginCount;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public String getNickname()
{
return nickname;
}
public void setNickname(String nickname)
{
this.nickname = nickname;
}
public String getPassword()
{
return password;
}
public void setPassword(String password)
{
this.password = password;
}
public String getSalt()
{
return salt;
}
public void setSalt(String salt)
{
this.salt = salt;
}
public String getHead()
{
return head;
}
public void setHead(String head)
{
this.head = head;
}
public Date getRegisterDate()
{
return registerDate;
}
public void setRegisterDate(Date registerDate)
{
this.registerDate = registerDate;
}
public Date getLastLoginDate()
{
return lastLoginDate;
}
public void setLastLoginDate(Date lastLoginDate)
{
this.lastLoginDate = lastLoginDate;
}
public Integer getLoginCount()
{
return loginCount;
}
public void setLoginCount(Integer loginCount)
{
this.loginCount = loginCount;
}
@Override
public String toString()
{
return "User{" +
"id=" + id +
", nickname='" + nickname + ''' +
", password='" + password + ''' +
", salt='" + salt + ''' +
", head='" + head + ''' +
", registerDate=" + registerDate +
", lastLoginDate=" + lastLoginDate +
", loginCount=" + loginCount +
'}';
}
}
Item类的代码:
public class Item
{
private Long id;
private String itemName;
private String title;
private String itemImg;
private String itemDetail;
private Double itemPrice;
private Integer stockNum;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public String getItemName()
{
return itemName;
}
public void setItemName(String itemName)
{
this.itemName = itemName;
}
public String getTitle()
{
return title;
}
public void setTitle(String title)
{
this.title = title;
}
public String getItemImg()
{
return itemImg;
}
public void setItemImg(String itemImg)
{
this.itemImg = itemImg;
}
public String getItemDetail()
{
return itemDetail;
}
public void setItemDetail(String itemDetail)
{
this.itemDetail = itemDetail;
}
public Double getItemPrice()
{
return itemPrice;
}
public void setItemPrice(Double itemPrice)
{
this.itemPrice = itemPrice;
}
public Integer getStockNum()
{
return stockNum;
}
public void setStockNum(Integer stockNum)
{
this.stockNum = stockNum;
}
@Override
public String toString()
{
return "Item{" +
"id=" + id +
", itemName='" + itemName + ''' +
", title='" + title + ''' +
", itemImg='" + itemImg + ''' +
", itemDetail='" + itemDetail + ''' +
", itemPrice=" + itemPrice +
", stockNum=" + stockNum +
'}';
}
}
MiaoshaItem继承了Item类,并新增了一些实例变量。
import java.util.Date;
public class MiaoshaItem extends Item
{
private Long id;
private Long itemId;
private double miaoshaPrice;
private Integer stockCount;
private Date startDate;
private Date endDate;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public Long getItemId()
{
return itemId;
}
public void setItemId(Long itemId)
{
this.itemId = itemId;
}
public double getMiaoshaPrice()
{
return miaoshaPrice;
}
public void setMiaoshaPrice(double miaoshaPrice)
{
this.miaoshaPrice = miaoshaPrice;
}
public Integer getStockCount()
{
return stockCount;
}
public void setStockCount(Integer stockCount)
{
this.stockCount = stockCount;
}
public Date getStartDate()
{
return startDate;
}
public void setStartDate(Date startDate)
{
this.startDate = startDate;
}
public Date getEndDate()
{
return endDate;
}
public void setEndDate(Date endDate)
{
this.endDate = endDate;
}
@Override
public String toString()
{
return "MiaoshaItem{" +
"id=" + id +
", itemId=" + itemId +
", miaoshaPrice=" + miaoshaPrice +
", stockCount=" + stockCount +
", startDate=" + startDate +
", endDate=" + endDate +
'}';
}
}
Order类:
import java.util.Date;
public class Order
{
private Long id;
private Long userId;
private Long itemId;
private String itemName;
private Integer orderNum;
private Double orderPrice;
private Integer orderChannel;
private Integer status;
private Date createDate;
private Date payDate;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public Long getUserId()
{
return userId;
}
public void setUserId(Long userId)
{
this.userId = userId;
}
public Long getItemId()
{
return itemId;
}
public void setItemId(Long itemId)
{
this.itemId = itemId;
}
public String getItemName()
{
return itemName;
}
public void setItemName(String itemName)
{
this.itemName = itemName;
}
public Integer getOrderNum()
{
return orderNum;
}
public void setOrderNum(Integer orderNum)
{
this.orderNum = orderNum;
}
public Double getOrderPrice()
{
return orderPrice;
}
public void setOrderPrice(Double orderPrice)
{
this.orderPrice = orderPrice;
}
public Integer getOrderChannel()
{
return orderChannel;
}
public void setOrderChannel(Integer orderChannel)
{
this.orderChannel = orderChannel;
}
public Integer getStatus()
{
return status;
}
public void setStatus(Integer status)
{
this.status = status;
}
public Date getCreateDate()
{
return createDate;
}
public void setCreateDate(Date createDate)
{
this.createDate = createDate;
}
public Date getPayDate()
{
return payDate;
}
public void setPayDate(Date payDate)
{
this.payDate = payDate;
}
@Override
public String toString()
{
return "Order{" +
"id=" + id +
", userId=" + userId +
", itemId=" + itemId +
", itemName='" + itemName + ''' +
", orderNum=" + orderNum +
", orderPrice=" + orderPrice +
", orderChannel=" + orderChannel +
", status=" + status +
", createDate=" + createDate +
", payDate=" + payDate +
'}';
}
}
MiaoshaOrder类:
public class MiaoshaOrder
{
private Long id;
private Long userId;
private Long orderId;
private Long itemId;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public Long getUserId()
{
return userId;
}
public void setUserId(Long userId)
{
this.userId = userId;
}
public Long getOrderId()
{
return orderId;
}
public void setOrderId(Long orderId)
{
this.orderId = orderId;
}
public Long getItemId()
{
return itemId;
}
public void setItemId(Long itemId)
{
this.itemId = itemId;
}
@Override
public String toString()
{
return "MiaoshaOrder{" +
"id=" + id +
", userId=" + userId +
", orderId=" + orderId +
", itemId=" + itemId +
'}';
}
}
四、实现Mapper(Dao层)
MyBatis的主要优势就是可以使用Mapper组件来充当DAO组件,开发者只需要简单地定义Mapper接口,并通过XML文件为Mapper接口中的方法提供对应的SQL语句,这样Mapper组件就开发完成了。
使用Mapper组件充当DAO组件,使用Mapper组件再次封装数据库操作,这也是Java EE应用中常用的DAO模式。
当使用DAO模式时,既体现了业务逻辑组件封装Mapper组件的门面模式,也可分离业务逻辑组件和Mapper组件的功能:
当引入DAO模式后,每个Mapper组件都包含了数据库的访问逻辑。每个Mapper组件都可对一个数据库表完成基本的CRUD等操作。
Dao模式是一种更符合软件工程的开发方式,使用DAO模式有如下理由:
Mapper组件提供了对各持久化对象的基本的CRUD操作,而Mapper接口则负责声明该组件所应包含的各种CRUD方法。
MyBatis Mapper组件中的方法并不会由框架自动提供,而是必须由开发者自行定义,并为之提供对应的SQL语句,因此Mapper组件中的方法可能会随着业务逻辑的需求而增加。
UserMaper接口定义:
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.crazyit.app.domain.User;
@Mapper
public interface UserMapper
{
// 根据user_id查询user_inf表的记录
@Select("select user_id as id, nickname, password, salt, head, " +
"register_date as registerDate, last_login_date as lastLoginDate, " +
"login_count as loginCount from user_inf where user_id = #{id}")
User findById(long id);
// 更新user_inf表的记录
@Update("update user_inf set last_login_date = #{lastLoginDate}" +
", login_count=#{loginCount} where user_id = #{id}")
void update(User user);
}
UserMapper根据需要提供了两个业务方法:
MiaoshaItemMapper接口定义如下:
import org.apache.ibatis.annotations.*;
import org.crazyit.app.domain.MiaoshaItem;
import java.util.List;
@Mapper
public interface MiaoshaItemMapper
{
// 查询所有秒杀商品
@Select("select it.*,mi.stock_count, mi.start_date, mi.end_date, " +
"mi.miaosha_price from miaosha_item mi left join item_inf " +
"it on mi.item_id = it.item_id")
@Results(id = "itemMapper", value = {
@Result(property = "itemId", column = "item_id"),
@Result(property = "itemName", column = "item_name"),
@Result(property = "title", column = "title"),
@Result(property = "itemImg", column = "item_img"),
@Result(property = "itemDetail", column = "item_detail"),
@Result(property = "itemPrice", column = "item_price"),
@Result(property = "stockNum", column = "stock_num"),
@Result(property = "miaoshaPrice", column = "miaosha_price"),
@Result(property = "stockCount", column = "stock_count"),
@Result(property = "startDate", column = "start_date"),
@Result(property = "endDate", column = "end_date")
})
List findAll();
// 根据商品ID查询秒杀商品
@Select("select it.*,mi.stock_count, mi.start_date, mi.end_date, " +
"mi.miaosha_price from miaosha_item mi left join item_inf it " +
"on mi.item_id = it.item_id where it.item_id = #{itemId}")
@ResultMap("itemMapper")
MiaoshaItem findById(@Param("itemId") long itemId);
// 更新miaosha_item表中的记录
@Update("update miaosha_item set stock_count = stock_count - 1" +
" where item_id = #{itemId}")
int reduceStock(MiaoshaItem miaoshaItem);
}
提供三个方法:
OrderMapper接口定义如下:
import org.apache.ibatis.annotations.*;
import org.crazyit.app.domain.Order;
@Mapper
public interface OrderMapper
{
// 向order_inf表插入新的记录
@Insert("insert into order_inf(user_id, item_id, item_name, order_num, " +
"order_price, order_channel, order_status, create_date) values" +
"(#{userId}, #{itemId}, #{itemName}, #{orderNum}, #{orderPrice}, " +
"#{orderChannel}, #{status}, #{createDate})")
// 指定获取向order_inf插入记录时所获取的自增长主键值
@Options(useGeneratedKeys = true, keyProperty = "id")
long save(Order order);
// 根据订单ID和下单用户的ID来获取订单
@Select("select order_id as id, user_id as userId, item_id as itemId, " +
"item_name as itemName, order_num as orderNum, order_price as " +
"orderPrice, order_channel as orderChannel, order_status as " +
"status, create_date as createDate, pay_date as payDate from " +
"order_inf where order_id = #{param1} and user_id = #{param2}")
Order findByIdAndOwnerId(long orderId, long userId);
}
MiaoshaOrderMapper接口定义如下:
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.crazyit.app.domain.MiaoshaOrder;
@Mapper
public interface MiaoshaOrderMapper
{
// 根据用户ID和商品ID获取秒杀订单
@Select("select miaosha_order_id as id, user_id as userId, order_id as " +
"orderId, item_id as itemId from miaosha_order " +
"where user_id=#{userId} and item_id=#{itemId}")
MiaoshaOrder findByUserIdItemId(@Param("userId") long userId,
@Param("itemId") long itemId);
// 插入秒杀订单
@Insert("insert into miaosha_order(user_id, item_id, order_id) values " +
"(#{userId}, #{itemId}, #{orderId})")
int save(MiaoshaOrder miaoshaOrder);
}
Mapper接口只需要定义Mapper组件应该实现的方法,并在Mapper接口中的方法上通过注解配置对应的SQL语句即可,这些SQL语句就是实现Mapper组件中方法的关键代码。
2.部署Mapper组件只需要在application.properties文件中指定连接数据库的必要信息,SpringBoot就会自动在容器中配置数据源、SqlSessionFactory等基础组件。有了这些基础组件之后,SpringBoot会自动扫描Mapper接口上的@Mapper注解,并将它们部署成容器中的Bean。
# 数据库驱动 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # 数据库URL spring.datasource.url=jdbc:mysql://localhost:3306/miaosha_app?serverTimezone=UTC # 连接数据库的用户名 spring.datasource.username=root # 连接数据库的密码 spring.datasource.password=32147五、分布式Session及用户登录的实现
高并发的秒杀系统都是分布式应用,需要使用分布式Session。本系统的用户登录及权限管理采用了分布式Session,这种分布式Session是基于Redis实现的。
1.实现Redis组件本系统的分布式Session,以及后面的缓存机制,都是基于Redis实现的。
为了让SpringBoot能为整合Redis提供自动配置,需要在application.properties文件中添加如下配置。
# -----------Redis有关的配置----------- spring.redis.host=localhost spring.redis.port=6379 # 指定连接Redis的DB0数据库 spring.redis.database=0 # 连接密码 spring.redis.password=32147 # 指定连接池中最大的活动连接数为20 spring.redis.lettuce.pool.maxActive = 20 # 指定连接池中最大的空闲连接数为20 spring.redis.lettuce.pool.maxIdle=20 # 指定连接池中最小的空闲连接数为2 spring.redis.lettuce.pool.minIdle = 2
经过上面配置,SpringBoot就会在容器中为Redis自动配置RedisConnectionFactory、StringRedisTemplate,接下来只要将StringRedisTemplate组件注入其他组件即可。
2.Redis工具类本系统开发了一个工具类对RedisTemplate进行封装,使用封装后的工具类可以更方便地操作本系统中的key-value对,包括添加key-value对、根据key获取对应的value、根据key删除指定的key-value对、判断指定的key是否存在等。
FkRedisUtil类:
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class FkRedisUtil
{
private final RedisTemplate redisTemplate;
private static final ObjectMapper objectMapper = new ObjectMapper();
public FkRedisUtil(RedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;}
// 根据key获取对应的值
public T get(KeyPrefix prefix, String key, Class clazz)
{
// 实际的key由prefix和key组成
String realKey = prefix.getPrefix() + key;
// 根据key获取对应的value
String str = redisTemplate.opsForValue().get(realKey);
try
{
// 将读取的字符串恢复成T对象
return stringToBean(str, clazz);
}
catch (JsonProcessingException e)
{
e.printStackTrace();
}
return null;
}
// 添加key-value对
public Boolean set(KeyPrefix prefix, String key, T value)
{
String str = null;
try
{
// 将T对象序列化为字符串
str = beanToString(value);
}
catch (JsonProcessingException e)
{
e.printStackTrace();
}
if (str == null || str.length() <= 0)
{
return false;
}
// 实际的key由prefix和key组成,且prefix还决定key的过期时间
String realKey = prefix.getPrefix() + key;
// 获取过期时间
int seconds = prefix.expireSeconds();
// expireSeconds为过期时间,seconds <= 0代表永不过期
if (seconds <= 0)
{
// 此处向Redis中添加普通key,value就是字符串
// 不设置过期时间,就是永不过期
redisTemplate.opsForValue().set(realKey, str);
}
else
{
// 最后一个参数设置过期时间,此处的过期事件以秒为单位
redisTemplate.opsForValue().set(realKey, str,
Duration.ofSeconds(seconds));
}
return true;
}
// 判断指定key是否存在
public Boolean exists(KeyPrefix prefix, String key)
{
String realPrefix = prefix.getPrefix() + key;
return redisTemplate.hasKey(realPrefix);
}
// 根据key删除数据
public Boolean delete(KeyPrefix prefix, String key)
{
String realPrefix = prefix.getPrefix() + key;
// 删除指定key及对应的数据
return redisTemplate.delete(realPrefix);
}
// 对指定key的值加一
public Long incr(KeyPrefix prefix, String key)
{
String realPrefix = prefix.getPrefix() + key;
return redisTemplate.opsForValue().increment(realPrefix);
}
// 对指定key的值减一
public Long decr(KeyPrefix prefix, String key)
{
String realPrefix = prefix.getPrefix() + key;
return redisTemplate.opsForValue().decrement(realPrefix);
}
// 将对象转成JSON字符串
public static String beanToString(T value)
throws JsonProcessingException
{
if (value == null)
{
return null;
}
Class> clazz = value.getClass();
// 如果要转换的对象是整型,通过添加空字符串将其转成字符串
if (clazz == Integer.class || clazz == int.class)
{
return "" + value;
}
else if (Long.class == clazz || clazz == long.class)
{
return "" + value;
}
else if (clazz == String.class)
{
return (String) value;
}
else
{
// 使用Jackson将对象转换成JSON字符串
return objectMapper.writevalueAsString(value);
}
}
// 将JSON字符串转成对象
public static T stringToBean(String str, Class clazz)
throws JsonProcessingException
{
if (str == null || str.length() <= 0 || clazz == null)
{
return null;
}
// 如果要恢复的目标对象类型是整型,调用对应的valueOf方法进行转换
if (clazz == int.class || clazz == Integer.class)
{
return (T) Integer.valueOf(str);
}
else if (clazz == long.class || clazz == Long.class)
{
return (T) Long.valueOf(str);
}
else if (clazz == String.class)
{
return (T) str;
}
else
{
// 使用Jackson将JSON字符串转换成对象
return objectMapper.readValue(str, clazz);
}
}
}
private final RedisTemplate
public T get(KeyPrefix prefix, String key, Class clazz)
KeyPrefix是一个自定义的接口,该接口定义了prefix及过期时间。
接口代码如下所示:
public interface KeyPrefix
{
int expireSeconds();
String getPrefix();
}
当程序后面需要向Redis中添加key-value对时,只要传入不同的KeyPrefix参数,即可同时实现两个目的:
为了便于后面为KeyPrefix提供实现类,此处先为KeyPrefix提供一个抽象实现类,该抽象实现类会作为其他KeyPrefix类的基类
public abstract class AbstractPrefix implements KeyPrefix
{
private final int expireSeconds;
private final String prefix;
public AbstractPrefix(String prefix)
{
// 小于0代表永不过期
this(-1, prefix);
}
public AbstractPrefix(int expireSeconds, String prefix)
{
// 设置过期时间
this.expireSeconds = expireSeconds;
this.prefix = prefix;
}
@Override
public int expireSeconds()
{
return expireSeconds;
}
// getPrefix将会返回“类名:prefix”的形式
@Override
public String getPrefix()
{
String className = getClass().getSimpleName();
return className + ":" + prefix;
}
}
getPrefix()方法:该方法的返回值是"类名:prefix"的形式,意味着实际得到dkey前缀总是由类名和prefix组成。
5.分布式Session的实现实现流程大致如下:
为了实现上面的流程,首先定义一个可用于操作cookie的工具类。
appcontrollercookieUtil.java
import org.crazyit.app.redis.UserKey;
import org.crazyit.app.util.UUIDUtil;
import javax.servlet.http.cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class cookieUtil
{
// 工具方法,该方法将SessionID以cookie形式写入浏览器
public static void addSessionId(HttpServletResponse response, String token)
{
// 使用cookie存放分布式Session的ID
cookie cookie = new cookie(UserKey.cookie_NAME_TOKEN, token);
cookie.setMaxAge(UserKey.token.expireSeconds());
cookie.setPath("/");
response.addcookie(cookie);
}
// 工具方法,用于读取指定cookie的值
public static String getcookievalue(HttpServletRequest request,
String cookieName)
{
// 获取所有cookie
cookie[] cookies = request.getcookies();
if (cookies == null || cookies.length <= 0)
{
return null;
}
// 遍历所有cookie
for (cookie cookie : cookies)
{
// 找到并返回目标cookie的值
if (cookie.getName().equals(cookieName))
{
return cookie.getValue();
}
}
return null;
}
// 工具方法,通过cookie读取分布式Session的ID,如果不存在则创建它
public static String getSessionId(HttpServletRequest request,
HttpServletResponse response)
{
// 通过cookie获取分布式SessionID
String token = cookieUtil.getcookievalue(request,
UserKey.cookie_NAME_TOKEN);
// 如果SessionID为null,表明第一次访问该系统或cookie已过期
if (token == null)
{
// 生成随机字符串,该字符串将作为分布式SessionID
token = UUIDUtil.uuid();
// 将分布SessionID以cookie写入浏览器
addSessionId(response, token);
}
return token;
}
}
工具类定义了如下3个方法:
appcontrollerUserController.java
import org.apache.commons.lang3.StringUtils;
import org.crazyit.app.domain.User;
import org.crazyit.app.exception.MiaoshaException;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.redis.UserKey;
import org.crazyit.app.result.CodeMsg;
import org.crazyit.app.result.Result;
import org.crazyit.app.service.UserService;
import org.crazyit.app.util.MD5Util;
import org.crazyit.app.vo.LoginVo;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
@Controller
@RequestMapping("/user")
public class UserController
{
private final UserService userService;
private final FkRedisUtil fkRedisUtil;
public UserController(UserService userService, FkRedisUtil fkRedisUtil)
{
this.userService = userService;
this.fkRedisUtil = fkRedisUtil;
}
@GetMapping("/login")
public String toLogin()
{
return "login";
}
@GetMapping(value = "/verifyCode")
@ResponseBody
public void getLoginVerifyCode(HttpServletRequest request,
HttpServletResponse response) throws IOException
{
// 从cookie中读取分布式Session ID
String token = cookieUtil.getSessionId(request, response);
// 创建验证码图片
BufferedImage image = userService.createVerifyCode(token);
OutputStream out = response.getOutputStream();
// 输出验证码
ImageIO.write(image, "JPEG", out);
out.flush();
out.close();
}
@PostMapping("/proLogin")
@ResponseBody
public Result proLogin(HttpServletRequest request,
HttpServletResponse response, LoginVo loginVo)
{
// 通过cookie获取分布式SessionID
String token = cookieUtil.getcookievalue(request,
UserKey.cookie_NAME_TOKEN);
// 如果代表分布式SessionID的cookie存在
if (token != null)
{
// 如果输入的验证码不匹配
if (!userService.checkVerifyCode(token,
loginVo.getVercode()))
{
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
// 从分布式Session中读取用户信息
User user = getByToken(response, token);
// 判断从Session中读取的信息与登录信息是否匹配
if (user != null && user.getId().toString().equals(
loginVo.getMobile()) && MD5Util.passToDbPass(
loginVo.getPassword(),
user.getSalt()).equals(user.getPassword()))
{
return Result.success(true); // ①
}
}
try
{
// 处理登录,返回符合条件的用户
User user = userService.login(loginVo); // ②
// 使用分布式Session保存登录用户的信息
addSession(response, token, user);
return Result.success(true);
}
catch (MiaoshaException e)
{
return Result.error(e.getCodeMsg());
}
}
// 该方法使用Redis缓存实现分布式Session
// 该方法将Session信息保存在Redis缓存中,SessionID以cookie写入浏览器
private void addSession(HttpServletResponse response, String token, User user)
{
// 以Redis缓存保存分布式Session信息
fkRedisUtil.set(UserKey.token, token, user);
// 使用cookie存放分布式Session的ID
cookieUtil.addSessionId(response, token);
}
// 该方法用于根据分布式SessionID读取对应的User
public User getByToken(HttpServletResponse response, String token)
{
if (StringUtils.isEmpty(token))
{
return null;
}
// 根据分布式SessionID读取对应的User
User user = fkRedisUtil.get(UserKey.token, token, User.class);
// 延长有效期,保证有效期总是最后一次访问时间再加上Session过期时间
if (user != null)
{
// 重新往缓存中设置token,并生成新的cookie,这样就达到了延长有效期的目的
addSession(response, token, user);
}
return user;
}
@GetMapping("/info")
@ResponseBody
public Result info(User user)
{
return Result.success(user);
}
}
addSession()方法用于将User对象添加到分布式Session中,getByToken()方法则用于通过分布式Session ID读取User对象。
fkRedisUtil.set(UserKey.token, token, user);使用FkRedisUtil读写key-value对时,用到了UserKey类,该类实现了前面的KeyPrefix接口,因此即指定了所添加key的前缀,也指定了所添加key的有效时间。
UserKey类的源代码:
appredisUserKey.java
public class UserKey extends AbstractPrefix
{
public static final String cookie_NAME_TOKEN = "token";
public static final int TOKEN_EXPIRE = 1800;
public UserKey(int expireSeconds, String prefix)
{
super(expireSeconds, prefix);
}
// 定义用于保存分布式Session ID的key
public static UserKey token = new UserKey(TOKEN_EXPIRE, "token");
// 0代表永不过期
public static UserKey getById = new UserKey(0, "id");
// 用于保存验证码的key
public static UserKey verifyCode = new UserKey(300, "vc");
}
User.token代表的key的过期时间为1800秒,User.token代表的key前缀为UserKey:token,其中token就创建UserKey对象时传入的第2个参数。
定义一个拦截器添加分布式Session ID,该拦截器代码如下:
appaccessAccessInterceptor.java
import org.apache.commons.lang3.StringUtils;
import org.crazyit.app.controller.cookieUtil;
import org.crazyit.app.controller.UserController;
import org.crazyit.app.domain.User;
import org.crazyit.app.redis.AccessKey;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.redis.UserKey;
import org.crazyit.app.result.CodeMsg;
import org.crazyit.app.result.Result;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
@Component
public class AccessInterceptor implements HandlerInterceptor
{
private final UserController userController;
private final FkRedisUtil fkRedisUtil;
public AccessInterceptor(UserController userController,
FkRedisUtil fkRedisUtil)
{
this.userController = userController;
this.fkRedisUtil = fkRedisUtil;
}
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception
{
// 获取或创建分布式Session的ID
cookieUtil.getSessionId(request, response);
User user = getUser(request, response);
// 将读取到的User信息存入UserContext的ThreadLocal容器中
UserContext.setUser(user);
if (handler instanceof HandlerMethod)
{
HandlerMethod hm = (HandlerMethod) handler;
// 获取被调用方法上的@AccessLimit注解
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
// 如果没有@AccessLimit注解,直接返回true(放行)
if (accessLimit == null)
{
return true;
}
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI();
// 如果needLogin为true,表明需要登录才能调用该方法
if (needLogin)
{
// 如果user为null,表明还为登录,直接拒绝调用
if (user == null)
{
render(response, CodeMsg.SESSION_ERROR);
return false;
}
}
// 如果设置了seconds和maxCount两个属性,
// 表明要限制在指定时间内指定方法只能被调用几次
if (seconds > 0 && maxCount > 0)
{
key += "_" + user.getId();
AccessKey ak = AccessKey.withExpire(seconds);
// 以ak为前缀、加上用户手机号作为真正的key来获取访问次数
Integer count = fkRedisUtil.get(ak, key, Integer.class);
// 如果count为null,表明之前不曾访问过
if (count == null)
{
fkRedisUtil.set(ak, key, 1);
}
// 如果访问次数还未达到最大次数,则可继续访问
else if (count < maxCount)
{
// 访问次数加1
fkRedisUtil.incr(ak, key);
}
// 如果访问次数达到限制
else
{
// 生成错误提示
render(response, CodeMsg.ACCESS_LIMIT_REACHED);
return false;
}
}
}
return true;
}
// 该方法用于根据CodeMsg生成错误响应
private void render(HttpServletResponse response,
CodeMsg cm) throws IOException
{
response.setContentType("application/json;charset=UTF-8");
OutputStream out = response.getOutputStream();
// 将CodeMsg包装成Result对象,再将它转换成字符串
String str = FkRedisUtil.beanToString(Result.error(cm));
// 输出响应字符串
out.write(str.getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
private User getUser(HttpServletRequest request, HttpServletResponse response)
{
// 获取名为token的请求参数
String paramToken = request.getParameter(UserKey.cookie_NAME_TOKEN);
// 获取名为token的cookie的值
String cookieToken = cookieUtil.getcookievalue(request, UserKey.cookie_NAME_TOKEN);
if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken))
{
return null;
}
// 优先使用paramToken作为分布式Session的ID
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
// 根据分布式Session ID获取Session对象
return userController.getByToken(response, token);
}
}
AccessInterceptor实现了HandlerInterceptor接口,该实现类中的preHandle()方法会拦截所有控制器的处理方法(只要将它配置成拦截器即可),而preHandle()方法中调用了cookieUtil的getSessionId()方法来获取或创建分布式Session Id,意味着只要用户访问该系统中任意控制器的方法,该拦截器就会向访问者的浏览器写入cookie,通过该cookie来保存分布式Session ID。
六、用户登录的实现系统的登录功能所使用的页面模板是login.html页面,当用户提交登录请求后,其输入的用户名、密码被提交到/user/proLogin,登录成功,系统将会跳转到/item/list,否则依然停留在login.html页面,并使用Layer库显示提示信息。
appcontrollerUserController.java
import org.apache.commons.lang3.StringUtils;
import org.crazyit.app.domain.User;
import org.crazyit.app.exception.MiaoshaException;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.redis.UserKey;
import org.crazyit.app.result.CodeMsg;
import org.crazyit.app.result.Result;
import org.crazyit.app.service.UserService;
import org.crazyit.app.util.MD5Util;
import org.crazyit.app.vo.LoginVo;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
@Controller
@RequestMapping("/user")
public class UserController
{
private final UserService userService;
private final FkRedisUtil fkRedisUtil;
public UserController(UserService userService, FkRedisUtil fkRedisUtil)
{
this.userService = userService;
this.fkRedisUtil = fkRedisUtil;
}
@GetMapping("/login")
public String toLogin()
{
return "login";
}
@GetMapping(value = "/verifyCode")
@ResponseBody
public void getLoginVerifyCode(HttpServletRequest request,
HttpServletResponse response) throws IOException
{
// 从cookie中读取分布式Session ID
String token = cookieUtil.getSessionId(request, response);
// 创建验证码图片
BufferedImage image = userService.createVerifyCode(token);
OutputStream out = response.getOutputStream();
// 输出验证码
ImageIO.write(image, "JPEG", out);
out.flush();
out.close();
}
@PostMapping("/proLogin")
@ResponseBody
public Result proLogin(HttpServletRequest request,
HttpServletResponse response, LoginVo loginVo)
{
// 通过cookie获取分布式SessionID
String token = cookieUtil.getcookievalue(request,
UserKey.cookie_NAME_TOKEN);
// 如果代表分布式SessionID的cookie存在
if (token != null)
{
// 如果输入的验证码不匹配
if (!userService.checkVerifyCode(token,
loginVo.getVercode()))
{
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
// 从分布式Session中读取用户信息
User user = getByToken(response, token);
// 判断从Session中读取的信息与登录信息是否匹配
if (user != null && user.getId().toString().equals(
loginVo.getMobile()) && MD5Util.passToDbPass(
loginVo.getPassword(),
user.getSalt()).equals(user.getPassword()))
{
return Result.success(true); // ①
}
}
try
{
// 处理登录,返回符合条件的用户
User user = userService.login(loginVo); // ②
// 使用分布式Session保存登录用户的信息
addSession(response, token, user);
return Result.success(true);
}
catch (MiaoshaException e)
{
return Result.error(e.getCodeMsg());
}
}
// 该方法使用Redis缓存实现分布式Session
// 该方法将Session信息保存在Redis缓存中,SessionID以cookie写入浏览器
private void addSession(HttpServletResponse response, String token, User user)
{
// 以Redis缓存保存分布式Session信息
fkRedisUtil.set(UserKey.token, token, user);
// 使用cookie存放分布式Session的ID
cookieUtil.addSessionId(response, token);
}
// 该方法用于根据分布式SessionID读取对应的User
public User getByToken(HttpServletResponse response, String token)
{
if (StringUtils.isEmpty(token))
{
return null;
}
// 根据分布式SessionID读取对应的User
User user = fkRedisUtil.get(UserKey.token, token, User.class);
// 延长有效期,保证有效期总是最后一次访问时间再加上Session过期时间
if (user != null)
{
// 重新往缓存中设置token,并生成新的cookie,这样就达到了延长有效期的目的
addSession(response, token, user);
}
return user;
}
@GetMapping("/info")
@ResponseBody
public Result info(User user)
{
return Result.success(user);
}
}
映射了如下3个URL地址:
/user/proLogin对应的proLogin()方法先从客户端cookie中读取Session ID,然后根据该Session ID从Redis中读取User信息(Session信息)。如果从Redis中读取到的User信息与登录的User信息相同,则表明用户在重复登陆,因此直接返回登录成功。
只有当用户之前不曾登录时,proLogin()方法才会调用UserService的login()方法来处理用户登录。
import org.crazyit.app.dao.UserMapper;
import org.crazyit.app.domain.User;
import org.crazyit.app.exception.MiaoshaException;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.redis.UserKey;
import org.crazyit.app.result.CodeMsg;
import org.crazyit.app.util.MD5Util;
import org.crazyit.app.util.VercodeUtil;
import org.crazyit.app.vo.LoginVo;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.awt.image.BufferedImage;
import java.util.Date;
import java.util.Random;
@Service
public class UserService
{
private final UserMapper userMapper;
private final FkRedisUtil fkRedisUtil;
public UserService(UserMapper userMapper, FkRedisUtil fkRedisUtil)
{
this.userMapper = userMapper;
this.fkRedisUtil = fkRedisUtil;
}
// 创建图形验证码
public BufferedImage createVerifyCode(String token)
{
if (token == null)
{
return null;
}
Random rdm = new Random();
// 调用VercodeUtil的generateVerifyCode生成图形验证码
String verifyCode = VercodeUtil.generateVerifyCode(rdm);
// 计算图形验证码的值
int rnd = VercodeUtil.calc(verifyCode);
// 将验证码的值存到Redis中
fkRedisUtil.set(UserKey.verifyCode, token, rnd);
// 返回生成的图片
return VercodeUtil.createVerifyImage(verifyCode, rdm);
}
// 检查图形验证码是否正确
public boolean checkVerifyCode(String token, int verifyCode)
{
if (token == null)
{
return false;
}
// 从Redis中读取服务端保存的验证码
Integer codeOld = fkRedisUtil.get(UserKey.verifyCode,
token, Integer.class);
// 如果codeOld为空或codeOld与verifyCode不同,则返回false
if (codeOld == null || codeOld - verifyCode != 0)
{
return false;
}
// 清除服务端保存的图形验证码
fkRedisUtil.delete(UserKey.verifyCode, token);
return true;
}
// 处理用户登录的方法
@Transactional
public User login(LoginVo loginVo)
{
if (loginVo == null)
{
throw new MiaoshaException(CodeMsg.SERVER_ERROR);
}
String mobile = loginVo.getMobile();
// 根据手机号获取对应的用户
User user = getById(Long.parseLong(mobile)); // ①
// 如果user为null,说明该用户不存在
if (user == null)
{
throw new MiaoshaException(CodeMsg.MOBILE_NOT_EXIST);
}
// 获取数据库中保存的密码
String dbPass = user.getPassword();
// 计算加盐加密后的密码
String calcPass = MD5Util.passToDbPass(loginVo.getPassword(),
user.getSalt());
// 如果加盐加密后的密码与数据库中保存的密码不相等,登录失败
if (!calcPass.equals(dbPass))
{
throw new MiaoshaException(CodeMsg.PASSWORD_ERROR);
}
// 增加登录次数
user.setLoginCount(user.getLoginCount() + 1);
// 更新最后的登录时间
user.setLastLoginDate(new Date());
// 更新用户信息
userMapper.update(user);
return user;
}
private User getById(long id)
{
// 先从Redis缓存中根据ID读取用户
User user = fkRedisUtil.get(UserKey.getById,
"" + id, User.class);
if (user != null)
{
return user;
}
// 如果Redis缓存中没有读到用户,从数据库中根据ID读取用户
user = userMapper.findById(id);
if (user != null)
{
// 将读取的用户存入Redis缓存
fkRedisUtil.set(UserKey.getById, "" + id, user);
}
return user;
}
}
/user/verifyCode对应的getLoginVerifyCode()方法同样也是先从客户端cookie中读取Session ID,然后调用UserService的createVerifyCode()方法来生成图形验证码,并将图形验证码输出到客户端。
login()方法中调用getById()方法根据手机号来获取用户,在获取用户后,先对用户输入的密码进加盐加密,然后用加盐加密后的密码与数据库中的密码进行比较,如果两个密码相同即可认为登录成功。
MD5Util工具类:
import org.apache.commons.codec.digest.DigestUtils;
public class MD5Util
{
public static String md5(String src)
{
return DigestUtils.md5Hex(src);
}
public static String passToDbPass(String formPass, String randSalt)
{
String str = "" + randSalt.charAt(0) + randSalt.charAt(2)
+ formPass + randSalt.charAt(5) + randSalt.charAt(4);
return md5(str);
}
public static void main(String[] args)
{
// 加盐加密后的密码
System.out.println(passToDbPass("123456", "0p9o8i"));
}
}
passToDbPass()方法就用于对指定密码执行加密加盐。
getById()方法的逻辑比较简单,该方法先尝试从Redis缓存中根据ID(手机号)读取用户。如果Redis缓存中没有对应的用户,则尝试从底层数据库中读取用户,如果从底层数据库中读取到了对应的用户,则将该用户存入Redis缓存中。
UserService调用VercodeUtil的generateVerifyCode()方法生成随机的图形验证码,还调用calc()方法来计算验证码的值,并调用createVefifyImage()方法生成验证码图片。
VercodeUtil是用于生成验证码的工具类,使用的是表达式验证码,会在验证码图片上生成一个表达式,用户必须填写该表达式的值才能通过验证。
import org.crazyit.app.domain.User;
import org.crazyit.app.redis.MiaoshaKey;
import javax.script.scriptEngine;
import javax.script.scriptEngineManager;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;
public class VercodeUtil
{
private static final char[] ops = new char[]{'+', '-', '*'};
// 生成图形验证码的表达式
public static String generateVerifyCode(Random rdm)
{
// 生成四个随机整数
int num1 = rdm.nextInt(10) + 1;
int num2 = rdm.nextInt(10) + 1;
int num3 = rdm.nextInt(10) + 1;
int num4 = rdm.nextInt(10) + 1;
var opsLen = ops.length;
// 生成三个随机的运算符
char op1 = ops[rdm.nextInt(opsLen)];
char op2 = ops[rdm.nextInt(opsLen)];
char op3 = ops[rdm.nextInt(opsLen)];
// 将整数和运算符拼接成表达式
return "" + num1 + op1 + num2 + op2 + num3 + op3 + num4;
}
// 根据图形验证码表达式来生成验证码图片
public static BufferedImage createVerifyImage(String verifyCode, Random rdm)
{
var width = 120;
var height = 32;
// 创建图形
var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
// 设置背景色
g.setColor(new Color(0xDCDCDC));
g.fillRect(0, 0, width, height);
// 绘制边框
g.setColor(Color.black);
g.drawRect(0, 0, width - 1, height - 1);
// 生成一些干扰椭圆
for (int i = 0; i < 50; i++)
{
int x = rdm.nextInt(width);
int y = rdm.nextInt(height);
g.drawOval(x, y, 0, 0);
}
// 设置颜色
g.setColor(new Color(0, 100, 0));
// 设置字体
g.setFont(new Font("Candara", Font.BOLD, 24));
// 绘制图形验证码
g.drawString(verifyCode, 8, 24);
g.dispose();
// 返回图片
return image;
}
public static int calc(String exp)
{
try
{
// 获取脚本引擎,用于计算表达式的值
scriptEngineManager manager = new scriptEngineManager();
scriptEngine engine = manager.getEngineByName("Javascript");
// 计算表达式的值
return (Integer) engine.eval(exp);
}
catch (Exception e)
{
e.printStackTrace();
return 0;
}
}
}
generateVerifyCode()用于生成验证码表达式,先生成4个随机的整数,再生成3个随机的运算符,拼接起来就组成了验证码表达式。
createVerifyImage()方法则使用了AWT的Graphics来绘制图片。
工具类的calc()方法使用了scriptEngine的eval()方法来计算表达式的值,此处使用了JDK内置的Javascript脚本引擎,通过使用Javascript请求可以非常方便计算表达式的值。
本系统的登录页面会使用jQuery发送请求来执行异步登录,并使用Layer库来显示登录结果。
九、秒杀商品列表及缓存的实现登录 用户登录
/item/list用于显示秒杀商品列表,由于秒杀商品列表页面需要被频繁地访问,且该页面并不需要针对不同用户提供不同的界面,因此本系统会对该页面的静态内容进行缓存。
1.秒杀商品列表ItemController控制器定义了显示秒杀商品列表的处理方法
import org.apache.commons.lang3.StringUtils;
import org.crazyit.app.access.AccessLimit;
import org.crazyit.app.domain.MiaoshaItem;
import org.crazyit.app.domain.User;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.redis.ItemKey;
import org.crazyit.app.result.Result;
import org.crazyit.app.service.MiaoshaService;
import org.crazyit.app.vo.ItemDetailVo;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.thymeleaf.context.IWebContext;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
@Controller
@RequestMapping("/item")
public class ItemController
{
private final MiaoshaService miaoshaService;
private final FkRedisUtil fkRedisUtil;
// 定义ThymeleafViewResolver用于解析Thymeleaf页面模板
private final ThymeleafViewResolver thymeleafViewResolver;
public ItemController(MiaoshaService miaoshaService, FkRedisUtil fkRedisUtil,
ThymeleafViewResolver thymeleafViewResolver)
{
this.miaoshaService = miaoshaService;
this.fkRedisUtil = fkRedisUtil;
this.thymeleafViewResolver = thymeleafViewResolver;
}
@GetMapping("/list")
@ResponseBody
@AccessLimit // 限制该方法必须登录才能访问
public String list(HttpServletRequest request,
HttpServletResponse response, User user)
{
// 从Redis缓存中取数据
String html = fkRedisUtil.get(ItemKey.itemList, "", String.class);
// 如果缓存中有HTML页面,直接返回HTML页面
if (!StringUtils.isEmpty(html))
{
return html;
}
// 如果缓存中没有HTML页面才会去执行查询
// 查询秒杀商品列表
List itemList = miaoshaService.listMiaoshaItem(); // ①
IWebContext ctx = new WebContext(request, response,
request.getServletContext(), request.getLocale(),
Map.of("user", user, "itemList", itemList));
// 渲染静态的HTML内容
html = thymeleafViewResolver.getTemplateEngine().process("item_list", ctx);
// 将静态HTML内容存入缓存
if (!StringUtils.isEmpty(html))
{
fkRedisUtil.set(ItemKey.itemList, "", html); // ②
}
return html;
}
@GetMapping(value = "/detail/{itemId}")
@ResponseBody
@AccessLimit // 限制该方法必须登录才能访问
public Result detail(User user,
@PathVariable("itemId") long itemId)
{
MiaoshaItem item = miaoshaService.getMiaoshaItemById(itemId);
// 获取秒杀开始时间
long startAt = item.getStartDate().getTime();
// 获取秒杀的结束时间
long endAt = item.getEndDate().getTime();
long now = System.currentTimeMillis();
// 定义距离开始秒杀还有多久的变量
int remainSeconds;
if (now < startAt)
{
// 秒杀还没开始
remainSeconds = (int) ((startAt - now) / 1000);
}
else if (now > endAt)
{
// 秒杀已结束
remainSeconds = -1;
}
else
{
// 秒杀进行中
remainSeconds = 0;
}
// 定义秒杀还剩多久结束的变量
var leftSeconds = (int) ((endAt - now ) / 1000);
// 创建ItemDetailVo,用于封装秒杀商品详情
ItemDetailVo itemDetailVo = new ItemDetailVo();
itemDetailVo.setMiaoshaItem(item);
itemDetailVo.setUser(user);
itemDetailVo.setRemainSeconds(remainSeconds);
itemDetailVo.setLeftSeconds(leftSeconds);
return Result.success(itemDetailVo);
}
}
fkRedisUtil.get用于从Redis缓存中读取渲染后的HTML静态内容,只有当该静态内容不存在时,该控制器才会去调用MiaoshaService的listMiaoshaItem()方法来获取所有秒杀商品。
fkRedisUtil.set通过listMiaoshaItem()方法获取所有的秒杀商品列表之后,使用ThymeleafViewResolver来执行页面渲染,生成静态HTML页面内容,并将静态的HTML页面内容存入Redis缓存中。
这样,当多个用户高并发地访问该列表页面时,只有第一次访问"/item/list"的用户才真正需要调用Service组件的方法,查询底层数据库,其他用户都会直接使用Redis缓存中的HTML页面内容。
appredisItemKey.java
public class ItemKey extends AbstractPrefix
{
public ItemKey(int expireSeconds, String prefix)
{
super(expireSeconds, prefix);
}
// 缓存秒杀商品列表页面的key前缀
public static ItemKey itemList = new ItemKey(120, "list");
// 缓存秒杀商品库存的key前缀
public static ItemKey miaoshaItemStock = new ItemKey(0, "stock");
}
ItemKey决定了缓存秒杀商品列表页面的时间是120秒,也就是2分钟,意味着2分钟内不管有多少并发请求,list()处理方法只需要调用Service组件一次即可,这样就可以从容面对高并发请求。
在MiaoshaService中获取秒杀列表的方法如下:
import java.awt.image.BufferedImage;
import java.util.Date;
import java.util.List;
import java.util.Random;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.crazyit.app.dao.MiaoshaItemMapper;
import org.crazyit.app.dao.MiaoshaOrderMapper;
import org.crazyit.app.dao.OrderMapper;
import org.crazyit.app.domain.MiaoshaItem;
import org.crazyit.app.domain.MiaoshaOrder;
import org.crazyit.app.domain.Order;
import org.crazyit.app.domain.User;
import org.crazyit.app.redis.MiaoshaKey;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.redis.OrderKey;
import org.crazyit.app.util.MD5Util;
import org.crazyit.app.util.UUIDUtil;
import org.crazyit.app.util.VercodeUtil;
@Service
public class MiaoshaService
{
private final FkRedisUtil fkRedisUtil;
private final MiaoshaItemMapper miaoshaItemMapper;
private final OrderMapper orderMapper;
private final MiaoshaOrderMapper miaoshaOrderMapper;
public MiaoshaService(FkRedisUtil fkRedisUtil,
MiaoshaItemMapper miaoshaItemMapper,
OrderMapper orderMapper,
MiaoshaOrderMapper miaoshaOrderMapper)
{
this.fkRedisUtil = fkRedisUtil;
this.miaoshaItemMapper = miaoshaItemMapper;
this.orderMapper = orderMapper;
this.miaoshaOrderMapper = miaoshaOrderMapper;
}
// 列出所有秒杀商品的方法
public List listMiaoshaItem()
{
return miaoshaItemMapper.findAll();
}
// 根据商品ID获取秒杀商品的方法
public MiaoshaItem getMiaoshaItemById(long itemId)
{
return miaoshaItemMapper.findById(itemId);
}
// 执行秒杀的方法
@Transactional
public Order miaosha(User user, MiaoshaItem item)
{
// 将秒杀商品的库存减1
boolean success = reduceStock(item);
if (success)
{
// 创建普通订单和秒杀订单
return createOrder(user, item);
}
else
{
// 如果秒杀失败,将该商品的秒杀状态设为已结束
fkRedisUtil.set(MiaoshaKey.isItemOver,
"" + item.getId(), true);
return null;
}
}
// 将秒杀商品的库存减1
public boolean reduceStock(MiaoshaItem miaoshaItem)
{
int ret = miaoshaItemMapper.reduceStock(miaoshaItem);
return ret > 0;
}
// 根据用户id和物品id返回秒杀订单id,
// 如果没有秒杀成功,当秒杀结束时返回-1,秒杀未结束时返回0
public long getMiaoshaResult(Long userId, long itemId)
{
// 根据用户ID和商品ID获取秒杀订单
MiaoshaOrder order = getMiaoshaOrderByUserIdAndItemId(userId, itemId);
// 如果秒杀订单不为null,返回订单ID
if (order != null)
{
return order.getOrderId();
}
else
{
// 根据物品ID获取该商品的秒杀状态
boolean isOver = fkRedisUtil
.exists(MiaoshaKey.isItemOver, "" + itemId);
// 如果秒杀已经结束返回-1
if (isOver)
{
return -1;
}
// 否则返回0
else
{
return 0;
}
}
}
// 判断用户输入的秒杀地址是否正确
public boolean checkPath(User user, long itemId, String path)
{
if (user == null || path == null)
{
return false;
}
// 获取Redis缓存的UUID字符串
String pathOld = fkRedisUtil.get(MiaoshaKey.miaoshaPath, ""
+ user.getId() + "_" + itemId, String.class);
// 拿用户输入的UUID字符串与Redis缓存的UUID字符串进行比较
return path.equals(pathOld);
}
// 生成秒杀地址的方法
public String createMiaoshaPath(User user, long itemId)
{
if (user == null || itemId <= 0)
{
return null;
}
// 先生成UUID字符串,对UUID字符串进行MD5加密
String str = MD5Util.md5(UUIDUtil.uuid());
// 将动态生成的秒杀地址存入Redis中
fkRedisUtil.set(MiaoshaKey.miaoshaPath, ""
+ user.getId() + "_" + itemId, str);
return str;
}
// 生成秒杀图形验证码
public BufferedImage createVerifyCode(User user, long itemId)
{
if (user == null || itemId <= 0)
{
return null;
}
Random rdm = new Random();
String verifyCode = VercodeUtil.generateVerifyCode(rdm);
int rnd = VercodeUtil.calc(verifyCode);
// 将验证码的值存到Redis中
fkRedisUtil.set(MiaoshaKey.miaoshaVerifyCode,
user.getId() + "," + itemId, rnd);
// 返回生成的图片
return VercodeUtil.createVerifyImage(verifyCode, rdm);
}
// 检查用户输入的秒杀验证码是否正确
public boolean checkVerifyCode(User user, long itemId, int verifyCode)
{
if (user == null || itemId <= 0)
{
return false;
}
// 获取Redis中保存的验证码
Integer codeOld = fkRedisUtil.get(MiaoshaKey.miaoshaVerifyCode,
user.getId() + "," + itemId, Integer.class);
// 拿用户输入的验证码与Redis中保存的验证码进行比较
if (codeOld == null || codeOld - verifyCode != 0)
{
return false;
}
// 删除Redis中保存的验证码
fkRedisUtil.delete(MiaoshaKey.miaoshaVerifyCode,
user.getId() + "," + itemId);
return true;
}
// 根据用户ID和商品ID获取秒杀订单
public MiaoshaOrder getMiaoshaOrderByUserIdAndItemId(long userId, long itemId)
{
// 从Redis缓存读取订单
return fkRedisUtil.get(OrderKey.miaoshaOrderByUserIdAndItemId,
"" + userId + "_" + itemId, MiaoshaOrder.class);
}
// 创建普通订单和秒杀订单
@Transactional
public Order createOrder(User user, MiaoshaItem item)
{
// 创建普通订单
var order = new Order();
// 设置订单信息
order.setUserId(user.getId());
order.setCreateDate(new Date());
order.setOrderNum(1);
order.setItemId(item.getItemId());
order.setItemName(item.getItemName());
order.setOrderPrice(item.getMiaoshaPrice());
order.setOrderChannel(1);
// 设置订单状态,0代表未支付订单
order.setStatus(0);
// 保存普通订单
orderMapper.save(order);
// 创建秒杀订单
var miaoshaOrder = new MiaoshaOrder();
// 设置秒杀订单信息
miaoshaOrder.setUserId(user.getId());
miaoshaOrder.setItemId(item.getItemId());
miaoshaOrder.setOrderId(order.getId());
// 保存秒杀订单
miaoshaOrderMapper.save(miaoshaOrder);
// 将秒杀订单保存到Redis缓存中
fkRedisUtil.set(OrderKey.miaoshaOrderByUserIdAndItemId,
"" + user.getId() + "_" + item.getItemId(), miaoshaOrder);
return order;
}
// 根据订单ID和用户ID获取订单的方法
public Order getOrderByIdAndOwnerId(long orderId, long userId)
{
return orderMapper.findByIdAndOwnerId(orderId, userId);
}
}
MiaoshaService组件调用了miaoshaItemMapper组件的findAll()方法来获取所有的秒杀商品列表。
2.自定义User参数解析器在ItemController的list()方法中有一个User参数,该方法必须在用户登录之后才能调用,很明显该参数应该从Redis(分布式Session)中读取,所有需要使用自定义的User参数解析器来处理该User参数。
appconfigUserArgumentResolver.java
import org.crazyit.app.access.UserContext;
import org.crazyit.app.domain.User;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver
{
// 该方法的返回true则表明要解析该参数
@Override
public boolean supportsParameter(MethodParameter methodParameter)
{
// 获取要解析的参数类型
Class> clazz = methodParameter.getParameterType();
// 只有当该返回值为true时,才会调用下面的resolveArgument方法解析参数
return clazz == User.class;
}
@Override
public Object resolveArgument(MethodParameter methodParameter,
ModelAndViewContainer modelAndViewContainer,
NativeWebRequest nativeWebRequest,
WebDataBinderFactory webDataBinderFactory)
{
// 将UserContext的getUser()方法的返回值作为User参数的值
return UserContext.getUser();
}
}
参数解析器类实现了HandlerMethodArgumentResolver接口,它的resolveArgument()方法将负责解析控制器处理方法中的参数。clazz == User.class;决定了该参数解析器只会解析User参数
resolveArgument()方法则以UserContext的getUser()方法的返回值作为User参数值。
appaccessUserContext.java
import org.crazyit.app.domain.User;
public class UserContext
{
private static final ThreadLocal userHolder = new ThreadLocal<>();
public static void setUser(User user)
{
userHolder.set(user);
}
public static User getUser()
{
return userHolder.get();
}
}
通过UserContext类的代码可以看到,UserContext只是使用ThreadLocal容器来保存User信息,ThreadLocal会保证每个线程都持有一个User副本,而UserArgumentResolver的resolveArgument()方法只是从ThreadLocal容器中获取User对象,AccessInterceptor拦截器负责将User对象放入该ThreadLocal容器中。
import org.apache.commons.lang3.StringUtils;
import org.crazyit.app.controller.cookieUtil;
import org.crazyit.app.controller.UserController;
import org.crazyit.app.domain.User;
import org.crazyit.app.redis.AccessKey;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.redis.UserKey;
import org.crazyit.app.result.CodeMsg;
import org.crazyit.app.result.Result;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
@Component
public class AccessInterceptor implements HandlerInterceptor
{
private final UserController userController;
private final FkRedisUtil fkRedisUtil;
public AccessInterceptor(UserController userController,
FkRedisUtil fkRedisUtil)
{
this.userController = userController;
this.fkRedisUtil = fkRedisUtil;
}
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception
{
// 获取或创建分布式Session的ID
cookieUtil.getSessionId(request, response);
User user = getUser(request, response);
// 将读取到的User信息存入UserContext的ThreadLocal容器中
UserContext.setUser(user);
if (handler instanceof HandlerMethod)
{
HandlerMethod hm = (HandlerMethod) handler;
// 获取被调用方法上的@AccessLimit注解
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
// 如果没有@AccessLimit注解,直接返回true(放行)
if (accessLimit == null)
{
return true;
}
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI();
// 如果needLogin为true,表明需要登录才能调用该方法
if (needLogin)
{
// 如果user为null,表明还为登录,直接拒绝调用
if (user == null)
{
render(response, CodeMsg.SESSION_ERROR);
return false;
}
}
// 如果设置了seconds和maxCount两个属性,
// 表明要限制在指定时间内指定方法只能被调用几次
if (seconds > 0 && maxCount > 0)
{
key += "_" + user.getId();
AccessKey ak = AccessKey.withExpire(seconds);
// 以ak为前缀、加上用户手机号作为真正的key来获取访问次数
Integer count = fkRedisUtil.get(ak, key, Integer.class);
// 如果count为null,表明之前不曾访问过
if (count == null)
{
fkRedisUtil.set(ak, key, 1);
}
// 如果访问次数还未达到最大次数,则可继续访问
else if (count < maxCount)
{
// 访问次数加1
fkRedisUtil.incr(ak, key);
}
// 如果访问次数达到限制
else
{
// 生成错误提示
render(response, CodeMsg.ACCESS_LIMIT_REACHED);
return false;
}
}
}
return true;
}
// 该方法用于根据CodeMsg生成错误响应
private void render(HttpServletResponse response,
CodeMsg cm) throws IOException
{
response.setContentType("application/json;charset=UTF-8");
OutputStream out = response.getOutputStream();
// 将CodeMsg包装成Result对象,再将它转换成字符串
String str = FkRedisUtil.beanToString(Result.error(cm));
// 输出响应字符串
out.write(str.getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
}
private User getUser(HttpServletRequest request, HttpServletResponse response)
{
// 获取名为token的请求参数
String paramToken = request.getParameter(UserKey.cookie_NAME_TOKEN);
// 获取名为token的cookie的值
String cookieToken = cookieUtil.getcookievalue(request, UserKey.cookie_NAME_TOKEN);
if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken))
{
return null;
}
// 优先使用paramToken作为分布式Session的ID
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
// 根据分布式Session ID获取Session对象
return userController.getByToken(response, token);
}
}
getUser()方法会从Redis中读取User信息,也就是从分布式Session中读取User信息,将从分布式Session中读取到的User信息存入UserContext的ThreadLocal容器中。UserArgumentResolver解析User参数本质上依然是从分布式Session中读取User信息。
3.访问权限控制在ItemController的list()方法上有一个@AccessLimit注解该注解具有权限控制的作用,该注解修饰的方法默认需要登录后才能调用,且该注解可限制被修饰的方法对于指定用户,在特定时间内只能调用多少次。通过这种方式既可限制同一个用户重复秒杀,也可避免用户多次秒杀引起并发高峰。
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit
{
boolean needLogin() default true;
// 该注解限制被修饰的方法在指定时间内最多访问几次
// -1表示不限制
int seconds() default -1;
int maxCount() default -1;
}
@AccessLimit注解可用于修饰方法,且该注解可一直被保留到运行时。该注解支持如下3个属性:
// 如果needLogin为true,表明需要登录才能调用该方法
if (needLogin)
{
// 如果user为null,表明还为登录,直接拒绝调用
if (user == null)
{
render(response, CodeMsg.SESSION_ERROR);
return false;
}
}
要求用户必须登录才能调用被修饰的方法,否则该方法返回CodeMsg.SESSION_ERROR错误提示。
// 如果count为null,表明之前不曾访问过
if (count == null)
{
fkRedisUtil.set(ak, key, 1);
}
// 如果访问次数还未达到最大次数,则可继续访问
else if (count < maxCount)
{
// 访问次数加1
fkRedisUtil.incr(ak, key);
}
// 如果访问次数达到限制
else
{
// 生成错误提示
render(response, CodeMsg.ACCESS_LIMIT_REACHED);
return false;
}
限制同一个用户在指定时间内,最多只能调用被修饰的方法多少次
4.秒杀商品页面模板miaoshasrcmainresourcestemplatesitem_list.html
十、商品秒杀界面的实现及静态化商品列表 秒杀商品列表
商品名称 商品图片 商品原价 秒杀价 库存数量 详情 ![]()
秒杀
进入秒杀界面时直接访问静态的HTML页面,客户端浏览器就可以对静态页面自动进行缓存,避免重复加载HTML页面,动态更新部分做成Restful响应,然后让静态页面通过jQuery用异步方式来加载需要动态更新的内容,这样每次请求的响应只是动态更新的数据,而不是完整的HTML页面。
1.获取秒杀商品 @GetMapping(value = "/detail/{itemId}")
@ResponseBody
@AccessLimit // 限制该方法必须登录才能访问
public Result detail(User user,
@PathVariable("itemId") long itemId)
{
MiaoshaItem item = miaoshaService.getMiaoshaItemById(itemId);
// 获取秒杀开始时间
long startAt = item.getStartDate().getTime();
// 获取秒杀的结束时间
long endAt = item.getEndDate().getTime();
long now = System.currentTimeMillis();
// 定义距离开始秒杀还有多久的变量
int remainSeconds;
if (now < startAt)
{
// 秒杀还没开始
remainSeconds = (int) ((startAt - now) / 1000);
}
else if (now > endAt)
{
// 秒杀已结束
remainSeconds = -1;
}
else
{
// 秒杀进行中
remainSeconds = 0;
}
// 定义秒杀还剩多久结束的变量
var leftSeconds = (int) ((endAt - now ) / 1000);
// 创建ItemDetailVo,用于封装秒杀商品详情
ItemDetailVo itemDetailVo = new ItemDetailVo();
itemDetailVo.setMiaoshaItem(item);
itemDetailVo.setUser(user);
itemDetailVo.setRemainSeconds(remainSeconds);
itemDetailVo.setLeftSeconds(leftSeconds);
return Result.success(itemDetailVo);
}
detail()方法调用了MiaoshaService的getMiaoshaItemById()方法来获取秒杀商品详情。
// 根据商品ID获取秒杀商品的方法
public MiaoshaItem getMiaoshaItemById(long itemId)
{
return miaoshaItemMapper.findById(itemId);
}
getMiaoshaItemById()实现是通过简单地调用MiaoshaItemMapper组件的findById()方法即可。
detail()处理方法要将当前用户信息也传到页面上,因此额外定义了一个ItemDetailVo类来封装MiaoshaItem和User。
import org.crazyit.app.domain.MiaoshaItem;
import org.crazyit.app.domain.User;
public class ItemDetailVo
{
private int remainSeconds = 0;
private int leftSeconds = 0;
private MiaoshaItem miaoshaItem;
private User user;
public int getRemainSeconds()
{
return remainSeconds;
}
public void setRemainSeconds(int remainSeconds)
{
this.remainSeconds = remainSeconds;
}
public int getLeftSeconds()
{
return leftSeconds;
}
public void setLeftSeconds(int leftSeconds)
{
this.leftSeconds = leftSeconds;
}
public MiaoshaItem getMiaoshaItem()
{
return miaoshaItem;
}
public void setMiaoshaItem(MiaoshaItem miaoshaItem)
{
this.miaoshaItem = miaoshaItem;
}
public User getUser()
{
return user;
}
public void setUser(User user)
{
this.user = user;
}
@Override
public String toString()
{
return "ItemDetailVo{" +
"remainSeconds=" + remainSeconds +
", leftSeconds=" + leftSeconds +
", miaoshaItem=" + miaoshaItem +
", user=" + user +
'}';
}
}
2.秒杀界面的实现
十一、秒杀实现及使用RabbitMQ实现并发削峰 1.秒杀实现商品详情 秒杀商品详情
原价:秒杀价:库存数量:开始时间:
package org.crazyit.app.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.crazyit.app.access.AccessLimit;
import org.crazyit.app.domain.MiaoshaItem;
import org.crazyit.app.domain.MiaoshaOrder;
import org.crazyit.app.domain.User;
import org.crazyit.app.rabbitmq.MiaoshaMessage;
import org.crazyit.app.rabbitmq.MiaoshaSender;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.redis.ItemKey;
import org.crazyit.app.result.CodeMsg;
import org.crazyit.app.result.Result;
import org.crazyit.app.service.MiaoshaService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@RequestMapping("/miaosha")
public class MiaoshaController implements InitializingBean
{
private final MiaoshaService miaoshaService;
private final FkRedisUtil fkRedisUtil;
private final MiaoshaSender mqSender;
// 存放ItemId与秒杀是否结束的对应关系
private final Map localOverMap =
Collections.synchronizedMap(new HashMap<>());
public MiaoshaController(MiaoshaService miaoshaService,
FkRedisUtil fkRedisUtil, MiaoshaSender mqSender)
{
this.miaoshaService = miaoshaService;
this.fkRedisUtil = fkRedisUtil;
this.mqSender = mqSender;
}
@Override
public void afterPropertiesSet()
{
// 获取所有物品列表
List itemList = miaoshaService.listMiaoshaItem();
if (itemList == null)
{
return;
}
for (MiaoshaItem item : itemList)
{
// 将所有物品及其对应库存放入Redis缓存
fkRedisUtil.set(ItemKey.miaoshaItemStock, ""
+ item.getItemId(), item.getStockCount());
localOverMap.put(item.getId(), false);
}
}
@GetMapping(value = "/verifyCode")
@ResponseBody
@AccessLimit // 限制该方法必须登录才能访问
public void getMiaoshaVerifyCode(HttpServletResponse response,
User user, @RequestParam("itemId") long itemId) throws IOException
{
// 生成验证码
BufferedImage image = miaoshaService.createVerifyCode(user, itemId);
OutputStream out = response.getOutputStream();
// 将验证码输出到客户端
ImageIO.write(image, "JPEG", out);
out.flush();
out.close();
}
@GetMapping(value = "/path")
@ResponseBody
// 限制该方法必须登录才能访问,且每5秒内只能调用5次
@AccessLimit(seconds = 5, maxCount = 5)
public Result getMiaoshaPath(User user,
@RequestParam("itemId") long itemId,
@RequestParam(value = "verifyCode",
defaultValue = "0") int verifyCode)
{
// 如果输入的验证码不匹配
if (!miaoshaService.checkVerifyCode(user, itemId, verifyCode)) // ①
{
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path = miaoshaService.createMiaoshaPath(user, itemId);
return Result.success(path);
}
@PostMapping("/{path}/proMiaosha")
@ResponseBody
@AccessLimit // 限制该方法必须登录才能访问
public Result proMiaosha(Model model, User user,
@RequestParam("itemId") long itemId,
@PathVariable("path") String path)
throws JsonProcessingException
{
model.addAttribute("user", user);
// 验证动态的秒杀地址是否正确
boolean check = miaoshaService.checkPath(user, itemId, path); // ②
if (!check)
{
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
// 通过内存快速获取该商品是否秒杀结束
Boolean over = localOverMap.get(itemId);
// 如果秒杀已经结束
if (over != null && over) // ③
{
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
// 预减库存
long stock = fkRedisUtil.decr(ItemKey.miaoshaItemStock, "" + itemId);
// 如果库存小于0,在内存中记录该商品秒杀结束,并返回秒杀结束的提示
if (stock < 0)
{
localOverMap.put(itemId, true);
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
// 根据用户ID和商品ID获取秒杀订单
MiaoshaOrder miaoshaOrder = miaoshaService
.getMiaoshaOrderByUserIdAndItemId(user.getId(), itemId); // ④
// 如果该用户已有对该商品的秒杀订单,判断为重复秒杀
if (miaoshaOrder != null)
{
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
// 发送消息给RabbitMQ消息队列
var miaoshaMessage = new MiaoshaMessage();
miaoshaMessage.setUser(user);
miaoshaMessage.setItemId(itemId);
// 让秒杀消息进入队列
mqSender.sendMiaoshaMessage(miaoshaMessage); // ⑤
return Result.success(0);
}
@GetMapping(value = "/result")
@ResponseBody
@AccessLimit // 限制该方法必须登录才能访问
public Result miaoshaResult(Model model, User user,
@RequestParam("itemId") long itemId)
{
model.addAttribute("user", user);
// 调用MiaoshaService的getMiaoshaResult()方法来获取秒杀结果
long result = miaoshaService.getMiaoshaResult(user.getId(), itemId);
return Result.success(result);
}
}
控制器类实现了InitializingBean接口,该控制器会在依赖关系被注入后自动执行该接口中定义的afterPropertiesSet()方法,该方法会调用MiaoshaService的listMiaoshaItem()方法获取所有秒杀商品,然后遍历每个商品,最后将所有秒杀商品的库存加载到Redis中。
// 生成秒杀图形验证码
public BufferedImage createVerifyCode(User user, long itemId)
{
if (user == null || itemId <= 0)
{
return null;
}
Random rdm = new Random();
String verifyCode = VercodeUtil.generateVerifyCode(rdm);
int rnd = VercodeUtil.calc(verifyCode);
// 将验证码的值存到Redis中
fkRedisUtil.set(MiaoshaKey.miaoshaVerifyCode,
user.getId() + "," + itemId, rnd);
// 返回生成的图片
return VercodeUtil.createVerifyImage(verifyCode, rdm);
}
2.使用RabbitMQ限制并发
import com.fasterxml.jackson.core.JsonProcessingException;
import org.crazyit.app.redis.FkRedisUtil;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.stereotype.Component;
@Component
public class MiaoshaSender
{
private final AmqpTemplate amqpTemplate;
public MiaoshaSender(AmqpTemplate amqpTemplate) {this.amqpTemplate = amqpTemplate;}
public void sendMiaoshaMessage(MiaoshaMessage miaoshaMessage) throws JsonProcessingException
{
// 将MiaoshaMessage转换成字符串
String msg = FkRedisUtil.beanToString(miaoshaMessage);
// 发送消息
amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
}
}
使用AmqpTemplate将消息发送到MQConfig.MIAOSHA_QUEUE消息队列中
MQConfig是一个用@Configuration修饰的配置类,会负责在RabbitMQ服务器中配置一个消息队列。
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MQConfig
{
public static final String MIAOSHA_QUEUE = "miaosha.queue";
// 配置Queue,对应于消息队列
@Bean
public Queue queue()
{
return new Queue(MIAOSHA_QUEUE, true);
}
}
application.properties
# -----------RabbitMQ有关的配置----------- # 配置主机名 spring.rabbitmq.host=localhost # 配置端口 spring.rabbitmq.port=5672 # 配置用户名 spring.rabbitmq.username=root # 配置密码 spring.rabbitmq.password=32147 # 配置虚拟主机 spring.rabbitmq.virtual-host=/ # 下面是和Listener有关的配置 # 指定Listener程序中线程的最小数量 spring.rabbitmq.listener.simple.concurrency=10 # 指定Listener程序中线程的最大数量 spring.rabbitmq.listener.simple.max-concurrency=20 # 指定Listener每次从消息队列抓取消息的数量 spring.rabbitmq.listener.simple.prefetch=1 # 设置监听器容器自动启动 spring.rabbitmq.listener.simple.auto-startup=true # 设置被拒绝的消息会重新入队 spring.rabbitmq.listener.simple.default-requeue-rejected=true # 下面是和AmqpTemplate有关的配置 # 消息发送失败时执行重发 spring.rabbitmq.template.retry.enabled=true # 指定重发消息的时间间隔为1秒 spring.rabbitmq.template.retry.initial-interval=1000 # 指定最多重发3次 spring.rabbitmq.template.retry.max-attempts=3 # 指定重发消息的时间间隔最大为10秒 spring.rabbitmq.template.retry.max-interval=10000 # 指定重发消息的时间间隔与前一次时间间隔的倍数, # 比如此处将multiplier设为1.5,且两次重发的初始时间间隔为1秒 # 这意味着重发消息的时间间隔依次为1s、1.5s、2.25s…… spring.rabbitmq.template.retry.multiplier=1.5
MiaoshaReceiver接收到消息后,同样是判断商品库存是否大于0,不大于0,说明库存不足,直接返回,根据用户ID和商品ID从Redis缓存中读取秒杀订单,秒杀订单不为null,说明用户正在进行重复秒杀,直接返回。
package org.crazyit.app.rabbitmq;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.crazyit.app.domain.MiaoshaItem;
import org.crazyit.app.domain.MiaoshaOrder;
import org.crazyit.app.domain.User;
import org.crazyit.app.redis.FkRedisUtil;
import org.crazyit.app.service.MiaoshaService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component
public class MiaoshaReceiver
{
private final MiaoshaService miaoshaService;
public MiaoshaReceiver(MiaoshaService miaoshaService)
{
this.miaoshaService = miaoshaService;
}
@RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
public void receive(String message) throws JsonProcessingException
{
// 将字符串类型的消息转换成MiaoshaMessage对象
MiaoshaMessage miaoshaMessage = FkRedisUtil
.stringToBean(message, MiaoshaMessage.class);
// 获取秒杀用户
User user = miaoshaMessage.getUser();
// 获取秒杀商品的ID
long itemId = miaoshaMessage.getItemId();
// 获取秒杀商品
MiaoshaItem item = miaoshaService.getMiaoshaItemById(itemId);
int stock = item.getStockCount();
// 如果秒杀商品的库存小于0,无法继续秒杀,直接返回
if (stock <= 0)
{
return;
}
// 从Redis缓存根据用户ID和商品ID读取秒杀订单
MiaoshaOrder miaoshaOrder = miaoshaService
.getMiaoshaOrderByUserIdAndItemId(user.getId(), itemId);
// 如果秒杀订单存在,说明用户正尝试重复秒杀,无需处理,因此直接返回
if (miaoshaOrder != null)
{
return;
}
// 调用MiaoshaService的miaosha()方法执行秒杀
miaoshaService.miaosha(user, item);
}
}