Javaで編集・更新機能を作るには?選んだデータを書き換える仕組み

最終更新日

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

タスクの状態を「未着手」から「完了」に変え、保存する。このとき必要なのは、新しい状態だけではありません。どのタスクを書き換えるかを示すIDも必要です。

登録は新しい行を追加する処理ですが、更新はすでにある行の値を書き換える処理です。この記事では、ID=2の「議事録を作る」を編集する例で、編集画面に元の値を出すところから、DBに変更を保存するところまでをつなげます。

本記事は、Schoo「Java入門 中級」第7回「データベースと連携したWebアプリケーションの実装」を学んだあとに、理解を深めたい方向けの学習記事です。未受講の方も、掲載したデータとコードを読み比べられる構成です。

更新では「どの行か」と「何に変えるか」を分ける

SQLiteのtasksテーブルに、次の3件が入っている例です。idは主キー、titleはタスク名、statusは状態です。今回は仕組みを追いやすいよう、タスク名は表示だけにして、状態を編集します。

idtitlestatus
1資料を確認する未着手
2議事録を作る未着手
3会議資料を作る進行中

ID=2の状態を「完了」にすると、変わるのはその行のstatusだけです。ID=2のまま、行数も3件のままです。新しい「完了」のタスクを追加するわけではありません。

更新に渡す情報値役割
id2どの行を書き換えるか
status完了その行の状態を何に変えるか

イラスト:編集画面を開く処理と、変更を保存する処理

GETでID2のタスクを取得し、未着手が選ばれた編集画面を表示する。完了に変えて更新を押すと、id=2とstatus=完了をPOSTでServletへ送る。ModelからDAOを呼び、UPDATE文のSETに完了、WHEREに2を渡す。更新後も3件のまま、ID2の状態だけが完了になる。
編集画面を開くGETと、更新ボタンで送るPOSTは、別々のリクエストです。

上段ではServletからModel・DAOを呼んで取得し、JSPで編集画面を作ります。図ではその結果をフォームへ入れる部分を示しています。ブラウザがDBへ直接接続するわけではありません。下段ではidとstatusの両方をServletへ送ります。図のURLはアプリのパスを省略しています。

操作ServletのメソッドDBで行うこと
編集リンクを押すdoGetSELECTで元の値を読む
状態を選び直すだけまだリクエストを送らないDBは変わらない
更新ボタンを押すdoPostUPDATEで指定した行を書き換える

1.GETで元のデータを取得する

一覧の「議事録を作る」に、次の編集リンクを付ける例です。一覧とServletは同じWebアプリ内に置きます。

<a href="task-edit?id=2">編集</a>

TaskEditServletのクラス宣言の直前に、対応するURLを指定します。

@WebServlet("/task-edit")

リンクを押すと、GETリクエストを受けたdoGetが動きます。アプリのパスが/tasks-appなら、URLは/tasks-app/task-edit?id=2です。

TaskEditServlet.java:doGet

@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    String idText = request.getParameter("id");
    String dbPath = getServletContext().getRealPath("/WEB-INF/db/tasks.db");
    TaskEditModel model = new TaskEditModel(dbPath);
    try {
        Task task = model.findTask(idText);
        if (task == null) {
            response.sendError(404);
            return;
        }
        request.setAttribute("task", task);
        request.getRequestDispatcher("/task-edit.jsp").forward(request, response);
    } catch (IllegalArgumentException e) {
        response.sendError(400);
    } catch (SQLException e) {
        getServletContext().log("Task load failed", e);
        response.sendError(500);
    }
}

ModelにIDを渡して取得し、Taskをtask属性に保存してJSPへforwardします。対象が存在しない場合は404で終了し、空の編集画面を出しません。

この例では、展開されたWebアプリ内のWEB-INF/db/tasks.dbを使用します。getRealPathは実際のファイルの場所を得るメソッドです。配備方式によってはnullになるため、運用環境ではDBの接続先・保存場所を別途設定します。JSPはアプリ直下に置きます。

TaskEditModel.java:findTask

public Task findTask(String idText) throws SQLException {
    int id = convertId(idText);
    TaskEditDao dao = new TaskEditDao(dbPath);
    return dao.findById(id);
}

convertIdは文字列のIDを正の整数へ変換する、このModelのメソッドです。後でコードを示します。DAOのfindByIdは、IDに一致する1行をTaskにして返します。

TaskEditDao.java:findById

public Task findById(int id) throws SQLException {
    String sql = "SELECT id, title, status 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"),
                        rs.getString("status"));
            }
        }
    }
    return null;
}

TaskEditModelとTaskEditDaoは、それぞれコンストラクタで受け取ったdbPathをフィールドに保持します。以下はModelの例です。DAO側のコンストラクタ名はTaskEditDaoです。

private final String dbPath;

public TaskEditModel(String dbPath) {
    this.dbPath = dbPath;
}

Task.java:取得した1件を保持するDTO

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

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

    public int getId() { return id; }
    public String getTitle() { return title; }
    public String getStatus() { return status; }
}

ID・タスク名・状態をひとまとまりにして、DAOからModel、Servlet、JSPへ渡します。この例のTaskはexampleパッケージに置きます。Taskを作っただけでは、DBは更新されません。

2.JSPに元の状態を表示し、IDをhiddenで保持する

task-edit.jsp:編集画面

この例のタスク名は、冒頭に示した固定データです。<%= ... %>は自動でHTMLエスケープされません。利用者が登録したタスク名などを表示するアプリへ広げる場合は、出力時のHTMLエスケープが必要です。ここで変更・保存する状態は、Modelで3種類に限定します。

<%@ 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>
<form action="task-edit" method="post">
    <input type="hidden" name="id" value="<%= task.getId() %>">
    <label for="status">状態</label>
    <select id="status" name="status">
        <option value="未着手" <%= "未着手".equals(task.getStatus()) ? "selected" : "" %>>未着手</option>
        <option value="進行中" <%= "進行中".equals(task.getStatus()) ? "selected" : "" %>>進行中</option>
        <option value="完了" <%= "完了".equals(task.getStatus()) ? "selected" : "" %>>完了</option>
    </select>
    <button type="submit">更新</button>
</form>
</body>
</html>

getAttribute("task")で、Servletが保存したTaskを取り出します。JSPを直接開くと属性がないため、この例では404にします。JSPにidを付けて開くだけではDB検索は行われません。

optionのselectedは、最初から選択しておく項目の指定です。現在の状態が「未着手」なら未着手、すでに「進行中」なら進行中を選んだ画面にします。毎回先頭の項目を選んでしまうと、DBに保存されている値と画面の初期表示が食い違います。

"未着手".equals(task.getStatus()) ? "selected" : ""

これは「状態が未着手ならselected、そうでなければ空文字を出す」という条件式です。他の選択肢も同じように判定しています。値をDBへ保存する式ではありません。

次のhiddenは、画面には入力欄を表示せず、送信時にIDを含めるためのものです。ID=2なら、JSPから生成されるHTMLはこうなります。

<input type="hidden" name="id" value="2">

画面に「ID:2」と文字で表示するだけでは、そのIDはフォーム送信に含まれません。nameとvalueを持つinputとしてフォーム内に置くことで、更新先のIDを送れます。

ただし、hiddenは改ざん防止ではありません。ブラウザの開発者ツールや別の送信方法で値を変更できます。サーバー側でIDを確認し、実際のアプリではログイン中の利用者がその行を編集できるかも確認する必要があります。

3.POSTでIDと新しい状態を受け取る

「完了」を選び、更新ボタンを押すと、フォーム内の値が送られます。分かりやすく表すと次の2項目です。

パラメータ名受け取る値
id文字列の”2″
status文字列の”完了”

GETで開いた編集画面とPOSTによる更新は、別のリクエストです。先ほどGETで使ったidTextやtask属性が、そのままPOSTにも残るわけではありません。POST側でも改めてパラメータを受け取ります。

TaskEditServlet.java:doPost

@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    request.setCharacterEncoding("UTF-8");
    String idText = request.getParameter("id");
    String status = request.getParameter("status");
    String dbPath = getServletContext().getRealPath("/WEB-INF/db/tasks.db");
    TaskEditModel model = new TaskEditModel(dbPath);
    try {
        int count = model.updateStatus(idText, status);
        if (count == 0) {
            response.sendError(404);
            return;
        }
        request.setAttribute("count", count);
        request.getRequestDispatcher("/task-edit-result.jsp").forward(request, response);
    } catch (IllegalArgumentException e) {
        response.sendError(400);
    } catch (SQLException e) {
        getServletContext().log("Task update failed", e);
        response.sendError(500);
    }
}

日本語のstatusを受け取るので、getParameterより前にUTF-8を指定します。idもgetParameterではStringです。整数への変換と値の確認はModelへまとめています。

フォームのactionはtask-editです。編集画面を開いたURLの?id=2に頼らず、hiddenのidをPOSTで送ります。URLの数字を変更する処理と、DBのIDを書き換える処理は別です。この例でDBのid列は更新しません。

4.ModelでIDと状態を確認する

TaskEditModel.java:updateStatus

public int updateStatus(String idText, String status) throws SQLException {
    int id = convertId(idText);
    if (!"未着手".equals(status) && !"進行中".equals(status)
            && !"完了".equals(status)) {
        throw new IllegalArgumentException("状態を正しく選択してください。");
    }
    TaskEditDao dao = new TaskEditDao(dbPath);
    return dao.updateStatus(id, status);
}

このタスク管理では、状態を未着手・進行中・完了の3種類にします。どれにも一致しない値は保存しません。selectに3種類しかなくても、サーバーへ別の値を送れるため、Modelでも確認します。

各比較は「この値ではない」を表し、三つとも当てはまる場合に拒否します。"完了".equals(status)のように定数から比較すると、statusがnullでも比較できます。

TaskEditModel.java:convertId。GETとPOSTのどちらからも使います。

private int convertId(String idText) {
    int id;
    try {
        id = Integer.parseInt(idText);
    } catch (NumberFormatException e) {
        throw new IllegalArgumentException("IDが正しくありません。");
    }
    if (id <= 0) {
        throw new IllegalArgumentException("IDが正しくありません。");
    }
    return id;
}

IDがない、数字でない、intの範囲を超える場合は整数に変換できません。0以下もこのアプリの対象外です。こうした入力ではDAOへ進まず、Servletが400を返します。正の整数であっても、対象の行が存在するかどうかはDB操作の結果で確認します。

5.DAOのUPDATEで、指定した行だけを書き換える

TaskEditDao.java:updateStatus

public int updateStatus(int id, String status) throws SQLException {
    String sql = "UPDATE tasks SET status = ? WHERE id = ?";
    try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
         PreparedStatement stmt = conn.prepareStatement(sql)) {
        stmt.setString(1, status);
        stmt.setInt(2, id);
        return stmt.executeUpdate();
    }
}
SQLの部分意味今回の値
SET status = ?何に書き換えるか1番目の?に「完了」
WHERE id = ?どの行を書き換えるか2番目の?に整数2

setStringの1とsetIntの2は、SQL内の?を左から数えた番号です。ID=2だからsetIntの第1引数も2にする、という意味ではありません。もしID=3を更新する場合も、stmt.setInt(2, id)の最初の2は変わりません。

WHEREを省くと、全行が更新対象になります。また、setStringやsetIntは値を設定するだけです。実際にSQLを実行するのはexecuteUpdateです。new Taskや画面上の選択変更だけではDBは変わりません。

この例は、新しい接続の標準状態である自動コミットで、1回のUPDATEを実行します。主キーのIDで絞るため、対象は最大1行です。executeUpdateの戻り値は今回の更新件数であり、IDでも、テーブル全体の行数でもありません。

なお、このSQLiteの例では、同じ状態をもう一度指定した場合も、対象の行があれば1が返ります。戻り値1は、状態の文字列が以前と違っていたことを保証するものではありません。「変更前と変更後が異なるか」と、UPDATEの処理件数は別に考えます。

6.更新結果を表示し、DBに残った値を確認する

成功すると、Servletがcount属性を設定して結果JSPへforwardします。int型のcountは、属性に保存するときにIntegerへ自動変換されるため、JSPではIntegerで受け取ります。

task-edit-result.jsp:更新結果

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
Integer count = (Integer) request.getAttribute("count");
if (count == null) {
    response.sendError(404);
    return;
}
%>
<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>更新結果</title></head>
<body>
<h1>更新結果</h1>
<p><%= count %>件更新しました。</p>
</body>
</html>

正常に更新できた今回の画面は「1件更新しました。」です。一方、DBの内容は次のようになります。

idtitlestatus
1資料を確認する未着手
2議事録を作る完了
3会議資料を作る進行中

もう一度task-edit?id=2へGETでアクセスすると、DBから新しい値を取得するので、今度は「完了」が選ばれた編集画面になります。画面の選択が変わっただけではなく、DBに保存されたことを確かめる確認方法です。

この例は処理のつながりを追うため、更新後にforwardしています。結果画面を再読み込みするとPOSTを再送信する可能性があります。実際のアプリでは、更新後のリダイレクトも検討します。ただし、リダイレクトだけで二重送信や同時更新の問題がすべて解決するわけではありません。

編集中に対象が消えた場合も、成功にしない

編集画面を開いたあと、別の操作で同じタスクが削除されることがあります。その場合、画面にはまだID=2が残っていても、UPDATEの対象は0件です。

状況判定する場所今回の応答
IDが不正、状態が選択肢以外Modelの入力チェック400。DBを更新しない。
編集画面を開く時点で対象なしfindByIdがnull404。編集画面を出さない。
保存する時点で対象なしexecuteUpdateが0404。更新成功の画面を出さない。
DBのSQL処理に失敗SQLException500。詳細はサーバーのログに残す。

この実装の400・404・500はHTTPステータスです。sendErrorで表示される画面の見た目や文言はサーバー設定によって変わります。0件だったからINSERTで登録し直す、という処理にはしていません。更新対象が消えた状況と、新規登録を分けて扱います。

また、同じタスクを二人が開き、順番に別の状態を保存すると、この単純な実装では後から保存した状態が残ります。実際の運用では、編集権限の確認、意図しない更新を防ぐCSRF対策、同時編集の検知なども必要です。この記事のコードは、ログインのない学習用データで更新の流れを確認する例です。

確認問題

  1. ID=2の「議事録を作る」を未着手から完了に更新しました。POSTで渡す二つのパラメータと値を答えてください。また、更新後のID、DBの行数、ID=1の状態はどうなりますか。
  2. SQLがUPDATE tasks SET status = ? WHERE id = ?の場合、ID=3を進行中から完了にするsetStringとsetIntの2行を書いてください。第1引数の番号の意味も説明してください。
  3. 編集画面には「ID:2」と表示されていますが、hiddenのinputを消して送信しました。今回のformのactionはtask-editです。POSTでidを取得できますか。今回のコードはどう処理しますか。
  4. 編集画面を開いた後で対象の行が削除されました。更新ボタンを押すと、DAOは何を返し、Servletはどう応答しますか。tasksテーブル自体がなくSQLに失敗する場合との違いも答えてください。

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

まとめ

  • 編集画面はGETで元の値を取得し、更新はPOSTで新しい値を送る。
  • IDは「どの行か」、状態は「何に変えるか」。両方を受け渡す。
  • hiddenはIDを送る手段であり、改ざん防止や編集権限の保証ではない。
  • DAOのSETに新しい値、WHEREに対象のIDを設定してUPDATEする。
  • 更新は既存の行を書き換える処理。0件やDBエラーを成功にせず、結果を確認する。

関連記事

シェアする