CLOVER🍀

That was when it all began.

DETACHED状態の関連エンティティへのアクセスについて

JPA、特にJPAの実装にHibernateを使っている場合に遭遇するということで、有名なやつを。

この話自体はちょこちょこ見かけるのですが、実際に自分の目で見たことはなかったので、ここはひとつ確認してみることにしました。

確認に使用する、テーブル定義はこんな感じです。

CREATE TABLE book_shelf (
  id INT AUTO_INCREMENT,
  name VARCHAR(255),
  PRIMARY KEY(id)
);

CREATE TABLE book (
  id INT AUTO_INCREMENT,
  book_shelf_id INT,
  name VARCHAR(255),
  publish_date DATE,
  PRIMARY KEY(id)
);

別の本棚に入れるためには、本自体が増えます…。

エンティティクラス。
src/main/scala/javaee6/web/entity/Entities.scala

package javaee6.web.entity

import scala.beans.BeanProperty
import scala.collection.JavaConverters._

import java.util.Date

import javax.persistence.{Column, Entity, GeneratedValue, GenerationType, Id}
import javax.persistence.{Table, Temporal, TemporalType}
import javax.persistence.{FetchType, JoinColumn, OneToMany}

object BookShelf {
  def apply(name: String): BookShelf = {
    val bs = new BookShelf
    bs.name = name
    bs
  }
}

@SerialVersionUID(1L)
@Entity
@Table(name = "book_shelf")
class BookShelf {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @BeanProperty
  var id: Int = _

  @Column
  @BeanProperty
  var name: String = _

  @OneToMany
  //@OneToMany(fetch = FetchType.EAGER)
  @JoinColumn(name = "book_shelf_id")
  @BeanProperty
  var booksAsJava: java.util.List[Book] = _

  def books: Iterable[Book] =
    booksAsJava.asScala.asInstanceOf[Iterable[Book]]
}

object Book {
  def apply(bookShelfId: Int, name: String, publishDate: Date): Book = {
    val book = new Book
    book.bookShelfId = bookShelfId
    book.name = name
    book.publishDate = publishDate
    book
  }
}

@SerialVersionUID(1L)
@Entity
@Table(name = "book")
class Book {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @BeanProperty
  var id: Int = _

  @Column(name = "book_shelf_id")
  @BeanProperty
  var bookShelfId: Int = _

  @Column
  @BeanProperty
  var name: String = _

  @Column(name = "publish_date")
  @Temporal(TemporalType.DATE)
  @BeanProperty
  var publishDate: Date = _
}

OneToManyを貼ったところのフェッチ戦略は、後で切り替えます。

EJB
src/main/scala/javaee6/web/service/Services.scala

package javaee6.web.service

import javax.ejb.{LocalBean, Stateless}
import javax.persistence.{EntityManager, PersistenceContext}

import javaee6.web.entity.{BookShelf, Book}

trait PersistenceContextSupport[T] {
  @PersistenceContext(unitName = "javaee6.web.pu")
  var entityManager: EntityManager = _

  def create(entity: T): Unit =
    entityManager.persist(entity)

  def update(entity: T): Unit =
    entityManager.merge(entity)

  def remove(entity: T): Unit =
    entityManager.remove(entityManager.merge(entity))
}

@Stateless
@LocalBean
class BookShelfService extends PersistenceContextSupport[BookShelf] {
  def find(id: Int): BookShelf =
    entityManager.find(classOf[BookShelf], id)

  def findByFetchQuery(id: Int): BookShelf =
    entityManager
      .createQuery("""|SELECT bs
                      |  FROM BookShelf bs
                      |  LEFT JOIN FETCH bs.booksAsJava
                      | WHERE bs.id = :id""".stripMargin)
      .setParameter("id", id)
      .getSingleResult
      .asInstanceOf[BookShelf]

  def count: Int =
    entityManager
      .createQuery("SELECT bs FROM BookShelf bs")
      .getResultList
      .size
}

@Stateless
@LocalBean
class BookService extends PersistenceContextSupport[Book]

JAX-RS。Applicationクラスのサブクラスについては、端折ります。
src/main/scala/javaee6/web/jaxrs/BookShelfResource.scala

package javaee6.web.jaxrs

import java.net.URI
import java.text.SimpleDateFormat
import java.util.Date

import javax.inject.Inject
import javax.ws.rs.{GET, Path, PathParam, Produces}
import javax.ws.rs.core.{CacheControl, MediaType, Response, UriBuilder}

import javaee6.web.entity.{Book, BookShelf}
import javaee6.web.service.{BookService, BookShelfService}

@Path("/bookshelf")
class BookShelfResource {
  @Inject
  var bookShelfService: BookShelfService = _

  @Inject
  var bookService: BookService = _

  @GET
  @Path("init")
  def init: Response = {
    val numbering = bookShelfService.count + 1
    val bookShelf = BookShelf("My本棚" + numbering)

    bookShelfService.create(bookShelf)

    val sdf = new SimpleDateFormat("yyyy/MM/dd")
    val date: String => Date = sdf.parse

    Array(
      Book(bookShelf.id,
           "Beginning Java EE 6 GlassFish 3で始めるエンタープライズJava",
           date("2013/3/9")),
      Book(bookShelf.id,
           "マスタリングJavaEE5 第2版",
           date("2009/11/28")),
      Book(bookShelf.id,
           "JavaによるRESTfulシステム構築",
           date("2010/8/23"))
    ).foreach(bookService.create)

    val cc = new CacheControl
    cc.setMaxAge(0)
    cc.setMustRevalidate(true)
    cc.setNoCache(true)
    cc.setNoStore(true)

    Response
      .status(Response.Status.MOVED_PERMANENTLY)
      .location(UriBuilder
                  .fromPath("/bookshelf/list/{id}")
                  .build(bookShelf.id.toString))
      .cacheControl(cc)
      .build
  }

  @GET
  @Path("list/{id}")
  def list(@PathParam("id") id: String): Response = {
    val bookShelf = bookShelfService.find(id.toInt)
    //val bookShelf = bookShelfService.findByFetchQuery(id.toInt)

    val sdf = new SimpleDateFormat("yyyy/MM/dd")
    val dformat: Date => String = sdf.format

    val responseHtml =
      <html>
        <head>
          <meta charset="UTF-8" />
          <title>{if (bookShelf != null) bookShelf.name
                  else "お探しの本棚はありません"}</title>
        </head>
        <body>
          <h1>{if (bookShelf != null) bookShelf.name
               else "お探しの本棚はありません"}</h1>
          {if (bookShelf!= null)
             <table border="1">
               <tr><th>書籍ID</th><th>書籍名</th><th>出版日</th></tr>
               {bookShelf.books.map { b =>
                 <tr><td>{b.id}</td><td>{b.name}</td><td>{dformat(b.publishDate)}</td></tr>}}
             </table>
           }
        </body>
      </html>

    Response
      .ok(responseHtml.toString)
      .build
  }
}

initというGETに対応するメソッドがべき等ではないので、REST好きな人からは怒られそうですが、ここはちょっとご愛嬌ということで…。単に簡単にデータを作りたかっただけなので。

で、initにアクセス後、list/{id}にアクセスすると、こういうことになります。

00:49:09,468 INFO  [stdout] (http--127.0.0.1-8080-1) Hibernate: 
00:49:09,468 INFO  [stdout] (http--127.0.0.1-8080-1)     select
00:49:09,468 INFO  [stdout] (http--127.0.0.1-8080-1)         bookshelf0_.id as id64_0_,
00:49:09,474 INFO  [stdout] (http--127.0.0.1-8080-1)         bookshelf0_.name as name64_0_ 
00:49:09,474 INFO  [stdout] (http--127.0.0.1-8080-1)     from
00:49:09,475 INFO  [stdout] (http--127.0.0.1-8080-1)         book_shelf bookshelf0_ 
00:49:09,475 INFO  [stdout] (http--127.0.0.1-8080-1)     where
00:49:09,476 INFO  [stdout] (http--127.0.0.1-8080-1)         bookshelf0_.id=?
00:49:09,533 ERROR [org.apache.catalina.core.ContainerBase.[jboss.web].[default-host].[/javaee6-web].[javaee6.web.jaxrs.SimpleApplication]] (http--127.0.0.1-8080-1) サーブレット javaee6.web.jaxrs.SimpleApplication のServlet.service()が例外を投げました: org.jboss.resteasy.spi.UnhandledException: org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: javaee6.web.entity.BookShelf.booksAsJava, no session or session was closed

はい、見るのはここ。

org.jboss.resteasy.spi.UnhandledException: org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: javaee6.web.entity.BookShelf.booksAsJava, no session or session was closed

JAX-RSのリソースクラスは永続化コンテキストの範囲外なので、関連エンティティをレイジーに取得しようとすると、Hibernateの場合にはこうなるということですね。

EclipseLinkだとデータを取り直すらしいですが、それもそれで…。

で、これを解決するためにどうしようか?というところですが、

  • 呼び出し元(この場合はJAX-RS)も永続化コンテキストのスコープの範囲に入れる
  • OneToManyのフェッチ戦略を、EAGERにする
  • 永続化コンテキストを抜ける前に、強引にロードしてしまう
  • JPQLで、フェッチジョインする
  • 拡張EntityManagerを使用する

などが考えられます。

最初のやつは、考え方的には「Open Session In View」ですね。

強引にロードしてしまおうというのは、永続化コンテキストを抜ける前に単に

for (Book book : bookShelf.getBooks()) {
  ...
}

のようにアクセスしてしまうことや、Listのサイズを取るなどすることです。

拡張EntityManagerは、デフォルトではJTAトランザクション境界に一致するはずのEntityManagerのライフサイクルを、ステートフル・セッションBeanと同じライフサイクルにまで広げることです。

とまあ、ここまで特にやりたくないものを挙げてみました。

個人的には、採用するならそれ以外かなぁと。

では、他の戦略を試してみましょう。

まずはフェッチ戦略をEAGERにする、から。先ほどのエンティティの定義で、以下の部分を

  @OneToMany
  //@OneToMany(fetch = FetchType.EAGER)
  @JoinColumn(name = "book_shelf_id")
  @BeanProperty
  var booksAsJava: java.util.List[Book] = _

こう変更します。

  //@OneToMany
  @OneToMany(fetch = FetchType.EAGER)
  @JoinColumn(name = "book_shelf_id")
  @BeanProperty
  var booksAsJava: java.util.List[Book] = _

フェッチ戦略をEAGERにしました。

コンパイルしてデプロイ後、確認してみるとHibernateから投げられるSQLがこうなります。

01:05:11,411 INFO  [stdout] (http--127.0.0.1-8080-1) Hibernate: 
01:05:11,416 INFO  [stdout] (http--127.0.0.1-8080-1)     select
01:05:11,416 INFO  [stdout] (http--127.0.0.1-8080-1)         bookshelf0_.id as id68_1_,
01:05:11,416 INFO  [stdout] (http--127.0.0.1-8080-1)         bookshelf0_.name as name68_1_,
01:05:11,417 INFO  [stdout] (http--127.0.0.1-8080-1)         booksasjav1_.book_shelf_id as book2_68_3_,
01:05:11,417 INFO  [stdout] (http--127.0.0.1-8080-1)         booksasjav1_.id as id3_,
01:05:11,417 INFO  [stdout] (http--127.0.0.1-8080-1)         booksasjav1_.id as id69_0_,
01:05:11,418 INFO  [stdout] (http--127.0.0.1-8080-1)         booksasjav1_.book_shelf_id as book2_69_0_,
01:05:11,418 INFO  [stdout] (http--127.0.0.1-8080-1)         booksasjav1_.name as name69_0_,
01:05:11,418 INFO  [stdout] (http--127.0.0.1-8080-1)         booksasjav1_.publish_date as publish4_69_0_ 
01:05:11,418 INFO  [stdout] (http--127.0.0.1-8080-1)     from
01:05:11,419 INFO  [stdout] (http--127.0.0.1-8080-1)         book_shelf bookshelf0_ 
01:05:11,419 INFO  [stdout] (http--127.0.0.1-8080-1)     left outer join
01:05:11,420 INFO  [stdout] (http--127.0.0.1-8080-1)         book booksasjav1_ 
01:05:11,420 INFO  [stdout] (http--127.0.0.1-8080-1)             on bookshelf0_.id=booksasjav1_.book_shelf_id 
01:05:11,420 INFO  [stdout] (http--127.0.0.1-8080-1)     where
01:05:11,428 INFO  [stdout] (http--127.0.0.1-8080-1)         bookshelf0_.id=?

LEFT OUTER JOINになりましたね。

これで、結果が取得できるようになりました。

それでは、もうひとつの方法のJPQLでフェッチジョインするやり方を試してみましょう。

先ほどの、OneToManyアノテーションのフェッチ戦略はデフォルトに戻しておきます。そして、JAX-RSリソースクラスの以下の部分を

    val bookShelf = bookShelfService.find(id.toInt)
    //val bookShelf = bookShelfService.findByFetchQuery(id.toInt)

このように変更します。

    //val bookShelf = bookShelfService.find(id.toInt)
    val bookShelf = bookShelfService.findByFetchQuery(id.toInt)

このメソッドが指定するJPQLは、以下のものです。

  def findByFetchQuery(id: Int): BookShelf =
    entityManager
      .createQuery("""|SELECT bs
                      |  FROM BookShelf bs
                      |  LEFT JOIN FETCH bs.booksAsJava
                      | WHERE bs.id = :id""".stripMargin)
      .setParameter("id", id)
      .getSingleResult
      .asInstanceOf[BookShelf]

コンパイル後、デプロイして動作させた際に得られるSQLは、先ほどのフェッチ戦略を変更した時と同じものです。

ケースバイケースですが、個人的にはJPQLでフェッチジョインする方が好みかなぁ。

JPQLフェッチジョインの参考URL:
http://docs.oracle.com/cd/E28613_01/apirefs.1211/e24396/ejb3_langref.html#ejb3_langref_fetch_joins

参考書籍:

マスタリングJavaEE5 第2版 (DVD付) (Programmer’s SELECTION)

マスタリングJavaEE5 第2版 (DVD付) (Programmer’s SELECTION)