Skip to content

iotacecil/miaoshaLearn

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

title date tags category
java秒杀
2018-10-16 14:18:11 -0700
项目流程

1.前后台json格式

实现效果,在Controller调用静态方法: 成功:Result.success(data); 成功时只返回数据 异常:Result.error(CodeMsg); 包括code和msg

class Result<T> {
    private int code;
    private String msg;
    private T data;
    /**
     * 成功时候的调用
     * */
    public static <T> Result<T> success(T data){
        return new  Result<T>(data);
    }

    /**
     * 失败时候的调用
     * */
    public static <T> Result<T> error(CodeMsg cm){
        return new  Result<T>(cm);
    }
    // 构造函数private 不被外部调用,外部只能使用2个静态方法
    // 失败构造
    private Result(CodeMsg cm) {
        if(cm  == null) {
            return;
        }
        this.code = cm.getCode();
        this.msg = cm.getMsg();
    }
    //成功构造
    private Result(T data) {
        this.code = 0;
        this.msg = "success";
        this.data = data;
    }
}

controller中测试:

@Controller
//@RequestMapping("/demo")
public class DemoController {
    @RequestMapping("/hello")
    @ResponseBody
    public Result<String> hello() {
        return Result.success("hello");
       // return new Result(0, "success", "hello");
    }
    @RequestMapping("/helloError")
    @ResponseBody
    public Result<String> helloError() {
        return Result.error(CodeMsg.SERVER_ERROR);
        //return new Result(500102, "XXX");
    }
}

封装错误信息类,用于生成各种各样的错误信息(枚举类?) //后面很难改 不要用枚举 //外部只能调用静态变量

public class CodeMsg {
    private int code;
    private String msg;
    
    //通用异常
    public static CodeMsg SUCCESS = new CodeMsg(0, "success");
    public static CodeMsg SERVER_ERROR = new CodeMsg(500100, "服务端异常");
    //登录模块 5002XX
    
    //商品模块 5003XX
    
    //订单模块 5004XX
    
    //秒杀模块 5005XX
    
    //私有
    private CodeMsg(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
}

{% fold %}

public enum  CodeMsg {
    SUCCESS(0,"success"),
    SERVER_ERROR(500100,"服务端异常");
    private final int code;
    private final String msg;
    private CodeMsg( int code,String msg ) {
        this.code = code;
        this.msg = msg;
    }
    public int getCode() {
        return code;
    }
    public String getMsg() {
        return msg;
    }
}

{% endfold %}

测试: 访问https://localhost:8080/hello {"code":0,"msg":"success","data":"hello,imooc"} 访问:https://localhost:8080/helloError {"code":500100,"msg":"服务端异常","data":null}

2.添加页面模板 配置文件配置项

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#common-application-properties /resources/aplication.properties

spring.thymeleaf.cache=false
spring.thymeleaf.content-type=text/html
spring.thymeleaf.enabled=true
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.mode=HTML5
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

controller返回页面

@RequestMapping("/hel")
public String  thymeleaf(Model model) {
    //写入model的属性可以在页面中取到
    model.addAttribute("name", "名字");
    //找的是prefix+hello+sufix ->/templates/hello.html
    return "hello";
}

/resources/templates/hello.html

<!DOCTYPE HTML>
<html xmlns:th="https://www.thymeleaf.org">
<head>
    <title>hello</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p th:text="'hello:'+${name}" ></p>
</body>
</html>

3. Mybatis

https://www.mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/

mybatis.type-aliases-package=package.model
#下划线转驼峰
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.configuration.default-fetch-size=100
mybatis.configuration.default-statement-timeout=3000
# 配置文件扫描 接口类和xml
mybatis.mapperLocations = classpath:package/dao/*.xml

数据源druid

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>1.3.1</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.0.5</version>
</dependency>
# druid
spring.datasource.url=jdbc:mysql:https://10.1.18.133:3306/maiosha?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&useSSL=false
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.filters=stat
spring.datasource.maxActive=2
spring.datasource.initialSize=1
spring.datasource.maxWait=60000
spring.datasource.minIdle=1
spring.datasource.timeBetweenEvictionRunsMillis=60000
spring.datasource.minEvictableIdleTimeMillis=300000
spring.datasource.validationQuery=select 'x'
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
spring.datasource.poolPreparedStatements=true
spring.datasource.maxOpenPreparedStatements=20

新建数据库并添加user表 ID:int(11) name:varchar(255) 添加数据 1 小明 新建/domain/User对象

public class User {
    private int id;
    private String name;
    //get/set
}

新建/dao/UserDao层interface UserDao

@Mapper
public interface UserDao { 
    @Select("select * from user where id = #{id}")
    public User getById(@Param("id")int id  );
}

写service

@Service
public class UserService {  
    @Autowired
    UserDao userDao;

    public User getById(int id) {
         return userDao.getById(id);
}

添加到controller

@Controller
@RequestMapping("/demo")
public class SampleController {
    @Autowired
    UserService userService;
    @RequestMapping("/db/get")
    @ResponseBody
    public Result<User> dbGet() {
        User user = userService.getById(1);
        return Result.success(user);
    }
}

访问: https://localhost:8080/demo/db/get 返回: {"code":0,"msg":"success","data":{"id":1,"name":"小明"}}

测试事务:数据库中已经有id=1的数据,连插入id=2,id=1的数据,如果能回滚就行 dao:

@Mapper
public interface UserDao {
    
    @Select("select * from user where id = #{id}")
    public User getById(@Param("id") int id);
    //添加Insert方法
    @Insert("insert into user(id, name)values(#{id}, #{name})")
    public int insert(User user);
    
}

service:

//注解注释掉 报错但插入了id=2
@Transactional
public boolean tx() {
    //整体在一块(一个事务)
    User u1= new User();
    u1.setId(2);
    u1.setName("2222");
    userDao.insert(u1);
    // 这条失败上面也不会插入
    User u2= new User();
    u2.setId(1);
    u2.setName("11111");
    userDao.insert(u2);
    return true;
}

controller:

@RequestMapping("/db/tx")
@ResponseBody
public Result<Boolean> dbTx() {
    userService.tx();
    return Result.success(true);
}

测试: 访问:https://localhost:8080/demo/db/tx 数据完整性错误,但是2没有被插入

Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Tue Oct 16 22:27:57 CST 2018
There was an unexpected error (type=Internal Server Error, status=500).
### Error updating database. Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry '1' for key 'PRIMARY' ### The error may involve com.cloud.miaosha.dao.UserDao.insert-Inline ### The error occurred while setting parameters ### SQL: insert into user(id, name)values(?, ?) ### Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry '1' for key 'PRIMARY' ; SQL []; Duplicate entry '1' for key 'PRIMARY'; nested exception is com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry '1' for key 'PRIMARY'

4.集成Redis

https://github.com/xetorthio/jedis

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.38</version>
</dependency>

配置:

redis.host=10.1.18.133
redis.port=6379
redis.timeout=3
redis.password=123456
redis.poolMaxTotal=10
redis.poolMaxIdle=10
spring.redis.pool.max-wait=3

新建redis包 配置类RedisConfig

@Component
//获取配置文件 配置里的前缀 
@ConfigurationProperties(prefix="redis")
public class RedisConfig {
    private String host;
    private int port;
    private int timeout;//秒
    private String password;
    private int poolMaxTotal;
    private int poolMaxIdle;
    private int poolMaxWait;//秒
}

Service

通过service提供Redis的get/set

@Service
public class RedisService{
    @Autowired
    JedisPool jedisPool;
    public<T> T get(String key,Class<T> clazz){
        Jedis jedis = jedisPool.getResource();
    }
    @Autowired
    RedisConfig redisConfig;
    @Bean
    public JedisPool JedisFactory(){
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxIdle(redisConfig.getPoolMaxIdle());
        poolConfig.setMaxTotal(redisConfig.getPoolMaxTotal());
        poolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait() * 1000);
        //redis默认16个库,从0库开始
        JedisPool jp = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),redisConfig.getTimeout()*1000, redisConfig.getPassword(), 0);
        return jp;
    }
}

查看源码找JedisPool中的timeout是什么单位 redis 默认6个库从0开始

JedisPool jp = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),redisConfig.getTimeout()*1000, redisConfig.getPassword(), 0);
//JedisPool.java
public JedisPool(final GenericObjectPoolConfig poolConfig, final String host, int port,int timeout, final String password, final int database) {
    this(poolConfig, host, port, timeout, password, database, null);
}
//this
public JedisPool(final GenericObjectPoolConfig poolConfig, final String host, int port,final int connectionTimeout, final int soTimeout, final String password, final int database,
  final String clientName, final boolean ssl, final SSLSocketFactory sslSocketFactory,
  final SSLParameters sslParameters, final HostnameVerifier hostnameVerifier) {
super(poolConfig, new JedisFactory(host, port, connectionTimeout, soTimeout, password,
    database, clientName, ssl, sslSocketFactory, sslParameters, hostnameVerifier));
}

JedisFactory

public JedisFactory(final String host, final int port, final int connectionTimeout,
  final int soTimeout, final String password, final int database, final String clientName,
  final boolean ssl, final SSLSocketFactory sslSocketFactory, final SSLParameters sslParameters,
  final HostnameVerifier hostnameVerifier) {
this.hostAndPort.set(new HostAndPort(host, port));
//找用到connect time的地方
this.connectionTimeout = connectionTimeout;
this.soTimeout = soTimeout;
this.password = password;
this.database = database;
this.clientName = clientName;
this.ssl = ssl;
this.sslSocketFactory = sslSocketFactory;
this.sslParameters = sslParameters;
this.hostnameVerifier = hostnameVerifier;
}
//connectionTimeout用的地方PooledObject类
final Jedis jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort(), connectionTimeout,
        soTimeout, ssl, sslSocketFactory, sslParameters, hostnameVerifier);
//Jedis.java
public Jedis(final String host, final int port, final int connectionTimeout, final int soTimeout,
  final boolean ssl, final SSLSocketFactory sslSocketFactory, final SSLParameters sslParameters,
  final HostnameVerifier hostnameVerifier) {
super(host, port, connectionTimeout, soTimeout, ssl, sslSocketFactory, sslParameters,
    hostnameVerifier);
}
//super BinaryJedis.java
public BinaryJedis(final String host, final int port, final int connectionTimeout,
  final int soTimeout, final boolean ssl, final SSLSocketFactory sslSocketFactory,
  final SSLParameters sslParameters, final HostnameVerifier hostnameVerifier) {
client = new Client(host, port, ssl, sslSocketFactory, sslParameters, hostnameVerifier);
//timeout的地方是Client
client.setConnectionTimeout(connectionTimeout);
client.setSoTimeout(soTimeout);
}
//Connection.java
socket.connect(new InetSocketAddress(host, port), connectionTimeout);
socket.setSoTimeout(soTimeout);
//Socket.java
//!!!毫秒
@param timeout the specified timeout, in milliseconds.
public synchronized void setSoTimeout(int timeout) throws SocketException {
    if (isClosed())
        throw new SocketException("Socket is closed");
    if (timeout < 0)
      throw new IllegalArgumentException("timeout can't be negative");

    getImpl().setOption(SocketOptions.SO_TIMEOUT, new Integer(timeout));
}

所以回到最开始redis.timeout=3是秒

JedisPool jp = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),redisConfig.getTimeout()*1000, redisConfig.getPassword(), 0);

//Client.java {% fold %}

//Client.java
public Client(final String host, final int port, final boolean ssl,
  final SSLSocketFactory sslSocketFactory, final SSLParameters sslParameters,
  final HostnameVerifier hostnameVerifier) {
super(host, port, ssl, sslSocketFactory, sslParameters, hostnameVerifier);
}
//super->BinaryClient.java
public BinaryClient(final String host, final int port, final boolean ssl,
  final SSLSocketFactory sslSocketFactory, final SSLParameters sslParameters,
  final HostnameVerifier hostnameVerifier) {
super(host, port, ssl, sslSocketFactory, sslParameters, hostnameVerifier);
}
//super->Connection.java
public Connection(final String host, final int port, final boolean ssl,
  SSLSocketFactory sslSocketFactory, SSLParameters sslParameters,
  HostnameVerifier hostnameVerifier) {
this.host = host;
this.port = port;
this.ssl = ssl;
this.sslSocketFactory = sslSocketFactory;
this.sslParameters = sslParameters;
this.hostnameVerifier = hostnameVerifier;
}

{% endfold %}

修改上面Service 加上释放连接池的代码 查看Jedis的close方法源码

public void close() {
if (dataSource != null) {
  if (client.isBroken()) {
    //不关掉 返回到连接池
    this.dataSource.returnBrokenResource(this);
  } else {
    this.dataSource.returnResource(this);
  }
} else {
  client.close();
}
}
set方法BeanToStrnig

用fastjson将bean对象变成string

public <T> boolean set(String key,T value){
    Jedis jedis = null;
    try{
        jedis = jedisPool.getResource();
        String str = beanToString(value);
        if(str == null||str.length()<=0)return false;
        jedis.set(key,str);
        return true;
        }finally{
            returnToPool(jedis);
        }
}
//任意类型转化成字符串
import com.alibaba.fastjson.JSON;
private <T> String beanToString(T value){
    //2. 添加空判断
    if(value == null)return null;
    //3. 如果是数字,字符串,Long
    Class<?> clazz = value.getClass();
    if(clazz == int.class || clazz == Integer.class) {
         return ""+value;
    }else if(clazz == String.class) {
         return (String)value;
    }else if(clazz == long.class || clazz == Long.class) {
        return ""+value;
    }else {
        return JSON.toJSONString(value);
    }
}

get方法 StringToBean

@Service
public class RedisService{
    @Autowired
    JedisPool jedisPool;
    @SuppressWarnings("unchecked")//屏蔽警告
    private <T> T stringToBean(String str,Class<T> clazz){
        //1. 参数校验
        if(str == null || str.length() <= 0 || clazz == null) {
             return null;
        }
        //2 如果是int,string,Long
        if(clazz == int.class || clazz == Integer.class) {
             return (T)Integer.valueOf(str);
        }else if(clazz == String.class) {
             return (T)str;
        }else if(clazz == long.class || clazz == Long.class) {
            return  (T)Long.valueOf(str);
        }else {
            //fastJson 只支持了bean类型 其他List类型要再写
            return JSON.toJavaObject(JSON.parseObject(str), clazz);
        }

    }
    public<T> T get(String key,Class<T> clazz){
        Jedis jedis = null;
        try{
        jdeis = jedisPool.getResource();
        //2.get的逻辑:get是String类型,需要的是T类型
        String value =  jedis.get(key);
        T t = stringToBean(value,clazz);
        return t;
        //1. 添加关闭代码
        }finally{
            returnToPool(jedis);
        }
    }
    private void returnToPool(Jedis jedis){
        if(jedis != null) {
             jedis.close();
         }
    }
    @Autowired
    RedisConfig redisConfig;
    @Bean
    public JedisPool JedisFactory(){
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxIdle(redisConfig.getPoolMaxIdle());
        poolConfig.setMaxTotal(redisConfig.getPoolMaxTotal());
        poolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait() * 1000);
        //redis默认16个库,从0库开始
        JedisPool jp = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),redisConfig.getTimeout()*1000, redisConfig.getPassword(), 0);
        return jp;
    }
}

controller get测试:

127.0.0.1:6379> auth 123456 OK 127.0.0.1:6379> set key1 1 OK

@Autowired
RedisService redisService;
@RequestMapping("/redis/get")
@ResponseBody
public Result<String> redisGet() {

    String  name  = redisService.get("key1", String.class);
    return Result.success(name);
}

报错:jedispoll循环引用 空指针

[redis.clients.jedis.JedisPool]: Circular reference involving containing bean 'redisService' - consider declaring the factory method as static for independence from its containing instance. Factory method 'JedisFactory' threw exception; nested exception is java.lang.NullPointerException

因为Service里注入了pool

@Autowired
JedisPool jedisPool;

但是 JedisPool是实例方法 创建这个Bean需要RedisSevice

@Bean
public JedisPool JedisFactory()

所以独立出JedisPool

@Service
public class RedisPoolFactory {
    @Autowired
    RedisConfig redisConfig;
    @Bean
    public JedisPool JedisFactory(){
    JedisPoolConfig poolConfig = new JedisPoolConfig();
    poolConfig.setMaxIdle(redisConfig.getPoolMaxIdle());
    poolConfig.setMaxTotal(redisConfig.getPoolMaxTotal());
    poolConfig.setMaxWaitMillis(redisConfig.getPoolMaxWait() * 1000);
    //redis默认16个库,从0库开始
    JedisPool jp = new JedisPool(poolConfig, redisConfig.getHost(), redisConfig.getPort(),redisConfig.getTimeout()*1000, redisConfig.getPassword(), 0);
    return jp;
}

controller set测试:

@RequestMapping("/redis/set")
@ResponseBody
public Result<Boolean> redisSet() {
    User user  = new User();
    user.setId(1);
    user.setName("1111");
    redisService.set("key3",user);//UserKey:id1
    return Result.success(true);
}

127.0.0.1:6379> get key3 "{"id":1,"name":"1111"}"

模板模式[接口<-抽象类<-实现类]:封装缓存key,加上前缀

优化:将key加上Prefix,按业务模块区分缓存的key KeyPrefix 接口 BasePrefix 抽象类 UserKey OrderKey等模块实现类 效果:在不同的controllor模块调用service时传入模块ID controller使用:classname+prefix+key redis效果:7) "UserKey:id1" UserKey.getById

 @RequestMapping("/redis/get")
    @ResponseBody
    public Result<User> redisGet() {
        User  user  = redisService.get(UserKey.getById, "1", User.class);
        return Result.success(user);
    }
    
    @RequestMapping("/redis/set")
    @ResponseBody
    public Result<Boolean> redisSet() {
        User user  = new User();
        user.setId(1);
        user.setName("1111");
        //UserKey:id1
        redisService.set(UserKey.getById,"1",user);
        return Result.success(true);
    }

接口:

public interface KeyPrefix(){
    //有效期
    public int expireSeconds();
    //前缀
    public String getPrefix(); 
}

实现的抽象类 防止被创建

public abstract class BasePrefix implements KeyPrefix{
    private int expireSeconds;
    private String prefix;
    //0表示永不过期
    public BasePrefix(String prefix) {//0代表永不过期
        this(0, prefix);
    }
    public int expireSeconds(){
        return expireSeconds;
    }
    //用类名当前缀
    public String getPrefix(){
        String className = getClass().getSimpleName();
        return className+":"+perfix;
    }
}

实现类:用户key

public class UserKey extends BasePrefix{
    //私有 防实例化
    private UserKey(String prefix){super(prefix);}
    public static UserKey getById = new UserKey("id");
    public static UserKey getByName = new UserKey("name");
}

实现类:订单key

public class OrderKey extends BasePrefix

修改Service中的get和set

/**
 * 获取单个对象
 */
public<T> T get(Prefix prefix,String key,Class<T> clazz){
    Jedis jedis = null;
    try{
        jedis = jedisPool.getResource();
        //真正写到数据库的key
        String prefixKey = prefix.getPrefix()+key;
        String value =  jedis.get(prefixKey);
        T t = stringToBean(value,clazz);
        return t;
    }finally{
        returnToPool(jedis);
    }
}

添加失效时间 127.0.0.1:6379> expire key1 3 (integer) 1

public <T> boolean set(KeyPrefix prefix,String key,T value){
    Jedis jedis = null;
    try{
        jedis = jedisPool.getResource();
        String str = beanToString(value);
        if(str == null||str.length()<=0)return false;
        String prefixKey = prefix.getPrefix()+key;
        int expire = prefix.expireSeconds();
        //永不过期
        if(expire<=0){
            jedis.set(prefixKey,str);

        }else{
            jedis.setex(prefixKey,expire,str);
        }
        return true;
    }finally{
        returnToPool(jedis);
    }
}

setex: 等于set+expire命令

public String setex(final String key, final int seconds, final String value) {
    checkIsInMultiOrPipeline();
    client.setex(key, seconds, value);
    return client.getStatusCodeReply();
  }

添加其他API:

127.0.0.1:6379> exists key1 (integer) 1

public <T> boolean exists(KeyPrefix prefix, String key) {
 Jedis jedis = null;
 try {
     jedis =  jedisPool.getResource();
     String prefixKey = prefix.getPrefix()+key;
    return  jedis.exists(prefixKey);
 }finally {
      returnToPool(jedis);
 }

127.0.0.1:6379> incr key1 (integer) 2 127.0.0.1:6379> incr key1 (integer) 3 127.0.0.1:6379> set key222 fdafda OK 127.0.0.1:6379> incr key222 (error) ERR value is not an integer or out of range

incr

public <T> Long incr(KeyPrefix prefix, String key) {
     Jedis jedis = null;
     try {
         jedis =  jedisPool.getResource();
        //生成真正的key
         String realKey  = prefix.getPrefix() + key;
        return  jedis.incr(realKey);
     }finally {
          returnToPool(jedis);
     }
}

127.0.0.1:6379> incr key1 (integer) 5 127.0.0.1:6379> decr key1 (integer) 4

decr

public <T> Long decr(KeyPrefix prefix, String key) {
     Jedis jedis = null;
     try {
         jedis =  jedisPool.getResource();
        //生成真正的key
         String realKey  = prefix.getPrefix() + key;
        return  jedis.decr(realKey);
     }finally {
          returnToPool(jedis);
     }
}

5.实现登陆 数据库设计 2次MD5 JSR303参数校验 全局异常 分布式session

数据库设计

create table `miaosha_user`(
  `id` bigint(20) not null comment '用户ID,手机号',
  `nickname` varchar(256) not null,
  `password` varchar(32) default null comment 'MD5(md5+salt)+salt',
  `salt` varchar(10) default null,
  `head` varchar(128) default null comment '头像',
  `register_date` datetime default null comment '注册时间',
  `last_login_date` datetime default null comment '上次登录时间',
  `login_count` int(11) default '0' comment '登陆次数',
  primary key (`id`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

用户端先MD5(明文+固定salt) 服务端存再一次md5(明文+随机salt)

<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.6</version>
</dependency>

新建util包使用apacheMD5加密 前端第一次salt是可以看到的 隐藏只能activeX控件之类的

import org.apache.commons.codec.digest.DigestUtils;
public static String md5(String src){
    return DigestUtils.md5Hex(src);
}
//添加一个salt
//前端form表单提交上来的密码
//一次加密"123456"-> 26718c17fe0b7862a27dd7dc1b532f29
public static String inputPassFormPass(String inputPass){
    String passsalt = salt.charAt(0)+salt.charAt(2)+inputPass+salt.charAt(5);
    return md5(passsalt);
}
//第二次加密,放入数据库
public static String formPassToDBPass(String formPass, String salt) {
    String toDB = ""+salt.charAt(0)+salt.charAt(2) + formPass +salt.charAt(5) + salt.charAt(4);
    return md5(toDB);
}
//两次合并成1次
public static String inputPassToDbPass(String inputPass, String saltDB) {
    String formPass = inputPassToFormPass(inputPass);
    String dbPass = formPassToDBPass(formPass, saltDB);
    return dbPass;
}
public static void main(String[] args) {
    //c996054adec06904c675b89aa68de2ec
    System.out.println(inputPassToFormPass("123456"));
    //bef054e9b1abb70963943f32b41a3f6d
    System.out.println(formPassToDBPass(inputPassToFormPass("123456"), "secondsalt"));

在controller添加path

@Controller
@RequestMapping("/login")
public class LoginController {

    private static Logger log = LoggerFactory.getLogger(LoginController.class);
@RequestMapping("/login")
    public String toLogin() {
        return "login";
    }
}

登陆页面 用bootstrap的css,jq的表单验证,layer的弹窗,md5加密

登陆html页面引入:

<html xmlns:th="https://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>
    <!-- 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>

bootstrap+jquery验证: {% fold %}

<!-- 50%宽度 居中 margin: 0 auto -->
<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>

{% endfold %} jquery validate: https://www.runoob.com/jquery/jquery-plugin-validate.html

function login(){
    // 在键盘按下并释放及提交后验证提交表单
    $("#loginForm").validate({
        submitHandler:function(form){
            //如果成功 异步提交表单
            doLogin()
        }
    })

使用ajax异步提交

function doLogin(){
    //每次提交loading框
    g_showLoading()
    //md5加密密码 与后台规则一样
    var inputpwd = $("#password").val()
    var str = salt.charAt(0)+salt.charAt(2)+inputpwd+salt.charAt(5)
    //123456->c996054adec06904c675b89aa68de2ec
    var password = md5(str)

    $.ajax({
        url:"/login/do_login",
        type:"POST",
        data:{
            mobile:$("#mobile").val(),
            password:password
        },
        success:function(data){
            //无论成功失败都关闭框
            layer.closeAll();
            console.log("login")
            console.log(password)
 /* {code: 0, msg: null, data: "登录成功"} */
            if(data.code==0){
                layer.msg("成功")
                console.log(data)
            }else{
                console.log("打印后端返回的错误信息")
                layer.msg(data.msg);
            }
        },
        error:function(){
            layer.closeAll()
        }
    })
}

layer.js弹窗 https://layer.layui.com/ common.js

function g_showLoading(){
    var idx = layer.msg('处理中...', {icon: 16,shade: [0.5, '#f5f5f5'],scrollbar: false,offset: '0px', time:100000}) ;  
    return idx;
}

在js中设置salt

var g_passsword_salt="abcd1234"

参数校验

在controller 验证手机号之后 再调用Service 用手机号查询dao数据库里面的密码,与前端传的密码做比较。 页面参数用vo封装。

后台添加vo接收前端数据的类:

public class LoginVo {
    private String mobile;
    private String password;
}

添加controller: 添加errormessage CodeMsg.java

//登录模块 5002XX
public static CodeMsg SESSION_ERROR = new CodeMsg(500210,"Session不存在或者已经失效");
public static CodeMsg PASSWORD_ERROR = new CodeMsg(500211,"登陆密码不能为空");
public static CodeMsg MOBILE_EMPTY = new CodeMsg(500212,"手机号不能为空");
public static CodeMsg SESSION_ERROR = new CodeMsg(500210,"Session不存在或者已经失效");
public static CodeMsg PASSWORD_EMPTY = new CodeMsg(500211,"登陆密码不能为空");
public static CodeMsg MOBILE_EMPTY = new CodeMsg(500212,"手机号不能为空");
public static CodeMsg MOBILE_ERROR = new CodeMsg(500213,"手机号格式错误");
public static CodeMsg MOBILE_NOT_EXIST = new CodeMsg(500214,"手机号不存在");
public static CodeMsg PASSWORD_ERROR = new CodeMsg(500215,"密码错误");
//添加log 可以查看前端传过来的form数据是什么
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static Logger log = LoggerFactory.getLogger(LoginController.class);
@RequestMapping("/do_login")
@ResponseBody
public Result<Boolean> doLogin(LoginVo loginVo) {
    log.info(loginVo.toString());
        //参数校验
    String password = loginVo.getPassword();
    String mobile = loginVo.getMobile();
    if(StringUtils.isEmpty(mobile)){
        return Result.error(CodeMsg.MOBILE_EMPTY);
    }
    if(StringUtils.isEmpty(password)){
        return Result.error(CodeMsg.PASSWORD_EMPTY);
    }
    if(!ValidatorUtil.isMobile(mobile))
        return Result.error(CodeMsg.MOBILE_ERROR);
    }

正则手机号 手机号验证类ValidatorUtil.java

import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
public class ValidatorUtil {
    private static final Pattern mobile_pattern = Pattern.compile("^(13[0-9]|14[579]|15[0-3,5-9]|16[6]|17[0135678]|18[0-9]|19[89])\\d{8}$");
    public static boolean isMobile(String str){
        if(StringUtils.isEmpty(str)){
            return false;
        }
        Matcher m = mobile_pattern.matcher(str);
        return m.matches();
    }
    public static void main(String[] args) {
        //true
            System.out.println(isMobile("18912341234"));
        //false
            System.out.println(isMobile("12345678900"));
    }
}

新建与数据库关联的domain对象

public class MiaoshaUser {
    //bigint
    private Long id;
    private String nickname;
    private String password;
    private String salt;
    private String head;
    private Date registerDate;
    private Date lastLoginDate;
    private Integer loginCount;
}

新建dao,通过id找用户

@Mapper
public interface MiaoshaUserDao{
    @Select"select * from miaosha user where id = #{id}")
    public MiaoshaUser getById(@Param("id") long id);
    @Insert("insert into user(id, name)values(#{id}, #{name})")
    public int insert(User user);

}

service获取用户及登陆:

@Service
public class MiaoshaUserService{
    @Autowired
    MiaoshaUserDao miaoshaUserDao;
    public MiaoshaUser getById(long id) {
        return miaoshaUserDao.getById(id);
    }
    public CodeMsg login(LoginVo loginVo){
        if(loginVo == null) {
            throw CodeMsg.SERVER_ERROR;
        }
        String mobile = loginVo.getMobile();
        String formPass = loginVo.getPassword();
        //数据库查询手机号
        MiaoshaUser user = getById(Long.parseLong(mobile));
        if(user == null) {
            //用户/手机号不存在
            throw new CodeMsg.MOBILE_NOT_EXIST;
        }
        //数据库中的密码,salt
        String dbPass = user.getPassword();
        String saltDB = user.getSalt();
        //用前端密码+数据库salt是否等于数据库密码
        String gassDBpass = MD5Util.formPassToDBPass(formPass, saltDB);
        if(!calcPass.equals(dbPass)) {
            throw CodeMsg.PASSWORD_ERROR;
        }
        return CodeMsg.SUCCESS;
    }
}

在controller中注入

@Autowired
MiaoshaUserService userService;

@RequestMapping("/do_login")
@ResponseBody
public Result<String> doLogin(LoginVo loginVo) {
    //..参数校验
    //登录
    CodeMsg code = userService.login(loginVo);
    if(code.getCode()==0)return Result.success("登录成功");
    else return Result.error(code);

6.JSR303参数校验+全局异常

不是每个controller的方法里都要写参数校验,而是把参数校验放到vo类上,在controller只要打注解

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

在controller要校验的实体前打@Valid

public Result<String> doLogin(@Valid  LoginVo loginVo)

在实体类加注解

public class LoginVo {
@NotNull
@Length(min=32)
private String password;

自定义注解

对手机号添加自定义验证注解 新建validator包,新建IsMobile.java 参考java.validation.constrains里的NotNull, 必须的,添加 来自Constraint.java的注释:

Each constraint annotation must host the following attributes:
    String message() default [...]; which should default to an error message key made of the fully-qualified class name of the constraint followed by .message. For example "{com.acme.constraints.NotSafe.message}"
    Class<?>[] groups() default {}; for user to customize the targeted groups
    Class<? extends Payload>[] payload() default {}; for extensibility purposes
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
// 注解实现类
@Constraint(validatedBy = {IsMobileValidator.class})
public @interface IsMobile{
    //不能为空
    boolean required() default true;
    //默认信息
    String message() default "手机号码格式错误";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}

注解实现类

新建类IsMobileValidator并在@interface里添加@Constraint(validatedBy = {IsMobileValidator.class})

public @interface Constraint {
    Class<? extends ConstraintValidator<?, ?>>[] validatedBy();
}

创建类<注解,检测的类型>,用上之前创建的ValidatorUtil

public class MobileValidator implements ConstraintValidator<IsMobile,String> {
    //成员变量,接收注解定义
    private boolean required = false;
    @Override
    public void initialize(IsMobile constraintAnnotation) {
        //初始化方法里可以获取注解对象
    required = constraintAnnotation.required();
    }
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(required){
            //初始化获取注解 传的值 如果是必须的,判断是否合法
            return ValidatorUtil.isMobile(value);
        }else//如果不是必须的
            if(StringUtils.isEmpty(value)){
            return true;

        }else{
            return ValidatorUtil.isMobile(value);
            }
    }
}

在LoginVo上加上

@NotNull
@Mobile(required = true,message = "手机号错")
private String mobile;

返回controller的doLogin可以删掉之前的非空检验参数校验

@RequestMapping("/do_login")
@ResponseBody
public Result<String> doLogin(@Valid  LoginVo loginVo) {
    log.info(loginVo.toString());
    // 登录
    CodeMsg code = userService.login(loginVo);
    // 如果有异常会给异常controller处理
    return Result.success("登录成功");
}

可以得到完整错误信息 绑定异常 errormsg.jpg

异常处理

错误处理新建exception包 添加@ControllerAdvice 和controller是一样的

@ControllerAdvice
@ResponseBody
public class BindExceptionHandler {
    // 拦截所有异常
    @ExceptionHandler(Exception.class)
    public Result<String> bindexp(HttpServletRequest request,Exception e){
        // 刚刚手机号错报的绑定异常
        if(e instanceof BindException){
            BindException ex = (BindException) e;
            List<ObjectError> errors = ex.getAllErrors();
            ObjectError objectError = errors.get(0);
            String defaultMessage = objectError.getDefaultMessage();
            return Result.error(CodeMsg.BIND_ERROR.fillArgs(defaultMessage));

        }else{
            //通用异常
            return Result.error(CodeMsg.SERVER_ERROR);
        }
    }
}

定义传参的错误信息

可传递参数的错误信息,原来定义的枚举类不能new 所以不用枚举了 CodeMsg.BIND_ERROR.fillArgs(msg)

public class  CodeMsg {
    private  int code;
    private  String msg;
    private CodeMsg( int code,String msg ) {
        this.code = code;
        this.msg = msg;
    }
    //通用的错误码
    public static CodeMsg SUCCESS = new CodeMsg(0, "success");
    public static CodeMsg SERVER_ERROR = new CodeMsg(500100, "服务端异常");
    // 绑定异常
    public static CodeMsg BIND_ERROR = new CodeMsg(500101, "参数校验异常:%s");
    //登录模块 5002XX
    public static CodeMsg SESSION_ERROR = new CodeMsg(500210, "Session不存在或者已经失效");
    public static CodeMsg PASSWORD_EMPTY = new CodeMsg(500211, "登录密码不能为空");
    public static CodeMsg MOBILE_EMPTY = new CodeMsg(500212, "手机号不能为空");
    public static CodeMsg MOBILE_ERROR = new CodeMsg(500213, "手机号格式错误");
    public static CodeMsg MOBILE_NOT_EXIST = new CodeMsg(500214, "手机号不存在");
    public static CodeMsg PASSWORD_ERROR = new CodeMsg(500215, "密码错误");
    //参数校验异常:%s
    public CodeMsg fillArgs(Object... args) {
        int code = this.code;
        // this 关键
        String message = String.format(this.msg,args);
        return new CodeMsg(code,message);
    }
}

测试:200返回{"code":500101,"msg":"参数校验异常:手机号错","data":null}

定义系统全局异常

业务模块MiaoshaUserService中的public CodeMsg login(LoginVo loginVo)方法,不应该返回CodeMsg,应该定义系统全局异常(业务异常)

public class GlobalException extends RuntimeException{

    private static final long serialVersionUID = 1L;
    
    private CodeMsg cm;
    
    public GlobalException(CodeMsg cm) {
        super(cm.toString());
        this.cm = cm;
    }//get
}

MiaoshaUserService.java 修改业务代码直接抛异常而不是返回CodeMsg

// 返回业务含义的 登陆 true false
public boolean login(LoginVo loginVo){
    if(loginVo == null){
        throw new GlobalException( CodeMsg.SERVER_ERROR);
    }
    String mobile = loginVo.getMobile();
    String formPass = loginVo.getPassword();
    MiaoshaUser user = getById(Long.parseLong(mobile));
    if(user == null) {
        //用户/手机号不存在
        throw new GlobalException( CodeMsg.MOBILE_NOT_EXIST);
    }
    //数据库中的密码,salt
    String dbPass = user.getPassword();
    String saltDB = user.getSalt();
//      用前端密码+数据库salt是否等于数据库密码
    String calcPass = MD5Util.formPassToDBPass(formPass, saltDB);
    log.info(calcPass);
    log.info(dbPass);
    if(!calcPass.equals(dbPass)) {
        throw new GlobalException( CodeMsg.PASSWORD_ERROR);
    }
    return true;
}

添加全局异常处理,注意合并成一个异常处理,不要覆盖 // todo 应该先小异常还是先大异常

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
    @ExceptionHandler(value=Exception.class)
    public Result<String> exceptionHandler(HttpServletRequest request, Exception e){
        e.printStackTrace();
        if(e instanceof GlobalException) {
            GlobalException ex = (GlobalException)e;
            return Result.error(ex.getCm());
        }else if(e instanceof BindException) {
            BindException ex = (BindException)e;
            List<ObjectError> errors = ex.getAllErrors();
            ObjectError error = errors.get(0);
            String msg = error.getDefaultMessage();
            return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
        }else {
            return Result.error(CodeMsg.SERVER_ERROR);
        }
    }
}

修改controllor中service的返回值,异常已经处理了,不用返回值

userService.login(loginVo);
return Result.success("登录成功");

7.分布式Session

1.容器session同步 比较复杂 2.登陆成功后生成token(sessionID)写到cookie传递给客户端,客户端每次访问上传cookie,服务器根据token找到user对象

新建生成ID的类 用uuid,原生UUID带‘-’,去掉

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

service中login比对密码正确后,生成token,并写到redis

在service中引入redisService,设置cookie中的token name

// cookie key
public static final String COOKI_NAME_TOKEN = "token";
@Autowired
RedisService redisService;
public boolean login( HttpServletResponse response,@Valid LoginVo loginVo) {
    if(loginVo == null) {
        System.out.println("loginvonull");
        throw new GlobalException(CodeMsg.SERVER_ERROR);
    }
    String mobile = loginVo.getMobile();
    String formPass = loginVo.getPassword();
    //判断手机号是否存在

    MiaoshaUser user = getById(Long.parseLong(mobile));
    if(user == null) {
        throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
    }
    //验证密码
    String dbPass = user.getPassword();
    String saltDB = user.getSalt();
    String calcPass = MD5Util.formPassToDBPass(formPass, saltDB);
    if(!calcPass.equals(dbPass)) {
        throw new GlobalException(CodeMsg.PASSWORD_ERROR);
    }
    //生成cookie
    String token = UUIDUtil.uuid();
    redisService.set(MiaoshaUserKey.token, token, user);
    Cookie cookie = new Cookie(COOKI_NAME_TOKEN,token);
    // 有效期 与redis中session有效期保持一致
    cookie.setMaxAge(MiaoshaUserKey.token.expireSeconds());
    // 网站根目录 注意不是 ./
    cookie.setPath("/");
     //写到response要HttpResponse
    response.addCookie(cookie);
    return true;
}

\redis\新建MiaoshaUserKey

public class MiaoshaUserKey extends BasePrefix{
    public MiaoshaUserKey(String prefix) {
            super(prefix);
    }
    public static tokenKey token = new tokenKey("tk");
}

修改login controller 里也要添加HttpServletResponse response

@RequestMapping("/do_login")
@ResponseBody
public Result<String> doLogin(HttpServletResponse response,@Valid  LoginVo loginVo) {
    log.info(loginVo.toString());
     userService.login(response,loginVo);
    return Result.success("登录成功");
}

登录成功跳转页

注意语法

<!DOCTYPE HTML>
<html xmlns:th="https://www.thymeleaf.org">
<head>
    <title>商品列表</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p th:text="'hello:'+${user.nickname}" ></p>
</body>
</html>

创建新的controller类

@Controller
@RequestMapping("/goods")
public class GoodsController {
    @RequestMapping("/to_list")
    public String list(Model model,MiaoshaUser user) {
        return "goods_list";
    }
}

login.html ajax成功后跳转

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

测试:登录后可以显示:hello:null 查看do_login的response Set-Cookie: token=1701f466f2904a568aa364d6992828eb; Max-Age=0; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=./

因为Max-Age=0所以to_list没有上传cookie 给cookie设置默认有效期 MiaoshaUserKey.java

public class MiaoshaUserKey extends BasePrefix{

    public static final int TOKEN_EXPIRE = 3600*24 * 2;

    // 构造函数里加上过期时间
    public MiaoshaUserKey(int expireSeconds,String prefix) {
        super(expireSeconds,prefix);
    }
    // 调用构造函数
    public static MiaoshaUserKey token = new MiaoshaUserKey(TOKEN_EXPIRE,"tk");

测试:do login的response Set-Cookie: token=38407e1482e246519727d0041bbd781c; Max-Age=172800; Expires=Tue, 23-Oct-2018 11:07:50 GMT; Path=/ tolist 里会带着 Cookie: token=38407e1482e246519727d0041bbd781c

public方法一定要做参数校验

实现用token从redis中得到MiaoshaUser

public MiaoshaUser getByToken( String token) {
    if(StringUtils.isEmpty(token)) {
        return null;
    }
    return redisService.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
    }

controller: 有的手机端会放到参数里传不在cookie里。设置优先级

@RequestMapping("/to_list")
public String toLogin(Model model,
                      @CookieValue(value = MiaoshaUserService.COOKI_NAME_TOKEN,required = false)String cookieToken,
                      @RequestParam(value = MiaoshaUserService.COOKI_NAME_TOKEN,required = false)String paramToken) {
    if(StringUtils.isEmpty(cookieToken)&& StringUtils.isEmpty(paramToken)){
        System.out.println("没获取到");
        return "login";
    }

    String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
    System.out.println("获取到了token");
    MiaoshaUser user = userService.getByToken(token);
    System.out.println("获取到了用户");
    System.out.println(user);
    model.addAttribute("user",user);
    return "goods_list";
}

session内登陆时延长有效期

每次response里都有set-cookie

把生成cookie的代码独立成一个方法:

private void addCookie(HttpServletResponse response, String token,MiaoshaUser user) {
    redisService.set(MiaoshaUserKey.token, token, user);
    Cookie cookie = new Cookie(COOKI_NAME_TOKEN, token);
    cookie.setMaxAge(MiaoshaUserKey.token.expireSeconds());
    cookie.setPath("/");
    response.addCookie(cookie);
}

每次token->User的时候重新对response更新cookie

public MiaoshaUser getByToken(HttpServletResponse response, String token) {
    if(StringUtils.isEmpty(token)) {
        return null;
    }
    MiaoshaUser user = redisService.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
//      延长有效期
    if(user != null) {
        addCookie(response,token,user);
    }
    return user;
}

在controller加response

判断登陆session的代码独立出来

实现效果:每个Controller不用验证登陆,只需要注入一个User。 相当于实现一个ArgumentResolver 新建包config WebConfig.java 参数通过框架回调WebMvcConfigurerAdapteraddArgumentResolvers

addArgumentResolvers 是spring MVC里controller中可以带很多参数,都是框架回调这个方法给controller赋值的。 所以只需要遍历方法的参数,如果有User这个参数,就赋值。

添加一个Resolver

赋值

@Configuration
public class WebConfig  extends WebMvcConfigurerAdapter {
    
    @Autowired
    UserArgumentResolver userArgumentResolver;
    
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(userArgumentResolver);
    }
}
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

    @Autowired
    MiaoshaUserService userService;
    
    public boolean supportsParameter(MethodParameter parameter) {
        // 获取参数类型 是User类型才会做resolveArgument
        Class<?> clazz = parameter.getParameterType();
        return clazz==MiaoshaUser.class;
    }

    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, 
        WebDataBinderFactory binderFactory) throws Exception {
        // 1. request 和 response
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);

        // 2. 浏览器不同 token可能在cookie里也可能在参数里
        String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
        String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
        if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)){return null;}
        // 3. 根据客户端token获取user
        String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
        return userService.getByToken(response, token);
    }

    /**
     * 遍历request里的所有cookie 找到对应那个的value
     * @param request
     * @param cookiName
     * @return
     */
    private String getCookieValue(HttpServletRequest request, String cookiName) {
        Cookie[]  cookies = request.getCookies();
        for(Cookie cookie : cookies) {
            if(cookie.getName().equals(cookiName)) {
                return cookie.getValue();
            }
        }
        return null;
    }
}
WebMvcConfigurerAdapter 已经被5弃用了(?)

public void configureContentNegotiation(ContentNegotiationConfigurer configurer)内容协商:对象->Json public void addInterceptors(InterceptorRegistry registry) 拦截器 public void addResourceHandlers(ResourceHandlerRegistry registry) 资源处理器 public void addCorsMappings(CorsRegistry registry) 跨域

可以删掉controller里检测登陆的代码:

@RequestMapping("/to_list")
public String toLogin(Model model,MiaoshaUser user) {
    model.addAttribute("user",user);
    return "goods_list";
}

完成分布式session


8.商品列表详情页 秒杀功能

秒杀商品表、秒杀订单表 要独立,因为变化大 新建数据库

create table `goods`(
  `id` bigint(20) not null AUTO_INCREMENT comment '商品ID',
  `goods_name` varchar(16) not null comment '商品名称',
  `goods_title` varchar(64) default null comment '商品标题',
  `goods_img` varchar(64) default null comment'商品图片',
  `goods_detail` longtext comment '商品详情介绍',
  `goods_price` decimal(10,2) default '0.00' comment '商品单价',
  `goods_stock` int(11) default '0' comment '商品库存,-1表示没有限制',

  primary key (`id`)
)ENGINE=InnoDB AUTO_INCREMENT = 3 DEFAULT CHARSET=utf8;

create table `miaosha_order`(
  `id` bigint(20) not null AUTO_INCREMENT ,
  `user_id` BIGINT(20) not null comment '用户ID',
  `order_id` BIGINT(20) default null comment '订单ID',
  `goods_id` BIGINT(20) default null comment'商品ID',

  primary key (`id`)
)ENGINE=InnoDB AUTO_INCREMENT = 3 DEFAULT CHARSET=utf8;


create table `miaosha_goods`(
  `id` bigint(20)  not null AUTO_INCREMENT comment '秒杀商品表',
  `goods_id` BIGINT(20) DEFAULT null comment '商品id',
  `miaosha_price` DECIMAL(10,2) default '0.00' comment '秒杀',
  `stock_count` INT(11) default null comment'库存数量',
  `start_date` DATETIME DEFAULT  NULL comment '秒杀开始时间',
  `end_date` DATETIME DEFAULT  NULL comment '秒杀结束时间',

  primary key (`id`)
)ENGINE=InnoDB AUTO_INCREMENT = 3 DEFAULT CHARSET=utf8;


create table `order_info`(
  `id` bigint(20) not null AUTO_INCREMENT ,
  `user_id` BIGINT(20) not null comment '用户ID',
  `goods_id` BIGINT(20) default null comment '商品ID',
  `delivery_addr_id` BIGINT(20) default null comment'收获地址ID',
  `goods_name` VARCHAR(16) DEFAULT  NULL comment '冗余商品名称',
  `goods_count` INT(11) DEFAULT '0' comment '商品数量',
  `goods_price` DECIMAL(10,2) DEFAULT '0.00' comment '商品单价',

  `order_channel` TINYINT(4) DEFAULT '0' comment '1pc,2android,3ios',
  `status` TINYINT(4) DEFAULT '0' comment '订单状态,0新建未支付,1已支付,3已收货,4已退款,5已完成',
  `create_date` DATETIME DEFAULT NULL comment '订单创建时间',
  `pay_date` DATETIME DEFAULT NULL comment '支付时间',

  primary key (`id`)
)ENGINE=InnoDB AUTO_INCREMENT = 12 DEFAULT CHARSET=utf8;

建立对应的domain对象

public class Goods {
    private Long id;
    private String goodsName;
    private String goodsTitle;
    private String goodsImg;
    private String goodsDetail;
    private Double goodsPrice;
    private Integer goodsStock;
    }
public class MiaoshaGoods {
    private Long id;
    private Long gooddsId;
    private Integer stockCount;
    private Date startDate;
    private Date endDate;
}
public class OrderInfo {
    private Long id;
    private Long userId;
    private Long goodsId;
    private Long deliveryAddrId;
    private String goodsName;
    private Integer goodsCount;
    private Double goodsPrice;
    private Integer orderChannel;
    private Integer status;
    private Date createDate;
    private Date payDate;
}
public class MiaoshaOrder {
    private Long id;
    private Long userId;
    private Long orderId;
    private Long goodsId;
}

创建GoodsService.java和对应的GoodsDao

查找商品希望同时查找到miaosha_goods中的秒杀信息 建立vo

public class GoodVo extends Goods{
    private Double miaoshaPrice;
    private Integer stockCount;
    private Date startDate;
    private Date endDate;

dao

@Mapper
public interface GoodsDao {

    /**
     * 查找商品信息和秒杀信息(库存和秒杀时间)
     */
    @Select("select g.*,mg.miaosha_price,mg.stock_count,mg.start_date,mg.end_date  from miaosha_goods mg left join goods g on mg.goods_id = g.id")
    public List<GoodVo> listGoodsVo();
}

service: 显示商品列表

@Service
public class GoodsService {
    @Autowired
    GoodsDao goodsDao;

    public List<GoodVo> listGoodsVo(){
        return goodsDao.listGoodsVo();
    }
}

controller 中添加到页面

@Autowired
GoodsService goodsService;

@RequestMapping("/to_list")
public String toLogin(Model model,MiaoshaUser user) {
    model.addAttribute("user",user);

    // 秒杀商品列表
    List<GoodVo> goodVos = goodsService.listGoodsVo();
    model.addAttribute("goodsList",goodVos);

    return "goods_list";
}

在goods_list.html 添加遍历 在static下放img/iphoneX.png 数据库img存img/iphoneX.png

<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.miaoshaPrice}"></td>
            <td th:text="${goods.stockCount}"></td>
            <td><a th:href="'/goods/to_detail/'+${goods.id}">详情</a></td>
        </tr>
    </table>
</div>

测试:https://localhost:8080/goods/to_list 可以看到表格

商品详情页 倒计时

为了防止数据库中id连号被遍历,一般使用snowflake算法

html里/goods/to_detail/'+${goods.id}

根据商品ID查询单个goodVO信息 并显示当前时间和秒杀时间的倒计时 controller:

@RequestMapping("/to_detail/{goodsId}")
public String detail(Model model,MiaoshaUser user,
                     @PathVariable("goodsId")long goodsId) {
    model.addAttribute("user", user);

    GoodVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    model.addAttribute("goods", goods);

    long startAt = goods.getStartDate().getTime();
    long endAt = goods.getEndDate().getTime();
    long now = System.currentTimeMillis();
    // 0:没开始 2:结束 1:进行中
    int miaoshaStatus = 0;
    // 倒计时
    int remainSeconds = 0;
    if(now < startAt ) {//秒杀还没开始,倒计时
        miaoshaStatus = 0;
        remainSeconds = (int)((startAt - now )/1000);
    }else  if(now > endAt){//秒杀已经结束
        miaoshaStatus = 2;
        remainSeconds = -1;
    }else {//秒杀进行中
        miaoshaStatus = 1;
        remainSeconds = 0;
    }
    model.addAttribute("miaoshaStatus", miaoshaStatus);
    model.addAttribute("remainSeconds", remainSeconds);
    return "goods_detail";
}

service 和dao 显示商品详情

public GoodVo getGoodsVoByGoodsId(long goodsId) {
        return goodsDao.getGoodsVoByGoodsId(goodsId);
    }
@Select("select g.*,mg.miaosha_price,mg.stock_count,mg.start_date,mg.end_date  from miaosha_goods mg left join goods g on mg.goods_id = g.id where g.id = #{goodId}" )
GoodVo getGoodsVoByGoodsId( long goodsId);

商品详情html:

<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="goodslist">
    <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="miaoshaTip">    
            <input type="hidden" id="remainSeconds" th:value="${remainSeconds}" />
            <span th:if="${miaoshaStatus eq 0}">秒杀倒计时:<span id="countDown" th:text="${remainSeconds}"></span></span>
            <span th:if="${miaoshaStatus eq 1}">秒杀进行中</span>
            <span th:if="${miaoshaStatus eq 2}">秒杀已结束</span>
        </td>
        <td>
            <form id="miaoshaForm" method="post" action="/miaosha/do_miaosha">
                <button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>
                <input type="hidden" name="goodsId" th:value="${goods.id}" />
            </form>
        </td>
     </tr>
     <tr>  
        <td>商品原价</td>  
        <td colspan="3" th:text="${goods.goodsPrice}"></td>  
     </tr>
      <tr>  
        <td>秒杀价</td>  
        <td colspan="3" th:text="${goods.miaoshaPrice}"></td>  
     </tr>
     <tr>  
        <td>库存数量</td>  
        <td colspan="3" th:text="${goods.stockCount}"></td>  
     </tr>
  </table>
</div>

倒计时:<span id="countDown" th:text="${remainSeconds}"></span>秒</span> 设置隐藏域保留remainSeconds(controller添加的) 这样miaoshaStatus是1或者2 js也能取到时间

<input type="hidden" id="remainSeconds" th:value="${remainSeconds}" />
<span th:if="${miaoshaStatus eq 0}">秒杀倒计时:<span id="countDown" th:text="${remainSeconds}"></span></span>

js判断remainSeconds 三种情况,设置标签颜色和倒计时

$(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);
        }
        $("#miaoshaTip").html("秒杀进行中");
    }else{//秒杀已经结束
        $("#buyButton").attr("disabled", true);
        $("#miaoshaTip").html("秒杀已经结束");
    }
}

测试:https://localhost:8080/goods/to_detail/1

秒杀功能

用表单提交 传递的参数是商品id

<form id="miaoshaForm" method="post" action="/miaosha/do_miaosha">
    <button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>
    <input type="hidden" name="goodsId" th:value="${goods.id}" />
</form>

添加秒杀模块的Error Message

public static CodeMsg MIAO_SHA_OVER = new CodeMsg(500500, "无库存");
public static CodeMsg REPEATE_MIAOSHA = new CodeMsg(500501, "不能重复秒杀");

业务逻辑:

  1. 判断登陆 -> 登陆页面
  2. 判断商品库存 -> 秒杀失败
  3. 判断用户是否已经秒杀过该商品 ->秒杀失败 //todo 如果可以买好几件?
  4. 事务:减库存 下单 加入秒杀订单 -> 订单详情页 定义OrderService 用于查询用户订单是否已经买过这个商品

添加秒杀失败页面:

<!DOCTYPE HTML>
<html xmlns:th="https://www.thymeleaf.org">
<head>
    <title>秒杀失败</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
秒杀失败:<p th:text="${errmsg}"></p>
</body>
</html>

新建MiaoshaController

@Controller
@RequestMapping("/miaosha")
public class MiaoshaController {

    @Autowired
    GoodsService goodsService;
    
    @Autowired
    OrderService orderService;
    
    @Autowired
    MiaoshaService miaoshaService;
    
    @RequestMapping("/do_miaosha")
    public String list(Model model, MiaoshaUser user,
                       @RequestParam("goodsId")long goodsId) {
        model.addAttribute("user", user);
        // 没登陆
        if(user == null) {
            return "login";
        }
        //判断库存
        GoodVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
        int stock = goods.getStockCount();
        if(stock <= 0) {
            model.addAttribute("errmsg", CodeMsg.MIAO_SHA_OVER.getMsg());
            return "miaosha_fail";
        }
//      从用户订单查询是否已经对这个物品下过单了
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
        if(order != null) {
            model.addAttribute("errmsg", CodeMsg.REPEATE_MIAOSHA.getMsg());
            return "miaosha_fail";
        }
        //1.减库存 2.下订单 3.写入秒杀订单 这三步是一个是事务
        OrderInfo orderInfo = miaoshaService.miaosha(user, goods);
        model.addAttribute("orderInfo", orderInfo);
        model.addAttribute("goods", goods);
        return "order_detail";
    }
}

查订单 查询用户是否已经秒杀过该商品 dao: 用 用户id和商品id 查询对应的订单

@Mapper
public interface OrderDao {
    
    @Select("select * from miaosha_order where user_id=#{userId} and goods_id=#{goodsId}")
    public MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(@Param("userId") long userId, @Param("goodsId") long goodsId);
    }

OrderService: controller 里判断是不是非null

@Service
public class OrderService {
    
    @Autowired
    OrderDao orderDao;
    // 根据用户ID和商品ID查找相应订单
    public MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(long userId, long goodsId) {
        return orderDao.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
    }
}

定义MiaoshaService 用于对1.减库存 2.下单 3.加入秒杀订单 包装成事务 MiaoshaService的miaosha方法减库存update如果失败,后面补应该继续写入订单

@Service
public class MiaoshaService {
    @Autowired
    GoodsService goodsService;

    @Autowired
    OrderService orderService;

    @Transactional
    public OrderInfo miaosha(MiaoshaUser user, GoodVo goods) {

        //减库存 下订单 写入秒杀订单
        boolean success = goodsService.reduceStock(goods);
        if(success){
            //order_info maiosha_order
            return orderService.createOrder(user, goods);
        }
       return null;
    }
}
  1. 减少库存:查找miaosha商品ID并更新数据库:

更新:通过GoodVo更新goods信息 要通过GoodsDao更新数据库,一般不引入其他Service,所以引入GoodsService 同理写订单不是调用OrderDao 而是 OrderService

减少库存的sql

@Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId}")
    public int reduceStock(MiaoshaGoods g);

GoodsService:

@Service
public class GoodsService {
    @Autowired
    GoodsDao goodsDao;

    // 商品列表
    public List<GoodVo> listGoodsVo(){
        return goodsDao.listGoodsVo();
    }
    // 商品详情
    public GoodVo getGoodsVoByGoodsId(long goodsId) {
        return goodsDao.getGoodsVoByGoodsId(goodsId);
    }
    // 减库存
    public boolean reduceStock(GoodVo goods) {
        MiaoshaGoods g = new MiaoshaGoods();
        g.setGooddsId(goods.getId());
        int rst = goodsDao.reduceStock(g);
        return rst > 0;
    }
}
  1. 下单(生成订单):生成订单Info OrderServece 根据User,GoodVo 拼成OrderInfo
Service
public class OrderService {
    
    @Autowired
    OrderDao orderDao;

    // 根据用户ID和商品ID查找相应订单
    public MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(long userId, long goodsId) {
        return orderDao.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
    }

    // 根据用户和商品信息创建订单信息
    @Transactional
    public OrderInfo createOrder(MiaoshaUser user, GoodVo goods) {
        OrderInfo orderInfo = new OrderInfo();
        orderInfo.setCreateDate(new Date());
        orderInfo.setDeliveryAddrId(0L);
        orderInfo.setGoodsCount(1);
        orderInfo.setGoodsId(goods.getId());
        orderInfo.setGoodsName(goods.getGoodsName());
        orderInfo.setGoodsPrice(goods.getMiaoshaPrice());
        orderInfo.setOrderChannel(1);
        orderInfo.setStatus(0);
        orderInfo.setUserId(user.getId());
        // 数据库insert order表 mybatis成功之后会把id加到对象中
        orderDao.insert(orderInfo);
        MiaoshaOrder miaoshaOrder = new MiaoshaOrder();
        miaoshaOrder.setGoodsId(goods.getId());
        miaoshaOrder.setOrderId(orderInfo.getId());
        miaoshaOrder.setUserId(user.getId());
        // 数据库 miaoshaOrder表
        orderDao.insertMiaoshaOrder(miaoshaOrder);
        return orderInfo;
    }
}

Dao 插入两个订单并且有返回值

@Mapper
public interface OrderDao {
    // 用户id+商品id查找miaosha表订单信息
    @Select("select * from miaosha_order where user_id=#{userId} and goods_id=#{goodsId}")
    public MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(@Param("userId") long userId, @Param("goodsId") long goodsId);
    // 创建订单
    @Insert("insert into order_info(user_id, goods_id, goods_name, goods_count, goods_price, order_channel, status, create_date)values("
            + "#{userId}, #{goodsId}, #{goodsName}, #{goodsCount}, #{goodsPrice}, #{orderChannel},#{status},#{createDate} )")
    @SelectKey(keyColumn="id", keyProperty="id", resultType=long.class, before=false, statement="select last_insert_id()")
    public long insert(OrderInfo orderInfo);
    // 创建秒杀订单
    @Insert("insert into miaosha_order (user_id, goods_id, order_id)values(#{userId}, #{goodsId}, #{orderId})")
    public int insertMiaoshaOrder(MiaoshaOrder miaoshaOrder);
    
}

新建订单详情页:

<div class="panel panel-default">
  <div class="panel-heading">秒杀订单详情</div>
      <p th:text="${orderInfo.getId()}"></p>
  <table class="table" id="goodslist">
        <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="${orderInfo.goodsPrice}"></td>  
     </tr>
     <tr>
            <td>下单时间</td>  
            <td th:text="${#dates.format(orderInfo.createDate, 'yyyy-MM-dd HH:mm:ss')}" colspan="2"></td>  
     </tr>
     <tr>
        <td>订单状态</td>  
        <td >
            <span th:if="${orderInfo.status eq 0}">未支付</span>
            <span th:if="${orderInfo.status eq 1}">待发货</span>
            <span th:if="${orderInfo.status eq 2}">已发货</span>
            <span th:if="${orderInfo.status eq 3}">已收货</span>
            <span th:if="${orderInfo.status eq 4}">已退款</span>
            <span th:if="${orderInfo.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  18812341234</td>  
     </tr>
     <tr>
            <td>收货地址</td>  
            <td colspan="2">北京市昌平区回龙观龙博一区</td>  
     </tr>
  </table>
</div>

访问:https://localhost:8080/goods/to_detail/1 会发送post带着goodId token里有用户 https://localhost:8080/miaosha/do_miaosha 完成秒杀

10.JMeter测试QPS压测 打成war包放到tomcat服务器上

https://jmeter.apache.org/ 1 压测商品列表页 QPS说法:并发在1000的时候网站的QPS是1000或者500 TPS 每秒钟完成了20笔订单 D:\apache-jmeter-5.0\bin\jmeter.bat TestPlan-右键-ADD-thread group Number of Thread : 10 线程数 Ramp-Up Period : 10 用10秒把10个线程都启动起来

默认配置 对线程组右键-add-Config Element -Http request default 添加http和IP和端口

对线程组右键-add-sample-http请求 不用填 http ip 端口 方法get,path:/goods/to_list

对线程组右键-add-Listener-Aggregate Report 也可以添加 Graph Results

jmetermiaosha.jpg Average 平均花费时间 10ms Throughput 可以当作qps 表示一秒能处理11.5个请求 添加监听器 View Results in Table 先把监听器都右键清空 Jmetertable.jpg 报错空指针 修改位置: UserArgumentResolver.java

private String getCookieValue(HttpServletRequest request, String cookiName) {
        Cookie[]  cookies = request.getCookies();
        // 添加空指针判断
        if(cookies == null || cookies.length <= 0){
            return null;
        }
        for(Cookie cookie : cookies) {
            if(cookie.getName().equals(cookiName)) {
                return cookie.getValue();
            }
        }
        return null;
    }

线程数1000的情况下 只有 35每秒qps onekjmeter.jpg 打开数据库服务器的top 10000个线程 大概因为虚拟机所以压榨主机需要的更多 照理说应该load average会超过1 多核cpu负载超过表示很多进程在等待

压测用户对象 新建http request path:/user/info 添加参数:token:ca5be550941349b7bb336f9451a41748

新建controller

@Controller
@RequestMapping("/user")
public class UserController {
    private static Logger log = LoggerFactory.getLogger(GoodsController.class);
    @RequestMapping("/info")
    @ResponseBody
    public Result<MiaoshaUser> info(Model model,MiaoshaUser user){
        return Result.success(user);
    }

}

userinfoqps.jpg 报错消息:JedisException: Could not get a resource from the pool redis获取不到连接 修改配置

# redis
redis.timeout=10
redis.password=123456
redis.poolMaxTotal=1000
redis.poolMaxIdle=500
spring.redis.pool.max-wait=500
# jdbc
spring.datasource.maxActive=1000
spring.datasource.initialSize=100
spring.datasource.maxWait=60000
spring.datasource.minIdle=500

不报错了 userinfomax.jpg

比商品列表的qps高很多,因为redis在内存

多用户token测试 在Jmeter中添加CSV data set config 在http请求里的参数是${userToken}

在服务器上测试jmeter 1.在windows上录好jmx 2.运行jmeter.sh -n -t xxx.jmx -l result.jtl 3.把result.jtl导入jmeter

redis压测: 100个并发 100000个请求 redis-benchmark -h 127.0.0.1 -p 6379 -c 100 -n 100000

====== GET ======
  100000 requests completed in 2.55 seconds
  100 parallel clients
  3 bytes payload
  keep alive: 1

7.87% <= 1 milliseconds
72.82% <= 2 milliseconds
94.64% <= 3 milliseconds
97.13% <= 4 milliseconds
98.74% <= 5 milliseconds
99.65% <= 6 milliseconds
99.98% <= 7 milliseconds
100.00% <= 7 milliseconds
39277.30 requests per second

1秒能4w get

[root@localhost ~]# redis-benchmark -h 127.0.0.1 -p 6379 -q -d 100
PING_INLINE: 41806.02 requests per second
PING_BULK: 41858.52 requests per second
SET: 39184.95 requests per second
GET: 41736.23 requests per second
INCR: 41876.05 requests per second

打war包

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-tomcat</artifactId>
  <!--编译时需要 运行时不需要-->
  <scope>provided</scope>
</dependency>


 <build>
    <finalName>${project.artifactId}</finalName>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-war-plugin</artifactId>
        <configuration>
          <failOnMissingWebXml>false</failOnMissingWebXml>
        </configuration>
      </plugin>
    </plugins>
  </build>

修改启动类

@SpringBootApplication
public class MainApplication extends SpringBootServletInitializer{

    public static void main(String[] args) throws Exception {
        SpringApplication.run(MainApplication.class, args);
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(MainApplication.class);
    }
}

命令行mvn clean package 把war放到tomcat里D:\apache-tomcat-8.5.31\webapps 启动startup.bat 访问https://localhost:8080/miaoshaLearn/login/to_login 真实部署的时候放到ROOT下面就不需要tomcat路径了

为了方便还是打jar包 war包插件改jar包的 注释掉tomcat的依赖

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
</plugin>

启动类还原

@SpringBootApplication
public class MainApplication{
    public static void main(String[] args) throws Exception {
        SpringApplication.run(MainApplication.class, args);
    }
}

命令行mvn clean package 打开jar包 META-INF\META-INF

Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.cloud.miaosha.MainApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/

上传jar包到linux nohup java -jar miaosha.jar & 访问:https://10.1.18.133:8080/goods/to_list

上传jmeter和jmx文件到linux

chmod 777  jmeter.sh
chmod 777  jmeter

生成测试用户: 用HttpURLConnection 发送post

URL url = new URL(urlString);
HttpURLConnection co = (HttpURLConnection)url.openConnection();
co.setRequestMethod("POST");
co.setDoOutput(true);
OutputStream out = co.getOutputStream();
String params = "mobile="+user.getId()+"&password="+MD5Util.inputPassToFormPass("123456");
out.write(params.getBytes());
out.flush();
InputStream inputStream = co.getInputStream();
ByteArrayOutputStream bout = new ByteArrayOutputStream();
byte buff[] = new byte[1024];
int len = 0;
while((len = inputStream.read(buff) >=0)){
    bout.write(buff,0,len);
}
inputStream.close();
bout.close();
String response = new String(bout.toByteArray());
//fast json解析并获取token

修改login返回token

@RequestMapping("/do_login")
@ResponseBody
public Result<String> doLogin(HttpServletResponse response,@Valid  LoginVo loginVo) {
    log.info(loginVo.toString());
//        登录
    String token = userService.login(response, loginVo);
    System.out.println("登陆成功");
    return Result.success(token);
}

dbutil

public class DBUtil {
    
private static Properties props;

static {
    try {
        InputStream in = DBUtil.class.getClassLoader().getResourceAsStream("application.properties");
        props = new Properties();
        props.load(in);
        in.close();
    }catch(Exception e) {
        e.printStackTrace();
    }
}

public static Connection getConn() throws Exception{
    String url = props.getProperty("spring.datasource.url");
    String username = props.getProperty("spring.datasource.username");
    String password = props.getProperty("spring.datasource.password");
    String driver = props.getProperty("spring.datasource.driver-class-name");
    Class.forName(driver);
    return DriverManager.getConnection(url,username, password);
}
}

删除数据库

delete from miaosha_user where nickname like 'user%';

userutil

public class UserUtil {
    
    private static void createUser(int count) throws Exception{
        List<MiaoshaUser> users = new ArrayList<MiaoshaUser>(count);
        //生成用户
        for(int i=0;i<count;i++) {
            MiaoshaUser user = new MiaoshaUser();
            user.setId(13000000000L+i);
            user.setLoginCount(1);
            user.setNickname("user"+i);
            user.setRegisterDate(new Date());
            user.setSalt("1a2b3c");
            user.setPassword(MD5Util.inputPassToDbPass("123456", user.getSalt()));
            users.add(user);
        }
        System.out.println("create user");
//      //插入数据库
        Connection conn = DBUtil.getConn();
        String sql = "insert into miaosha_user(login_count, nickname, register_date, salt, password, id)values(?,?,?,?,?,?)";
        PreparedStatement pstmt = conn.prepareStatement(sql);
        for(int i=0;i<users.size();i++) {
            MiaoshaUser user = users.get(i);
            pstmt.setInt(1, user.getLoginCount());
            pstmt.setString(2, user.getNickname());
            pstmt.setTimestamp(3, new Timestamp(user.getRegisterDate().getTime()));
            pstmt.setString(4, user.getSalt());
            pstmt.setString(5, user.getPassword());
            pstmt.setLong(6, user.getId());
            pstmt.addBatch();
        }
        pstmt.executeBatch();
        pstmt.close();
        conn.close();
        System.out.println("insert to db");
        //登录,生成token
        String urlString = "https://localhost:8080/login/do_login";
        File file = new File("D:/miaoshaLearn/tokens.txt");
        if(file.exists()) {
            file.delete();
        }
        RandomAccessFile raf = new RandomAccessFile(file, "rw");
        file.createNewFile();
        raf.seek(0);
        for(int i=0;i<users.size();i++) {
            MiaoshaUser user = users.get(i);
            URL url = new URL(urlString);
            HttpURLConnection co = (HttpURLConnection)url.openConnection();
            co.setRequestMethod("POST");
            co.setDoOutput(true);
            OutputStream out = co.getOutputStream();
            String params = "mobile="+user.getId()+"&password="+MD5Util.inputPassToFormPass("123456");
            out.write(params.getBytes());
            out.flush();
            InputStream inputStream = co.getInputStream();
            ByteArrayOutputStream bout = new ByteArrayOutputStream();
            byte buff[] = new byte[1024];
            int len = 0;
            while((len = inputStream.read(buff)) >= 0) {
                bout.write(buff, 0 ,len);
            }
            inputStream.close();
            bout.close();
            String response = new String(bout.toByteArray());
            JSONObject jo = JSON.parseObject(response);
            String token = jo.getString("data");
            System.out.println("create token : " + user.getId());
            
            String row = user.getId()+","+token;
            raf.seek(raf.length());
            raf.write(row.getBytes());
            raf.write("\r\n".getBytes());
            System.out.println("write to file : " + user.getId());
        }
        raf.close();
        
        System.out.println("over");
    }
    
    public static void main(String[] args)throws Exception {
        createUser(5000);
    }
}

// 虚拟机压测todo

页面缓存

页面静态化:前后端分离,通过ajax渲染页面 浏览器会把html缓存在客户端,页面数据不需要重复下载,只下载动态数据

Redis 页面缓存key

public class GoodsKey extends BasePrefix {
    private GoodsKey(int expireSeconds, String prefix) {
        super(expireSeconds, prefix);
    }
    public static GoodsKey getGoodsList = new GoodsKey(60, "gl");
    public static GoodsKey getGoodsDetail = new GoodsKey(60, "gd");
}
@Autowired
ThymeleafViewResolver thymeleafViewResolver;

@Autowired
ApplicationContext applicationContext;

@RequestMapping(value = "/to_list",produces = "txt/html")
@ResponseBody
public String toLogin(HttpServletRequest request, HttpServletResponse response, Model model, MiaoshaUser user) {
    model.addAttribute("user",user);
    // 取页面缓存
    String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
    if(!StringUtils.isEmpty(html)) {
        return html;
    }
    // 秒杀商品列表
    List<GoodVo> goodVos = goodsService.listGoodsVo();
    model.addAttribute("goodsList",goodVos);
    SpringWebContext ctx = new SpringWebContext(request,response,
            request.getServletContext(),request.getLocale(), model.asMap(), applicationContext );
    //手动渲染
    // 模板名称 context
    html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);

    // 缓存起来
    if(!StringUtils.isEmpty(html)) {
        redisService.set(GoodsKey.getGoodsList, "", html);
    }
    return "goods_list";
}

https://localhost:8080/goods/to_list 连上redis 查看keys GoodsKey:gl

URL缓存 详情页缓存

@RequestMapping(value = "/to_detail/{goodsId}",produces="text/html")
@ResponseBody
public String detail(HttpServletRequest request, HttpServletResponse response,
                     Model model,MiaoshaUser user,
                     @PathVariable("goodsId")long goodsId) {
    model.addAttribute("user", user);
    //取缓存
    String html = redisService.get(GoodsKey.getGoodsDetail, ""+goodsId, String.class);
    if(!StringUtils.isEmpty(html)) {
        return html;
    }

    GoodVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    model.addAttribute("goods", goods);

    long startAt = goods.getStartDate().getTime();
    long endAt = goods.getEndDate().getTime();
    long now = System.currentTimeMillis();
    // 0:没开始 2:结束 1:进行中
    int miaoshaStatus = 0;
    // 倒计时
    int remainSeconds = 0;
    if(now < startAt ) {//秒杀还没开始,倒计时
        miaoshaStatus = 0;
        remainSeconds = (int)((startAt - now )/1000);
    }else  if(now > endAt){//秒杀已经结束
        miaoshaStatus = 2;
        remainSeconds = -1;
    }else {//秒杀进行中
        miaoshaStatus = 1;
        remainSeconds = 0;
    }
    model.addAttribute("miaoshaStatus", miaoshaStatus);
    model.addAttribute("remainSeconds", remainSeconds);
//        return "goods_detail";
    SpringWebContext ctx = new SpringWebContext(request,response,
            request.getServletContext(),request.getLocale(), model.asMap(), applicationContext );
    html = thymeleafViewResolver.getTemplateEngine().process("goods_detail", ctx);
    if(!StringUtils.isEmpty(html)) {
        redisService.set(GoodsKey.getGoodsDetail, ""+goodsId, html);
    }
    return html;
}

对象缓存

分布式session根据token从redis中拿User对象 添加redis 用户id key

public class MiaoshaUserKey extends BasePrefix{

    public static final int TOKEN_EXPIRE = 3600*24 * 2;
    private MiaoshaUserKey(int expireSeconds, String prefix) {
        super(expireSeconds, prefix);
    }
    public static MiaoshaUserKey token = new MiaoshaUserKey(TOKEN_EXPIRE, "tk");
    //永久有效
    public static MiaoshaUserKey getById = new MiaoshaUserKey(0, "id");
}

登陆验证的时候不是从mysql按id取 而是从redis取

public MiaoshaUser getById(long id) {
    //取缓存
    MiaoshaUser user = redisService.get(MiaoshaUserKey.getById, ""+id, MiaoshaUser.class);
    if(user != null) {
        return user;
    }
    //取数据库
    user = miaoshaUserDao.getById(id);
    if(user != null) {
        redisService.set(MiaoshaUserKey.getById, ""+id, user);
    }
    return user;
}

修改密码: 更新数据库,修改缓存

Cache Aside Pattern cachepattern.jpg

更新缓存的的Design Pattern有四种:Cache aside, Read through, Write through, Write behind caching.

public boolean updatePassword(String token, long id, String formPass) {
    //取user
    MiaoshaUser user = getById(id);
    if(user == null) {
        throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
    }
    //更新数据库
    MiaoshaUser toBeUpdate = new MiaoshaUser();
    toBeUpdate.setId(id);
    toBeUpdate.setPassword(MD5Util.formPassToDBPass(formPass, user.getSalt()));
    miaoshaUserDao.update(toBeUpdate);
    //处理缓存
    redisService.delete(MiaoshaUserKey.getById, ""+id);
    user.setPassword(toBeUpdate.getPassword());
    redisService.set(MiaoshaUserKey.token, token, user);
    return true;
}

user对象缓存应该删除。token缓存应该更新。

redisService 添加delete方法

public boolean delete(KeyPrefix prefix, String key) {
    Jedis jedis = null;
    try {
        jedis =  jedisPool.getResource();
        //生成真正的key
        String realKey  = prefix.getPrefix() + key;
        long ret =  jedis.del(realKey);
        return ret > 0;
    }finally {
        returnToPool(jedis);
    }
}

在dao层添加update

@Mapper
public interface MiaoshaUserDao {
    
    @Select("select * from miaosha_user where id = #{id}")
    public MiaoshaUser getById(@Param("id") long id);

    @Update("update miaosha_user set password = #{password} where id = #{id}")
    public void update(MiaoshaUser toBeUpdate);
}

页面静态化

不用thymeleaf

详情页

将商品详情页包装成vo

public class GoodsDetailVo {
    private int miaoshaStatus = 0;
    private int remainSeconds = 0;
    private GoodsVo goods ;
    private MiaoshaUser user;
}

静态页面xhr获取后台数据 <a th:href="'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/goods_detail.htm?goodsId='+${goods.id}">

$(function(){
    //countDown();
    getDetail();
});

function getDetail(){
    var goodsId = g_getQueryString("goodsId");
    $.ajax({
        url:"/goods/detail/"+goodsId,
        type:"GET",
        success:function(data){
            if(data.code == 0){
                render(data.data);
            }else{
                layer.msg(data.msg);
            }
        },
        error:function(){
            layer.msg("客户端请求有误");
        }
    });
}

js获取url后面的参数name的值

// 获取url参数
function g_getQueryString(name) {
    var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
    var r = window.location.search.substr(1).match(reg);
    if(r != null) return unescape(r[2]);
    return null;
};

成功之后的回调函数 渲染页面

function render(detail){
    var miaoshaStatus = detail.miaoshaStatus;
    var remainSeconds = detail.remainSeconds;
    var goods = detail.goods;
    var user = detail.user;
    if(user){
        $("#userTip").hide();
    }
    $("#goodsName").text(goods.goodsName);
    $("#goodsImg").attr("src", goods.goodsImg);
    $("#startTime").text(new Date(goods.startDate).format("yyyy-MM-dd hh:mm:ss"));
    $("#remainSeconds").val(remainSeconds);
    $("#goodsId").val(goods.id);
    $("#goodsPrice").text(goods.goodsPrice);
    $("#miaoshaPrice").text(goods.miaoshaPrice);
    $("#stockCount").text(goods.stockCount);
    countDown();
}

日期格式化

//设定时间格式化函数,使用new Date().format("yyyyMMddhhmmss");  
Date.prototype.format = function (format) {  
    var args = {  
        "M+": this.getMonth() + 1,  
        "d+": this.getDate(),  
        "h+": this.getHours(),  
        "m+": this.getMinutes(),  
        "s+": this.getSeconds(),  
    };  
    if (/(y+)/.test(format))  
        format = format.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length));  
    for (var i in args) {  
        var n = args[i];  
        if (new RegExp("(" + i + ")").test(format))  
            format = format.replace(RegExp.$1, RegExp.$1.length == 1 ? n : ("00" + n).substr(("" + n).length));  
    }  
    return format;  
};  

页面:

<body>
<div class="panel panel-default">
    <div class="panel-heading">秒杀商品详情</div>
    <div class="panel-body">
        <span id="userTip"> 您还没有登录,请登陆后再操作<br/></span>
    </div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名称</td>
            <td colspan="3" id="goodsName"></td>
        </tr>
        <tr>
            <td>商品图片</td>
            <td colspan="3"><img  id="goodsImg" width="200" height="200" /></td>
        </tr>
        <tr>
            <td>秒杀开始时间</td>
            <td id="startTime"></td>
            <td >
                <input type="hidden" id="remainSeconds" />
                <span id="miaoshaTip"></span>
            </td>
            <td>
                <!--
                    <form id="miaoshaForm" method="post" action="/miaosha/do_miaosha">
                        <button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>
                        <input type="hidden" name="goodsId"  id="goodsId" />
                    </form>-->
                <button class="btn btn-primary btn-block" type="button" id="buyButton"onclick="doMiaosha()">立即秒杀</button>
                <input type="hidden" name="goodsId"  id="goodsId" />
            </td>
        </tr>
        <tr>
            <td>商品原价</td>
            <td colspan="3" id="goodsPrice"></td>
        </tr>
        <tr>
            <td>秒杀价</td>
            <td colspan="3"  id="miaoshaPrice"></td>
        </tr>
        <tr>
            <td>库存数量</td>
            <td colspan="3"  id="stockCount"></td>
        </tr>
    </table>
</div>
</body>

controller

@RequestMapping(value = "/detail/{goodsId}")
@ResponseBody
public Result<GoodsDetailVo> detail(HttpServletRequest request, HttpServletResponse response, MiaoshaUser user,
    @PathVariable("goodsId")long goodsId) {

    GoodVo goods = goodsService.getGoodsVoByGoodsId(goodsId);

    long startAt = goods.getStartDate().getTime();
    long endAt = goods.getEndDate().getTime();
    long now = System.currentTimeMillis();
    // 0:没开始 2:结束 1:进行中
    int miaoshaStatus = 0;
    // 倒计时
    int remainSeconds = 0;
    if(now < startAt ) {//秒杀还没开始,倒计时
        miaoshaStatus = 0;
        remainSeconds = (int)((startAt - now )/1000);
    }else  if(now > endAt){//秒杀已经结束
        miaoshaStatus = 2;
        remainSeconds = -1;
    }else {//秒杀进行中
        miaoshaStatus = 1;
        remainSeconds = 0;
    }
   GoodsDetailVo vo = new GoodsDetailVo();
    vo.setGoods(goods);
    vo.setUser(user);
    vo.setMiaoshaStatus(miaoshaStatus);
    vo.setRemainSeconds(remainSeconds);
    return Result.success(vo);
}

秒杀按钮

<button class="btn btn-primary btn-block" type="button" id="buyButton"onclick="doMiaosha()">立即秒杀</button>

秒杀返回订单 对后台数据有影响的要用post,put不能用get。因为搜索引擎遍历,执行/delete?等链接

function doMiaosha(){
    $.ajax({
        url:"/miaosha/do_miaosha",
        type:"POST",
        data:{
            goodsId:$("#goodsId").val(),
        },
        success:function(data){
            if(data.code == 0){
                window.location.href="/order_detail.htm?orderId="+data.data.id;
            }else{
                layer.msg(data.msg);
            }
        },
        error:function(){
            layer.msg("客户端请求有误");
        }
    });

}

跳转到订单详情页

@Controller
@RequestMapping("/order")
public class OrderController {

    @Autowired
    MiaoshaUserService userService;
    
    @Autowired
    RedisService redisService;
    
    @Autowired
    OrderService orderService;
    
    @Autowired
    GoodsService goodsService;
    
    @RequestMapping("/detail")
    @ResponseBody
    public Result<OrderDetailVo> info(Model model, MiaoshaUser user,
                                      @RequestParam("orderId") long orderId) {
        if(user == null) {
            return Result.error(CodeMsg.SESSION_ERROR);
        }
        OrderInfo order = orderService.getOrderById(orderId);
        if(order == null) {
            return Result.error(CodeMsg.ORDER_NOT_EXIST);
        }
        // 用订单的商品id 查商品信息
        long goodsId = order.getGoodsId();
        GoodVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
        OrderDetailVo vo = new OrderDetailVo();
        vo.setOrder(order);
        vo.setGoods(goods);
        return Result.success(vo);
    }
    
}

新建订单VO

public class OrderDetailVo {
    private GoodVo goods;
    private OrderInfo order;
}

新建订单页controller 新的sql 用订单id查订单

@Mapper
public interface OrderDao {
    @Select("select * from order_info where id = #{orderId}")
    public OrderInfo getOrderById(@Param("orderId")long orderId);
}

新的错误对象

//订单模块 5004XX
public static CodeMsg ORDER_NOT_EXIST = new CodeMsg(500400, "订单不存在");
<body>
<div class="panel panel-default">
  <div class="panel-heading">秒杀订单详情</div>
  <table class="table" id="goodslist">
        <tr>  
        <td>商品名称</td>  
        <td colspan="3" id="goodsName"></td> 
     </tr>  
     <tr>  
        <td>商品图片</td>  
        <td colspan="2"><img  id="goodsImg" width="200" height="200" /></td>  
     </tr>
      <tr>  
        <td>订单价格</td>  
        <td colspan="2"  id="orderPrice"></td>  
     </tr>
     <tr>
            <td>下单时间</td>  
            <td id="createDate" colspan="2"></td>  
     </tr>
     <tr>
        <td>订单状态</td>  
        <td id="orderStatus">
        </td>  
        <td>
            <button class="btn btn-primary btn-block" type="submit" id="payButton">立即支付</button>
        </td>
     </tr>
      <tr>
            <td>收货人</td>  
            <td colspan="2">XXX  18812341234</td>  
     </tr>
     <tr>
            <td>收货地址</td>  
            <td colspan="2">北京市昌平区回龙观龙博一区</td>  
     </tr>
  </table>
</div>
</body>
<script>
function render(detail){
    var goods = detail.goods;
    var order = detail.order;
    $("#goodsName").text(goods.goodsName);
    $("#goodsImg").attr("src", goods.goodsImg);
    $("#orderPrice").text(order.goodsPrice);
    $("#createDate").text(new Date(order.createDate).format("yyyy-MM-dd hh:mm:ss"));
    var status = "";
    if(order.status == 0){
        status = "未支付"
    }else if(order.status == 1){
        status = "待发货";
    }
    $("#orderStatus").text(status);
    
}

$(function(){
    getOrderDetail();
})

function getOrderDetail(){
    var orderId = g_getQueryString("orderId");
    $.ajax({
        url:"/order/detail",
        type:"GET",
        data:{
            orderId:orderId
        },
        success:function(data){
            if(data.code == 0){
                render(data.data);
            }else{
                layer.msg(data.msg);
            }
        },
        error:function(){
            layer.msg("客户端请求有误");
        }
    });
}
</script>

静态化配置

304是客户端(浏览器)向服务端自动加 If-Modified-Since: Fri, 28 Dec 2018 11:48:23 GMT 服务端会检查如果没发生变化就304.还是发生了一次交互。

让页面直接从浏览器取。 Spring resources handling

#static
spring.resources.add-mappings=true
spring.resources.cache-period= 3600
spring.resources.chain.cache=true 
spring.resources.chain.enabled=true
spring.resources.chain.gzipped=true
spring.resources.chain.html-application-cache=true
spring.resources.static-locations=classpath:/static/

response里200 (但其实是来自缓存)响应头里有,达到从浏览器读。 Cache-Control: max-age=3600

bug1:秒杀并发库存到0以下and stock_count >0

code review 1.判断库存2.判断用户订单3.秒杀

@RequestMapping(value = "/do_miaosha",method = RequestMethod.POST)
@ResponseBody
public Result<OrderInfo> list(MiaoshaUser user,
                   @RequestParam("goodsId")long goodsId) {
    // 没登陆
    if(user == null) {
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    //判断库存
    GoodVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    int stock = goods.getStockCount();
    if(stock <= 0) {
        return Result.error(CodeMsg.MIAO_SHA_OVER);
    }
//      从用户订单查询是否已经对这个物品下过单了
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    if(order != null) {
        return Result.error(CodeMsg.REPEATE_MIAOSHA);
    }
    //1.减库存 2.下订单 3.写入秒杀订单 这三步是一个是事务
    OrderInfo orderInfo = miaoshaService.miaosha(user, goods);

    return Result.success(orderInfo);
}

减库存的sql

@Update("update miaosha_goods set stock_count = stock_count -1 where goods_id = #{gooddsId}")
public int reduceStock(MiaoshaGoods g);

如果库存有1,两个线程同时调用这个sql就负数了

修改:只有库存>0才减

@Update("update miaosha_goods set stock_count = stock_count -1 where goods_id = #{gooddsId} and stock_count >0")
    public int reduceStock(MiaoshaGoods g);

因为数据库会加锁 不会两个线程同时更新

bug2:用户秒杀了多个商品:数据库唯一索引

同一个用户多个请求,在没完成第一个订单之前都判断完了有库存,也没秒杀过。 结果:多个线程都到减库存,下订单,生成新的秒杀订单。

数据库唯一索引,让一个用户只能有一个秒杀订单 uniqueindex.jpg

优化: 查询用户是否买过这个商品不走数据库 建key

public class OrderKey extends BasePrefix {

    public OrderKey( String prefix) {
        super( prefix);
    }
    public static OrderKey getMiaoshaOrderByUidGid = new OrderKey("moug");
}
// 根据用户ID和商品ID查找相应订单
public  MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(long userId, long goodsId) {
    return redisService.get(OrderKey.getMiaoshaOrderByUidGid,""+userId+"_"+goodsId , MiaoshaOrder.class);
//      return orderDao.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
}

生成订单之后要写缓存

// 根据用户和商品信息创建订单信息
@Transactional
public OrderInfo createOrder(MiaoshaUser user, GoodVo goods) {
    //...
    // 数据库 miaoshaOrder表
    orderDao.insertMiaoshaOrder(miaoshaOrder);

    redisService.set(OrderKey.getMiaoshaOrderByUidGid,""+user.getId()+"_"+goods.getId(),miaoshaOrder);
    return orderInfo;
}

其他静态资源优化

1.js/css压缩 2.多个js/css组合成一个减少连接数 3.CDN

接口优化

1)redis预减库存减少数据库访问,减库存请求入消息队列,返回排队中。 2)服务端:请求出队,生成订单,减库存。 3)客户端轮询秒杀是否成功。

安装 RabitMQ

1.安装依赖yum install ncurses-devel 2.安装erlangyum install erlang 执行erl表示安装成功 3.安装rabbitmq 解压xz -d rabbitmq-server-generic-unix-3.7.9.tar.xz 解压tar xf rabbitmq-server-generic-unix-3.7.9.tar 安装python 安装yum install xmlto -y 安装yum install python-simplejson -y

cd rabbitmq_server-3.7.9/
cd sbin

启动然后报错 yum remove掉erlang*相关的

tar -xf otp_src_21.0.tar.gz
./configure --prefix=/usr/local/erlang20 --without-javac
make -j 8
make install
vim /etc/profile
export PATH=/usr/local/erlang21/bin:$PATH 
source /etc/profile

回到sbin启动./rabbitmq-server 可以看log 端口是5672

[root@localhost sbin]# netstat -nap |grep 5672
tcp        0      0 0.0.0.0:25672           0.0.0.0:*               LISTEN      14205/beam.smp      
tcp6       0      0 :::5672                 :::*                    LISTEN      14205/beam.smp 

关闭用./rabbitmqctl stop

[root@localhost sbin]# netstat -nap |grep 5672
tcp        0      0 127.0.0.1:43296         127.0.0.1:25672         TIME_WAIT   -

安装lsof

[root@localhost ebin]# lsof -i:5672
COMMAND    PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
beam.smp 17480 root   68u  IPv6 176892      0t0  TCP *:amqp (LISTEN)
beam.smp 17480 root   69u  IPv6 176909      0t0  TCP localhost.localdomain:amqp->10.1.18.15:10893 (ESTABLISHED)

SpringBoot 集成RabbitMQ

erlang有原生socket一样的延迟 AMQP协议模型 生产者:1.投递到server,2投递到virtual host,3投递到exchange exchange 和 message queue 有绑定关系。 amqp.jpg

核心概念: {% note %}

  • Server:Broker。接受连接。
  • Connection:应用与Broker的网络连接。
  • Channel:网络信道,一个会话任务。一个客户端可以建立多个channel。
  • Message:消息结构由Properties(优先级,延迟)和Body组成。
  • Virtual host:逻辑隔离,消息路由,划分服务。一个host里可以有多个exchange和queue。同一个host不能有同名的exchange和queue。(相当于redis 16个db的逻辑概念)
  • Exchange:交换机,接受消息,根据路由键转发消息到binding的队列。
  • Binding:Exchange和Queue的虚拟连接,可以有routing key。
  • Routing key:路由规则。host确定如何路由一个消息。
  • Queue:保存并转发给消费者。

{% endnote %}

核心配置文件位置: vi /usr/local/rabbitmq_server-3.7.9/ebin/rabbit.app loopback_users也在里面 可以修改成[guest]

rabbitMQ插件 可视化管理

rabbitmq-plugins list
rabbitmq-plugins enable rabbitmq_management

访问:https://10.1.18.20:15672/

1 添加依赖

<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-amqp</artifactId>  
</dependency> 

2 添加配置

#rabbitmq
spring.rabbitmq.host=10.1.18.20
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
spring.rabbitmq.virtual-host=/
#消费者数量
spring.rabbitmq.listener.simple.concurrency= 10
spring.rabbitmq.listener.simple.max-concurrency= 10
#从队列里每次取几个
spring.rabbitmq.listener.simple.prefetch= 1
#消费者默认自动启动
spring.rabbitmq.listener.simple.auto-startup=true
# 消费失败会重新压入队列
spring.rabbitmq.listener.simple.default-requeue-rejected= true
#生产者重试
spring.rabbitmq.template.retry.enabled=true 
spring.rabbitmq.template.retry.initial-interval=1000 
spring.rabbitmq.template.retry.max-attempts=3
spring.rabbitmq.template.retry.max-interval=10000
spring.rabbitmq.template.retry.multiplier=1.0

新建rabbitmq包,

交换机的4种模式

Direct 模式

配置类

import org.springframework.amqp.core.Queue;
@Configuration
public class MQConfig {
    public static final String QUEUE = "queue";

    @Bean
    public Queue queue(){
        return new Queue(QUEUE,true);
    }
}

新建发送者

@Service
public class MQSender {

    @Autowired
    AmqpTemplate amqpTemplate;

    public void send(Object message){
        String msg = RedisService.beanToString(message);
        amqpTemplate.convertAndSend(MQConfig.QUEUE,msg);
    }
}

重用redis中bean2String的方法

public static  <T> String beanToString(T value){
    //2. 添加空判断
    if(value == null)return null;
    //3. 如果是数字,字符串,Long
    Class<?> clazz = value.getClass();
    if(clazz == int.class || clazz == Integer.class) {
        return ""+value;
    }else if(clazz == String.class) {
        return (String)value;
    }else if(clazz == long.class || clazz == Long.class) {
        return ""+value;
    }else {
        return JSON.toJSONString(value);
    }
}

String2Bean

public static <T> T stringToBean(String str,Class<T> clazz){
    //1. 参数校验
    if(str == null || str.length() <= 0 || clazz == null) {
        return null;
    }
    //2 如果是int,string,Long
    if(clazz == int.class || clazz == Integer.class) {
        return (T)Integer.valueOf(str);
    }else if(clazz == String.class) {
        return (T)str;
    }else if(clazz == long.class || clazz == Long.class) {
        return  (T)Long.valueOf(str);
    }else {
        //fastJson 其他List类型要再写
        return JSON.toJavaObject(JSON.parseObject(str), clazz);
    }
}

消费者监听轮询

@Service
public class MQSender {
    private static Logger log = LoggerFactory.getLogger(MQSender.class);

    @Autowired
    AmqpTemplate amqpTemplate;

    public void send(Object message){
        String msg = RedisService.beanToString(message);
        log.info("send message"+msg);
        amqpTemplate.convertAndSend(MQConfig.QUEUE,msg);
    }
}

随便用一个controller测试一下

@RequestMapping("/mq")
@ResponseBody
public Result<String> mq(){
    sender.send("rabbitMQ消息测试");
    return Result.success("rabbitMQ消息测试");
}

报错:

rabbitmq.client.AuthenticationFailureException: ACCESS_REFUSED - Login was refused using authentication mechanism PLAIN. For details see the broker logfile.

因为guest用户默认不能远程连接 localhost是可以的 https://www.rabbitmq.com/access-control.html it can only connect over a loopback interface (i.e. localhost).

用方法2 修改rabbitmq.config 在/usr/local/rabbitmq_server-3.7.9/etc/rabbitmq下创建rabbitmq.config 添加 [{rabbit, [{loopback_users, []}]}].

重启

rabbitmqctl stop
rabbitmq-server 

访问https://localhost:8080/demo/mq 可以看到log

Topic模式 可以发给多个queue

@Configuration
public class MQConfig {
    public static final String QUEUE = "queue";
    public static final String TOPIC_QUEUE1 = "topic_queue1";
    public static final String TOPIC_QUEUE2 = "topic_queue2";
    public static final String TOPIC_EXCHANGE = "topic_queue2";
    public static final String ROUTING_KEY1 = "topic.key1";
    //* 表示一个单词。 #表示0个或者多个单词
    public static final String ROUTING_KEY2 = "topic.#";

    // 直接模式
    @Bean
    public Queue queue(){
        return new Queue(QUEUE,true);
    }

    // topic模式
    @Bean
    public Queue topicQueue1(){
        return new Queue(TOPIC_QUEUE1,true);
    }

    @Bean
    public Queue topicQueue2(){
        return new Queue(TOPIC_QUEUE2,true);
    }

    @Bean
    public TopicExchange topicExchange(){
        return new TopicExchange(TOPIC_EXCHANGE);
    }
    @Bean
    public Binding topicBinding1(){
        return BindingBuilder.bind(topicQueue1()).to(topicExchange()).with(ROUTING_KEY1);
    }

    @Bean
    public Binding topicBinding2(){
        return BindingBuilder.bind(topicQueue2()).to(topicExchange()).with(ROUTING_KEY2);
    }
}

发送:

@Service
public class MQSender {
    private static Logger log = LoggerFactory.getLogger(MQSender.class);

    @Autowired
    AmqpTemplate amqpTemplate;

    public void send(Object message){
        String msg = RedisService.beanToString(message);
        log.info("send message"+msg);
        amqpTemplate.convertAndSend(MQConfig.QUEUE,msg);
    }

    public void sendTopic(Object message){
        String msg = RedisService.beanToString(message);
        log.info("send topic message"+msg);
        // queue1和2都能匹配上都能收到
        amqpTemplate.convertAndSend(MQConfig.TOPIC_EXCHANGE,MQConfig.ROUTING_KEY1,msg+"1");
        // 只有queue2能匹配上
        amqpTemplate.convertAndSend(MQConfig.TOPIC_EXCHANGE,MQConfig.ROUTING_KEY2,msg+"2");
    }
}

接收:

@Service
public class MQReceiver {

    private static Logger log = LoggerFactory.getLogger(MQSender.class);

    @RabbitListener(queues = MQConfig.QUEUE)
    public void receive(String message){
        log.info("receive message:"+message);
    }

    @RabbitListener(queues = MQConfig.TOPIC_QUEUE1)
    public void receiveTopic1(String message){
        log.info("receive q1 message:"+message);
    }

    @RabbitListener(queues = MQConfig.TOPIC_QUEUE2)
    public void receiveTopic2(String message){
        log.info("receive q2 message:"+message);
    }
}
@RequestMapping("/mq/topic")
@ResponseBody
public Result<String> topic(){
    // 发两条消息
    sender.sendTopic("topic消息测试");
    return Result.success("topic消息测试");
}

结果 topicmq.jpg

Fanout模式 广播模式 不需要绑定key

mqfanout.jpg

// 广播模式 广播交换机
@Bean
public FanoutExchange fanoutExchange(){
    return new FanoutExchange(FANOUT_EXCHANGE);
}
@Bean
public Binding FanoutBinding1(){
    return BindingBuilder.bind(topicQueue1()).to(fanoutExchange());
}
@Bean
public Binding FanoutBinding2(){
    return BindingBuilder.bind(topicQueue2()).to(fanoutExchange());
}
public void sendFanout(Object message){
    String msg = RedisService.beanToString(message);
    log.info("send topic message"+msg);
    // queue1和2都能都能收到
    amqpTemplate.convertAndSend(MQConfig.FANOUT_EXCHANGE,"",msg+"1");
}
@RequestMapping("/mq/fanout")
@ResponseBody
public Result<String> fanout(){
    sender.sendFanout("广播 消息测试");
    return Result.success("广播消息测试");
}

Header模式

MQConfig

// Header模式
@Bean
public HeadersExchange headersExchange(){
    return new HeadersExchange(HEADER_EXCHANGE);
}

@Bean
public Queue headerQueue(){
    return new Queue(HEADER_QUEUE,true);
}

@Bean
public Binding headerBinding(){
    Map<String,Object> map = new HashMap<String, Object>();
    map.put("header1","value1" );
    map.put("header2","value2" );
    return BindingBuilder.bind(headerQueue()).to(headersExchange()).whereAll(map).match();
}

sender

public void sendHeader(Object message){
    String msg = RedisService.beanToString(message);
    log.info("send header message"+msg);
    MessageProperties properties = new MessageProperties();
    properties.setHeader("header1","value1" );
    properties.setHeader("header2","value2" );
    Message obj = new Message(msg.getBytes(),properties);
    amqpTemplate.convertAndSend(MQConfig.HEADER_EXCHANGE,obj);
}

receiver

@RabbitListener(queues = MQConfig.HEADER_QUEUE)
public void receiveHeader(byte[] message){
    log.info("receive q2 message:"+ new String(message));
}

controller测试

@RequestMapping("/mq/header")
@ResponseBody
public Result<String> header(){
    sender.sendHeader("header 消息测试");
    return Result.success("header消息测试");
}

秒杀接口优化 同步下单->异步下单

秒杀review:

@Autowired
MiaoshaService miaoshaService;
@RequestMapping(value = "/do_miaosha",method = RequestMethod.POST)
@ResponseBody
public Result<OrderInfo> list(MiaoshaUser user,
                   @RequestParam("goodsId")long goodsId) {
    // 没登陆
    if(user == null) {
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    //判断库存(读数据库)
    GoodVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    int stock = goods.getStockCount();
    if(stock <= 0) {
        return Result.error(CodeMsg.MIAO_SHA_OVER);
    }
//   (redis)   从用户订单查询是否已经对这个物品下过单了
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    if(order != null) {
        return Result.error(CodeMsg.REPEATE_MIAOSHA);
    }
    //1.减库存 update 2.下订单 insert 3.写入秒杀订单 insert 这三步是一个是事务
    OrderInfo orderInfo = miaoshaService.miaosha(user, goods);

    return Result.success(orderInfo);
}

判断库存要读数据库,下单减库存update生成订单两个insert,一共要3次数据库。 思路: 1)减少数据库访问,将系统初始化时,将库存数量加载到redis。 2)redis预减库存,如果redis里库存没有直接返回。 3)否则【异步下单】放到消息队列,返回排队中。 4)请求出队,生成订单,减少库存。 5)客户端轮询是否秒杀成功。

启动时将库存加载到redis:框架会回调,实现的方法。

@Controller
@RequestMapping("/miaosha")
public class MiaoshaController implements InitializingBean{
// 系统初始化 读数据库库存,写到redis
    @Override
    public void afterPropertiesSet() throws Exception {
        // 查询出所有商品数量
        List<GoodVo> goodslist = goodsService.listGoodsVo();
        if(goodslist!=null){
            for(GoodVo goods : goodslist){
                redisService.set(GoodsKey.getMiaoshaGoodsStock,""+goods.getId() ,goods.getStockCount() );}}}}

设置库存的rediskey

public class GoodsKey extends BasePrefix {
    private GoodsKey(int expireSeconds, String prefix) {
        super(expireSeconds, prefix);
    }
    public static GoodsKey getGoodsList = new GoodsKey(60, "gl");
    public static GoodsKey getGoodsDetail = new GoodsKey(60, "gd");
    // 添加 预加载库存key
    public static GoodsKey getMiaoshaGoodsStock= new GoodsKey(0, "gs");
}

队列中的消息格式:(秒杀)用户,商品id rabbitmq/MiaoshaMessage.java

public class MiaoshaMessage {
    private MiaoshaUser user;
    private long goodsId;
}

新的秒杀controller流程:

@Autowired
RedisService redisService;

@Autowired
MiaoshaSender sender;
@Autowired
MiaoshaService miaoshaService;
@RequestMapping(value = "/do_miaosha",method = RequestMethod.POST)
@ResponseBody
public Result<Integer> list(MiaoshaUser user,
                   @RequestParam("goodsId")long goodsId) {
    // 没登陆
    if(user == null) {
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    // redis中预减库存
    Long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
    if(stock < 0){
        return Result.error(CodeMsg.MIAO_SHA_OVER);
    }
//      从用户订单查询是否已经对这个物品下过单了
    MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    if(order != null) {
        return Result.error(CodeMsg.REPEATE_MIAOSHA);
    }
    // 入队
    MiaoshaMessage msg = new MiaoshaMessage();
    msg.setUser(user);
    msg.setGoodsId(goodsId);
    sender.sendMiaoshaMessage(msg);
    return Result.success(0);
}

config

@Configuration
public class MiaoshaMQConfig {
    public static final String MIAOSHA_QUEUE = "miaosha.queue";

    // 直接模式
    @Bean
    public Queue miaoshaQueue(){
        return new Queue(MIAOSHA_QUEUE,true);
    }
}

sender:

@Service
public class MiaoshaSender {
    private static Logger log = LoggerFactory.getLogger(MQSender.class);

    @Autowired
    AmqpTemplate amqpTemplate;

    public void sendMiaoshaMessage(MiaoshaMessage message) {
        // direct模式
        String msg = RedisService.beanToString(message);
        log.info("send message: "+msg);
        amqpTemplate.convertAndSend(MiaoshaMQConfig.MIAOSHA_QUEUE,msg);
    }
}

reveicer :减库存 创建订单

@Service
public class MiaoshaReceiver {
    @Autowired
    GoodsService goodsService;

    @Autowired
    OrderService orderService;

    @Autowired
    MiaoshaService miaoshaService;

    private static Logger log = LoggerFactory.getLogger(MiaoshaMQConfig.class);

    @RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
    public void maishaReceive(String message){
        log.info("receive message:"+message);
        MiaoshaMessage msg = RedisService.stringToBean(message, MiaoshaMessage.class);
        long goodsId = msg.getGoodsId();
        MiaoshaUser user = msg.getUser();
        // 判断真的库存
        GoodVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
        int stock = goods.getStockCount();
        if(stock <= 0) {
            return;
        }
        // 判断秒杀过没有
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
        if(order != null) {
            return;
        }
        //1.减库存 2.下订单 3.写入秒杀订单 这三步是一个是事务
        OrderInfo orderInfo = miaoshaService.miaosha(user, goods);
    }
}

客户端轮询

后台添加轮询接口

MiaoshaController

// 客户端轮询接口 判断是否秒杀到
/*
 orderID:成功
 -1:秒杀失败
 0:排队中
 */
@RequestMapping(value = "/result",method = RequestMethod.GET)
@ResponseBody
public Result<Long> miaoshaResult(Model model,MiaoshaUser user,@RequestParam("goodsId")long goodsId){
    model.addAttribute("user",user);
    if(user == null){
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    long rst = miaoshaService.getMiaoshaResult(user.getId(),goodsId);
    return Result.success(rst);
}

轮询方法:

@Service
public class MiaoshaService {
    @Autowired
    GoodsService goodsService;

    @Autowired
    OrderService orderService;

    @Autowired
    RedisService redisService;

    @Transactional
    public OrderInfo miaosha(MiaoshaUser user, GoodVo goods) {

        //减库存 下订单 写入秒杀订单
        boolean success = goodsService.reduceStock(goods);
        if(success){
            //order_info maiosha_order
            return orderService.createOrder(user, goods);
        }else{
            // 如果失败  说明秒杀失败 做标记 防止一直轮询
            setGoodsOver(goods.getId());
            return null;
        }
    }

    public long getMiaoshaResult(Long userid, long goodsId) {
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(userid, goodsId);
        // 订单不空,秒杀成功
        if(order != null){
            return order.getOrderId();
        }else{
            // 判断是排队中还是失败了
            boolean isOver = getGoodsOver(goodsId);
            if(isOver) {
                return -1;
            }else {
                return 0;
            }
        }
    }

    private void setGoodsOver(Long goodsId) {
        redisService.set(MiaoshaKey.isGoodsOver, ""+goodsId, true);
    }

    private boolean getGoodsOver(long goodsId) {
        return redisService.exists(MiaoshaKey.isGoodsOver, ""+goodsId);
    }
}

用redis添加秒杀完库存的标记,防止一直轮询,判断是排队中还是秒杀完了。

public class MiaoshaKey extends BasePrefix{

    private MiaoshaKey(String prefix) {
        super(prefix);
    }
    public static MiaoshaKey isGoodsOver = new MiaoshaKey("go");
前端轮询:

goods_detail.htm详情页中的秒杀按钮

<button class="btn btn-primary btn-block" type="button" id="buyButton"onclick="doMiaosha()">立即秒杀</button>

添加排队 并用get轮询

function doMiaosha(){
    $.ajax({
        url:"/miaosha/do_miaosha",
        type:"POST",
        data:{
            goodsId:$("#goodsId").val(),
        },
        success:function(data){
            if(data.code == 0){
                // 成功 排队中 轮询
                getMiaoshaResult($("#goodsId").val());
            }else{
                layer.msg(data.msg);
            }
        },
        error:function(){
            layer.msg("客户端请求有误");
        }
    });
    }
function getMiaoshaResult(goodsId) {
    // 加载中的动画
    g_showLoading();
    $.ajax({
        url: "/miaosha/result",
        type: "GET",
        data: {
            goodsId: $("#goodsId").val(),
        },
        success: function (data) {
            //成功
            if (data.code == 0) {
                var result = data.data;
                // -1失败
                if (result < 0) {
                    layer.msg("对不起,秒杀失败");
                } //0排队继续轮询
                else if (result == 0) {
                    setTimeout(function () {
                        getMiaoshaResult(goodsId);
                    }, 50);
                } // 成功返回订单id
                else {
                    layer.confirm("恭喜你,秒杀成功!查看订单?", {btn: ["确定", "取消"]},
                        function () {
                            window.location.href = "/order_detail.htm?orderId=" + result;
                        },
                        function () {
                            layer.closeAll();
                        });
                }
            } else {
                layer.msg(data.msg);
            }
        },
        error: function () {layer.msg("客户端请求有误");}
    });
}

清理redis

redis-cli
flushdb
keys *

测试秒杀ok

优化点 减少预减库存

预减库存code review:

// redis中预减库存
Long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
if(stock < 0){
    return Result.error(CodeMsg.MIAO_SHA_OVER);
}

如果库存本来有10个,有13个请求,库存减少成负的的操作都没必要访问redis。

内存标记,减少redis访问

// 结束标记 <商品ID,是否秒杀结束>
private Map<Long,Boolean> localOverMap = new HashMap<Long, Boolean>();

// 系统初始化 读数据库库存,写到redis
@Override
public void afterPropertiesSet() throws Exception {
    List<GoodVo> goodslist = goodsService.listGoodsVo();
    if(goodslist!=null){
        for(GoodVo goods : goodslist){
            redisService.set(GoodsKey.getMiaoshaGoodsStock,""+goods.getId() ,goods.getStockCount() );
            // 初始化所有商品都没结束
            localOverMap.put(goods.getId(),  false);
        }
    }
}

@Autowired
MiaoshaService miaoshaService;
@RequestMapping(value = "/do_miaosha",method = RequestMethod.POST)
@ResponseBody
public Result<Integer> list(MiaoshaUser user,
                   @RequestParam("goodsId")long goodsId) {
   //...
    // 判断商品结束标记
    Boolean over = localOverMap.get(goodsId);
    if(over){
        return Result.error(CodeMsg.MIAO_SHA_OVER);
    }

    // redis中预减库存
    Long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
    if(stock < 0){
        localOverMap.put(goodsId, true);
        return Result.error(CodeMsg.MIAO_SHA_OVER);
    }
    //...
}

重置操作:

@RequestMapping(value="/reset", method=RequestMethod.GET)
@ResponseBody
public Result<Boolean> reset(Model model) {
    List<GoodVo> goodsList = goodsService.listGoodsVo();
    for(GoodVo goods : goodsList) {
        // 库存还原成10个
        goods.setStockCount(10);
        // redis中库存也变成10个
        redisService.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), 10);
        // 内存变量 所有商品重置成没结束
        localOverMap.put(goods.getId(), false);
    }
    // 删除用户订单和秒杀标记缓存
    redisService.delete(OrderKey.getMiaoshaOrderByUidGid);
    redisService.delete(MiaoshaKey.isGoodsOver);
    miaoshaService.reset(goodsList);
    return Result.success(true);
}

根据前缀删除redis

public boolean delete(KeyPrefix prefix) {
    if(prefix == null) {
        return false;
    }
    List<String> keys = scanKeys(prefix.getPrefix());
    if(keys==null || keys.size() <= 0) {
        return true;
    }
    Jedis jedis = null;
    try {
        jedis = jedisPool.getResource();
        jedis.del(keys.toArray(new String[0]));
        return true;
    } catch (final Exception e) {
        e.printStackTrace();
        return false;
    } finally {
        if(jedis != null) {
            jedis.close();
        }
    }
}
public List<String> scanKeys(String key) {
    Jedis jedis = null;
    try {
        jedis = jedisPool.getResource();
        List<String> keys = new ArrayList<String>();
        String cursor = "0";
        ScanParams sp = new ScanParams();
        sp.match("*"+key+"*");
        sp.count(100);
        do{
            ScanResult<String> ret = jedis.scan(cursor, sp);
            List<String> result = ret.getResult();
            if(result!=null && result.size() > 0){
                keys.addAll(result);
            }
            //再处理cursor
            cursor = ret.getStringCursor();
        }while(!cursor.equals("0"));
        return keys;
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }
}

mysql数据库中删除订单

public void reset(List<GoodVo> goodsList) {
    goodsService.resetStock(goodsList);
    orderService.deleteOrders();
}

dao:

@Delete("delete from order_info")
public void deleteOrders();

@Delete("delete from miaosha_order")
public void deleteMiaoshaOrders();

/miaosha/do_miaosha压测5000个线程10次 一共5w个请求 在服务器上测试jmeter 1.在windows上录好jmx... 2.运行jmeter.sh -n -t xxx.jmx -l result.jtl 3.把result.jtl导入jmeter

压测QPS->2000

nginx 横向扩展(反向代理proxy_pass)配置多台服务器 负载均衡 weight nginx.jpg nginx 缓存

proxy_cache_path /usr/local/nginx/proxy_cache levels=1:2 keys_zone=my_cache:200m inactive=1d max_size=20g;
proxy_ignore_headers x-Accel-Expires Expires Cache-Control;
proxy_hide_header Cache-Control;
proxy_hide_header Pragma;

LVS负载均衡 已经在linux内核里了 浏览器-LVS-n个nginx-nn个tomcat

安全优化

秒杀接口地址隐藏

请求服务端秒杀地址,动态生成的 方法: 秒杀接口带上PathVariable@RequestMapping(value = "/{path}/do_miaosha"

前端秒杀按钮获取地址

<button class="btn btn-primary btn-block" type="button" id="buyButton"onclick="getmiaoshaPath()">立即秒杀</button>

后台path接口: 新建redis key保存随机path,并且设置有效期

private MiaoshaKey( int expireSeconds, String prefix) {
        super(expireSeconds, prefix);
    }
    public static MiaoshaKey isGoodsOver = new MiaoshaKey(0,"go");
    public static MiaoshaKey getMiaoshaPath = new MiaoshaKey(60, "mp");
}

随机生成path,每个用户,每个商品地址不一样 保存到redis

@RequestMapping(value = "/path",method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoShaPath(Model model,MiaoshaUser user,@RequestParam("goodsId")long goodsId){
    model.addAttribute("user",user);
    if(user == null){
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    String path =  miaoshaService.createMiaoshaPath(user,goodsId);
    return Result.success(path);
}

path生成service方法

public String createMiaoshaPath(MiaoshaUser user,long goodsId) {
    String str = MD5Util.md5(UUIDUtil.uuid()+"123456");
    redisService.set(MiaoshaKey.getMiaoshaPath,"" +user.getId()+"_"+goodsId,str );
    return str;
}

秒杀接口添加path变量 添加非法请求key result/CodeMsg.java

public static CodeMsg REQUEST_ILLEGAL = new CodeMsg(500102, "请求非法");
@Autowired
MiaoshaService miaoshaService;
@RequestMapping(value = "/{path}/do_miaosha",method = RequestMethod.POST)
@ResponseBody
public Result<Integer> list(MiaoshaUser user,
                            @RequestParam("goodsId")long goodsId,
                            @PathVariable("path")String path) {
    // 没登陆
    if(user == null) {
        return Result.error(CodeMsg.SESSION_ERROR);
    }

    //验证path
    boolean check = miaoshaService.checkPath(user,goodsId,path);
    if(!check){
        return Result.error(CodeMsg.REQUEST_ILLEGAL);
    }
public boolean checkPath(MiaoshaUser user, long goodsId, String path) {
    if(user == null || path == null){
        return false;
    }
    String pathRec = redisService.get(MiaoshaKey.getMiaoshaPath, "" + user.getId() + "_" + goodsId, String.class);ath
    return path.equals(pathRec);
}

前端的获取path的xhr方法,并且拼接后继续xhr doMiaosha()

function getMiaoshaPath(){
    var goodsId=$("#goodsId").val();
    // 加载中的动画
    g_showLoading();
    $.ajax({
        url: "/miaosha/path",
        type: "GET",
        data: {
            goodsId: goodsId
        },
        success:function (data) {
            if(data.code == 0){
                var path = data.data;
                doMiaosha(path)
            }else {
                layer.msg(data.msg);
            }
        },
        error:function () {layer.msg("客户端请求有误");}
    });
}
function doMiaosha(path){
    $.ajax({
        url:"/miaosha/"+path+"/do_miaosha",
        type:"POST",
        data:{
            goodsId:$("#goodsId").val(),
        },
        success:function(data){
            if(data.code == 0){
                // 成功 排队中 轮询
                getMiaoshaResult($("#goodsId").val());
            }else{
                layer.msg(data.msg);
            }
        },
        error:function(){
            layer.msg("客户端请求有误");
        }
    });
}

完成动态获取秒杀path再秒杀

数学公式验证码

点击秒杀之前先输入验证码 ScriptEngine java中可以使用js v8

前端验证码:

<div class="row">
    <div class="form-inline">
        <img id="verifyCodeImg" width="80" height="32"  style="display:none" onclick="refreshVerifyCode()"/>
        <input id="verifyCode"  class="form-control" style="display:none"/>
        <button class="btn btn-primary" type="button" id="buyButton"onclick="getMiaoshaPath()">立即秒杀</button>
    </div>
</div>

页面初始化渲染render完页面后,在countDown方法里,生成验证码

function countDown(){
    var remainSeconds = $("#remainSeconds").val();
    var timeout;
    if(remainSeconds > 0){//秒杀还没开始,倒计时
        $("#buyButton").attr("disabled", true);
        $("#miaoshaTip").html("秒杀倒计时:"+remainSeconds+"秒");
        timeout = setTimeout(function(){
            $("#countDown").text(remainSeconds - 1);
            $("#remainSeconds").val(remainSeconds - 1);
            countDown();
        },1000);
    }else if(remainSeconds == 0){//秒杀进行中
        $("#buyButton").attr("disabled", false);
        if(timeout){
            clearTimeout(timeout);
        }
        $("#miaoshaTip").html("秒杀进行中");
        $("#verifyCodeImg").attr("src","/miaosha/verifyCode?goodsId="+$("#goodsId").val());
        $("#verifyCodeImg").show();
        $("#verifyCode").show();
    }else{//秒杀已经结束
        $("#buyButton").attr("disabled", true);
        $("#miaoshaTip").html("秒杀已经结束");
        $("#verifyCodeImg").hide();
        $("#verifyCode").hide();
    }
}

后台验证码接口

生成验证码 后台验证码redis key

public static MiaoshaKey getMiaoshaVerifyCode = new MiaoshaKey(300, "vc");

添加秒杀失败msg

public static CodeMsg MIAOSHA_FAIL = new CodeMsg(500502, "秒杀失败");

验证码接口 直接写到output上

@RequestMapping(value = "/verifyCode",method = RequestMethod.GET)
@ResponseBody
public Result<String> getVerifyCode(HttpServletResponse response, MiaoshaUser user, @RequestParam("goodsId")long goodsId){
    if(user == null){
        return Result.error(CodeMsg.SESSION_ERROR);
    }
    BufferedImage image = miaoshaService.createVerifyCode(user,goodsId);
    try{
        OutputStream out = response.getOutputStream();
        ImageIO.write(image, "JPEG", out);
        out.flush();
        out.close();
        return null;
    } catch (Exception e) {
        e.printStackTrace();
        return Result.error(CodeMsg.SESSION_ERROR);
    }
}

service:用Graphics生成图片

public BufferedImage createVerifyCode(MiaoshaUser user, long goodsId) {
    if(user == null || goodsId <=0){
        return null;
    }
    int width = 80;
    int height = 32;
    //create the image
    BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
    Graphics g = image.getGraphics();
    // set the background color
    g.setColor(new Color(0xDCDCDC));
    g.fillRect(0, 0, width, height);
    // draw the border
    g.setColor(Color.black);
    g.drawRect(0, 0, width - 1, height - 1);
    // create a random instance to generate the codes
    Random rdm = new Random();
    // make some confusion
    for (int i = 0; i < 50; i++) {
        int x = rdm.nextInt(width);
        int y = rdm.nextInt(height);
        g.drawOval(x, y, 0, 0);
    }

    // 生成随机验证码 保存到key为用户,商品id用于用户输入的验证
    String verifyCode = generateVerifyCode(rdm);
    g.setColor(new Color(0, 100, 0));
    g.setFont(new Font("Candara", Font.BOLD, 24));
    g.drawString(verifyCode, 8, 24);
    g.dispose();
    //把验证码存到redis中
    int rnd = calc(verifyCode);
    redisService.set(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, rnd);
    //输出图片  
    return image;
}
// 计算表达式的结果
private static int calc(String exp) {
    try{
        ScriptEngineManager manger = new ScriptEngineManager();
        ScriptEngine engine = manger.getEngineByName("JavaScript");
        return (Integer)engine.eval(exp);
    }catch (Exception e){
        e.printStackTrace();
        return 0;
    }
}

private static char[] ops = new char[] {'+', '-', '*'};
// 加减乘的验证码
private String generateVerifyCode(Random rdm) {
    int num1 = rdm.nextInt(10);
    int num2 = rdm.nextInt(10);
    int num3 = rdm.nextInt(10);
    char op1 = ops[rdm.nextInt(3)];
    char op2 = ops[rdm.nextInt(3)];
    String exp = ""+ num1 + op1 + num2 + op2 + num3;
    return exp;
}

已经可以显示了

添加点击事件重新渲染验证码, 注意浏览器图片缓存

<img id="verifyCodeImg" width="80" height="32"  style="display:none" onclick="refreshVerifyCode()"/>
function refreshVerifyCode(){
    $("#verifyCodeImg").attr("src", "/miaosha/verifyCode?goodsId="+$("#goodsId").val()+"&timestamp="+new Date().getTime());
}

miaoshaverficode.jpg

点击秒杀 获取秒杀地址之前校验验证码 修改后台path接口,

@RequestMapping(value = "/path",method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoShaPath(Model model,MiaoshaUser user,
                                     @RequestParam("goodsId")long goodsId,
                                     @RequestParam("verifyCode")int verify){
    model.addAttribute("user",user);
    if(user == null){
        return Result.error(CodeMsg.SESSION_ERROR);
    }

    boolean check = miaoshaService.checkVerifyCode(user,goodsId,verify);
    if(!check){
        return Result.error(CodeMsg.REQUEST_ILLEGAL);
    }
    String path =  miaoshaService.createMiaoshaPath(user,goodsId);
    return Result.success(path);
}

service:

public boolean checkVerifyCode(MiaoshaUser user, long goodsId, int verify) {
    if(user == null || goodsId <=0){
        return false;
    }
    Integer codeOld = redisService.get(MiaoshaKey.getMiaoshaVerifyCode, user.getId() + "," + goodsId, Integer.class);
    if(codeOld == null || codeOld - verify != 0){
        return false;
    }
    // 从redis删除 否则还可以用
    redisService.delete(MiaoshaKey.getMiaoshaVerifyCode, user.getId() + "," + goodsId);
    return true;
}

前端获取path的时候传入 输入的验证码

function getMiaoshaPath(){
    var goodsId=$("#goodsId").val();
    // 加载中的动画
    g_showLoading();
    $.ajax({
        url: "/miaosha/path",
        type: "GET",
        data: {
            goodsId: goodsId,
            verifyCode: $("#verifyCode").val()
        },
        success:function (data) {
            if(data.code == 0){
                var path = data.data;
                doMiaosha(path)
            }else {
                layer.msg(data.msg);
            }
        },
        error:function () {layer.msg("客户端请求有误");}

        });
}

可以删除全部的Model了因为前后端分离了。

接口限流

用缓存的有效期,key是用户访问的地址+用户id 新建限流key

public class AccessKey extends BasePrefix {
    private AccessKey( int expireSeconds, String prefix) {
        super(expireSeconds, prefix);
    }
    // 比枚举好 因为可以new一个动态参数的
    public static AccessKey withExpire(int expireSeconds) {
        return new AccessKey(expireSeconds, "access");
    }
}

request里的 getURI/miaosha/path:the part of this request's URL from the protocol name up to the query string in the first line of the HTTP request. getURLhttps://localhost:8022/miaosha/path:The returned URL contains a protocol, server name, port number, and server path, but it does not include query string parameters.

添加访问太频繁的错误

public static CodeMsg ACCESS_LIMIT_REACHED= new CodeMsg(500104, "访问太频繁!");
@RequestMapping(value = "/path",method = RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoShaPath(HttpServletRequest request,MiaoshaUser user,
                                     @RequestParam("goodsId")long goodsId,
                                     @RequestParam(value = "verifyCode",defaultValue = "0")int verify){
    if(user == null){
        return Result.error(CodeMsg.SESSION_ERROR);
    }

    String uri = request.getRequestURI();
    //限流
    String acKey = uri + "_" + user.getId();
    Integer count = redisService.get(AccessKey.withExpire(5), acKey, Integer.class);
    if(count == null){
        redisService.set(AccessKey.withExpire(5), acKey, 1);
    }else if(count < 5){
        redisService.incr(AccessKey.withExpire(5), acKey);
    }else{
        return Result.error(CodeMsg.ACCESS_LIMIT_REACHED);
    }
    // 验证码
    boolean check = miaoshaService.checkVerifyCode(user,goodsId,verify);
    if(!check){
        return Result.error(CodeMsg.REQUEST_ILLEGAL);
    }
    String path =  miaoshaService.createMiaoshaPath(user,goodsId);
    return Result.success(path);
}

使用拦截器(注解)抽取限流功能(因为不是业务代码)

实现效果:5秒最多访问5次 需要登陆 @AccessLimit(seconds=5, maxCount=5, needLogin=true)

新建access包 新建注解

@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
    int seconds();
    int maxCount();
    boolean needLogin() default true;
}

新建拦截器顺便解析用户保存到线程 并更新之前 ArgumentResolver参数解析器实现的登陆 拦截器比参数解析先执行,一个请求接收到之后是一个线程在执行。 拦截器实现

@Service
public class AccessIntercepter extends HandlerInterceptorAdapter{

    @Autowired
    MiaoshaUserService userService;

    @Autowired
    RedisService redisService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
       if(handler instanceof HandlerMethod){
           // 1. 取用户
           MiaoshaUser user = getUser(request, response);
           // 2. 用户存到threadlocal
           UserContext.setUser(user);
           // 3. 获取注解参数
           HandlerMethod hm = (HandlerMethod)handler;
           AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
           if(accessLimit == null){
               return true;
           }
           int second = accessLimit.seconds();
           int maxcount = accessLimit.maxCount();
           boolean needLogin = accessLimit.needLogin();

           // 限制访问的key
           String key = request.getRequestURI();

           // 如果用户没登陆
           if(needLogin){
               if(user == null){
                   render(response, CodeMsg.SESSION_ERROR);
                   return false;
               }
               // 如果需要登陆 key + 用户id
               key += "_"+user.getId();
           }
           // else key就只有path
           AccessKey ak = AccessKey.withExpire(5);
           Integer count = redisService.get(ak, key, Integer.class);
           if(count == null){
               redisService.set(ak, key, 1);
           }else if(count < maxcount){
               redisService.incr(ak, key);
           }else{
               render(response, CodeMsg.ACCESS_LIMIT_REACHED);
               return false;
           }
       }
       return false;
    }
    private void render(HttpServletResponse response, CodeMsg cm)throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        OutputStream out = response.getOutputStream();
        String str  = JSON.toJSONString(Result.error(cm));
        out.write(str.getBytes("UTF-8"));
        out.flush();
        out.close();
    }
    private MiaoshaUser getUser(HttpServletRequest request, HttpServletResponse response) {
        String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
        String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
        if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
            return null;
        }
        String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
        return userService.getByToken(response, token);
    }

    private String getCookieValue(HttpServletRequest request, String cookiName) {
        Cookie[]  cookies = request.getCookies();
        if(cookies == null || cookies.length <= 0){
            return null;
        }
        for(Cookie cookie : cookies) {
            if(cookie.getName().equals(cookiName)) {
                return cookie.getValue();
            }
        }
        return null;
    }
}

WebConfig中注册拦截器:

@Configuration
public class WebConfig  extends WebMvcConfigurerAdapter {
    
    @Autowired
    UserArgumentResolver userArgumentResolver;

    @Autowired
    AccessIntercepter accessIntercepter;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(accessIntercepter);
    }

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

在拦截器里就解析用户并保存到线程:

public class UserContext {
    private static ThreadLocal<MiaoshaUser> userHolder = new ThreadLocal<MiaoshaUser>();
    public static void setUser(MiaoshaUser user) {
        userHolder.set(user);
    }

    public static MiaoshaUser getUser() {
        return userHolder.get();
    }
}

之前的参数解析器,直接从线程中获取

@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
    @Autowired
    MiaoshaUserService userService;
    
    public boolean supportsParameter(MethodParameter parameter) {
        // 获取参数类型 是User类型才会做resolveArgument
        Class<?> clazz = parameter.getParameterType();
        return clazz==MiaoshaUser.class;
    }

    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return UserContext.getUser();
    }
}

最后注解使用结果

@AccessLimit(seconds = 5,maxCount = 5,needLogin = true)
    @RequestMapping(value = "/path",method = RequestMethod.GET)
    @ResponseBody
    public Result<String> getMiaoShaPath(HttpServletRequest request,
        MiaoshaUser user,
        @RequestParam("goodsId")long goodsId, 
        @RequestParam("verifyCode")int verify){
        if(user == null){
            return Result.error(CodeMsg.SESSION_ERROR);
        }
        String uri = request.getRequestURI();
        System.out.println("uri"+uri);
        System.out.println("url"+request.getRequestURL());
        // 验证码
        boolean check = miaoshaService.checkVerifyCode(user,goodsId,verify);
        if(!check){
            return Result.error(CodeMsg.REQUEST_ILLEGAL);
        }
        String path =  miaoshaService.createMiaoshaPath(user,goodsId);
        return Result.success(path);
    }

About

秒杀下单系统

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published