前言
本篇将完成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,首先会有一个创建对象的过程,也就是对象工厂,这个工厂可以创建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官网,找到相关配置示例,复制过来
先测试两个获取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);
}
可以看到最下面拿到了List
接着测试exportSeckillUrl方法,用于显示秒杀接口地址或者是日期
@Test
public void testExportSeckillUrl() {
long id = 1000;
Exposer exposer = seckillService.exportSeckillUrl(id);
logger.info("exposer={}", exposer);
}
最下面显示了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());
}
}
第一条是之前测试的数据
方法中的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层接口的设计与实现完成了