自己参照でかつ継承するテーブルの扱い

切るところが無い。。。
非常に冗長になってしまったので、短気な人は暇なときにでもお越しくださいな。



Javaでプログラムを作っている以上、みんなオブジェクト指向設計を行っているはずです。


オブジェクト指向設計を行っていれば、以下のようなクラス構造は普通に出てきますよね。

Hibernateは、オブジェクト指向設計の味方です。
このような、クラス構造をきちんとテーブルにマッピングしてくれます。



また、こういうクラス構造も当然出てきます。

Hibernateは、オブジェクト指向設計の味方です。
このような、クラス構造をきちんとテーブルにマッピングしてくれます。



当然この2つが混ざった以下のようなクラス構造も出てくることがあるでしょう。

Hibernateは、オブジェクト指向設計の味方です。(しつこい!)
このような、クラス構造をきちんとテーブルにマッピングしてくれます。


しかし、若干動きが納得いかんのです。


例えば、以下のようなクラス構造があったとして、

以下のようなマッピングを行ったとします。

<?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 name="entity.Entity" table="ENTITY">
    <id name="id" column="ID" type="java.lang.Long">
      <generator class="assigned">
      </generator>
    </id>
    <many-to-one
        name="parent" class="entity.Entity" 
        cascade="none" outer-join="true"
        update="true" insert="true"
    >
      <column name="PARENT_ID" />
    </many-to-one>
    <joined-subclass 
        name="entity.EntityB" table="ENTITY_B">
      <key column="ID" />
      <property
          name="valueB" type="java.lang.String"
          update="true" insert="true"
          column="VALUE_B" length="256"
          not-null="true"
      />
    </joined-subclass>
    <joined-subclass
        name="entity.EntityA" table="ENTITY_A"
    >
      <key column="ID" />
      <property
          name="valueA" type="java.lang.String"
          update="true" insert="true"
          column="VALUE_A" length="256"
          not-null="true"
      />
    </joined-subclass>
  </class>
</hibernate-mapping>

データはあらかじめ以下のSQLで登録しておきます。(DBはOracle10gXE)

insert into ENTITY(ID, PARENT_ID) values(1, null);
insert into ENTITY(ID, PARENT_ID) values(2, 1);
insert into ENTITY(ID, PARENT_ID) values(3, 2);
insert into ENTITY(ID, PARENT_ID) values(4, 3);
insert into ENTITY_A(ID, VALUE_A) values(1, 'valueA-1');
insert into ENTITY_B(ID, VALUE_B) values(2, 'valueB-2');
insert into ENTITY_A(ID, VALUE_A) values(3, 'valueA-3');
insert into ENTITY_B(ID, VALUE_B) values(4, 'valueB-4');
commit;

で、以下のようなコードを実行するわけですよ。

:
    public static void main(String[] args) throws Exception {
        Configuration config = new Configuration();
        config.configure();
        SessionFactory sf = config.buildSessionFactory();
        Session session = sf.openSession();
        try {
            Entity e = (Entity)session.get(Entity.class, 4L);
            while(e != null) {
                if(e instanceof EntityA) {
                    EntityA eA = (EntityA)e;
                    System.out.println(eA.getId() + ":" + eA.getValueA());
                } else if(e instanceof EntityB) {
                    EntityB eB = (EntityB)e;
                    System.out.println(eB.getId() + ":" + eB.getValueB());
                } else {
                    System.out.println(e.getId());
                }
                e = e.getParent();
            }
        } finally {
            session.close();
        }
:
}

で、結果はこんな感じ。

4:valueB-4
3:valueA-3
2
1:valueA-1

2だけおかしい!!

上記プログラムでクラス名も一緒に出力するように変えてみました。
結果は、

4:valueB-4:entity.EntityB
3:valueA-3:entity.EntityA
2:entity.Entity$$EnhancerByCGLIB$$80064be4
1:valueA-1:entity.EntityA

こんな感じ。
2だけプロキシクラスで帰ってきてます。

何故でしょうね。
SQLを見てみました。

select 
  entity0_.ID as ID6_1_, 
  entity0_.PARENT_ID as PARENT2_6_1_, 
  entity0_1_.VALUE_B as VALUE2_7_1_, 
  entity0_2_.VALUE_A as VALUE2_8_1_, 
  case 
    when entity0_1_.ID is not null then 1 
    when entity0_2_.ID is not null then 2 
    when entity0_.ID is not null then 0 
  end as clazz_1_,
  entity1_.ID as ID6_0_, 
  entity1_.PARENT_ID as PARENT2_6_0_, 
  entity1_1_.VALUE_B as VALUE2_7_0_, 
  entity1_2_.VALUE_A as VALUE2_8_0_, 
  case 
    when entity1_1_.ID is not null then 1 
    when entity1_2_.ID is not null then 2 
    when entity1_.ID is not null then 0 
  end as clazz_0_ 
from 
  XXX.ENTITY entity0_ left outer join 
    XXX.ENTITY_B entity0_1_ on 
      entity0_.ID=entity0_1_.ID left outer join 
        XXX.ENTITY_A entity0_2_ on 
          entity0_</span>.ID=entity0_2_.ID left outer join 
            XXX.ENTITY entity1_ on 
              entity0_.PARENT_ID=entity1_.ID left outer join 
                XXX.ENTITY_B entity1_1_ on 
                  entity1_.ID=entity1_1_.ID left outer join 
                    XXX.ENTITY_A entity1_2_ on entity1_.ID=entity1_2_.ID 
where 
  entity0_.ID=?

だから、結果は

ID6_1_ PARENT2_6_1_ VALUE2_7_1_ VALUE2_8_1_ clazz_1_ ID6_0_ PARENT2_6_0_ VALUE2_7_0_ VALUE2_8_0_ clazz_0_
4 3 valueB-4   1 3 2   valueA-3 12

と返ってくる。

この結果から、Hibernateはオブジェクトを生成するわけですが、以下のことまでやってくれます。

  1. IDが4のオブジェクトを生成しようとする。clazz_1_が1なので、EntityBオブジェクトとして生成する
  2. valueB-4をvalueBにセットする
  3. IDが4のEntityオブジェクトのparent(ID=3)のオブジェクトを生成しようとする。clazz_0_が1なのでEntityBオブジェクトとして生成する
  4. valueA-3をvalueAにセットする
  5. IDが3のEntityオブジェクトのparent(ID=2)のオブジェクトを生成しようとする。が全部読み込んでないのでプロキシクラスを生成する

素晴らしい!!パーフェクトです。なんらおかしいことはありません。
ありませんが、動きがきしょい。
じゃぁ、せめて、else句だけ以下のように書き換えて見ましょう。

e = (Entity)session.get(Entity.class, e.getId());
System.out.println(e.getId() + ":" + e.getClass().getName());

結果は、、、

4:valueB-4:entity.EntityB
3:valueA-3:entity.EntityA
2:entity.Entity$$EnhancerByCGLIB$$80064be4
1:valueA-1:entity.EntityA

変わってません!!
これは、さすがに完全にきしょい!!!

どーもプロキシクラスで生成したオブジェクトそのものをセッションでキャッシュしているようです。
Hibernateの秀逸なキャッシュ機構が仇となりました。
クラス継承さえしてなければ問題はないのです。
クラス継承をしていなければ、lazy loadingが発生した際に、DBから情報を引っ張ってきて、
プロキシクラスオブジェクトに値はセットされるのですから。
実際、ID=1のオブジェクトまで取得できているのはその証左に他なりません。

しかし、クラス継承している場合は若干状況がことなります。
プロキシクラスは「プロキシクラス extends Entity」である以上、valueAも、valueBもセットすることは出来ないのです。
Javaである以上、動的にオブジェクトのクラスを変更することは出来ません。
これは、ちょっとクリティカルです。


手っ取り早い解決方法は、evictメソッドでキャッシュを破棄することですが、いちいちevictするのはバグの元です。
それに、破棄してほしくないものとの区別をするのは結構面倒なのです。
と言うことで、evict使用は却下です。


ちなみに求める動作はこんな感じですかね〜

  • プロキシじゃなければじゃんじゃんキャッシュしてほしい
  • プロキシも主として取得しない場合はキャッシュしても良い(しなくても良い)
  • 基本は、lazy loadingのまま。一気に取得されても困るケースが多いはず
  • 主として返されるオブジェクトはキャッシュがあっても破棄して実オブジェクトを返して欲しい

これらを満たすために、いろいろ調べたところ、org.hibernate.event.def.DefaultLoadEventListenerを改造するのがよさそうです。
#なんで、ここに行き着いたかは、長い道のりだったので省略。
DefaultLoadEventListenerを継承して、DefaultEventListenerExtと言うのを作成してみました。

public class LoadEventListenerExt extends DefaultLoadEventListener {
    private static final long serialVersionUID = 1L;

    @Override
    protected Object proxyOrLoad(LoadEvent event, EntityPersister persister,
            EntityKey keyToLoad, LoadType options) throws HibernateException {

        final PersistenceContext persistenceContext = event.getSession().getPersistenceContext();
        persistenceContext.removeProxy(keyToLoad);
        return super.proxyOrLoad(event, persister, keyToLoad, options);
    }
}

みそはproxyOrLoadメソッドを呼び出す前にキャッシュを強制破棄してるところですね。

これで、実処理に入るときはプロキシはいない子として処理されます。
データベースから取得してきた主たるオブジェクトは、sessionでキャッシュしてるので、プロキシを破棄してもDBまでは取りにいかない実装のようでなかなかグーです。


このリスナを使用するように設定を変えて実行してみました。
リスナを加えるには、「config.buildSessionFactory();」を実行する直前に以下の行を加えればOK。

config.setListener("load", new LoadEventListenerExt());

で、早速実行してみた。

4:valueB-4:entity.EntityB
3:valueA-3:entity.EntityA
2:entity.EntityB
1:valueA-1:entity.EntityA

惜しい!else句には入ってますね。
でもこれはいいのですよ。
else句に入らないようにするには、最初のSQL実行時に再帰的にDBにアクセスする必要があるのでそんなの自動でされたらたまった物ではありません。
重要なのは、session.get()で取り直したときにプロキシではなくちゃんとしたクラスが返ることですね。
else句の最後にcontinueを加えて再度実行すると、

4:valueB-4:entity.EntityB
3:valueA-3:entity.EntityA
2:entity.EntityB
2:valueB-2:entity.EntityB
1:valueA-1:entity.EntityA

と、valueBに値も正しくセットされているのがわかります。
いい感じ!!
ID=3のparentには、相変わらずプロキシがセットされていますが実処理でこれがまずい場合は、取得しなおした親オブジェクトをparentにセットしてあげれば無問題。

最後に、ソースを再掲しますね。こんな感じです。

:
    public static void main(String[] args) throws Exception {
        Configuration config = new Configuration();
        config.configure();
        config.setListener("load", new LoadEventListenerExt());
        SessionFactory sf = config.buildSessionFactory();
        Session session = sf.openSession();
        try {
            Entity e = (Entity)session.get(Entity.class, 4L);
            while(e != null) {
                if(e instanceof EntityA) {
                    EntityA eA = (EntityA)e;
                    System.out.println(eA.getId() + ":" + eA.getValueA() + ":" + e.getClass().getName());
                } else if(e instanceof EntityB) {
                    EntityB eB = (EntityB)e;
                    System.out.println(eB.getId() + ":" + eB.getValueB() + ":" + e.getClass().getName());
                } else {
                    e = (Entity)session.get(Entity.class, e.getId());
                    System.out.println(e.getId() + ":" + e.getClass().getName());
                    continue;
                }
                e = e.getParent();
            }
        } finally {
            session.close();
        }
    }
:

なお、LoadEventListenerExtに関してはまだ、功夫が足りていないので副作用があるかもしれません。
使用される際には自己責任で使用してください。


や、これまで書いたものも今後書いたものも基本的にはそうなんですけどね。。。