登陆部分
1.1密码加密
密码加密选择进行两次MD5加密,一次在前端一次在后端,在前端加密是为了防止密码在网络传输过程中泄露,其次是在后端加密,单次的MD5不一定安全,在实验室条件下可能通过碰撞进行解码。对密码进行两次md5操作是为了更好地保密。
package com.xxxx.seckill.utils;
//md5工具类,用来为密码加密
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 formPassToDBPass(String formPass,String salt){
String str = ""+ salt.charAt(0)+salt.charAt(2)+formPass+salt.charAt(5)+salt.charAt(4);
return md5(str);
}
public static String inputPassToDBPass(String inputPass,String salt){
String fromPass=inputPassToFromPass(inputPass);
String dbPass=formPassToDBPass(fromPass,salt);
return dbPass;
}
public static void main(String[] args) {
System.out.println(inputPassToFromPass("123456"));
System.out.println(formPassToDBPass("d3b1294a61a07da9b49b6e22b2cbd7f9","1a2b3c4d"));
System.out.println(inputPassToDBPass("123456","1a2b3c4d"));
}
}
1.2、VO类
在登陆方法对用户名和密码校验后需要返回对象,则需要创造一个公用的返回对象vo类
先创造两个vo类
1公共返回对象
package com.xxxx.seckill.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(),RespBean.success().getMessage(),obj);
}
//error方法之所以参数不同是因为错误的返回结果很多,不像成功一样返回200即可,所以需要传枚举类
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公共返回对象的枚举类,存放状态码,信息提示等。
package com.xxxx.seckill.vo;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
@AllArgsConstructor
public enum RespBeanEnum {
SUCCESS(200,"SUCCESS"),
ERROR(500,"服务端异常"),
//登录模块
LOGIN_ERROR(500210,"用户名或密码错误"),
MOBILE_ERROR(500211,"手机号码格式不正确"),
BIND_ERROR(500212,"参数校验异常");
private final Integer code;
private final String message;
}
1.3登陆传参
传参因为要传手机号码和密码,这是两个参数,所以还要设置一个vo类来接收参数
package com.xxxx.seckill.vo;
import com.xxxx.seckill.validator.IsMobile;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotNull;
@Data
public class LoginVo {
// @NotNull
//@IsMobile
private String mobile;
// @NotNull
//@Length(min = 32)
private String password;
}
1.4验证登陆对象是否正确
先根据手机号查询到用户对象,再验证密码是否正确,不根据手机号和密码直接查询的原因是密码会进行二次加密,也就是说当用户注册的时候已经在前端加密后的密码还会进行一次加密,加密完成之后才能传到数据库,所以前端一开始传到的密码与数据库中经过二次加密的密码是不一样的。
进行数据库验证之前要先对数据格式进行校验,检查数据格式是否正确。
package com.xxxx.seckill.utils;
import org.springframework.util.StringUtils;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
//后端的校验代码
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();
}
}
1.5使用注解进行参数校验
有很多冗余的参数校验导致代码冗长,可以通过其他方式。
1、首先需要引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>2.3.6.RELEASE</version>
</dependency>
2、在接收参数时在参数前面加上@Valid注解
@RequestMapping("/doLogin")
@ResponseBody
public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response){
return userService.doLogin(loginVo,request,response);
}
以及在对应参数的vo类上加上需要的校验如不为空(@NotNull)等
package com.xxxx.seckill.vo;
import com.xxxx.seckill.validator.IsMobile;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotNull;
@Data
public class LoginVo {
@NotNull
@IsMobile
private String mobile;
@NotNull
@Length(min = 32)
private String password;
}
但是也有些规则没有对应的注解,那么可以自己创造一个注解。
将@NotNull的注解和deafuolt复制过来,再根据自己需求加属性。
package com.xxxx.seckill.validator;
import com.xxxx.seckill.vo.IsMobileValidator;
import javax.validation.Constraint;
import javax.validation.Payload;
import javax.validation.constraints.NotNull;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
validatedBy = {IsMobileValidator.class}
)
public @interface IsMobile {
boolean required() default true;
String message() default "手机号码格式错误";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
再额外创造一个类来实现更多的校验规则。创造IsMobileValidator类来实现校验规则,需要继承ConstraintValidator<IsMobile,String>
package com.xxxx.seckill.vo;
import com.xxxx.seckill.utils.ValidatorUtil;
import com.xxxx.seckill.validator.IsMobile;
import org.springframework.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class IsMobileValidator implements ConstraintValidator<IsMobile,String> {
private boolean required =false;
@Override
public void initialize(IsMobile constraintAnnotation) {
required = constraintAnnotation.required();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if(required){
return ValidatorUtil.isMobile(value);
}else {
if(StringUtils.isEmpty(value)){
return true;
}else {
return ValidatorUtil.isMobile(value);
}
}
}
}
package com.xxxx.seckill.utils;
import org.springframework.util.StringUtils;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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();
}
}
但此时还不够,返回的值不会返回到前端,所以需要自定义异常类来将结果返回到前端
1.6自定义异常类
1首先创造一个exception包,创建一个全局异常类去继承RuntimeException
package com.xxxx.seckill.exception;
import com.xxxx.seckill.vo.RespBeanEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GlobalException extends RuntimeException{
private RespBeanEnum respBeanEnum;
}
2其次创造一个异常处理类
package com.xxxx.seckill.exception;
import com.xxxx.seckill.vo.RespBean;
import com.xxxx.seckill.vo.RespBeanEnum;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
//这个注解是将所有抛出的异常都接到这个类来处理
@RestControllerAdvice
public class GlobalExceptionHandler {
//指定处理具体的异常
@ExceptionHandler(Exception.class)
public RespBean 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);
}
}
1.7通过cookie和session来保存登录信息
1首先需要两个工具类
package com.xxxx.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 zhoubin
* @since 1.0.0
*/
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;
}
}
package com.xxxx.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("-", "");
}
}
然后在业务实现类中加入以下代码
//生成cookie
String ticket= UUIDUtil.uuid();
request.getSession().setAttribute(ticket,user);
CookieUtil.setCookie(request,response,"userTicket",ticket);
最后在登陆后的页面获取cookie和sesion值
@RequestMapping("/toList")
public String toList(HttpSession session, Model model,@CookieValue("userTicket") String ticket){
if(StringUtils.isEmpty(ticket)){
return "login";
}
User user = (User) session.getAttribute(ticket);
if (null==user){
return "login";
}
model.addAttribute("user",user);
return "goodsList";
}
}
分布式session问题
之前的代码在我们之后一台应用系统,所有操作都在一台Tomcat上,没有什么问题。当我们部署多台系统,配合Nginx的时候会出现用户登录的问题
原因:
由于 Nginx 使用默认负载均衡策略(轮询),请求将会按照时间顺序逐一分发到后端应用上。
也就是说刚开始我们在 Tomcat1 登录之后,用户信息放在 Tomcat1 的 Session 里。过了一会,请求又被 Nginx 分发到了 Tomcat2 上,这时Tomcat2 上 Session 里还没有用户信息,于是还得登录。
解决方案:
Session复制
优点:
无需修改代码,只需要修改Tomcat配置
缺点:
Session同步传输占用内网带宽
多台Tomcat同步性能指数级下降
Session占用内存,无法有效水平扩展
前端存储
优点:
不占用服务端内存
缺点:
存在安全风险
数据大小受cookie限制
占用外网带宽
Session粘滞
优点:
无需修改代码
服务端可以水平扩展
缺点:
增加新机器,会重新Hash,导致重新登录
应用重启,需要重新登录
后端集中存储
优点:
安全
容易水平扩展
缺点:
增加复杂度
需要修改代码
第一种方式 springsession
首先添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
其次进行redis配置
#redis配置
redis:
host: 192.168.244.130
port: 6379
database: 0
connect-timeout: 10000ms
lettuce:
pool:
#最大连接数
max-active: 8
#最大连接阻塞等待时间
max-wait: 10000ms
#最大空闲连接数
max-idle: 200
最后进行登录,就可以看见登陆的信息被存到redis中了
第二种直接存到redis中
上一种方式存到数据库时是二进制的,不方便观察,因此以第二种方式来进行。
第一步:像创建redis配置类,配置好连接工厂以及对key和value的序列化
package com.xxxx.seckill.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@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类型 value序列化
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
//hash类型 value序列化
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
//注入连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}}
第二步:修改登陆方法
不再将cookie放进session里,将cookie放进redis里。
@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.ERROR);
// }
// if(!ValidatorUtil.isMobile(mobile)){
// return RespBean.error(RespBeanEnum.MOBILE_ERROR);
// }
//根据手机号获取用户
User user = userMapper.selectById(mobile);
if(null==user){
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
}
if(MD5Util.inputPassToDBPass(password,user.getSalt()).equals(user.getPassword()))
{
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
}
//生成cookie
String ticket= UUIDUtil.uuid();
//request.getSession().setAttribute(ticket,user);
redisTemplate.opsForValue().set("user:"+ticket,user);
CookieUtil.setCookie(request,response,"userTicket",ticket);
return RespBean.success(ticket);
}
之前是从session中取出user对象,现在则是从redis中取出
@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;
}
解决验证登陆代码耦合
当前登录完成后的每一步操作都需要判断是否有cookie,里面是否有user,才能执行,过于冗余,解决办法是直接在各个接口传递User参数,验证登录将会在传参以前。
第一步:创建MVC配置类来自定义参数
package com.xxxx.seckill.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {
/* @Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/");
}*/
@Autowired
private UserArgumentResolver userArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
第二步:建立UserArgumentResolver类来自定义参数
package com.xxxx.seckill.config;
import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IUserService;
import com.xxxx.seckill.utils.CookieUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
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;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Parameter;
//自定义用户参数
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
@Autowired
private IUserService userService;
@Override
public boolean supportsParameter(MethodParameter parameter) {//判断是否是User类型
Class<?> clazz = parameter.getParameterType();
return clazz==User.class;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
//首先获取cookie
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
String ticket = CookieUtil.getCookieValue(request, "userTicket");
if(StringUtils.isEmpty(ticket)){
return null;
}
return userService.getUserByCookie(ticket,request,response);
}
}
自定义参数类需要实现HandlerMethodArgumentResolver 接口,实现两个方法,实现supportsParameter方法是为了做条件判断,只有当返回true时才会执行第二个方法。第二个方法resolveArgument就写验证规则,如果有cookie就返回cookie,没有则返回null。
最后需要注册MVCConfig中才能生效。
GoodsVo的创建
因为详情页面需要两个表的数据,需要关联两个表的id来查询,所以创建GoodsVo类来满足返回给前端的数据。
package com.xxxx.seckill.vo;
import com.xxxx.seckill.pojo.Goods;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoodsVo extends Goods {
private BigDecimal seckillPrice;
private Integer stockCount;
private Date startDate;
private Date endDate;
}
这里有关mybatisplus的使用,就是自定义sql语句。需要用到IService接口和BaseMapper以及ServiceImpl
首先在IGoodsService接口创建方法findGoodsVo,然后在GoodsServiceImpl类调用GoodsMapper.findGoodsVO,最后就在mapper文件中自定义sql语句。
关于配置MVCConfig类后静态资源无法访问的解决方法
在MVCConfig类中加入以下代码即可
@Configuration
public class WebConfig implements WebMvcConfigurer {
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
"classpath:/META-INF/resources/", "classpath:/resources/",
"classpath:/static/", "classpath:/public/" };
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
}
}
或者
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/");
}
日期格式化
前端对日期格式化
利用common.js的方法来进行格式化,需要先引入common.js
${#dates.format(goods.startDate,'yyyy-MM-dd HH:mm:ss')}
后端对日期格式化
1、全局时间格式化
在springboot的配置文件中添加两行配置
#格式化全局时间字段
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
#指定时间区域类型
spring.jackson.time-zone=GMT+8
2、部分时间格式化
// 对 createtime 字段进行格式化处理
@JsonFormat(pattern = "yyyy-MM-dd hh:mm:ss", timezone = "GMT+8")
private Date createtime;
秒杀功能实现
两个判断:1、是否还有库存
2、用户是否重复购买
@RequestMapping(value = "/doSeckill2",method = RequestMethod.POST)
public String doSeckill2(Model model, User user,Long goodsId){
if(user==null){
return "login";
}
//库存数量只能根据id去数据库里查,不能在前端查
model.addAttribute("user",user);
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if(goods.getStockCount()<1){
model.addAttribute("errmsg", 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("errmsg",RespBeanEnum.REPEATE_ERROR.getMessage());
return "seckillFail";
}
Order order= orderService.seckill(user,goods);
model.addAttribute("order",order);
model.addAttribute("goods",goods);
return "orderDetail";
}
当都没问题时则开始秒杀。
先减库存,再建立订单,再建立秒杀订单
(先生成订单的原因是,秒杀订单的字段中有订单的外键id)
//秒杀
@Transactional
@Override
public Order seckill(User user, GoodsVo goods) {
ValueOperations valueOperations = redisTemplate.opsForValue();
SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goods.getId()));
seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
//seckillGoodsService.updateById(seckillGoods);
// boolean seckillGoodsResult= seckillGoodsService.update(new UpdateWrapper<SeckillGoods>().set("stock_count", seckillGoods.getStockCount()).eq("id", seckillGoods.getId()).gt("stock_count", 0));
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;
}
if(seckillGoods.getStockCount()<1){
valueOperations.set("isStockEmpty:"+goods.getId(),"0");
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);
redisTemplate.opsForValue().set("order:"+user.getId()+":"+goods.getId(),seckillOrder);
return order;
}
使用工具类生成5000个用户
package com.xxxx.seckill.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.vo.RespBean;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
//生成用户工具类
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(1300000000L+i);
user.setNickname("user"+i);
user.setSalt("1a2b3c4d");
user.setPassword(MD5Util.inputPassToDBPass("123456",user.getSalt()));
user.setLoginCount(1);
user.setRegisterDate(new Date());
users.add(user);
}
System.out.println("create user!!!");
Connection conn=getConn();
String sql="insert into t_user(login_count,nickname,register_date,salt,password,id) values(?,?,?,?,?,?)";
PreparedStatement ps = conn.prepareStatement(sql);
for (int i = 0; i < users.size(); i++) {
User user=users.get(i);
ps.setInt(1,user.getLoginCount());
ps.setString(2,user.getNickname());
ps.setTimestamp(3,new Timestamp(user.getRegisterDate().getTime()));
ps.setString(4,user.getSalt());
ps.setString(5,user.getPassword());
ps.setLong(6,user.getId());
ps.addBatch();
}
ps.executeBatch();
ps.clearParameters();
conn.close();
System.out.println("insert to db");
String urlString="http://localhost:8080/login/toLogin";
File file=new File("D:\\11\\config.txt");
if(file.exists()){
file.delete();
}
RandomAccessFile raf = new RandomAccessFile(file,"rw");
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.inputPassToFromPass("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=utf8&useSSL=false&serverTimezone=Asia/Shanghai&tinyInt1isBit=false";
String username="root";
String password="w70968254";
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(5000);
}
}
页面优化
页面缓存
每次从数据库查询数据后再展示到页面上时总会耗费很多时间,可以将页面放到redis中进行缓存。
不在跳转页面而是将页面放入redis中。
将商品列表页放入redis缓存中需要的步骤
1、先从redis读取缓存,如果有页面则直接返回给浏览器,没有就需要手动渲染模板(因为不在经过thymeleaf跳转),然后放入redis中,在输出到浏览器
tips:需要给保存的页面设置过期时间,因为不能给客户看到永远不变的页面
@Autowired
private ThymeleafViewResolver thymeleafViewResolver;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private IGoodsService goodsService;
@Autowired
private IUserService userService;
/**
* 功能描述
* @param
* @return
*/
@RequestMapping(value = "/toList",produces = "text/html;charset=utf-8")
@ResponseBody
public String toList(Model model,User user,HttpServletRequest request,HttpServletResponse response){
//if(StringUtils.isEmpty(ticket)){
// return "login";
//}
// // User user = (User) session.getAttribute(ticket);
// User user = userService.getUserByCookie(ticket, request, response);
// if (null==user){
// return "login";
//}
//从Redis中获取页面,如果不为空,直接返回页面
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());
// return "goodsList";
//如果为空则手动渲染存入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;
}
url缓存
//跳转商品详情页
@ResponseBody
@RequestMapping(value = "/toDetail2/{goodsId}",produces = "text/html;charset=utf-8")
public String toDetail2(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= 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;
}
model.addAttribute("remainSeconds",remainSeconds);
model.addAttribute("seckillStatus",seckillStatus);
model.addAttribute("goods",goodsVo);
WebContext context = new WebContext(request,response,request.getServletContext(),request.getLocale(),model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", context);
// return "goodsDetail";
if(!StringUtils.isEmpty(html)){
valueOperations.set("goodsDetail:"+goodsId,html,60,TimeUnit.SECONDS);
}
return html;
}
即使缓存页面在redis后,将页面从redis传递到前端仍然需要很长时间,还需要前后端分离。
对象缓存
对象缓存是更加细粒度的缓存,将用户储存到redis中。但是当用户信息修改如修改密码时,永久存储在redis中的用户信息就不太好了,所以为了redis与数据库的一致性,在每次对数据库操作时都先清空redis。
商品详情页面静态化
Redis 只适合数据规模比较小的情况,假如数据量比较大,例如商品详情页,每个页面如果10kb,100万商品,就是10GB空间,对内存占用比较大。此时就给缓存系统带来极大压力,如果缓存崩溃,接下来倒霉的就是数据库了。
所以缓存并不是万能的,某些场景需要其它技术来解决,比如静态化技术。
第一步:因为要使用ajax来请求数据,不再使用model来传递,所以要准备一个vo对象。
package com.xxxx.seckill.vo;
import com.xxxx.seckill.pojo.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DetailVo {
private User user;
private GoodsVo goodsVo;
private int seckillStatus;
private int remainSeconds;
}
第二步:处理后端
//跳转商品详情页
@ResponseBody
@RequestMapping( "/detail/{goodsId}")
public RespBean toDetail(Model model, 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);
}
第三步:将新的静态页面放到static目录下,并使用ajax方法请求后端数据。
<!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 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 if="${seckillStatus eq 0}">秒杀倒计时:<span id="countDown" text="${remainSeconds}"></span>秒</span>-->
<!-- <span if="${seckillStatus eq 1}">秒杀进行中</span>-->
<!-- <span if="${seckillStatus eq 1}">秒杀已结束</span>-->
<span id="seckillTip"></span>
</td>
<td>
<!-- <form id="seckillForm" method="post" action="/seckill/doSeckill">-->
<!-- <input type="hidden" name="goodsId" id="goodsId">-->
<!-- <button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>-->
<!-- </form>-->
<div class="row">
<div class="form-inline">
<img id="captchaImg" width="130" height="32" style="display: none"
onclick="refreshCaptcha()"/>
<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>
</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>
<script>
function refreshCaptcha() {
$("#captchaImg").attr("src", "/seckill/captcha?goodsId=" +
$("#goodsId").val() + "&time=" + new Date())
}
$(function () {
//countDown();
getDetails()
});
function getSeckillPath() {
var goodsId= $("#goodsId").val()
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) {
$.ajax({
url:'/seckill/'+path+'/doSeckill',
type:'post',
data:{
goodsId:$("#goodsId").val()
},
success:function (data) {
if (data.code==200){
//window.location.href="/orderDetail.htm?orderId="+data.obj.id;
getResult($("#goodsId").val());
}else {
layer.msg("dodoodododododo")
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 {
console.log(data)
layer.confirm("恭喜您秒杀成功 查看订单?",{btn:["确定","取消"]},
function () {
window.location.href="/orderDetail.htm?orderId="+result;
},function () {
layer.close()
})
}
}
},error:function () {
layer.msg("客户端请求错误")
}
})
}
function getDetails( ) {
var goodsId = g_getQueryString("goodsId");
$.ajax({
url:'/goods/detail/'+goodsId,
type:'GET',
success:function (data) {
if (data.code==200){
render(data.obj)
}else {
layer.msg("客户端请求出错")
}
},
error:function(){
layer.msg("客户端请求出错")
}
})
countDown();
}
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("yyyy-MM-dd HH:mm:ss"))
$("#remainSeconds").val(remainSeconds)
$("#goodsId").val(goods.id)
$("#goodsPrice").text(goods.goodsPrice)
$("#seckillPrice").text(goods.seckillPrice)
$("#stockCount").text(goods.stockCount)
}
function countDown() {
var remainSeconds = $("#remainSeconds").val();
var timeout;
//秒杀还没开始,倒计时
if (remainSeconds > 0) {
$("#buyButton").attr("disabled", true);
$("#seckillTip").html("秒杀倒计时:" + remainSeconds + "秒");
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("秒杀进行中");
$("#captchaImg").attr("src", "/seckill/captcha?goodsId=" + $("#goodsId").val())
$("#captchaImg").show();
$("#captcha").show();
//秒杀已经结束
} else {
$("#buyButton").attr("disabled", true);
$("#seckillTip").html("秒杀已经结束");
$("#captchaImg").hide();
$("#captcha").hide();
}
}
</script>
</html>
解决库存超卖问题
之前减库存的时候没有判断库存数量是否小于0就直接操作了,这样会导致库存超卖问题。
seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
//seckillGoodsService.updateById(seckillGoods);
// boolean seckillGoodsResult= seckillGoodsService.update(new UpdateWrapper<SeckillGoods>().set("stock_count", seckillGoods.getStockCount()).eq("id", seckillGoods.getId()).gt("stock_count", 0));
boolean result = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>().setSql("stock_count=stock_count-1").eq("goods_id", goods.getId()).gt("stock_count", 0));
但是判断是否重复抢购还么解决,比如一个人同时发起两个请求 ,在此时判断库存和是否重复购买是没有问题的,就会导致一个人可能抢到两个商品。
为了防止这种情况,可以将用户id和商品id绑定成一个唯一索引。
再在秒杀的方法上加上@Transactional事务注解
redis预减库存
主要思路是减少对数据库的访问
//预减库存操作
// Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
Long stock = (Long) redisTemplate.execute(script, Collections.singletonList("seckillGoods:" + goodsId), Collections.EMPTY_LIST);
if(stock<0){
EmptyStockMap.put(goodsId,true);
valueOperations.increment("seckillGoods:" + goodsId);
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
mqSender.sendSeckillMessage(JSONUtil.toJsonStr(seckillMessage));
return RespBean.success(0);
使用Redis遇见库存的第一步操作就是在程序运行前先去数据库中将库存取出来。
为了达到这一目的,需要实现InitializingBean接口,然后重写他的afterPropertiesSet()方法,这个方法会在系统初始化时就开始执行
@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);
});
}
使用RabbitMQ来实现秒杀操作
第一步:创造消息对象的实体类
package com.xxxx.seckill.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SeckillMessage {
private User user;
private Long goodId;
}
第二步:
将用户的商品id存入要发送的信息中
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
第三步:创建Topic主题模式的配置类
package com.xxxx.seckill.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@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.#");
}
}
第四步:创建MQsender来发送秒杀信息
package com.xxxx.seckill.rabbitmq;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class MQSender {
@Autowired
private RabbitTemplate rabbitTemplate;
public void sendSeckillMessage(String message){
log.info("发送消息:"+message);
rabbitTemplate.convertAndSend("seckillExchange","seckill.message",message);
}
}
第五步:在秒杀controller类中注入MQsender类来完成信息发送
@Autowired
private MQSender mqSender;
注:MQ传对象必须要序列化,但是转JSON后不用
mqSender.sendSeckillMessage(JSONUtil.toJsonStr(seckillMessage));
第六步:发送消息后要在消费者里去处理消息
package com.xxxx.seckill.rabbitmq;
import cn.hutool.json.JSONUtil;
import com.xxxx.seckill.pojo.SeckillMessage;
import com.xxxx.seckill.pojo.SeckillOrder;
import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IGoodsService;
import com.xxxx.seckill.service.IOrderService;
import com.xxxx.seckill.vo.GoodsVo;
import com.xxxx.seckill.vo.RespBean;
import com.xxxx.seckill.vo.RespBeanEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@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.toBean(message, SeckillMessage.class);
Long goodId = seckillMessage.getGoodId();
User user = seckillMessage.getUser();
//判断库存
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodId);
if(goodsVo.getStockCount()<1){
return;
}
判断是否重复抢购
SeckillOrder seckillOrder= (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodId);
if(seckillOrder!=null){
return ;
}
//下单操作
orderService.seckill(user,goodsVo);
}
}
减少对redis数据库的访问
当库存为0时,访问redis已经没有意义,还会是的redis压力增大,因此我们要在库存为0后停止访问redis。
第一步:
map的key来储存商品id,map的value用来表示该商品库存是否为0
private Map<Long,Boolean> EmptyStockMap=new HashMap<>();
第二步:在之前初始化的方法里将每个商品的boolean值都设置为false
list.forEach(goodsVo -> {
redisTemplate.opsForValue().set("seckillGoods:"+goodsVo.getId(),goodsVo.getStockCount());
EmptyStockMap.put(goodsVo.getId(),false);
});
第三步:当库存为0时将value设置为true
if(stock<0){
EmptyStockMap.put(goodsId,true);}
第四步:在访问redis前加入以下代码
//内存标记,减少Redis的访问
if(EmptyStockMap.get(goodsId)){
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}