Webサイトに入力機能を追加する (2)

ということで、servletによる入力機能を追加についてです。

これまで扱ってきたJSPはHTMLファイル中にスクリプト的にjavaプログラムを記入したものをWebコンテナのディレクトリに保存し、ロードすることでWebコンテナが自動的に対応したjavaプログラムを生成、コンパイルして、WebブラウザがJSPファイルにアクセスするとこんぱいるされたjavaプログラムが実行されるというものでした。

JSPはHTMLの構造をHTMLとして記述できるので画面構成を作りやすいことと、Webアプリケーションの配置をWebコンテナが半自動で行ってくれる点は便利ですが、HTMLファイル中にjavaプログラムが散在するのでプログラムとしての見通しがあまりよくないという問題があります。

これに対して、servletは決められた作法に従って書かれたjavaプログラムをコンパイルして所定のディレクトリに保存し、所定の配置作業を行うとWebアプリケーションがjavaプログラムを呼び出してくれるというもので、javaプログラムとしての見通しは良くなりますが、HTMLファイルの出力をjavaプログラムとして実装しなければならない点が面倒なところです。

さて、前回はJリーグ試合結果を表示するプログラムで新規試合情報を入力する画面までは作成しました。

今回は入力された試合情報を検査して、問題ないようであればユーザーに確認ボタンをクリックしてもらうところまでを作っていこうと思います。

ところで、いま作っているサービスはデータベースに保存する情報は決まった文字列と数値、日付だけなのでSQLインジェクションやクロスサイトスクリプティングといった不正な行為の影響を受ける可能性は低いと考えられます。

しかし、それを担保するにはWebブラウザから入力された値が保存されるべき値かどうかを検査しなければなりません。

この検査処理が大きなものになると考えたので、jaaプログラムとして作りやすいservletでの実装を選択しました。

servletを使用する場合、servletを実装するjavaプログラムとURLとの紐づけを行う必要があります。

これにはWEB-INFディレクトリに置いたweb.xmlファイルにservletに関する情報を記述します。

<?xml version="1.0" encoding="ISO-8859-1"?>
<web-app xmlns="http://java.sun.com/xml/ns/j2ee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee

http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"

version="2.4">

  <servlet>

    <servlet-name>add_match</servlet-name>

    <servlet-class>AddMatch</servlet-class>

  </servlet>

  <servlet-mapping>

    <servlet-name>add_match</servlet-name>

    <url-pattern>/add_match</url-pattern>

  </servlet-mapping>

</web-app>

1行目の<?xml ... *>はこのファイルがXMLファイルであることを示す決まり文句ですので、基本はそのまま、ただし、内部で日本語を使いたい場合にはencoding属性をencoding="UTF-8"に変更します。

2行目から始まる<web-app ...>から</web-app>までがWebアプリケーション情報の本体で、この中にservletのurlへの紐づけ情報を記述します。

なお、<web-app ...>のxmlns属性とversion属性は決まり事ですのでこのまま記述してください。xmlns:xsi="..."とxsi:schemaLocation="..."はWebアプリケーション情報の構造について記載したものですので、たぶん無くても困らないとは思いますが、念のためこれらもこのまま書いておいたほうがいいと思います (詳しくはXML Schemaを調べてみてください)。

次の<servlet>...</servlet>と<servlet-mapping>...</servlet-mapping>がURLとjavaプログラムを紐づける情報となります。

まず、<servlet>...</servlet>でサーブレットの名前とjavaプログラムを紐づけます。servlet-nameには適当な名前を記述し、servlet-classにはservletを実装するクラスのクラス名を記述します。

今回の例ではservletを実装するクラスにパッケージを設定していないので、クラス名をそのまま書いていますが、パッケージが設定されている場合にはcom.example.AddMatchのようにパッケージ名を含めた完全修飾名で記述する必要があります (パッケージについてはたぶんそのうち書きます)。

次に<servlet-mapping>...</servlet-mapping>でサーブレット名とURLを紐づけます。

servlet-nameには<servlet>で指定したサーブレット名を記述し、url-patternにはそのservletを起動するURLを記述します。

URLはWebアプリケーションが配置されたディレクトリを基準として、そこからの絶対パスで記述されます。

例えばTomcatのwebappsディレクトリにstandingsというディレクトリを作成してその下にWEB-INFディレクトリを作ってweb.xmlを配置した場合、url-patternに/add_matchと記述した場合にはhttp://ip-address/standings/add_matchというURLでservletが起動します。

続いてJavaプログラムを考えます。

servletプログラムはHttpServletクラスの派生クラスとして実装します。

public class AddMatch extends HttpServlet {
}

Webコンテナは起動時及び再ロード指示を受けた際にweb.xmlを読み込んで、記載されているservletクラスを初期化します。

その後、url-patternで指定されたURLへのGETアクセスがあった場合にはそのクラスのdoGetメソッドを、POSTアクセスがあった場合にはdoPostメソッドを呼び出します。

今回はPOSTメソッドを使用することにしましたので、doPostメソッドを実装していきます。

doPostメソッドのシグネチャは以下のようになります。

public void doPost(HttpServletRequest request, HttpServletResponse response);

当然ですがdoPostはクラス外から呼ばれるのでpublicで宣言する必要があります。

HttpServletRequestにはhttpのPOSTアクセスに付随するパラメータの取得にHttpServletResponseはhttp応答を返すために使用します。

まず、リクエストパラメータの取得です。

前回作った入力ページでは以下のパラメータをformで設定していました。

  • section: 節番号、数値
  • date: 試合開催日、日付
  • home: ホームチーム名、文字列
  • away: アウェイチーム名、文字列
  • goals_for: ホームチーム得点、数値
  • goals_against: アウェイチーム得点、数値

これらの値はHttpServletRequestのgetParameterメソッドで文字列として取得できます。

例えば、節番号を取得する手順を考えます。

public void doPost(HttpServetRequest request, HttpServletResponse response) {
  String section_str = request.getParameter("section");
  int section = Integer.parseInt(section_str);

基本はこれでいいのですが、入力ページ以外からアクセスがあり、sectionというパラメータが送られてこなかった場合にはsection_strはnullとなり、入力ページで値が空のまま送信された場合はsection_strは空文字列 ("") となります。

まず、これらを検査してエラー処理を行うロジックが必要です。

public void doPost(HttpServetRequest request, HttpServletResponse response) {
  String section_str = request.getParameter("section").trim();
  if (section_str == null || section_str.equals("")) {

    // section (節) が未入力のエラーを表示して終了する

  }

  int section = Integer.parseInt(section_str);

section_str.equals("")は入力文字列が空文字列と一致するかを判定する手順です。Javaの場合、オブジェクト同士の==での比較は同じオブジェクトかを判定するものなので、文字列自体が同じであっても別オブジェクトだった場合には不一致と判定されてしまいます。このため、文字列比較では文字列に含まれる文字を比較するequalsメソッドを使うことが標準とされています。

また、パラメータを取得するgetParameterの後でtrim()を呼んでいます。これはgetParameterで取得した文字列の前後に空白がついていた場合にはそれを削除するというもので、実運用上はほとんど意味がないのですがとりあえずおまじないとしてつけています (なお、日付入力項目で入力がされていない場合に空白文字がパラメータとして送られてくるため、trim()で不要な空白を取り除いておかないと空文字列の判定が正しくできないので、まったく意味がないわけではありません)。

数値入力で数字以外の文字が入力されてきた場合、Integer.getIntメソッドはNumberFormatException例外を発生します。この例外が発生した場合にはエラーページに落とすのではなく「節」には数値を入力してくださいといったエラーを表示するのが望ましいと思われます。そのロジックを入れると以下のようになります。

public void doPost(HttpServetRequest request, HttpServletResponse response) {
  String section_str = request.getParameter("section").trim();


  int section = -1;

  if (section_str == null || section_str.equals("")) {

    // section (節) が未入力のエラーを表示して終了する

  }

  try |

    section = Integer.parseInt(section_str);

  } catch (NumberFormatExcetion ex) {

    // section (節) に数字以外の文字が入力されているエラーを表示して終了する

  }

なお、NumberFormatException例外を個別に処理するのではなく、プロセスの最後でまとめて処理するという方法も考えられますが、今回の例では数値入力項目が節とホームチーム得点、アウェイチーム得点の3種類あるため、どれが間違っているのかがわかならないと不親切だと思われるので、個別に検査を行うようにしています。

ところで、2022シーズンサッカーJ1リーグは34節で構成されていますので「節」に入力可能な数値は1から34になります。

これも入力値判定の対象となりますが、この判定をどこでやるかは悩みどころです。シーズンごとの試合数は現在は原則34試合となっています。しかし、シーズンによって変わることもあることを考えるとシーズンごとに変更に対応する部分を局所化するという考察も必要となります。

と言いつつ、それは後で考えるということで今回はここに組み込みます。

public void doPost(HttpServetRequest request, HttpServletResponse response) {
  String section_str = request.getParameter("section").trim();
  int section = -1;

  if (section_str == null || section_str.equals("")) {

    // section (節) が未入力のエラーを表示して終了する

  }

  try |

    section = Integer.parseInt(section_str);

  } catch (NumberFormatExcetion ex) {

    // section (節) に数字以外の文字が入力されているエラーを表示して終了する

  }

  if (section < 1 || section > 34) {

    // section (節) に1から34の範囲外の数値が入力されているエラーを表示して終了する

  }

次に試合開催日の処理を行います。

String date_str = request.getParameter("date).trim();
LocalDate date = null;
try {

  date = LocalDate.parse(date_str);

} catch (DateTimeFormatException ex) {

  // 日付が不正である旨のエラーを表示して終了する

}

日付の範囲検査は例えば2022年と入力されているかとか、今日よりも前の日付が入力されているかといったことを確認してもいいかもしれませんが、今回は気にしないことにしました。

3番目はホームチーム名の処理です。

ホームチーム名の処理には入力された文字列が正しいチーム名と一致するかを確認する検査を入れようと思います。

まず、有効なチーム名をクラススタティックの文字列定数として定義します。これもシーズンごとに入れ替えがあるので別の場所に保存しておくのが本当はいいのですが…。

private static final List<String> teams = Arrays.asList("札幌", "鹿島", "浦和", ...);

まず、teams変数はdoPostメソッド内ではなく、AddMatchクラスのトップレベルに配置します。doPostメソッド内で定義するとdoPostメソッドが呼ばれるたびにリストの初期化が発生して無駄なので、doPostメソッドでは参照するだけとしました。

ただし、クラス変数にするとteams変数はクラスが有効な間ずっとメモリを占有するため、メモリ不足による性能低下や不正動作が発生する原因ともなりえます。今回のプログラムは小さいもので、teams変数のサイズもそれほど大きくないのでクラス変数にしても問題ないと考えています。

privateとつけているのはクラス内でのみ使用することを宣言しています。

staticはこの変数がスタティック変数、クラスのインスタンスが複数生成された場合にインスタンスごとに変数を用意するのではなくインスタンス共通で1個の変数を共有することを宣言しています。

そしてfinalはteamsという変数に対する代入操作は許されないことを示しています。残念ながらteams変数に割り当てられているオブジェクトの変更を禁止するものではないのでいつも同じ値が得られるかは割り当てられているオブジェクトによります。変更可能なオブジェクトであってもfinalを付けることでオブジェクトと変数の関係が不変なため、コンパイラによる性能改善の役に立つ場合があるなどのメリットがあります。

次は実際の検査プログラムです。

String home = request.getParameter("home").trim();
if (home == null || home.equals("")) {
  // home (ホームチーム名) が未入力のエラーを表示して終了する

}

if (!teams.contains(home)) {

  // home (ホームチーム名) に入力されている文字列が不正とのエラーを表示して終了する

}

teams.contains(home)はteamsというListにhomeで示される文字列が含まれているかを返すメソッドで、teams変数には有効なチーム名のリストが保存されているので、このメソッド呼び出しによりチーム名の正当性を検査できます。

アウェイチーム名に関してもホームチーム名と同じコードが使えます。

そしてホームチーム得点、アウェイチーム得点については節の数値入力確認のコードが使用でき、数値の有効性はマイナスではないという点だけ確認すればいいかと思います。

以上で入力値の正当性検査の枠組みはできましたが、大事なことはこれをどのように出力画面に反映するかです。

一つの方法として、エラーを検出したところでえらーがめんをしゅつりょくするというのがあります。

最初の「節」入力の部分に戻って考えます。

public void doPost(HttpServetRequest request, HttpServletResponse response) {
String section_str = request.getParameter("section").trim();
int section = -1;

if (section_str == null || section_str.equals("")) {

  // section (節) が未入力のエラーを表示して終了する

  PrintWriter out = response.getWriter();

  out.println("<html>");

  out.println(" <head>");

  out.println(" <title>match parameter error</title>");

  out.println(" <meta charset='UTF-8' />");

  out.println(" </head>");

  out.println(" <body>");

  out.println(" <h1>エラー</h1>");

  out.println(" <p>節が未入力です</p>");

  out.println(" <p><a href='matches_edit.jsp'>こちら</a>から再度編集を行ってください</p>");

  out.println(" </body>");

  out.println("</html>");

  return;

}

HTMLを出力する処理は上記のような感じになります。

まず、HttpServerResponseからHTMLを出力するためのPrintWriterをgetWriterメソッドにより取得して、それに対して1行ずつHTMLデータを出力していきます。

HTMLは改行を意識していないので、実際のところ1行でだらだらっと出力してもいいのですが、あまり行が長くなるとブラウザが対応していない場合もありますので、ここでは地道に1行ずつ出力しています。

そしてエラーが発生した場合にはその先には進めないのでreturnでdoPostメソッドを終了しています。

このようにエラー一つずつに対してエラーページを出力するコードを書いていってもいいのですが、同じような文字列が大量に続いているのは気持ち悪いですし、途中に含まれている「こちら」の移動先URLが変更になった場合などに修正が大変になるので、エラーページの出力部分を別メソッドに追い出すのがいいかもしれません。

ということで別メソッドに追い出すとこんな感じになります。

private void sendErrorPage(HttpServletResponse response, String message) {
  PrintWriter out = response.getWriter();
  out.println("<html>");

  out.println(" <head>");

  out.println(" <title>match parameter error</title>");

  out.println(" <meta charset='UTF-8' />");

  out.println(" </head>");

  out.println(" <body>");

  out.println(" <h1>エラー</h1>");

  out.println(" <p>節が未入力です</p>");

  out.println(" <p><a href='matches_edit.jsp'>こちら</a>から再度編集を行ってください</p>");

  out.println(" </body>");

  out.println("</html>");

}

public void doPost(HttpServetRequest request, HttpServletResponse response) {
  String section_str = request.getParameter("section").trim();
  int section = -1;

  if (section_str == null || section_str.equals("")) {

    // section (節) が未入力のエラーを表示して終了する

    sendErrorPage(response, "節が未入力です");

    return;

  }

  try |

    section = Integer.parseInt(section_str);

  } catch (NumberFormatExcetion ex) {

    // section (節) に数字以外の文字が入力されているエラーを表示して終了する

    sendErrorPage(resonse, "節に数字以外の文字が入力されています");

    return;

  }

  if (section < 1 || section > 34) {

    // section (節) に1から34の範囲外の数値が入力されているエラーを表示して終了する

    sendErrorPage(response, "節に入力された値が範囲外です。1から34の値を入力してください");

    return;

  }

まとめたことでだいぶすっきりしてきましたが、入力値は全部で6種類、判定はあわせて13あるので、やはりだらだらと長くなってしまいます。

そこで判定自体を別メソッドに追い出してしまうのがよさそうです。

ここで考えるポイントは、別メソッドに追い出した場合の戻り値をどうするかです。戻り値の型をStringにして、入力値に問題がなければnullを返し、問題があれば問題を明示したメッセージ文字列を返すという方法が一つ考えられますが、その場合、数値や日付に変換する操作をもう一度行わなければならず、あまり好ましいとは思えません。

変換した値とメッセージ文字列を返すという考え方もあります。変換した値は6個あるので、それらを一つのオブジェクトにまとめて返し、エラーがあった場合は文字列を返すというやり方です。

呼び出し方としてはこんな感じになります。

private Object validateParameters(...);
Object o = validateParameters(...);
if (o instanceof String) {

  // エラーページの出力

} else {

  // 正常処理

}

instanceof演算子はオブジェクトがあるクラスのインスタンスであるかを判定する演算子でo instanceof Stringと記述するとオブジェクトoがString型 (またはその派生クラス) のインスタンスである場合に式の値はtrueとなります。

このやり方で実現はできるのですが、Objectを返すメソッドというのが気持ち悪すぎます。

ところで、普通に考えるとエラーになるような入力は通常発生しません。そのため、エラーになるような不正な入力が行われた場合を例外として扱うという方法を考えてもいいかもしれません。

例外の処理は一般的に処理負荷が大きいので、例外が発生する条件が多数発生する場合には勧められませんが、今回は例外条件がほぼ発生しないだろうという前提で例外を使用することにします。

まずは例外クラスの定義です。

ここでは一番簡単に作ってみました。

public class InvalidMatchParameterException extends Exception {
  public InvalidMatchParameterException(String message) {
    super(message);

  }

}

とこれで、例外についてです。

例外的な事象が発生した場合、プログラムの実行を打ち切って、発生した事象の内容を上位のプログラムなどに報告する機能を例外と呼んでいます。

上のInvalidMatchParameterExceptionクラスは例外事象を報告するためのオブジェクトを定義するクラスで、Javaの決まりとして例外事象の報告に使用するクラスはExceptionクラスからの派生 (extends Exception) でなければなりません。

例外事象の詳細をメッセージとして登録しておくことが可能ですが、メッセージの登録はインスタンス生成時 (newを行った時) に実行するのが基本ですので、ここではコンストラクタ引数にメッセージを追加し、super(messege)で基本クラス (Exception) の初期化を通してメッセージを伝播させています。

もうひとつ、リクエストパラメータのセットを保持するクラスを作っておきます。

public class Match {
  public int section;
  public LocalDate date;

  public String home;

  public String away;

  public int goals_for;

  public int goals_against;

}

これはクラスといっても単にデータを保持する変数を並べただけのものです。

これだけ用意するとリクエストパラメータの検査と変換を行うメソッドが定義できるようになります。

public Match validateRequestParameters(HttpServetRequest request) {
  Match match = new Match();


  String section_str = request.getParameter("section").trim();

  int section = -1;

  if (section_str == null || section_str.equals("")) {

    // section (節) が未入力のエラーを表示して終了する

    throw new InvalidMatchParameterException("節が未入力です");

  }

  try |

    section = Integer.parseInt(section_str);

  } catch (NumberFormatExcetion ex) {

    // section (節) に数字以外の文字が入力されているエラーを表示して終了する

    throw new InvalidMatchParameterException("節に数字以外の文字が入力されています");

  }

  if (section < 1 || section > 34) {

    // section (節) に1から34の範囲外の数値が入力されているエラーを表示して終了する

    throw new InvalidMatchParameterException("節に入力された値が範囲外です。1から34の値を入力してください");

  }

  match.section = section;

  // 中略: 他の値も上記同様に検査と変換を行っている

  return match;

}

だいぶ前のところで節に入力可能な値は1から34までという制限をどこで行うかという点について検討が必要と書きました。

節として入力された文字列が数値かどうかの判定は入力処理の一環と思われますが、節の数値範囲に関する検査は入力処理というより試合情報として適切かという判定になると考えられますのでこの検査をリクエストパラメータを保持するオブジェクトの側で検査するほうが適切かもしれません。

検査をMatchクラスに移動させると以下のような感じになります。

public class Match {
  private int section;
  private LocalDate date;

  private String home;

  private String away;

  private int goals_for;

  private int goals_against;


  private final LIst<String> teams = Arrays.asList("札幌", "鹿島", "浦和", ...);


  public void setSection(int section) {

    if (section < 1 || section > 34) {

      throw new InvalidMatchParameterException("節に入力された値が範囲外です。1から34の値を入力してください");

    }

    this.section = section;

  }

  public int getSection() {

    return section;

  }

  // 中略

  public void setHome(String home) {

    if (!teams.contains(home)) {

      throw new InvalidMatchParameterException("ホームチーム名として不明な文字列が入力されました");

    }

    this.home = home;

  }

  // 以下略

}

ここではsection (節) に対するset (値の保存) とget (値の取得) およびhome (ホームチーム名) に対するsetだけを書いていますが、その他の入力項目に対しても同様に作成します。

sectionなどのクラス変数に対して外部から直接アクセスを行うのではなく、set/getメソッドを用意して、setメソッドで検査を行うことでデータとしての正当性判定をこのクラスに集約することができます。

例えばリーグのチーム編成はシーズンごとに変わりますが、上のteamsのリストを修正するだけで対応できるようになります。

これに合わせてvalidateRequestParametersメソッドは以下のようになります。

public Match validateRequestParameters(HttpServetRequest request) {
  Match match = new Match();
  String section_str = request.getParameter("section").trim();

  int section = -1;

  if (section_str == null || section_str.equals("")) {

    // section (節) が未入力のエラーを表示して終了する

    throw new InvalidMatchParameterException("節が未入力です");

  }

  try {

    section = Integer.parseInt(section_str);

  } catch (NumberFormatExcetion ex) {

    // section (節) に数字以外の文字が入力されているエラーを表示して終了する

    throw new InvalidMatchParameterException("節に数字以外の文字が入力されています");

  }

  match.setSection(section);

  // 中略: 他の値も上記同様に検査と変換を行っている

  return match;

}

MatchクラスのsetSectionが発生する例外とvalidateRequestParametersメソッドが発生する例外を合わせておくことでどちらで検出された例外であっても上位では気にせずに扱えるようにしています。

これを使用したdoPostメソッドは以下のようになります。

public void doPost(HttpServetRequest request, HttpServletResponse response) {
  try {
    Match match = validateRequestParameters(request, response);

    // 以下略

  } catch (InvalidMatchParameterException ex) {

    sendErrorPage(ex.getMessage());

  }

}

例外を発生する可能性があるコードをtry節で囲むことでtry節に続くcatch節で例外を捕捉することができるようになります。

ここではInalidMatchParameterExceptionを捕捉して、メッセージをgetMessageメソッドで取り出すことでエラーページを表示しています。

ということでリクエストパラメータの処理だけでだいぶ長くなってしまいました。

ここまで考えたことを最後にまとめておきます。

  • Webアプリケーションのリクエストパラメータはまず正当性検査を行う必要があります。
  • 情報としてのまとまりを保持するクラスを用意して、情報としての正当性検査はこちらで行うほうがよさそうです。
  • 例外的な事象の伝達には例外を使用する方法が考えられます。

ということで、この後の処理については次回で。



0コメント

  • 1000 / 1000