SSM 框架搭建 sckill 秒杀系统
—— step.3 业务逻辑层建设
一、Service 层简介
Service 层是服务层,也被称为业务逻辑层,是整个系统实现系统所需的业务逻辑功能的核心部分,承担着通过调用 DAO 接口实现数据的操纵与存储、利用 JavaBean 实现对系统所需数据处理并存储页面渲染功能的关键层级。
小贴士: 此处 Service 层的构建方式是使用接口 + 实现类的形式进行构建的。这样做是出于 Java 架构思想中开闭原则(即对拓展开放,对修改关闭)。(就通常而言,这是一种编程习惯,更适用项目的开发。)通过接口 + 实现类的组合,在需要向系统中添加新功能时,无需对原有功能的源码进行修改,只需添加新的抽象方法,并在实现类中进行实现就可以达到目的,并且,由于定义接口的存在,使得代码的重用率大大提高,在移植项目框架时,只需要重新定义方法的实现,而不需要整体替换。从而达到松耦合的效果,使得系统的维护更加便捷。
二、Service 层搭建
如图所示,Service 层的接口及实现类存放与 service 文件夹下,通常,接口抽象方法的实现类放于 service/impl 目录下,以 “对应接口名 + Impl” 的方式命名。
(1) Service 接口定义
由于在该 Seckill 秒杀系统较为简洁。主要的业务逻辑只需要:“1.对所有的秒杀记录进行查询;2.对单个特定秒杀记录进行查询;3.通过时间,控制秒杀的开启;4.执行秒杀操作” 四个核心功能。因此接口中,我们只需要对这四个方法进行定义。定义方式为,在接口 SeckillService 中定义:
public interface SeckillService {
/**
* 查询所有秒杀记录
* @return
*/
List<SeckillTab> getSeckillList();
/**
* 查询单个秒杀记录
* @param seckillId
* @return
*/
SeckillTab getSeckillById(@Param("seckillId") long seckillId);
/**
* 目的:秒杀开启时输出秒杀地址,否则输出系统时间和秒杀时间
* DTO :封装数据传输,
* @param seckillId
*/
Exposer exportSeckillUrl(@Param("seckill") long seckillId);
/**
* 执行秒杀操作
* 内部 md5 比对方式如果不匹配,则用户ID被篡改,拒绝执行秒杀
* 当抛出异常时,应该告诉接口使用方,可能会输出什么样的异常
* SeckillException : 告诉用户秒杀错误
* RepeatKillException : 告诉用户已经秒杀成功
* SeckillCloseException : 告诉用户秒杀已经关闭
* @param seckillId
* @param userPhone
* @param md5
*/
SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException;
}
(2)系统异常的定义
考虑到系统的稳定性。在系统运行时,会由于一些非正常操作,或者缓存、信息传输延时等原因导致的运行期异常。为与 Java 自带的运行期异常进行区分。视频中定义了三种异常的抛出类,分别是: RepeatKillException (重复秒杀异常)、SeckillCloseException (通道关闭后秒杀异常)和 SeckillException (系统运行异常)。在 根目录下定义 exception 目录作为异常类的储存目录,三种异常分别配置如下:
- SeckillException (系统运行异常):
package org.seckill.exception;
/**
* 秒杀相关业务异常
* 所有相关异常的父类,从理论上来讲,所有属于该系统的报错都由该类进行处理,
* 但是这样就失去了进行异常类定义的意义,无法对报错的原因进行精确的定位,
* 因此该类只是作为整个系统运行时的总体异常类。
*/
public class SeckillException extends RuntimeException {
public SeckillException(String message) {
super(message);
}
public SeckillException(String message, Throwable cause) {
super(message, cause);
}
}
- SeckillCloseException (秒杀接口关闭异常):
public class SeckillCloseException extends SeckillException {
public SeckillCloseException(String message) {
super(message);
}
public SeckillCloseException(String message, Throwable cause) {
super(message, cause);
}
}
- RepeatKillException (重复秒杀异常):
package org.seckill.exception;
/**
* 重复秒杀异常(本质上:运行期异常) —— 属于秒杀相关业务异常
* 用户通过重复执行秒杀程序提高秒杀成功率时,当秒杀成功时,产生重复秒杀异常
* 系统能够识别重复秒杀异常并阻止重复秒杀行为
*/
public class RepeatKillException extends SeckillException {
public RepeatKillException(String message) {
super(message);
}
public RepeatKillException(String message, Throwable cause) {
super(message, cause);
}
}
(3)常量判定符 - 数据字典 建设
一个良好的系统在实现 if - else 逻辑分辨时,通常使用 enum (枚举)来定义具有特定意义的 key 键,而非单纯的数字或文字。这样的好处是:
- 通过特定意义的 key 键的定义,使得每个 key 键都有自己的注释,最直观的表现是代码的可读性得到极大的提升;
- 由于系统的功能具有部分重叠与逻辑关联的特性,key 键的定义通常可以得到广泛的使用,当一个位置上的 key 值需要改变时,只需要改动 key 键的定义,就可以完成判定规则的修改。从而大大提高了系统的可维护性;(如果使用的是单纯的数字或文字,当一处 key 被修改后,为保证系统的正常运行,必须对整个系统进行检查,防止同样的定义在系统的相关功能区应用。)
数据字典的定义是建立在系统根目录下的 enums 目录下,如图所示:
枚举型数据字典 SeckillStatEnum 定义如下:
public enum SeckillStatEnum {
SUCCESS(1, "秒杀成功"),
END(0, "秒杀结束"),
REPEAT_KILL(-1, "重复秒杀"),
INNER_ERROR(-2, "系统异常"),
DATA_REWTIRE(-3, "数据篡改");
private int state;
private String stateInfo;
SeckillStatEnum(int state, String stateInfo) {
this.state = state;
this.stateInfo = stateInfo;
}
public int getState() {
return state;
}
public String getStateInfo() {
return stateInfo;
}
// 重定义枚举索引方法,通过 state 索引对应的枚举值 key
public static SeckillStatEnum stateOf(int index){
for (SeckillStatEnum state: values()){
if(state.getState() == index){
return state;
}
}
return null;
}
}
(4)DTO 数据传输对象的构建
DTO 是数据传输对象(Data Transfer Object)的缩写,用于 MVC 设计模式下,充当 Model 的角色(即 JavaBean)。该类定义在根目录下的 dto 文件夹中,如图所示:
在刚开始学习的时候,也是有这么一个疑问,我们从数据库中获得的数据已经被封装在了 entity 类中,在页面渲染时,直接使用 entity 对象数据不是简洁明了么,为什么还要专门开一个空间封装数据传输对象?
但事实上, DTO 的存在并非多此一举。这是由 entity 类自身定义决定的。我们为了操作的方便,通常会在 entity 类中添加一定的数据冗余,而这些冗余在很多时候只是为了处理业务逻辑时能够更加快捷,更多的时候,前端并不需要这些数据,甚至于前端要展示的只是整个数据表的很小一部分数据,如果不加区分的一股脑全部传输给前端,会对数据传输造成很大的负担,而且有数据泄露的风险。
DTO 则是一种针对的是前端的 UI 需求而进行封装的特定封装类型。通过继承和包含 entity 类,添加上一些特定的 key 键,来实现数据对应读取的效果。在减轻数据传输负担的同时,有效防止了后台数据的泄露。
在本 Seckill 秒杀系统中,总共定义了三个封装类型: Exposer (秒杀地址暴露接口 DTO)、SeckillExecution (秒杀操作执行后,反馈数据封装 DTO)、SeckillResult (封装 json 格式数据的反馈类型数据)。
- Exposer 类的定义:
public class Exposer {
private boolean exposed; // 布尔值 : 是否给予用户秒杀地址
private String md5; // 一种加密措施(算法), md5是一种在 Java 中封装好的算法,返回一个唯一的字符串,用以充当验证码的作用
private long seckillId;
private long now; // 系统当前时间(毫秒)
private long start; // 秒杀开启时间
private long end; // 秒杀结束时间
public Exposer() {}
public Exposer(boolean exposed, String md5, long seckillId) {
this.exposed = exposed;
this.md5 = md5;
this.seckillId = seckillId;
}
public Exposer(boolean exposed, long seckillId, long now, long start, long end) {
this.exposed = exposed;
this.seckillId = seckillId;
this.now = now;
this.start = start;
this.end = end;
}
public Exposer(boolean exposed, long seckillId) {
this.exposed = exposed;
this.seckillId = seckillId;
}
public boolean isExposed() {
return exposed;
}
public void setExposed(boolean exposed) {
this.exposed = exposed;
}
public String getMd5() {
return md5;
}
public void setMd5(String md5) {
this.md5 = md5;
}
public long getSeckillId() {
return seckillId;
}
public void setSeckillId(long seckillId) {
this.seckillId = seckillId;
}
public long getNow() {
return now;
}
public void setNow(long now) {
this.now = now;
}
public long getStart() {
return start;
}
public void setStart(long start) {
this.start = start;
}
public long getEnd() {
return end;
}
public void setEnd(long end) {
this.end = end;
}
@Override
public String toString() {
return "Exposer{" +
"exposed=" + exposed +
", md5='" + md5 + '\'' +
", seckillId=" + seckillId +
", now=" + now +
", start=" + start +
", end=" + end +
'}';
}
}
- SeckillExecution 类的定义:
public class SeckillExecution {
private long seckillId;
private int state; // 秒杀状态
private String stateInfo; // 秒杀状态描述
private SucKilled sucKilled; // 秒杀成功对象
public SeckillExecution() {}
public SeckillExecution(long seckillId, SeckillStatEnum seckillStatEnum, SucKilled sucKilled) {
this.seckillId = seckillId;
this.state = seckillStatEnum.getState();
this.stateInfo = seckillStatEnum.getStateInfo();
this.sucKilled = sucKilled;
}
public SeckillExecution(long seckillId, SeckillStatEnum seckillStatEnum) {
this.seckillId = seckillId;
this.state = seckillStatEnum.getState();
this.stateInfo = seckillStatEnum.getStateInfo();
}
public long getSeckillId() {
return seckillId;
}
public void setSeckillId(long seckillId) {
this.seckillId = seckillId;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
public String getStateInfo() {
return stateInfo;
}
public void setStateInfo(String stateInfo) {
this.stateInfo = stateInfo;
}
public SucKilled getSucKilled() {
return sucKilled;
}
public void setSucKilled(SucKilled sucKilled) {
this.sucKilled = sucKilled;
}
@Override
public String toString() {
return "SeckillExecution{" +
"seckillId=" + seckillId +
", state=" + state +
", stateInfo='" + stateInfo + '\'' +
", sucKilled=" + sucKilled +
'}';
}
}
- SeckillResult 类的定义:
// 所有ajax请求返回类型: 封装 json 结果
public class SeckillResult<T> {
private boolean success;
private T data;
private String error;
public SeckillResult(){}
public SeckillResult(boolean success, T data) {
this.success = success;
this.data = data;
}
public SeckillResult(boolean success, String error) {
this.success = success;
this.error = error;
}
public boolean isSuccess() {
return success;
}
public T getData() {
return data;
}
public String getError() {
return error;
}
}
(5)Service 接口的实现
准备工作做完,就是进行 Service 接口方法的实现。Service 接口实现类存放在 service/impl 路径下。以 “对应接口 + Impl” 的形式进行命名。
以下是 SeckillServiceImpl 类的定义和实现:
/**
* Service层实现类,实现Service接口
* 命名习惯: 接口名 + Impl
* spring 提供注解类型:
* --@Component : 代表所有组件,当不知道类属于dao, service,conroller等时使用。统称spring组件实例
* --@Service : 特指服务层代码
* --@Controller : 特指控制区代码
*/
// 在 Service 层,的实现类前 @Service 注释必须写,
// 这是告诉 Spring 将该类中的方法导入到 Spring 管理池中,如果不写,Service 接口将找不到实现类,故而报错
@Service
public class SeckillServiceImpl implements SeckillService {
// 日志的应用
private Logger logger = LoggerFactory.getLogger(this.getClass());
// 注入Service依赖
@Autowired // 自动注入
private SeckillDao seckillDao;
@Autowired
private SuccessKilledDao successKilledDao;
private final String slat = "the@SLAT&confusion_md5#slat"; // 用于混淆 md5
@Override // 注释,表 实现/重写 父类方法
public List<SeckillTab> getSeckillList() {
return seckillDao.queryAll(0, 100);
}
@Override
public SeckillTab getSeckillById(long seckillId) {
return seckillDao.queryById(seckillId);
}
@Override
public Exposer exportSeckillUrl(long seckillId) {
SeckillTab seckillTab = seckillDao.queryById(seckillId);
if(seckillTab == null){
return new Exposer(false, seckillId);
}
Date startTime = seckillTab.getStartTime();
Date endTime = seckillTab.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());
}
// md5 本质: 对任意字符串转换为特定长度的编码 —— 不可逆!!
String md5 = getMD5(seckillId);
return new Exposer(true, md5, seckillId);
}
// 自定义生成 md5 函数
private String getMD5(long seckillId){
String base = seckillId + '@' +slat + '#';
// DigestUtils.md5DigestAsHex() 用于生成 md5 , 参数为二进制(byte),用 .getBytes()得到字符串二进制
String md5 = DigestUtils.md5DigestAsHex(base.getBytes());
return md5;
}
@Override
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException {
try {
if(md5 == null || !md5.equals(getMD5(seckillId))){
throw new SeckillException("seckill info rewrite");
}else{
// 执行秒杀操作
// 操作逻辑: 减库存 + 记录购买行为
Date nowTime = new Date();
// 减库存
int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
if(updateCount <= 0){
// 没有更新记录,秒杀已经结束
throw new SeckillCloseException("seckill was closed");
}else{
// 记录购买行为
int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);
if(insertCount <= 0){ // 重复秒杀,无效,抛出重复秒杀异常
throw new RepeatKillException("seckill repeated");
}else{ // 秒杀成功,打印秒杀记录
SucKilled sucKilled = successKilledDao.queryByIdwithSeckill(seckillId, userPhone);
return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, sucKilled);
}
}
}
}catch(SeckillCloseException es){
throw es; // 抛出“秒杀窗口关闭异常”
}catch(RepeatKillException er){
throw er; // 抛出 “重复秒杀异常”
}catch (Exception e){
logger.error(e.getMessage(), e);
// 将所有编译期异常转化为运行期异常, RuntimeException -> rowback(回滚机制)
throw new SeckillException("seckill inner error:" + e.getMessage());
}
}
}
三、整合 - Spring 框架管理 Service 层
从配置代码数量的角度看,Service 层的 Spring 管理配置是几个层中配置最简单的一个。同样的,在 resources/spring 路径下,创建 spring-service.xml 文件,进行整合配置。配置如下:
<?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/tx
http://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd">
<!-- 配置并使用spring声明式事务 -->
<!-- 扫描 service 包下所有使用注解的类型,初始化该类型并放入spring容器中 -->
<context:component-scan base-package="org.seckill.service" />
<!-- 配置事务管理器, MyBatis 采用jdbc 事务管理器 -->
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<!-- 注入数据库连接池 -->
<property name="dataSource" ref="dataSource" />
</bean>
<!-- 配置基于注解的声明式事务,默认使用注解的方式来管理事务行为 -->
<tx:annotation-driven transaction-manager="transactionManager" />
</beans>
注意:此处需回头检查 Service 实现类前,是否带了 Spring 的@Service
注释,只有带了该注释,Spring 才会将该实现类中的方法纳入 Spring 管理池进行管理。否则,将会在调用时,发生报错,提示方法未纳入管理。(接口不需要写@Service
注释 )
至此,Service 层的创建和配置工作就算告一段落了。
**小贴士:**Service 层所用特殊注释标签及含义
注解名 | 用途解释 |
---|---|
@Component | 通用组件代表所有组件,当不知道该 Java 类具体属于 dao, service, conroller 等时使用。统称 spring 组件实例 |
@Controller | web 页面控制组件,作用于表现层(spring-mvc 的注解) |
@Service | 服务层控制,作用于业务逻辑层 |
@Autowired | 对类成员变量、方法及构造函数进行标注,完成自动装配工作 |
四、测试 Service 层接口功能
和 DAO 层实现完毕一样, Service 层的接口方法也要进行测试操作。测试类生成方法同 DAO 接口层。将光标置于接口名上, alt + enter(回车)
,点击 Create Test ,便可在弹出的窗口中快捷建立 test 文件夹内对应的 JUnit 4 测试类。如图所示:
选中所有要测试的方法,点击 OK ,自动生成 Test 测试类。
测试类代码如下:
package org.seckill.service;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.seckill.dto.Exposer;
import org.seckill.dto.SeckillExecution;
import org.seckill.entity.SeckillTab;
import org.seckill.exception.RepeatKillException;
import org.seckill.exception.SeckillCloseException;
import org.seckill.exception.SeckillException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import java.util.List;
// @RunWith(SpringJUnit4ClassRunner.class) junit 依赖,在junit启动时,加载springIOC容器
@RunWith(SpringJUnit4ClassRunner.class)
// 告诉junit spring 的配置文件,
// 注意:大括号中的字符串配置索引地址要用逗号(",")隔开
// 在 DAO 接口测试时,只用导入 spring-dao.xml 配置,
// 在 Service 接口测试时,不仅要导入 spring-service 配置,还要导入 spring-dao.xml 配置
@ContextConfiguration({
"classpath:spring/spring-dao.xml",
"classpath:spring/spring-service.xml"})
public class SeckillServiceTest {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private SeckillService seckillService;
@Test
public void getSeckillList() {
List<SeckillTab> list = seckillService.getSeckillList();
logger.info("list={}", list);
// Closing non transactional SqlSession ——> 只读操作,不是在事务控制下
}
@Test
public void getSeckillById() {
long id = 1000L;
SeckillTab seckillTab = seckillService.getSeckillById(id);
logger.info("seckill={}", seckillTab);
}
@Test
public void exportSeckillUrl() {
// long id = 1000L;
long id = 1004L;
Exposer exposer = seckillService.exportSeckillUrl(id);
logger.info("exposer={}", exposer);
// 秒杀未开启或已结束(报错): exposer=Exposer{exposed=false, md5='null', seckillId=1000, now=1612421304186, start=1610380800000, end=1610467200000}
// 秒杀开启: exposer=Exposer{exposed=true, md5='da80b6ac68b1c8e104bf3964c6d6b7e3', seckillId=1004, now=0, start=0, end=0}
}
@Test
public void executeSeckill() {
// long id = 1000L;
long id = 1004L;
long phone = 13568957721L;
String md5 = "da80b6ac68b1c8e104bf3964c6d6b7e3";
try {
SeckillExecution execution = seckillService.executeSeckill(id, phone, md5);
logger.info("result={}", execution);
// result=SeckillExecution{seckillId=1004, state=1, stateInfo='秒杀成功', sucKilled=SucKilled [seckillId=1004, userPhone=13568957721, state=0, killTime=Thu Feb 04 15:29:57 CST 2021]}
} catch (RepeatKillException er) {
logger.error(er.getMessage());
} catch (SeckillCloseException es) {
logger.error(es.getMessage());
}
}
// 测试代码完整逻辑,注意代码的可重复执行性
@Test // 整合 exportSeckillUrl() 与 executeSeckill() 测试
public void testSeckillLogic() {
long id = 1004L;
Exposer exposer = seckillService.exportSeckillUrl(id);
long phone = 13568957769L;
if(exposer.isExposed()){
String md5 = exposer.getMd5();
try {
SeckillExecution execution = seckillService.executeSeckill(id, phone, md5);
logger.info("result={}", execution);
// result=SeckillExecution{seckillId=1004, state=1, stateInfo='秒杀成功', sucKilled=SucKilled [seckillId=1004, userPhone=13568957721, state=0, killTime=Thu Feb 04 15:29:57 CST 2021]}
} catch (RepeatKillException er) {
logger.error(er.getMessage());
} catch (SeckillCloseException es) {
logger.error(es.getMessage());
}
}else{
// 秒杀未开启
logger.warn("exposer={}", exposer);
}
}
}
如果测试没有问题,那么恭喜,Service 层的搭建宣告完毕。
报错及解决方法
(1)Service 实现类未纳入 Spring 管理池管理。解决方法就是在 Service 接口的实现类前加@Service
注释。报错共有两处,如图所示:
- Service 接口导入报错(提示不存在要导入的 bean)
- 功能报错,(同样是提示 autowired 依赖导入失败)