Hibernate 的 Lazy Loading 全攻略 by MonsterSupreme | CodeData
top

Hibernate 的 Lazy Loading 全攻略

分享:

對 Hibernate 的使用者來說,應該每個人都看過類似的訊息:

org.apache.catalina.core.StandardWrapperValve invoke
SEVERE: Servlet.service() for servlet [RegionController] 
    in context with path [/HibernateTransaction] threw exception 
    [An exception occurred processing JSP page /regions.jsp at line 40

37:                         </td>
38:                         <td>
39:                            <input type="text" name="regionName" size="50"
40:                                 value="<%= region.getRegionName() %>">
41:                         </td>
42:                     </tr>
43:                 </tbody>

Stacktrace:] with root cause
org.hibernate.LazyInitializationException: 
    could not initialize proxy - no Session
    at org.hibernate.proxy.AbstractLazyInitializer.initialize(...)
    ...
    at java.lang.Thread.run(Thread.java:722)

裡頭的 LazyInitializationException,就是傳說中的 Lazy Initialization,也叫做 Lazy Loading,所造成的。

原因

Hibernate 預設會使用所謂的 Lazy Loading 機制。也就是說,有些欄位的內容,比方說 BLOB 型別這種資料量比較大的欄位,或是 Association 做出來的欄位,並不會在一開始 DAO 取得資料的時候就載入,而是等到 View 真正想用到這些欄位資料的時候才載入。為了做出這個效果,DAO 一開始透過 Hibernate 所回傳的物件,通常不是原來設計的 Persistent Object,而是 Persistent Object 的 Proxy。如果我們在 View 裡頭呼叫這個 Proxy 物件的 Getter 方法,想要使用欄位的內容,Hibernate 才會讓 Proxy 物件透過 Hibernate 的 Session 物件的協助,從資料庫取得資料。

一般會去取得 Persistent Object 的地方通常都是在 DAO,如果這時候需要讀取欄位資料做處理,因為 Hibernate 的 Session 物件還在,讀取資料並不會有問題。但是將流程轉到 View
之後,如果還需要呼叫 Getter 方法讀取欄位資料,可是因為 Lazy Loading 的緣故,欄位沒有內容的話,那時候 Hibernate 的 Session 物件通常早在 DAO 就已經呼叫 close 方法而被關閉,所以 Proxy 物件就無法去資料庫取得資料,問題也因此發生。

借用 Guide to Java Persistence and Hibernate 網站 Short Life of a Session 這張 Sequence Diagram 來做說明的話,就是在步驟 8 JSP Rendering 的時候,Proxy 物件需要透過 Hibernate 的 Session 物件去資料庫讀取資料,Session 物件的生命週期卻已經結束,沒辦法幫上忙,因此才會產生 LazyInitializationException 例外。

Short Life of a Session

有一點要釐清的是,Lazy Loading 這個問題並不是 Hibernate 獨有,事實上很多 Object/Relational Mapping (ORM) 的程式庫,為了要爭取一些執行上的效率、降低記憶體空間與網路流量的負荷,幾乎都採用類似的作法。換句話說,想要學好任何一種 ORM,怎麼處理 Lazy Loading 都是必須面對的重要議題。

想法

知道原因之後,有沒有一些想法,可能有機會解決問題?

有,而且超白爛的:

  • 如果擔心後面的 View 需要用到 Persistent Object 欄位資料的時候,Hibernate 的 Session 物件已經掛了,那就在 Session 物件還沒掛掉之前,提早塞好資料到 Persistent Object ,就可以了啊!
  • 如果擔心後面的 View 需要用到 Persistent Object 欄位資料的時候,Hibernate 的 Session 物件已經掛了,那就延後關閉 Session 的時間點,讓 Session 物件撐到 View 把 Persistent Object 資料讀完,就好了啊!

作法

有了想法之後,接下來才是從 Hibernate 的組態設定或是 API 裡頭,去找出可行的作法,實現你的想法。

常見的作法有底下四種,分成兩大類:

  • 提早塞好資料
    • 修改 Mapping 檔案
    • 修改 DAO 程式寫法
  • 延後關閉 Session
    • 自行撰寫 Filter 處理
    • 透過 Spring Framework 協助

修改 Mapping 檔案

最直接的作法,就是把 Lazy Loading 機制關掉,不就結了?

沒錯,所以解決 Lazy Loading 的方法之一,就是修改 Persistent Object 的 Mapping 檔案,關閉 Lazy Loading 機制,讓 Hibernate 強迫 Session 物件,對整個 Persistent Object 所有的欄位或是特定的欄位,把資料讀入。作法很簡單,就是在 Mapping 檔案 <class> 或是特定的 <property> Tag,將 lazy 屬性設定為 false 即可。範例如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE hibernate-mapping PUBLIC 
    "-//Hibernate/Hibernate Mapping DTD 3.0//EN" 
    "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd">
<hibernate-mapping>
    <class lazy="false" catalog="codedata" table="Region"
        name="tw.com.codedata.model.Region">
    ...    
    </class>
</hibernate-mapping>

這種作法的好處是超級方便,缺點則是可能會因此花比較多的執行時間、記憶體空間、與網路流量處理資料。會發生 Lazy Loading 問題的欄位,通常是 1-1、1-n、n-m 的 Association 欄位,因此衍生出來的資料量會很可怕。

結論就是,如果不是 Hello World 或 Proof of Concept 型態的專案,還是不要這樣做比較好。

修改 DAO 程式寫法

如果靜態地、全面性地在 Mapping 檔案裡面加上 lazy=false,不是一個很好的作法,那在 DAO 裡面根據實際需求、動態地讀取資料,應該會比較好吧?

可以,Hibernate 提供了一個類別,剛好就叫做 Hibernate,有一個 static 方法 initialize,可以叫 Hibernate 回傳的 Proxy 物件把 Persistent Object 塞好資料,isInitializedisPropertyInitialized 方法,可以用來判斷整個 Persistent Object 或是特定欄位的資料,是不是已經塞好:

  • initialize(Object proxy)
  • isInitialized(Object proxy)
  • isPropertyInitialized(Object proxy, String propertyName)

所以,解決 Lazy Loading 的方法之二,就是修改 DAO 的各個 CRUD 方法,透過 Hibernate 類別的 initialize 方法,強迫 Hibernate 把所有資料讀入 Persistent Object 之中。範例如下:

public class RegionDAO
{
    private SessionFactory factory = HibernateUtil.getSessionFactory();

    public void setSessionFactory(SessionFactory factory)
    {
        this.factory = factory;
    }

    public Region findByPrimaryKey(int regionId)
    {
        Region region = null;

        Session session = factory.getCurrentSession();
        Transaction tx = null;

        try
        {
            tx = session.beginTransaction();
            region = (Region) session.load(Region.class, regionId);
            Hibernate.initialize(region);
            tx.commit();
        }
        catch (Exception ex)
        {
            if (tx != null) tx.rollback();
            System.out.println(ex.getMessage());
        }

        return region;
    }
}

這種作法看起來似乎很有彈性,但是這個彈性其實沒什麼太大意義。因為 DAO 基本上提供的是比較一般化的功能,在 DAO 裡面是沒辦法預測 View 會怎麼使用它傳回的 Persistent Object。即使我們對每個 DAO 方法都提供兩種版本,一種呼叫 initialize,另一種不呼叫,這樣也只是把預測的責任移到 Facade 而已,Facade 一方面同樣不見得知道 View 會怎麼使用 Persistent Object,二方面如果知道 View 會怎麼使用 Persistent Object 而去呼叫特定的 DAO 方法,那就表示 Facade 知道太多 View 與 DAO 的實作細節,耦合度太高會導致其他更嚴重的問題。如果不管三七二十一讓 DAO 所有方法都呼叫 initialize,那不是比改 Mapping 檔案還笨?

結論就是,第二種作法感覺起來比第一種作法有彈性,但是實際上是半斤八兩。

自行撰寫 Filter 處理

提早塞好資料這些作法,雖然簡單,但是都不是很好的作法。那有沒有辦法延後關閉 Hibernate 的 Session 物件呢?有啊!如果是 Web 應用程式的話,我們就寫個 Filter,想辦法讓 Hibernate 的 Session 物件,在 View 可能還需要讀取 Persistent Object 欄位資料的時候,一直都是活著的,但是在送回 HTTP Response 之前,要記得幫我們把 Hibernate 的 Session 物件關掉,不就好了?

借用 Guide to Java Persistence and Hibernate 網站 Lifetime Until the View is Rendered 這張 Sequence Diagram 來做說明的話,就是透過 Filter 的協助,在步驟 9 JSP Rendering 的時候,Session 物件一樣是活著的,這樣就可以幫 Proxy 物件讀取 Persistent Object 欄位資料了。

Lifetime Until the View is Rendered

所以解決 Lazy Loading 的方法之三,就是自己寫個 Filter,採用 Session per Request 的方式,控制 Hibernate 的 SessionTransaction 物件的生命週期。範例如下:

public class LazyLoadingFilter implements Filter
{
    private SessionFactory factory = null;
    private FilterConfig config = null;

    public void destroy() {}

    public void init(FilterConfig config) throws ServletException
    {
        this.config = config;
        factory = HibernateUtil.getSessionFactory();
    }

    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException
    {
        Transaction tx = null;

        try
        {
            tx = factory.getCurrentSession().beginTransaction();
            chain.doFilter(request, response);
            tx.commit();
        }
        catch (Exception ex)
        {
            System.out.println(ex.getMessage());
            tx.rollback();
        }
    }
}

web.xml 檔案當然得宣告 LazyLoadingListener,範例如下:

<?xml version="1.0" encoding="UTF-8"?>
<web-app ...>

    <filter>
        <filter-name>LazyLoadingFilter</filter-name>
        <filter-class>tw.com.codedata.helper.LazyLoadingFilter</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>LazyLoadingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

</web-app>

因為採用 Session per Request 的方式,所以必須調整 DAO 的寫法,把 Transaction 相關的程式碼拿掉。範例如下:

public class RegionDAO
{
    private SessionFactory factory = HibernateUtil.getSessionFactory();

    public void setSessionFactory(SessionFactory factory)
    {
        this.factory = factory;
    }

    public Region findByPrimaryKey(int regionId)
    {
        Session session = factory.getCurrentSession();
        return (Region) session.load(Region.class, regionId);
    }
}

這種作法看起來辛苦一點,因為還要自己寫個 Filter,並且要宣告。不過好處就是,這種作法只有在真正需要用到資料的時候,才會透過 Hibernate 的 Session 物件讀取,所以對於執行時間、記憶體空間、與網路流量這些問題,都是控制在一個最佳的狀態。

透過 Spring Framework 協助

Spring Framework 對「不要重複發明輪子」這件事還蠻堅持的,既然每個人每個專案用到 Hibernate 都需要處理 Lazy Loading 的問題,所以解決方法之四,就是不要每個人每個專案都得寫個 LazyLoadingFilter,直接用 Spring Framework 提供的 OpenSessionInViewFilter。如果不是 Web 環境,也可以透過 Aspect-Oriented Programming 的方式,掛上 OpenSessionInViewInterceptor

為什麼叫做 OpenSessionInViewFilter? 看名字就知道,它透過 Filter 的方式,實作出 Hibernate 的 Open Session in View Pattern,讓 Hibernate 的 Session 物件,在 View 可能還需要用到它的時候,是活著的。等到 View 做完所有的事情之後,才去關閉 Hibernate 的 Session 物件。

使用方式很簡單,第一點當然是透過 LocalSessionFactoryBean 類別,將 Hibernate 的 SessionFactory 宣告在 Spring Framework 組態設定檔案 applicationContext.xml 之內:

<?xml version="1.0" encoding="UTF-8"?>
<beans ...>

    <jee:jndi-lookup id="dataSource" 
        expected-type="javax.sql.DataSource"
        jndi-name="java:comp/env/jdbc/CodeDataDS" />

    <bean id="codedataSessionFactory"
        class="org.springframework.orm.hibernate3.LocalSessionFactoryBean">
        <property name="dataSource" ref="dataSource" />
        <property name="configLocation">
            <value>classpath:hibernate.cfg.xml</value>
        </property>
    </bean>

    <bean id="txManager" 
        class="org.springframework.orm.hibernate3.HibernateTransactionManager">
        <property name="sessionFactory" ref="codedataSessionFactory" />
    </bean>

    <tx:annotation-driven transaction-manager="txManager" />

    <bean id="regionDAO" class="tw.com.codedata.model.RegionDAO">
        <property name="sessionFactory" ref="codedataSessionFactory" />
    </bean>

    <bean id="regionFacade" class="tw.com.codedata.model.RegionFacade">
        <property name="regionDAO" ref="regionDAO" />
    </bean>

</beans>

第二點,因為是 Filter,當然一樣必須在 web.xml 檔案宣告:

<?xml version="1.0" encoding="UTF-8"?>
<web-app ...>

    <filter>
        <filter-name>OpenSessionInViewFilter</filter-name>
        <filter-class>
            org.springframework.orm.hibernate3.support.OpenSessionInViewFilter
        </filter-class>
        <init-param>
            <param-name>sessionFactoryBeanName</param-name>
            <param-value>codedataSessionFactory</param-value>
       </init-param>
       <init-param>
            <param-name>singleSession</param-name>
            <param-value>true</param-value>
       </init-param>
       <init-param>
            <param-name>flushMode</param-name>
            <param-value>AUTO</param-value>
       </init-param>
    </filter>

    <filter-mapping>
        <filter-name>OpenSessionInViewFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

</web-app>

Filter 三個參數的意義分別是:

  • sessionFactoryBeanName:Spring Framework 組態設定檔案中,LocalSessionFactoryBean id 屬性的值
  • singleSession:是不是按照 Hibernate 的 Single Session per Request Pattern 控制 Session 的生命週期
  • flushMode:預設是 NEVER,也就是不會將資料寫入,雖然據說 HibernateTransactionManager 為了讓資料可以寫入,會適時地改為 AUTO,但還是自己改比較放心

第三點,因為搭配使用 Spring Framework 提供的 HibernateTransactionManager,所以必須在適當的地方加上 @Transactional Annotation,一般來說是寫在比較高階的 Facade:

@Transactional(isolation=Isolation.READ_COMMITTED, propagation=Propagation.REQUIRED)
public class RegionFacade
{
    private RegionDAO regionDAO = null;

    public void setRegionDAO(RegionDAO regionDAO)
    {
        this.regionDAO = regionDAO;
    }

    public Region getRegion(int regionId)
    {
        return regionDAO.findByPrimaryKey(int regionId);
    }
}

第四點,DAO 取得 Hibernate Session 物件的方式也要修改,變成透過 Spring Framework 的輔助類別 SessionFactoryUtils 或是 HibernateTemplate 取得才行:

public class RegionDAO
{
    private SessionFactory factory = null;

    public void setSessionFactory(SessionFactory factory)
    {
        this.factory = factory;
    }

    public Region findByPrimaryKey(int regionId)
    {
        Session session = SessionFactoryUtils.getSession(factory, true);
        return (Region) session.load(Region.class, regionId);
    }
}

第四種作法跟第三種作法在執行時間、記憶體空間、與網路流量這些問題上,應該是差不多,不過好處就是使用 Spring Framework 撰寫的 OpenSessionInViewFilter,理論上強度應該比我們自己寫的要來得好,缺點當然就是必須知道使用這個 Filter 該注意的一些小細節囉!

分享:
按讚!加入 CodeData Facebook 粉絲群

留言

留言請先。還沒帳號註冊也可以使用FacebookGoogle+登錄留言

關於作者

目前從事教育訓練工作。自認為會的技術不多,但是學不會的也不多,最擅長把老闆交代的工作,以及找不到老師教的技術,想辦法變成自己的專長。

熱門論壇文章

熱門技術文章