1. 設計前的分析
分層的必要性
- DAO層工作演變為:接口設計+SQL編寫(不需要其他雜七雜八的功能)
- 代碼和SQL的分離,方便review(瀏覽)
- DAO拼接等邏輯在Service層完成(DAO只需負責SQL語句,其他都由Service層完成)
一些初學者容易出現的錯誤,就是喜歡在DAO層進行邏輯的編寫,其實DAO就是數據訪問的縮寫,它只進行數據的訪問操作。
業務接口的編寫
初學者總是關注細節,關注接口如何去實現,這樣設計出來的接口往往比較冗余。業務接口的編寫要站在“使用者”的角度定義,三個方面:方法定義的粒度、參數、返回值。
- 方法定義粒度:關注接口的功能本身,至于這個功能需要包含哪些步驟那是具體的實現,也就是說,功能明確而且單一。
- 參數:方法所需要的數據,供使用者傳入,明確方法所需要的數據,而且盡可能友好,簡練。
- 返回值:一般情況下,entity數據不夠,需要自定義DTO,也有可能拋出異常,需要自定義異常,不管是DTO還是異常,盡可能將接口調用的信息返回給使用者,哪怕是失敗信息。
DTO與entity的區別
DTO數據傳輸層:用于Web層和Service層之間傳遞的數據封裝。
entity:用于業務數據的封裝,比如數據庫中的數據。
關于秒殺地址的暴露
- 需要有專門一個方法實現秒殺地址輸出,避免人為因素提前知道秒殺地址而出現漏洞。
- 獲取秒殺url時,如果不合法,則返回當前時間和秒殺項目的時間;如果合法,才返回md5加密后url,以避免url被提前獲知。
- 使用md5將url加密、校驗,防止秒殺的url被篡改。
MD5加密
Spring提供了MD5生成工具。代碼如下:
DigestUtils.md5DigestAsHex();
MD5鹽值字符串(salt),用于混淆MD5,添加MD5反編譯難度
2. Service層的接口設計
在src/main/java 包下建立com.lewis.service 包,用來存放Service接口;在src/main/java 包下建立com.lewis.exception 包,用來存放Service層出現的異常類:比如重復秒殺異常、秒殺已關閉異常;在src/main/java 包下建立com.lewis.dto 包,用來封裝Web層和Service層之間傳遞的數據。
定義SeckillService接口
/**
* 業務接口:站在使用者(程序員)的角度設計接口 三個方面:1.方法定義粒度,方法定義的要非常清楚2.參數,要越簡練越好 3.返回類型(return
* 類型一定要友好/或者return異常,我們允許的異常)
*/
public interface SeckillService {
/**
* 查詢全部的秒殺記錄
*
* @return
*/
List<Seckill> getSeckillList();
/**
* 查詢單個秒殺記錄
*
* @param seckillId
* @return
*/
Seckill getById(long seckillId);
// 再往下,是我們最重要的行為的一些接口
/**
* 在秒殺開啟時輸出秒殺接口的地址,否則輸出系統時間和秒殺時間
*
* @param seckillId 秒殺商品Id
* @return 根據對應的狀態返回對應的狀態實體
*/
Exposer exportSeckillUrl(long seckillId);
/**
* 執行秒殺操作,有可能失敗,有可能成功,所以要拋出我們允許的異常
*
* @param seckillId 秒殺的商品ID
* @param userPhone 手機號碼
* @param md5 md5加密值
* @return 根據不同的結果返回不同的實體信息
*/
SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException,
RepeatKillException, SeckillCloseException;
}
在dto包中創建Exposer.java,用于封裝秒殺的地址信息
/**
* 暴露秒殺地址(接口)DTO
*/
public class Exposer {
// 是否開啟秒殺
private boolean exposed;
// 加密措施
private String md5;
//id為seckillId的商品的秒殺地址
private long seckillId;
// 系統當前時間(毫秒)
private long now;
// 秒殺的開啟時間
private long start;
// 秒殺的結束時間
private long end;
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 + '}';
}
}
在dto包中創建SeckillExecution.java,用于封裝秒殺是否成功的結果(該對象用來返回給頁面)
/**
* 封裝執行秒殺后的結果:是否秒殺成功
*/
public class SeckillExecution {
private long seckillId;
//秒殺執行結果的狀態
private int state;
//狀態的明文標識
private String stateInfo;
//當秒殺成功時,需要傳遞秒殺成功的對象回去
private SuccessKilled successKilled;
//秒殺成功返回所有信息
public SeckillExecution(long seckillId, int state, String stateInfo, SuccessKilled successKilled) {
this.seckillId = seckillId;
this.state = state;
this.stateInfo = stateInfo;
this.successKilled = successKilled;
}
//秒殺失敗
public SeckillExecution(long seckillId, int state, String stateInfo) {
this.seckillId = seckillId;
this.state = state;
this.stateInfo = stateInfo;
}
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 SuccessKilled getSuccessKilled() {
return successKilled;
}
public void setSuccessKilled(SuccessKilled successKilled) {
this.successKilled = successKilled;
}
}
在exception包中創建秒殺過程中可能出現的異常類
定義一個基礎的異常類SeckillException,繼承自RuntimeException
/**
* 秒殺相關的所有業務異常
*/
public class SeckillException extends RuntimeException {
public SeckillException(String message) {
super(message);
}
public SeckillException(String message, Throwable cause) {
super(message, cause);
}
}
重復秒殺異常,繼承自SeckillException
/**
* 重復秒殺異常,是一個運行期異常,不需要我們手動try catch
* Mysql只支持運行期異常的回滾操作
*/
public class RepeatKillException extends SeckillException {
public RepeatKillException(String message) {
super(message);
}
public RepeatKillException(String message, Throwable cause) {
super(message, cause);
}
}
秒殺已關閉異常,繼承自SeckillException
/**
* 秒殺關閉異常,當秒殺結束時用戶還要進行秒殺就會出現這個異常
*/
public class SeckillCloseException extends SeckillException{
public SeckillCloseException(String message) {
super(message);
}
public SeckillCloseException(String message, Throwable cause) {
super(message, cause);
}
}
3. Service層接口的實現
在com.lewis.service 包下再建立impl 包,用來存放接口的實現類SeckillServiceImpl
public class SeckillServiceImpl implements SeckillService
{
//日志對象
private Logger logger= LoggerFactory.getLogger(this.getClass());
//加入一個混淆字符串(秒殺接口)的salt,為了我避免用戶猜出我們的md5值,值任意給,越復雜越好
private final String salt="aksehiucka24sf*&%&^^#^%$";
//注入Service依賴
@Autowired //@Resource
private SeckillDao seckillDao;
@Autowired //@Resource
private SuccessKilledDao successKilledDao;
public List<Seckill> getSeckillList() {
return seckillDao.queryAll(0,4);
}
public Seckill getById(long seckillId) {
return seckillDao.queryById(seckillId);
}
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 (startTime.getTime()>nowTime.getTime() || endTime.getTime()<nowTime.getTime())
{
return new Exposer(false,seckillId,nowTime.getTime(),startTime.getTime(),endTime.getTime());
}
//秒殺開啟,返回秒殺商品的id、用給接口加密的md5
String md5=getMD5(seckillId);
return new Exposer(true,md5,seckillId);
}
private String getMD5(long seckillId)
{
String base=seckillId+"/"+salt;
String md5= DigestUtils.md5DigestAsHex(base.getBytes());
return md5;
}
//秒殺是否成功,成功:減庫存,增加明細;失敗:拋出異常,事務回滾
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException {
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,1,"秒殺成功",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());
}
}
}
在以上代碼中,我們捕獲了運行時異常,原因是Spring的事務默認是發生了RuntimeException才會回滾,發生了其他異常不會回滾,所以在最后的catch塊里通過throw new SeckillException("seckill inner error :"+e.getMessage()); 將編譯期異常轉化為運行期異常。
另外,在代碼里還存在著硬編碼的情況,比如秒殺結果返回的state和stateInfo參數信息是輸出給前端的,這些字符串應該考慮用常量枚舉類封裝起來,方便重復利用,也易于維護。
在src/main/java 包下新建一個枚舉包com.lewis.enums 包,在該包下創建一個枚舉類型SeckillStatEnum
public enum SeckillStatEnum {
SUCCESS(1,"秒殺成功"),
END(0,"秒殺結束"),
REPEAT_KILL(-1,"重復秒殺"),
INNER_ERROR(-2,"系統異常"),
DATE_REWRITE(-3,"數據篡改");
private int state;
private String info;
SeckillStatEnum(int state, String info) {
this.state = state;
this.info = info;
}
public int getState() {
return state;
}
public String getInfo() {
return info;
}
public static SeckillStatEnum stateOf(int index) {
for (SeckillStatEnum state : values()) {
if (state.getState()==index) {
return state;
}
}
return null;
}
}
創建了枚舉類型后,就需要修改之前硬編碼的地方,修改SeckillExecution 涉及到state和stateInfo參數的構造方法
//秒殺成功返回所有信息
public SeckillExecution(long seckillId, SeckillStatEnum statEnum, SuccessKilled successKilled) {
this.seckillId = seckillId;
this.state = statEnum.getState();
this.stateInfo = statEnum.getInfo();
this.successKilled = successKilled;
}
//秒殺失敗
public SeckillExecution(long seckillId, SeckillStatEnum statEnum) {
this.seckillId = seckillId;
this.state = statEnum.getState();
this.stateInfo = statEnum.getInfo();
}
接著把SeckillServiceImpl 里返回的秒殺成功信息的return new SeckillExecution(seckillId,1,"秒殺成功",successKilled); 改成return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS,successKilled);
4. 使用Spring進行Service層的配置
在之前創建的spring 包下創建spring-service.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www./schema/beans"
xmlns:xsi="http://www./2001/XMLSchema-instance"
xmlns:context="http://www./schema/context"
xmlns:tx="http://www./schema/tx"
xsi:schemaLocation="http://www./schema/beans
http://www./schema/beans/spring-beans.xsd
http://www./schema/context
http://www./schema/context/spring-context.xsd
http://www./schema/tx
http://www./schema/tx/spring-tx.xsd">
<!--掃描service包下所有使用注解的類型 -->
<context:component-scan base-package="com.lewis.service" />
<!--配置事務管理器 -->
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<!--注入數據庫連接池 -->
<property name="dataSource" ref="dataSource" />
</bean>
<!--配置基于注解的聲明式事務 默認使用注解來管理事務行為 -->
<tx:annotation-driven transaction-manager="transactionManager" />
</beans>
事務管理器
MyBatis采用的是JDBC的事務管理器
Hibernate采用的是Hibernate的事務管理器
通過注解的方式將Service的實現類(注意,不是Service接口)加入到Spring IoC容器中
@Service
public class SeckillServiceImpl implements SeckillService;
在需要進行事務聲明的方法上加上事務的注解@Transactional
@Transactional
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException {}
Spring的聲明式事務管理
Java異常分編譯期異常和運行期異常,運行期異常不需要手工try-catch,Spring的的聲明式事務只接收運行期異常回滾策略,非運行期異常不會幫我們回滾。
Spring一共有7個事務傳播行為,默認的事務傳播行為是PROPAGATION_REQUIRED ,詳情可以參考這篇文章
使用注解控制事務方法的優點(對于秒殺這種對事務延遲要求高的業務場景尤為重要)
- 1.開發團隊達成一致約定,明確標注事務方法的編程風格
- 2.保證事務方法的執行時間盡可能短,不要穿插其他網絡操作RPC/HTTP請求或者剝離到事務方法外部(保證事務方法里面是很干凈的/效率的)
- 3.不是所有的方法都需要事務,如只有一條修改操作、只讀操作不要事務控制(MYSQL 表級鎖、行級鎖)
為什么使用IoC(控制反轉)
- 對象創建統一托管。
- 規范的生命周期管理。
- 靈活的依賴注入。
- 一致的對象獲取方式。
Spring基于注解的事務操作
- 在Spring早期版本中是使用ProxyFactoryBean+XMl方式來配置事務。
- 在Spring配置文件使用tx:advice+aop命名空間,好處就是一次配置永久生效,你無須去關心中間出的問題,不過出錯了你很難找出來在哪里出了問題。
- 注解@Transactional的方式,注解可以在方法定義、接口定義、類定義、public方法上,但是不能注解在private、final、static等方法上,因為Spring的事務管理默認是使用Cglib動態代理的:
- private方法因為訪問權限限制,無法被子類覆蓋
- final方法無法被子類覆蓋
- static是類級別的方法,無法被子類覆蓋
- protected方法可以被子類覆蓋,因此可以被動態字節碼增強
不能被Spring AOP事務增強的方法
序號 |
動態代理策略 |
不能被事務增強的方法 |
1 |
基于接口的動態代理 |
除了public以外的所有方法,并且public static的方法也不能被增強 |
2 |
基于Cglib的動態代理 |
private、static、final的方法 |
關于Spring的組件注解、注入注解
- @Component:標識一個組件,當不知道是什么組件,或者該組件不好歸類時使用該注解
- @Service:標識業務層組件
- @Repository:標識DAO層組件
- @Controller:標識控制層組件
通過Spring提供的組件自動掃描機制,可以在類路徑下尋找標注了上述注解的類,并把這些類納入進spring容器中管理,這些注解的作用和在xml文件中使用bean節點配置組件時一樣的。
<context:component-scan base-package=”xxx.xxx.xxx”>
component-scan 標簽默認情況下自動掃描指定路徑下的包(含所有子包),將帶有@Component、@Repository、@Service、@Controller標簽的類自動注冊到spring容器。getBean的默認名稱是類名(頭字母小寫),如果想自定義,可以@Service(“aaaaa”)這樣來指定。這種bean默認是“singleton”的,如果想改變,可以使用@Scope(“prototype”)來改變。
當使用<context:component-scan/> 后,就可以將<context:annotation-config/> 移除了,前者包含了后者。
另外,@Resource,@Inject 是J2EE規范的一些注解
@Autowired是Spring的注解,可以對類成員變量、方法及構造函數進行標注,完成自動裝配的工作。通過 @Autowired的使用來消除setter/getter方法,默認按類型裝配,如果想使用名稱裝配可以結合@Qualifier注解進行使用,如下:
@Autowired() @Qualifier("baseDao")
private BaseDao baseDao;
與@Autowired類似的是@Resource,@Resource屬于J2EE規范,默認安照名稱進行裝配,名稱可以通過name屬性進行指定,如果沒有指定name屬性,當注解寫在字段上時,默認取字段名進行按照名稱查找,如果注解寫在setter方法上默認取屬性名進行裝配。當找不到與名稱匹配的bean時才按照類型進行裝配。但是需要注意的是,如果name屬性一旦指定,就只會按照名稱進行裝配。
@Resource(name="baseDao")
private BaseDao baseDao;
而@Inject與@Autowired類似,也是根據類型注入,也可以通過@Named注解來按照name注入,此時只會按照名稱進行裝配。
@Inject @Named("baseDao")
private BaseDao baseDao;
5. 進行Service層的集成測試
使用logback來輸出日志信息,在resources 包下創建logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT" />
</root>
</configuration>
通過IDE工具快速生成Junit單元測試,然后在各個方法里寫測試代碼。
@RunWith(SpringJUnit4ClassRunner.class)
//告訴junit spring的配置文件
@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 testGetSeckillList() throws Exception {
List<Seckill> list=seckillService.getSeckillList();
logger.info("list={}", list);
}
@Test
public void testGetById() throws Exception {
long seckillId=1000;
Seckill seckill=seckillService.getById(seckillId);
logger.info("seckill={}", seckill);
}
}
在測試通過了這兩個方法后,開始對后兩個業務邏輯方法的測試,首先測試testExportSeckillUrl()
@Test
public void testExportSeckillUrl() throws Exception {
long seckillId=1000;
Exposer exposer=seckillService.exportSeckillUrl(seckillId);
logger.info("exposer={}", exposer);
}
會發現沒有返回商品的秒殺地址,因為我們數據庫的秒殺時間和結束秒殺時間沒有修改,所以判斷當前商品的秒殺已結束。將數據庫中的秒殺時間和結束秒殺時間修改成滿足我們當前的時間的范圍,重新測試該方法,可以獲取到該商品的秒殺地址。而第四個方法的測試需要使用到該地址(md5),將該值傳入到testExecuteSeckill() 中進行測試:
@Test
public void testExecuteSeckill() throws Exception {
long seckillId=1000;
long userPhone=13476191876L;
String md5="70b9564762568e9ff29a4a949f8f6de4";
SeckillExecution execution=seckillService.executeSeckill(seckillId,userPhone,md5);
logger.info("result={}", execution);
}
需要注意的是,該方法是會產生異常的,比如我們重復運行該方法,會報錯,因為用戶進行了重復秒殺,所以我們需要手動try-catch,將程序允許的異常包起來而不去向上拋給junit,更改測試代碼如下:
@Test
public void testExecuteSeckill() throws Exception {
long seckillId=1000;
long userPhone=13476191876L;
String md5="70b9564762568e9ff29a4a949f8f6de4";
try {
SeckillExecution execution = seckillService.executeSeckill(seckillId, userPhone, md5);
logger.info("result={}", execution);
}catch (RepeatKillException e)
{
logger.error(e.getMessage());
}catch (SeckillCloseException e1)
{
logger.error(e1.getMessage());
}
}
在測試過程中,第四個方法使用到了第三個方法返回的秒殺地址,在實際開發中,我們需要將第三個和第四個方法合并成一個完整邏輯的方法:
//集成測試代碼完整邏輯,注意可重復執行
@Test
public void testSeckillLogic() throws Exception {
long seckillId=1000;
Exposer exposer=seckillService.exportSeckillUrl(seckillId);
if (exposer.isExposed())
{
logger.info("exposer={}", exposer);
long userPhone=13476191876L;
String md5=exposer.getMd5();
try {
SeckillExecution execution = seckillService.executeSeckill(seckillId, userPhone, md5);
logger.info("result={}", execution);
}catch (RepeatKillException e)
{
logger.error(e.getMessage());
}catch (SeckillCloseException e1)
{
logger.error(e1.getMessage());
}
}else {
//秒殺未開啟
logger.warn("exposer={}", exposer);
}
}
我們可以在SeckillServiceTest類里面加上@Transational注解,原因是:
@Transactional注解是表明此測試類的事務啟用,這樣所有的測試方案都會自動的 rollback,即不用自己清除自己所做的任何對數據庫的變更了。
日志無法打印的問題
在pom.xml中加上
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.1.9</version>
</dependency>
存在的坑
相關鏈接
Spring事務異常回滾,捕獲異常不拋出就不會回滾
本節結語
至此,關于Java高并發秒殺API的Service層的開發與測試已經完成,接下來進行Web層的開發,詳情請參考下一篇文章。
上一篇文章:Java高并發秒殺API(一)之業務分析與DAO層
下一篇文章:Java高并發秒殺API(三)之Web層
|