JDBCコードの「困った」から、設計原則とAI時代のコード統治を考える
なぜ、わざわざこんなクラスを作るのだろう?
Javaでデータベースプログラミングを学んでいると、やがて DTO や DAO という言葉が登場します。
DTOは「データを受け渡すためのオブジェクト」、DAOは「データベースへのアクセスを担当するオブジェクト」と説明されます。
説明を読めば、言葉の意味は分かります。
しかし、初めてDTOやDAOを学んだとき、
「なぜ、わざわざこんなクラスを作るのだろう?」
と思ったことはないでしょうか。
SQLを実行したいだけなのに、DTOクラスを作る。DAOクラスも作る。さらにデータベース接続用のクラスまで作る。
一つのクラスに書けば済みそうな処理が、いくつものクラスに分かれていきます。
「これが実務で使われる書き方です」 「DAOパターンを使いましょう」 「責務を分離しましょう」
そう説明されれば、その形を覚えることはできます。
しかし、それだけでは、なぜその形にする必要があるのかまでは、なかなか得心できません。
そこでこの記事では、最初からDTOやDAOが配置された完成形を見るのではなく、もっと素朴なJDBCプログラムから始めます。
プログラムへ少しずつ機能を追加し、その過程で何が問題になり、なぜコードを分けたくなるのかを追ってみます。
DTOやDAOが存在しないところから始めて、DTOやDAOが欲しくなるところまで進んでみましょう。
今回使う、小さなデータベース
題材には、JavaとPostgreSQLで作る簡単な出納帳を使います。データベース名は rakuraku とします。
分類を管理する 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 | 住居費
6 | 教育費
7 | 医療費
8 | 雑費
もう一つ、収入や支出を記録する cash_entries テーブルがあります。
categories
1
│
│ category_id
N
cash_entries
cash_entries には取引日、分類ID、摘要、収入額、支出額などを保持し、category_id で categories を参照します。
ただし、最初からこの2テーブルをすべて操作するわけではありません。
まずはもっと簡単なところから始めます。
JavaからPostgreSQLへ接続できることを確認するだけです。
まずは1クラスで書いてみる
JavaからPostgreSQLの rakuraku データベースへ接続する、最小限のプログラムを考えてみます。
package chap01;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class Main01 {
private static final String URL =
"jdbc:postgresql://localhost:54320/rakuraku";
private static final String USER = "postgres";
private static final String PASSWORD = "jdbc";
public static void main(String[] args) {
try (Connection conn = DriverManager.getConnection(
URL, USER, PASSWORD)) {
System.out.println("PostgreSQLへの接続に成功!!");
} catch (SQLException e) {
e.printStackTrace();
System.err.println("PostgreSQLへの接続に失敗!!");
}
}
}
実行結果はこれだけです。
PostgreSQLへの接続に成功!!
ポート番号を
54320にしている理由PostgreSQLのデフォルトポート番号は
5432です。 この実習環境では、学習者のPCにすでにPostgreSQLがインストールされ、5432が使われている場合でも共存できるよう、あえて54320を使用しています。 一般的な環境では、それぞれのPostgreSQLに設定されたポート番号を指定してください。
また、実務ではパスワードをソースコードへ直接記述するべきではありません。ここでは、まずJDBC接続そのものを理解するために、学習用として単純な形にしています。
ここで一つ大切なことがあります。
このプログラムを、すぐに「設計が悪い」と考える必要はありません。
確かに、データベースの接続情報も、接続処理も、結果を表示する処理も、一つのクラスに書かれています。
しかし、今の学習目的は、
「JavaからJDBCを使ってPostgreSQLへ接続すると、何が起こるのか」
を理解することです。
その目的に限れば、一つのファイルを上から下へ読めば処理全体を追えることには大きな価値があります。
接続情報を用意する
↓
DriverManager.getConnection()を呼ぶ
↓
Connectionを取得する
↓
成功メッセージを表示する
↓
try-with-resourcesでConnectionを閉じる
初めてJDBCを学ぶ人にとって、この単純さは理解を助けます。
もし最初から、
Main
↓
Service
↓
DAO
↓
DatabaseConnection
という構造を提示したらどうでしょう。
JavaからPostgreSQLへ接続する仕組みを学びたいだけなのに、複数のクラスを行き来しなければ処理全体を追えません。
小さいプログラムなら、分けない方が分かりやすいこともある。
少なくとも、この時点ではDTOもDAOも必要ありません。
まずは小さく、素朴に書きます。
最初の変化――データベース接続を別のクラスへ任せる
ただし、これから作ろうとしているのは「PostgreSQLへ接続できることを確認するだけのプログラム」ではありません。
分類を一覧表示したい。取引を登録したい。検索したい。更新や削除もしたい。
そうなれば、データベースへの接続は一度だけでは済みません。
Main01 を見ると、少なくとも二つの異なる仕事が入っています。
- どのデータベースへ、どのように接続するか
- 接続した結果をどう扱うか
そこで、「接続する」という仕事を別のクラスへ取り出してみます。
package chap02;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class DatabaseConnection {
private static final String URL =
"jdbc:postgresql://localhost:54320/rakuraku";
private static final String USER = "postgres";
private static final String PASSWORD = "jdbc";
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}
}
Main 側は次のようになります。
package chap02;
import java.sql.Connection;
import java.sql.SQLException;
public class Main02 {
public static void main(String[] args) {
try (Connection conn =
DatabaseConnection.getConnection()) {
System.out.println("PostgreSQLへの接続に成功!!");
} catch (SQLException e) {
e.printStackTrace();
System.err.println("PostgreSQLへの接続に失敗!!");
}
}
}
実行結果は変わりません。
しかし内部では、重要な変化が起きています。
Main02 から、
DriverManager.getConnection(URL, USER, PASSWORD)
という接続方法の詳細が消え、
DatabaseConnection.getConnection()
という一行になりました。
Main02 は、「データベースへどう接続するのか」を知らなくてもよくなったのです。
必要なのは、「接続が必要なら DatabaseConnection へ頼めばよい」ということだけです。
分けることで「知らなくてよいこと」が増える
クラスを二つにしたので、コードの総量だけを見れば少し増えています。ファイルも一つ増えました。
それでも分けた理由の一つは、それぞれのクラスが知らなければならないことを減らせるからです。
Main02
│
│ 接続が欲しい
↓
DatabaseConnection
│
│ 接続方法の詳細を引き受ける
↓
PostgreSQL
今後、接続先などを変更する必要が生じても、その知識が DatabaseConnection に集まっていれば、変更する場所を限定できます。
別のクラスからデータベースへ接続したくなった場合も、DatabaseConnection.getConnection() を利用できます。
つまり、コードを分けることには、
人間が一度に気にしなければならないことを減らす
という効果があります。
今回のように、異なる役割を分けて担当を明確にする考え方を 責務の分離(Separation of Concerns) と呼びます。また、一つのクラスが持つ責任を明確にする考え方は 単一責任の原則(SRP: Single Responsibility Principle) につながります。
ただし、ここでは順序が重要です。
最初に、
単一責任の原則があるから、クラスを分けなければならない
と考えたわけではありません。
これからプログラムが成長することを考え、
データベースへの接続方法を、いろいろな場所で面倒を見るのは避けたい
という困り事が見えてきた。
そこで接続を専門のクラスへ任せた。
その考え方を一般化したものに、すでに先人が名前を付けていた。
この順序を覚えておいてください。あとで、この記事の本題につながります。
分類一覧をSELECTする――まだMainに書ける
ここまで来ても、DTOもDAOも登場していません。
次に、categories テーブルから分類一覧を取得してみます。
実行したいSQLは単純です。
SELECT category_id, category_name
FROM categories
ORDER BY category_id;
DAOを使わず、検索処理を Main へ直接書くこともできます。説明用に処理部分だけを抜き出すと、次のようになります。
String sql =
"SELECT category_id, category_name "
+ "FROM categories "
+ "ORDER BY category_id";
try (Connection conn = DatabaseConnection.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
System.out.println(
"ID: " + rs.getInt("category_id")
+ ", 分類名: "
+ rs.getString("category_name"));
}
}
これで分類一覧は表示できます。
そして、このコードも直ちに悪いコードというわけではありません。
DBへ接続する
↓
SELECT文を用意する
↓
PreparedStatementを作る
↓
executeQuery()を実行する
↓
ResultSetを1行ずつ読む
↓
分類を表示する
分類一覧を一度表示するだけの小さなプログラムなら、これでも十分でしょう。
ところが、出納帳の分類管理はSELECTだけでは終わりません。
Read 分類一覧を表示する
Create 分類を追加する
Update 分類名を変更する
Delete 分類を削除する
SQLも増えます。
SELECT category_id, category_name
FROM categories
ORDER BY category_id;
INSERT INTO categories (category_name)
VALUES (?);
UPDATE categories
SET category_name = ?
WHERE category_id = ?;
DELETE FROM categories
WHERE category_id = ?;
すると、Main には次のようなものが集まり始めます。
メニューの表示
利用者からの入力
入力値の変換
SELECT / INSERT / UPDATE / DELETE
PreparedStatement
ResultSet
executeQuery()
executeUpdate()
検索・登録・更新・削除結果の表示
SQLExceptionの処理
まだ一つのクラスに書こうと思えば書けます。
コンピュータも困りません。正しく書かれていれば、そのまま実行してくれます。
しかし、人間が読む側になると事情が変わってきます。
さらに、このあと cash_entries の取引管理まで追加したらどうでしょう。
プログラムが成長するにつれて、Main は「アプリケーションの入口」ではなく、何でも知っている巨大なクラスへ近づいていきます。
ここで、初めて明確な「困った」が生まれます。
困った① SQLとJDBC処理をどこへ置くのか
Main には、利用者とのやり取りを担当してもらいたいとします。
メニューを表示する
↓
利用者の入力を受け取る
↓
必要な処理を依頼する
↓
結果を表示する
ところが現在の Main は、PreparedStatement や ResultSet、executeQuery()、executeUpdate() といったデータベースアクセスの詳細まで知っています。
そこで考えます。
データベースとのやり取りを、別のクラスへまとめられないだろうか。
例えば、次のようなメソッドを持つクラスです。
getAllCategories()
insertCategory(...)
updateCategory(...)
deleteCategory(...)
すると Main は、
categoryDAO.getAllCategories();
のように、「何をしたいか」だけを指定すればよくなります。
SQLをどう組み立てるのか。PreparedStatement をどう作るのか。ResultSet をどう読むのか。
そうした詳細は、そのクラスへ任せます。
この役割を担当するのが、DAO(Data Access Object) です。
今回なら CategoryDAO です。
Main
│
│ 分類一覧が欲しい
↓
CategoryDAO
│
│ SELECTを実行する
↓
DatabaseConnection
│
↓
PostgreSQL
ここで、分類一覧を取得する処理を CategoryDAO へ移したいのですが、もう一つ問題があります。
困った② データベースから取ってきたデータをどう渡すのか
CategoryDAO がPostgreSQLから、例えば次の1行を取得したとします。
category_id = 4
category_name = 食費
これを Main へ渡したい。
では、ResultSet をそのまま返せばよいでしょうか。
そうすると Main は再び、
rs.next()
rs.getInt(...)
rs.getString(...)
といったJDBCの扱い方を知る必要があります。
せっかくデータベースアクセスの詳細を CategoryDAO へ移したのに、ResultSet を外へ持ち出したら、役割を分けた意味が薄れてしまいます。
そこで、データベースから取得した1行分のデータを、Javaの普通のオブジェクトへ詰め替えることにします。
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(Data Transfer Object) です。
今回の CategoryDTO は、categories テーブルの1行分のデータを保持します。
PostgreSQLの1行
category_id = 4
category_name = 食費
↓
CategoryDTO 1個
↓
categoryId = 4
categoryName = "食費"
検索結果が複数行なら、List<CategoryDTO> にします。
PostgreSQLの複数行
↓
ResultSet
↓
CategoryDAO
↓
CategoryDTO
CategoryDTO
CategoryDTO
↓
List<CategoryDTO>
↓
Main
CategoryDAO の検索処理は、例えば次のようになります。
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;
}
ResultSet はDAOの中だけで扱い、Main はJavaのオブジェクトだけを扱えるようになりました。
Main 側の分類一覧表示は、次の程度になります。
List<CategoryDTO> categories =
categoryDAO.getAllCategories();
for (CategoryDTO category : categories) {
System.out.println(
"ID: " + category.getCategoryId()
+ ", 分類名: "
+ category.getCategoryName());
}
ここまでで、役割はかなり明確になりました。
Main
利用者との入出力
CategoryDAO
categoriesテーブルへのデータベース操作
CategoryDTO
分類データの保持と受け渡し
DatabaseConnection
データベース接続
そして分類の登録、更新、削除を追加しても、データベースアクセスは CategoryDAO に集められます。
CategoryDAO
getAllCategories()
insertCategory()
updateCategory()
deleteCategory()
ここまで成長すると、最初は「クラスが増えて面倒」にしか見えなかったDAOやDTOに、少し違った意味が見えてきます。
DTOやDAOは、最初から必要だったのか
最初の Main01 を思い出してください。
あのプログラムは、JavaからPostgreSQLへ接続するだけでした。
あの時点で、
Main
DAO
DTO
DatabaseConnection
という完成形を最初から見せられていたら、
「なぜ、こんな簡単なプログラムをわざわざ複雑にするのだろう?」
と思ったとしても不思議ではありません。
あの時点では、まだDAOやDTOが解決すべき問題が、ほとんど表面化していなかったからです。
ところが、
DBへ接続する
↓
分類をSELECTする
↓
分類を追加する
↓
分類を更新する
↓
分類を削除する
↓
さらに取引管理も追加する
とプログラムを成長させると、
SQLをどこへ置くのか
DBアクセスの詳細を誰が知るのか
取得したデータをどう渡すのか
Mainにどこまで仕事をさせるのか
という問題が現れてきます。
その問題を一つずつ整理した結果として、DAOやDTOが必要になってきました。
ここで初めて、
「なるほど。だからDAOやDTOがあるのか」
と得心できます。
なぜ初心者には、ベストプラクティスの価値が見えにくいのか
ここから少し視点を広げてみます。
初心者がプログラムを学ぶときに使うサンプルは、そもそも小さく、単純で、分かりやすく作られています。
これは当然です。
if 文を学ぶために数万行の業務システムを使う必要はありません。JDBC接続を学ぶために何十ものクラスからなるアプリケーションを読む必要もありません。
学びたい部分だけを取り出し、人間が簡単に全体を理解できるところまで意図的に単純化する。
それが入門用のサンプルプログラムです。
ところが、ここに一つの矛盾が生まれます。
設計原則やデザインパターンの多くは、プログラムが大きくなり、複雑になったときに生じる問題への解決策です。
一方、初心者が使っているのは、複雑さを取り除いた小さなプログラムです。
そこへ、
DAOを作りましょう
DTOを作りましょう
責務を分離しましょう
疎結合にしましょう
と、完成されたベストプラクティスを先回りして適用すると、初心者から見れば、
30行で書けたプログラム
↓
なぜか4つのクラスになった
ということになります。
その規模では、分割によって得られるメリットよりも、ファイルやコードが増え、複数のクラスを行き来する負担の方が目につくことさえあります。
そこで初心者は考えます。
これの何が良いのだろう?
これは自然な疑問です。
まだ、その解決策を必要とする問題を経験していないからです。
問題を経験していない人に、その問題への解決策だけを見せても、ありがたさは分かりにくいでしょう。
私たちは、先人がたどった歴史を逆向きに学んでいる
設計原則やデザインパターンが生まれた過程を、かなり単純化して考えてみます。
出発点は、名前ではなかったはずです。
プログラムを書いた
↓
大きくなった
↓
分かりにくくなった
↓
変更すると別の場所が壊れた
↓
どうすればよいか考えた
↓
役割を分けた
↓
うまくいった
↓
似た問題でも繰り返し有効だった
↓
考え方が整理された
↓
名前が付いた
DAOやDTO、単一責任の原則、さまざまなデザインパターンも、そうした試行錯誤の積み重ねの中から整理されてきた知恵と考えることができます。
ところが学ぶ側は、しばしば最後から始めます。
DAOとは何か
DTOとは何か
SRPとは何か
MVCとは何か
そして、
これがベストプラクティスです。覚えましょう。
となる。
つまり私たちは、先人がたどった歴史を逆向きに学んでいるとも言えます。
先人は「問題」から「解決策」へ進んだ。
私たちは「解決策の名前」から入り、その後で意味を理解しようとする。
だから、形は覚えられても、「なぜ、この形なのか」が分からない。
理由が分からなければ、別の問題へ応用することも難しくなります。
ベストプラクティスは「問題への回答」として学ぶ
そう考えると、設計を学ぶ順序も変わってきます。
まず素朴に書く
↓
実際に動かす
↓
機能を増やす
↓
困る
↓
何が問題なのか考える
↓
改善する
↓
その考え方に名前が付いていることを知る
今回なら、最初から、
DAOはデータベースアクセスを担当するクラスです。
と覚えるのではありません。
まず Main へSQLを書いてみる。SELECTだけなら困らない。INSERT、UPDATE、DELETEを加える。さらに別のテーブルも扱う。
そこで、
SQLやJDBC処理をMainから追い出したい。
という必要性が生まれる。
その解決策としてDAOを見る。
するとDAOは、暗記すべき用語ではなく、自分が感じた問題への回答になります。
DTOも同じです。
ResultSet をそのまま Main へ返したくない。では、データを何に入れて渡せばよいのか。
そこでDTOが必要になる。
この順序なら、「DTOとはデータを運ぶもの」という定義の裏側に、なぜ運ぶための専用オブジェクトが必要なのかまで見えるようになります。
設計とは、人間が複雑さを扱うための知恵である
では、なぜ私たちは、そもそもプログラムを分割するのでしょう。
コンピュータは、コードが一つの巨大な main メソッドに書かれていても、正しく書かれていれば実行できます。
コンピュータ自身が、
このクラスは責務が多すぎる。
このメソッドは長すぎて読みにくい。
と困るわけではありません。
困るのは、人間です。
人間は、何万行ものコードを同時に頭の中へ保持して理解することはできません。
だから私たちは、大きなものを小さくします。
大きな処理
↓
メソッドへ分ける
大きなクラス
↓
複数のクラスへ分ける
大きなシステム
↓
レイヤーやモジュールへ分ける
そして、それぞれに名前を付けます。
例えば100行の処理に、
validateInput();
という名前を付ければ、その100行を毎回頭の中で展開する必要はありません。
「ここでは入力値を検証している」という一つの概念として扱えます。
CategoryDAO という名前も同じです。
その内部に何十行、何百行のJDBCコードがあったとしても、利用する側は、
分類に関するデータベース操作を担当するもの
という一つの単位として考えられます。
ここに、この記事で最も伝えたいことがあります。
設計原則やデザインパターンは、コードを難しくするためにあるのではありません。人間が、自分の頭だけでは扱いきれない複雑さを扱えるようにするために生まれた知恵なのです。
もちろん、ソフトウェア設計のすべてが人間の認知だけを目的としているわけではありません。性能、セキュリティ、障害分離、再利用性など、ほかの重要な目的もあります。
それでも、ソフトウェア設計の歴史の大きな流れの一つを、
増大するプログラムの複雑さを、人間が制御可能な形へ整理してきた歴史
と見ることはできるでしょう。
では、AIがコードを書く時代には設計は不要になるのか
ここで、一つ疑問が生まれます。
もしAIが人間の代わりに大量のコードを書けるのであれば、こうした設計は必要なくなるのでしょうか。
極端に考えれば、
AIなら巨大な
mainメソッドでも扱えるのではないか。
という考え方もできます。
仮に、人間がコードを書く必要も読む必要もなくなり、AIがすべて生成し、AIがすべて修正するのであれば、人間のための可読性は今ほど重要ではなくなるかもしれません。
しかし、現実のシステムでは重要な問題が残ります。
最終的に「このシステムは正しい」と判断するのは誰なのか。
例えば、
この仕様でよいのか
この計算結果は正しいのか
この変更を本番へ入れてよいのか
個人情報は正しく扱われているか
障害が起きたときに何が影響を受けるのか
こうした判断について人間が最終的な責任を持つのであれば、人間がシステムを理解できる構造は依然として必要です。
AIが何万行のコードを一瞬で生成できたとしても、人間がそのコードの正しさや変更の影響を判断できなければ、システムを統治しているとは言いにくいでしょう。
設計上の「境界」は、AIへ仕事を任せる境界にもなる
さらに、AI時代には設計上の境界に新しい意味が加わると考えています。
例えば、役割が明確に分かれていれば、AIに対しても、
DAOのデータベース処理だけを修正してほしい。
利用者との入出力には触れないでほしい。
このクラスの公開メソッドの仕様は変えないでほしい。
と、変更範囲を指定しやすくなります。
つまり、これまで設計上の境界は主として、人間がコードを理解し、人間同士で仕事を分担するための境界でした。
これからは、それに加えて、人間がAIへ仕事を委任するための境界にもなっていくのではないでしょうか。
どこまで変更してよいのか。どこには触れてはいけないのか。何を入力として与え、何を結果として返すのか。
そうした境界が明確であるほど、AIへ仕事を任せやすくなり、人間もその結果を検証しやすくなります。
AIがコードを書く時代になっても、人間がシステムを統治する限り、複雑さを分割する設計はなくならない。むしろ設計は、人間がAIによる開発を統治するための技術へも広がっていく。
私はそう考えています。
まとめ――「なぜ、この形なのか」を説明できるようになる
最初の問いへ戻りましょう。
なぜDTOやDAOは、いきなり説明されると分かりにくいのか。
DTOやDAOそのものが難しすぎるからではないと思います。
初心者がまだ経験していない問題への解決策を、完成形から先に見せられるからです。
小さなプログラムを書く
↓
機能を増やす
↓
見通しが悪くなる
↓
役割を分けたくなる
↓
データベース処理をDAOへ移す
↓
データを渡すためにDTOを作る
その過程をたどって初めて、
なるほど。だからこの形なのか。
と理解できます。
プログラミングには、デザインパターン、設計原則、フレームワークなど、先人が長い経験の中でたどり着いた多くのベストプラクティスがあります。
しかし、完成形だけを見て名前と形だけを覚えても、なぜその形が必要になったのかが分からなければ、自分で使いどころを判断することはできません。
問題を知る。
素朴な実装を経験する。
その限界を感じる。
そして解決策を学ぶ。
その順番をたどることで、ベストプラクティスは、暗記する知識から、自分で選択できる道具へ変わります。
DTOやDAOを学ぶ本当の目的も、その名前を覚えることではないはずです。
「なぜ、この形なのか」を自分の言葉で説明できるようになること。
そこまで理解できたとき、初めてその知識は、自分で使える知識になったと言えるのではないでしょうか。