高并发秒杀API(三)

作者: MOVE1925 | 来源:发表于2017-01-10 23:34 被阅读248次

    前言

    本篇将完成Service层的设计与开发,包括:

    • 秒杀业务接口设计与实现
    • 基于Spring托管Service实现类
    • 配置Spring声明式事务
    • Service层集成测试

    一、秒杀业务接口设计

    在org.seckill包下创建Service层需要的包:

    • service:存放Service接口和实现类
    • exception:存放Service接口所需要的一些自定义异常,比如重复秒杀、秒杀关闭等
    • dto:数据传输层,和entity有些类似,都是存放表示数据的一些类型,entity关注的是业务上的一些封装,dto关注的是WEB和Service之间的数据传递

    接着在service包下新建一个SeckillService接口

    首先要做一个列表页的话,就要拿到Seckill的所有属性

        /**
         * 查询所有秒杀商品记录
         * @return
         */
        List<Seckill> getSeckillList();
        
        /**
         * 查询单个秒杀商品记录
         * @param seckillId
         * @return
         */
        Seckill getById(long seckillId);
    

    这是基本的查询方法,接下来是一些行为的方法

        /**
         * 秒杀开启时输出秒杀接口地址
         * 否则输出系统时间和秒杀时间
         * @param seckillId
         * @return
         */
        Exposer exportSeckillUrl(long seckillId);
    

    在秒杀活动开始之前,要保证没有人知道秒杀接口的地址,不能让用户通过url规则拼出来秒杀活动的地址,所以这个方法在秒杀活动开启时输出秒杀接口地址,否则输出系统时间和秒杀时间

    那么这个方法返回的类型是什么呢?活动期间,这个方法返回的是秒杀接口地址url,不在活动期间,返回的是一些日期,都是和业务不相关的一些数据,所以我们需要一个dto

    在dto包下新建一个Exposer类,用来暴露秒杀接口,或者输出日期

    /**
     * 暴露秒杀地址DTO
     * @author Fzero
     *
     */
    public class Exposer {
        
        //是否开启秒杀
        private boolean exposed;
        
        //加密措施
        private String md5;
        
        //id
        private long seckillId;
        
        //系统当前时间(毫秒)
        private long now;
        
        //秒杀开启时间
        private long start;
        
        //秒杀结束时间
        private long end;
    
        @Override
        public String toString() {
            return "Exposer [exposed=" + exposed + 
                    ", md5=" + md5 + 
                    ", seckillId=" + seckillId + 
                    ", now=" + now + 
                    ", start=" + start + 
                    ", end=" + end + 
                    "]";
        }
    }
    

    首先是设置一个开关exposerd,判断秒杀接口是否要暴露,对于暴露的接口,还要对接口进行一下改动,加上简单的md5,还有上面说的系统时间和秒杀时间,复写toString方法,使得日志输出的时候方便获取属性值,并生成get和set方法

    为了方便初始化,需要设置几个构造方法

        public Exposer(boolean exposed, String md5, long seckillId) {
            super();
            this.exposed = exposed;
            this.md5 = md5;
            this.seckillId = seckillId;
        }
    

    如果在秒杀活动期内,存储MD5,如果不在活动期内,MD5的值为空

        public Exposer(boolean exposed, long seckillId, long now, long start, long end) {
            super();
            this.exposed = exposed;
            this.seckillId = seckillId;
            this.now = now;
            this.start = start;
            this.end = end;
        }
    

    如果不在秒杀活动期,返回系统时间和秒杀活动开启、关闭时间

        public Exposer(boolean exposed, long seckillId) {
            super();
            this.exposed = exposed;
            this.seckillId = seckillId;
        }
    

    是否开启秒杀,返回seckillId

    Exposer类完成,返回SeckillService接口

    接下来就是执行秒杀操作的方法

        /**
         * 执行秒杀操作
         * @param seckillId
         * @param userPhone
         * @param md5
         * @return
         */
        SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
            throws SeckillException,RepeatKillException,SeckillCloseException;
    

    之前说过,唯一确定一个用户的方法是seckillId和phone,seckillId加上自定义的盐值,通过MD5加密,一定程度上能够防止用户通过url规则拼出秒杀接口地址,因为Exposer方法先被调用,里面有MD5的值,传递进来的MD5的值要和内部生成的一个MD5规则进行比较,如果不吻合,说明用户的url被篡改了,不执行秒杀操作,返回的类型还要再封装一个DTO接口,用来封装秒杀执行后的结果,那么秒杀执行后的结果需要什么数据呢?秒杀成功了还是失败了?如果失败了,还要知道为什么失败了

    在dto包下新建一个SeckillExecution类

    public class SeckillExecution {
        
        private long seckillId;
        
        //秒杀结果执行后的状态
        private int state;
        
        //状态信息
        private String stateInfo;
    
        //秒杀成功对象
        private SuccessKilled successKilled;
        
        @Override
        public String toString() {
            return "SeckillExecution [seckillId=" + 
                    "" + seckillId + 
                    ", state=" + state + 
                    ", stateInfo=" + stateInfo+ 
                    ", successKilled=" + successKilled + 
                    "]";
        }
    }
    

    这里需要实例化SuccessKilled对象,如果秒杀成功,要能获得成功秒杀商品的用户信息,接着直接生成get和set方法,还要初始化几个构造方法

        public SeckillExecution(long seckillId, SeckillStateEnum stateEnum, SuccessKilled successKilled) {
            super();
            this.seckillId = seckillId;
            this.state = stateEnum.getState();
            this.stateInfo = stateEnum.getStateString();
            this.successKilled = successKilled;
        }
    

    秒杀成功后,返回所有的属性值

    在数据库success_killed表中,有state作为状态标识,不同的数字表示不同的状态,在接下来的代码中,也经常用到这些状态标识,但是这些状态标识和状态信息,应该是输出给前端的,这个是属于数据字典中的,没必要在代码中频繁出现,像这种常量,建议使用枚举表示,所以在org.seckill包下新建一个枚举包enums,新建一个枚举类型的文件SeckillStateEnum

    public enum SeckillStateEnum {
    
        SUCCESS(1, "秒杀成功"),
        END(0, "秒杀结束"),
        REPEAT_KILL(-1, "重复秒杀"),
        INNER_ERROR(-2, "系统异常"),
        DATA_REWRITE(-2, "数据篡改");
        
        private int state;
        
        private String stateString;
    
        private SeckillStateEnum(int state, String stateString) {
            this.state = state;
            this.stateString = stateString;
        }
    }
    

    初始化一个构造方法,并生成get方法

    接着创建一个方法

        public static SeckillStateEnum stateOf(int index){
            for(SeckillStateEnum state : values()){
                if(state.getState() == index){
                    return state;
                }
            }
            return null;
        }
    

    返回state状态标识,可以通过状态标识获得相应的状态信息,本项目的数据字典已经完成了

    在dto包下的SeckillExecution类还要再初始化一个构造方法

        public SeckillExecution(long seckillId, SeckillStateEnum stateEnum) {
            super();
            this.seckillId = seckillId;
            this.state = stateEnum.getState();
            this.stateInfo = stateEnum.getStateString();
        }
    

    秒杀失败后返回相应的状态信息

    这时还有个小问题,就是当执行异常的时候,要告知接口使用方这个方法可能会输出什么样的异常,因为跟业务相关的异常可以利用起来

    在存放异常的exception包下新建一个SeckillException类,异常一般是作为通用的接口或类,解决某一层面的问题

    /**
     * 秒杀业务相关异常
     * @author Fzero
     *
     */
    public class SeckillException extends RuntimeException {
    
        public SeckillException(String message, Throwable cause) {
            super(message, cause);
            // TODO Auto-generated constructor stub
        }
    
        public SeckillException(String message) {
            super(message);
            // TODO Auto-generated constructor stub
        }
    
    }
    

    继承与RuntimeException,初始化两个构造方法

    这个异常本质上是一个运行期异常,运行期异常不需要手动的try/catch,使用运行期异常还有个重要的原因,就是** Spring的声明式事务,它只接收运行期异常回滚策略,也就是当抛出一个非运行期异常,Spring的声明式事务不会帮我们做回滚的 **

    接着可以定义一些业务中比较常见的异常,可以单独捕捉或处理

    新建一个RepeatKillException类,从名字可以看出,处理的是重复秒杀的异常

    /**
     * 重复秒杀异常(运行期异常)
     * @author Fzero
     *
     */
    public class RepeatKillException extends SeckillException {
    
        public RepeatKillException(String message, Throwable cause) {
            super(message, cause);
            // TODO Auto-generated constructor stub
        }
    
        public RepeatKillException(String message) {
            super(message);
            // TODO Auto-generated constructor stub
        }
    
    }
    

    再新建一个SeckillCloseException类,秒杀关闭异常

    /**
     * 秒杀关闭异常
     * @author Fzero
     *
     */
    public class SeckillCloseException extends SeckillException {
    
        public SeckillCloseException(String message, Throwable cause) {
            super(message, cause);
            // TODO Auto-generated constructor stub
        }
    
        public SeckillCloseException(String message) {
            super(message);
            // TODO Auto-generated constructor stub
        }
    
    }
    

    秒杀活动关闭后,秒杀的执行不能被允许,比如时间到了,或者库存消耗光了

    二、秒杀接口实现

    在service包下新建一个impl包,用于存放实现Service接口的实现类,新建一个SeckillService接口的实现类SeckillServiceImpl,然后复写SeckillService的四个方法

    public class SeckillServiceImpl implements SeckillService {
        
        private Logger logger = LoggerFactory.getLogger(this.getClass());
        
        private SeckillDao seckillDao;
    
        private SuccessKilledDao successKilledDao;
        
        //MD5盐值字符串,用户混淆MD5
        private final String slat = "sldjflsjdfwiejfwjefi$&**(&*&";
    
        public List<Seckill> getSeckillList() {
            return seckillDao.queryAll(0, 4);
        }
    
        public Seckill getById(long seckillId) {
            return seckillDao.queryById(seckillId);
        }
    }
    

    这里要用DAO中的接口,把之前设计的两个接口实例化并生成get方法,但不初始化,因为所有DAO接口的实现类都在Spring容器当中,然后是实例化日志对象,导入org.slf4j包,接着修改复写的方法

        public Exposer exportSeckillUrl(long seckillId) {
            Seckill seckill = seckillDao.queryById(seckillId);
            if(seckill == null){
                return new Exposer(false, seckillId);
            }
            Date startTime = seckill.getStartTime();
            Date endTime = seckill.getEndTime();
            Date nowTime = new Date();//系统当前时间
            if(nowTime.getTime() < startTime.getTime() || nowTime.getTime() > endTime.getTime()){
                return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime());
            }
            String md5 = getMD5(seckillId);
            return new Exposer(true, md5, seckillId);
        }
    

    这个方法就是用于显示秒杀接口的地址,首先要进行一个判断,如果seckill为空,就返回一个Exposer,使用Exposer中的构造方法,如果不为空,但是不在秒杀活动期内,就返回秒杀活动的开始时间、结束时间和系统时间,最后既然都判断完了,时间在秒杀活动内,就要返回秒杀开启的一些信息,通过返回的MD5才能与用户输入的地址是否正确

    为了不让用户猜到返回值,前面已经定义了一个用于混淆的值slat,越复杂越好,然后创建一个方法,生成MD5

        private String getMD5(long seckillId){
            String base = seckillId + "/" + slat;
            String md5 = DigestUtils.md5DigestAsHex(base.getBytes());
            return md5;
        }
    

    如果只使用id进行MD5加密的话,实际上用户是可以通过算法跑出来的,如果加上混淆的字符串,几乎是不可能的

    最后要完成的是执行秒杀方法的实现

        public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) {
            if(md5 == null || !md5.equals(getMD5(seckillId))){
                throw new SeckillException("seckill data rewrite");
            }
            //执行秒杀逻辑:减库存 + 记录购买行为
            Date nowTime = new Date();
            try {
                //减库存
                int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
                if(updateCount <= 0){
                    //没有更新记录,即秒杀活动结束
                    throw new SeckillCloseException("seckill is closed");
                } else {
                    //记录购买行为
                    int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);
                    if(insertCount <= 0){
                        //重复秒杀
                        throw new RepeatKillException("seckill repeated");
                    } else {
                        //秒杀成功
                        SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);
                        return new SeckillExecution(seckillId, SeckillStateEnum.SUCCESS, successKilled);
                    }
                }
            } catch(SeckillCloseException e1) {
                throw e1;
            } catch(RepeatKillException e2){
                throw e2;
            } catch (Exception e) {
                logger.error(e.getMessage(), e);
                //所有编译期异常转化为运行期异常
                throw new SeckillException("seckill inner error: " + e.getMessage());
            }
        }
    

    首先要判断用户传递过来的MD5是否和我们设置的MD5的值是否相同,不相同或是空的话,抛出一个SeckillException异常

    成功秒杀后,就要执行秒杀逻辑,即减库存+记录购买行为,以系统当前时间为秒杀时间,reduceNumber方法还能判断秒杀时间是否在活动时间内,如果返回的值小于等于0,说明没有更新记录,即秒杀活动结束了

    成功减库存后,就要记录购买行为了,同样进行判断,如果返回值小于等于0 说明重复秒杀,insert语句不执行,如果秒杀成功,返回SuccessKilled对象

    最后,整个逻辑要try/catch,因为可能会有其他的异常,比如超时、链接断开等,并抛出业务异常,因为SeckillException继承自RuntimeException,所以这里就把编译期异常,转化为运行期异常,这样Spring的声明式事务可以帮我们做回滚操作,因为之前方法中会可能有指定的异常,比如重复秒杀,秒杀关闭等,所以在SeckillException父类异常之前catch住,然后抛出

    至此,Service层接口的实现类完成了

    三、基于Spring管理Service依赖

    管理Service,本质上是通过Spring IOC功能,即依赖注入


    Spring IOC

    对于Spring IOC,首先会有一个创建对象的过程,也就是对象工厂,这个工厂可以创建SeckillService的实现

    而SeckillService也需要众多依赖,比如DAO层的依赖,所以Spring IOC不但帮我们创建SeckillDAO和SuccessKilledDAO的实现,还要创建Service层的实现,并且把这些实现组成依赖,最终提供一个一直的访问接口,通过这个接口,我们可以访问工厂当中任意的实例

    业务对象依赖

    SeckillService依赖DAO层的两个接口,DAO的接口依赖于SqlSessionFactory,而SqlSessionFactory也需要数据源等一些依赖,整个这个初始化过程,都是由Spring自动组装好,最后给我们的是一个SeckillService,一个完整的实例

    在resources/spring包下新建一个spring-service.xml,可以把spring-dao.xml中beans标签中的内容复制过来,当然,在这基础场在添加三行内容,关于tx的命名空间

    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:context="http://www.springframework.org/schema/context"
        xmlns:tx="http://www.springframework.org/schema/tx"
        xsi:schemaLocation="
            http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans.xsd
            http://www.springframework.org/schema/context
            http://www.springframework.org/schema/context/spring-context.xsd
            http://www.springframework.org/schema/tx
            http://www.springframework.org/schema/tx/spring-tx.xsd">
    <beans>
    

    首先配置的是扫描service包下所有使用注解的类型

        <!-- 扫描service包下所有使用注解的依赖 -->
        <context:component-scan base-package="org.seckill.service"/>
    

    扫描后会初始化这些类型,然后放到Spring容器当中,如果这些类型中有其他的依赖,比如SeckillServiceImpl类中有SeckillDao和SuccessKilledDao的依赖,这个配置也会初始化这些依赖

    那么是哪些注解?首先是@Component,代表所有的组件,当不清楚这个类或者属性是属于DAO或者是Service的时候,可以使用这个,当然还有@Service、@Dao、@Conroller等

    对于SeckillServiceImpl,很明显,在类上添加@Service注解

    @Service
    public class SeckillServiceImpl implements SeckillService
    

    同样,在SeckillServiceImpl方法中,对于seckillDao属性和successKillDao属性

        //注入Service依赖
        @Autowired
        private SeckillDao seckillDao;
        
        @Autowired
        private SuccessKilledDao successKilledDao;
    

    MyBatis和Spring整合之后,MyBatis下所有的DAO接口都会使用mapper的方式初始化好,然后放到Spring容器当中,要在Spring容器中获取DAO的实例,并注入到Service中,可以使用Spring提供的注解@Autowired,使用之后,它就会在Spring容器中查找SeckillDao的实例,因为之前MyBatis的mapper已经初始化好并放在Spring容器中,所以就会注入到Service的属性中,不需要手动的new一个

    四、Spring声明式事务

    声明式事务基本流程

    声明式事务的基本流程,先是开启事务,然后修改SQL语句,最后再提交或回滚事务

    由于** Spring只有在抛出运行期异常(RuntimeException)时才会执行回滚操作 **,所以在代码中要小心使用try/catch,当异常被捕捉到的时候,Spring接收不到异常,就不会执行回滚操作

    打开spring-service.xml,开始配置Spring声明式事务

        <!-- 配置事务管理器 -->
        <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
            <!-- 注入数据库连接池 -->
            <property name="dataSource" ref="dataSource"></property>
        </bean>
    

    只要用到事务,首先配置的就是事务管理器,MyBatis默认的是jdbc的事务管理器,接着注入数据库连接池

    然后配置基于注解的声明式事务,这个配置的作用就是默认使用注解来管理事务行为

        <!-- 配置基于注解的声明式事务:默认使用注解来管理事务行为 -->
        <tx:annotation-driven transaction-manager="transactionManager"/>
    

    打开SeckillServiceImpl类,在executeSeckill方法上添加@Transactional注解

        @Transactional
        public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
    

    不是所有的方法都要在事务的控制之下,比如只读,所以只在会对数据库进行更新操作的方法上添加Spring的声明式事务注解,这样就可以保证该方法运行在事务的控制之下,防止数据被错误的修改

    五、Service层集成测试

    对SeckillService类生成相应的测试类,选中所有的方法,同样在类上加上@RunWith和@ContextConfiguration注解

    @RunWith(SpringJUnit4ClassRunner.class)
    @ContextConfiguration({
        "classpath:spring/spring-dao.xml",
        "classpath:spring/spring-service.xml"
    })
    public class SeckillServiceTest
    

    然后通过Spring依赖注入的方式,将测试类的属性注入到当前测试类下,在测试之前,先把日志的输出定义一下,导入org.slf4j包

        private final Logger logger = LoggerFactory.getLogger(this.getClass());
        
        @Autowired
        private SeckillService seckillService;
    

    不过slf4j只是接口,要实现日志需要logback,在resources目录下新建一个logback.xml文件,然后打开logback官网,找到相关配置示例,复制过来

    logback官网

    先测试两个获取Seckill数据的方法

        @Test
        public void testGetSeckillList() {
            List<Seckill> list = seckillService.getSeckillList();
            logger.info("list={}", list);
        }
    
        @Test
        public void testGetById() {
            long id = 1000;
            Seckill seckill = seckillService.getById(id);
            logger.info("seckill={}", seckill);
        }
    
    getSeckillList方法测试结果

    可以看到最下面拿到了List

    getById方法测试结果

    接着测试exportSeckillUrl方法,用于显示秒杀接口地址或者是日期

        @Test
        public void testExportSeckillUrl() {
            long id = 1000;
            Exposer exposer = seckillService.exportSeckillUrl(id);
            logger.info("exposer={}", exposer);
        }
    
    exportSeckillUrl方法测试结果

    最下面显示了MD5的值,如果返回的MD5为null,说明不在秒杀活动期内,想看到MD5的话,在控制台进入MySQL,把seckill表中的start_time/end_time改一下

    最后是executeSeckill方法

        @Test
        public void testExecuteSeckill() {
            long id = 1000;
            long userPhone = 13587456321L;
            String md5 = "f540e8a6bf6016cea1d929ca265dfed3";
            try {
                SeckillExecution execution = seckillService.executeSeckill(id, userPhone, md5);
                logger.info("result={}", execution);
            } catch (RepeatKillException e) {
                logger.error(e.getMessage());
            } catch (SeckillCloseException e) {
                logger.error(e.getMessage());
            }
        }
    
    executeSeckill方法测试结果

    第一条是之前测试的数据

    方法中的MD5的值是上个方法测试后日志输出时的数据

    当然,最后两个方法可以放在一个测试方法中,免得要手动复制MD5

        @Test
        public void testSeckillLogic() {
            long id = 1001;
            Exposer exposer = seckillService.exportSeckillUrl(id);
            if(exposer.isExposed()){
                logger.info("exposer={}", exposer);
                long userPhone = 13587456321L;
                String md5 = exposer.getMd5();
                try {
                    SeckillExecution execution = seckillService.executeSeckill(id, userPhone, md5);
                    logger.info("result={}", execution);
                } catch (RepeatKillException e) {
                    logger.error(e.getMessage());
                } catch (SeckillCloseException e) {
                    logger.error(e.getMessage());
                }
            } else {
                //秒杀未开启
                logger.warn("exposer={}", exposer);
            }
    

    这里的try/catch其实是为了保证测试类的通过,因为像重复秒杀异常(RepeatKillException)、秒杀关闭异常(SeckillCloseException)都是自定义的异常,这些异常报错,也说明测试通过了,所以为了统一,把这些用于测试的异常也catch住并打印

    至此,Service层接口的设计与实现完成了

    相关文章

      网友评论

        本文标题:高并发秒杀API(三)

        本文链接:https://www.haomeiwen.com/subject/uywkbttx.html