Help us understand the problem. What is going on with this article?

Spring Data JPAのEntityの相互参照とその注意点

More than 3 years have passed since last update.

Springでデータを永続するためのフレームワークSpring Data JPAでは2つのEntityクラス間にリレーションを定義することができます。
その際、お互いのクラスに他方のオブジェクトの参照を定義することができ、これを相互参照といいます。
相互参照を使った際にいろいろと引っかかった点があったので、まとめていきます。
使ったバージョンはSpring Data JPA1.10.6で、Spring Boot1.4.3を使ってプロジェクトを作っています。

Entityクラス間の相互参照

Entityクラス間のリレーションは「@OneToOne」「@OneToMany」「@ManyToOne」「@ManyToMany」といったアノテーションを使って指定することができます。
「@OneToMany」「@ManyToOne」は表現するリレーションは同じように思えますが、そのEntityクラスから見て相手側のEntityクラスの関係はどうかを指定することができます。

具体的に、従業員と会社というデータの関係について見ていきます。
ER図にすると以下のようになります。

image

これを相互参照なEntityクラス化すると以下のようになります。

Company.java
@Entity
class Company {
    @Id
    private long id;

    private String name;

    @OneToMany // 多対1
    List<Employee> employees;

    /*アクセッサ略*/
}
Employee.java
@Entity
class Employee {
    @Id
    private long id;

    private String name;

    @ManyToOne //多
    Company company;

    /*アクセッサ略*/
}

相互参照なので、ER図にはないですが会社側が従業員クラスのリストを持っています。
従業員側の会社IDはクラス化した場合に会社クラスへの参照になります。

DBへの登録順序

ER図で示した通り、従業員エンティティ内の会社ID(会社クラスへの参照)は外部キー扱いになります。
一般的なRDBMSでは外部キーが設定されている場合、対象の要素が先に存在している必要があります。
JPAでもそれは同じで、従業員オブジェクトをDBに登録する前には会社オブジェクトが最初に登録されている必要があります。

@Autowired
EmployeeRepository empRepository;
@Autowired
CompanyRepository coRepository;

@PostConstruct
public init() {
    /*会社の登録*/
    Company co = new Company();
    co.setName("A社");
    coRepository.saveAndFlush(co);

    /*従業員の登録*/
    Employee emp = new Employee();
    emp.setName("佐藤");
    emp.setCompany(co); //登録済みの会社の設定が必要
    empRepository.saveAndFlush(emp);
}

データの格納

先ほどの処理によって、会社coと従業員empをDBに登録することができました。
しかし、coRepositoryからcoを取り出してemployeesの中身を見てみると佐藤さんのデータが格納されていません。
実は、相互参照のEntityを作ったとしても自動的に対応するオブジェクトの情報が対応するデータ構造に格納されるわけではありません。
そのため、A社のオブジェクトから佐藤さんのオブジェクトを参照するためには明示的にデータを設定してDBに保存する必要があります。

public init() {
    /*会社の登録*/
    /*従業員の登録*/
    co.setEmployees(Arrays.asList(emp));
    coRepository.saveAndFlush(co);
}

登録するためには先に対象となる従業員オブジェクトがDBに登録されている必要があります。
なぜこのようなことが起きるかというと、従業員から会社への参照は従業員テーブルの中にデータが存在していますが、会社から従業員への参照は別のテーブルで参照関係を管理しているためです。
すなわち、相互参照の2つのエンティティは次のような3つのテーブルで管理されていることになります。

image

mappedByによる参照

しかし、この方法では「従業員に対する会社」と「会社に対する従業員」という情報が別々に管理されてしまいます。
そこで、@OneToManyで用意されているmappedByオプションを使います。

Employee.java
/*省略*/
    @OneToMany(mappedBy="company") // 多対1
    List<Employee> employees;
/*省略*/

mappedByに指定する値は「対応する(@ManyToOneがついた)フィールド変数名」になります。

Compnay.java
/*省略*/
    @ManyToOne //多
    Company company;
/*省略*/

これによって、参照管理テーブルが作られなくなります。
参照管理テーブルがない場合、employeesの中身は従業員テーブルから自動的に作られます。
そのため、CompanysetEmployeesメソッドでEmployeeのリストを設定するという処理も不要になります。

循環参照

相互参照というのは、お互いがお互いに対するオブジェクト参照を持っているので循環参照になっているということです。
循環参照のオブジェクトを扱う際には参照を無限に展開してしまう可能性に注意する必要があります。
SpringのRestControllerやThyemelafでJavaScriptオブジェクト化する場合、各オブジェクトをjson形式に変換するという処理を行います。
しかし、循環参照があるオブジェクトをjson化する場合、無限に展開されてしまいます。
例えば、Employeeオブジェクトを展開した場合、以下のようになります。

{"id":1, "name":"佐藤", "company":{"id":1, "name":"A社", "employees":[{"id":1, "name":"佐藤", "company":{}}]}}

これは最終的にStackOverflowErrorが発生してプログラムが落ちます。

循環参照の無限展開回避

無限展開を回避する方法の一つはもちろん循環参照(相互参照)をやめることです。
Employeeクラスのcompany、もしくはCompanyクラスのemployeesフィールドを削除すれば循環参照が無くなるため、無限展開を回避することができます。
しかし、相互参照は便利なので相互参照を残したまま無限展開だけを回避したいという場合もあります。
そのためには、Springのオブジェクト展開処理の穴を突きます。
オブジェクト展開処理は、対象のgetter、すなわちget○○という名前のメソッドを展開していきます。
そのため、相互参照をしているオブジェクトのgetterの名前を変更すれば無限展開を回避することができます。
例えば、CompanyクラスのgetEmployeeメソッドの名前をacquireEmployeeという名前に変更します。
すると、acquireEmployeeメソッドはjson化の展開対象には含まれなくなるので無限展開を回避することができます。

Company.java
@Entity
class Company {
    /*省略*/

    /*privateに変更*/
    private getEmployees(){
        return employees;
    }

    /*getterを呼び出し*/
    public acquireEmpployees(){
        return getEmployees();
    }
}

上の例では、単純に名前を変更するのではなく元々のgetterをprivateにして、publicなacquireEmployeesから呼び出すようにしてみました。
get接頭辞が付くメソッドでもprivateなメソッドは展開されないので循環参照が回避されます。

{"id":1, "name":"佐藤", "company":[{"id":1, "name":"A社"}]}

RestControllerやJavaScriptオブジェクトではcompany内のEmployeesを見ることができなくなります。
しかし、acquireEmployeesメソッドを呼び出すことでJavaやThymeleafでは使うことができます。

thymeleaf
<ul>
    <li th:each="emp: ${company.acquireEmployees()}" th:text="${emp}"></li>
</ul>

おまけ:json化の展開について

上で述べたように、json化の処理はpublicかつgetを接頭辞として持つメソッドを呼び出し、展開を行います。
逆にいえば、publicかつgetを接頭辞として持つメソッドであればそれが返す情報をフィールドに持っていなかったとしてもjson化の際にはオブジェクトのプロパティに含まれるということです。
例えば、会社に所属する従業員の数をプロパティに含めたい場合はgetEmployeeNumberメソッドを追加します。

Company.java
@Entity
class Company {
    /*省略*/

    private getEmployees(){
        return employees;
    }

    public getEmployeeNumber(){
        return getEmployees().size();
    }
}
{"id":1, "name":"佐藤", "company":[{"id":1, "name":"A社", "employeeNumber":1}]}

これを利用すると、循環参照が発生するようなオブジェクトをjsonに含めることができなくてもデータの代表値などは返すことができます。
また、参照を排除した内部クラスを定義することで各データを完全に返すこともできます(面倒ですが)。

参考ページ

@OneToManyで相互参照したEntityをThymeleafを使ってJavaScript内で呼び出すとStackOverFlowする件
JPAによるリレーション: @OneToManyと@ManyToOne
初めてのJPA--シンプルで使いやすい、Java EEのデータ永続化機能の基本を学ぶ

frost_star
まだまだ半人前プログラマー。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした