Java秒殺方案


1.課程介紹

1.1. 技術點介紹
前端:Thymeleaf、Bootstrap、JQuery
后端:SpringBoot、MyBatisPlus、Lombok
中間件:RabbitMQ(異步、流量削峰)、Redis(緩存)

1.2. 課程介紹
Java秒殺方案:項目搭建、分布式Session、秒殺功能、壓力測試、頁面優化、服務優化、接口安全

2.學習目標

通過本課程的學習,主要是學習到怎么應對大並發:怎么使用異步、怎么使用緩存?如何編寫優雅的代碼?
分布式會話:用戶登錄、共享Session
功能開發:商品列表、商品詳情、秒殺、訂單詳情
系統壓測:JMeter入門、自定義變量、正式壓測
頁面優化:緩存、靜態化分離
服務優化:RabbitMQ消息隊列、接口優化、分布式鎖
安全優化:隱藏秒殺地址、驗證碼、接口限流

3.如何設計一個秒殺系統

秒殺,對我們來說,都不是一個陌生的東西。每年的雙11,618以及時下流行的直播等等。秒殺然而,這對於我們系統而言是一個巨大的考驗。

那么,如何才能更好地理解秒殺系統呢?我覺得作為一個程序員,你首先需要從高緯度出發,從整體上思考問題。在我看來,秒殺其實主要解決兩個問題,一個是並發讀,一個是並發寫。並發讀的核心優化理念是盡量減少用戶到服務端來“讀”數據,或者讓他們讀更少的數據;並發寫的處理原則也一樣,它要求我們在數據庫層面獨立出來一個庫,做特殊的處理。另外,我們還要針對秒殺系統做一些保護,針對意料之外的情況設計兜底方案,以防止最壞的情況發生。

其實,秒殺的整體架構可以概括為“穩、准、快”幾個關鍵字。
所謂“穩”,就是整個系統架構要滿足高可用,流量符合預期是肯定要穩定,就是超出預期時也同樣不能掉鏈子,你要保證秒殺活動順利完成,即秒殺商品順利地賣出去,這個是最基本的前提。
然后就是“准”,就是秒殺10台iPhone,那就只能成交10台,多一台少一台都不行。一旦庫存不對,那平台就要承擔損失,所以“准”就是要求保證數據的一致性。
最后再看“快”,“快”其實很好理解,它就是說系統的性能要足夠高,否則你怎么支撐這么大的流量呢?不光是服務端要做極致的性能優化,而且在整個請求鏈路上都要做協同的優化,每個地方快一點,整個系統就完美了。

所以從技術角度上看“穩”、“准”、“快”,就對應了我們架構上的高可用、一致性和高性能的要求

  • 高性能。秒殺涉及大量的並發讀和並發寫。因此支持高並發訪問這點非常關鍵。對應的方案比如動靜分離方案、熱點的發現與隔離,請求的削峰與分層過濾、服務器的極致優化
  • 一致性。秒殺中商品減庫存的實現方式同樣關鍵。可想而知,有限數量的商品在同一時刻被很多倍的請求同時來減庫存,減庫存又分為“拍下減庫存”“付款減庫存”以及預扣等幾種,在大並發更新的過程中都要保證數據的准確性,其難度可想而知
  • 高可用。現實中總難免出現一些我們考慮不到的情況,所以要保證系統的高可用和正確性,我們還要設計一個PlanB來兜底,以便在最壞情況發生時仍然能夠從容應對。

4.項目搭建

4.1. 創建項目

完整目錄

添加依賴
pom.xml

<?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.4.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.xxxx</groupId>
    <artifactId>seckill</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>seckill</name>
    <description>Demo project for Spring Boot</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>
        <!--mybatisplus依賴-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.2</version>
        </dependency>
        <!--mysql-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!--lombok-->
        <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>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.8.1</version>
        </dependency>
        <!--validation 組件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!--spring data redis 依賴-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--commons-pool2 對象池依賴-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</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>

修改配置文件
application.yml

spring:
  # thymeleaf配置
  thymeleaf:
    # 關閉緩存
    cache: false
  # 數據源配置
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    hikari:
      # 連接池名
      pool-name: DateHikariCP
      # 最小空閑連接數
      minimum-idle: 5
      # 空閑連接存貨最大時間,默認600000(10分鍾)
      idle-timeout: 1800000
      # 最大連接數,默認10
      maximum-pool-size: 10
      # 從連接池返回的連接自動提交
      auto-commit: true
      # 連接最大存活時間,0表示永久存活,默認1800000(30分鍾)
      max-lifetime: 1800000
      # 連接超時時間,默認30000(30秒)
      connection-timeout: 30000
      # 測試連接是否可用的查詢語句
      connection-test-query: SELECT 1

  # redis配置
  redis:
    # 服務器地址
    host: 121.37.15.219
    # 端口
    port: 6379
    # 數據庫
    database: 0
    # 超時時間
    timout: 10000ms
    lettuce:
      pool:
        # 最大連接數,默認8
        max-active: 8
        # 最大連接阻塞等待時間,默認-1
        max-wait: 10000ms
        # 最大空閑連接,默認8
        max-idle: 200
        # 最小空閑連接,默認0
        min-idle: 5
    # 密碼
    password: kisen

# Mybatis-plus配置
mybatis-plus:
  # 配置Mapper.xml映射文件
  mapper-locations: classpath*:/mapper/*Mapper.xml
  # 配置MyBatis數據返回類型別名(默認別名是類名)
  type-aliases-package: com.xxx.seckill.pojo


# MyBatis SQL打印(方法接口所在的包,不是Mapper.xml所在的包)
logging:
  level:
    com.xxxx.seckill.mapper: debug

測試
DemoController.java

package com.xxxx.seckill.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * 測試
 *
 * @ClassName: DemoController
 * @Title: seckill
 * @Package: com.xxxx.seckill.controller
 * @Description:
 * @Author: Kisen
 * @Date: 2021/3/1 22:47
 */
@Controller
@RequestMapping("/demo")
public class DemoController {

    /**
     * 測試頁面跳轉
     * @param model
     * @return
     */
    @RequestMapping("/hello")
    public String hello(Model model){
        model.addAttribute("name", "xxxx");
        return "hello";
    }
}

hello.html

<!doctype html>
<html lang="en"
    xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>測試</title>
</head>
<body>
<p th:text="'hello'+${name}"></p>
</body>
</html>

測試結果

添加公共結果返回對象
RespBeanEnum.java

package com.xxxx.seckill.vo;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;

/**
 * @ClassName: RespBeanEnum
 * @Title: seckill
 * @Package: com.xxxx.seckill.vo
 * @Description: 公共返回對象枚舉
 * @Author: Kisen
 * @Date: 2021/3/4 22:29
 */
@Getter
@ToString
@AllArgsConstructor
public enum RespBeanEnum {
    // 通用
    SUCCES(200, "SUCCES"),
    ERROR(500, "服務端異常"),
    // 登錄模塊
    LOGIN_ERROR(500210, "用戶名或密碼不正確"),
    MOBILE_ERROR(500211, "手機號碼格式不正確"),
    BIND_ERROR(500212, "參數校驗異常");

    private final Integer code;
    private final String message;
}

RespBean.java

package com.xxxx.seckill.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @ClassName: RespBean
 * @Title: seckill
 * @Package: com.xxxx.seckill.vo
 * @Description: 公共返回對象
 * @Author: Kisen
 * @Date: 2021/3/4 22:29
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RespBean {

    private long code;
    private String message;
    private Object obj;

    /**
     * 成功返回結果
     * @return
     */
    public static RespBean success() {
        return new RespBean(RespBeanEnum.SUCCES.getCode(),RespBeanEnum.SUCCES.getMessage(),null);
    }

    public static RespBean success(Object obj) {
        return new RespBean(RespBeanEnum.SUCCES.getCode(),RespBeanEnum.SUCCES.getMessage(),obj);
    }

    /**
     * 失敗返回結果
     * @param respBeanEnum
     * @return
     */
    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);
    }
}

5.分布式會話

5.1. 實現登錄功能

5.1.1. 兩次MD5加密

用戶端:PASS=MD5(明文+固定salt)
服務端:PASS=MD5(用戶輸入+隨機salt)

用戶端MD5加密是為了防止用戶密碼在網絡中明文傳輸,服務端MD5加密是為了提高密碼安全性,雙重保險。

引入pom.xml

        <!--md5 依賴-->
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.8.1</version>
        </dependency>

編寫MD5工具類
MD5Util.java

package com.xxxx.seckill.utils;

import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.stereotype.Component;

/**
 * MD5工具類
 *
 * @ClassName: MD5Util
 * @Title: seckill
 * @Package: com.xxxx.seckill.utils
 * @Description:
 * @Author: Kisen
 * @Date: 2021/3/1 23:24
 */
@Component
public class MD5Util {

    private static final String salt = "1a2b3c4d";

    public static String md5(String src){
        return DigestUtils.md5Hex(src);
    }

    public static String inputPassToFormPass(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 formPass = inputPassToFormPass(inputPass);
        String dbPass = formPassToDBPass(formPass, salt);
        return dbPass;
    }

    public static void main(String[] args) {
        // ce21b747de5af71ab5c2e20ff0a60eea
        System.out.println(inputPassToFormPass("123456"));
        System.out.println(formPassToDBPass("d3b1294a61a07da9b49b6e22b2cbd7f9", "1a2b3c4d"));
        System.out.println(inputPassToDBPass("123456", "1a2b3c4d"));
    }
}

5.1.2. 登錄功能實現

逆向工程

首先需要通過逆向工程t_user表生產對應POJO、Mapper、Service、ServiceImpl、Controller等類,項目中使用了MyBatisPlus,所以逆向工程也是用了MyBatisPlus提供的AutoGenerator,代碼如下。具體可去官網查看

CodeGenerator.java

package com.xxxx.generator;

import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

/**
 * @ClassName: CodeGenerator
 * @Title: generator
 * @Package: com.xxxx.generator
 * @Description:
 * @Author: Kisen
 * @Date: 2021/3/4 21:52
 */
public class CodeGenerator {

    /**
     * <p>
     * 讀取控制台內容
     * </p>
     */
    public static String scanner(String tip) {
        Scanner scanner = new Scanner(System.in);
        StringBuilder help = new StringBuilder();
        help.append("請輸入" + tip + ":");
        System.out.println(help.toString());
        if (scanner.hasNext()) {
            String ipt = scanner.next();
            if (StringUtils.isNotBlank(ipt)) {
                return ipt;
            }
        }
        throw new MybatisPlusException("請輸入正確的" + tip + "!");
    }

    public static void main(String[] args) {
        // 代碼生成器
        AutoGenerator mpg = new AutoGenerator();

        // 全局配置
        GlobalConfig gc = new GlobalConfig();
        String projectPath = System.getProperty("user.dir");
        gc.setOutputDir(projectPath + "/src/main/java");
        //作者
        gc.setAuthor("kisen");
        //打開輸出目錄
        gc.setOpen(false);
        //xml開啟BaseResultMap
        gc.setBaseResultMap(true);
        //xml開啟BaseColumnList
        gc.setBaseColumnList(true);
        //日期格式,采用Date
        gc.setDateType(DateType.ONLY_DATE);
        // gc.setSwagger2(true); 實體屬性 Swagger2 注解
        mpg.setGlobalConfig(gc);

        // 數據源配置
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl("jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai");
        // dsc.setSchemaName("public");
        dsc.setDriverName("com.mysql.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("root");
        mpg.setDataSource(dsc);

        // 包配置
        PackageConfig pc = new PackageConfig();
        pc.setParent("com.xxxx.seckill")
                .setEntity("pojo")
                .setMapper("mapper")
                .setService("service")
                .setServiceImpl("service.impl")
                .setController("controller");
        mpg.setPackageInfo(pc);

        // 自定義配置
        InjectionConfig cfg = new InjectionConfig() {
            @Override
            public void initMap() {
                // to do nothing
            }
        };

        // 如果模板引擎是 freemarker
        String templatePath = "/templates/mapper.xml.ftl";
        // 如果模板引擎是 velocity
        // String templatePath = "/templates/mapper.xml.vm";

        // 自定義輸出配置
        List<FileOutConfig> focList = new ArrayList<>();
        // 自定義配置會被優先輸出
        focList.add(new FileOutConfig(templatePath) {
            @Override
            public String outputFile(TableInfo tableInfo) {
                // 自定義輸出文件名 , 如果你 Entity 設置了前后綴、此處注意 xml 的名稱會跟着發生變化!!
                return projectPath + "/src/main/resources/mapper/" + "/" + tableInfo.getEntityName() + "Mapper"
                        + StringPool.DOT_XML;
            }
        });
        cfg.setFileOutConfigList(focList);
        mpg.setCfg(cfg);

        // 配置模板
        TemplateConfig templateConfig = new TemplateConfig()
                .setEntity("templates/entity2.java")
                .setMapper("templates/mapper2.java")
                .setService("templates/service2.java")
                .setServiceImpl("templates/serviceImpl2.java")
                .setController("templates/controller2.java");

        templateConfig.setXml(null);
        mpg.setTemplate(templateConfig);

        // 策略配置
        StrategyConfig strategy = new StrategyConfig();
        //數據庫表映射到實體的命名策略
        strategy.setNaming(NamingStrategy.underline_to_camel);
        //數據庫表字段映射到實體的命名策略
        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
        //lombok模型
        strategy.setEntityLombokModel(true);
        //生成@RestController控制器
        // strategy.setRestControllerStyle(true);
        strategy.setInclude(scanner("表名,多個英文逗號分割").split(","));
        strategy.setControllerMappingHyphenStyle(true);
        //表前綴
        strategy.setTablePrefix("t_");
        mpg.setStrategy(strategy);
        mpg.setTemplateEngine(new FreemarkerTemplateEngine());
        mpg.execute();
    }
}

ValidatorUtil.java

package com.xxxx.seckill.utils;

import org.apache.commons.lang3.StringUtils;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * @ClassName: ValidatorUtil
 * @Title: seckill
 * @Package: com.xxxx.seckill.utils
 * @Description: 手機號碼校驗
 * @Author: Kisen
 * @Date: 2021/3/6 15:31
 */
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();
    }
}

LoginController.java

package com.xxxx.seckill.controller;

import com.xxxx.seckill.service.IUserService;
import com.xxxx.seckill.vo.LoginVo;
import com.xxxx.seckill.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;

/**
 * @ClassName: LoginController
 * @Title: seckill
 * @Package: com.xxxx.seckill.controller
 * @Description:
 * @Author: Kisen
 * @Date: 2021/3/4 22:17
 */
@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {

    @Autowired
    private IUserService userService;

    /**
     * 跳轉登錄頁面
     * @return
     */
    @RequestMapping("/toLogin")
    public String toLogin() {
        return "login";
    }

    /**
     * 登錄功能
     * @param loginVo
     * @return
     */
    @RequestMapping("/doLogin")
    @ResponseBody
    public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
        return userService.doLogin(loginVo, request, response);
    }
}

IUserService.java

package com.xxxx.seckill.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.vo.LoginVo;
import com.xxxx.seckill.vo.RespBean;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <p>
 *  服務類
 * </p>
 *
 * @author kisen
 * @since 2021-03-04
 */
public interface IUserService extends IService<User> {

    /**
     * 登錄
     * @param loginVo
     * @param request
     * @param response
     * @return
     */
    RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response);

    /**
     * 根據cookie獲取用戶
     * @param userTicket
     * @return
     */
    User getUserByCookie(String userTicket, HttpServletRequest request, HttpServletResponse response);

}

UserServiceImpl.java

package com.xxxx.seckill.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xxxx.seckill.exception.GlobalException;
import com.xxxx.seckill.mapper.UserMapper;
import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IUserService;
import com.xxxx.seckill.utils.CookieUtil;
import com.xxxx.seckill.utils.MD5Util;
import com.xxxx.seckill.utils.UUIDUtil;
import com.xxxx.seckill.vo.LoginVo;
import com.xxxx.seckill.vo.RespBean;
import com.xxxx.seckill.vo.RespBeanEnum;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <p>
 *  服務實現類
 * </p>
 *
 * @author kisen
 * @since 2021-03-04
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 登錄
     * @param loginVo
     * @param request
     * @param response
     * @return
     */
    @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.LOGIN_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.formPassToDBPass(password, user.getSalt()).equals(user.getPassword())) {
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }
        // 生成cookie
        String ticket = UUIDUtil.uuid();
        // 將用戶信息存入redis中
        redisTemplate.opsForValue().set("user:" + ticket, user);
//        request.getSession().setAttribute(ticket, user);
        CookieUtil.setCookie(request, response, "userTicket", ticket);
        return RespBean.success();
    }

    /**
     * 根據cookie獲取用戶
     * @param userTicket
     * @return
     */
    @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;
    }
}

login.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登錄</title>
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/>
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- jquery-validator -->
    <script type="text/javascript" th:src="@{/jquery-validation/jquery.validate.min.js}"></script>
    <script type="text/javascript" th:src="@{/jquery-validation/localization/messages_zh.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- md5.js -->
    <script type="text/javascript" th:src="@{/js/md5.min.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<form name="loginForm" id="loginForm" method="post" style="width:50%; margin:0 auto">

    <h2 style="text-align:center; margin-bottom: 20px">用戶登錄</h2>

    <div class="form-group">
        <div class="row">
            <label class="form-label col-md-4">請輸入手機號碼</label>
            <div class="col-md-5">
                <input id="mobile" name="mobile" class="form-control" type="text" placeholder="手機號碼" required="true"
                       minlength="11" maxlength="11"/>
            </div>
            <div class="col-md-1">
            </div>
        </div>
    </div>

    <div class="form-group">
        <div class="row">
            <label class="form-label col-md-4">請輸入密碼</label>
            <div class="col-md-5">
                <input id="password" name="password" class="form-control" type="password" placeholder="密碼"
                       required="true" minlength="6" maxlength="16"/>
            </div>
        </div>
    </div>

    <div class="row">
        <div class="col-md-5">
            <button class="btn btn-primary btn-block" type="reset" onclick="reset()">重置</button>
        </div>
        <div class="col-md-5">
            <button class="btn btn-primary btn-block" type="submit" onclick="login()">登錄</button>
        </div>
    </div>
</form>
</body>
<script>
    function login() {
        $("#loginForm").validate({
            submitHandler: function (form) {
                doLogin();
            }
        });
    }

    function doLogin() {
        g_showLoading();

        var inputPass = $("#password").val();
        var salt = g_passsword_salt;
        var str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
        var password = md5(str);

        $.ajax({
            url: "/login/doLogin",
            type: "POST",
            data: {
                mobile: $("#mobile").val(),
                password: password
            },
            success: function (data) {
                layer.closeAll();
                if (data.code == 200) {
                    layer.msg("成功");
                    window.location.href="/goods/toList";
                } else {
                    layer.msg(data.message);
                }
            },
            error: function () {
                layer.closeAll();
            }
        });
    }
</script>
</html>

測試

手機號碼格式不正確

手機號碼或密碼不正確

正確登錄

5.2. 參數校驗

每個類都寫大量的健壯性判斷過於麻煩,我們可以使用validation簡化我們的代碼

添加依賴
pom.xml

        <!--validation 組件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

自定義手機號碼驗證規則
IsMobileValidator.java

package com.xxxx.seckill.vo;

import com.xxxx.seckill.utils.ValidatorUtil;
import com.xxxx.seckill.validator.IsMobile;
import org.apache.commons.lang3.StringUtils;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * @ClassName: IsMobileValidator
 * @Title: seckill
 * @Package: com.xxxx.seckill.vo
 * @Description: 手機號碼校驗規則
 * @Author: Kisen
 * @Date: 2021/3/6 16:48
 */
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 s, ConstraintValidatorContext constraintValidatorContext) {
        if (required) {
            return ValidatorUtil.isMobile(s);
        } else {
            if (StringUtils.isEmpty(s)) {
                return true;
            } else {
                return ValidatorUtil.isMobile(s);
            }
        }
    }
}

自定義注解
IsMobile.java

package com.xxxx.seckill.validator;

import com.xxxx.seckill.vo.IsMobileValidator;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;

/**
 * @ClassName: IsMobile
 * @Title: seckill
 * @Package: com.xxxx.seckill.validator
 * @Description: 驗證手機號
 * @Author: Kisen
 * @Date: 2021/3/6 16:46
 */
@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 {};
}

修改LoginVo
LoginVo.java

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;

/**
 * @ClassName: LoginVo
 * @Title: seckill
 * @Package: com.xxxx.seckill.vo
 * @Description: 登錄參數
 * @Author: Kisen
 * @Date: 2021/3/6 15:14
 */
@Data
public class LoginVo {

    @NotNull
    @IsMobile
    private String mobile;

    @NotNull
    @Length(min = 32)
    private String password;
}

其他修改

LoginController.java
入參添加@Valid

    /**
     * 登錄功能
     * @param loginVo
     * @return
     */
    @RequestMapping("/doLogin")
    @ResponseBody
    public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
        return userService.doLogin(loginVo, request, response);
    }

UserServiceImpl.java
注釋掉之前的健壯性判斷即可

    /**
     * 登錄
     * @param loginVo
     * @param request
     * @param response
     * @return
     */
    @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.LOGIN_ERROR);
        }
        if (!ValidatorUtil.isMobile(mobile)) {
            return RespBean.error(RespBeanEnum.MOBILE_ERROR);
        }*/
        // 根據手機號獲取用戶
        User user = userMapper.selectById(mobile);
        if (null == user) {
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        // 判斷密碼是否正確
        if (!MD5Util.formPassToDBPass(password, user.getSalt()).equals(user.getPassword())) {
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        return RespBean.success();
    }

測試

5.3. 異常處理

系統中異常包括:編譯時異常和運行時異常RuntimeException,前者通過捕獲異常從而獲得異常信息,后者主要通過規范代碼開發、測試通過手段減少運行時異常的發生。在開發中,不管是dao層、service層還是controller層,都有可能拋出異常,在SpringMVC中,能將所有類型的異常處理從各處理過程解耦處來,既保證了相關處理過程的功能較單一,也實現了異常信息的統一處理和維護。SpringBoot全局異常處理方式主要兩種:
使用@ControllerAdvice@ExceptionHandler注解。
使用ErrorController類來實現
區別:
@ControllerAdvice方式只能處理控制器拋出的異常。此時請求已經進入控制器中。
ErrorController類方式可以處理所有的異常,包括未進入控制器的錯誤,比如404,401等錯誤
如果應用中兩者共同存在,則@ControllerAdvice方式處理控制器拋出的異常,ErrorController類方式處理未進入控制器的異常。
@ControllerAdvice方式可以定義多個攔截方式,攔截不同的異常類,並且可以獲取拋出的異常信息,自由度更大。

GlobalException.java

package com.xxxx.seckill.exception;

import com.xxxx.seckill.vo.RespBeanEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @ClassName: GlobalException
 * @Title: seckill
 * @Package: com.xxxx.seckill.exception
 * @Description: 全局異常
 * @Author: Kisen
 * @Date: 2021/3/7 12:59
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GlobalException extends RuntimeException {
    private RespBeanEnum respBeanEnum;
}

GlobalExceptionHandler.java

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;

/**
 * @ClassName: GlobalExceptionHandler
 * @Title: seckill
 * @Package: com.xxxx.seckill.exception
 * @Description: 全局異常處理
 * @Author: Kisen
 * @Date: 2021/3/7 13:01
 */
@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);
    }
}

修改之前代碼
直接返回RespBean改為直接拋GlobalException異常

    /**
     * 登錄
     * @param loginVo
     * @param request
     * @param response
     * @return
     */
    @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.LOGIN_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.formPassToDBPass(password, user.getSalt()).equals(user.getPassword())) {
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }
        return RespBean.success();
    }

測試

6.分布式Session

6.1. 完善登錄功能

使用cookie+session記錄用戶信息

准備工具類
CookieUtil.java

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;

/**
 * @ClassName: CookieUtil
 * @Title: seckill
 * @Package: com.xxxx.seckill.utils
 * @Description: Cookie工具類
 * @Author: Kisen
 * @Date: 2021/3/7 13:48
 */
public 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
     * @param isDecoder
     * @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
     * @param encodeString
     * @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的值 不設置生效時間默認瀏覽器關閉即生效,也不編碼
     * @param request
     * @param response
     * @param cookieName
     * @param cookieValue
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response,
                                 String cookieName, String cookieValue) {
        setCookie(request, response, cookieName, cookieValue, -1);
    }

    /**
     * 設置Cookie的值 在指定時間內生效,但不編碼
     * @param request
     * @param response
     * @param cookieName
     * @param cookieValue
     * @param cookieMaxage
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response,
                                 String cookieName, String cookieValue, int cookieMaxage) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, false);
    }

    /**
     * 設置Cookie的值 不設置生效時間,但編碼
     * @param request
     * @param response
     * @param cookieName
     * @param cookieValue
     * @param isEncode
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response,
                                 String cookieName, String cookieValue, boolean isEncode) {
        doSetCookie(request, response, cookieName, cookieValue, -1, isEncode);
    }

    /**
     * 設置Cookie的值 在指定時間內生效,編碼參數
     * @param request
     * @param response
     * @param cookieName
     * @param cookieValue
     * @param cookieMaxage
     * @param isEncode
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response,
                                 String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode);
    }

    /**
     * 設置Cookie的值 在指定時間內生效,編碼參數(指定編碼)
     * @param request
     * @param response
     * @param cookieName
     * @param cookieValue
     * @param cookieMaxage
     * @param encodeString
     */
    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域名
     * @param request
     * @param response
     * @param cookieName
     */
    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response,
                                    String cookieName) {
        doSetCookie(request, response, cookieName, "", -1, false);
    }

    /**
     * 設置Cookie的值,並使其在指定時間內生效
     * @param request
     * @param response
     * @param cookieName
     * @param cookieValue
     * @param cookieMaxage
     * @param isEncode
     */
    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 request
     * @param response
     * @param cookieName
     * @param cookieValue
     * @param cookieMaxage
     * @param encodeString
     */
    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的域名
     * @param request
     * @return
     */
    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[] array = domainName.split("\\:");
            domainName = array[0];
        }
        return domainName;
    }
}

UUIDUtil.java

package com.xxxx.seckill.utils;

import java.util.UUID;

/**
 * @ClassName: UUIDUtil
 * @Title: seckill
 * @Package: com.xxxx.seckill.utils
 * @Description: UUID工具類
 * @Author: Kisen
 * @Date: 2021/3/7 13:31
 */
public class UUIDUtil {

    public static String uuid() {
        return UUID.randomUUID().toString().replace("-", "");
    }
}

UserServiceImpl.java

    /**
     * 登錄
     * @param loginVo
     * @param request
     * @param response
     * @return
     */
    @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.LOGIN_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.formPassToDBPass(password, user.getSalt()).equals(user.getPassword())) {
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }
         // 生成cookie
        String ticket = UUIDUtil.uuid();
        request.getSession().setAttribute(ticket, user);
        CookieUtil.setCookie(request, response, "userTicket", ticket);
        return RespBean.success();
    }

LoginController.java

    /**
     * 登錄功能
     * @param loginVo
     * @return
     */
    @RequestMapping("/doLogin")
    @ResponseBody
    public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
        return userService.doLogin(loginVo, request, response);
    }

GoodsController.java

package com.xxxx.seckill.controller;

import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * @ClassName: GoodsController
 * @Title: seckill
 * @Package: com.xxxx.seckill.controller
 * @Description: 商品
 * @Author: Kisen
 * @Date: 2021/3/7 14:47
 */
@Controller
@RequestMapping("/goods")
@Slf4j
public class GoodsController {

    @Autowired
    private IUserService userService;

    @RequestMapping("/toList")
    public String toList(Model model, User user) {
        if (StringUtils.isEmpty(ticket)) {
            return "login";
        }
        User user = (User) session.getAttribute(ticket);
        if (null == user) {
            return "login";
        }
        model.addAttribute("user", user);
        return "goodsList";
    }

}

login.html

        $.ajax({
            url: "/login/doLogin",
            type: "POST",
            data: {
                mobile: $("#mobile").val(),
                password: password
            },
            success: function (data) {
                layer.closeAll();
                if (data.code == 200) {
                    layer.msg("成功");
                    window.location.href="/goods/toList";
                } else {
                    layer.msg(data.message);
                }
            },
            error: function () {
                layer.closeAll();
            }
        });

goodsList.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>商品列表</title>
</head>
<body>
<p th:text="'Hello:'+${user.nickname}"></p>
</body>
</html>

測試

7.分布式Session問題

之前的代碼在我們之后一台應用系統,所有操作都在一台Tomcat上,沒有什么問題。當我們部署多台系統,配合Nginx的時候會出現用戶登錄的問題。

原因
由於Nginx使用默認負載均衡策略(輪詢),請求將會按照時間順序逐一分發到后端應用上。
也就是說剛開始我們在Tomcat1登錄之后,用戶信息放在Tomcat1的Session里。過來一會,請求又被Nginx分發到了Tomcat2上,這時Tomcat2上Session里還沒有用戶信息,於是又要登錄。

解決方案:
    Session復制
        優點:無需修改代碼,只需要修改Tomcat配置
        缺點:
            Session同步傳輸占用內網帶寬
            多台Tomcat同步性能指數級下降
            Session占用內存,無法有效水平擴展
    前端存儲
        優點:不占用服務端內存
        缺點:
            存在安全風險
            數據大小受cookie限制
            占用外網帶寬
    Session粘滯
        優點:
            無需修改代碼
            服務端可以水平擴展
        缺點:
            增加新機器,會重新Hash,導致重新登錄
            應用重啟,需要重新登錄
    后端集中存儲
        優點:
            安全
            容易水平擴展
        缺點:
            增加復雜度
            需要修改代碼

8.Redis安裝

下載地址
http://redis.io/

將下載好的安裝包上傳至服務器

解壓
tar zxvf redis-5.0.5.tar.gz
安裝依賴
yum -y install gcc-c++ autoconf automake
預編譯

# 切換至解壓目錄
cd redis-5.0.5/
# 預編譯
make

安裝

# 創建安裝目錄
mkdir -p /usr/local/redis
# 安裝
make PREFIX=/usr/local/redis/ install

修改配置文件

# 復制redis.conf至安裝路徑下
cp redis.conf /usr/local/redis/
# 修改配置文件
vim /usr/local/redis/redis.conf

修改內容如下

#bind 127.0.0.1
#關閉保護模式
protected-mode no
#后台啟動
daemonize yes
#添加訪問認證
requirepass root

啟動redis
./redis-server redis.conf

9.Redis實現分布式Session

9.1. 方法一:使用SpringSession實現

添加依賴
pom.xml

        <!--spring data redis 依賴-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--commons-pool2 對象池依賴-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!--spring-session 依賴-->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

添加配置
application.yml

  # redis配置
  redis:
    # 服務器地址
    host: 121.37.15.219
    # 端口
    port: 6379
    # 數據庫
    database: 0
    # 超時時間
    timout: 10000ms
    lettuce:
      pool:
        # 最大連接數,默認8
        max-active: 8
        # 最大連接阻塞等待時間,默認-1
        max-wait: 10000ms
        # 最大空閑連接,默認8
        max-idle: 200
        # 最小空閑連接,默認0
        min-idle: 5
    # 密碼
    password: kisen

測試
其余代碼暫時不動,重新登錄測試。會發現session已經存儲在Redis上

9.2. 方法二:將用戶信息存入Redis

依賴

        <!--spring data redis 依賴-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--commons-pool2 對象池依賴-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

添加配置

spring:
  # redis配置
  redis:
    # 服務器地址
    host: 121.37.15.219
    # 端口
    port: 6379
    # 數據庫
    database: 0
    # 超時時間
    timout: 10000ms
    lettuce:
      pool:
        # 最大連接數,默認8
        max-active: 8
        # 最大連接阻塞等待時間,默認-1
        max-wait: 10000ms
        # 最大空閑連接,默認8
        max-idle: 200
        # 最小空閑連接,默認0
        min-idle: 5
    # 密碼
    password: kisen

RedisConfig.java

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;

/**
 * @ClassName: RedisConfig
 * @Title: seckill
 * @Package: com.xxxx.seckill.config
 * @Description: Redis配置類
 * @Author: Kisen
 * @Date: 2021/3/9 0:29
 */
@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;
    }
}

修改之前代碼
IUserService.java

    /**
     * 根據cookie獲取用戶
     * @param userTicket
     * @return
     */
    User getUserByCookie(String userTicket, HttpServletRequest request, HttpServletResponse response);

UserServiceImpl.java

package com.xxxx.seckill.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xxxx.seckill.exception.GlobalException;
import com.xxxx.seckill.mapper.UserMapper;
import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IUserService;
import com.xxxx.seckill.utils.CookieUtil;
import com.xxxx.seckill.utils.MD5Util;
import com.xxxx.seckill.utils.UUIDUtil;
import com.xxxx.seckill.vo.LoginVo;
import com.xxxx.seckill.vo.RespBean;
import com.xxxx.seckill.vo.RespBeanEnum;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <p>
 *  服務實現類
 * </p>
 *
 * @author kisen
 * @since 2021-03-04
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 登錄
     * @param loginVo
     * @param request
     * @param response
     * @return
     */
    @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.LOGIN_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.formPassToDBPass(password, user.getSalt()).equals(user.getPassword())) {
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }
        // 生成cookie
        String ticket = UUIDUtil.uuid();
        // 將用戶信息存入redis中
        redisTemplate.opsForValue().set("user:" + ticket, user);
//        request.getSession().setAttribute(ticket, user);
        CookieUtil.setCookie(request, response, "userTicket", ticket);
        return RespBean.success();
    }

    /**
     * 根據cookie獲取用戶
     * @param userTicket
     * @return
     */
    @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;
    }
}

GoodsController.java

package com.xxxx.seckill.controller;

import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * @ClassName: GoodsController
 * @Title: seckill
 * @Package: com.xxxx.seckill.controller
 * @Description: 商品
 * @Author: Kisen
 * @Date: 2021/3/7 14:47
 */
@Controller
@RequestMapping("/goods")
@Slf4j
public class GoodsController {

    @Autowired
    private IUserService userService;

    @RequestMapping("/toList")
    public String toList(Model model, HttpServletRequest request, HttpServletResponse response, @CookieValue("userTicket") String ticket) {
        if (StringUtils.isEmpty(ticket)) {
            return "login";
        }
//        User user = (User) session.getAttribute(ticket);
        User user = userService.getUserByCookie(ticket, request, response);
        if (null == user) {
            return "login";
        }
        model.addAttribute("user", user);
        return "goodsList";
    }

}

測試

Web全局校驗登錄功能

UserArgumentResolver.java

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.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
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;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @ClassName: UserArgumentResolver
 * @Title: seckill
 * @Package: com.xxxx.seckill.config
 * @Description: 自定義用戶參數
 * @Author: Kisen
 * @Date: 2021/3/9 22:39
 */
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

    @Autowired
    private IUserService userService;

    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        Class<?> clazz = methodParameter.getParameterType();
        return clazz == User.class;
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
        HttpServletRequest request = nativeWebRequest.getNativeRequest(HttpServletRequest.class);
        HttpServletResponse response = nativeWebRequest.getNativeResponse(HttpServletResponse.class);
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        if (StringUtils.isEmpty(ticket)) {
            return null;
        }
        return userService.getUserByCookie(ticket, request, response);
    }
}

WebConfig.java

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.WebMvcConfigurer;

import java.util.List;

/**
 * @ClassName: WebConfig
 * @Title: seckill
 * @Package: com.xxxx.seckill.config
 * @Description: MVC配置類
 * @Author: Kisen
 * @Date: 2021/3/9 22:37
 */
@Configuration
//@EnableWebMvc //添加該注解,則是完全控制MVC
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private UserArgumentResolver userArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(userArgumentResolver);
    }
}

GoodsController.java

package com.xxxx.seckill.controller;

import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * @ClassName: GoodsController
 * @Title: seckill
 * @Package: com.xxxx.seckill.controller
 * @Description: 商品
 * @Author: Kisen
 * @Date: 2021/3/7 14:47
 */
@Controller
@RequestMapping("/goods")
@Slf4j
public class GoodsController {

    @Autowired
    private IUserService userService;

    @RequestMapping("/toList")
    public String toList(Model model, User user) {
//        if (StringUtils.isEmpty(ticket)) {
//            return "login";
//        }
////        User user = (User) session.getAttribute(ticket);
//        User user = userService.getUserByCookie(ticket, request, response);
//        if (null == user) {
//            return "login";
//        }
        model.addAttribute("user", user);
        return "goodsList";
    }

}

10.秒殺功能

10.1. 商品列表頁

用逆向工程生成所需的所有類

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;

/**
 * @ClassName: GoodsVo
 * @Title: seckill
 * @Package: com.xxxx.seckill.vo
 * @Description: 商品返回對象
 * @Author: Kisen
 * @Date: 2021/3/14 16:09
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoodsVo extends Goods {
    private BigDecimal seckillPrice;
    private Integer stockCount;
    private Date startDate;
    private Date endDate;
}

GoodsMapper.java

package com.xxxx.seckill.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xxxx.seckill.pojo.Goods;
import com.xxxx.seckill.vo.GoodsVo;

import java.util.List;

/**
 * <p>
 *  Mapper 接口
 * </p>
 *
 * @author kisen
 * @since 2021-03-14
 */
public interface GoodsMapper extends BaseMapper<Goods> {

    /**
     * 獲取商品列表
     * @return
     */
    List<GoodsVo> findGoodsVo();
}

GoodsMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xxxx.seckill.mapper.GoodsMapper">

    <!-- 通用查詢映射結果 -->
    <resultMap id="BaseResultMap" type="com.xxxx.seckill.pojo.Goods">
        <id column="id" property="id" />
        <result column="goods_name" property="goodsName" />
        <result column="goods_title" property="goodsTitle" />
        <result column="goods_img" property="goodsImg" />
        <result column="goods_detail" property="goodsDetail" />
        <result column="goods_price" property="goodsPrice" />
        <result column="goods_stock" property="goodsStock" />
    </resultMap>

    <!-- 通用查詢結果列 -->
    <sql id="Base_Column_List">
        id, goods_name, goods_title, goods_img, goods_detail, goods_price, goods_stock
    </sql>

    <!-- 獲取商品列表 -->
    <select id="findGoodsVo" resultType="com.xxxx.seckill.vo.GoodsVo">
        SELECT
            g.id,
            g.goods_name,
            g.goods_title,
            g.goods_img,
            g.goods_detail,
            g.goods_price,
            g.goods_stock,
            sg.seckill_price,
            sg.stock_count,
            sg.start_date,
            sg.end_date
        FROM t_goods g
        LEFT JOIN t_seckill_goods sg ON g.id=sg.goods_id
    </select>
</mapper>

GoodsService
IGoodService.java

package com.xxxx.seckill.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.xxxx.seckill.pojo.Goods;
import com.xxxx.seckill.vo.GoodsVo;

import java.util.List;

/**
 * <p>
 *  服務類
 * </p>
 *
 * @author kisen
 * @since 2021-03-14
 */
public interface IGoodsService extends IService<Goods> {

    /**
     * 獲取商品列表
     * @return
     */
    List<GoodsVo> findGoodsVo();
}

GoodsServiceImpl.java

package com.xxxx.seckill.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xxxx.seckill.mapper.GoodsMapper;
import com.xxxx.seckill.pojo.Goods;
import com.xxxx.seckill.service.IGoodsService;
import com.xxxx.seckill.vo.GoodsVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * <p>
 *  服務實現類
 * </p>
 *
 * @author kisen
 * @since 2021-03-14
 */
@Service
public class GoodsServiceImpl extends ServiceImpl<GoodsMapper, Goods> implements IGoodsService {

    @Autowired
    private GoodsMapper goodsMapper;

    /**
     * 獲取商品列表
     * @return
     */
    @Override
    public List<GoodsVo> findGoodsVo() {
        return goodsMapper.findGoodsVo();
    }
}

GoodsController

package com.xxxx.seckill.controller;

import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IGoodsService;
import com.xxxx.seckill.service.IUserService;
import com.xxxx.seckill.vo.GoodsVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.Date;

/**
 * @ClassName: GoodsController
 * @Title: seckill
 * @Package: com.xxxx.seckill.controller
 * @Description: 商品
 * @Author: Kisen
 * @Date: 2021/3/7 14:47
 */
@Controller
@RequestMapping("/goods")
@Slf4j
public class GoodsController {

    @Autowired
    private IUserService userService;
    @Autowired
    private IGoodsService goodsService;

    /**
     * 跳轉商品列表頁
     * @param model
     * @param user
     * @return
     */
    @RequestMapping("/toList")
    public String toList(Model model, User user) {
        model.addAttribute("user", user);
        model.addAttribute("goodsList", goodsService.findGoodsVo());
        return "goodsList";
    }

}

MvcConfig

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.WebMvcConfigurer;

import java.util.List;

/**
 * @ClassName: WebConfig
 * @Title: seckill
 * @Package: com.xxxx.seckill.config
 * @Description: MVC配置類
 * @Author: Kisen
 * @Date: 2021/3/9 22:37
 */
@Configuration
//@EnableWebMvc //添加該注解,則是完全控制MVC
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private UserArgumentResolver userArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(userArgumentResolver);
    }
//
//    @Override
//    public void addResourceHandlers(ResourceHandlerRegistry registry) {
//        registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
//    }
}

goodsList.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>商品列表</title>
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/>
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒殺商品列表</div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名稱</td>
            <td>商品圖片</td>
            <td>商品原價</td>
            <td>秒殺價</td>
            <td>庫存數量</td>
            <td>詳情</td>
        </tr>
        <tr th:each="goods,goodsStat : ${goodsList}">
            <td th:text="${goods.goodsName}"></td>
            <td><img th:src="@{${goods.goodsImg}}" width="100" height="100"/></td>
            <td th:text="${goods.goodsPrice}"></td>
            <td th:text="${goods.seckillPrice}"></td>
            <td th:text="${goods.stockCount}"></td>
            <td><a th:href="'/goods/toDetail/'+${goods.id}">詳情</a></td>
        </tr>
    </table>
</div>
</body>
</html>

測試

10.2. 商品詳情頁

GoodsMapper

GoodsMapper.java

    /**
     * 獲取商品詳情
     * @param goodsId
     * @return
     */
    GoodsVo findGoodsVoByGoodsId(Long goodsId);

GoodsMapper.xml

    <!-- 獲取商品詳情 -->
    <select id="findGoodsVoByGoodsId" resultType="com.xxxx.seckill.vo.GoodsVo">
        select
            g.id,
            g.goods_name,
            g.goods_title,
            g.goods_img,
            g.goods_detail,
            g.goods_price,
            g.goods_stock,
            sg.seckill_price,
            sg.stock_count,
            sg.start_date,
            sg.end_date
        from t_goods g
        left join t_seckill_goods sg on g.id=sg.goods_id
        where g.id = #{goodsId}
    </select>

GoodsService

IGoodsService.java

    /**
     * 獲取商品詳情
     * @param goodsId
     * @return
     */
    GoodsVo findGoodsVoByGoodsId(Long goodsId);

GoodsServiceImpl.java

    /**
     * 獲取商品詳情
     * @param goodsId
     * @return
     */
    @Override
    public GoodsVo findGoodsVoByGoodsId(Long goodsId) {
        return goodsMapper.findGoodsVoByGoodsId(goodsId);
    }

GoodsController

    /**
     * 跳轉商品詳情頁
     * @param goodsId
     * @param model
     * @param user
     * @return
     */
    @RequestMapping("/toDetail/{goodsId}")
    public String toDetail(@PathVariable Long goodsId, Model model, User user) {
        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);
        return "goodsDetail";
    }

goodsDetail.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>商品詳情</title>
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/>
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒殺商品詳情</div>
    <div class="panel-body">
        <span th:if="${user eq null}"> 您還沒有登錄,請登陸后再操作<br/></span>
        <span>沒有收貨地址的提示。。。</span>
    </div>
    <table class="table" id="goods">
        <tr>
            <td>商品名稱</td>
            <td colspan="3" th:text="${goods.goodsName}"></td>
        </tr>
        <tr>
            <td>商品圖片</td>
            <td colspan="3"><img th:src="@{${goods.goodsImg}}" width="200" height="200"/></td>
        </tr>
        <tr>
            <td>秒殺開始時間</td>
            <td th:text="${#dates.format(goods.startDate, 'yyyy-MM-dd HH:mm:ss')}"></td>
            <td id="seckillTip">
                <input type="hidden" id="remainSeconds" th:value="${remainSeconds}">
                <span th:if="${secKillStatus eq 0}">秒殺倒計時:<span id="countDown" th:text="${remainSeconds}"></span>秒</span>
                <span th:if="${secKillStatus eq 1}">秒殺進行中</span>
                <span th:if="${secKillStatus eq 2}">秒殺已結束</span>
            </td>
            <td>
                <form id="secKillForm" action="/seckill/doSeckill">
                    <input type="hidden" name="goodsId" th:value="${goods.id}">
                    <button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒殺</button>
                </form>
            </td>
        </tr>
        <tr>
            <td>商品原價</td>
            <td colspan="3" th:text="${goods.goodsPrice}"></td>
        </tr>
        <tr>
            <td>秒殺價</td>
            <td colspan="3" th:text="${goods.seckillPrice}"></td>
        </tr>
        <tr>
            <td>庫存數量</td>
            <td colspan="3" th:text="${goods.stockCount}"></td>
        </tr>
    </table>
</div>
</body>
<script>
    $(function () {
        countDown();
    });

    function countDown(){
        var remainSeconds = $("#remainSeconds").val();
        var timeout;
        // 秒殺還未開始
        if (remainSeconds > 0) {
            $("#buyButton").attr("disabled", true);
            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("秒殺進行中");
        } else {
            $("#buyButton").attr("disabled", true);
            $("#seckillTip").html("秒殺已經結束");
        }
    };
</script>
</html>

測試

秒殺未開始

秒殺進行中

秒殺已結束

10.3. 秒殺功能實現

OrderService

IOrderService.java

package com.xxxx.seckill.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.xxxx.seckill.pojo.Order;
import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.vo.GoodsVo;

/**
 * <p>
 *  服務類
 * </p>
 *
 * @author kisen
 * @since 2021-03-14
 */
public interface IOrderService extends IService<Order> {

    /**
     * 秒殺
     * @param user
     * @param goods
     * @return
     */
    Order seckill(User user, GoodsVo goods);
}

OrderServiceImpl.java

package com.xxxx.seckill.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xxxx.seckill.mapper.OrderMapper;
import com.xxxx.seckill.pojo.Order;
import com.xxxx.seckill.pojo.SeckillGoods;
import com.xxxx.seckill.pojo.SeckillOrder;
import com.xxxx.seckill.pojo.User;
import com.xxxx.seckill.service.IOrderService;
import com.xxxx.seckill.service.ISeckillGoodsService;
import com.xxxx.seckill.service.ISeckillOrderService;
import com.xxxx.seckill.vo.GoodsVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Date;

/**
 * <p>
 *  服務實現類
 * </p>
 *
 * @author kisen
 * @since 2021-03-14
 */
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService {

    @Autowired
    private ISeckillGoodsService seckillGoodsService;

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private ISeckillOrderService seckillOrderService;

    /**
     * 秒殺
     * @param user
     * @param goods
     * @return
     */
    @Override
    public Order seckill(User user, GoodsVo goods) {
        // 秒殺商品表減庫存
        SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goods.getId()));
        seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
        seckillGoodsService.updateById(seckillGoods);
        // 生成訂單
        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);
        return order;
    }
}

SeckillController

package com.xxxx.seckill.controller;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.xxxx.seckill.pojo.Order;
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.service.ISeckillOrderService;
import com.xxxx.seckill.vo.GoodsVo;
import com.xxxx.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * @ClassName: SecKillController
 * @Title: seckill
 * @Package: com.xxxx.seckill.controller
 * @Description: 秒殺
 * @Author: Kisen
 * @Date: 2021/3/20 14:59
 */
@Controller
@RequestMapping("/seckill")
public class SecKillController {

    @Autowired
    private IGoodsService goodsService;

    @Autowired
    private ISeckillOrderService seckillOrderService;

    @Autowired
    private IOrderService orderService;

    /**
     * 秒殺
     * @param model
     * @param user
     * @param goodsId
     * @return
     */
    @RequestMapping("/doSeckill")
    public String doSeckill(Model model, User user, Long goodsId) {
        if (user == null) {
            return "login";
        }
        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";
    }
}

測試

秒殺成功進入訂單詳情注意查看庫存是否正確扣減,訂單是否正確生成

庫存不足

重復搶購

10.4. 訂單詳情頁

本課程重點針對秒殺,所以訂單詳情只做簡單頁面展示,隨后得支付等功能也不在本課程體現

OrderDetail.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>訂單詳情</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}" />
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒殺訂單詳情</div>
    <table class="table" id="order">
        <tr>
            <td>商品名稱</td>
            <td th:text="${goods.goodsName}" colspan="3"></td>
        </tr>
        <tr>
            <td>商品圖片</td>
            <td colspan="2"><img th:src="@{${goods.goodsImg}}" width="200" height="200" /></td>
        </tr>
        <tr>
            <td>訂單價格</td>
            <td colspan="2" th:text="${order.goodsPrice}"></td>
        </tr>
        <tr>
            <td>下單時間</td>
            <td th:text="${#dates.format(order.createDate, 'yyyy-MM-dd HH:mm:ss')}" colspan="2"></td>
        </tr>
        <tr>
            <td>訂單狀態</td>
            <td >
                <span th:if="${order.status eq 0}">未支付</span>
                <span th:if="${order.status eq 1}">待發貨</span>
                <span th:if="${order.status eq 2}">已發貨</span>
                <span th:if="${order.status eq 3}">已收貨</span>
                <span th:if="${order.status eq 4}">已退款</span>
                <span th:if="${order.status eq 5}">已完成</span>
            </td>
            <td>
                <button class="btn btn-primary btn-block" type="submit" id="payButton">立即支付</button>
            </td>
        </tr>
        <tr>
            <td>收貨人</td>
            <td colspan="2">XXX  18012345678</td>
        </tr>
        <tr>
            <td>收貨地址</td>
            <td colspan="2">上海市浦東區世紀大道</td>
        </tr>
    </table>
</div>

</body>
</html>

測試
至此,簡單的秒殺功能邏輯就完成了,下面進入優化階段

11.系統壓測

11.1. JMeter入門

11.1.1.安裝

官網:https://jmeter.apache.org/
下載地址:https:/jmeter.apache.org/download_jmeter.cgi

下載解壓后直接在bin目錄里雙擊jmeter.bat即可啟動(Linux系統通過jmeter.sh啟動

修改中文
Choose Language->Chinese(Simplified)

11.1.2.簡單使用

我們先使用JMeter測試一下商品列表頁的接口。
首先創建線程組,步驟:添加->線程(用戶)->線程組

Ramp-up指在幾秒之內啟動指定線程數

創建HTTP請求默認值,步驟:添加->配置元件->HTTP請求默認值

添加測試接口,步驟:添加->取樣器->HTTP請求

查看輸出結果,步驟:添加->監聽器->聚合報告/圖形結果/用表格擦看結果

啟動即可在監聽器看到對應的結果

Linux壓測命令:

./jmeter.sh -n -t first.jmx -l result.jtl

11.2.安裝MySQL

11.2.1.安裝

wget http://repo.mysql.com/mysql57-community-release-el7-10.noarch.rpm

安裝mysql的repo源:

rpm -ivh mysql57-community-release-el7-10.noarch.rpm

查看是否安裝成功:

yum repolist enabled | grep "mysql.*-community.*"

開始安裝:

yum install mysql-community-server

11.2.2.啟動服務

服務開關操作:

systemctl status mysqld	#檢查mysql運行狀態
systemctl start/restart/stop mysqld   #啟動/重啟/停止mysql
systemctl enable mysqld		#開機啟動mysql服務

下圖表示MySQL正常運行

11.2.3.修改密碼

修改root本地密碼:

#查看初始密碼
grep 'temporary password' /var/log/mysqld.log
#進入mysql,輸入初始秘密
mysql -uroot -p
#修改密碼
set password for 'root'@'localhost'=password('root');
#注意如果你的密碼簡單,必須修改兩個全局參數:
set global validate_password_policy=0;
set global validate_password_length=1;

授權所有其他機器登陸:

GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY 'root' WITH GRANT OPTION;
FLUSH PRIVILEGES;
#創建新的database
create database mmall default character set utf8 collate utf8_general_ci;

配置默認編碼為utf8:修改/etc/my.cnf配置文件,在[mysqld]下添加編碼配置

character_set_server=utf8
#default=character-set=utf8
init_connect='SET NAMES utf8'

11.2.4.訪問數據庫

輸入以下命令,並輸入修改后的密碼進行登錄
mysql -u root -p

遠程連接數據庫

附:RabbitMQ相關

下載erlang
wget https://github.com/rabbitmq/erlang-rpm/releases/download/v23.3.1/erlang-23.3.1-1.el7.x86_64.rpm
安裝erlang
rpm -ivh erlang-23.3.1-1.el7.x86_64.rpm
安裝完成后,運行命令來查看你安裝的erl版本
erl -version
yum -y install socat
下載RabbitMQ
wget https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.8.14/rabbitmq-server-3.8.14-1.el7.noarch.rpm
安裝RabbitMQ
rpm -ivh rabbitmq-server-3.8.14-1.el7.noarch.rpm
啟動rabbitmq
systemctl start rabbitmq-server
查看rabbitmq狀態
systemctl status rabbitmq-server
設置開機啟動rabbitmq
systemctl enable rabbitmq-server

顯示所有插件
rabbitmq-plugins list
安裝RabbitMQ管控插件(用默認的guest用戶)
rabbitmq-plugins enable rabbitmq_management

配置防火牆
查看防火牆開發端口
firewall-cmd --zone=public --list-ports
開發RabbitMQ端口15672以便遠程訪問
firewall-cmd --zone=public --add-port=15672/tcp --permanent
重啟防火牆
firewall-cmd --reload

配置RabbitMQ非本機訪問的配置文件
cd /etc/rabbitmq/
vim rabbitmq.config
重啟服務
systemctl restart rabbitmq-server


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM