在本系列的上一篇文章中,我們講到了Java事務處理的基本問題,並且講到了Service層和DAO層,在本篇文章中,我們將以BankService為例學習一個事務處理失敗的案例。
請通過以下方式下載github源代碼:
git clone https://github.com/davenkin/java_transaction_workshop.git
BankService的功能為:某個用戶有兩個賬戶,分別為銀行賬戶和保險賬戶,並且有各自的賬戶號,BankService的transfer方法從該用戶的銀行賬戶向保險賬戶轉帳,兩個DAO分別用於對兩個賬戶表的存取操作。
定義一個BankService接口如下:
package davenkin; public interface BankService { public void transfer(int fromId, int toId, int amount); }
在兩個DAO對象中,我們通過傳入的同一個DataSource獲得Connection,然后通過JDBC提供的API直接對數據庫進行操作。
定義操作銀行賬戶表的DAO類如下:
package davenkin.step1_failure; import javax.sql.DataSource; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class FailureBankDao { private DataSource dataSource; public FailureBankDao(DataSource dataSource) { this.dataSource = dataSource; } public void withdraw(int bankId, int amount) throws SQLException { Connection connection = dataSource.getConnection(); PreparedStatement selectStatement = connection.prepareStatement("SELECT BANK_AMOUNT FROM BANK_ACCOUNT WHERE BANK_ID = ?"); selectStatement.setInt(1, bankId); ResultSet resultSet = selectStatement.executeQuery(); resultSet.next(); int previousAmount = resultSet.getInt(1); resultSet.close(); selectStatement.close(); int newAmount = previousAmount - amount; PreparedStatement updateStatement = connection.prepareStatement("UPDATE BANK_ACCOUNT SET BANK_AMOUNT = ? WHERE BANK_ID = ?"); updateStatement.setInt(1, newAmount); updateStatement.setInt(2, bankId); updateStatement.execute(); updateStatement.close(); connection.close(); } }
FailureBankDao的withdraw方法,從銀行賬戶表(BANK_ACCOUNT)中帳號為bankId的用戶賬戶中取出數量為amount的金額。
采用同樣的方法,定義保險賬戶的DAO類如下:
package davenkin.step1_failure; import javax.sql.DataSource; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class FailureInsuranceDao { private DataSource dataSource; public FailureInsuranceDao(DataSource dataSource){ this.dataSource = dataSource; } public void deposit(int insuranceId, int amount) throws SQLException { Connection connection = dataSource.getConnection(); PreparedStatement selectStatement = connection.prepareStatement("SELECT INSURANCE_AMOUNT FROM INSURANCE_ACCOUNT WHERE INSURANCE_ID = ?"); selectStatement.setInt(1, insuranceId); ResultSet resultSet = selectStatement.executeQuery(); resultSet.next(); int previousAmount = resultSet.getInt(1); resultSet.close(); selectStatement.close(); int newAmount = previousAmount + amount; PreparedStatement updateStatement = connection.prepareStatement("UPDATE INSURANCE_ACCOUNT SET INSURANCE_AMOUNT = ? WHERE INSURANCE_ID = ?"); updateStatement.setInt(1, newAmount); updateStatement.setInt(2, insuranceId); updateStatement.execute(); updateStatement.close(); connection.close(); } }
FailureInsuranceDao類的deposit方法向保險賬戶表(INSURANCE_ACCOUNT)存入amount數量的金額,這樣在BankService中,我們可以先調用FailureBankDao的withdraw方法取出一定金額的存款,再調用FailureInsuranceDao的deposit方法將該筆存款存入保險賬戶表中,一切看似OK,實現BankService接口如下:
package davenkin.step1_failure; import davenkin.BankService; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; public class FailureBankService implements BankService{ private FailureBankDao failureBankDao; private FailureInsuranceDao failureInsuranceDao; private DataSource dataSource; public FailureBankService(DataSource dataSource) { this.dataSource = dataSource; } public void transfer(int fromId, int toId, int amount) { Connection connection = null; try { connection = dataSource.getConnection(); connection.setAutoCommit(false); failureBankDao.withdraw(fromId, amount); failureInsuranceDao.deposit(toId, amount); connection.commit(); } catch (Exception e) { try { assert connection != null; connection.rollback(); } catch (SQLException e1) { e1.printStackTrace(); } } finally { try { assert connection != null; connection.close(); } catch (SQLException e) { e.printStackTrace(); } } } public void setFailureBankDao(FailureBankDao failureBankDao) { this.failureBankDao = failureBankDao; } public void setFailureInsuranceDao(FailureInsuranceDao failureInsuranceDao) { this.failureInsuranceDao = failureInsuranceDao; } }
在FailureBankService的transfer方法中,我們首先通過DataSource獲得Connection,然后調用connection.setAutoCommit(false)已開啟手動提交模式,如果一切順利,則commit,如果出現異常,則rollback。 接下來,開始測試我們的BankService吧。
為了准備測試數據,我們定義個BankFixture類,該類負責在每次測試之前准備測試數據,分別向銀行賬戶(1111)和保險賬戶(2222)中均存入1000元。BankFixture還提供了兩個helper方法(getBankAmount和getInsuranceAmount)幫助我們從數據庫中取出數據以做數據驗證。我們使用HSQL數據庫的in-memory模式,這樣不用啟動數據庫server,方便測試。BankFixture類定義如下:
package davenkin; import org.junit.Before; import javax.sql.DataSource; import java.sql.*; public class BankFixture { protected final DataSource dataSource = DataSourceFactory.createDataSource(); @Before public void setUp() throws SQLException { Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement(); statement.execute("DROP TABLE BANK_ACCOUNT IF EXISTS"); statement.execute("DROP TABLE INSURANCE_ACCOUNT IF EXISTS"); statement.execute("CREATE TABLE BANK_ACCOUNT (\n" +
"BANK_ID INT,\n" +
"BANK_AMOUNT INT,\n" +
"PRIMARY KEY(BANK_ID)\n" +
");"); statement.execute("CREATE TABLE INSURANCE_ACCOUNT (\n" +
"INSURANCE_ID INT,\n" +
"INSURANCE_AMOUNT INT,\n" +
"PRIMARY KEY(INSURANCE_ID)\n" +
");"); statement.execute("INSERT INTO BANK_ACCOUNT VALUES (1111, 1000);"); statement.execute("INSERT INTO INSURANCE_ACCOUNT VALUES (2222, 1000);"); statement.close(); connection.close(); } protected int getBankAmount(int bankId) throws SQLException { Connection connection = dataSource.getConnection(); PreparedStatement selectStatement = connection.prepareStatement("SELECT BANK_AMOUNT FROM BANK_ACCOUNT WHERE BANK_ID = ?"); selectStatement.setInt(1, bankId); ResultSet resultSet = selectStatement.executeQuery(); resultSet.next(); int amount = resultSet.getInt(1); resultSet.close(); selectStatement.close(); connection.close(); return amount; } protected int getInsuranceAmount(int insuranceId) throws SQLException { Connection connection = dataSource.getConnection(); PreparedStatement selectStatement = connection.prepareStatement("SELECT INSURANCE_AMOUNT FROM INSURANCE_ACCOUNT WHERE INSURANCE_ID = ?"); selectStatement.setInt(1, insuranceId); ResultSet resultSet = selectStatement.executeQuery(); resultSet.next(); int amount = resultSet.getInt(1); resultSet.close(); selectStatement.close(); connection.close(); return amount; } }
編寫的Junit測試繼承自BankFixture類,測試代碼如下:
package davenkin.step1_failure; import davenkin.BankFixture; import org.junit.Test; import java.sql.SQLException; import static junit.framework.Assert.assertEquals; public class FailureBankServiceTest extends BankFixture { @Test public void transferSuccess() throws SQLException { FailureBankDao failureBankDao = new FailureBankDao(dataSource); FailureInsuranceDao failureInsuranceDao = new FailureInsuranceDao(dataSource); FailureBankService bankService = new FailureBankService(dataSource); bankService.setFailureBankDao(failureBankDao); bankService.setFailureInsuranceDao(failureInsuranceDao); bankService.transfer(1111, 2222, 200); assertEquals(800, getBankAmount(1111)); assertEquals(1200, getInsuranceAmount(2222)); } @Test public void transferFailure() throws SQLException { FailureBankDao failureBankDao = new FailureBankDao(dataSource); FailureInsuranceDao failureInsuranceDao = new FailureInsuranceDao(dataSource); FailureBankService bankService = new FailureBankService(dataSource); bankService.setFailureBankDao(failureBankDao); bankService.setFailureInsuranceDao(failureInsuranceDao); int toNonExistId = 3333; bankService.transfer(1111, toNonExistId, 200); assertEquals(1000, getInsuranceAmount(2222)); assertEquals(1000, getBankAmount(1111)); } }
運行測試,第一個測試(transferSuccess)成功,第二個測試(transferFailure)失敗。
分析錯誤,原因在於:我們分別從FailureBankService,FailureBankDao和FailureInsuranceDao中調用了三次dataSource.getConnection(),亦即我們創建了三個不同的Connection對象,而Java事務是作用於Connection之上的,所以從在三個地方我們開啟了三個不同的事務,而不是同一個事務。
第一個測試之所以成功,是因為在此過程中沒有任何異常發生。雖然在FailureBankService中將Connection的提交模式改為了手動提交,但是由於兩個DAO使用的是各自的Connection對象,所以兩個DAO中的Connection依然為默認的自動提交模式。
在第二個測試中,我們給出一個不存在的保險賬戶id(toNonExistId),就是為了使程序產生異常,然后在assertion語句中驗證兩張表均沒有任何變化,但是測試在第二個assertion語句處出錯。發生異常時,銀行賬戶中的金額已經減少,而雖然程序發生了rollback,但是調用的是FailureBankService中Connection的rollback,而不是FailureInsuranceDao中Connection的,對保險賬戶的操作根本就沒有執行,所以保險賬戶中依然為1000,而銀行賬戶卻變為了800。
因此,為了使兩個DAO在同一個事務中,我們應該在整個事務處理過程中使用一個Connection對象,在下一篇文章中,我們將講到通過共享Connection對象的方式達到事務處理的目的。