JavaでJDBCを学び始めたころ、SELECT文を実行してResultSetを取得するところまでは、比較的すんなり理解できました。
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
System.out.println(
rs.getInt("category_id")
+ " "
+ rs.getString("category_name"));
}
SELECTした結果を、その場でコンソールへ表示するだけなら、これで動きます。
ところが、DAOを使ってデータベースアクセスを別クラスへ分けようとしたとき、私自身が次のところで戸惑いました。
「DAOで取得したSELECT結果を、Mainへどうやって返せばよいのだろう?」
最初に思いつくのは、
return rs;
つまり、ResultSetをそのまま返す方法です。
しかし、実際のコードではそうせず、
List<CategoryDTO>
という形に変換して返すことがあります。
さらに、そのコードを見ると、
List<CategoryDTO> categories = new ArrayList<>();
while (rs.next()) {
CategoryDTO category = new CategoryDTO(
rs.getInt("category_id"),
rs.getString("category_name"));
categories.add(category);
}
return categories;
と書かれています。
初めて見たとき、私はいくつもの疑問を持ちました。
- なぜ
ResultSetをそのまま返さないのか - なぜ最初に空の
ArrayListを作るのか - なぜ
if (rs.next())ではなくwhile (rs.next())なのか - なぜ1行読むたびにDTOを
newするのか - なぜDTOを
Listへadd()するのか - なぜ
tryがあるのにcatchがないのか - なぜ
return categoriesはtryの外にあるのか
この記事では、こうした疑問を一つずつ整理しながら、
JDBCのSELECT結果をResultSetからList<DTO>へ変換し、Java側へ持ち帰る基本的な考え方
を見ていきます。
今回使うテーブル
例として、PostgreSQLに次のcategoriesテーブルがあるものとします。
CREATE TABLE categories (
category_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_name VARCHAR(10) NOT NULL
);
例えば、次のようなデータが入っているとします。
category_id | category_name
------------+--------------
1 | 給与
2 | 配当金
3 | 雑収入
4 | 食費
5 | 住居費
実行するSELECT文は単純です。
SELECT category_id, category_name
FROM categories
ORDER BY category_id;
JDBCでは、例えば次のように実行できます。
PreparedStatement pstmt =
conn.prepareStatement(SELECT_ALL_SQL);
ResultSet rs =
pstmt.executeQuery();
ここまでは、SELECTの基本的な処理です。
問題は、この後です。
SELECT結果をその場で表示するだけなら簡単
DAOを使わず、SELECTした場所でそのまま表示するなら、次のように書けます。
while (rs.next()) {
System.out.println(
"ID: "
+ rs.getInt("category_id")
+ ", 分類名: "
+ rs.getString("category_name"));
}
処理の流れは明快です。
SELECTする
↓
ResultSetを取得する
↓
1行読む
↓
その場で表示する
↓
次の行を読む
このプログラムだけを見ると、DTOもListも必要ありません。
ところが、データベースアクセスをDAOへ分けると話が変わります。
例えば、次のようなメソッドを作りたいとします。
public ??? getAllCategories()
throws SQLException {
...
}
DAOの中でSELECTし、その結果を呼び出し側へ返したい。
では、戻り値の???には何を指定すればよいのでしょうか。
ResultSetをそのままreturnすればよい?
最初は、次のように考えたくなるかもしれません。
public ResultSet getAllCategories()
throws SQLException {
ResultSet rs =
pstmt.executeQuery();
return rs;
}
SELECT結果はResultSetに入っているのだから、それをそのまま返せばよさそうです。
しかし、ResultSetは単なるデータの箱ではありません。
JDBCでデータベースアクセスを行う際には、通常、
Connection
↓
PreparedStatement
↓
ResultSet
というリソースを使います。
実際には、次のようにtry-with-resourcesでまとめて管理することが多いでしょう。
try (Connection conn =
DatabaseConnection.getConnection();
PreparedStatement pstmt =
conn.prepareStatement(SELECT_ALL_SQL);
ResultSet rs =
pstmt.executeQuery()) {
...
}
ResultSetは、このデータベースアクセス処理の中で使っているJDBCのオブジェクトです。
そこで、DAOの外へResultSetそのものを持ち出すのではなく、
DAOの中で必要なデータをJavaの普通のオブジェクトへ詰め替え、そのJavaオブジェクトを返す
という方法を考えます。
ここでDTOが登場します。
1行分のデータを入れるCategoryDTO
今回は、categoriesテーブルの1行分のデータを保持するクラスとして、CategoryDTOを使います。
説明に必要な部分だけにすると、次のようなクラスです。
public class CategoryDTO {
private int categoryId;
private String categoryName;
public CategoryDTO(
int categoryId,
String categoryName) {
this.categoryId = categoryId;
this.categoryName = categoryName;
}
public int getCategoryId() {
return categoryId;
}
public String getCategoryName() {
return categoryName;
}
}
今回のポイントは、DTOそのものの設計ではありません。
重要なのは、
データベースの1行を、Javaの1オブジェクトとして表す
という考え方です。
例えば、ResultSetの現在行が、
category_id = 4
category_name = 食費
なら、
CategoryDTO category =
new CategoryDTO(
4,
"食費");
というJavaオブジェクトへ変換できます。
図にすると、
PostgreSQLの1行
category_id = 4
category_name = 食費
↓
CategoryDTO 1個
categoryId = 4
categoryName = "食費"
です。
複数行ならList<CategoryDTO>へ集める
SELECT結果が1行だけなら、CategoryDTOを1個返す方法も考えられます。
しかし、今回のSELECT文は、
SELECT category_id, category_name
FROM categories
ORDER BY category_id;
です。
複数行が返ってきます。
そこで、
1行目 → CategoryDTO
2行目 → CategoryDTO
3行目 → CategoryDTO
4行目 → CategoryDTO
...
と変換し、それらを一つにまとめます。
そのために使うのが、
List<CategoryDTO>
です。
つまり、
DBの1行
↓
DTO 1個
DBの複数行
↓
List<DTO>
という関係です。
これが、この記事で最も重要な考え方です。
解決方法の全体像
細かなコードを見る前に、処理全体を確認しておきましょう。
PostgreSQL
↓
SELECT
↓
ResultSet
↓
while (rs.next())
↓
現在行のデータを取得
↓
CategoryDTOをnew
↓
Listへadd
↓
次の行へ
↓
すべて読み終わったら
↓
List<CategoryDTO>をreturn
もっと短くすると、
ResultSet
↓
1行 → DTO
↓
複数DTOをListへ集める
↓
List<DTO>を返す
となります。
完成コードを先に見る
CategoryDAOの検索メソッド全体は、次のようになります。
private static final String SELECT_ALL_SQL =
"SELECT category_id, category_name "
+ "FROM categories ORDER BY category_id";
public List<CategoryDTO> getAllCategories()
throws SQLException {
List<CategoryDTO> categories =
new ArrayList<>();
try (Connection conn =
DatabaseConnection.getConnection();
PreparedStatement pstmt =
conn.prepareStatement(SELECT_ALL_SQL);
ResultSet rs =
pstmt.executeQuery()) {
while (rs.next()) {
CategoryDTO category =
new CategoryDTO(
rs.getInt("category_id"),
rs.getString("category_name"));
categories.add(category);
}
}
return categories;
}
コード自体はそれほど長くありません。
しかし、初めて見ると、
List<CategoryDTO> categories =
new ArrayList<>();
while (rs.next()) {
CategoryDTO category =
new CategoryDTO(...);
categories.add(category);
そして、
return categories;
が、それぞれなぜ必要なのか分かりにくいかもしれません。
ここから一つずつ見ていきます。
1. 最初に空のListを作る
まず、
List<CategoryDTO> categories =
new ArrayList<>();
です。
この時点では、まだSELECT結果を一行も読み込んでいません。
したがって、categoriesの中身は空です。
List<CategoryDTO>
[]
この空のListへ、これからResultSetから作ったCategoryDTOを順番に追加していきます。
イメージとしては、
最初
[]
↓ 1行目を読む
[DTO]
↓ 2行目を読む
[DTO, DTO]
↓ 3行目を読む
[DTO, DTO, DTO]
です。
先に入れ物を用意しておき、SELECT結果を読みながら中身を増やしていきます。
2. executeQuery()でResultSetを取得する
SELECT文を実行するのが、
ResultSet rs =
pstmt.executeQuery();
です。
例えば、今回の検索結果が、
category_id | category_name
------------+--------------
1 | 給与
2 | 配当金
3 | 雑収入
4 | 食費
5 | 住居費
だったとします。
ResultSetからこれらの行を順番に読み取ります。
ただし、ResultSetを取得した直後は、まだ1行目のデータを直接読める位置にはありません。
そこで、
rs.next()
を使います。
3. なぜwhile (rs.next())なのか?
今回のコードでは、
while (rs.next()) {
...
}
となっています。
rs.next()は、ResultSetのカーソルを次の行へ進めます。
同時に、
- 次の行があれば
true - 次の行がなければ
false
を返します。
例えば5行の検索結果があれば、
最初
↓
1行目 → true
↓
2行目 → true
↓
3行目 → true
↓
4行目 → true
↓
5行目 → true
↓
次の行なし → false
となります。
そのため、
while (rs.next()) {
...
}
と書けば、検索結果がある間、すべての行を順番に処理できます。
if (rs.next())ではだめなのか?
1行だけ処理するなら、
if (rs.next()) {
...
}
でも構いません。
しかし、今回欲しいのは分類一覧です。
複数行すべてを読みたいので、
while (rs.next()) {
を使います。
違いは、
if (rs.next())
↓
1行あるか確認して、その1行を処理
while (rs.next())
↓
行がある限り繰り返して、すべて処理
です。
rs.next()そのものの働きは同じでも、周囲がifかwhileかによって処理する件数が変わります。
4. ResultSetの現在行からDTOを作る
whileの中では、
CategoryDTO category =
new CategoryDTO(
rs.getInt("category_id"),
rs.getString("category_name"));
としています。
例えば現在行が、
category_id = 4
category_name = 食費
なら、
rs.getInt("category_id")
は、
4
を取得します。
そして、
rs.getString("category_name")
は、
食費
を取得します。
その2つをコンストラクタへ渡して、
new CategoryDTO(
4,
"食費")
というオブジェクトを作ります。
つまり、この処理は、
ResultSetの現在行
category_id = 4
category_name = 食費
↓
Javaのオブジェクト
CategoryDTO
categoryId = 4
categoryName = "食費"
への変換です。
なぜ1行読むたびにDTOをnewするのか?
ここも、私が初めて学んだときに少し考えたところです。
コードは、
while (rs.next()) {
CategoryDTO category =
new CategoryDTO(...);
categories.add(category);
}
となっています。
なぜループの外でDTOを一つ作り、それを使い回さないのでしょうか。
理由は、
検索結果の各行を、それぞれ独立したJavaオブジェクトとして保持したいから
です。
例えば、
1 | 給与
2 | 配当金
3 | 雑収入
という3行があれば、
1行目
↓
CategoryDTO A
categoryId = 1
categoryName = "給与"
2行目
↓
CategoryDTO B
categoryId = 2
categoryName = "配当金"
3行目
↓
CategoryDTO C
categoryId = 3
categoryName = "雑収入"
と、3個の異なるDTOを作ります。
だから、
new CategoryDTO(...)
はwhileの中にあります。
5. 作ったDTOをListへ追加する
DTOを作ったら、
categories.add(category);
でListへ追加します。
例えば、1行目の「給与」を処理した後は、
categories
[
CategoryDTO(1, "給与")
]
となります。
2行目の「配当金」を処理すると、
categories
[
CategoryDTO(1, "給与"),
CategoryDTO(2, "配当金")
]
3行目まで処理すると、
categories
[
CategoryDTO(1, "給与"),
CategoryDTO(2, "配当金"),
CategoryDTO(3, "雑収入")
]
というように増えていきます。
SELECT結果を最後まで読み終わるころには、
ResultSetの複数行
↓
複数のCategoryDTO
↓
List<CategoryDTO>
という変換が完了しています。
ResultSet → DTO → List<DTO>を図で整理する
ここまでの処理をまとめると、次のようになります。
PostgreSQL
│
│ SELECT
↓
ResultSet
│
├─ 1行目
│ ↓
│ CategoryDTO
│ ↓
│ Listへadd
│
├─ 2行目
│ ↓
│ CategoryDTO
│ ↓
│ Listへadd
│
├─ 3行目
│ ↓
│ CategoryDTO
│ ↓
│ Listへadd
│
└─ ...
↓
List<CategoryDTO>
↓
Main
この記事のタイトルにある、
ResultSetからList<DTO>へ変換する
とは、結局この処理です。
ここで疑問――tryがあるのにcatchがない
ここまでコードを読んで、もう一つ不思議に感じるところがあります。
try (Connection conn =
DatabaseConnection.getConnection();
PreparedStatement pstmt =
conn.prepareStatement(SELECT_ALL_SQL);
ResultSet rs =
pstmt.executeQuery()) {
while (rs.next()) {
...
}
}
tryがあります。
しかし、その後に、
catch (...) {
がありません。
Javaの例外処理を最初に学んだとき、私は、
try {
...
} catch (Exception e) {
...
}
のように、
「tryとcatchはセットで書くもの」
という印象を持っていました。
そのため、
「tryがあるのにcatchがなくてよいのか?」
と戸惑いました。
今回は通常のtryではなくtry-with-resources
今回使っているのは、
try {
...
}
という通常のtry文ではありません。
次の形です。
try (リソース) {
...
}
これはtry-with-resources文です。
今回なら、
try (Connection conn = ...;
PreparedStatement pstmt = ...;
ResultSet rs = ...) {
...
}
となっています。
この構文を使うと、tryブロックを抜けるときに、
ResultSetPreparedStatementConnection
といったリソースを自動的にクローズできます。
正常に処理が終わった場合だけでなく、途中で例外が発生した場合も、リソースを閉じるための仕組みです。
そして、try-with-resourcesでは、必要に応じてcatchやfinallyを付けることもできますが、必ずしも同じ場所へcatchを書く必要はありません。
では、SQLExceptionが発生したらどうなるのでしょうか。
SQLExceptionはthrowsで呼び出し側へ伝えている
メソッド宣言を見ると、
public List<CategoryDTO> getAllCategories()
throws SQLException {
となっています。
ここに、
throws SQLException
があります。
つまり、このメソッドではSQLExceptionをその場でcatchせず、
呼び出し側へ例外を伝える
という方針です。
流れを図にすると、
CategoryDAO#getAllCategories()
│
│ SQLException発生
↓
DAOではcatchしない
↓
throws SQLException
↓
呼び出し側へ伝える
となります。
今回のコードでは、
-
JDBCリソースの後始末
→ try-with-resources -
SQLExceptionをどこで処理するか
→throws SQLExceptionで呼び出し側へ任せる
と考えると分かりやすくなります。
呼び出し側ではcatchしている
DAOから返されたSQLExceptionは、さらに呼び出し側へ伝えることができます。
例えばMain側では、
private static void getAllCategories()
throws SQLException {
List<CategoryDTO> categories =
categoryDAO.getAllCategories();
for (CategoryDTO category : categories) {
System.out.println(
"ID: "
+ category.getCategoryId()
+ ", 分類名: "
+ category.getCategoryName());
}
}
とし、このメソッド自身も、
throws SQLException
で上へ伝えます。
そして、最終的にmain()側で、
try {
getAllCategories();
} catch (SQLException e) {
System.err.println(
"データベース操作中にエラーが発生しました.");
e.printStackTrace();
}
のようにcatchできます。
つまり、
CategoryDAO
│
│ throws SQLException
↓
MainのgetAllCategories()
│
│ throws SQLException
↓
main()
│
└─ catch(SQLException)
という流れです。
tryのすぐ後ろにcatchがないからといって、例外処理そのものがなくなっているわけではありません。
なぜreturn categoriesはtryの外なのか?
完成コードを見ると、
try (...) {
while (rs.next()) {
...
categories.add(category);
}
}
return categories;
となっています。
return categoriesはtry-with-resourcesの外にあります。
これにも意味があります。
tryブロックを抜けると、
ResultSet
PreparedStatement
Connection
というJDBCのリソースはクローズされます。
しかし、その前に必要なデータはすでに、
CategoryDTO
へコピーされています。
そして、それらのDTOは、
List<CategoryDTO>
へ保存されています。
したがって、try終了後は、
ResultSet → クローズ済み
PreparedStatement → クローズ済み
Connection → クローズ済み
しかし
List<CategoryDTO>
↓
Javaの普通のオブジェクトとして残っている
という状態になります。
そこで、
return categories;
でListを返せます。
これがResultSetをそのまま返さない理由にもつながる
最初の疑問へ戻ります。
「ResultSetをそのままreturnすればよいのでは?」
という疑問です。
今回の方法では、DAOの中で、
Connection
PreparedStatement
ResultSet
を使ってデータベースへアクセスします。
そして、ResultSetの内容を、
CategoryDTO
へコピーします。
複数行なら、
List<CategoryDTO>
へまとめます。
その後、JDBCのリソースを閉じてからListを返します。
つまり、
DAOの中
Connection
PreparedStatement
ResultSet
↓ データを変換
List<CategoryDTO>
↓
JDBCリソースを閉じる
↓
List<CategoryDTO>だけをDAOの外へ返す
という形です。
DAOの外へ持ち出すのは、データベースアクセス中のResultSetではなく、
すでにJavaのデータへ変換されたList<CategoryDTO>
です。
Main側はResultSetを知らなくてよい
DAOが、
List<CategoryDTO>
を返すようにすると、Main側は次のように書けます。
List<CategoryDTO> categories =
categoryDAO.getAllCategories();
for (CategoryDTO category : categories) {
System.out.println(
"ID: "
+ category.getCategoryId()
+ ", 分類名: "
+ category.getCategoryName());
}
ここには、
ResultSet
も、
PreparedStatement
も、
Connection
も登場しません。
Main側が扱うのは、
CategoryDTO
List<CategoryDTO>
というJavaのオブジェクトだけです。
比較すると、違いが分かりやすくなります。
ResultSetをそのまま扱う場合
Main
↓
ResultSet
↓
rs.next()
rs.getInt()
rs.getString()
Main側もJDBCの扱い方を知る必要があります。
List<DTO>へ変換する場合
DAO
↓
ResultSet
↓
DTOへ変換
↓
List<DTO>
↓
Main
Main側は、DTOのgetterなどを使ってJavaのデータとして扱えます。
検索結果が0件ならどうなる?
この形には、もう一つ分かりやすい点があります。
最初に、
List<CategoryDTO> categories =
new ArrayList<>();
と空のListを作っています。
もしSELECT結果が0件なら、
while (rs.next()) {
は一度も実行されません。
したがって、
categories = []
のままです。
そして、
return categories;
で空のListが返ります。
呼び出し側では、
if (categories.isEmpty()) {
System.out.println(
"表示する分類がありません。");
}
のように判断できます。
0件の場合でも、
ResultSetをどうするか
をMain側で考える必要はありません。
DAOから返されたListが空かどうかを確認すればよいわけです。
完成コードをもう一度読む
ここまで理解したところで、完成コードをもう一度見てみます。
private static final String SELECT_ALL_SQL =
"SELECT category_id, category_name "
+ "FROM categories ORDER BY category_id";
public List<CategoryDTO> getAllCategories()
throws SQLException {
List<CategoryDTO> categories =
new ArrayList<>();
try (Connection conn =
DatabaseConnection.getConnection();
PreparedStatement pstmt =
conn.prepareStatement(SELECT_ALL_SQL);
ResultSet rs =
pstmt.executeQuery()) {
while (rs.next()) {
CategoryDTO category =
new CategoryDTO(
rs.getInt("category_id"),
rs.getString("category_name"));
categories.add(category);
}
}
return categories;
}
処理を言葉にすると、
① 空のListを用意する
List<CategoryDTO>
↓
② SELECTを実行する
executeQuery()
↓
③ ResultSetを1行ずつ読む
while (rs.next())
↓
④ 現在行をDTOへ変換する
new CategoryDTO(...)
↓
⑤ DTOをListへ追加する
categories.add(...)
↓
⑥ ResultSetをすべて読み終わる
↓
⑦ JDBCリソースを閉じる
↓
⑧ List<CategoryDTO>を返す
return categories
となります。
コードの一行一行が、一本の処理としてつながって見えてきます。
この変換を一言で表すと
ResultSetからList<DTO>への変換は、特別に難しい仕組みではありません。
やっていることは、
「データベースの1行をJavaの1オブジェクトへ変換し、それを複数件ならListへ集める」
ということです。
データベースの1行
↓
DTO 1個
データベースの複数行
↓
DTO 複数個
↓
List<DTO>
この対応関係が理解できると、
while (rs.next()) {
DTO dto = new DTO(...);
list.add(dto);
}
というコードを、単なる定型文として暗記する必要がなくなります。
まとめ
JDBCのSELECT結果をResultSetからList<DTO>へ変換する基本的な流れは、次のようになります。
SELECT
↓
executeQuery()
↓
ResultSet
↓
while (rs.next())
↓
現在行の値を取得
↓
DTOをnew
↓
Listへadd
↓
すべての行を処理
↓
List<DTO>をreturn
特に、最初は次の点が分かりにくいかもしれません。
-
ResultSetをそのまま返すのではなく、DAO内でJavaオブジェクトへ変換する - SELECT結果の1行をDTO 1個に対応させる
- 複数行なら複数のDTOを
Listへ集める -
while (rs.next())で検索結果を最後まで読む - 1行ごとに別のDTOを
newする - try-with-resourcesでは、同じ場所に
catchを書かない形もある -
SQLExceptionはthrowsで呼び出し側へ伝えられる - JDBCリソースを閉じた後でも、DTOへコピーしたデータは
Listとして利用できる
私自身、JDBCを学び始めたころは、
while (rs.next()) {
CategoryDTO category =
new CategoryDTO(...);
categories.add(category);
}
というコードを見ても、それぞれの行がなぜ必要なのか、すぐにはつながって見えませんでした。
しかし、
ResultSetの1行
↓
DTO 1個
↓
Listへ追加
という処理を繰り返しているだけだと分かると、ずっと理解しやすくなります。
ResultSet → List<DTO>という形を覚えるよりも、
「データベースの検索結果を、JDBCの世界からJavaのオブジェクトの世界へ変換して持ち帰る処理」
として理解すると、応用しやすくなると思います。
サンプルコードを試してみたい方へ
この記事で使用したcategoriesテーブルを含むPostgreSQLの学習環境とJavaのサンプルコードは、次のサポートサイトで公開しています。
create_rakuraku.sqlを利用すると、この記事の題材になっている学習用データベースrakurakuを作成できます。
Javaのサンプルコードも公開しているので、ResultSetからList<CategoryDTO>へ変換する処理を実際に動かして確認できます。
JDBCをコードを書きながら学びたい方へ
この記事のコードは、Kindle書籍 『実践Javaデータベースプログラミング: JDBC・PostgreSQLで学ぶ』 で扱っている題材をもとにしています。
書籍では、最小限のJDBC接続から始め、データベースから取得した複数行をList<DTO>へ変換する処理、DAOによるデータベースアクセスの分離、CRUD処理へと段階的にコードを発展させます。
完成したコードだけを見るのではなく、「なぜ、このコードが必要になるのか」を実際に手を動かしながら確認したい方には、続きとして読んでいただければと思います。