Javaで検索機能を作るには?入力したキーワードでデータを絞り込む仕組み

最終更新日

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

タスク名に「資料」と入力すると、「資料を確認する」と「会議資料を作る」だけが表示される。検索機能では、入力した文字をDBの検索条件に使い、一致したデータを一覧へ返します。画面に全件を表示してから、不要な行を隠しているわけではありません。

この記事では、入力欄 → Servlet → Model → DAO → DBへキーワードが届き、取得したデータがList<Task> → JSP → HTMLとして一覧になるまでを追います。入力が空の場合と、検索した結果が0件の場合の違いも整理します。

本記事は、Schoo「Java入門 中級」第7回「データベースと連携したWebアプリケーションの実装」に対応する学習記事です。未受講の方も、以下のデータとコードを読み比べて理解できる構成です。

「資料」を含むタスクだけを表示したい

SQLiteのtasksテーブルに、次の3件が入っているものとします。idは各行を識別する番号、titleはタスク名です。この例では、あらかじめ用意した固定データを検索します。

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

「資料」で検索した結果は、ID=1とID=3の2件です。ID=3のように途中に「資料」がある場合も対象にします。このような文字列の一部分が一致する検索を、部分一致検索と呼びます。

入力するキーワード期待する検索結果
資料ID=1、ID=3の2件
議事録ID=2の1件
買い物0件。「該当するタスクはありません。」と表示

イラスト:入力したキーワードで絞り込み、一覧へ返す

資料という入力をServlet、Model、DAOへ渡し、LIKEの検索条件を設定する。tasksテーブルのID1と3が一致し、DAOが2個のTaskをListに追加する。ListはModelからServletへ戻り、tasks属性でJSPへ渡され、2件の一覧のHTMLになる。
上段は検索条件が届く流れ、下段は一致したデータが画面へ戻る流れです。

上下の図は、同じ1回の検索を前半と後半に分けたものです。DBは条件に一致する行を返し、DAOが各行の値からTaskを作ってListへ追加します。DBからList<Task>そのものが送られるわけではありません。

1.フォームのnameを使って、入力した文字を受け取る

task-search.html:検索画面。Servletと同じWebアプリの直下に置く例です。

<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>タスク検索</title></head>
<body>
<h1>タスク検索</h1>
<form action="task-search" method="get">
    <label for="keyword">タスク名</label>
    <input type="text" id="keyword" name="keyword">
    <button type="submit">検索</button>
</form>
</body>
</html>

method="get"なので、検索ボタンを押すとGETリクエストが送られます。actionのtask-searchはServletのURLです。入力欄のname=”keyword”が、入力値を送るときの名前になります。idはlabelとの対応に使っており、送信するパラメータ名を決めるものではありません。

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

@WebServlet("/task-search")

そのServletのdoGet内で、次のように入力を受け取ります。

String keyword = request.getParameter("keyword");

「資料」を入力すると、keywordにはStringの"資料"が入ります。今回は文字を検索するため、整数への変換は不要です。

アプリのパスが/tasks-appなら、送信先は/tasks-app/task-searchです。URLにはkeywordの値も付きます。日本語は通信時にURLエンコードされるため、アドレス欄やログでは%で始まる表記になることがあります。検索はデータを変更しない操作なので、この例ではGETを使っています。

2.Modelで空欄を確認してから、DAOに検索を依頼する

Modelは、この検索処理の窓口です。「キーワードなしでは検索しない」というルールをここに置きます。

TaskSearchModel.java:searchメソッド

public List<Task> search(String keyword) throws SQLException {
    if (keyword == null || keyword.isBlank()) {
        throw new IllegalArgumentException("検索キーワードを入力してください。");
    }
    TaskSearchDao dao = new TaskSearchDao(dbPath);
    return dao.findByTitle(keyword);
}

keyword == nullはkeywordというパラメータがない場合、isBlank()は空文字や空白だけの場合を調べます。条件に合わない入力では例外を通知し、DAOを呼びません。Java 11以降のisBlankを使っています。

dbPathはコンストラクタで受け取って保持するDBファイルの場所です。ModelとDAOはそれぞれ次の形で保持します。クラス名は、それぞれTaskSearchModel・TaskSearchDaoになります。

private final String dbPath;

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

この例では、空欄で全件検索する仕様にはしていません。また、前後の空白を取り除く処理もしていないため、文字と一緒に入力した空白は検索条件の一部になります。こうした入力の扱いは、画面と処理の両方で決めておく必要があります。

3.LIKEと「%」で、部分一致の条件を作る

SELECT id, title FROM tasks WHERE title LIKE ? ORDER BY id

LIKEは、文字列が指定したパターンに一致するかを調べます。%は「0文字以上の任意の文字列」です。キーワードの前後に%を付けることで、前後に文字があっても一致する条件になります。

stmt.setString(1, "%" + keyword + "%");
パターン一致する条件今回の3件での結果
資料「資料」と同じ文字列0件
資料%「資料」で始まるID=1
%資料「資料」で終わる0件
%資料%途中を含め、どこかに「資料」があるID=1、ID=3

keywordが「資料」なら、Javaで作る文字列は"%資料%"です。setStringの第1引数の1は、最初の?の番号であり、ID=1を検索する指定ではありません。

SQLはLIKE ?と書き、?をシングルクォートで囲みません。%を付けた検索値をsetStringで渡します。入力値をSQL文へ直接つなげるのではなく、SQLの形と、条件として渡す値を分ける書き方です。

注意:入力した%や_も、LIKEの特殊文字として働きます。%は任意の0文字以上、_は任意の1文字です。PreparedStatementを使っても、この意味はなくなりません。例えば今回のデータで%を入力すると3件すべてに一致します。「%という文字そのもの」を検索したい仕様では、LIKEのエスケープを別途設計する必要があります。ここでは授業と同じ、LIKEのパターンによる検索を扱います。

4.一致した行を、一つずつTaskにしてListへ追加する

TaskSearchDao.java:findByTitleメソッド

public List<Task> findByTitle(String keyword) throws SQLException {
    String sql = "SELECT id, title FROM tasks WHERE title LIKE ? ORDER BY id";
    List<Task> tasks = new ArrayList<>();
    try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
         PreparedStatement stmt = conn.prepareStatement(sql)) {
        stmt.setString(1, "%" + keyword + "%");
        try (ResultSet rs = stmt.executeQuery()) {
            while (rs.next()) {
                Task task = new Task(rs.getInt("id"), rs.getString("title"));
                tasks.add(task);
            }
        }
    }
    return tasks;
}

DBの検索結果は、ここでは0件・1件・複数件のどれもあり得ます。そこでwhile (rs.next())で1行ずつ読み、毎回new TaskしてListへ追加します。IDの詳細検索のようにifだけで読むと、2件目以降を取りこぼします。

// 「資料」で検索した場合の、Listに追加する値のイメージ
tasks.add(new Task(1, "資料を確認する"));
tasks.add(new Task(3, "会議資料を作る"));

この2行は結果のイメージです。実装では上のDAOのとおり、ResultSetから取得した値を使います。検索する文字が変わるたびに、addのコードを書き換えるわけではありません。

Task.java:1件分のIDとタスク名を保持するDTO

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

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

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

一致する行がなくても、最初に作った空のListが返ります。0件はnullではなく、要素数が0のListです。一方、SQL処理に失敗した場合はSQLExceptionが通知されます。失敗を空のListに置き換えてはいけません。

ResultSetなどはtry-with-resourcesで閉じますが、すでに値を取り出して作ったTaskとListは、その後も利用できます。JSPにはResultSetを渡さず、Javaのオブジェクトにした結果を渡します。

5.Servletで検索結果をtasks属性に保存し、JSPへ渡す

TaskSearchServlet.java:doGet全体。このServletには前述の@WebServlet("/task-search")を付けます。

@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    String keyword = request.getParameter("keyword");
    String dbPath = getServletContext().getRealPath("/WEB-INF/db/tasks.db");
    TaskSearchModel model = new TaskSearchModel(dbPath);
    try {
        List<Task> tasks = model.search(keyword);
        request.setAttribute("tasks", tasks);
    } catch (IllegalArgumentException e) {
        response.setStatus(400);
        request.setAttribute("error", "検索キーワードを入力してください。");
    } catch (SQLException e) {
        getServletContext().log("Task search failed", e);
        response.setStatus(500);
        request.setAttribute("error", "検索できませんでした。時間をおいて再度お試しください。");
    }
    request.getRequestDispatcher("/task-search.jsp").forward(request, response);
}

この例は、展開されたWebアプリのWEB-INF/db/tasks.dbを使う構成です。getRealPathはサーバー上の実際の場所を取得します。配備方式によってはnullになるため、実際の運用では接続先や保存場所を環境に合わせて設定します。JSPはWEB-INFではなく、アプリ直下に置きます。

名前使う場所と役割
keywordフォームのnameとgetParameterに使う。ブラウザから送られたString。
taskssetAttributeとgetAttributeに使う。サーバー内でJSPへ渡すList<Task>。
error入力不正やDB処理の失敗を知らせる、サーバー側で決めたメッセージ。

keywordはリクエストパラメータ、tasksはリクエスト属性です。getParameter(“tasks”)ではListを取り出せません。Servletが設定する属性名と、JSPで取り出す属性名を一致させます。

forwardは同じリクエストを使ってJSPへ処理を引き継ぐため、tasks属性を読むことができます。URLは検索用Servletのままです。例外の詳細はログへ記録し、DBの場所やSQLを画面へ出さないようにしています。

6.JSPで、検索結果の件数と一覧を表示する

task-search.jsp:アプリ直下の表示用ファイル

以下は冒頭の固定データを表示する学習用の例です。<%= ... %>は自動でHTMLエスケープされません。利用者が登録したタスク名などを表示するアプリに広げる場合は、出力時のHTMLエスケープが必要です。この例では、検索キーワードや例外の詳細を画面へそのまま出力しません。

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="java.util.List,example.Task" %>
<%
List<Task> tasks = (List<Task>) request.getAttribute("tasks");
String error = (String) request.getAttribute("error");
%>
<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>タスク検索結果</title></head>
<body>
<h1>タスク検索結果</h1>
<% if (error != null) { %>
    <p><%= error %></p>
<% } else if (tasks == null) { %>
    <p>検索画面からキーワードを入力してください。</p>
<% } else if (tasks.isEmpty()) { %>
    <p>該当するタスクはありません。</p>
<% } else { %>
    <p><%= tasks.size() %>件</p>
    <table>
        <tr><th>ID</th><th>タスク名</th></tr>
        <% for (Task task : tasks) { %>
        <tr>
            <td><%= task.getId() %></td>
            <td><%= task.getTitle() %></td>
        </tr>
        <% } %>
    </table>
<% } %>
<p><a href="task-search.html">検索画面に戻る</a></p>
</body>
</html>

(List<Task>)は、Object型で取り出した属性を、このServletが保存したList<Task>として扱うためのキャストです。Taskはexampleパッケージのクラスなので、pageディレクティブでListとともに読み込んでいます。

tasks.size()で件数を出し、拡張for文でTaskを1件ずつ表示します。「資料」の検索なら、ブラウザには2件とID=1・ID=3の表が表示されます。JSPが生成したHTMLがブラウザへ届き、JavaのListそのものが送られるわけではありません。

ここには2種類の繰り返しがあります。DAOのwhileはDBの結果をJavaのListにする処理、JSPのforはそのListをHTMLにする処理です。JSPで再びSQLを実行しているのではありません。

空欄・0件・DBエラーは、別々に扱う

状況判明する場所今回の表示・応答
キーワードがない、空文字、空白だけModelの入力チェック検索せず、入力を促す。HTTP 400。
買い物で検索して一致なしDAOで0行。空のListが返る該当するタスクはありません。HTTP 200。
tasksテーブルがないなど、検索失敗DAOからSQLException検索できませんでした。HTTP 500。
JSPを直接開いたtasksもerrorもない検索画面からの入力を促す。DB検索はしない。

検索結果が0件でも、検索処理自体は正常に完了しています。詳細画面で指定した1件が存在しない場合とは違い、この検索一覧では404にしません。逆に、DBの故障を「0件でした」と表示すると、利用者は本当にデータがないと誤解してしまいます。

入力を変えても検索結果が変わらないとき

  • フォーム:name=”keyword”とgetParameter(“keyword”)の名前が一致しているか。
  • Servlet・Model:受け取ったkeywordを渡しているか。search(“資料”)のような固定値になっていないか。
  • DAO:SQLにWHERE title LIKE ?があり、setStringで今回の入力を設定しているか。
  • DAO:whileの中でTaskを作って追加しているか。最初の1件しか読んでいない状態ではないか。
  • JSP:getAttribute(“tasks”)のListを表示しているか。固定の一覧を書いていないか。

確認問題

  1. 「資料」を入力した場合、setStringで渡す文字列は何ですか。冒頭の3件のうち、どのIDが検索結果になりますか。
  2. 検索結果が2件あるとき、DAOでif (rs.next())とするのではなくwhileを使う理由と、new Taskをwhileの中に置く理由を説明してください。
  3. 入力が空白だけの場合、「買い物」で検索して0件の場合、tasksテーブルがなく検索できない場合について、どこで判明し、何を表示するかを整理してください。
  4. フォームのnameがkeywordなのにgetParameter(“title”)と書いた場合と、ServletでsetAttribute(“tasks”, tasks)したのにJSPでgetAttribute(“task”)と書いた場合は、それぞれどうなりますか。正しい名前も答えてください。

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

まとめ

  • フォームのnameを使って入力を受け取り、Model・DAOへ検索条件を渡す。
  • LIKE ?に%キーワード%を設定すると、前後に文字があっても一致する。
  • DAOは一致した各行をTaskにし、Listとして返す。0件なら空のList。
  • Servletがtasks属性に保存し、JSPがListから結果一覧のHTMLを作る。
  • 入力不正、検索結果0件、DB処理の失敗を区別する。

関連記事

シェアする