Java高併發秒殺API(二)之Service層
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
定義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.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">
<!--掃描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>
存在的坑
- 關於同類中呼叫事務方法的時候有個坑,同學們需要注意下AOP切不到呼叫事務方法。事務不會生效,解決辦法有幾種,可以搜一下,找一下適合自己的方案。本質問題是類內部呼叫時AOP不會用代理呼叫內部方法。
沒有引入AOP的xsd會報錯
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance http://www.springmodules.org/schema/cache/springmodules-cache.xsd http://www.springmodules.org/schema/cache/springmodules-ehcache.xsd"
相關連結
本節結語
至此,關於Java高併發秒殺API的Service層的開發與測試已經完成,接下來進行Web層的開發,詳情請參考下一篇文章。