Javaで詳細画面を作るには?IDでデータを1件取得して表示する流れ

最終更新日

Schoo Java入門 中級 第7回 データベースと連携したWebアプリケーションの実装

一覧の「詳細」を押すと、選んだタスクの情報が表示される。タスクごとに別のHTMLファイルを作っているのでしょうか。この例では、一つのServletと一つのJSPを使い、渡されたIDに応じてDBから取得するデータを変えます。

IDが2なら「議事録を作る」、IDが1なら「資料を確認する」。画面の枠は同じで、中に表示する1件が変わる仕組みです。商品詳細や予約詳細などでも、「どの1件か」を指定して取得する考え方を使えます。

本記事は、Schoo「Java入門 中級」第7回「データベースと連携したWebアプリケーションの実装」に対応する学習記事です。授業の1件取得の題材を、一覧からIDを渡して詳細を表示する例へ広げて説明します。未受講の方も、掲載したデータ・コード・表示結果を読み比べられます。環境構築や授業ファイルのダウンロードは前提にしません。

詳細画面で使うのは、表示する1件を選ぶためのID

SQLiteのtasksテーブルに、次の3件が入っているものとします。idは行を一意に識別する主キー、titleはタスク名です。この記事では、あらかじめ用意したこの固定データを読み取ります。

idtitle
1資料を確認する
2議事録を作る
3資料を確認する

ID=1とID=3は、同じタスク名でも別のデータです。名前だけではどちらか決まりませんが、IDなら対象の1件を指定できます。IDは一覧の「上から何番目」という表示順でもありません。並び順が変わっても、ID=2が指すのは同じ行です。

イラスト:選んだIDと、返ってきた1件を追う

一覧の詳細リンクからID=2をServletへ送信。整数に変換した2をModel、DAOへ渡してtasksテーブルの1件を取得する。DAOが作ったTaskはModel、Servletへ返り、task属性としてJSPに渡される。JSPが生成したHTMLをブラウザが表示する。
上段はIDを渡す流れ、下段は取得したTaskを表示へ渡す流れです。

上段と下段は、同じ1回のリクエストを「検索するまで」と「結果が戻ってから」に分けたものです。TaskはDAOが検索結果から作るオブジェクトです。DBからJavaのTaskそのものが送られるわけではありません。図のURLでは、アプリ名に当たる部分を省略しています。

  • ブラウザ:選んだ行のIDを送る。タスク名をURLに入れて表示を決めるのではない。
  • Servlet:文字列のIDを整数へ変換し、Modelを呼ぶ。結果をJSPへ渡す。
  • Model:処理の窓口としてIDの条件を確認し、DAOへ取得を依頼する。
  • DAO:IDを条件にSELECTを実行し、取得した行をTaskに格納する。
  • JSP:Taskから値を取り出し、HTMLを生成する。SQLは実行しない。

1.詳細リンクから、IDをGETで送る

一覧画面の「議事録を作る」の行には、次のリンクを置きます。ここでは一覧をtask-list.htmlとして、Servletと同じアプリの直下に置く例です。

<a href="task-detail?id=2">詳細</a>

task-detailが呼び出し先、id=2がリクエストパラメータです。通常のリンクを押すとGETリクエストが送られます。アプリのパスが/tasks-appなら、送信先は/tasks-app/task-detail?id=2です。

@WebServlet("/task-detail")

このURLに対応するTaskDetailServletのdoGet()で受け取ります。リンク先はJSPではなくServletです。まだDBを検索していない段階でJSPだけを開いても、表示するTaskは用意されていません。

一覧をJSPで作る場合、各行のリンクにはその行のtask.getId()を入れます。URLのアプリ部分はgetContextPath()で取得できます。

<a href="<%= request.getContextPath() %>/task-detail?id=<%= task.getId() %>">詳細</a>

これは一覧の各Taskを表示する繰り返しの中に置く1行です。全行にid=2を固定で書くと、どの詳細リンクを押しても同じタスクを開いてしまいます。

2.Servletで文字列のIDを整数に変換する

URLに数字が書かれていても、getParameterで取得する値はStringです。今回なら文字列"2"を整数2に変換してからModelへ渡します。

String text = request.getParameter("id");
int id;
try {
    id = Integer.parseInt(text);
} catch (NumberFormatException e) {
    response.sendError(400);
    return;
}
if (id <= 0) {
    response.sendError(400);
    return;
}

これはdoGetの先頭部分です。id=abcや空文字、intの範囲を超える数値は変換できません。パラメータがなくtextがnullの場合も、Integer.parseIntはNumberFormatExceptionを通知します。0以下は変換できても、このアプリで受け付けるIDではないため拒否します。

sendError(400)は、リクエストの指定が不正であることをHTTPステータスで知らせます。その後のreturnは、検索へ進まずdoGetを終えるために書いています。エラー画面の見た目や文言はサーバーの設定により異なります。

3.Modelを通して、同じIDをDAOへ渡す

Servletでは、DBファイルの場所を渡してModelを作り、受け取ったIDで検索を依頼します。以下は有効なIDを受け取った後の抜粋です。SQLExceptionへの対応を含むdoGet全体は後で示します。

String dbPath = getServletContext().getRealPath("/WEB-INF/db/tasks.db");
TaskDetailModel model = new TaskDetailModel(dbPath);
Task task = model.findTask(id);

この例のDBは、展開されたWebアプリ内のWEB-INF/db/tasks.dbに置く想定です。getRealPathはサーバー上の実際のファイルの場所を得るメソッドで、配備方式によってはnullを返します。この配置は学習用の例であり、本番ではDBの接続先や保存場所を環境に合わせて設定します。JSPはWEB-INF内ではなく、アプリ直下に置きます。

TaskDetailModel.java:IDを確認してDAOを呼ぶメソッド

public Task findTask(int id) throws SQLException {
    if (id <= 0) {
        throw new IllegalArgumentException("IDには正の整数を指定してください。");
    }
    TaskDetailDao dao = new TaskDetailDao(dbPath);
    return dao.findById(id);
}

ModelのdbPathは、コンストラクタで受け取ってフィールドに保持した値です。DAOにも同じ場所を渡します。ServletのfindTask(id)、ModelのfindById(id)とつながることで、ブラウザが指定した2が検索条件まで届きます。

Servletで確認していても、Modelをテストなどから直接呼ぶことがあります。そのため、この例ではModelも「正のID」という条件を確認します。HTTPの文字列を数値にする処理と、取得処理が受け付けるIDの条件は分けています。

Modelの役割は、必ず複雑な計算を書くことではありません。今回は取得の窓口ですが、利用者ごとの閲覧条件などが加われば、そのルールを組み込む場所になります。ここではログインや利用者別の権限は扱わず、用意した3件を閲覧できる例にしています。

4.DAOでIDを条件に検索し、1件をTaskにする

TaskDetailDao.java:findByIdメソッド

public Task findById(int id) throws SQLException {
    String sql = "SELECT id, title FROM tasks WHERE id = ?";

    try (
        Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
        PreparedStatement stmt = conn.prepareStatement(sql)
    ) {
        stmt.setInt(1, id);
        try (ResultSet rs = stmt.executeQuery()) {
            if (rs.next()) {
                return new Task(rs.getInt("id"), rs.getString("title"));
            }
            return null;
        }
    }
}

stmt.setInt(1, id)の1は、SQLの最初の「?」を指す番号です。ID=1を検索するという意味ではありません。idが2なら、最初の?に整数2を設定します。

idは主キーなので、条件に一致する行は最大1件です。rs.next()がtrueならその1件があり、falseならありません。全件をListにする場合と違い、ここではwhileで何件も読み取るのではなく、ifで有無を確認します。

検索結果のid列とtitle列を取り出し、new Task(2, "議事録を作る")に相当するオブジェクトを作って返します。Taskはその値を保持するDTOです。

Task.java:取得した1件を保持するクラス

public class Task {
    private int id;
    private String title;

    public Task(int id, String title) {
        this.id = id;
        this.title = title;
    }

    public int getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }
}

見つからないときはこのDAOの約束としてnullを返します。JDBCが自動でnullのTaskを返すのではなく、findByIdの中でそう決めています。一方、SQLの実行に失敗した場合はSQLExceptionを通知し、0件と混同しません。

try-with-resourcesでResultSet・PreparedStatement・Connectionを閉じても、取り出した値を持つTaskは使用できます。JSPが閉じたResultSetを操作するのではなく、Javaのオブジェクトに移した値を表示するためです。

5.Servletで結果を確認し、task属性でJSPへ渡す

取得できたTaskはModelを経てServletへ戻ります。存在しなければ404、DB処理の失敗なら500とし、正常な場合だけJSPへ進みます。

TaskDetailServlet.java:doGet全体

@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    String text = request.getParameter("id");
    int id;
    try {
        id = Integer.parseInt(text);
    } catch (NumberFormatException e) {
        response.sendError(400);
        return;
    }
    if (id <= 0) {
        response.sendError(400);
        return;
    }

    String dbPath = getServletContext().getRealPath("/WEB-INF/db/tasks.db");
    TaskDetailModel model = new TaskDetailModel(dbPath);
    try {
        Task task = model.findTask(id);
        if (task == null) {
            response.sendError(404);
            return;
        }
        request.setAttribute("task", task);
        request.getRequestDispatcher("/task-detail.jsp").forward(request, response);
    } catch (SQLException e) {
        getServletContext().log("タスクの取得に失敗しました。", e);
        response.sendError(500);
    }
}

"id"はブラウザから届くリクエストパラメータの名前です。"task"はサーバー内で取得結果を渡すリクエスト属性の名前です。同じrequestを使っていても、別の仕組みです。setAttributeはURLにタスク名を追加する処理ではありません。

forwardはサーバー内でJSPに処理を引き継ぎます。ブラウザのURLは/tasks-app/task-detail?id=2のままです。JSPは同じリクエストのtask属性を読み取れます。例外の詳細はサーバー側のログへ記録し、ブラウザにはコードからDBの場所やSQLを出力しません。

6.JSPはTaskを読み、同じ画面の枠に値を表示する

task-detail.jsp:アプリ直下に置く表示用ファイル

この表示例は、冒頭の固定データ3件だけを使います。<%= ... %>は文字列を自動でHTMLエスケープしません。利用者が入力・登録したタスク名などを表示するアプリへ広げる場合は、出力時のHTMLエスケープが必要です。DBに入っているから安全、という意味ではありません。

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="example.Task" %>
<%
    Task task = (Task) request.getAttribute("task");
    if (task == null) {
        response.sendError(404);
        return;
    }
%>
<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>タスク詳細</title></head>
<body>
    <h1>タスク詳細</h1>
    <p>ID: <%= task.getId() %></p>
    <p>タスク名: <%= task.getTitle() %></p>
</body>
</html>

example.TaskはTaskクラスのパッケージを含む名前です。getAttribute("task")で、Servletが設定したTaskを取り出します。戻り値の型がObjectなので、(Task)でTaskとして扱います。

そのあとgetIdとgetTitleで値を読み、HTMLの中に出力します。ブラウザへ届くのはTaskオブジェクトでもJSPのJavaコードでもなく、生成されたHTMLです。

<h1>タスク詳細</h1>
<p>ID: 2</p>
<p>タスク名: 議事録を作る</p>

ID=1でアクセスすれば、同じJSPから「ID: 1」「タスク名: 資料を確認する」が出力されます。1件ごとにJSPを増やす必要はありません。JSPを直接開いた場合はtask属性がないため、この例では404で終えます。URLにidを付けてJSPだけを開いても、DB検索は実行されません。

不正なID・存在しないデータ・DBエラーを区別する

指定・状態今回の動き
id=2HTTP 200。ID=2の「議事録を作る」を表示する。
id=1 または id=3同じ名前でも、指定したIDの別々のデータを表示する。
idなし、空、abc、intの範囲外HTTP 400。整数へ変換できないので検索しない。
id=0、id=-1HTTP 400。このアプリで有効なIDの条件に合わない。
id=999(該当行なし)HTTP 404。検索は成功したが、対象が存在しない。
tasksテーブルがないなど、SQL実行失敗HTTP 500。検索そのものを完了できていない。

一覧を表示した後で別の操作によって対象が削除されることもあります。「リンクから来たから必ず存在する」とは限りません。404を用意するのは、URLの手入力に備えるためだけではありません。

IDが分かることと、そのデータを閲覧してよいことも別です。URLのIDは変更できます。個人の予約などを扱う実際のアプリでは、ログイン中の利用者がそのデータを閲覧できるかもサーバー側で確認します。連番を隠すだけでは閲覧権限の確認になりません。

どの詳細リンクを押しても同じ内容になるとき

まず、値が固定になっている場所を確認します。

  • 一覧:全行のリンクがid=2になっていないか。各行のgetId()でリンクを作っているか。
  • Servlet:受け取ったidではなく、findTask(2)のように固定値で呼んでいないか。
  • Model:dao.findById(id)へ受け取ったIDをそのまま渡しているか。
  • DAO:setInt(1, id)の第2引数が固定値になっていないか。
  • JSP:固定のタスク名ではなく、task属性のgetTitle()を表示しているか。

図では追跡する具体例としてfindTask(2)と書いていますが、実装では受け取った変数idを渡します。図の値をそのままコードへ固定で書き込むのではありません。

確認問題

  1. ID=1とID=3はどちらも「資料を確認する」です。ID=3の行を表示したいとき、詳細リンクには何を含めますか。タスク名や一覧の表示順ではなくIDを使う理由も答えてください。
  2. id=2を送ったとき、stmt.setInt(1, id)の1とidはそれぞれ何を表しますか。
  3. id=abc、id=999で該当行なし、DBのtasksテーブルなし。この三つはそれぞれどの段階で判明し、今回の実装では何番のHTTPステータスを返しますか。
  4. task-detail.jsp?id=2を直接開いても詳細が出ません。なぜですか。正しく表示するにはどのURLへアクセスし、Servletでどの属性を設定する必要がありますか。

演習後の確認:こちらのページを使って、自分で考えた内容を見直せます。

まとめ

  • 詳細画面は、IDで対象の1件を選び、同じJSPに別のデータを表示する仕組み。
  • ブラウザから届くIDは文字列。Servletで数値へ変換し、Model・DAOへ渡す。
  • DAOは検索結果をTaskに格納する。0件のnullと、DBエラーのSQLExceptionを区別する。
  • ServletはTaskをtask属性に設定し、JSPへforwardする。
  • JSPはTaskを読んでHTMLを生成する。IDの妥当性と閲覧権限は別に考える。

関連記事

シェアする