Spring Data JPAを使用して自作サービスを作成する過程で、Entity間のリレーションの表すには @OneToMany や @ManyToOne アノテーションを使用し、これらのアノテーションの付け方でリレーションが単方向か双方向かを表すことができることを知った。
この記事では、単方向と双方向の参照の違いについて以下のようなTaskとStatusの関係を例を用いてまとめていく。
+----------------+ 1 * +----------------+
| Status |-------------------------->| Task |
+----------------+ +----------------+
| - id: Long | | - id: Long |
| - name: String | | - title: String|
| | | - userId: Long |
| | | |
+----------------+ +----------------+
※まだ勉強中のため、間違いがあれば指摘いただけますと幸いです。。
単方向リレーション
@OneToMany の単方向リレーション
以下は、Status が複数の Task を持つケース。
Status から Task を参照し、Task 側には Status へのリファレンスがないため、
これを名前の通り「単方向」と呼ぶ。
@Entity
@Table(name = "statuses")
public class Status {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "statusId") // 外部キーとしてstatusIdカラムがtasksテーブルに追加される
private List<Task> tasks;
}
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
}
この場合、以下のようなテーブルが自動生成される。
-- 生成されるDDL
create table statuses (
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
create table tasks (
id bigint generated by default as identity,
title varchar(255),
status_id bigint,
primary key (id)
);
-- 自動で外部キー制約を作成してくれる
alter table if exists tasks
add constraint FKhp48vnj340wm1stg2kux5dqcx
foreign key (status_id) references statuses;
ここでそれぞれのデータを取得するためにリクエストを送ってみる。
まずはタスクの一覧を取得する"/api/tasks"にリクエストを送る。
すると、以下のSQLが実行される。
select t1_0.id,t1_0.title from tasks t1_0
Task側にはリレーションの記述を指定をしていないため、もちろんStatusに関するデータを持ってこない。
[
{
"id": 1,
"title": "開発"
},
{
"id": 2,
"title": "お出かけ"
},
{
"id": 3,
"title": "読書"
},
.
.
.
]
次にステータスの一覧を取得する"/api/statuses"にリクエストを送る。
すると、以下のSQLが実行される。
select s1_0.id,s1_0.name from statuses s1_0
select t1_0.status_id,t1_0.id,t1_0.title from tasks t1_0 where t1_0.status_id=?
select t1_0.status_id,t1_0.id,t1_0.title from tasks t1_0 where t1_0.status_id=?
select t1_0.status_id,t1_0.id,t1_0.title from tasks t1_0 where t1_0.status_id=?
select t1_0.status_id,t1_0.id,t1_0.title from tasks t1_0 where t1_0.status_id=?
そして、それぞれのステータスに紐づくタスクが取得できていることがわかる。
[
{
"id": 1,
"name": "未着手",
"tasks": [
{
"id": 1,
"title": "開発"
},
{
"id": 2,
"title": "お出かけ"
}
]
},
{
"id": 2,
"name": "進行中",
"tasks": [
{
"id": 5,
"title": "料理"
}
]
},
{
"id": 3,
"name": "完了",
"tasks": [
{
"id": 3,
"title": "読書"
},
{
"id": 4,
"title": "勉強"
}
]
},
{
"id": 4,
"name": "保留",
"tasks": []
}
]
@ManyToOne の単方向リレーション
今度は逆にTask側にStatusのリレーションを記述する。
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
private Status status;
}
@Entity
@Table(name = "statuses")
public class Status {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
すると以下のDDLが生成される。
create table statuses (
id bigint generated by default as identity,
name varchar(255),
primary key (id)
)
create table tasks (
id bigint generated by default as identity,
title varchar(255),
status_id bigint, primary key (id)
)
alter table if exists tasks
add constraint FK7rfie9afjter7xqs940p99roi
foreign key (status_id) references statuses
こちらもTaskテーブルにstatusIdが追加されていることがわかる。
ただ、先ほどと同様それぞれのデータを取得するエンドポイントにリクエストを送ってみる。
・/api/tasks
select t1_0.id,t1_0.status_id,t1_0.title from tasks t1_0
select t1_0.id,t1_0.status_id,t1_0.title from tasks t1_0
select s1_0.id,s1_0.name from statuses s1_0 where s1_0.id=?
select s1_0.id,s1_0.name from statuses s1_0 where s1_0.id=?
select s1_0.id,s1_0.name from statuses s1_0 where s1_0.id=?
[
{
"id": 1,
"title": "開発",
"status": {
"id": 1,
"name": "未着手"
}
},
{
"id": 2,
"title": "お出かけ",
"status": {
"id": 1,
"name": "未着手"
}
},
{
"id": 3,
"title": "読書",
"status": {
"id": 3,
"name": "完了"
}
},
{
"id": 4,
"title": "勉強",
"status": {
"id": 3,
"name": "完了"
}
},
{
"id": 5,
"title": "料理",
"status": {
"id": 2,
"name": "進行中"
}
}
]
・/api/statuses
select s1_0.id,s1_0.name from statuses s1_0
[
{
"id": 1,
"name": "未着手"
},
{
"id": 2,
"name": "進行中"
},
{
"id": 3,
"name": "完了"
},
{
"id": 4,
"name": "保留"
}
]
上記の通り、Task側ではStatusの情報まで取得できていて、
逆にStatus側にはTaskの情報は含まれていないることがわかる。
双方向リレーション
次に双方向リレーションについて試してみる。
単方向と同様、Entityにリレーションを記述する。
@Entity
@Data
@Table(name = "statuses")
public class Status {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "status")
private List<Task> tasks;
}
@Entity
@Data
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
private Status status;
}
すると、単方向参照の際と同様、Taskテーブル側にstatusIdが外部キーつとして作成される。
create table statuses (
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
create table tasks (
id bigint generated by default as identity,
title varchar(255),
status_id bigint,
primary key (id)
);
alter table if exists tasks
add constraint FKhp48vnj340wm1stg2kux5dqcx
foreign key (status_id) references statuses;
先ほどと同様、リクエストを送ってみる。
すると、以下のエラーが出力される。
Ignoring exception, response committed already:org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Document nesting depth (1001) exceeds the maximum allowed (1000, from`StreamWriteConstraints.getMaxNestingDepth()`)
上記は循環参照をしているため発生している。
返ってきたデータを見てみると以下のようになっている。
[
{
"id": 1,
"name": "未着手",
"tasks": [
{
"id": 1,
"title": "開発",
"status": {
"id": 1,
"name": "未着手",
"tasks": [
{
"id": 1,
"title": "開発",
"status": {
"id": 1,
"name": "未着手",
"tasks": [
.
.
.
]
名前の通り、Status が Task を持ち、Task が再度 Status を参照し、さらにStatusが・・・
というように、無限にデータがネストされる状況が発生する。
循環参照を回避するには?
@JsonBackReference と @JsonManagedReferenceを使用することで
双方向のリレーションを保ったまま循環参照を回避できるとのこと。
試しに以下のようにEntityクラスを修正する。
@Entity
public class Status {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "status")
@JsonManagedReference
private List<Task> tasks;
}
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "status_id")
@JsonBackReference
private Status status;
}
リクエストを送ってみる。
・/api/tasks
[
{
"id": 1,
"title": "開発"
},
{
"id": 2,
"title": "お出かけ"
},
{
"id": 3,
"title": "読書"
},
{
"id": 4,
"title": "勉強"
},
{
"id": 5,
"title": "料理"
}
]
・/api/statuses
[
{
"id": 1,
"name": "未着手",
"tasks": [
{
"id": 1,
"title": "開発"
},
{
"id": 2,
"title": "お出かけ"
}
]
},
{
"id": 2,
"name": "進行中",
"tasks": [
{
"id": 5,
"title": "料理"
}
]
},
{
"id": 3,
"name": "完了",
"tasks": [
{
"id": 3,
"title": "読書"
},
{
"id": 4,
"title": "勉強"
}
]
},
{
"id": 4,
"name": "保留",
"tasks": []
}
]
循環参照を回避することができた。
しかし、Task側ではStatusの情報を持てていない。
調べてみると以下の記載を見つけた。
@JsonManagedReference is the forward part of reference, the one that gets serialized normally.
@JsonBackReference is the back part of reference; it’ll be omitted from serialization.
The serialized Item object doesn’t contain a reference to the User object.
Also note that we can’t switch around the annotations. The following will work for the serialization:
But when we attempt to deserialize the object, it’ll throw an exception, as @JsonBackReference can’t be used on a collection.
JsonBackReference側はシリアライズ時に省略されるとのこと。
また、このアノテーションは1に@JsonManagedReference、多に@JsonBackReferenceをつける必要があるとのこと。
上記の記事を見てみるとほかにも循環参照を回避する術があるとのことだったので、
試したらまた記事にまとめようと思う。
参考記事
https://www.baeldung.com/jackson-bidirectional-relationships-and-infinite-recursion#bd-managed-back-reference
https://sora-tomita.hatenablog.com/entry/2017/08/15/001134
https://itpfdoc.hitachi.co.jp/manuals/link/cosmi_v0870/APKC/EU070312.HTM
https://codezine.jp/article/detail/5061