GAE + GWT プログラミングメモ

Google App Engine とGoogle Web Toolkit のメモ

アニメ感想サイトまとめ(メイン画面)

GAE、GWTの勉強用に作っているアニメ感想サイトまとめがある程度形になった。
http://kumo2ji.appspot.com/index.html
f:id:kumo2ji:20140215140325p:plain

f:id:kumo2ji:20140215140612p:plain

機能

左に配置しているGWTのCellBrowserでアニメを選択すると、中央のCellListに選ばれたアニメの記事を表示する。
同時に右端のAmazonもアニメに関係あるものを表示する。
取得処理はGWT RPCでやっているので、アニメを選択してから、記事とAmazonの取得をしにいっている。
記事とAmazon品目はGAEのCronで定期的に取得して、Datastoreに永続化したものを取り出していて、通信のたびに取得している訳ではない。
デフォルトは新着が選択され、アニメ関係なく、最も新しい記事が表示される。
GAEで作っているので、初回起動時にはサイトが表示されるまでにそれなりの時間がかかる。
ロード中であることを知らせた方が親切なのだろうけど、このサイトを見ている人は自分しかいないので、必要ない。

アニメアイコン

選択部分に表示しているアニメアイコンは公式Twitterのアイコンを使っている。
このアイコンを取得するために、アニメ登録時に公式Twitterのscreen_nameを紐付けている。
公式Twitterがないアニメはアイコンを表示していない。

所感

途中でLow Level APIからSlim3に変えたり、CellBrowser、CellListで書き直したりと、ここまで作るのにそれなりに時間が掛かった。
勉強用に作っているので、そのこと自体はよいが、途中でモチベーションが落ちた時期があった。
やりたいことはまだ色々あるので、時間つくってチマチマ作っていく。

JavaでTwitter REST API v1.1のリクエスト

概要

GAEでTwitterREST API v1.1を使って色々情報を取得する方法を書いていきます。
有名どころでtwitter4jというものもありますが、GAEだからかうまくいかなかったので、
素でリクエストを投げます。

Twitterアプリケーション登録

以前のTwitter APIバージョンであれば、特定のプロファイル情報を取得するのは苦なく
できていたみたいだけれど、v1.1からは、OAuth認証必須になっています。
ので、まずは、Twitterにアプリケーションを登録します。
参考したのはこの辺
Twitterアプリケーション登録の仕方 - Ignis
今回はプロファイル情報を取得したいので、
1.Consumer key
2.Consumer secret
3.Access token
4.Access token secret
があればOK!

目標

ローゼンメイデンの公式ツイッターのプロファイル情報(プロファイル画像URL等)を取得する。

ライブラリ

apache commonsのBase64を使いたいので、
Codec - Download Commons Codec
からダウンロードし、ビルドパスに追加。
Eclipse上のwar/WEB-INF/libにドラッグアンドドラッグしてコピーする。

Javaコード

Simplest Java example retrieving user_timeline with twitter API version 1.1 - Stack Overflowを参考にしました。
というより、自分にわかりやすいように、書き換えただけです。

String getUsersShow(String screen_name)

このメソッドは指定したscreen_nameのプロファイル情報をJSON形式のStringで返す。
今回、実装したいメソッドになる。
ローゼンメイデン公式ツイッターのscreen_nameはrozen_anime。

    public String getUsersShow(String screen_name)
            throws InvalidKeyException, NoSuchAlgorithmException, MalformedURLException, IOException {
        String method = "GET";
        String url = "https://api.twitter.com/1.1/users/show.json";
        Map<String, String> paramMap = getUsersShowParamMap(screen_name);
        Map<String, String> oAuthParamMap = getOAuthParamMap();
        
        String urlWithParams = getUrlWithParams(url, paramMap);
        String signatureBaseString = getSignatureBaseString(method, url, paramMap, oAuthParamMap);
        String authorizationHeaderValue = getAuthorizationHeaderValue(signatureBaseString, oAuthParamMap);
        
        return request(urlWithParams, authorizationHeaderValue);
    }

urlはGET users/show | Twitter Developersで与えられているものを指定する。

Map getUsersShowParamMap(String screen_name)

screen_nameとrozen_animeをMapに入れる。
このMapに入っている値が、URLのケツに追加される。
https://api.twitter.com/1.1/users/show.json?screen_name=rozen_animeみたいな感じ。
別のAPIであれば、このパラメータMapを変更すればよい。

    private Map<String, String> getUsersShowParamMap(String screen_name) {
        Map<String, String> urlParamMap = new HashMap<String, String>();
        urlParamMap.put("screen_name",screen_name);
        
        return urlParamMap;
    }
Map getOAuthParamMap()

OAuth認証するために、必要なパラメータをMapで返す。

    private Map<String, String> getOAuthParamMap() {
        String oAuthConsumerKey = "1.Consumer key";
        String oAuthAccessToken = "3.Access token";
        String oAuthNonce = String.valueOf(System.currentTimeMillis());
        String oAuthSignatureMethod = "HMAC-SHA1";
        String oAuthTimestamp = getTimestamp();
        String oAuthVersion = "1.0";
        
        Map<String, String> paramMap = new HashMap<String, String>();
        
        paramMap.put("oauth_consumer_key", oAuthConsumerKey);
        paramMap.put("oauth_nonce", oAuthNonce);
        paramMap.put("oauth_signature_method", oAuthSignatureMethod);
        paramMap.put("oauth_timestamp", oAuthTimestamp);
        paramMap.put("oauth_token", oAuthAccessToken);
        paramMap.put("oauth_version", oAuthVersion);

        return paramMap;
    }

oAuthConsumerKeyとoAuthAccessTokenにはTwitterアプリケーション登録で取得した1.Consumer keyと3.Access tokenを入れる。

String getSignatureBaseString(String method, String url, Map urlParamMap, Map oAuthParamMap)

OAuth認証のために、getUsersShowParamMapとgetOAuthParamMapで取得したパラメータを足し合わせてを一つの文字列にする。

    private String getAuthorizationHeaderValue(String signatureBaseString, Map<String, String> oAuthParamMap)
            throws UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException {
        String oAuthConsumerSecret = "2.Consumer secret";
        String oAuthAccessTokenSecret = "4.Access token secret";
        String compositeKey = URLEncoder.encode(oAuthConsumerSecret, "UTF-8") + "&" 
                + URLEncoder.encode(oAuthAccessTokenSecret, "UTF-8");

        String oAuthSignature =  computeSignature(signatureBaseString, compositeKey);

        String oAuthSignatureEncoded = URLEncoder.encode(oAuthSignature, "UTF-8");

        String authorizationHeaderValueTempl = 
                "OAuth oauth_consumer_key=\"%s\", oauth_nonce=\"%s\", oauth_signature=\"%s\", " + 
                "oauth_signature_method=\"%s\", oauth_timestamp=\"%s\", oauth_token=\"%s\", oauth_version=\"%s\"";
        String authorizationHeaderValue = String.format(
                authorizationHeaderValueTempl,
                oAuthParamMap.get("oauth_consumer_key"),
                oAuthParamMap.get("oauth_nonce"),
                oAuthSignatureEncoded,
                oAuthParamMap.get("oauth_signature_method"),
                oAuthParamMap.get("oauth_timestamp"),
                oAuthParamMap.get("oauth_token"),
                oAuthParamMap.get("oauth_version"));
        
        return authorizationHeaderValue;
    }

oAuthConsumerSecretとoAuthAccessTokenSecret にはTwitterアプリケーション登録で取得した2.Consumer secretと4.Access token secretを入れる。

private String getUrlWithParams(String url, Map paramMap)

https://api.twitter.com/1.1/users/show.json?screen_name=rozen_animeを作る。

    private String getUrlWithParams(String url, Map<String, String> paramMap) {
        StringBuffer urlWithParams = new StringBuffer(url);
        TreeMap<String, String> treeMap = new TreeMap<String, String>();
        treeMap.putAll(paramMap);
        for (Entry<String, String> paramEntry : treeMap.entrySet()) {
            if (paramEntry.equals(treeMap.firstEntry())) {
                urlWithParams.append("?");
            } else {
                urlWithParams.append("&");
            }
            urlWithParams.append(paramEntry.getKey() + "=" + paramEntry.getValue());
        }
        
        return urlWithParams.toString();
    }
String computeSignature(String baseString, String keyString)

signatureBaseString、2.Consumer secret、4.Access token secretでシグネチャを計算する。
得られたシグネチャBase64エンコードする。

    private static String computeSignature(String baseString, String keyString)
            throws NoSuchAlgorithmException, InvalidKeyException
    {
        SecretKey secretKey = null;

        byte[] keyBytes = keyString.getBytes();
        secretKey = new SecretKeySpec(keyBytes, "HmacSHA1");

        Mac mac = Mac.getInstance("HmacSHA1");

        mac.init(secretKey);

        byte[] text = baseString.getBytes();

        return new String(Base64.encodeBase64(mac.doFinal(text))).trim();
    }
String getTimestamp()

タイムスタンプをStringで取得する。

    private String getTimestamp() {
        long millis = System.currentTimeMillis();
        long secs = millis / 1000;
        return String.valueOf( secs );
    }
String request(String urlWithParams, String authorizationHeaderValue)

getUrlWithParamsとgetAuthorizationHeaderValueで得られる値を使って、Twitterにリクエストする。
TwitterからはJSONの文字列が返ってくる。

    private String request(String urlWithParams, String authorizationHeaderValue)
            throws MalformedURLException, IOException {
        URLConnection urlConnection = new URL(urlWithParams).openConnection();
        urlConnection.setRequestProperty("Authorization", authorizationHeaderValue);
        
        BufferedReader br = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = br.readLine()) != null) {
            sb.append(line);
        }

        br.close();
        
        return sb.toString();
    }
全コード
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;

import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;

public class Twitter {
    public String getUsersShow(String screen_name)
            throws InvalidKeyException, NoSuchAlgorithmException, MalformedURLException, IOException {
        String method = "GET";
        String url = "https://api.twitter.com/1.1/users/show.json";
        Map<String, String> paramMap = getUsersShowParamMap(screen_name);
        Map<String, String> oAuthParamMap = getOAuthParamMap();
        
        String urlWithParams = getUrlWithParams(url, paramMap);
        String signatureBaseString = getSignatureBaseString(method, url, paramMap, oAuthParamMap);
        String authorizationHeaderValue = getAuthorizationHeaderValue(signatureBaseString, oAuthParamMap);
        
        return request(urlWithParams, authorizationHeaderValue);
    }
    
    private String request(String urlWithParams, String authorizationHeaderValue)
            throws MalformedURLException, IOException {
        URLConnection urlConnection = new URL(urlWithParams).openConnection();
        urlConnection.setRequestProperty("Authorization", authorizationHeaderValue);
        
        BufferedReader br = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = br.readLine()) != null) {
            sb.append(line);
        }

        br.close();
        
        return sb.toString();
    }
    
    private Map<String, String> getUsersShowParamMap(String screen_name) {
        Map<String, String> urlParamMap = new HashMap<String, String>();
        urlParamMap.put("screen_name",screen_name);
        
        return urlParamMap;
    }
    
    private Map<String, String> getOAuthParamMap() {
        String oAuthConsumerKey = "1.Consumer key";
        String oAuthAccessToken = "3.Access token";
        String oAuthNonce = String.valueOf(System.currentTimeMillis());
        String oAuthSignatureMethod = "HMAC-SHA1";
        String oAuthTimestamp = getTimestamp();
        String oAuthVersion = "1.0";
        
        Map<String, String> paramMap = new HashMap<String, String>();
        
        paramMap.put("oauth_consumer_key", oAuthConsumerKey);
        paramMap.put("oauth_nonce", oAuthNonce);
        paramMap.put("oauth_signature_method", oAuthSignatureMethod);
        paramMap.put("oauth_timestamp", oAuthTimestamp);
        paramMap.put("oauth_token", oAuthAccessToken);
        paramMap.put("oauth_version", oAuthVersion);

        return paramMap;
    }
    
    private String getSignatureBaseString(String method, String url,
            Map<String, String> urlParamMap, Map<String, String> oAuthParamMap) throws UnsupportedEncodingException {
        TreeMap<String, String> sortedParamMap = new TreeMap<String, String>();
        sortedParamMap.putAll(urlParamMap);
        sortedParamMap.putAll(oAuthParamMap);
        
        StringBuffer paramStringBuffer = new StringBuffer();
        for (Entry<String, String> paramEntry : sortedParamMap.entrySet()) {
            if (!paramEntry.equals(sortedParamMap.firstEntry())) {
                paramStringBuffer.append("&");
            }
            paramStringBuffer.append(paramEntry.getKey() + "=" + paramEntry.getValue());
        }
        
        String signatureBaseStringTemplate = "%s&%s&%s";
        String signatureBaseString =  String.format(
                signatureBaseStringTemplate, 
                URLEncoder.encode(method, "UTF-8"), 
                URLEncoder.encode(url, "UTF-8"),
                URLEncoder.encode(paramStringBuffer.toString(), "UTF-8"));
        
        return signatureBaseString;
    }
    
    private String getAuthorizationHeaderValue(String signatureBaseString, Map<String, String> oAuthParamMap)
            throws UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException {
        String oAuthConsumerSecret = "2.Consumer secret";
        String oAuthAccessTokenSecret = "4.Access token secret";
        String compositeKey = URLEncoder.encode(oAuthConsumerSecret, "UTF-8") + "&" 
                + URLEncoder.encode(oAuthAccessTokenSecret, "UTF-8");

        String oAuthSignature =  computeSignature(signatureBaseString, compositeKey);
        System.out.println("oAuthSignature       : "+oAuthSignature);

        String oAuthSignatureEncoded = URLEncoder.encode(oAuthSignature, "UTF-8");
        System.out.println("oAuthSignatureEncoded: "+oAuthSignatureEncoded);

        String authorizationHeaderValueTempl = 
                "OAuth oauth_consumer_key=\"%s\", oauth_nonce=\"%s\", oauth_signature=\"%s\", " + 
                "oauth_signature_method=\"%s\", oauth_timestamp=\"%s\", oauth_token=\"%s\", oauth_version=\"%s\"";
        String authorizationHeaderValue = String.format(
                authorizationHeaderValueTempl,
                oAuthParamMap.get("oauth_consumer_key"),
                oAuthParamMap.get("oauth_nonce"),
                oAuthSignatureEncoded,
                oAuthParamMap.get("oauth_signature_method"),
                oAuthParamMap.get("oauth_timestamp"),
                oAuthParamMap.get("oauth_token"),
                oAuthParamMap.get("oauth_version"));
        
        return authorizationHeaderValue;
    }
    
    private String getUrlWithParams(String url, Map<String, String> paramMap) {
        StringBuffer urlWithParams = new StringBuffer(url);
        TreeMap<String, String> treeMap = new TreeMap<String, String>();
        treeMap.putAll(paramMap);
        for (Entry<String, String> paramEntry : treeMap.entrySet()) {
            if (paramEntry.equals(treeMap.firstEntry())) {
                urlWithParams.append("?");
            } else {
                urlWithParams.append("&");
            }
            urlWithParams.append(paramEntry.getKey() + "=" + paramEntry.getValue());
        }
        
        return urlWithParams.toString();
    }
    
    private static String computeSignature(String baseString, String keyString)
            throws NoSuchAlgorithmException, InvalidKeyException
    {
        SecretKey secretKey = null;

        byte[] keyBytes = keyString.getBytes();
        secretKey = new SecretKeySpec(keyBytes, "HmacSHA1");

        Mac mac = Mac.getInstance("HmacSHA1");

        mac.init(secretKey);

        byte[] text = baseString.getBytes();

        return new String(Base64.encodeBase64(mac.doFinal(text))).trim();
    }

    private String getTimestamp() {
        long millis = System.currentTimeMillis();
        long secs = millis / 1000;
        return String.valueOf( secs );
    }
}

JSONのパース

JSONをパースして、情報を得る必要がありますが、適当にライブラリ導入してやればいいじゃないですかね。
GWTには、JSONをパースするクラスがあるので、クライアントサイドにそのまま投げちゃってもいい。

終わり

雲の中の2次にアニメ情報を追加したいなと思い、Twitter APIでプロファイル情報を取得する方法を調べました。
今時、公式ツイッターアカウントを持っていないほうが珍しいし、うまく組み込めると面白いことができるんじゃないですかね。

GWTのPlaceとActivity

作成中のアニメ特化アンテナサイト

雲の中の2次

ブラウザの履歴に残るようにする

GWTAjaxでクリックイベントを処理するので、ブラウザの履歴に残らない。
ただ、通常の感覚としては、リンククリックしたらブラウザの戻るで戻ってほしい。
そこで、GWTでは、PlaceとActivityというクラスでこの仕組みを提供している。

UI

anchor1とanchor2を用意し、anchor1がクリックされたらHello anchor1、anchor2がクリックされたらHello anchor2と表示されるサイトを作成する。
このクリックをブラウザバックに対応させる。
f:id:kumo2ji:20130810155614p:plain

UiBinder

anchor1とanchor2を追加し、Labelに結果を表示する。

<!DOCTYPE ui:UiBinder SYSTEM "http://dl.google.com/gwt/DTD/xhtml.ent">
<ui:UiBinder xmlns:ui="urn:ui:com.google.gwt.uibinder"
    xmlns:g="urn:import:com.google.gwt.user.client.ui">
    <ui:style>
        
    </ui:style>
    <g:HTMLPanel>
        <g:VerticalPanel>
            <g:Anchor text="anchor1" ui:field="anchor1" />
            <g:Anchor text="anchor2" ui:field="anchor2" />
            <g:Label ui:field="label" />
        </g:VerticalPanel>
    </g:HTMLPanel>
</ui:UiBinder> 

HelloViewImplコンストラクタはHelloの後に続く文字列を第2引数でとる。
HelloPlaceコンストラクタは第1引数にtokenをとり、この文字列がURLに追加される。
ここでは、押されたリンクに依存し、anchor1もしくはanchor2を指定する。

public class HelloViewImpl extends Composite {
    private static HelloViewImplUiBinder uiBinder = GWT
            .create(HelloViewImplUiBinder.class);

    interface HelloViewImplUiBinder extends UiBinder<Widget, HelloViewImpl> {
    }

    @UiField
    Anchor anchor1;
    @UiField
    Anchor anchor2;
    @UiField
    Label label;
    private PlaceController placeController;

    public HelloViewImpl(PlaceController placeController, String text) {
        initWidget(uiBinder.createAndBindUi(this));
        this.placeController = placeController;
        label.setText("Hello " + text);
    }

    @UiHandler("anchor1")
    void onAnchor1Click(ClickEvent event) {
        placeController.goTo(new HelloPlace(anchor1.getText()));
    }

    @UiHandler("anchor2")
    void onAnchor2Click(ClickEvent event) {
        placeController.goTo(new HelloPlace(anchor2.getText()));
    }
}

Place

PlaceはURLに追加されるトークンを規定する。
具体的には#HelloPlace:tokenが追加される。TokenizerのgetPlace(String token)の第1引数tokenはコロン以降の文字列が入る。

public class HelloPlace extends Place {
    private String token;

    public HelloPlace(String token) {
        this.token = token;
    }

    public String getToken() {
        return token;
    }

    public static class Tokenizer implements PlaceTokenizer<HelloPlace> {
        @Override
        public String getToken(HelloPlace place) {
            return place.getToken();
        }

        @Override
        public HelloPlace getPlace(String token) {
            return new HelloPlace(token);
        }
    }
}

Activity

ActivityはPlace(トークン)とUI(UiBinder)を繋ぐ。
startのオーバーライドで、HelloViewImplをインスタンス化し、containerWidgetにsetWidgetする。
containerWidgetはActivityManagerのsetDisplayで指定したWidgetになる。

public class HelloActivity extends AbstractActivity {
    private String token;
    private PlaceController placeController;

    public HelloActivity(HelloPlace place, PlaceController placeController) {
        this.token = place.getToken();
        this.placeController = placeController;
    }

    @Override
    public void start(AcceptsOneWidget containerWidget, EventBus eventBus) {
        containerWidget.setWidget(new HelloViewImpl(placeController, token));
    }
}

また、Activityを利用するためには、Gwt.gwt.xmlにcom.google.gwt.activity.Activityを追加する必要がある。

<?xml version="1.0" encoding="UTF-8"?>
<!-- When updating your version of GWT, you should also update this DTD reference, 
    so that your app can take advantage of the latest GWT module capabilities. -->
<!DOCTYPE module PUBLIC "-//Google Inc.//DTD Google Web Toolkit 2.5.1//EN"
  "http://google-web-toolkit.googlecode.com/svn/tags/2.5.1/distro-source/core/src/gwt-module.dtd">
<module rename-to='gwt'>

    <inherits name='com.google.gwt.user.User' />

    <inherits name='com.google.gwt.user.theme.clean.Clean' />
    <inherits name="com.google.gwt.activity.Activity" />

    <entry-point class='com.ry.gwt.client.Gwt' />

    <source path='client' />
    <source path='shared' />

</module>

ActivityMapper

ActivityMapperはPlaceとActivityを繋ぐ。
instanceofでplaceのクラスを特定し、適切なActivityのサブクラスを返すようにgetActivityをオーバーライドする。

public class AppActivityMapper implements ActivityMapper {
    private PlaceController placeController;
    public AppActivityMapper(PlaceController placeController) {
        this.placeController = placeController;
    }
    @Override
    public Activity getActivity(Place place) {
        if (place instanceof HelloPlace) {
            return new HelloActivity((HelloPlace) place, placeController);
        }
        return null;
    }
}

PlaceHistoryMapper

PlaceHistoryMapperはPlaceをHistory(履歴)にマッピングする。

@WithTokenizers({HelloPlace.Tokenizer.class})
public interface AppPlaceHistoryMapper extends PlaceHistoryMapper
{
}

エントリーポイント

エントリーポイントで、これらの要素を繋ぐ。
Activityのstartメソッドの第1引数AcceptsOneWidget containerWidgetはここで指定したSimplePanelになる。

public class Gwt implements EntryPoint {
    @Override
    public void onModuleLoad() {
        SimplePanel appWidget = new SimplePanel();
        RootPanel.get().add(appWidget);
        
        EventBus eventBus = new SimpleEventBus();
        PlaceController placeController = new PlaceController(eventBus);

        // Start ActivityManager for the main widget with our ActivityMapper
        ActivityMapper activityMapper = new AppActivityMapper(placeController);
        
        ActivityManager activityManager = new ActivityManager(activityMapper, eventBus);
        activityManager.setDisplay(appWidget);

        // Start PlaceHistoryHandler with our PlaceHistoryMapper
        AppPlaceHistoryMapper historyMapper = GWT.create(AppPlaceHistoryMapper.class);
        PlaceHistoryHandler historyHandler = new PlaceHistoryHandler(historyMapper);
        historyHandler.register(placeController, eventBus, new HelloPlace("anchor1"));

        // Goes to the place represented on URL else default place
        historyHandler.handleCurrentHistory();
    }
}

参考

GWT Project

GAE + GWT環境構築

GAE + GWTプログラミングの環境構築メモ

作成中のアニメ特化アンテナサイト

雲の中の2次

Eclipseダウンロード

Eclipse 日本語化 | MergeDoc Projectで32bitもしくは64bitのJava Full Editionをダウンロードする。
f:id:kumo2ji:20130810084331p:plain

Eclipse設定

ダウンロードしたzipを解凍し、pleiades\eclipseにあるeclipse.exeを実行する。
f:id:kumo2ji:20130810091646p:plain
ファイル保存場所になるワークスペースはデフォルト値../workspaceのままでOKする。
f:id:kumo2ji:20130810091730p:plain
Eclipseが立ち上がったらウィンドウ→設定を選択し、設定ウィンドウを開く。
f:id:kumo2ji:20130810091909p:plain
Javaコンパイラーを選択し、コンパイラー準拠レベルを1.6から1.7に変更し、適用する。
f:id:kumo2ji:20130810094223p:plain
Java→インストール済みのJREを選択し、Java7に変更し、OKする。
f:id:kumo2ji:20130810094349p:plain
ヘルプ→Eclipseマーケットプレースを選択する。
f:id:kumo2ji:20130810094538p:plain
gaeで検索し、Googleプラグインのインストールボタンを押し、インストールする。
f:id:kumo2ji:20130810094651p:plain
f:id:kumo2ji:20130810094811p:plain
f:id:kumo2ji:20130810094855p:plain
最後に再起動する。
f:id:kumo2ji:20130810094916p:plain

GAEプロジェクト作成

新規Webアプリケーションプロジェクトを選択する。
f:id:kumo2ji:20130810095012p:plain
プロジェクト名、パッケージを適当に設定し、完了ボタンを押す。
f:id:kumo2ji:20130810095116p:plain

プロジェクトのデバッグ

サンプルが動く形で、作成されるので、そのままデバッグを開始する。
f:id:kumo2ji:20130810095232p:plain
URLが表示されるので、このURLをダブルクリックして、ブラウザを起動する。
f:id:kumo2ji:20130810095612p:plain
アクセスを許可する。
f:id:kumo2ji:20130810095346p:plain
f:id:kumo2ji:20130810095549p:plain
GWTデバッグを行うためには対象ブラウザにGWTアドオンがインストールされている必要があるので、インストールする。
f:id:kumo2ji:20130810095741p:plain
GWTアドオンをインストールした後にもう一度URLにアクセスするとアプリケーションが表示される。
f:id:kumo2ji:20130810095837p:plain
f:id:kumo2ji:20130810100000p:plain

Windows8 + Google Chromeでのデバッグ

GWTのバグでWindows8 + Google Chromeの組み合わせで、GWTアドオンをインストールすることができない。
https://www.dropbox.com/s/ldtpxuuh9ovof12/gwt-dev-plugin.crxからダウンロードしたファイルを以下の手順でGoogle Chromeにインストールすることで解決することができる。
1. Google Chromeを右クリック→プロパティ→ショートカットを開く
2. リンク先に --enable-easy-off-store-extension-install を追加する
3. chrome://chrome/extensions/ を開く
4. ダウンロードしたcrxファイルをドラッグアンドドロップする

Datastoreのテスト

作成中のアニメ特化アンテナサイト
雲の中の2次

Datastoreのテストを記述する方法が用意されているので、これを用いて、GAEのテストを記述する。

1. 必要なビルドパスを通す。
GAEプロジェクトを作った初期状態では、テスト記述に必要なビルドパスが通っていない。
${SDK_ROOT}/lib/impl/appengine-api-stubs.jar と ${SDK_ROOT}/lib/testing/appengine-testing.jar が通っていないので、プロジェクトのプロパティを開いて、「外部Jar追加」から追加する。
jarファイルは特に変更していなければ、eclipseのplugins以下を探せばある。
f:id:kumo2ji:20130731174604p:plain

ライブラリを追加すると警告が表示される。
これを消すためには、Google -> Webアプリケーション の警告抑制に追加する。f:id:kumo2ji:20130731175445p:plain

JUnit 4に関してもビルドパスを通しておく。


2. テストを記述する。
GAEプロジェクトを作成すると、testフォルダが作成されるので、この下にテストクラスを作成する。

import static org.junit.Assert.*;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import com.google.appengine.api.datastore.DatastoreService;
import com.google.appengine.api.datastore.DatastoreServiceFactory;
import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.FetchOptions;
import com.google.appengine.api.datastore.Query;
import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig;
import com.google.appengine.tools.development.testing.LocalServiceTestHelper;

public class LocalDatastoreTest {
	private final LocalServiceTestHelper helper =
	        new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig());

	    @Before
	    public void setUp() {
	        helper.setUp();
	    }

	    @After
	    public void tearDown() {
	        helper.tearDown();
	    }

	    // run this test twice to prove we're not leaking any state across tests
	    private void doTest() {
	        DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
	        assertEquals(0, ds.prepare(new Query("yam")).countEntities(FetchOptions.Builder.withLimit(10)));
	        ds.put(new Entity("yam"));
	        ds.put(new Entity("yam"));
	        assertEquals(2, ds.prepare(new Query("yam")).countEntities(FetchOptions.Builder.withLimit(10)));
	    }

	    @Test
	    public void testInsert1() {
	        doTest();
	    }

	    @Test
	    public void testInsert2() {
	        doTest();
	    }

}

3. テストを実行

参考:
ローカル ユニットのテスト - Google App Engine — Google Developers

Cron+TaskQueue+Backendsで定期実行

作成中のアニメ特化アンテナサイト
http://kumo2ji.appspot.com/

Cronはタスクを1時間に1回、1週間に1回など定期的間隔で実行するための仕組み。
TaskQueueは時間のかかるタスクをいくつかのQueueに分割して実行するための仕組み。
BackendsはFrontendと別に用意されたリソース。無料分がFrontendと別に用意されている。

この3つを組み合わせて無料リソースを最大限利用して、比較的時間のかかる処理を定期的に実行する。

まずはBackendインスタンスを作成する。
WEB-INF/backends.xmlを作成し、インスタンスの設定を記述する。

<backends>
  <backend name="b1">
    <class>B1</class>
   <options>
     <dynamic>true</dynamic>
   </options>
  </backend>
</backends>

次に、CronとTaskQueueのリクエストを受け取るためのServletを作成する。
ここでは、例として、アマゾンの商品情報をDatastoreに保存するstoreItemメソッドをアニメ数分行う処理を考える。
StoreAmazonItemServletはCronからはdoGetが呼ばれ、TaskQueueからはdoPostが呼ばれる。
Cronから呼ばれるdoGetでは、QueueFactory.getQueue("StoreAmazonItemServlet")でQueueを作成する。
getQueueの引数はqueue.xmlで指定するキュー名である。
queueには、パラメータとHostヘッダーを指定する。
このヘッダーがTaskQueueをBackendで実行するために必要な情報となる。
getBackendAddressの引数はbackends.xmlで指定したbackendの名前。

TaskQueueにより実行されるdoPostでは、storeItemメソッドを実行する。
この例では、doGetでアニメの数だけQueueが追加されたので、その回数分、doPostが呼ばれることになる。

public class StoreAmazonItemServlet extends HttpServlet {
	private static final long serialVersionUID = 1L;
	
	@Override
	protected void doPost(HttpServletRequest req, HttpServletResponse resp)
			throws ServletException, IOException {
		AmazonService service = new AmazonService();
		service.storeItem(req.getParameter("animeKeyword"));
	}

	@Override
	protected void doGet(HttpServletRequest req, HttpServletResponse resp)
			throws ServletException, IOException {
		AnimeService service = new AnimeService();
                Queue queue = QueueFactory.getQueue("StoreAmazonItemServlet");
		for (String keyword : service.getAnimeList()) {
			queue.add(TaskOptions.Builder
				.withParam("animeKeyword", keyword)
				.header("Host", BackendServiceFactory.getBackendService().getBackendAddress("b1")));
		}
	}
}

このServletをweb.xmlに登録する。
queueはURLを指定しない場合、/_ah/queue/[キュー名]が使用されるので、そのように指定している。

<servlet>
    <servlet-name>StoreAmazonItemServlet</servlet-name>
    <servlet-class>com.kumo2ji.server.StoreAmazonItemServlet</servlet-class>
  </servlet>
  
  <servlet-mapping>
    <servlet-name>StoreAmazonItemServlet</servlet-name>
    <url-pattern>/_ah/queue/StoreAmazonItemServlet</url-pattern>
  </servlet-mapping>

WEB-INF/cron.xmlのurlにも/_ah/queue/StoreAmazonItemServletを指定することで、TaskQueueと同じServletを使用する。
もちろん異なるServletを用意し、それぞれに指定してもよい。
targetにbackends.xmlのbackend名を指定することで、CronをBackendで実行することができる。

<?xml version="1.0" encoding="UTF-8"?>
<cronentries>
  <cron>
    <url>/_ah/queue/StoreAmazonItemServlet</url>
    <description>store Amazon item entry</description>
    <schedule>every day 00:00</schedule>
    <target>b1</target>
  </cron>
</cronentries>

最後にWEB-INF/queue.xmlにTaskQueueを登録すれば、完了。

<queue-entries>
  <queue>
    <name>StoreAmazonItemServlet</name>
    <rate>2/m</rate>
  </queue>
</queue-entries>

Cronはデバッグ環境では、実行されないので、本番環境にデプロイして、動作確認する。

MemcacheでDatastoreへのアクセスを減らす

作成中のアニメ特化アンテナサイト
http://kumo2ji.appspot.com/


GAEではDatastoreへのRead、Writeの回数に対して、課金している。
なので、Datastoreへのアクセスはなるべく減らしたほうがよい。
Memcacheを使えば、応答性を上げつつ、Datastoreへのアクセスを減らすことができる。

Memcacheを利用するためには、まず、MemcacheServiceFactory.getMemcacheService("namespace")でMemcacheServiceを得る。
namespaceはキャッシュの名前空間を指定している。
このMemcacheServiceに対して、第1引数keyオブジェクト、第2引数valueオブジェクトを指定して、putすることで、キャッシュすることができる。
MemcacheServiceFactory.getMemcacheServiceで得られるMemcacheServiceはリクエストを跨ぐので、異なるリクエストに対してもputしたvalueを返すことができる。

MemcacheService cache = MemcacheServiceFactory.getMemcacheService("namespace");
cache.put("key", "value");

試しに、KeyからEntityを得るメソッドgetEntityにMemcacheの仕組みを入れてみる。
cache.contains(key)で登録されているかどうか調べ、登録されていなければ、cache.put(key, ds.get(key))でEntityをキャッシュに登録。
登録されていれば、(Entity)cache.get(key)でキャッシュの値を返す。

public static Entity getEntity(Key key) throws EntityNotFoundException {
	MemcacheService cache = MemcacheServiceFactory.getMemcacheService();
	if (!cache.contains(key)) {
		DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
		cache.put(key, ds.get(key));
	}
	return (Entity)cache.get(key);
}

Memcacheがキャッシュできる容量は1Mでそのときの使用状況でクリアされるので、Memcacheにあることを前提としてはいけない。