JavaのWebアプリで入力チェックはどこに書く?HTML・Servlet・Modelの役割分担

最終更新日

Schoo Java入門 中級 第6回 Webアプリケーション構築演習(基礎編)

HTMLにrequiredを書いたのに、Servletでも未入力をチェックするの? 数量の上限はServletとModelのどちらに書くの?Webアプリを作ると、同じ「入力チェック」でも書く場所に迷います。

分ける基準は、「入力する人が間違いに気付くための確認」「届いたデータをJavaで扱えるかの確認」「業務ルールとして許されるかの確認」です。この記事では、数量21を送った見積もりフォームが、入力値を残したままエラー画面へ戻るところまで作ります。

Schoo「Java入門 中級」第6回「Webアプリケーション構築演習(基礎編)」の補足記事です。Java 17・Tomcat 10.1の jakarta.servlet を前提にしています。授業のサンプルを持っていなくても、以下の仕様とコードで確認できます。

結論:HTMLは入力の手助け、サーバー側は処理を守る

場所確認する目的この記事の例
HTML(ブラウザ)送信前に入力ミスへ気付けるようにする必須入力と半角数字の形式を確認する
Servlet(サーバー)リクエストの文字列を、Modelへ渡せる値にする未入力・形式を確認し、intへ変換する
Model(サーバー)業務ルールを満たす値だけで処理する数量が1〜20かを確認してから計算する
JSP(サーバー)入力のやり直しに必要なHTMLを作るエラーメッセージと入力済みの値を表示する

HTMLとサーバー側で同じ項目を確認するのは、役割が違うからです。HTMLのチェックは、開発者ツールで変更したフォームや、別のHTTPクライアントからの送信で回避できます。ブラウザで確認済みだと決めつけず、サーバー側でも受け取った値を確かめます。

たとえば数量0をそのまま計算したり、abcを数値に変換して未処理の例外になったりすると、正しい結果を返せません。入力チェックは「画面に警告を出すため」だけでなく、「不正な条件で後続の処理を進めないため」にも必要です。

この分担が唯一の書き方というわけではありません。規模が大きいアプリでは入力検証用のクラスへ切り出すこともあります。ここではServlet・Model・JSPの基本構成の中で、確認する目的と責任を明確にします。

作るもの:数量を間違えたら、入力を残して戻す

  • 単価500円の商品について、数量1〜20を受け付ける。数量欄は半角数字だけとし、小数・符号・前後の空白は受け付けない。
  • 小計は500円×数量。小計3000円未満は送料300円、3000円以上は送料0円。
  • 正しければ小計・送料・合計を結果画面へ表示する。DB登録や注文確定は行わない。
  • 間違っていれば入力画面へ戻し、理由と入力した値を表示する。未送信の場合は空欄で表示する。

前の記事の DTOで見積もり結果を渡す例を、入力のやり直しができる形へ発展させます。前の記事のコードと混ぜず、この記事のファイル一式を一つの構成として使ってください。同じ /quote を担当するServletを二つ置かないようにします。

送信された値Servletでの扱いModelでの扱い表示
未送信・空欄未入力として止める呼ばない数量を入力してください。
abc・1.5・-1半角数字以外なので止める呼ばない数量は半角数字で入力してください。
2147483648intの上限を超えるため変換失敗呼ばない数量の数値が大きすぎます。
0・21整数に変換できる1〜20の範囲外なので止める数量は1〜20で指定してください。
5整数5へ変換検証して計算する小計2500円・送料300円・合計2800円

半角数字であることと、intに収まることと、数量として認められることは別です。「21は数値だから正しい」とは限らない、という点が今回のポイントです。

イラスト:数量21は、どこで止まり、どう戻るのか

数量21を送る例。HTMLの必須・半角数字の確認を通過し、Servletで整数21に変換する。Modelが範囲外を例外で通知し、Servletは入力値とエラーをJSPへ渡す。JSPが作った画面には21とエラーメッセージが残る。
数量21の経路に絞ったイラストです。コードは要点の抜粋で、URLのアプリ名は省略しています。forwardはサーバー内の処理で、最後の画面はJSPが生成したHTMLをブラウザが表示したものです。画像を選択すると拡大できます。

この例のHTMLは必須と半角数字だけを確認するため、21は送信できます。Servletもintへの変換には成功します。しかしModelの数量制限には合わないので、計算結果を作らず、Servletへ例外で知らせます。Servletがその例外を受け取り、入力画面へ戻す役割を持ちます。

HTMLのチェックは、入力しやすくするために使う

<input name="quantity" type="text" inputmode="numeric"
       pattern="[0-9]+" required>
  • required:空欄での通常のフォーム送信を止める。
  • pattern="[0-9]+":値全体が1文字以上の半角数字かを確認する。上限20の指定ではない。
  • inputmode="numeric":数字を入力しやすいキーボードを表示するためのヒント。これ自体に入力を拒否する機能はない。

今回は、abcなどの間違った文字も再表示できるように type="text" にしています。type="number" では数値でないvalueが空になることがあり、「入力したものをそのまま見直す」という今回の目的に合わないためです。

number型にmin・maxを付け、ブラウザでも範囲を案内する設計もできます。ただし、どちらを選んでもサーバー側の数量制限は残します。ブラウザの案内とModelのルールが同じ項目を扱う場合は、仕様変更時に両方が一致しているか確認します。

Modelには、呼び出し元が変わっても守るルールを書く

src/main/java/model/QuoteModel.java

package model;

public class QuoteModel {
    public QuoteResult calculate(int quantity) {
        if (quantity < 1 || quantity > 20) {
            throw new IllegalArgumentException("数量は1〜20で指定してください。");
        }

        int subtotal = 500 * quantity;
        int shippingFee = 300;
        if (subtotal >= 3000) {
            shippingFee = 0;
        }
        int total = subtotal + shippingFee;
        return new QuoteResult(subtotal, shippingFee, total);
    }
}

数量1〜20というルールをModelで確認します。これなら、今のServlet以外から calculate(21) が呼ばれても、範囲外の計算を防げます。チェックをServletだけに置くと、別の呼び出し元がチェックを忘れたときに、Modelが不正な数量で計算してしまいます。

このModelの引数はintなので、未送信のnullやabcという文字列は直接渡りません。一方で、intでも0や21は渡せます。そのためModel側の範囲確認は必要です。ModelはrequestやJSPを知らず、業務処理として値を受け付けられるかを判断します。

結果をまとめるQuoteResultは、前の記事と同じDTOです。

src/main/java/model/QuoteResult.java

package model;

public class QuoteResult {
    private final int subtotal;
    private final int shippingFee;
    private final int total;

    public QuoteResult(int subtotal, int shippingFee, int total) {
        this.subtotal = subtotal;
        this.shippingFee = shippingFee;
        this.total = total;
    }

    public int getSubtotal() {
        return subtotal;
    }

    public int getShippingFee() {
        return shippingFee;
    }

    public int getTotal() {
        return total;
    }
}

Servletでは、文字列を確認してからModelへ渡す

src/main/java/controller/QuoteServlet.java

package controller;

import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import model.QuoteModel;
import model.QuoteResult;

@WebServlet("/quote")
public class QuoteServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest request,
            HttpServletResponse response)
            throws ServletException, IOException {
        request.setCharacterEncoding("UTF-8");
        String text = request.getParameter("quantity");
        request.setAttribute("quantityText", text == null ? "" : text);

        if (text == null || text.isBlank()) {
            showError(request, response, "数量を入力してください。");
            return;
        }
        if (!text.matches("[0-9]+")) {
            showError(request, response, "数量は半角数字で入力してください。");
            return;
        }

        int quantity;
        try {
            quantity = Integer.parseInt(text);
        } catch (NumberFormatException e) {
            showError(request, response, "数量の数値が大きすぎます。");
            return;
        }

        QuoteResult result;
        try {
            QuoteModel model = new QuoteModel();
            result = model.calculate(quantity);
        } catch (IllegalArgumentException e) {
            showError(request, response, "数量は1〜20で指定してください。");
            return;
        }

        request.setAttribute("quote", result);
        request.getRequestDispatcher("/quote-result.jsp")
                .forward(request, response);
    }

    private void showError(HttpServletRequest request,
            HttpServletResponse response, String message)
            throws ServletException, IOException {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        request.setAttribute("error", message);
        request.getRequestDispatcher("/quote-form.jsp")
                .forward(request, response);
    }
}

処理を追うと、確認する順序が分かります。

  1. 文字コードを指定してから、getParameter("quantity") でStringを受け取る。パラメータがない場合はnullになる。
  2. 入力値を quantityText 属性に保存する。変換に失敗しても、元の文字列を画面へ戻せるようにする。
  3. 未入力と半角数字の形式を確認する。
  4. Integer.parseInt() でintへ変換する。数字だけでもintの範囲を超える場合はNumberFormatExceptionになる。
  5. Modelを呼ぶ。業務上の範囲外ならIllegalArgumentExceptionを受け取る。
  6. 成功した場合だけ、DTOをquote属性に入れて結果JSPへ進む。

showError()を呼んだ後のreturnは省略しません。forwardやshowErrorは、呼び出し元のdoPostを自動で終了させる命令ではありません。入力エラーを表示した後に、変換や計算まで続けないよう、doPostから戻ります。

数値変換のtryとModelを呼ぶtryを分けているのは、どこで何が失敗したかに応じて案内を変えるためです。NumberFormatExceptionはIllegalArgumentExceptionの子クラスですが、今回は別々の範囲で受け取っています。何でも catch (Exception e) で入力ミスとして処理する構成にはしていません。

この小さなModelが通知するIllegalArgumentExceptionは、数量範囲の検証だけです。処理が増えて例外の理由が複数になる場合は、エラー種別を持つ戻り値や検証専用の例外などで区別します。内部の例外メッセージを無条件に画面へ出すのではなく、利用者向けの案内を明示するのが大切です。

JSPへは、エラーメッセージと元の入力値を渡す

入力エラー時のリクエスト属性は、次の二つです。

属性名中身の例JSPでの用途
quantityText“21”、”abc”などの元の文字列入力欄のvalueに戻す
error数量は1〜20で指定してください。何を直せばよいか案内する

入力値はリクエストパラメータquantityから受け取りましたが、再表示ではサーバー側で用意したリクエスト属性quantityTextを使います。getParameterとgetAttributeは別の取得方法です。

表示に使う前に、文字列をHTML用にエスケープする小さな補助クラスを用意します。次の処理は、今回のHTML本文とダブルクォートで囲んだvalue属性へ文字列を出すためのものです。JavaScript・CSS・URLなど別の文脈へ、そのまま流用するための万能な処理ではありません。

src/main/java/view/Html.java

package view;

public final class Html {
    private Html() {
    }

    public static String escape(String value) {
        if (value == null) {
            return "";
        }
        return value.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
}

src/main/webapp/quote-form.jsp

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="view.Html" %>
<%
    String quantityText = (String) request.getAttribute("quantityText");
    String error = (String) request.getAttribute("error");
%>
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>見積もり入力</title>
</head>
<body>
    <h1>見積もり入力</h1>
    <p>単価500円。小計3000円未満は送料300円です。</p>
    <% if (error != null) { %>
        <p id="quantity-error" role="alert"><%= Html.escape(error) %></p>
    <% } %>
    <form action="quote" method="post">
        <label for="quantity">数量(1〜20)</label>
        <input id="quantity" name="quantity" type="text"
               inputmode="numeric" pattern="[0-9]+" required
               value="<%= Html.escape(quantityText) %>">
        <button type="submit">見積もりを表示</button>
    </form>
</body>
</html>

初回にこのJSPを開いたときは属性がないため、quantityTextとerrorはnullです。Html.escape(null)は空文字を返し、エラーは表示しません。エラー時のforwardでは二つの属性があるので、21と案内文を表示したフォームになります。

エラー画面だから入力値をそのまま出してよい、というわけではありません。入力チェックを通らなかった文字列こそ、引用符やHTMLの記号が含まれている可能性があります。Html.escape(quantityText) で、入力をHTMLの命令ではなく文字として表示します。

JSPに送料や上限の判定を持たせてはいません。JSPの if (error != null) は「案内を表示するか」の分岐です。業務ルールの判定と、表示のための分岐を分けて考えてください。

エラーはforward、成功したら結果画面へ進む

showErrorでは、リクエスト属性を設定してから入力JSPへforwardしています。sendRedirectに置き換えると、ブラウザが新しいリクエストを送るため、今回の属性はそのまま引き継がれません。エラーと入力値を同じリクエストで表示する、という今回の構成にはforwardが合います。

URLはPOST先の /quote のままで、入力画面のHTMLが返ります。今回は入力不正の応答として400 Bad Requestを選んでいます。200で入力画面を再表示する設計もありますが、どちらの場合もエラー表示だけで終わらせず、後続の計算を止める必要があります。

正しい入力だけが、次の結果JSPへ進みます。

src/main/webapp/quote-result.jsp

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="model.QuoteResult" %>
<%
    QuoteResult result = (QuoteResult) request.getAttribute("quote");
%>
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>見積もり結果</title>
</head>
<body>
    <h1>見積もり結果</h1>
    <% if (result != null) { %>
        <p>小計: <%= result.getSubtotal() %>円</p>
        <p>送料: <%= result.getShippingFee() %>円</p>
        <p>合計: <%= result.getTotal() %>円</p>
    <% } else { %>
        <p>入力画面から数量を送信してください。</p>
    <% } %>
    <a href="quote-form.jsp">入力画面に戻る</a>
</body>
</html>

JSPはどちらも src/main/webapp 直下に置きます。結果JSPを直接開いてDTOがない場合は、入力画面から送信するよう案内します。この例は見積もり表示だけなので成功時もforwardですが、DBへの登録などを伴う画面では、再送信による二重登録を防ぐ仕組みも別途設計します。

テストは、ブラウザ・Servlet・Modelに分ける

アプリ名を /lesson06 とすると、最初に開くのは http://localhost:8080/lesson06/quote-form.jsp です。自分の環境でアプリ名が異なる場合は、その部分を読み替えます。

確認する場所操作・入力期待する結果
ブラウザ空欄で送信HTMLのrequiredで送信が止まる
ブラウザabcを入力して送信HTMLのpatternで送信が止まる
ブラウザからサーバーまで21を送信Modelの検証で止まり、21と範囲の案内が表示される
ブラウザからサーバーまで21を5へ直して送信小計2500円・送料300円・合計2800円を表示する
Servletへ直接送信quantityなし/空文字400と未入力の案内。計算しない
Servletへ直接送信abc/2147483648形式エラー/変換エラーを案内し、元の文字列を残す
Modelを直接呼ぶcalculate(0)、calculate(21)IllegalArgumentExceptionで通知する
Modelを直接呼ぶcalculate(1)、calculate(20)範囲の両端を受け付ける

空欄やabcは通常のフォーム送信ではブラウザが止めるため、それだけではサーバー側の検証を試したことになりません。HTTPクライアントなどで、HTMLを通さず送る試験も行います。自分のローカルアプリに対して、たとえば次のように送れます。

curl -i --data-urlencode 'quantity=abc' \
  http://localhost:8080/lesson06/quote

curl -i --data-urlencode 'quantity=21' \
  http://localhost:8080/lesson06/quote

どちらも400ですが、abcは「半角数字で入力」、21は「1〜20で指定」と異なる案内が返ります。HTMLのvalueにも、送信した値がエスケープされて残ることを確認してください。

さらに quantity=2147483648 の数値変換エラー、0・1・20・21の境界、パラメータ未送信も確認します。HTMLの属性を確認する試験、Servletの文字列処理の試験、Modelの業務ルールの試験では、確かめているものが違います。

仕様が変わったら、どこを修正するのか

数量の上限を20から50へ変更する場合を考えます。

  • QuoteModelの quantity > 20 を50へ変更する。
  • Servletが範囲外に対して返す案内文を「1〜50」へ変更する。
  • 入力JSPのラベル「数量(1〜20)」を「数量(1〜50)」へ変更する。
  • 上限の試験を49・50・51などで行い、50は成功、51は入力エラーになることを確かめる。

HTMLのpattern、Integer.parseInt、DTOの3つの金額の項目は、この変更だけならそのままです。業務ルールを判定する場所はModelにまとめ、画面の案内も同じ仕様へそろえる、という関係です。Modelさえ変えれば、画面の説明も勝手に変わるわけではありません。

確認問題

この記事の構成と、数量1〜20という仕様で考えてください。

  1. HTMLにrequiredとpatternがあるのに、Servletでも未入力・形式を確認するのはなぜですか。サーバー側の形式チェックを試す方法も一つ答えてください。
  2. 数量21を通常のフォームから送信すると、HTML・Servlet・Modelでそれぞれどう扱われますか。入力画面に戻すのはどのクラスですか。
  3. 入力画面へ戻る処理をforwardからsendRedirectに変えたら、エラーと入力値が表示されなくなりました。理由と、今回の構成に合わせた直し方を答えてください。
  4. 数量abcを受け取ったとき、quantityText属性・Html.escape()・showErrorの後のreturnは、それぞれ何のために必要ですか。

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

まとめ

  • HTMLの入力チェックは利用者の手助け。サーバー側の検証の代わりにはならない。
  • Servletはリクエストの文字列を検証・変換し、Modelは業務ルールを確認する。
  • エラー時は後続処理を止め、理由と元の入力値をJSPへ渡す。
  • JSPは入力値を安全に表示し、利用者が直して送信し直せる画面を作る。
  • ブラウザの操作だけでなく、直接送信とModelの呼び出しも試験する。

関連記事

シェアする