Skip to content
Playground

4.JDBC と検索

学習目標

  • JDBC が Java と DB をつなぐ標準 API であることを説明できる
  • 接続から結果取得までの流れを、4 つの段階で説明できる
  • try-with-resources で接続を確実に閉じられる
  • PreparedStatement で SELECT を実行し、ResultSet から値を取り出せる
  • 文字列連結ではなくプレースホルダを使う理由を説明できる

アプリケーションでは、Java や JavaScript などのプログラムが DB に SQL を送り、返ってきた結果を使って処理を続けます。MySQL Shell に人が SQL を入力する代わりに、プログラムが SQL を送って結果を受け取ります。Java から DB を操作するための標準 API が JDBC(Java Database Connectivity)です。

JDBC は Java の標準ライブラリ(java.sql パッケージ)に含まれており、MySQL や PostgreSQL など DB 製品の違いを吸収する共通のメソッドを定義しています。JDBC を使うコードは、SQL の方言を別にすれば、接続先の DB を切り替えてもほとんど変わりません。

JDBC は共通のメソッドを定義するだけで、実際の DB との通信は JDBC ドライバー が担います。DB 製品ごとに内部の通信の仕方が異なるため、ドライバーも製品ごとに用意され、JDBC のメソッド呼び出しをその DB の通信に翻訳します。

Java コード → JDBC → JDBC ドライバー → MySQL

MySQL の JDBC ドライバーは mysql-connector-j で、外部ライブラリとして配布されています。プロジェクトに取り込んで使います。

mysql-connector-j を含む Java プロジェクトを、VS Code の Spring Initializr で作成します。Spring Initializr は、必要なライブラリをまとめて取り込んだ Java プロジェクトの雛形を生成する機能です。

  1. Spring Initializr を開く

    コマンドパレット(Ctrl + Shift + P)で Spring Initializr: Create a Maven Project を選びます。

  2. プロジェクトの設定を選ぶ

    Java のバージョン、グループ名(com.example)、アーティファクト名(jdbc-practice)を順に入力します。パッケージング形式は Jar を選びます。

  3. 依存関係に MySQL Driver を追加する

    依存関係の選択画面で MySQL Driver を選びます。これが mysql-connector-j です。選び終えたら生成先のフォルダーを指定し、開いたプロジェクトで作業します。

生成されたプロジェクトには Spring Boot の設定も含まれますが、ここで使うのは mysql-connector-j を取り込むための仕組みだけです。書くコードは、main メソッドから始まる素の JDBC です。src/main/java/com/example の中に Main クラスを作り、先頭にパッケージ宣言を書きます。

Main.java
package com.example;
public class Main {
public static void main(String[] args) {
// ここに JDBC のコードを書く
}
}

以降のコード例では、この main メソッドの中身を示します。

JDBC で DB を操作する手順は、どの操作でも次の 4 段階をたどります。

  1. 接続する — DB との接続を取得する

  2. SQL を送る — 実行する SQL を DB に渡す

  3. 結果を受け取る — SELECT なら返ってきた行を読み取る

  4. 閉じる — 使い終わった接続を解放する

この章では、まず接続と解放(①④)を確認し、続いて SELECT の送信と結果の受け取り(②③)を見ていきます。

DB を操作する最初の段階が、接続の取得です。取得した接続は、操作を終えたら解放します。

接続先の DB は、JDBC URL という決まった形式で指定します。プロトコル・ホスト・ポート・データベース名を :/ でつないだ文字列です。

jdbc:mysql://ホスト:ポート/データベース名

環境構築 で起動した MySQL は、127.0.0.1 のポート 3307 で待ち受けています。bookstore データベースに接続する URL は次のようになります。

jdbc:mysql://127.0.0.1:3307/bookstore

2-2. DriverManager で接続を取得する

Section titled “2-2. DriverManager で接続を取得する”

接続は DriverManager.getConnection メソッドで取得します。引数は URL・ユーザー名・パスワードの 3 つで、返ってくる Connection が DB との接続を表します。

Connection conn = DriverManager.getConnection(url, "root", "root");

接続情報が間違っていたり MySQL が起動していなかったりすると、getConnectionSQLException を投げます。SQLExceptionチェック例外なので、呼び出す側は try-catch で捕まえるか throws で宣言する必要があります。

この接続をどう閉じるかが次の問題です。

取得した Connection は、MySQL サーバーが同時に受け付けられる接続の 1 つを占有します。接続の数には上限があるため、操作を終えたら close で閉じて枠をサーバーに返します。閉じ忘れた接続は枠を握ったままになり、これが積み重なると上限に達して、新しい接続を張れなくなります。

close を処理の最後に書くだけでは、確実に閉じられません。途中で例外が発生すると、close の行に到達せず、接続が開いたまま残ります。

Main.java
Connection conn = DriverManager.getConnection(url, "root", "root");
// ここで例外が発生すると…
conn.close(); // この行は実行されず、接続が閉じられない

確実に閉じるには、try-with-resources を使います。try の括弧の中で Connection を宣言すると、ブロックを正常に抜けても例外で抜けても、自動で close が呼ばれます。

Main.java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class Main {
public static void main(String[] args) throws SQLException {
String url = "jdbc:mysql://127.0.0.1:3307/bookstore";
try (Connection conn = DriverManager.getConnection(url, "root", "root")) {
System.out.println("接続成功");
}
}
}
接続成功

ConnectionAutoCloseable を実装しているため、try-with-resources で扱えます。これ以降のコードでは、接続をこの形で取得します。

接続が取得できたら、その接続を通して SELECT を送り、返ってきた結果を読み取ります。この間に 2 つのオブジェクトを順に作ります。Connection から SQL を保持する PreparedStatement を作り、それを実行すると結果の行を表す ResultSet が返ってきます。

Connection → PreparedStatement → ResultSet
(接続) (送る SQL) (返ってきた行)

books テーブルから先頭 3 件の idtitleprice を取得します。接続・SQL・結果の 3 つを 1 つの try-with-resources にまとめます。

Main.java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
// main メソッドの中、url を宣言したあと
String sql = "SELECT id, title, price FROM books LIMIT 3";
try (Connection conn = DriverManager.getConnection(url, "root", "root");
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
int id = rs.getInt("id");
String title = rs.getString("title");
int price = rs.getInt("price");
System.out.println(id + ": " + title + " (" + price + "円)");
}
}
1: こころ (473円)
2: 吾輩は猫である (693円)
3: ノルウェイの森 上 (715円)

try の括弧で、接続から結果までを順に作っています。conn.prepareStatement(sql) で SQL を保持した PreparedStatement を作り、stmt.executeQuery() で SELECT を実行すると、結果の行が ResultSet として返ってきます。executeQuery は SELECT 専用のメソッドです。

3 つは互いに依存するため、try の括弧では ConnectionPreparedStatementResultSet の順に宣言します。閉じるときは逆順に close が呼ばれ、依存関係が構文で守られます。

返ってきた ResultSet から値を取り出すのが、while ループの中です。カーソルを次の行へ進めること(3-2)と、その行から列の値を取り出すこと(3-3)の、2 つの段階に分かれます。

SELECT の結果は、数万行になることもあります。これを一度にすべて Java のメモリへ載せると、メモリを圧迫します。そこで ResultSet は全行を抱え込まず、カーソル を 1 行ずつ動かして、必要な行だけを順に読み取る仕組みになっています。

返ってきた直後、カーソルは最初の行の手前にあります。next() を呼ぶとカーソルが次の行へ進み、行があれば true、なければ false を返します。

先頭 3 件の books でカーソルが動く様子は、次の通りです。next() を呼ぶたびにカーソルが 1 つ下へ進み、行を指している間は true、末尾を越えると false を返します。

rs.next() でカーソルが手前から各行へ進み、末尾を越えると false を返す様子

呼び出しごとの戻り値とカーソルの位置を並べると、次のようになります。

rs.next() の呼び出し戻り値カーソルの位置
1 回目true1 行目(こころ)
2 回目true2 行目(吾輩は猫である)
3 回目true3 行目(ノルウェイの森 上)
4 回目false行がない(末尾を越えた)

while (rs.next()) は、true の間ループを続け、false になった時点で終わります。これで全行を 1 行ずつ処理できます。

カーソルが行を指している間、その行の各列の値を getXxx メソッドで取り出します。引数には列名を渡し、列の型に合った getXxx を選びます。

メソッド返り値の型対応するカラムの型
getInt(列名)intINT
getString(列名)StringVARCHARTEXT

books.idpriceINT なので getInttitleVARCHAR なので getString で取り出しています。getXxx には、ほかにも DB のカラムの型に応じたメソッドが用意されています。

3-1 の SELECT は、SQL の中身が固定でした。プログラムでは、利用者が入力した値を使って検索条件を組み立てることがよくあります。このとき、値の組み込み方を誤ると、入力した側に SQL の意味を書き換えられてしまいます。

利用者が入力したタイトルで本を検索する場面を考えます。入力された値を WHERE 句に入れるとき、最初に思いつくのは文字列連結です。

// input には利用者が入力したタイトルが入る
String sql = "SELECT id, title FROM books WHERE title = '" + input + "'";

inputこころ なら、意図通りの SQL になり、一致する本だけが取得されます。

SELECT id, title FROM books WHERE title = 'こころ'

しかし input' OR '1'='1 という文字列が入力されると、連結の結果は次のようになります。

SELECT id, title FROM books WHERE title = '' OR '1'='1'

入力の先頭の 'title = ' の文字列を途中で閉じてしまい、続く OR '1'='1 が SQL の一部として解釈されます。WHERE 句は「title が空、または '1'='1'」という条件に変わり、'1'='1' は常に成り立つため、絞り込みが無効になってすべての本が返ります。入力した値が、検索の条件ではなく SQL の構造そのものを書き換えています。

このように、外部からの入力で SQL の意味を書き換える攻撃を SQL インジェクション と呼びます。この例では全件が返るだけですが、同じ手口で WHERE 条件を書き換えれば、ログイン認証をすり抜けたり、本来見えないはずの他人のデータを取得したり、データを削除したりできます。

値を安全に埋め込むため、PreparedStatement では SQL の中で値が入る位置に プレースホルダ ? を置き、setXxx メソッドで値を設定します。books のタイトル検索で書き方を確認します。

Main.java
String sql = "SELECT id, title FROM books WHERE title = ?";
try (Connection conn = DriverManager.getConnection(url, "root", "root");
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, "こころ");
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getInt("id") + ": " + rs.getString("title"));
}
}
}
1: こころ

setString(1, "こころ") の最初の 1プレースホルダの位置(1 始まり)、2 つ目が設定する値です。getXxx と同じく、setStringsetInt など値の型に合わせて選びます。

? が複数ある場合は、左から順に 1、2、3 と番号を振って、それぞれに値を設定します。

String sql = "SELECT title FROM books WHERE price >= ? AND stock > ?";
// stmt は取得済みとして
stmt.setInt(1, 3000); // 1 つ目の ? に 3000
stmt.setInt(2, 0); // 2 つ目の ? に 0

プレースホルダへの値の設定は executeQuery の前に行う必要があるため、ResultSetPreparedStatement の try の中で別に宣言します。setString で値を設定してから executeQuery を呼ぶ、という順序です。

文字列連結では、値と SQL が 1 本の文字列に混ざるため、値の中の ' が構文の一部になってしまいました。プレースホルダでは、? を含む SQL の骨格と、setString で設定する値が、別々に DB へ渡されます。骨格の構造は先に決まっていて、後から渡す値はその ? の位置に当てはめる対象でしかないため、値の中身が SQL の構文として解釈されることはありません。

そのため setString(1, "' OR '1'='1") を渡しても、title がその文字列に等しい本を探すだけで、SQL の構造は変わりません。入力値がどんな文字列でも条件の一部にしかならないため、SQL インジェクションを防げます。

外部からの入力を SQL に組み込むときは、文字列連結ではなくプレースホルダを使います。