1. 程式人生 > 其它 >Java秒殺系統二:Service層

Java秒殺系統二:Service層

本文為Java高併發秒殺API之Service層課程筆記。

編輯器:IDEA

java版本:java8

前文:秒殺系統環境搭建與DAO層設計

秒殺業務介面與實現

DAO層:介面設計、SQL編寫

Service:業務,DAO拼接等邏輯

程式碼和SQL分離,方便review。

service介面設計

目錄如下:

首先是SecKillService介面的設計:

/**
 * 業務介面:站在使用者角度設計介面
 * 三個方面:
 * 方法定義粒度 - 方便呼叫
 * 引數 - 簡練直接
 * 返回型別 - return(型別、異常)
 */
public interface SecKillService {
    /**
     * 查詢所有秒殺記錄
     * @return
     */
    List<seckill> getSecKillList();

    SecKill getById(long seckillId);

    /**
     * 秒殺開啟時,輸出秒殺介面地址
     * 否則輸出系統時間和秒殺時間
     * @param seckillId
     */
    Exposer exportSecKillUrl(long seckillId);

    // 執行秒殺操作驗證MD5秒殺地址,拋三個異常(有繼承關係)
    // 是為了更精確的丟擲異常
    SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
        throws SeckillException, RepeatKillException,SeckillCloseException;

}

exportSecKillUrl函式用來暴露出介面的地址,用一個專門的dto類Exposer來實現:

/**
 * 暴露秒殺地址DTO
 */
public class Exposer {
    // 是否開啟秒殺
    private boolean exposed;
    // 加密措施
    private String md5;

    private long seckillId;
    // 系統當前時間
    private long now;

    // 秒殺開啟結束時間
    private long start;
    private long end;
    // constructor getter setter
}

這裡有一個md5,是為了構造秒殺地址,防止提前猜出秒殺地址,執行作弊手段。

MD5是一個安全的雜湊演算法,輸入兩個不同的明文不會得到相同的輸出值,根據輸出值,不能得到原始的明文,即其過程不可逆;所以要解密MD5沒有現成的演算法,只能用窮舉法。

executeSeckill函式表示執行秒殺,應該返回執行的結果相關資訊:

/**
 * 封裝秒殺執行後結果
 */
public class SeckillExecution {
    private long seckillId;
    // 秒殺結果狀態
    private int state;

    // 狀態資訊
    private String stateInfo;

    // 秒殺成功物件
    private SuccessKilled successKilled;
    // constructor getter setter
}

執行過程中可能會丟擲異常。這裡面用到了幾個異常:SeckillException, RepeatKillException, SeckillCloseException

SeckillException.java,這個是其他兩個的父類,除了那兩個精確的異常,都可以返回這個異常。

// 秒殺相關業務異常
public class SeckillException extends RuntimeException {
    public SeckillException(String message) {
        super(message);
    }

    public SeckillException(String message, Throwable cause) {
        super(message, cause);
    }
}

RepeatKillException是重複秒殺異常,一個使用者一件商品只能秒殺一次:

// 重複秒殺異常,執行期異常
public class RepeatKillException extends SeckillException {
    public RepeatKillException(String message) {
        super(message);
    }

    public RepeatKillException(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);
    }
}

service介面實現

首先開啟掃描。

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" xsi:schemalocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
    <!--掃描service包下使用註解的型別-->
    <context:component-scan base-package="cn.orzlinux.service">

</context:component-scan></beans>

SecKillServiceImpl實現類實現了SecKillService介面的方法:指定日誌物件,DAO物件。

@Service
public class SecKillServiceImpl implements SecKillService {
    // 使用指定類初始化日誌物件,在日誌輸出的時候,可以打印出日誌資訊所在類
    private Logger logger = LoggerFactory.getLogger(this.getClass());

    // 需要兩個 dao的配合
    // 注入service依賴
    @Resource
    private SecKillDao secKillDao;
    @Resource
    private SuccessKilledDao successKilledDao;
    
    //...
}

查詢比較簡單,直接呼叫DAO方法:

@Override
public List<seckill> getSecKillList() {
    // 這裡因為只有四條秒殺商品
    return secKillDao.queryAll(0,4);
}

@Override
public SecKill getById(long seckillId) {
    return secKillDao.queryById(seckillId);
}

暴露秒殺介面函式:

/**
 * 秒殺開啟時,輸出秒殺介面地址
 * 否則輸出系統時間和秒殺時間
 *
 * @param seckillId
 */
@Override
public Exposer exportSecKillUrl(long seckillId) {
    SecKill secKill = secKillDao.queryById(seckillId);
    if(secKill == null) {
        // 查不到id,false
        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);
}

getMD5是一個自定義函式:

// 加入鹽、混淆效果,如瞎打一下:
private final String slat="lf,ad.ga.dfgm;adrktpqerml[fasedfa]";
private String getMD5(long seckillId) {
    String base = seckillId+"/orzlinux.cn/"+slat;
    // spring已有實現
    String md5 = DigestUtils.md5DigestAsHex(base.getBytes(StandardCharsets.UTF_8));
    return md5;
}

如果直接對密碼進行雜湊,那麼黑客可以對通過獲得這個密碼雜湊值,然後通過查雜湊值字典(例如MD5密碼破解網站),得到某使用者的密碼。加Salt可以一定程度上解決這一問題。所謂加Salt方法,就是加點”佐料”。其基本想法是這樣的:當用戶首次提供密碼時(通常是註冊時),由系統自動往這個密碼裡撒一些“佐料”,然後再雜湊。而當用戶登入時,系統為使用者提供的程式碼撒上同樣的“佐料”,然後雜湊,再比較雜湊值,已確定密碼是否正確。這裡的“佐料”被稱作“Salt值”,這個值是由系統隨機生成的,並且只有系統知道。

執行秒殺函式,這裡面牽扯到編譯異常和執行時異常。

異常

編譯時異常:編譯成位元組碼過程中可能出現的異常。

執行時異常:將位元組碼載入到記憶體、執行類時出現的異常。

異常體系結構:

 * java.lang.Throwable
 * 		|-----java.lang.Error:一般不編寫針對性的程式碼進行處理。
 * 		|-----java.lang.Exception:可以進行異常的處理
 * 			|------編譯時異常(checked)
 * 					|-----IOException
 * 						|-----FileNotFoundException
 * 					|-----ClassNotFoundException
 * 			|------執行時異常(unchecked,RuntimeException)
 * 					|-----NullPointerException
 * 					|-----ArrayIndexOutOfBoundsException
 * 					|-----ClassCastException
 * 					|-----NumberFormatException
 * 					|-----InputMismatchException
 * 					|-----ArithmeticException

使用try-catch-finally處理編譯時異常,是得程式在編譯時就不再報錯,但是執行時仍可能報錯。相當於我們使用try-catch-finally將一個編譯時可能出現的異常,延遲到執行時出現。開發中,由於執行時異常比較常見,所以我們通常就不針對執行時異常編寫try-catch-finally了。針對於編譯時異常,我們說一定要考慮異常的處理。

executeSeckill.java

@Override
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws
        SeckillException, RepeatKillException, SeckillException {
    if(md5==null || !md5.equals(getMD5(seckillId))) {
        // 秒殺的資料被重寫修改了
        throw new SeckillException("seckill data rewrite");
    }
    // 執行秒殺邏輯:減庫存、加記錄購買行為
    Date nowTime = new Date();
    // 減庫存
    int updateCount = secKillDao.reduceNumber(seckillId,nowTime);
    try {
        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);
                return new SeckillExecution(seckillId, SecKillStatEnum.SUCCESS,successKilled);
            }
        }
    } catch (SeckillCloseException | RepeatKillException e1){
        throw e1;
    } catch (Exception e) {
        logger.error(e.getMessage(),e);
        // 所有編譯期異常轉化為執行期異常,這樣spring才能回滾
        throw new SeckillException("seckill inner error"+e.getMessage());
    }
}

SeckillCloseExceptionRepeatKillException都繼承了執行時異常,所以這些操作把異常都轉化為了執行時異常。這樣spring才能回滾。資料庫的修改才不會紊亂。

這裡有一個操作就是列舉的使用。

//return new SeckillExecution(seckillId,1,"秒殺成功",successKilled);
return new SeckillExecution(seckillId, SecKillStatEnum.SUCCESS,successKilled);

用第一行的方式割裂了狀態和狀態資訊,很不優雅,而且後續要更改的話,這些程式碼分散在各個程式碼中,不易修改,所以用列舉代替。

package cn.orzlinux.enums;

/**
 * 使用列舉表述常量資料欄位
 */
public enum SecKillStatEnum {

    SUCCESS(1,"秒殺成功"),
    END(0,"秒殺結束"),
    REPEAT_KILL(-1,"重複秒殺"),
    INNER_ERROR(-2,"系統異常"),
    DATA_REWRITE(-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;
    }

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

宣告式事務

spring早期使用方式(2.0):ProxyFactoryBean + XML

後來:tx:advice+aop名稱空間,一次配置永久生效。

註解@Transactional,註解控制。(推薦)

支援事務方法巢狀。

何時回滾事務?丟擲執行期異常,小心try/catch

具體配置:

在spring-service.xml新增:

<!--配置事務管理器-->
<bean id="transationManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <!--注入資料庫連線池-->
    <property name="dataSource" ref="dataSource">
</property></bean>

<!--配置基於註解的宣告式事務-->
<!--預設使用註解管理事務行為-->
<tx:annotation-driven transaction-manager="transationManager">

在SecKillServiceImpl.java檔案添加註解

@Override
@Transactional
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) 		throws SeckillException, RepeatKillException, SeckillException
...

使用註解控制事務方法的優點

  • 開發團隊達成一致約定,明確標註事務方法的程式設計風格
  • 保證事務方法的執行時間儘可能短,不要穿插其它網路操作,要剝離到事務外部
  • 不是所有的方法都需要事務

整合測試

在resource資料夾下新建logback.xml,日誌的配置檔案:

<!--?xml version="1.0" encoding="UTF-8"?-->
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>[%d{yyyy-MM-dd' 'HH:mm:ss.sss}] [%C] [%t] [%L] [%-5p] %m%n</pattern>
        </layout>
    </appender>

    <root level="DEBUG">
        <appender-ref ref="STDOUT">
    </appender-ref></root>
</configuration>

SecKillServiceTest.java

@RunWith(SpringJUnit4ClassRunner.class)
@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<seckill> list = secKillService.getSecKillList();;
        logger.info("list={}",list);
        // 輸出資訊: [main] [89] [DEBUG] JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@5443d039] will not be managed by Spring
        //[DEBUG] ==>  Preparing: select seckill_id,name,number,start_time,end_time,create_time from seckill order by create_time DESC limit 0,?;
        //[DEBUG] ==> Parameters: 4(Integer)
        //[DEBUG] <==      Total: 4
        //[DEBUG]
        // ------------------Closing non transactional SqlSession ---------------------
        // [org.apache.ibatis.session.defaults.DefaultSqlSession@66c61024]
        //[INFO ] list=[SecKill{seckillId=1000, name='1000秒殺iphone13', number=99, startTime=Tue Oct 05 02:00:00 CST 2021, endTime=Wed Oct 06 02:00:00 CST 2021, createTime=Tue Oct 05 10:01:47 CST 2021}, SecKill{seckillId=1001, name='500秒殺iphone12', number=200, startTime=Tue Oct 05 02:00:00 CST 2021, endTime=Wed Oct 06 02:00:00 CST 2021, createTime=Tue Oct 05 10:01:47 CST 2021}, SecKill{seckillId=1002, name='300秒殺iphone11', number=300, startTime=Tue Oct 05 02:00:00 CST 2021, endTime=Wed Oct 06 02:00:00 CST 2021, createTime=Tue Oct 05 10:01:47 CST 2021}, SecKill{seckillId=1003, name='100秒殺iphone6', number=400, startTime=Tue Oct 05 02:00:00 CST 2021, endTime=Wed Oct 06 02:00:00 CST 2021, createTime=Tue Oct 05 10:01:47 CST 2021}]
    }

    @Test
    public void getById() {
        long id = 1000;
        SecKill secKill = secKillService.getById(id);
        logger.info("seckill={}",secKill);
        // seckill=SecKill{seckillId=1000, name='1000秒殺iphone13', n...}
    }

    @Test
    public void exportSecKillUrl() {
        long id = 1000;
        Exposer exposer = secKillService.exportSecKillUrl(id);
        logger.info("exposer={}",exposer);
        //exposer=Exposer{exposed=true, md5='c78a6784f8e8012796c934dbb3f76c03',
        //          seckillId=1000, now=0, start=0, end=0}
        // 表示在秒殺時間範圍內
    }

    @Test
    public void executeSeckill() {
        long id = 1000;
        long phone = 10134256781L;
        String md5 = "c78a6784f8e8012796c934dbb3f76c03";

        // 重複測試會丟擲異常,junit會認為測試失敗,要把異常捕獲一下更好看
        try {
            SeckillExecution seckillExecution = secKillService.executeSeckill(id,phone,md5);
            logger.info("result: {}",seckillExecution);
        } catch (RepeatKillException | SeckillCloseException e) {
            logger.error(e.getMessage());
            // 再執行一次: [ERROR] seckill repeated
        }


        // 有事務記錄
        // Committing JDBC transaction on Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@4b40f651]
        //[2021-10-05 16:45:00.000] [org.springframework.jdbc.datasource.DataSourceTransactionManager] [main] [384] [DEBUG] Releasing JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@4b40f651] after transaction
        //result: SeckillExecution{seckillId=1000, state=1, stateInfo='秒殺成功', successKilled=SuccessKilled{seckillId=1000, userPhone=10134256781, state=0, createTime=Wed Oct 06 00:45:00 CST 2021}}
    }

    // 整合測試完整邏輯實現
    @Test
    public void testSeckillLogic() {
        long id = 1001;
        Exposer exposer = secKillService.exportSecKillUrl(id);
        if(exposer.isExposed()) {
            logger.info("exposer={}",exposer);

            long phone = 10134256781L;
            String md5 = exposer.getMd5();

            // 重複測試會丟擲異常,junit會認為測試失敗,要把異常捕獲一下更好看
            try {
                SeckillExecution seckillExecution = secKillService.executeSeckill(id,phone,md5);
                logger.info("result: {}",seckillExecution);
            } catch (RepeatKillException | SeckillCloseException e) {
                logger.error(e.getMessage());
                // 再執行一次: [ERROR] seckill repeated
            }

        } else {
            // 秒殺未開啟
            logger.warn("exposer={}",exposer);
        }
    }
}

本文同步釋出於orzlinux.cn