初めに
この記事は主に同期に対してSpringを紹介するために作成されました。
なるべく楽をしながらSpringと仲良くなるのが目的ですので、網羅的な説明は極力避け、目的を達成する手段が幾つかある中から、その内の1つを学ぶ形式で進みます。
また、記事中には正確性に欠ける表現が出現しますが、これも上記と同じ理由です。
前回までのあらすじ
- 【Train with Spring】1. プロジェクトのセットアップ
- 【Train with Spring】2. 画面遷移とパラメータの受け取り
- 【Train with Spring】3. データベースアクセス1
前回はSpring Data JPAを用いたデータベースへのアクセスを学びました。
今回はより一般的なエンティティをSpring Data JPAで扱う方法を学びます。
リレーション
前回は例として単一のユーザーエンティティを扱いましたが、実世界においてはユーザーエンティティは他のエンティティと関係(Relation, リレーション)を持っているのが一般的です。
今回はユーザーと携帯電話を管理するというシチュエーションを例に解説を進めます。
上記のシチュエーションでは、ユーザーと携帯電話のリレーションを記録する必要があります。
JDBCの場合
JDBCでは携帯電話テーブルには電話番号などの属性に加えて、所有しているユーザーの主キーをカラムとして用意すると思います。
そうすると、ユーザーの主キーを基にユーザーが持っている携帯電話を検索する事ができるようになり、携帯電話とユーザーの対応が取れます。
一対一のリレーション
では、ユーザーは必ず1つ携帯電話を持っているというリレーションをSpring Data JPAで定義しましょう。
携帯電話エンティティ
まずは携帯電話エンティティを定義します。Spring Data JPAにおけるエンティティはJDBCのDTOに対応する概念でしたね。
ユーザーエンティティと同じくエンティティパッケージ(entities
)に携帯電話エンティティ(Phone
)を定義します。
package com.example.trainwithspring.entities;
import java.time.OffsetDateTime;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Entity // このクラスがエンティティである事を明示
@Table // このエンティティをDBに保存する事を明示
@Getter // ゲッターを実装
@Setter // セッターを実装
@ToString(exclude = "user") // 文字列化を実装(userフィールドを除く)
@EqualsAndHashCode(exclude = "user") // オブジェクトの比較をフィールドの値ベースで行う(userフィールドを除く)
@NoArgsConstructor // 引数無しのコンストラクタを作る
@AllArgsConstructor // 全てのフィールドを引数に取るコンストラクタを作る
public class Phone {
@Id // このフィールドが主キーである事を明示
@Column(length = 13) // ハイフン有りの携帯電話番号を保存するのに十分な長さを定義(090-1234-1234⇒13文字)
private String number;
@OneToOne // 一対一対応である事を明示
private User user; // 所有者を格納するためのフィールド
@Column(updatable = false, nullable = false) // テーブルでこのカラムはNULLに出来ず更新も不可能である事を明示
@CreationTimestamp // データベースにエンティティを記録した初回に自動で現在日時を設定する
private OffsetDateTime createdAt;
@Column(nullable = false)
@UpdateTimestamp // データベースにエンティティを記録/上書きした時に自動で現在日時を設定する
private OffsetDateTime updatedAt;
}
JDBCでは携帯電話テーブルにユーザーの主キーを持たせたように、Spring Data JPAでもエンティティのフィールドとしてUser
を持たせます。
そして、携帯電話エンティティがユーザーエンティティと一対一対応している事を示す@OneToOne
アノテーションを付けます。
@ToString
アノテーションと@EqualsAndHashCode
アノテーションは、それぞれ.toString()
と.equals(object)
を良い感じに実装してくれます。
exclude
属性は後程説明します。
携帯電話リポジトリ
続けて、携帯電話リポジトリを作成します。
ユーザーリポジトリと同じように、CrudRepository<T, ID>
を継承したインターフェースを定義します。
package com.example.trainwithspring.repositories;
import java.util.Optional;
import org.springframework.data.repository.CrudRepository;
import com.example.trainwithspring.entities.Phone;
import com.example.trainwithspring.entities.User;
public interface PhoneRepository extends CrudRepository<Phone, String>{
Optional<Phone> findByUser(User user);
}
今回は、後で使うためにユーザーで携帯電話を検索するメソッドを定義しました。
ユーザーエンティティの変更
コンソールに表示するために、クラスに対して@ToString
アノテーションを付けます。
/* 省略 */
@ToString // 文字列化を実装
/* 省略 */
public class User {
/* 省略 */
}
ユーザーリポジトリの変更
また、後で使うためにユーザーリポジトリにも名前の完全一致検索を追加します。
package com.example.trainwithspring.repositories;
import java.util.List;
import java.util.Optional;
import org.springframework.data.repository.CrudRepository;
import com.example.trainwithspring.entities.User;
public interface UserRepository extends CrudRepository<User, Long> {
// 追加
Optional<User> findByName(String name);
List<User> findByNameContaining(String name);
}
動かす
さて、エンティティ(DTO)もリポジトリ(DAO)も実装したので、動かしてみましょう。
コントローラーを定義してWeb上で結果を閲覧しても良いですが、ここではもう少し簡単な方法を取ります。
ApplicationRunner
Springアプリケーションが起動した後に処理を行う方法の1つです。
ApplicationRunner
インターフェースを実装したクラスを作って@Component
アノテーションをクラスに付けると、run()
メソッドが起動時に自動実行されるようになります。
コンポーネントパッケージ(components
)を作成して、その中にUserAndPhoneDemo
クラスを定義します。
package com.example.trainwithspring.components;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import com.example.trainwithspring.entities.Phone;
import com.example.trainwithspring.entities.User;
import com.example.trainwithspring.repositories.PhoneRepository;
import com.example.trainwithspring.repositories.UserRepository;
import lombok.extern.slf4j.Slf4j;
@Slf4j // ロギング(ログの出力)のためのアノテーション
@Component // @Controllerや@Serviceと同じくSpringに勝手に上手くやってもらうためのアノテーション
public class UserAndPhoneDemo implements ApplicationRunner {
@Autowired
UserRepository userRepository;
@Autowired
PhoneRepository phoneRepository;
@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
// 新たに作成するユーザーの名前
String nameOfUser = "たかし";
// ユーザーインスタンスを作成して名前を設定
User user = new User();
user.setName(nameOfUser);
// ユーザーをDBに保存
userRepository.save(user);
// DBからユーザーを取得(見つからない場合は例外発生)
User savedUser = userRepository.findByName(nameOfUser).orElseThrow();
// 携帯電話インスタンスを作成して電話番号とユーザーを設定
Phone phone = new Phone();
phone.setNumber("090-1234-1234");
// 必ずDBから取得したユーザーを指定する(DB保存前のユーザーにはIDがまだ振られていないため)
phone.setUser(savedUser);
// 携帯電話をDBに保存
phoneRepository.save(phone);
// DBから携帯電話をユーザーで検索(見つからない場合は例外発生)
Phone savedPhone = phoneRepository.findByUser(savedUser).orElseThrow();
log.info("作成した携帯電話 ⇒ " + savedPhone.toString());
log.info("携帯電話に紐づいているユーザー ⇒ " + savedPhone.getUser().toString());
}
}
log.info()
の部分でEclipseのコンソールに情報を出力しています。
以下のように、携帯電話の所有者としてユーザーが記録されている事が分かります。
# 出力されたログ(本質的でない部分は一部省略)
作成した携帯電話 ⇒ Phone(number=090-1234-1234, createdAt=...)
携帯電話に紐づいているユーザー ⇒ User(id=1, name=たかし, createdAt=..., updatedAt=...)
また、JDBCでは携帯電話テーブルに記録された所有者の主キーでユーザーテーブルから所有者の情報を取得する必要がありますが、Spring Data JPAでは自動で情報を取得してくれます。
データの規模によってはこの機能が原因で処理効率の問題が発生しますが、このチュートリアルでは無視します。
ここでのポイントは、ユーザーの主キー(@GeneratedValue Long id
)が自動生成される設定なので、ユーザーのインスタンスを作った後に一度DBに保存してIDを発行する必要がある点です。
それ以外は通常のフィールドと同じように、携帯電話にユーザーを設定して保存すると、リレーションを記録できます。
作成されるテーブル
ちなみに、MySQLでは上記のように携帯電話エンティティを定義すると、以下のようなテーブルが作成されます。
CREATE TABLE `phone` (
`number` varchar(13) NOT NULL,
`created_at` datetime(6) NOT NULL,
`updated_at` datetime(6) NOT NULL,
`user_id` bigint DEFAULT NULL,
PRIMARY KEY (`number`),
KEY `FKb0niws2cd0doybhib6srpb5hh` (`user_id`),
CONSTRAINT `FKb0niws2cd0doybhib6srpb5hh` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
)
JDBCと同じように、携帯電話テーブルに所有者の主キーを格納するためのカラムが追加されていますね。
一対多/多対一のリレーション
次は、ユーザーが複数の携帯電話を持っている状況を考えます。
と言っても、そんなに多くの変更はありません。
携帯電話エンティティの変更
何となくアノテーションの名前から察していたかもしれませんが、@OneToOne
を@ManyToOne
に変更します。
// 変更前
// @OneToOne
// private User user;
// 変更後
@ManyToOne
private User user;
@ManyToOne
To
の左側が自分(携帯電話)、右側が相手(ユーザー)の数を示しています。
ユーザーが複数の携帯電話を持つ状況を考えているので、携帯電話はMany
、ユーザーはOne
になります。
携帯電話リポジトリの変更
併せて携帯電話リポジトリも変更します。
一人のユーザーから複数の携帯電話が取得できる可能性が生まれたため、戻り値の型をOptional<Phone>
からList<Phone>
にします。
このList
は一般的なJavaのリストjava.util.List
です。
package com.example.trainwithspring.repositories;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
import com.example.trainwithspring.entities.Phone;
import com.example.trainwithspring.entities.User;
public interface PhoneRepository extends CrudRepository<Phone, String>{
List<Phone> findByUser(User user);
}
動かす
では、先ほどのUserAndPhoneDemo
でユーザーに対し携帯電話を複数設定するようにして、動かして見ましょう。
package com.example.trainwithspring.components;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import com.example.trainwithspring.entities.Phone;
import com.example.trainwithspring.entities.User;
import com.example.trainwithspring.repositories.PhoneRepository;
import com.example.trainwithspring.repositories.UserRepository;
import lombok.extern.slf4j.Slf4j;
@Slf4j // ロギング(ログの出力)のためのアノテーション
@Component // @Controllerや@Serviceと同じくSpringに勝手に上手くやってもらうためのアノテーション
public class UserAndPhoneDemo implements ApplicationRunner {
@Autowired
UserRepository userRepository;
@Autowired
PhoneRepository phoneRepository;
@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
// 新たに作成するユーザーの名前
String nameOfUser = "たかし";
// ユーザーインスタンスを作成して名前を設定
User user = new User();
user.setName(nameOfUser);
// ユーザーをDBに保存
userRepository.save(user);
// DBからユーザーを取得(見つからない場合は例外発生)
User savedUser = userRepository.findByName(nameOfUser).orElseThrow();
log.info("作成したユーザー ⇒ " + savedUser.toString());
// 携帯電話インスタンスを作成して電話番号とユーザーを設定
Phone phone1 = new Phone();
phone1.setNumber("090-1111-1111");
phone1.setUser(savedUser);
phoneRepository.save(phone1);
Phone phone2 = new Phone();
phone2.setNumber("090-2222-2222");
phone2.setUser(savedUser);
phoneRepository.save(phone2);
// DBから携帯電話をユーザーで検索(見つからない場合は例外発生)
List<Phone> savedPhones = phoneRepository.findByUser(savedUser);
log.info("作成した携帯電話 ⇒ " + savedPhones.toString());
}
}
ログはこんな感じです。2台の携帯電話が出るようになりました。
# 出力されたログ(本質的でない部分は一部省略、手動で整形)
作成したユーザー ⇒ User(id=1, name=たかし, createdAt=..., updatedAt=...)
作成した携帯電話 ⇒ [
Phone(number=090-1111-1111, createdAt=..., updatedAt=...),
Phone(number=090-2222-2222, createdAt=..., updatedAt=...)
]
双方向リレーション
Spring Data JPAの強力な機能に双方向リレーションがあります。
先ほど、携帯電話エンティティのフィールドにユーザーエンティティを定義すると、携帯電話エンティティを取得した時点でユーザーエンティティも自動的に取得されました。
これと同様に、ユーザーエンティティを取得したら携帯電話エンティティを自動的に取得するように出来ます。
ユーザーエンティティの変更
@OneToMany
のついたList<Phone>
フィールドを定義します。
また、コンソールに表示しやすいように追加で色々アノテーションを追加します。
package com.example.trainwithspring.entities;
import java.time.OffsetDateTime;
import java.util.List;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Entity // このクラスがエンティティである事を明示
@Table // このエンティティをDBに保存する事を明示
@Getter // ゲッターを実装
@Setter // セッターを実装
@ToString(exclude = "phones") // 文字列化を実装(phonesフィールドを除く)
@EqualsAndHashCode // オブジェクトの比較をフィールドの値ベースで行う
@NoArgsConstructor // 引数無しのコンストラクタを作る
@AllArgsConstructor // 全てのフィールドを引数に取るコンストラクタを作る
public class User {
@Id // このフィールドが主キーである事を明示
@GeneratedValue // このフィールドは自動生成される事を明示
private Long id;
@Column(nullable = false, unique = true) // テーブルでこのカラムはNULLに出来ない且つユニークである事を明示
private String name;
@OneToMany(mappedBy = "user") // Phone.userでマッピング
private List<Phone> phones;
@Column(updatable = false, nullable = false) // テーブルでこのカラムはNULLに出来ず更新も不可能である事を明示
@CreationTimestamp // データベースにエンティティを記録した初回に自動で現在日時を設定する
private OffsetDateTime createdAt;
@Column(nullable = false)
@UpdateTimestamp // データベースにエンティティを記録/上書きした時に自動で現在日時を設定する
private OffsetDateTime updatedAt;
}
循環参照
さて、ここで双方向リレーションでは重要な注意点があります。
文字列化(.toString()
)など、それぞれのフィールドに対してメソッドを呼ぶようなメソッドでは、循環参照にならないように注意する必要があります。
User
クラスについている@ToString
アノテーションを見てください。
@ToString(exclude = "phones") // 文字列化を実装(phonesフィールドを除く)
exclude
属性によって、文字列化する際に参照するフィールドからphones
を除外しています。
これを行わず.toString()
をした場合、ユーザーの文字列化の際に携帯電話の文字列化も行われます。
今回はPhone
でもuser
を除外しているので循環参照は発生しないようになっていますが、念のため付けておきましょう。
ちなみに、両方のクラスでexclude
を指定しない場合、「ユーザーの携帯電話のユーザーの携帯電話のユーザーの携帯電話の……」と無限に文字列化が呼ばれ続け、最終的にスタックオーバーフローでアプリケーションが強制終了します。
Eclipseのコンソールに表示しきれないほど長いエラーログが出力されるので、そのようなエラーログを見かけたら循環参照を疑うと良いと思います。
動かす
では最後に、UserAndPhoneDemo
を次のように変更して動かして見ましょう。
package com.example.trainwithspring.components;
import java.util.ArrayList;
import java.util.List;
import javax.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import com.example.trainwithspring.entities.Phone;
import com.example.trainwithspring.entities.User;
import com.example.trainwithspring.repositories.PhoneRepository;
import com.example.trainwithspring.repositories.UserRepository;
import lombok.extern.slf4j.Slf4j;
@Slf4j // ロギング(ログの出力)のためのアノテーション
@Component // @Controllerや@Serviceと同じくSpringに勝手に上手くやってもらうためのアノテーション
public class UserAndPhoneDemo implements ApplicationRunner {
@Autowired
UserRepository userRepository;
@Autowired
PhoneRepository phoneRepository;
@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
// 新たに作成するユーザーの名前
String nameOfUser = "たかし";
// ユーザーインスタンスを作成して名前を設定
User user = new User();
user.setName(nameOfUser);
// ユーザーをDBに保存
userRepository.save(user);
// DBからユーザーを取得(見つからない場合は例外発生)
User savedUser = userRepository.findByName(nameOfUser).orElseThrow();
List<Phone> phones = new ArrayList<>();
// 携帯電話インスタンスを作成して電話番号とユーザーを設定
Phone phone1 = new Phone();
phone1.setNumber("090-1111-1111");
phone1.setUser(savedUser);
phoneRepository.save(phone1);
phones.add(phoneRepository.findById("090-1111-1111").orElseThrow());
Phone phone2 = new Phone();
phone2.setNumber("090-2222-2222");
phone2.setUser(savedUser);
phoneRepository.save(phone2);
phones.add(phoneRepository.findById("090-2222-2222").orElseThrow());
savedUser.setPhones(phones);
userRepository.save(savedUser);
// もう一度DBからユーザーを取得(見つからない場合は例外発生)
savedUser = userRepository.findByName(nameOfUser).orElseThrow();
log.info("作成したユーザー ⇒ " + savedUser.toString());
log.info(savedUser.getName() + "の携帯電話: " + savedUser.getPhones().toString());
}
}
ログには以下のように表示されて、ユーザーから携帯電話を参照可能になっている事が分かります。
作成したユーザー ⇒ User(id=1, name=たかし, createdAt=..., updatedAt=...)
たかしの携帯電話: [
Phone(number=090-1111-1111, createdAt=..., updatedAt=...), Phone(number=090-2222-2222, createdAt=..., updatedAt=...)
]
まとめ
今回はリレーションをSpring Data JPAで実装する方法を学びました。
次回はページネーションについて学びます。