コードの「なぜ?」Q&A 第4章 CategoryDAO.java

Q. SELECT結果を、なぜResultSetのまま返さずList<DTO>にするのですか?

SELECT結果をResultSetからList<DTO>へ変換し、DAOの外へ渡す理由と処理の流れを確認します。

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をListadd()するのか
  • なぜtryがあるのにcatchがないのか
  • なぜreturn categoriestryの外にあるのか

この記事では、こうした疑問を一つずつ整理しながら、

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()そのものの働きは同じでも、周囲がifwhileかによって処理する件数が変わります。


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ブロックを抜けるときに、

  • ResultSet
  • PreparedStatement
  • Connection

といったリソースを自動的にクローズできます。

正常に処理が終わった場合だけでなく、途中で例外が発生した場合も、リソースを閉じるための仕組みです。

そして、try-with-resourcesでは、必要に応じてcatchfinallyを付けることもできますが、必ずしも同じ場所へcatchを書く必要はありません。

では、SQLExceptionが発生したらどうなるのでしょうか。


SQLExceptionthrowsで呼び出し側へ伝えている

メソッド宣言を見ると、

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 categoriestryの外なのか?

完成コードを見ると、

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を書かない形もある
  • SQLExceptionthrowsで呼び出し側へ伝えられる
  • JDBCリソースを閉じた後でも、DTOへコピーしたデータはListとして利用できる

私自身、JDBCを学び始めたころは、

while (rs.next()) {
    CategoryDTO category =
            new CategoryDTO(...);

    categories.add(category);
}

というコードを見ても、それぞれの行がなぜ必要なのか、すぐにはつながって見えませんでした。

しかし、

ResultSetの1行
    ↓
DTO 1個
    ↓
Listへ追加

という処理を繰り返しているだけだと分かると、ずっと理解しやすくなります。

ResultSet → List<DTO>という形を覚えるよりも、

データベースの検索結果を、JDBCの世界からJavaのオブジェクトの世界へ変換して持ち帰る処理

として理解すると、応用しやすくなると思います。