技術ネタ‎ > ‎

Slim3でAjaxを活用したアプリケーションを構築する

AppEngineではサーバ側でページを出力するタイプではなく、サーバ側はデータを処理して必要な値をJSONで返すだけ、というAjaxを使った実装が標準だと感じています。

  • サーバ側でページを出力していると、AppServerの異常時をハンドルできない。AppServerの異常もめったにありませんが、それ以上にエラーを起こしていないのはstatic server(一度も落ちている場面を見た事がない気がする?)で、HTML+Ajaxの場合はstatic serverさえ無事なら何らかの処理が可能。
  • リクエストがSpinUpに当たった場合に、Ajaxだとページの一部分の読み込みだけに時間がかかるので、SpinUpにかかる時間をごまかしやすい。これはAjaxの特徴そのまんまですが。
  • サーバ側のJavaアプリケーションは全て自動テストできる。サーバ側(Java側)の自動テストが「できる部分」「できない部分」混在するよりはスッキリするという印象。Client側の自動テストの問題は残ってしまうが、それはAjax使っても使わなくても同じ。
もちろん、管理画面などは最も作りやすいフレームワークを使うのが良いと思いますけれども。

セットアップ

前提

  • eclipse, maven がセットアップ済みである
  • eclipse に Google Plugins for Eclipseがインストール済みである
  • eclipseのclasspathの設定にmavenのローカルリポジトリへの参照として"M2_REPO"変数が定義されている

手順

ここではmavenを使った手順で説明します。CIまで考えると、Hudsonと相性が良いmavenを使っておくのが良いかなと思いますね。
  1. Slim3maven連携の手順日本語ドキュメント)を参考に、maven archetype pluginを使ってプロジェクトを作成する。
    • $ mvn archetype:generate -DarchetypeCatalog=http://slim3.googlecode.com/svn/trunk/repository
  2. json-libとhamcrest-libraryを利用したいので、pom.xmlに以下を追記する。

    pom.xml

    <dependency>
      <groupId>net.sf.json-lib</groupId>
      <artifactId>json-lib</artifactId>
      <version>2.3</version>
      <classifier>jdk15</classifier>
    </dependency>
    <dependency>
      <groupId>org.hamcrest</groupId>
      <artifactId>hamcrest-library</artifactId>
      <version>1.1</version>
      <scope>test</scope>
    </dependency>
    json-libの最新バージョンはmvnrepository.comで確認できる。
  3. $ mvn eclipse:eclipse を実行してeclipseプロジェクトを生成する。
  4. 生成したプロジェクトをeclipseにインポートする

Controllerの基底クラス

次のような、必ずJSONをレスポンスする基底クラスを作成し、全てのControllerはこのクラスを継承するように作ることにします。

JsonController.java

package jp.co.topgate.controller;

import java.util.Map;
import java.util.logging.Logger;
import net.sf.json.JSONSerializer;
import org.slim3.controller.Controller;
import org.slim3.controller.Navigation;

import com.google.appengine.api.datastore.DatastoreFailureException;
import com.google.appengine.api.datastore.DatastoreTimeoutException;
import com.google.appengine.repackaged.com.google.common.collect.Maps;
import com.google.apphosting.api.DeadlineExceededException;
import com.google.apphosting.api.ApiProxy.CapabilityDisabledException;

public abstract class JsonController extends Controller {
static final Logger logger = Logger.getLogger(JsonController.class.getName());
static final String STATUS = "status";
static final String ERRCODE = "errorCode";
static final String ERRMESSAGE = "errorMessage";
static final String CANRETRY = "canRetry";
static final String STATUS_OK = "OK";
static final String STATUS_NG = "NG";

abstract protected Map<String, Object> handle() throws Exception;
@Override
protected Navigation run() throws Exception {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
Map<String, Object> map = handle();
if (map == null) {
throw new AssertionError("handle() must not be null.");
}
if (map.get(STATUS) == null) {
map.put(STATUS, STATUS_OK);
}
JSONSerializer.toJSON(map).write(response.getWriter());
response.flushBuffer();
return null;
}

@Override
protected Navigation handleError(Throwable error) throws Throwable {
Map<String, Object> map = Maps.newHashMap();
String errorCode;
String errorMessage;
boolean canRetry = false;
if (error instanceof CapabilityDisabledException) {
errorCode = "READONLY";
errorMessage = "AppEngineのサービスが読み取り専用です";
} else if (error instanceof DatastoreTimeoutException) {
errorCode = "DSTIMEOUT";
errorMessage = "データストアがタイムアウトしました。";
canRetry = true;
} else if (error instanceof DatastoreFailureException) {
errorCode = "DSFAILURE";
errorMessage = "データストアのアクセスに失敗しました。";
} else if (error instanceof DeadlineExceededException) {
errorCode = "DEE";
errorMessage = "30秒を超えても処理が終了しませんでした。";
canRetry = true;
} else {
errorCode = "UNKNOWN";
errorMessage = "予期せぬエラーが発生しました。" + error.toString();
}
map.put(STATUS, STATUS_NG);
map.put(ERRCODE, errorCode);
map.put(ERRMESSAGE, errorMessage);
map.put(CANRETRY, canRetry);
JSONSerializer.toJSON(map).write(response.getWriter());
response.flushBuffer();
return null;
}
}
ちょっと長いコードですが、ステータス・エラーコード・エラーメッセージ・リトライ可能かどうか、を表す専用のキー文字列を用意して、クライアント側(JavaScript)では必ずこれらの値に応じた処理をする、というようなルールにします。

処理としては一部の例外を専用に用意したコードでハンドルし、それ以外の例外をまとめてUNKNOWNとしてレスポンスを組み立てているだけです。

サンプル

例のごとく"Hello, world!"を表示するアプリケーションを作ってみます。

IndexControllerTest

archetype pluginを使ってプロジェクトを生成するといくつかソースのテンプレートが含まれますが、今回は必要ないので最初から存在するクラスは全て消してしまいましょう。新しくsrc/test/java/.../IndexControllerTest.javaを作成するとします。

IndexControllerTest.java

package jp.co.topgate.controller;

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import net.sf.json.JSONObject;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.slim3.tester.ControllerTester;

import static org.junit.Assert.*;
import static org.hamcrest.Matchers.*;

public class IndexControllerTest {
@Test
public void 正常系() throws NullPointerException, IllegalArgumentException,
IOException, ServletException {
tester.start("/");
assertThat(tester.response.getStatus(), is(equalTo(HttpServletResponse.SC_OK)));
JSONObject json = JSONObject.fromObject(tester.response.getOutputAsString());
assertThat(json.getString(JsonController.STATUS), is(equalTo(JsonController.STATUS_OK)));
assertThat(json.getString("message"), is(containsString("Hello, world!")));
}

ControllerTester tester;

@Before
public void setUp() throws Exception {
tester = new ControllerTester(this.getClass());
tester.setUp();
}

@After
public void tearDown() throws Exception {
tester.tearDown();
}
}
Slim3のControllerTesterが大変便利なので、それを使います。この例ではHttpステータスと、レスポンスされた文字列からJSONObjectを組み立てて、"status"と"message"の内容を確認しています。例では単なる文字列しか使用していませんが、POJOやList<POJO>を返す場合はJSONObject#getJSONObject()JSONObject#getJSONArray()を使用する事になります。このようにしてレスポンスを簡単に確認できるため、サーバ側のソースコードは100%自動テストの対象とすることができます。

IndexController.java

IndexController.java

package jp.co.topgate.controller;

import java.util.Date;
import java.util.Map;

import com.google.appengine.repackaged.com.google.common.collect.Maps;

public class IndexController extends JsonController {
@Override
protected Map<String, Object> handle() throws Exception {
Map<String, Object> map = Maps.newHashMap();
map.put("message", "Hello, world! - " + new Date());
return map;
}
}

特に説明する内容はありません。先の手順で作成したJsonControllerを継承し、Map<String, Object>を組み立てて返すだけです。先に作成したIndexControllerTestクラスをJUnitで実行すると動作が確認できます。IndexControllerTestは、サクッとEclipseから実行しても構いませんし、もちろんmavenを使って実行することもできます。Hudson等のCI環境でも実行可能です。

index.html

まずはこのサンプルではjqueryを使うので、jquery-XXX.jsをプロジェクトのwarフォルダへコピーしておきます。次に、warフォルダ直下にindex.htmlを作成します。プロジェクトをAppEngineWebアプリとしてEclipseから起動してから http://localhost:8888/ を開いて動作確認します。サーバ側のJavaモジュールは既にテストを通しているので、Slim3のHotReloadingもオフで構いません。HTMLやJSの修正であればWebサーバの再起動も必要ありません。

index.html

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title>slim3example</title>
<script type="text/javascript" src="jquery-1.4.2.min.js" />
<script type="text/javascript">//<![CDATA[
$(function(){
  $('#refreshButton').click(function() { refresh(); });
  refresh();
});
function refresh() {
  $.ajax({
    type: 'get', url: 'index', 
    success: function(json) {
      if (json.status === 'OK') {
        $('#message').children('span').remove();
        $('<span/>').appendTo('#message').text(json.message);
      } else {
        alert(json.errorMessage);
      }
    }
  });
}
//]]></script>
</head>
<body>
<h1>slim3example</h1>
<p id="message"><span>ここにメッセージが表示されます。</span></p>
<p><input type="button" id="refreshButton" value="更新" /></p>
</body>
</html>
この例では、通信結果のstatusしか判断していませんが、基本形はこのようなものです。アプリケーションの規模に合わせて、以下のようにJS側もしっかりフレームワーク化しておいた方が良いです(このレイヤはAppEngineとは全然関係ない、一般的な話なのでJSに詳しいサイトを参照する方が良いです)。

  • JS内でもMVCに分けてオブジェクトを管理する
  • リトライ処理についてはライブラリ化するなどしてラップする

スクリーンショット

その他の工夫

  • ブラウザからテストする際のリクエストの内容をログとして出力し、サーバ側の自動テストのインプットとして利用する事ができるような開発環境用のFilterを使ってみたり。せっかく手動でデータを叩いたなら、利用しないと勿体無いです。
  • 異常系のレスポンスをJS側でハンドルする処理を試すための専用のFilterを使ってみたり。

課題

JSのテストをするために、以下のような仕組みがあればもっと効率がよくなりそうです。

  • サーバへリクエストを投げる際、通信処理を差し替えられる(サーバ側の静的に配置されたファイルを読むとか)ような仕組み。
  • JS内の任意の箇所に、assertionを差し込んでログを取るような仕組み。
どちらもAOPっぽく定義できると便利そう。
Comments