【入門編】Slim3で始める!GAE/JでWebアプリケーション開発 (第3回)

はじめに

前回まででSlim3のDatastore、Controller、Viewまで一通りの作成を通して基本的な実装方法を紹介しました。今回実装する残りの画面も殆どが前回のControllerとViewの開発と同等の手順です。今回はいよいよ本番環境までのデプロイを行います。

今回の内容

BBSアプリケーションの開発 (第3回)

  • 残機能の開発
  • 本番環境へのデプロイ
  • 管理コンソールについて
  • CNMVについて
  • AppEngineの制約について
  • あとがき

前提

以下の内容が完了していること

残機能の開発

前回までで<記事一覧>ページ、<記事作成>ページ、そして新記事投稿処理の実装が完了しています。今回実装する残りの画面は以下に赤点線で囲った部分になります。


作成する機能

それでは早速<記事詳細>ページから実装していきます。

<記事詳細>ページのControllerとJSPの実装

<記事詳細>ページの画面イメージをおさらいします。


記事詳細イメージ

<記事詳細>ページでは、<トップ>ページの記事一覧から選択された記事の本文を表示し、コメントがあればそれも一覧表示します。また、この画面からコメントを投稿できるようにフォームも設けています。

この辺りの作成手順は前回の内容とほぼ同じになりますが復習も兼ねて少し詳細に見ていきましょう。

  • Antタスク gen-controller の実行gen-controller を実行し、プロンプトに /bbs/read と記述しOKボタンを押下します。すると、/bbs/read に対応する以下のController、JSPが生成されます。
    • simplebbs.controller.bbs.ReadController
    • simplebbs.controller.bbs.ReadControllerTest
    • war/bbs/read.jsp

    ブラウザから確認してみましょう。まずブラウザから http://localhost:8888/bbs/ にアクセスし、前回作成した記事一覧を表示します。記事がない場合は「新しい記事を投稿する」から記事を作成して下さい。


    <トップ>ページ

    記事のタイトルをクリックしてみると・・・


    read.jspの生成直後イメージ

    Hello bbs Read !!! が表示されれば gen-controller は完了です。

  • JSPの実装gen-controller で生成された war/bbs/read.jsp を以下のように修正しました。

    read.jsp

    <%@page pageEncoding="UTF-8" isELIgnored="false" session="false"%>
    <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
    <%@taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
    <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
    <%@taglib prefix="f" uri="http://www.slim3.org/functions"%>
    
    <html>
    <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>SimpleBBS</title>
    <link href="/css/bbs.css" rel="stylesheet" type="text/css" />
    </head>
    <body>
      <div id="page">
      <h1>SimpleBBS</h1>
      <div class="err">${errors.message}</div>
      <table>
      <thead><tr><td><div class="read_subject">${f:h(head.subject)}</div></td></tr></thead>
      <tbody>
      <tr>
        <td>
          <div class="read_header">${f:h(head.username)} さん (<fmt:formatDate value="${head.postDate}" pattern="yyyy/MM/dd HH:mm:ss" />)</div>
          <hr>
          <div class="read_body">${f:br(f:h(body.text))}</div>
          <div class="read_footer">
            <span class="err">${errors.password}</span>
            <form method="post" action="edit" style="display: inline">
            <input type="hidden" name="key" value="${f:h(head.key)}"/>
            <input type="password" ${f:text("password")}" class="password ${f:errorClass("password","err")}"/>
            <input type="submit" value=" 修正 " class="button"/>
            </form>
          </div>
        </td>
      </tr>
      </tbody>
      </table>
      <hr class="separate_entry">
      <c:forEach var="c" items="${commentList}" varStatus="cs" >
        <table>
          <tbody>
            <tr>
              <td>
                <div class="read_header">No.${f:h(c.key.id)} : ${f:h(c.username)} さん (<fmt:formatDate value="${c.postDate}" pattern="yyyy/MM/dd HH:mm:ss" />)</div>
                <hr>
                <div class="read_body">${f:br(f:h(c.comment))}</div>
              </td>
            </tr>
          </tbody>
        </table>
        <div>&nbsp;</div>
      </c:forEach>
      <div class="err">${errors.post}</div>
      <form method="post" action="postComment" style="display: inline">
        <input type="hidden" name="key" value="${f:h(head.key)}"/>
        <table>
          <thead><tr><td colspan="2">コメントの投稿</td></tr></thead>
          <tbody>
            <tr><td class="label">お名前</td><td class="elem"><input type="text" ${f:text("username")} class="normal ${f:errorClass("username","err")}"/><span class="err">${errors.username}</span></td></tr>
            <tr><td>コメント</td><td><textarea name="comment" class="smalltext ${f:errorClass("comment","err")}">${f:h(comment)}</textarea><div class="err">${errors.comment}</div></td></tr>
            <tr><td colspan=2><input type="submit" value=" 投稿する " class="button"/></td></tr>
          </tbody>
        </table>
      </form>
      <p><a href="index">戻る</a></p>
      </div>
    </body>
    </html>
    

    ちょっとコードが長いですが、新しい記述は1箇所しかありません。記事本文の表示箇所で使っている f:br() です。

    • f:br() についてf:br() は引数の文字列内の改行コードを <br> に変換して出力するfunctionです。
      ${f:br(f:h(body.text))}

      実際には上記のように f:h() と組み合わせて使います。

      JSPの修正が終わったら保存して、先に開いていたブラウザをリロードします。


      read.jspの修正後イメージ

      表示するべき記事データが準備されていないので記事の内容が表示されていません。次にControllerを実装して表示できるようにしましょう。

  • テストケースの作成ReadController は記事一覧のリンクから辿って呼び出されますが、その際、選択された記事の主キーをGETパラメータで受け取っています。ほとんどの場合は記事の詳細表示を行えるはずですが、記事一覧を表示してから時間を開けてリンクをクリックした場合、該当の記事が削除されている可能性があります。また、GETパラメータはブラウザのURLに /bbs/read?key=ahVzZWF0dGx… のようにパラメータの内容が表示されるため、悪意のあるユーザによって不正な値に改ざんされてリクエストが送信されてくることもありえます。これらを想定して、以下の3つのテストケースを作成しました。
    1. 正常なパラメータで<記事詳細>に遷移した場合、記事の詳細表示が行われること
    2. 正常なパラメータで<記事詳細>に遷移したが、該当の記事が既に削除されていた場合、<トップ>ページに戻ること
    3. 不正なパラメータで<記事詳細>に遷移した場合、<トップ>ページに戻ること

    2,3のケースは<トップ>ページに戻した後、「該当する記事が見つからない」旨のエラーメッセージを表示します。

    ReadControllerTest を以下のように修正しました。

    ReadControllerTest.java

    package simplebbs.controller.bbs;
    
    import java.util.Date;
    import org.slim3.datastore.Datastore;
    import org.slim3.tester.ControllerTestCase;
    import org.junit.Test;
    import simplebbs.model.bbs.Body;
    import simplebbs.model.bbs.Head;
    import simplebbs.service.bbs.BbsService;
    import static org.junit.Assert.*;
    import static org.hamcrest.CoreMatchers.*;
    
    public class ReadControllerTest extends ControllerTestCase {
    
        private BbsService service = new BbsService();
        private String keyString = null;
    
        @Override
        // 各テストの前に実行される処理
        public void setUp() throws Exception {
            super.setUp();
            // 各テストの実行前に記事を1件登録しておく
            insertEntry("テスト用の記事", "テストユーザ", new Date(), "テスト用の記事の本文です。");
            // 登録した1件の記事を取得する
            Head head = service.getAll().get(0);
            // 記事のKeyを文字列に変換して保持
            keyString = Datastore.keyToString(head.getKey());
        }
    
        // 記事を新規登録する
        private void insertEntry(String subject, String username, Date postDate, String text) throws Exception {
            // 記事の作成
            Head head = new Head();
            head.setSubject(subject);
            head.setUsername(username);
            head.setPostDate(postDate);
            // 本文の作成
            Body body = new Body();
            body.setText(text);
            // データストアへ登録
            service.insert(head, body);
        }
    
        @Test
        public void testValidParameter() throws Exception {
            // 記事一覧のタイトルリンクがクリックされた動作をエミュレート
            tester.param("key", keyString);
            tester.start("/bbs/read");
            ReadController controller = tester.getController();
            // 記事詳細ページを表示
            // ========== assertion start ========== //
            assertThat(controller, is(notNullValue()));
            // requestScopeに記事情報がセットされていること
            assertThat(tester.requestScope("head"), is(notNullValue()));
            assertThat(tester.requestScope("body"), is(notNullValue()));
            assertThat(tester.requestScope("commentList"), is(notNullValue()));
            // エラーメッセージは空であること
            assertThat(tester.getErrors().isEmpty(), is(true));
            // read.jspにフォワードしていること
            assertThat(tester.getDestinationPath(), is("/bbs/read.jsp"));
            assertThat(tester.isRedirect(), is(false));
            // ========== assertion end ========== //
        }
        @Test
        public void testAfterDeleted() throws Exception {
            // 該当の記事を予め削除
            service.delete(Datastore.stringToKey(keyString));
            // 記事一覧のタイトルリンクがクリックされた動作をエミュレート
            tester.param("key", keyString);
            tester.start("/bbs/read");
            ReadController controller = tester.getController();
            // トップページへ戻る
            // ========== assertion start ========== //
            assertThat(controller, is(notNullValue()));
            // Errorsのキー"message"にエラーメッセージがセットされていること
            assertThat(tester.getErrors().get("message"), is(notNullValue()));
            // 記事一覧(/bbs/)にフォワードしていること
            assertThat(tester.getDestinationPath(), is("/bbs/"));
            assertThat(tester.isRedirect(), is(false));
            // ========== assertion end ========== //
        }
        @Test
        public void testInvalidParameter() throws Exception {
            // パラメータを不正な値に改ざんしてアクセスされた場合の動作をエミュレート
            tester.param("key", "aaaaaaaaaaa");
            tester.start("/bbs/read");
            ReadController controller = tester.getController();
            // トップページへ戻る
            // ========== assertion start ========== //
            assertThat(controller, is(notNullValue()));
            // Errorsのキー"message"にエラーメッセージがセットされていること
            assertThat(tester.getErrors().get("message"), is(notNullValue()));
            // 記事一覧(/bbs/)にフォワードしていること
            assertThat(tester.getDestinationPath(), is("/bbs/"));
            assertThat(tester.isRedirect(), is(false));
            // ========== assertion end ========== //
        }
    }
    

    今回のテストケースは、記事の主キーを受け取って詳細表示を行うため、予め登録された記事が最低1件は必要です。先ほど、ブラウザ上で一件の記事が登録されていることを確認しましたが、開発サーバ上のデータストアとJunitテスト用のデータストアは別物です。

    つまり、テスト用にテストデータとなる記事を一件登録しておく必要があります。このような場合は ControllerTestCase#setUp() メソッドをオーバーライドし、そこで必要なデータを登録しておきます。 setUp() は各テストケース(テストメソッド)の実行前に実行されます。

    上記の setUp() では、記事のテストデータを1件登録し、その記事の主キーを文字列に変換したものをフィールド変数に準備しています。各テストメソッドではこのキー文字列をパラメータに設定してリクエストを送信します。補足ですが、今回のように tester.request.setMethod() を省略した場合、デフォルトでGETメソッドが指定されます。

    各テストケースの記述に目新しいものはありませんので検査内容はコメントを参照下さい。

    Keyから文字列へ、文字列からKeyへの変換

    データストアの主キーを現すKeyオブジェクトは Datastore.keyToString( key ) でKeyオブジェクトから文字列へ、 Datastore.stringToKey( string ) で文字列からKeyオブジェクトへ変換することができます。
    これらは共に KeyFactory.keyToString() 、 KeyFactory.stringToKey() をラップしているだけですのでKeyFactoryの方を使っても問題ありません。

    余談ですが、前回の説明で出てきたJSP Functionsの f:h( key )も内部では KeyFactory.keyToString( key ) を使ってJSP上でKeyから文字列へ変換しています。

    全てのテストケースを書き終わったら保存して実行します。例によって全て失敗するはずです。


    ReadControllerTestの失敗イメージ

    次にこのテストをクリアするよう ReadController を実装していきます。