ぺんぎんらぼ

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です。たまに絵を描きます。

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です

Spring Bootで作るWebアプリケーション④ - 画面間のデータの受け渡し

前回から、Spring Bootを使ったWebアプリケーションの実装に入りました。
しかし、前回の実装では期待通りの動作にならず、課題を残した形になりました。

今回は、前回の問題点を解決していきます。

前回の問題点

前回のおさらいです。

前回は、

名前入力画面⇒年齢入力画面⇒ようこそ画面

と画面遷移するWebアプリケーションを作成しました。最後の「ようこそ画面」では、入力された名前と年齢を表示するものですが、残念ながら年齢しか表示されませんでした。

これは、Springの問題ではなく、Webの原則である「ステートレス」が原因となります。「ステートレス」とは、状態(ステート)を持たない(レス)ということです。もう少しわかりやすく言うと、「情報を保持しない」です。

今回の画面の流れを図にしました。

f:id:penguinlabo:20200311181349p:plain

問題の「ようこそ画面」では、「年齢入力画面」から年齢(age)を受け取っていますが、名前(name)は受け取ってません。
名前(name)は、「年齢入力画面」では受け取っているのですが、Webでは「情報を保持しない」ので、「ようこそ画面」では名前は忘れてしまうのです。

簡単に言うと、Webでは直前の画面で入力された情報しかわからないのです。だから、「年齢入力画面」では、前の「名前入力画面」で入力された「名前」しかわからないし、「ようこそ画面」では、前の「年齢入力画面」で入力された「年齢」しかわからないのです。

これを解決する代表的な方法は2通りあります。

解決方法1 - 必要なデータを引き継ぐ

直前の画面から渡されたデータしかわからないのであれば、必要なデータすべてを直前の画面からもらえばいいのです。
「ようこそ画面」で名前と年齢が必要なのであれば、直前の画面である「年齢入力画面」から名前と年齢をもらえばいいのです。

画面の流れを図にすると、次のようになります。

f:id:penguinlabo:20200311194702p:plain

「年齢入力画面」は「名前入力画面」から名前を受け取っているので、その名前をそのまま「ようこそ画面」に送信すればよいのです。
ただ、名前は再入力するわけではないので、テキストボックスではなく、隠し項目として画面に保持しておき、年齢とともに「ようこそ画面」に送信します。

では、実際に実装を修正します。

まずは、「年齢入力画面」の「age.html」を修正します。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Age</title>
</head>
<body>
<form th:action="@{/profile/hello}" method="post">
    <input type="hidden" name="name" th:value="${name}" />
    <p>
        <label for="age"><span th:text="${name}">あなた</span>さんの年齢を入力してください</label>
        <input type="text" name="age" id="age">
    </p>
    <p>
        <input type="submit" value="次へ">
    </p>
</form>
</body>
</html>

修正箇所は、次の1行が追加されているだけです。

    <input type="hidden" name="name" th:value="${name}" />

「名前入力画面」で入力された名前を隠し項目として保持しておきます。
これにより、名前が「ようこそ画面」のコントローラーメソッドに渡されます。

次にコントローラークラス「ProfileController」の「ようこそ画面」のメソッドを修正します。

    @RequestMapping(path = "hello", method = RequestMethod.POST)
    public String hello(@RequestParam("name") String name, @RequestParam("age") String age, Model model) {
        model.addAttribute("name", name);
        model.addAttribute("age", age);
        return "hello";
    }

修正箇所は2か所です。

名前が渡されるように修正したので、その名前を受け取るためにメソッドの引数を増やしています。

    public String hello(@RequestParam("name") String name, @RequestParam("age") String age, Model model) {

そして、受け取った名前を表示するために、この引数をmodelオブジェクトに追加します。

        model.addAttribute("name", name);

この、たった3行の修正だけで「ようこそ画面」に名前が表示されるようになります。

この方法の利点

この方法は、Webのステートレスの原則を守りつつ、一連の画面遷移で情報を保持していることです。 画面に限らず、情報を保持しない「ステートレス」と、情報を保持する「ステートフル」を比較した場合、「ステートフル」は便利ですが、考慮することが増え、作りも複雑になります。

「ステートフル」の場合、どんな状態を保持しているのか、状態のバリエーションはどんなものがあるか、など、どんな状態がわたってくるのかを把握して、それに対応できるコードを書く必要があります。
どのような状態がプログラムに渡されるのかも見えずらいのも問題です。

「ステートレス」の場合、渡されるデータが明確です。例えば、コントローラーの「ようこそ画面」のメソッドシグニチャを見てください。

    public String hello(@RequestParam("name") String name, @RequestParam("age") String age, Model model) {

引数に名前(name)と年齢(age)があります。つまり、この画面は名前と年齢の2つが渡されることが明確です。ステートレスの原則を守る限り、この2つの情報以外については考えなくてよいのです。

また、ここでは詳しい説明は省きますが、スケーラビリティの低下を防げます。アプリケーションサーバ冗長化構成にしたときも、アプリは影響を受けにくくなります。

この方法の欠点

送信データが増えるので、単純に通信量が増えます。
そして、項目が増えた数分、画面に保持する隠し項目が増えます。
情報を引き継ぐ画面が多いほど、引き継ぐ情報の項目が多いほど、コードが煩雑になり通信量も増えます。

今回は情報が「名前」と「年齢」だけでしたが、それ以外の情報、例えば、「性別」「血液型」「住所」「電話番号」など、情報が増えたコードを想像してみてください。
特に業務アプリのような入力項目が多くなりがちなものだと、この方法では管理が煩雑になります。

また、クライアントのHTMLに情報を保存することになるので、情報の改ざんが容易になり、改ざん検知などの処理が必要になることが多いです。

解決方法2 - 必要なデータをサーバに保存する

Webは原則「ステートレス」で情報を保持できませんが、例外的にcookieやLoacalStorageといった、ブラウザに情報を保存する領域が設けられています。
しかし、cookieやLoacalStorageは、改ざんが容易なことと、保存できる容量の上限が低いという問題があります。

そこで登場した技術がHTTPセッションです。
HTTPセッションは、サーバ側で情報を保持します。そして、その情報がどのブラウザで保存したかをセッションIDというもので紐づけます。

f:id:penguinlabo:20200327194321p:plain

サーバには、いろいろな人がアクセスして情報を保存しますが、セッションIDにより、HTTPセッションに保存した情報の持ち主(ブラウザ)を識別することで、サーバ上に複数の人の情報を持たせることができます。
セッションIDは、基本的にブラウザのcookieとして保存されるので、ブラウザを閉じるまで、サーバのHTTPセッションに保存した情報にアクセスすることができます。

今回は、「名前入力画面」で入力した「名前」を、このHTTPセッションに保存します。

f:id:penguinlabo:20200327195850p:plain

HTTPセッションの保存した情報は、いつでも取り出すことができるので、「ようこそ画面」でも「名前」を取り出すことができるようになります。

では、実際に実装を修正します。(方法1で修正した内容は、元に戻して修正してください)

今回は情報をサーバ上に保存するので、サーバサイドのロジックとなるコントローラーを修正するだけです。

@Controller
@RequestMapping("profile")
@SessionAttributes(names = "name")
public class ProfileController {
    @ModelAttribute("name")
    public String setName(String name) {
        return name;
    }

    @RequestMapping("name")
    public String name() {
        return "name";
    }

    @RequestMapping(path = "age", method = RequestMethod.POST)
    public String age(@RequestParam("name") String name) {
        setName(name);
        return "age";
    }

    @RequestMapping(path = "hello", method = RequestMethod.POST)
    public String hello(@RequestParam("age") String age, Model model, SessionStatus sessionStatus) {
        sessionStatus.setComplete();
        model.addAttribute("age", age);
        return "hello";
    }
}

修正箇所ごとに説明します。

まずは、コントローラークラスの宣言部分です。

@Controller
@RequestMapping("profile")
@SessionAttributes(names = "name")
public class ProfileController {

「@SessionAttributes(names = "name")」が追加されています。これは、「このクラスは "name" という名前でセッションに情報を保存します」という宣言になります。

次に以下のメソッドが追加されています。

    @ModelAttribute("name")
    public String setName(String name) {
        return name;
    }

これはメソッドは簡単に言うと「このメソッドの復帰値が "name" という名前のセッション情報に保存される」ことを意味します。
HTTPセッションに「名前」を保存するときは、保存する値を引数に、このメソッドを呼び出します。

次に、名前を入力した後に呼び出されるageメソッドです。

    @RequestMapping(path = "age", method = RequestMethod.POST)
    public String age(@RequestParam("name") String name) {
        setName(name);
        return "age";
    }

このメソッドはリクエスト情報として、入力された「名前」を受け取っています。この時点ではまだ「名前」はHTTPセッションに保存されていません。
メソッドのはじめに先ほど実装した「setName」メソッドを呼び出して、入力された「名前」をHTTPセッションに保存します。

最後に、「ようこそ画面」表示前に呼び出されるhelloメソッドです。

    @RequestMapping(path = "hello", method = RequestMethod.POST)
    public String hello(@RequestParam("age") String age, Model model, SessionStatus sessionStatus) {
        sessionStatus.setComplete();
        model.addAttribute("age", age);
        return "hello";
    }

引数に「SessionStatus sessionStatus」が追加になっています。そして、メソッド内で、この引数を使って「sessionStatus.setComplete()」メソッドを呼び出しています。setCompleteメソッドは、このクラスで管理しているHTTPセッションを破棄します。
HTTPセッションは明示的に破棄するかタイムアウトになるまで、サーバ上に情報が残り続けます。HTTPセッションはサーバもメモリ上に記録されているので、有限であるメモリを消費しています。なので、不要になった情報は削除することが望ましいです。
「ようこそ画面」に名前を表示した後は、「名前」の情報は不要となるので、ここでsetCompleteメソッドを呼び出して、このクラスで管理しているセッション情報を破棄します。

この方法の利点

一番の利点は、一度、HTTPセッションに保存した情報であれば、いつでもどこでも利用できることが利点です。
また、サーバ側で保存するので、データが改ざんされる心配もいりません。
よく使われる方法は、ログインが必要なサイトで、ログイン情報をHTTPセッションに保持します。ログイン情報はほとんどの画面で参照されますし、改ざん防止にも気を付ける必要があるので、HTTPセッションはマッチします。

他に細かいところでは、最初に紹介した画面の隠し項目にデータを保持する方法では、文字列として情報を保持しますが、HTTPセッションではオブジェクトとして保存するので、データ型が何であろうと保存できます。

この方法の欠点

HTTPの「ステートレス」の原則を破る方法なので、HTTPセッションを参照するところでは、HTTPセッションがどんな状態になっているかを把握する必要があります。
アプリケーションの規模が大きくなると、どこでどんな情報がHTTPセッションに保存されるのかを把握することが困難になります。

今回のまとめ

画面間でデータを引き継ぐ方法を2通り紹介しました。少量のデータであれば、最初に紹介した画面の隠し項目に持つ方法でもよいのですが、HTTPセッションは使い勝手がよく、一定のルールの上で使用すれば、HTTPセッションの内容がカオスな状況になることも防げます。

一般的に、次のような点に気を付ければ、HTTPセッションも問題なく運用できます。

  1. 不要になった情報は削除する。
    一定の画面間でしか必要としない情報は、不要になったタイミングで削除します。これにより、サーバのメモリ消費量を抑えることができます。
  2. 保存するデータ量は最小となるように気を付ける
    HTTPセッションは便利な保存場所です。いったん保存すれば、どこからでも参照できるので、問いあえず必要になりそうな情報を保存しがちですが、HTTPセッションはサーバのメモリを消費します。サーバがメモリ不足にならないよう、本当に必要なデータだけを保存するようにします。

つまりは、「必要な期間だの間だけ必要なデータだけを保存する」ということです。

次回予告

今回の実装で、いったんは動くアプリとして完成しています。
このアプリえは「名前」と「年齢」を入力しますが、入力チェックを全くしていないので、「名前」が未入力でも「年齢」が数値以外でもエラーになりません。
次回では、入力チェック処理を実装します。

では、次回の「Spring Bootで作るWebアプリケーション⑤ - 入力バリデーション」でお会いしましょう!!