Java高并发秒杀API(三)之Web层

在进行前端交互设计和开发高并发秒杀api时,遵循restful规范、使用springmvc框架以及bootstrapjquery是关键步骤。以下是详细的开发流程和注意事项。

Java高并发秒杀API(三)之Web层

前端页面流程

Java高并发秒杀API(三)之Web层

详情页流程逻辑

立即学习Java免费学习笔记(深入)”;

考虑到用户可能位于不同时区,且他们的系统时间可能不同,这一点在设计时需要特别注意。

Restful规范

Restful规范通过优雅的URI表达方式来组织资源路径:/模块/资源/{标识}/集合1/…

  • GET -> 查询操作
  • POST -> 添加/修改操作(用于非幂等操作)
  • PUT -> 修改操作(用于幂等操作)
  • delete -> 删除操作

在SpringMVC中,使用注解来映射http方法:

@RequestMapping(value = "/path", method = RequestMethod.GET) @RequestMapping(value = "/path", method = RequestMethod.POST) @RequestMapping(value = "/path", method = RequestMethod.PUT) @RequestMapping(value = "/path", method = RequestMethod.DELETE)

幂等性(idempotency)表示对同一URL的多个请求应返回相同的结果。在Restful规范中,GET、PUT、DELETE是幂等操作,而POST是非幂等操作。

POST和PUT都可用于创建和更新资源,区别在于前者用于非幂等操作,后者用于幂等操作。例如,使用POST方法请求创建资源,如果重复发送N次,将创建N个资源;使用GET方法请求创建资源,即使重复发送N次,也只会创建一个资源。

秒杀API的URL设计

Java高并发秒杀API(三)之Web层

注解映射技巧

Java高并发秒杀API(三)之Web层

整合配置SpringMVC框架

2.1 配置web.xml

<web-app metadata-complete="true" version="3.0" xmlns="http://Java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd">     <servlet>         <servlet-name>seckill-dispatcher</servlet-name>         <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>         <init-param>             <param-name>contextConfigLocation</param-name>             <param-value>classpath:spring/spring-*.xml</param-value>         </init-param>     </servlet>     <servlet-mapping>         <servlet-name>seckill-dispatcher</servlet-name>         <url-pattern>/</url-pattern>     </servlet-mapping> </web-app>

Servlet版本为3.0,适用于tomcat7.0版本。配置文件以spring-开头,可使用通配符*一次性加载所有配置文件。url-pattern设置为/,符合Restful规范;而在使用struts框架时,通常配置为*.do,这是一种较为丑陋的表达方式。

2.2 在src/main/resources/spring包下建立spring-web.xml

<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd">     <annotation-driven></annotation-driven>     <default-servlet-handler></default-servlet-handler>     <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">         <property name="viewClass" value="org.springframework.web.servlet.view.JStlView"></property>         <property name="prefix" value="/WEB-INF/jsp/"></property>         <property name="suffix" value=".jsp"></property>     </bean>     <component-scan base-package="com.lewis.web"></component-scan> </beans>

Controller设计

Controller中的每个方法对应系统中的一个资源URL,应遵循Restful接口设计风格。

3.1 在java包下新建com.lewis.web包,在该包下新建SeckillController.java

@Controller @RequestMapping("/seckill") // url:模块/资源/{}/细分 public class SeckillController {     @Autowired     private SeckillService seckillService; <pre class="brush:php;toolbar:false">@RequestMapping(value = "/list", method = RequestMethod.GET) public String list(Model model) {     // list.jsp+mode=ModelAndView     // 获取列表页     List<Seckill> list = seckillService.getSeckillList();     model.addAttribute("list", list);     return "list"; }  @RequestMapping(value = "/{seckillId}/detail", method = RequestMethod.GET) public String detail(@PathVariable("seckillId") Long seckillId, Model model) {     if (seckillId == null) {         return "redirect:/seckill/list";     }     Seckill seckill = seckillService.getById(seckillId);     if (seckill == null) {         return "forward:/seckill/list";     }     model.addAttribute("seckill", seckill);     return "detail"; }  // ajax, json暴露秒杀接口的方法 @RequestMapping(value = "/{seckillId}/exposer", method = RequestMethod.GET, produces = {"application/json;charset=UTF-8"}) @ResponseBody public SeckillResult<Exposer> exposer(@PathVariable("seckillId") Long seckillId) {     SeckillResult<Exposer> result;     try {         Exposer exposer = seckillService.exportSeckillUrl(seckillId);         result = new SeckillResult<Exposer>(true, exposer);     } catch (Exception e) {         e.printStackTrace();         result = new SeckillResult<Exposer>(false, e.getMessage());     }     return result; }  @RequestMapping(value = "/{seckillId}/{md5}/execution", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"}) @ResponseBody public SeckillResult<SeckillExecution> execute(@PathVariable("seckillId") Long seckillId, @PathVariable("md5") String md5, @CookieValue(value = "userPhone", required = false) Long userPhone) {     if (userPhone == null) {         return new SeckillResult<SeckillExecution>(false, "未注册");     }     try {         SeckillExecution execution = seckillService.executeSeckill(seckillId, userPhone, md5);         return new SeckillResult<SeckillExecution>(true, execution);     } catch (RepeatKillException e1) {         SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.REPEAT_KILL);         return new SeckillResult<SeckillExecution>(true, execution);     } catch (SeckillCloseException e2) {         SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.END);         return new SeckillResult<SeckillExecution>(true, execution);     } catch (Exception e) {         SeckillExecution execution = new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR);         return new SeckillResult<SeckillExecution>(true, execution);     } }  // 获取系统时间 @RequestMapping(value = "/time/now", method = RequestMethod.GET) @ResponseBody public SeckillResult<Long> time() {     date now = new Date();     return new SeckillResult<Long>(true, now.getTime()); }

}

在处理Cookie时,如果找不到对应的Cookie会报错,因此设置required=false,将Cookie是否存在的逻辑判断放到代码中。

Service层中的抛出异常是为了让Spring能够回滚,Controller层中捕获异常是为了将异常转换为对应的Json供前台使用,缺一不可。

3.2 在dto包下新建一个SeckillResult

// 将所有的ajax请求返回类型,全部封装成json数据 public class SeckillResult<T> { // 请求是否成功 private boolean success; private T data; private String error;</p><pre class="brush:php;toolbar:false">public SeckillResult(boolean success, T data) {     this.success = success;     this.data = data; }  public SeckillResult(boolean success, String error) {     this.success = success;     this.error = error; }  public boolean isSuccess() {     return success; }  public void setSuccess(boolean success) {     this.success = success; }  public T getData() {     return data; }  public void setData(T data) {     this.data = data; }  public String getError() {     return error; }  public void setError(String error) {     this.error = error; }

}

SeckillResult是一个VO类(View Object),属于DTO层,用于封装json结果,方便页面取值。将其设计成泛型,可以灵活地封装各种类型的对象。success属性指的是页面是否发送请求成功,而秒杀执行的结果则封装在data属性中。

基于bootstrap开发页面

由于项目的前端页面都是由Bootstrap开发的,因此需要下载Bootstrap或使用在线CDN服务。Bootstrap依赖于jQuery,因此需要先引入jQuery。

4.1 在webapp下建立resources目录,接着建立script目录,建立seckill.js

// 存放主要交互逻辑的js代码 // JavaScript 模块化(package.类.方法) var seckill = { // 封装秒杀相关ajax的url URL: { now: function () { return '/seckill/seckill/time/now'; }, exposer: function (seckillId) { return '/seckill/seckill/' + seckillId + '/exposer'; }, execution: function (seckillId, md5) { return '/seckill/seckill/' + seckillId + '/' + md5 + '/execution'; } }, // 验证手机号 validatePhone: function (phone) { if (phone && phone.length == 11 && !isNaN(phone)) { return true; // 直接判断对象会看对象是否为空,空就是undefine就是false; isNaN 非数字返回true } else { return false; } }, // 详情页秒杀逻辑 detail: { // 详情页初始化 init: function (params) { // 手机验证和登录,计时交互 // 规划我们的交互流程 // 在cookie中查找手机号 var userPhone = $.cookie('userPhone'); // 验证手机号 if (!seckill.validatePhone(userPhone)) { // 绑定手机 控制输出 var killPhoneModal = $('#killPhoneModal'); killPhoneModal.modal({ show: true, // 显示弹出层 backdrop: 'static', // 禁止位置关闭 keyboard: false // 关闭键盘事件 }); $('#killPhoneBtn').click(function () { var inputPhone = $('#killPhoneKey').val(); console.log("inputPhone: " + inputPhone); if (seckill.validatePhone(inputPhone)) { // 电话写入cookie(7天过期) $.cookie('userPhone', inputPhone, { expires: 7, path: '/seckill' }); // 验证通过刷新页面 window.location.reload(); } else { // todo 错误文案信息抽取到前端字典里 $('#killPhoneMessage').hide().html('<label class="label label-danger">手机号错误!</label>').show(300); } }); } // 已经登录 // 计时交互 var startTime = params['startTime']; var endTime = params['endTime']; var seckillId = params['seckillId']; $.get(seckill.URL.now(), {}, function (result) { if (result && result['success']) { var nowTime = result['data']; // 时间判断 计时交互 seckill.countDown(seckillId, nowTime, startTime, endTime); } else { console.log('result: ' + result); alert('result: ' + result); } }); } }, handlerSeckill: function (seckillId, node) { // 获取秒杀地址,控制显示器,执行秒杀 node.hide().html('开始秒杀'); $.get(seckill.URL.exposer(seckillId), {}, function (result) { // 在回调函数种执行交互流程 if (result && result['success']) { var exposer = result['data']; if (exposer['exposed']) { // 开启秒杀 // 获取秒杀地址 var md5 = exposer['md5']; var killUrl = seckill.URL.execution(seckillId, md5); console.log("killUrl: " + killUrl); // 绑定一次点击事件 $('#killBtn').one('click', function () { // 执行秒杀请求 // 1.先禁用按钮 $(this).addClass('disabled'); // , // 2.发送秒杀请求执行秒杀 $.post(killUrl, {}, function (result) { if (result && result['success']) { var killResult = result['data']; var state = killResult['state']; var stateInfo = killResult['stateInfo']; // 显示秒杀结果 node.html('' + stateInfo + ''); } }); }); node.show(); } else { // 未开启秒杀(浏览器计时偏差) var now = exposer['now']; var start = exposer['start']; var end = exposer['end']; seckill.countDown(seckillId, now, start, end); } } else { console.log('result: ' + result); } }); }, countDown: function (seckillId, nowTime, startTime, endTime) { console.log(seckillId + '<em>' + nowTime + '</em>' + startTime + '_' + endTime); var seckillBox = $('#seckill-box'); if (nowTime > endTime) { // 秒杀结束 seckillBox.html('秒杀结束!'); } else if (nowTime < startTime) { // 秒杀未开始,计时事件绑定 var killTime = new Date(startTime + 1000); seckillBox.countdown(killTime, function (event) { // 时间格式 var format = event.strftime('秒杀倒计时: %D天 %H时 %M分 %S秒'); seckillBox.html(format); }).on('finish.countdown', function () { // 时间完成后回调事件 // 获取秒杀地址,控制现实逻辑,执行秒杀 seckill.handlerSeckill(seckillId, seckillBox); }); } else { // 秒杀开始 seckill.handlerSeckill(seckillId, seckillBox); } } };

使用Json来实现JavaScript模块化(类似于Java的package),避免将js代码混杂在一起,不利于维护和阅读。

由于eclipse内嵌的Tomcat设置的原因,需要在URL的所有路径前加上/seckill(项目名)才能正常映射到Controller中对应的方法。

// 封装秒杀相关ajax的url URL: { now: function () { return '/seckill/seckill/time/now'; }, exposer: function (seckillId) { return '/seckill/seckill/' + seckillId + '/exposer'; }, execution: function (seckillId, md5) { return '/seckill/seckill/' + seckillId + '/' + md5 + '/execution'; } },

如果在测试页面时找不到路径,可以删除URL中的/seckill。

4.2 编写页面

在WEB-INF目录下新建一个jsp目录,用于存放jsp页面。为了减少工作量,将每个页面都会使用到的头部文件和标签库分离出来,放到common目录下,在jsp页面中静态包含这两个公共页面。

关于jsp页面,请从源码中拷贝。实际开发中,前端页面由前端工程师完成,但后端工程师也应了解jQuery和ajax。想要了解本项目的页面实现,请观看慕课网的Java高并发秒杀API之Web层。

静态包含会直接将页面包含进来,最终只生成一个Servlet;而动态包含会先将要包含进来的页面生成Servlet后再包含进来,最终会生成多个Servlet。

在页面中,不要写成,这样会导致后边的js加载不了,应写成

startTime是Date类型的,通过${startTime.time}来将Date转换成long类型的毫秒值。

4.3 测试页面

首先清理maven项目,接着编译Maven项目(-X compile命令),然后启动Tomcat,在浏览器输入https://www.php.cn/link/5937bc13febda34938aa32a74ad94173,成功进入秒杀商品页面;输入https://www.php.cn/link/ffad99a1f556e0e0595aec7b8060662d成功进入详情页面。

1. pom.xml

<dependency> <groupId>org.webjars.bower</groupId> <artifactId>jquery.countdown</artifactId> <version>2.1.0</version> </dependency>

2. 页面

<script src="js/jquery.countdown.min.js"></script>

关于显示NaN天 NaN时 NaN分 NaN秒的问题,原因是new Date(startTime + 1000),startTime被解释成一个字符串

解决办法:

new Date(startTime - 0 + 1000); new Date(Number(startTime) + 1000);

根据系统标准时间判断,如果在分布式环境下各机器时间不同步怎么办?同时发起的两次请求,可能一个活动开始,另一个提示没开始。后端服务器需要做NTP时间同步,如每5分钟与NTP服务同步保证时间误差在微妙级以下。时间同步在业务需要或者活性检查场景很常见(如hbase的RegionServer)。

如果判断逻辑都放到后端,遇到有刷子,后端处理这些请求扛不住了怎么办?可能活动没开始,服务器已经挂掉了。秒杀开启判断在前端和后端都有,后端的判断比较简单,取秒杀单做判断,这块的IO请求是DB主键查询很快,单DB就可以抗住几万QPS,后面也会加入redis缓存为DB减负。

负载均衡问题,比如根据地域在nginx哈希,怎样能较好的保证各机器秒杀成功的尽量分布均匀呢?负载均衡包括nginx入口端和后端upstream服务,在入口端一般采用智能DNS解析请求就近进入nginx服务器。后端upstream不建议采用一致性hash,防止请求不均匀。后端服务无状态可以简单使用轮训机制。nginx负载均衡本身过于简单,可以使用openresty自己实现或者nginx之后单独架设负载均衡服务如netflix的Zuul等。

对于流量爆增造成的后端不可用情况,这门课程(Java高并发秒杀API)并没有做动态降级和弹性伸缩架构上的处理,后面受慕课邀请会做一个独立的实战课,讲解分布式架构,弹性容错,微服务相关的内容,到时会加入这方面的内容。

至此,关于Java高并发秒杀API的Web层的开发与测试已经完成,接下来进行对该秒杀系统进行高并发优化,详情可以参考下一篇文章。

上一篇文章: Java高并发秒杀API(二)之Service层

下一篇文章: Java高并发秒杀API(四)之高并发优化

警告

本文最后更新于 October 5, 2017,文中内容可能已过时,请谨慎使用。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享