2015年8月2日 星期日

Spring的注解(Annotation)事務(Transaction)範例與JUnit測試(Spring+Hibernate+JUnit)

此篇文以一個簡單完整的範例,來說明在Spring中如何使用注解(Annotation)事務(Transaction)與其要注意的事項,並且以JUnit來測試

首先我們要下載 aopalliance.jar ,不然在執行事務時可能會發生ClassNotFoundException。可以到The Central Repository中搜尋最新版的jar,這是因為aopalliance.jar已經從NetBeans內建的Spring中分離了出來,要自已手動下載並引入至Library。如下圖:

在這裡,我們想要設計的構想其專案配置如下:
詳述如下:

  1. DaoClass.java:操控資料庫的CRUD(新增、查詢、修改、刪除)之Class。
  2. Guest.java:代表資料庫中的資料表之ORM(Object-Relational Mapping) 物件。
  3. ServiceClass.java:使用DaoClass進行事務(Transaction)處理的Class。
  4. DaoTest.java:用來測試DaoClass的測試JUnit。
  5. ServiceTest:用來測試ServiceClass的測試JUnit。
在這裡,Guest代表的Table設計如下圖所示(在此例中Table中不用儲任何紀錄,這裡只是做個示範):


首先是在applicationContext.xml中配置要給Spring托管的內容,如連線池、SessionFactory、Annotation的支持設定、自動裝配Bean等等:


applicationContext.xml:
<?xml version='1.0' encoding='UTF-8' ?>
<!-- was: <?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:p="http://www.springframework.org/schema/p"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"       
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
       http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.0.xsd
       http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.0.xsd
       http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd
       http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-4.0.xsd">
  
    <!-- 設定dataSource連接池 -->
    <bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close"> 
        <property name="driverClass" value="com.mysql.jdbc.Driver" />
        <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/guest_database?zeroDateTimeBehavior=convertToNull" />
        <property name="user" value="user" />
        <property name="password" value="pass" />
        <property name="maxPoolSize" value="20" />
        <property name="minPoolSize" value="5" />
        <property name="maxStatements" value="50" />
        <property name="idleConnectionTestPeriod" value="300" />
    </bean> 
    
    <!-- 設定sessionFactory -->
    <bean id="sessionFactory" class="org.springframework.orm.hibernate4.LocalSessionFactoryBean" destroy-method="destroy"> 
        <!-- 指定數據源,此處是C3P0連接池 -->
        <property name="dataSource" ref="dataSource" />
        <!-- 指定ORM物件關聯表映射檔的設定檔位置 -->
        <property name="mappingResources"> 
            <list> 
                <value>OrmPackage/Guest.hbm.xml</value> 
            </list> 
        </property> 
        <!-- 捨棄原hibernate.cfg.xml檔或
        覆蓋原hibernate.cfg.xml檔的部份設定 -->
        <property name="hibernateProperties"> 
            <props> 
                <prop key="hibernate.dialect">org.hibernate.dialect.MySQLDialect</prop> 
                <prop key="hibernate.show_sql">true</prop>
                <!-- 不要用 <prop key="hibernate.current_session_context_class">thread</prop> -->
                <prop key="hibernate.current_session_context_class">org.springframework.orm.hibernate4.SpringSessionContext</prop>                
                <prop key="hibernate.query.factory_class">org.hibernate.hql.internal.ast.ASTQueryTranslatorFactory</prop>
            </props> 
        </property> 
    </bean>
    
    <!-- 設定交易管理員transactionManager -->
    <bean name="transactionManager" class="org.springframework.orm.hibernate4.HibernateTransactionManager">
        <property name="sessionFactory" ref="sessionFactory" />
    </bean>

    <!-- 對base-package下及其子資料夾偵測並自動裝配Bean -->
    <context:component-scan base-package="DaoPackage,ServicePackage" />
    <!-- 要使用 @Transactional 時需要 -->
    <tx:annotation-driven />   
</beans>

在這裡要注意到
<prop key="hibernate.current_session_context_class">org.springframework.orm.hibernate4.SpringSessionContext</prop>
不要設成
<prop key="hibernate.current_session_context_class">thread</prop>
否則事務會沒有辦法正常運作,原因是當把事務交由Spring托管時,事務就不是以thread的方式運作了,而是以Spring自已的上下文方式運作。

接下來我們來進行DaoClass的實作,內容只是簡單的新增、修改、刪除、查詢 :

DaoClass:
package DaoPackage;

import OrmPackage.Guest;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.Criteria;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.criterion.Restrictions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

@Repository
public class DaoClass {
    //自動裝配SessionFactory
    @Autowired
    private SessionFactory sessionFactory;
    
    private Session currentSession() {
        return sessionFactory.getCurrentSession();
    }
    
    //新增儲存
    public void save(Guest guest){
        currentSession().save(guest);
    }
    
    //修改
    public void update(Guest guest){
        currentSession().update(guest);
    }
    
    //刪除
    public void delete(Guest guest){
        currentSession().delete(guest);
    }
    
    //查詢
    public Guest findById(String id){
        Guest guest;
        guest = (Guest) currentSession().get(Guest.class, id);
        
        //在使用Spring的JUnit中其Transaction自動Rollback時,理應不會對資料庫造成影響,
        //但如果再Update、Delete等再使用Criteria的查詢方式的話,則就會對資料庫造成影響,
        //不知道為什麼
        //Criteria criteria = currentSession().createCriteria(Guest.class);
        //criteria.add(Restrictions.eq("id", id));
        //guest = (Guest) criteria.uniqueResult();
        //guestList = currentSession().createCriteria(Guest.class).add(Restrictions.eq("id", id)).list();
        
        return guest;
    }
}

在這邊findById()的實作沒有使用Criteria的方式,是因為它似乎會影響使用Srping管理JUnit和Transaction時,讓自動Rollback的不影響資料庫效果無效,即是說如果在JUnit裡先使用DaoClass.update()等會改變資料庫裡內容的方法,在用Criteria查詢並使用list()的話,資料庫的內容就會真得被影響,而正常來說Spring應該會在Test完後自動Rollback,不會影響資料庫原來內容才對,原因不明,如果有誰知道原因的話,還望不吝賜較。

接下來我們來設計用來測試DaoClass.java的JUnit項目,DaoTest.java:

DaoTest.java:

import DaoPackage.DaoClass;
import OrmPackage.Guest;
import java.util.Calendar;
import java.util.Date;
import org.hibernate.SessionFactory;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import static org.junit.Assert.*;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "file:web/WEB-INF/applicationContext.xml")
@Transactional(propagation = Propagation.REQUIRED)       //使用Spring管理交易
//Spring會在每一個測試後自動Rollback,即是說,測試完最後不會影響到資料庫
//不過如果在update等更新後用Criteria方式查詢的話,就會影響到資料庫,即是說Srping的
//自動Rollback失效,不知道為什麼
public class DaoTest {

    @Autowired
    //要測試的Class
    private DaoClass daoClass;

    @Autowired
    //使用SessionFactory來在測試中使用其他的CRUD功能,
    //例如在要測試DaoClass.update()的Test中,除了DaoClass.update(),
    //不要用到DaoClass.findById()等其他method
    private SessionFactory sessionFactory;

    //測試要用的參數
    String id;
    String name;
    int age;
    Date birthDate;

    public DaoTest() {
        //設置初始參數
        id = "123";
        name = "Tom";
        age = 55;

        Calendar birthDateCalendar = Calendar.getInstance();
        birthDateCalendar.set(2005, Calendar.JANUARY, 5);
        birthDate = birthDateCalendar.getTime();
    }

    @Test
    public void saveTest() {
        //模擬儲存
        Guest guestToBeSave = new Guest(id, name, age, birthDate);
        daoClass.save(guestToBeSave);

        //取出剛儲存進去的Guest
        Guest guestToBeChecked = (Guest) sessionFactory.getCurrentSession().get(Guest.class, id);
        //是否取出的Guest之各項值等於剛儲存的Guest
        assertEquals(id, guestToBeChecked.getId());
        assertEquals(name, guestToBeChecked.getName());
        assertEquals(age, guestToBeChecked.getAge());
        assertEquals(birthDate, guestToBeChecked.getBirthDate());
    }

    @Test
    public void updateTest() {
        String updatedName = "345";
        //先存一個等下要update的Guest
        sessionFactory.getCurrentSession().save(new Guest(id, name, age, birthDate));
        //儲完後先再取出來
        Guest guest = (Guest) sessionFactory.getCurrentSession().get(Guest.class, id);        
        //測試是否是剛儲存的那一個Guest
        assertEquals(id, guest.getId());
        assertEquals(name, guest.getName());
        //測試DaoTest的update方法
        guest.setName(updatedName);
        daoClass.update(guest);

        //再一次取出被改過Name的Guest
        Guest updatedGuest = (Guest) sessionFactory.getCurrentSession().get(Guest.class, id);
        //測試取出的Guest的Name是否真得被改過了
        assertEquals(updatedName, updatedGuest.getName());
    }

    @Test
    public void deleteTest() {
        //先存一個要delete的Guest
        sessionFactory.getCurrentSession().save(new Guest(id, name, age, birthDate));
        //再將之取出後delete掉
        Guest guestToBeDeleted = (Guest) sessionFactory.getCurrentSession().get(Guest.class, id);
        daoClass.delete(guestToBeDeleted);
        //確認是否真得被刪除了,即是說找用同樣的id(主鍵)找不到Guest了
        assertNull((Guest) sessionFactory.getCurrentSession().get(Guest.class, id));
    }

    @Test
    public void findByIdTest() {
        //先存一個要Guest
        sessionFactory.getCurrentSession().save(new Guest(id, name, age, birthDate));

        //測試DaoClass的findById()
        Guest guest = daoClass.findById(id);
        //測試是否Guest有正確被找到
        assertNotNull(guest);
        //測試被找到的Guest是否就是剛存的那一個Guest
        assertEquals(id, guest.getId());
        assertEquals(name, guest.getName());
        assertEquals(age, guest.getAge());
        assertEquals(birthDate, guest.getBirthDate());
    }

    @BeforeClass
    public static void setUpClass() {
    }

    @AfterClass
    public static void tearDownClass() {
    }

    @Before
    public void setUp() {
    }

    @After
    public void tearDown() {
    }
}

接著我們實作一個幸行事務的Class,ServiceClass.java:

ServicecClass:

package ServicePackage;

import DaoPackage.DaoClass;
import OrmPackage.Guest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(propagation = Propagation.REQUIRED)       //使用Spring管理交易
public class ServiceClass {

    @Autowired                  //使用Spring注入DaoClass
    private DaoClass daoClass;

    //儲存一個Guest並刪除另一個Guest    
    public void saveOneAndDeleteAnother(Guest guestToBeSave, Guest guestToBeDelete) {
        daoClass.save(guestToBeSave);
        daoClass.delete(guestToBeDelete);
    }
}

最後我們再實作用來測試ServiceClass的JUnit項目,ServiceTest:

ServiceTest.java:

import OrmPackage.Guest;
import ServicePackage.ServiceClass;
import java.util.Calendar;
import java.util.Date;
import org.hibernate.SessionFactory;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import static org.junit.Assert.*;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "file:web/WEB-INF/applicationContext.xml")

//使用Spring管理交易,主要是因為這裡用到了SessionFactory,主要是給SessionFactory用的,
//其實更完美正確的單元測試應該是要連ServiceClass裡面用到的DaoClass都用Mockito去模擬
//才對,這樣才更具單元解耦性
@Transactional(propagation = Propagation.REQUIRED)       
public class ServiceTest {

    @Autowired
    private ServiceClass serviceClass;

    @Autowired
    private SessionFactory sessionFactory;

    public ServiceTest() {
    }

    @Test
    public void saveOneAndDeleteAnotherTest() {
        //設置測試要用的參數

        //設置saveOneAndDeleteAnother()要儲存的Guest
        String idForGuestToBeSave = idForGuestToBeSave = "123";
        String nameForGuestToBeSave = nameForGuestToBeSave = "Tom";
        int ageForGuestToBeSave = ageForGuestToBeSave = 55;

        Calendar birthDateCalendarForGuestToBeSave = Calendar.getInstance();
        birthDateCalendarForGuestToBeSave.set(2005, Calendar.JANUARY, 5);
        Date birthDateForGuestToBeSave = birthDateForGuestToBeSave = birthDateCalendarForGuestToBeSave.getTime();
        
        Guest guestToBeSave = guestToBeSave = new Guest(idForGuestToBeSave, nameForGuestToBeSave, ageForGuestToBeSave, birthDateForGuestToBeSave);

        //設置saveOneAndDeleteAnother要刪除的Guest
        String idForGuestToBeDelete = idForGuestToBeDelete = "345";
        String nameForGuestToBeDelete = nameForGuestToBeDelete = "Mary";
        int ageForGuestToBeDelete = ageForGuestToBeDelete = 65;

        Calendar birthDateCalendarForGuestToBeDelete = Calendar.getInstance();
        birthDateCalendarForGuestToBeDelete.set(2005, Calendar.JANUARY, 6);
        Date birthDateForGuestToBeDelete = birthDateForGuestToBeDelete = birthDateCalendarForGuestToBeDelete.getTime();

        Guest guestToBeDelete = guestToBeDelete = new Guest(idForGuestToBeDelete, nameForGuestToBeDelete, ageForGuestToBeDelete, birthDateForGuestToBeDelete);
        //先把saveOneAndDeleteAnother要刪除的Guest儲存起來
        sessionFactory.getCurrentSession().save(guestToBeDelete);

        //開始測試
        //測試是否能取出要剛除的Guest,即是有無正確地儲存起來
        assertEquals(guestToBeDelete, sessionFactory.getCurrentSession().get(Guest.class, guestToBeDelete.getId()));
        //測試saveOneAndDeleteAnother()方法
        serviceClass.saveOneAndDeleteAnother(guestToBeSave, guestToBeDelete);
        
        //測試saveOneAndDeleteAnother()有無把要儲存的Guest儲存起來
        assertEquals(guestToBeSave, sessionFactory.getCurrentSession().get(Guest.class, guestToBeSave.getId()));
        //測試saveOneAndDeleteAnother()是否有把要刪除的Guest刪除掉
        assertNull(sessionFactory.getCurrentSession().get(Guest.class, guestToBeDelete.getId()));
    }

    @BeforeClass
    public static void setUpClass() {
    }

    @AfterClass
    public static void tearDownClass() {
    }

    @Before
    public void setUp() {
    }

    @After
    public void tearDown() {
    }
}

這樣我們的建製就完成了,我們可以在NetBeans中對專案按右鍵選擇“Test”進行測試,注意右下角的Rollback提示訊息,在JUnit中,如果採用Sprnig托管測試及事務(Transaction)的話,會在一個Test測完後將事務Rollback,將資料庫回復原樣,不會動到資料庫原來的內容,十分方便。如果要不Rollback,想要更新到資料庫的話,可以如下設定:

@Rollback(true)


最後附上源始碼:
MySpringHibernateTranscation.7z

參考資料:

  1. spring事務管理錯誤createSQLQuery is not valid without active transaction
  2. hibernate.current_session_context_class
  3. Spring - @Transactional does not start transaction
  4. Spring中Transactional配置
  5. Mocking Hibernate DAOs to test Service Level Spring Beans
  6. Spring MVC 3 實作教學 (10) - 資料存取 ( Transaction、DAO 設定 )

1 則留言 :