写在前面:本项目以学习为主,目的是记录学习过程。完整源代码已上传至guihub。
项目介绍:该项目模拟了高并发场景的商城系统,主要包括用户注册登录、商品列表、商品详情、秒杀功能以及安全优化模块。解决了用户安全登录问题,用户重复下单、超卖超买、限流防刷问题。(使用redis缓存,并使用页面静态化技术将静态资源缓存在CDN中,降低服务器的压力,加快了用户的访问速度。还使用异步消息队列机制对系统的交易性能进行了优化。在秒杀接口使用计数器来防止脚本对秒杀接口的不断刷新,增强了服务器的稳定性。最后还使用验证码技术不仅起到削峰的作用,还能防止恶意刷访问。)
一、分布式Session
该模块主要是实现基本的登陆注册功能,以及如何实现分布式Session,如何进行参数校验等。
1、用户登录
通过两次MD5加密来保证用户安全,第一次实在前端进行一次加密;第二次是在后端接收到之后再次加密,与数据库中存放的密码进行校验来实现登录功能。
首先添加依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Springboot依赖-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>seckill_shop</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>seckill_shop</name>
<description>seckill_shop</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- thymeleaf组件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- web组件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--mybatis-plus 依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
<!-- lombook-->
<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>
<!-- md5 依赖 -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
编写MD5工具类进行MD5加密
package com.example.seckill_shop.utils;
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.stereotype.Component;
@Component
public class MD5Util {
public static String md5(String src){
return DigestUtils.md5Hex(src);
}
private static final String salt = "1a2b3c4d";
public static String inputPassToFromPass(String inputPass){
String str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
return md5(str);
}
public static String fromPassToDBPass(String fromPass,String salt){
String str = "" + salt.charAt(0) + salt.charAt(2) + fromPass + salt.charAt(5) + salt.charAt(4);
return md5(str);
}
public static String inputPassToDBPass(String inputPass,String salt){
String fromPass = inputPassToFromPass(inputPass);
String dbPass = fromPassToDBPass(fromPass,salt);
return dbPass;
}
public static void main(String[] args){
// d3b1294a61a07da9b49b6e22b2cbd7f9
System.out.println(inputPassToFromPass("123456"));
// b7797cce01b4b131b433b6acf4add449
System.out.println(inputPassToDBPass("123456",salt));
}
}
在Controller层中实现页面跳转到登录页
package com.example.seckill_shop.controller;
import com.example.seckill_shop.service.UserService;
import com.example.seckill_shop.vo.LoginVo;
import com.example.seckill_shop.vo.RespBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {
@Autowired
private UserService userService;
//跳转登陆页面
@RequestMapping("/toLogin")
public String toLogin(){
return "login";
}
@RequestMapping("/doLogin")
@ResponseBody
public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response){
return userService.doLogin(loginVo,request,response);
}
}
在Service层中UserService接口的实现类中编写登录逻辑。写一个LoginVo类来接收前端ajax传来的用户输入的账号和经过一次MD5加密后的密码。如果密码输入正确则进入Mapper层(Dao层),在数据库中查询到User对象。
package com.example.seckill_shop.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.seckill_shop.exception.GlobalException;
import com.example.seckill_shop.pojo.User;
import com.example.seckill_shop.mapper.UserMapper;
import com.example.seckill_shop.service.UserService;
import com.example.seckill_shop.utils.CookieUtil;
import com.example.seckill_shop.utils.MD5Util;
import com.example.seckill_shop.utils.UUIDUtil;
import com.example.seckill_shop.vo.LoginVo;
import com.example.seckill_shop.vo.RespBean;
import com.example.seckill_shop.vo.RespBeanEnum;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* <p>
* 服务实现类
* </p>
*
* @author tyz
* @since 2022-10-28
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public RespBean doLogin(LoginVo loginVo) {
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
if(StringUtils.isEmpty(mobile) || StringUtils.isEmpty(password)){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
if(!ValidatorUtil.isMobile(mobile)){
return RespBean.error(RespBeanEnum.MOBILE_ERROR);
}
User user = userMapper.selectById(mobile);
if(user == null){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
if(!MD5Util.fromPassToDBPass(password,user.getSalt()).equals(user.getPassword())){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
return RespBean.success();
}
}
编写状态码枚举类
package com.example.seckill_shop.vo;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
@ToString
@AllArgsConstructor
@Getter
public enum RespBeanEnum {
//通用
SUCCESS(200,"登陆成功"),
ERROR(500,"服务端异常"),
//登录
LOGIN_ERROR(530,"用户名或密码错误"),
MOBILE_ERROR(531,"手机号码格式不正确"),
BIND_ERROR(543,"参数校验异常");
private final Integer code;
private final String message;
}
package com.example.seckill_shop.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RespBean {
private long code;
private String message;
private Object obj;
public static RespBean success(){
return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBeanEnum.SUCCESS.getMessage(),null);
}
public static RespBean success(Object obj){
return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBeanEnum.SUCCESS.getMessage(),obj);
}
public static RespBean error(RespBeanEnum respBeanEnum){
return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),null);
}
public static RespBean error(RespBeanEnum respBeanEnum,Object obj){
return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),obj);
}
}
2、分布式Session
涉及的问题:当Nginx分发给多个tomcat时,如果用户只在其中一个tomcat登陆后,后续如果留言页面会会检验用户是否登录,在页面跳转后,可能会分配给另一个tomcat,而此tomcat中无用户登录的信息。
解决方法:后端集中存储,在pom.xml中引入redis依赖。
将用户的登录信息统一存储在redis中。编写redis配置类
package com.example.seckill_shop.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* 配置redis序列化与反序列化
*
* @author: tyz
* @date 2022/3/8 5:24 下午
* @ClassName: RedisConfig
*/
@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;
}
@Bean
public DefaultRedisScript<Long> script(){
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
//lock.lua脚本位置和application.yml同级目录
redisScript.setLocation(new ClassPathResource("stock.lua"));
redisScript.setResultType(Long.class);
return redisScript;
}
}
(因为redis中只能存储二进制字节码文件,所以需要进行序列化,后续读取redis中数据,需要反序列化。)
在用户第一次登录后,通过UUID工具类生成cookie后,以cookie作为key,user对象作为value,传进redis中,并设置失效时间。在后续用户页面跳转中,校验用户是否登录,只需要在redis中查询是否存在cookie值即可。
二、功能开发
1、商品页面
package com.example.seckill_shop.controller;
import com.example.seckill_shop.pojo.User;
import com.example.seckill_shop.service.GoodsService;
import com.example.seckill_shop.vo.DetailVo;
import com.example.seckill_shop.vo.GoodsVo;
import com.example.seckill_shop.vo.RespBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* 商品控制层 主要负责提供商品List,以及每件商品的详情信息
*
* @author: tyz
* @date 2022/3/8 5:24 下午
* @ClassName: GoodsController
*/
@Controller
@RequestMapping("goods")
public class GoodsController {
@Autowired
private GoodsService goodsService;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ThymeleafViewResolver thymeleafViewResolver;
/**
* 跳转到商品页面
* 压测数据2286.6/sec
*/
@RequestMapping(value = "/toList",produces = "text/html;character=utf8",method = RequestMethod.GET)
@ResponseBody
public String toList(Model model, HttpServletRequest request, HttpServletResponse response, User user){
if(user == null){
return "login";
}
//序列化
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String) valueOperations.get("goodsList");
if(!StringUtils.isEmpty(html)){
return html;
}
model.addAttribute("user",user);
model.addAttribute("goodsList",goodsService.findGoodsVo());
//如果为空,手动渲染,存入redis中
WebContext context = new WebContext(request,response,request.getServletContext(),request.getLocale(),model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsList",context);
if(!StringUtils.isEmpty(html)){
valueOperations.set("goodsList",html,60, TimeUnit.SECONDS);
}
return html;
}
//跳转商品详情页
@RequestMapping(value = "/detail/{goodsId}",method = RequestMethod.GET)
@ResponseBody
public RespBean toDetail(User user, @PathVariable Long goodsId){
GoodsVo goodsVo = goodsService.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 {
seckillStatus = 1;
remainSeconds = 0;
}
DetailVo detailVo = new DetailVo();
detailVo.setUser(user);
detailVo.setGoodsVo(goodsVo);
detailVo.setSeckillStatus(seckillStatus);
detailVo.setRemainSeconds(remainSeconds);
return RespBean.success(detailVo);
}
}
通过model.addAttribute将数据传给前端。
2、设计秒杀
1、在Controller层的toDetail中,通过GoodsService层中findGoodsVoByGoodsId方法在mapper层中映射查询到商品信息,获取秒杀商品的开始时间、结束时间用来判断是否开启秒杀。
2、秒杀倒计时:通过model传给前端,在前端设计定时器,来计算。
3、实现秒杀,需要做两个判断:库存是否足够:获取数据库中商品的库存做判断。
用户重复下单:SeckillOrderService判断每个用户只能买一件商品(通过继承MyBatis-plus的IService接口的getOne方法,若该数据库已有,该userId与goodsId,则认为用户已购买过该商品,返回失败;若数据库没有,则下单成功)。