自建网站餐饮服务提供者,免费seo快速排名系统,织梦可以做论坛网站,wordpress卖邀请码链接构思了很多种讲述这个简易版的秒杀项目的思路#xff0c;比如按照功能分类#xff0c;按照项目亮点串起来讲述#xff0c;总觉得不适合基础薄弱的同学来学习#xff0c;所以本项目按照从搭建开始#xff0c;过程中需要什么来学习什么。
技术栈
SpringBootmybatisPlus比如按照功能分类按照项目亮点串起来讲述总觉得不适合基础薄弱的同学来学习所以本项目按照从搭建开始过程中需要什么来学习什么。
技术栈
SpringBootmybatisPlusMySQL,Redis,RabbitMQ
项目地址
ma/seckill
亮点
1.自定义注解Spring AOP
2.用户密码两次MD5加密 第一次MD5加密防止用户明文密码在网络进行传输 第二次MD5加密防止数据库被盗避免通过MD5反推出密码双重保险
3.redis分布式锁保证秒杀业务场景下的正确性
4.秒杀操作后的秒杀成功信息进入RabbitMQ进行排队 项目代码结构图 准备
创建SpringBoot项目写入Maven文件导入sql文件。
数据库介绍
使用MYSQL goods代表货物信息表
order_info订单详情表
seckill_good秒杀商品表
seckill_order秒杀商品订单表
user用户表
Redis服务启动 这里使用的是Redis-windows版本无密码
RabbitMQ服务启动 如果你不会安装RabbitMQ请查看windows环境下安装RabbitMQ超详细_windows安装rabbitmq-CSDN博客
http://localhost:15672/#/保证本机RabbitMQ服务启动账号是默认账号guest/guest
http://localhost:15672/#/
Coding
用户登录模块
对应LoginController.java
一共有两个方法一个是界面跳转方法略过 RequestMapping(/do_login)ResponseBodypublic ResultString doLogin(Valid LoginParam loginParam, HttpServletResponse response){System.out.println(loginParam);//登陆String token userService.login(response, loginParam);return Result.success(token);}
首先来说一下整个方法的返回值Result.java
public class ResultT {private int code;private String msg;private T data;private Result(T data) {this.code CodeMsg.SUCCESS.getCode();this.msg CodeMsg.SUCCESS.getMsg();this.data data;}public boolean isSuccess(){return this.codeCodeMsg.SUCCESS.getCode();}public static T ResultT success(T data){return new ResultT(data);}public static T ResultT error(CodeMsg codeMsg){return new ResultT(codeMsg);}private Result(int code, String msg) {this.code code;this.msg msg;}private Result(CodeMsg codeMsg) {if(codeMsg ! null) {this.code codeMsg.getCode();this.msg codeMsg.getMsg();}}public int getCode() {return code;}public void setCode(int code) {this.code code;}public String getMsg() {return msg;}public void setMsg(String msg) {this.msg msg;}public T getData() {return data;}public void setData(T data) {this.data data;}
}
里面有三个变量分别代表着结果代码结果信息结果中返回的数据T代表泛型的意思不懂的可以百度CodeMsg代表的具体定义的一些成功和异常信息。 接下来我们再返回LoginController里面的doLogin方法可以注意到在函数参数中使用了Valid注解代表着LoginParam需要进行参数检查我们进入LoginParam.java
import com.lgc.SeckillProject.vaildator.IsMobile;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.validator.constraints.Length;import javax.validation.constraints.NotNull;Getter
Setter
ToString
public class LoginParam {NotNull(message 手机号不能为空)IsMobileprivate String mobile;NotNullLength(min 23,message 密码长度需要在7个字以内)private String password;
}
上面的代码特意粘贴了使用注解来源于哪个包明确一下注解的来源。
代码中的两个字段mobile和password两个字段分别用NotNull注解修饰意思是两个字段传入的时候不允许为空。
另外password对字段的长度进行了限制.
拓展 我们还注意到他使用了IsMobile注解这是一个自定义的注解也是本项目中的一个亮点。
我们需要自定义注解首先创建一个自定义注解类 Target({METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER}) //注解的作用范围 方法字段、枚举的常量注解构造函数方法参数
Retention(RetentionPolicy.RUNTIME) //注解的生命周期默认是class,RUNTIME运行时存在。RUNTIMEclasssource
Documented //如果一个注解B被Documented标注那么被B修饰的类生成文档时会显示B。如果B没有被Documented标准最终生成的文档中就不会显示B。
Constraint(validatedBy {IsMobileValidator.class})//自定义约束
public interface IsMobile {boolean required() default true;String message() default 手机号码格式错误;Class?[] groups() default {};Class? extends Payload[] payload() default {};}
从上面看有很多陌生的注解我们一个一个来分析
首先是Target注解里面写入了这个注解的使用范围包括可以在方法上使用字段、枚举的常量注解构造函数方法参数
Retention(RetentionPolicy.RUNTIME)注解的生命周期一般是RUNTIME默认是class;RUNTIMECLASSSOURCE
Document .如果一个注解B被Documented标注那么被B修饰的类生成文档时会显示B。如果B没有被Documented标准最终生成的文档中就不会显示B
Constraint 自定义约束IsMobileValidator.class是自定义的约束类
自定义注解中有几个方法并且每个方法中都有默认的值。
接下来我们来看一下注解中自定义约束类IsMobileValidator.java
public class IsMobileValidator implements ConstraintValidatorIsMobile,String {private boolean requiredfalse;Overridepublic void initialize(IsMobile constraintAnnotation) {requiredconstraintAnnotation.required();}Overridepublic boolean isValid(String value, ConstraintValidatorContext context) {if (required){return VaildatorUtil.isMobile(value);}else{if (StringUtils.isEmpty(value)){return true;}else{return VaildatorUtil.isMobile(value);}}}
}
首先实现接口ConstraintValidator,参数为自定义注解和String
重写里面的initialize方法还有isValid方法require代表的是需不需要进行验证isValid是验证方法如果是需要的验证的话调用isValid方法isValid里面又使用ValidatorUtil.isMobild方法,这个方法的内部是使用正则表达式进行实现的。 至此自定义注解讲解完成。
我们返回LoginController继续我们来看一下userService.login这个方法进入UserServiceImpl中login方法。 我们来分析一下上面的红框中的代码为了防止从浏览器中输入的密码在网络中明文传输我们使用了MD5进行加密在从浏览器的输入中获取密码后加入“盐”使用MD5.formPassToDBPass进行加密处理然后再与数据库中已加密的密码进行比较。 以上是加密的细节。
返回UserServiceImpl继续看login方法后半段生成cookie部分
使用UUIDUtil工具类生成随机的token ,进入addCookie方法
//生成cookie
String token UUIDUtil.uuid();
addCookie(response,token,user);
return token;
在Cookie方法中把生成的token作为key的后半段UserKey.token作为前半段拼接构成key值user对象作为value值传入Redis,并且生成一个Cookie对象放入response中。
private void addCookie(HttpServletResponse response,String token,User user){redisService.set(UserKey.token,token,user,UserKey.TOKEN_EXPIRE);Cookie cookie new Cookie(COOKI_NAME_TOKEN, token);cookie.setMaxAge(UserKey.TOKEN_EXPIRE);cookie.setPath(/);response.addCookie(cookie);}
这里重点讲解一下redisService.set方法的内部逻辑
/*** 设置对象** param prefix 对象Prefix* param key 键* param value 值* param exTime 过期时间* param T 返回类型* return*/public T boolean set(KeyPrefix prefix,String key,T value,int exTime){String str beanToString(value);if (strnull || str.length() 0){return false;}//生成唯一keyString realKey prefix.getPrefix() key;//设置过期时间if (exTime0){stringRedisTemplate.opsForValue().set(realKey,str);}else{return stringRedisTemplate.opsForValue().setIfAbsent(realKey,str,exTime, TimeUnit.SECONDS);}return true;}
为了保证整个秒杀业务中商品数量的正确性关键的一个步骤是并发场景下锁的竞争在这里使用了redis的分布式锁机制也就是setIfAbsent这个方法我认为也是整个项目的关键之一。在本文中的最后部分《redis分布式锁解析》详细介绍一下这个东西。
订单模块
对应orderController.java
订单模块的控制层只包含一个方法info 方法参数为user以及订单号返回值为订单详情。
该模块大多数为增删改查以及简单的业务逻辑较为简单自己顺着代码看看就可以
货物模块
对应GoodsController.java
首先针对list方法进行分析。
为了应对在SpringBoot中的高并发及优化访问速度我们一般会把页面上的数据查询出来然后放到redis中进行缓存减少数据库的压力。如果再进行改进的话可以对整个界面进行缓存。
RequestMapping(/list)ResponseBodypublic String list(Model model, HttpServletRequest request, HttpServletResponse response){//取缓存String html redisService.get(GoodsKey.getGoodsList, , String.class);if (!StringUtils.isEmpty(html)){return html;}//获取数据绑定到modelListGoodsVo goodsVos goodService.listGoodVo();model.addAttribute(goodsVos,goodsVos);WebContext ctx new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());//手动渲染html thymeleafViewResolver.getTemplateEngine().process(goods_list, ctx);if (!StringUtils.isEmpty(html)){redisService.set(GoodsKey.getGoodsList,,html,60);}return html;}
goodsDetail和detailStatic思路也是一样的无非就是添加了秒杀状态还有倒计时这俩思路和上面的代码是一致的。自己顺着代码理一遍就可以
秒杀模块
对应模块SeckillController.java 实现了InitializingBean接口需要重写afterPropertiesSet方法系统启动后进行初始化将热点数据放入redis中。 这里在方法里还定义了一个map,我们知道map的存储位置是内存中所以我们将秒杀的商品的Id放入内存中查询速度会飞快。
下面介绍一下秒杀接口1.0以及改进版本从一开始的QPS793经过优化后QPS1658
首先是1.0版本 QPS:793 * 线程5000 * 10 * 进行秒杀
RequestMapping(/do_seckill)public String seckill(Model model, User user, RequestParam(goodsId)long goodsId){model.addAttribute(user,user);if (usernull){return login;}//库存判断GoodsVo goodsVo goodsService.getGoodsVoById(goodsId);int stock goodsVo.getStockCount();if (stock0){model.addAttribute(ErrorMsg, CodeMsg.MIAO_SHA_OVER.getMsg());return seckill_fail;}//判断是否秒杀到了SeckillOrder order orderService.getOrderByUserIdGoodsId(user.getId(), goodsId);if (order!null){model.addAttribute(ErrorMsg,CodeMsg.REPEATE_MIAOSHA.getMsg());return seckill_fail;}//减库存下订单写入秒杀订单OrderInfo orderInfo seckillService.seckill(user, goodsVo);model.addAttribute(orderInfo,orderInfo);model.addAttribute(goods,goodsVo);return order_detail;}
核心是将处理好的数据放到model,界面查询速度偏慢
2.0版本 * QPS:1206 * 线程5000 * 10 * 订单页面静态化
RequestMapping(value /seckill,method RequestMethod.POST)ResponseBodypublic ResultOrderInfo seckillStatic(User user,RequestParam(goodsId)long goodsId){if (usernull){return Result.error(CodeMsg.SESSION_ERROR);}//判断库存GoodsVo goods goodsService.getGoodsVoById(goodsId);int stockgoods.getStockCount();if (stock0){return Result.error(CodeMsg.MIAO_SHA_OVER);}//判断是否已经秒杀到了SeckillOrder order orderService.getOrderByUserIdGoodsId(user.getId(), goodsId);if (order!null){return Result.error(CodeMsg.REPEATE_MIAOSHA);}//减少库存写入秒杀订单OrderInfo orderInfo seckillService.seckill(user, goods);return Result.success(orderInfo);}
整体的流程和1.0没有区别区别在于将最后的结果封装在Result中了。速度还可以再提升
3.0版 * QPS:1658 * 线程5000 * 10 * 加入消息队列
RequestMapping(value /{path}/seckill_mq,method RequestMethod.POST)ResponseBodypublic ResultInteger seckillMq(User user, RequestParam(goodsId)long goodsId, PathVariable(path)String path){if (usernull){return Result.error(CodeMsg.SESSION_ERROR);}//验证pathboolean checkPath seckillService.checkPath(user, goodsId, path);if (!checkPath){return Result.error(CodeMsg.REQUEST_ILLEGAL);}//内存标记减少Redis访问Boolean over localOverMap.get(goodsId);if (over){return Result.error(CodeMsg.MIAO_SHA_OVER);}//预减库存Long stock redisService.decr(GoodsKey.getSeckillGoodStock, goodsId);if (stock0){localOverMap.put(goodsId,true);return Result.error(CodeMsg.MIAO_SHA_OVER);}//判断是否秒杀到了SeckillOrder order orderService.getOrderByUserIdGoodsId(user.getId(), goodsId);if (order!null){return Result.error(CodeMsg.REPEATE_MIAOSHA);}//压入消息队列中//入队SeckillMessage sm new SeckillMessage();sm.setUser(user);sm.setGoodsId(goodsId);sender.sendSeckillMessage(sm);return Result.success(0);//排队中}
3.0版本一个是引入了本地map减少了Redis的访问另外使用了预减库存秒杀成功后将秒杀成功信息发送到RabbitMQ中进入队列中排队。
从代码中可以看出预减成功后也只是进入到了RabbitMQ中进行排队不至于阻塞至于最后入库还需要对队列进行监听。
值得说一点的是秒杀接口操作的层次仅仅只在Redis中所有操作的数据都在Redis中所以此过程不存在与数据库的任何操作也就是说你如果在秒杀过程中失败了不会影响到数据库中的数据这是极为巧妙的也是值得学习的只有当秒杀成功后秒杀成功的消息放入MQ并且MQ监听到的时候此时监听到的信息才会真正的创建订单并存入数据库。 RabbitListener(queues MQConfig.SECKILL_QUEUE)public void receive(String message){log.info(receive message:message);SeckillMessage sm redisService.stringToBean(message, SeckillMessage.class);User user sm.getUser();long goodsId sm.getGoodsId();GoodsVo goods goodsService.getGoodsVoById(goodsId);int stockgoods.getStockCount();if (stock0){return;}//判断是否秒杀到了SeckillOrder order orderService.getOrderByUserIdGoodsId(user.getId(), goodsId);if (order!null){return;}//减库存 下订单 写入秒杀订单seckillService.seckill(user,goods);}
上面的方法是RabbitMQ监听队列SECKILL_QUEUE,按照顺序从队列中读取数据同步数据库操作。
/*** 客户端轮询查询是否下单成功* orderId成功* -1秒杀失败* 0 排队中*/RequestMapping(value /result,method RequestMethod.GET)ResponseBodypublic ResultLong seckillResult(RequestParam(goodsId)long goodsId,User user){if (usernull){return Result.error(CodeMsg.USER_NO_LOGIN);}long result seckillService.getSeckillResult(user.getId(), goodsId);return Result.success(result);}
前端轮训查询是否下单成功最终查询的是数据库中的数据而不是Redis中的数据。
/*** 获取秒杀地址* 自定义接口限流5秒内最多访问5次并需要为登录状态* param user* param goodsId* return*/AccessLimit(seconds 5,maxCount 5,needLogin true)RequestMapping(value /path,method RequestMethod.GET)ResponseBodypublic ResultString getSeckillPath(User user,RequestParam(goodsId)long goodsId){if (usernull){return Result.error(CodeMsg.USER_NO_LOGIN);}String path seckillService.createPath(user, goodsId);return Result.success(path);}
上面是获取秒杀地址的接口因为主要的并发压力在这个接口所以需要对这个接口进行限流。
核心点在AccessLimit这个自定义注解中来看一下自定义注解的定义
Retention(RetentionPolicy.RUNTIME)
Target(ElementType.METHOD)
public interface AccessLimit {int seconds();int maxCount();boolean needLogin() default true;
}
注解不解释了里面定义了三个方法seconds(),maxCount(),needLogin(),除了第三个方法从字面意思上能知道是干啥用的其余头俩都不清楚这就引出了Spring一个很重要的特性面向切面编程。
找到AccessInterceptor.java这个类实现HandlerInterceptor接口实现preHandle方法
Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if (handler instanceof HandlerInterceptor){//获取用户并保存User user getUser(request, response);UserContext.setUser(user);//获取限流注解HandlerMethod hm (HandlerMethod) handler;AccessLimit accessLimit hm.getMethodAnnotation(AccessLimit.class);if (accessLimitnull){return true;}//自定义接口限流时间、访问数、是否需要登录//在conttoller方法上加上AccessLimit(second5,maxCount5,needLogintrue)int seconds accessLimit.seconds();int maxCount accessLimit.maxCount();boolean needLogin accessLimit.needLogin();String key request.getRequestURI();if (needLogin){if (usernull){render(response, CodeMsg.SESSION_ERROR);return false;}key_user.getId();}else{//do nothing}//根据限流键值获取缓存AccessKey ak AccessKey.withExpire();Integer count redisService.get(ak, key, Integer.class);if (countnull){redisService.set(ak,key,1,seconds);} else if (countmaxCount) {redisService.incr(ak,key);}else{render(response,CodeMsg.ACCESS_LIMIT_REACHED);return false;}}return true;}
Spring中AOP的概念将在上面的代码中体现的淋漓尽致首先是if判断是否继承自HandlerInterceptor,然后从request中获取token,进而得到当前的用户得到用户后与ThreadLocal进行绑定Threadlocal的底层是一个Map的结构。
随后我们基于当前的handler处理器得到方法的注解通过这个对象的获取我们可以拿到注解中各个参数的值。 如果说注解标记的方法需要登录后才能使用恰巧获取的当前用户为空需要返回给界面一些提示信息 比如像下面代码这样写
/*** 把提示返回给客户端* param response* param cm* throws Exception*/private void render(HttpServletResponse response, CodeMsg cm) throws Exception{response.setContentType(application/json;charsetUTF-8);OutputStream out response.getOutputStream();String str JSON.toJSONString(Result.error(cm));out.write(str.getBytes(UTF-8));out.flush();out.close();}
之后我们需要根据限流键值对从redis中获取此时这个方法目前已被访问的次数。 值得一提的是redisService.incr以及redisService.decr都是原子方法。
至此切面写完了我们需要把切面注入到Spring中
来到WebConfig.java实现WebMvcConfigurer接口,需要重写addArgumentResolvers还有addInterceptors。
addInterceptors这个方法是将自定义的accessInterceptor注册进来就可以
Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(accessInterceptor);}
addArgumentReslvers这个是对Controller层传入的参数进行处理将处理好的参数传给Controller里面的方法。详情请查看《WebMvcConfigurer中addArgumentResolvers方法的使用》
我们在这里使用的是自己定义的UserArgumentResolver.java这个类。
Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {Overridepublic boolean supportsParameter(MethodParameter parameter) {Class? clazz parameter.getParameterType();return clazz User.class;}Overridepublic Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {return UserContext.getUser();}
}
我们来简单介绍一下这个实现了HandlerMethodArgumentResolver接口的自定义方法只有参数是User.class时才会生效生效后会返回一个User对象。要想深入理解可参考
https://blog.csdn.net/ocean35/article/details/105892788结束----
自定义全局异常
自定义异常类在项目中会经常遇到主要帮助用户抛出自定义的异常方便用户理解。
public class GlobalException extends RuntimeException{private static final long serialVersionUID1L;private CodeMsg cm;public GlobalException(CodeMsg cm){super(cm.toString());this.cmcm;}public CodeMsg getCm(){return cm;}
}
因为继承自RuntimeException所以必须有个super方法。
Redis配置类
这里使用了Jedis的直接读且application.properties文件的方法比较新颖如果以后在项目中碰到业务场景需加入redis并且要求配置简便的情况下可以考虑这种
首先是RedisConfig这个配置 类主要是与application.properties中的配置字段对应上。
Data
Component
ConfigurationProperties(prefix redis)
public class RedisConfig {private String host;private int port;private int timeout;private String password;private int poolMaxTotal;private int poolMaxIdle;private int poolMaxWait;
}
ConfigurationProperties(prefixredis)这与application.properties相对应。 接下来是jedisPool创建操作
Service
public class RedisPoolFactory {Autowiredprivate RedisConfig redisConfig;Beanpublic JedisPool JedisPoolFactory(){JedisPoolConfig poolConfig new JedisPoolConfig();poolConfig.setMaxIdle(redisConfig.getPoolMaxIdle());poolConfig.setMaxTotal(redisConfig.getPoolMaxTotal());poolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait()*1000);JedisPool jp new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),redisConfig.getTimeout() * 1000, redisConfig.getPassword(), 0);return jp;}
}
核心的一点就是创建JedisPool操作。 redis分布式锁解析
知识点
setIfAbsent(key,value,时长时长单位):设置之前先判断key值是否存在
setIfAbsent 是redis(setnx)在java中的用法
思路
1.根据秒杀的业务场景我们需要对秒杀商品的库存数生成一个锁更改库存数的时候先判断库存锁是否有效和存在
2.如果库存锁存在返回一个错误提示
3.如果库存锁不存在对库存数量进行操作
4.执行完整个逻辑后删除库存锁
锁的设计
stringRedisTemplate.opsForValue().setIfAbsent(realKey,str,exTime, TimeUnit.SECONDS);
realKey作为key
str作为value
exTime代表过期时间
TimeUnit.SECONDs代表时间单位
WebMvcConfigurer中addArgumentResolvers方法的使用 在Springboot中的WebMvcConfigurer接口在Web开发中经常被使用例如配置拦截器、配置ViewController、配置Cors跨域等。
本文主要讲解另一个方法addArgumentResolvers()在实例中的应用。
一、方法作用 该方法可以用在对于Controller中方法参数传入之前对该参数进行处理。然后将处理好的参数在传给Controller中的方法。 官方API文档解释添加解析器以支持自定义控制器方法参数类型。 这不会覆盖对解析处理程序方法参数的内置支持。要自定义对参数解析的内置支持请RequestMappingHandlerAdapter直接配置。
二、场景描述 在权限场景中通常会有要求用户登录之后才能访问的场景。对于这些问题可以多种解决方案如使用CookieSession的会话控制、使用拦截器、使用SpringSecurity或shiro等权限管理框架等。 这里使用CookieSession处理。处理的逻辑为 用户第一次登录之后会得到一个cookie在以后每次的访问过程中都会携带Cookie进行访问。在后台的Controller中对于需要登录权限的访问接口都要先获取Cookie中的Token再使用Token从session中获取用户登录信息来判断用户登录情况决定是否放行。