365連休

にわかのandroidとかの開発メモ。

Android Studio 4.1 と Google Cloud Platformで無料クライアント・サーバアプリを構築してみる

免責事項

この記事は有料サービスであるGoogle Cloud Platformを無料で使う実験的試みである。


この記事の内容を実践したり、参考にしたことによって、
間接的、直接的を問わず、何らかの損害が発生したとしても
執筆者は一切保証しない。

同意できない場合、今すぐ記事を離れること。

 

 

 

対象読者 

以下の全てに該当する人

 

 

前書き 

Androidクライアントとサーバ連携させてなんかアプリ作ったろ。

->日本語対応の良さそうな無料サーバ無い??

-->Googleがやってるクラウドサービスでサーバっぽいのあるらしい

--->サービスの負荷が少ないうちは無料で使える!?

 

各無料枠については公式を参照されたい。

cloud.google.com

 

執筆時時点でus-centralリージョンを使えば、無料でクライアント・サーバアプリを実現できそう。

 

なお、Android Studioでコードを書いて、Google Cloud Platformに配置

これだけで作るため、言語はJava縛り(`・ω・´)

 

 

以降で出現する、GCPプロジェクトは、この記事のために一時的に作成したものであるため、現在はアクセスすることはできない。

コードをコピペする場合は、適宜リージョン名やプロジェクト名を置き換える事。

 

 

アプリ設計 

ここではサンプルとして単一スレッド(スレ立てできないということ)のBBS(掲示板)を構築する。

f:id:neet_rookie:20210219002820p:plain

画面イメージ

Androidクライアント

  • BBS表示・書き込み

 

Google Cloud Platform

  • BBS用のデータベース
  • Web APIによるDBアクセス
  • Web サイト

 

 

詳細設計 

f:id:neet_rookie:20210219094807p:plain

システム構成図

Androidクライアント

  • 単一画面(単一アクティビティ)
    • BBS表示
      サーバから落としたデータをListに保持
      データ更新時にListViewにAdapterをかまして表示
    • BBS更新
      起動時以外は、手動更新とする
    • BBS書き込み
      [Button]->[EditTextダイアログ]->[サーバ送信]
    • 利用規約・ライセンス
      [ActionBar]->[オプションメニュー]->[AlertDialog]で表示する
  • minSdkVersion 16 (Android 4.1) ※インストール可能なAndroid端末の下限
  • compileSdkVersion & targetSdkVersion 30 (Android 11)
    ※将来Android 12が出たらAndroid 11互換モードで動作するという意味

 

Google Cloud Platform(通称GCP)

  • Google Cloud Datastore
    Kind bbs
    Entity ip
    文字列
    書込元IP
    time
    数値(int64)
    Unix Epoch(ミリ秒)
    text
    文字列
    本文
    good
    数値(int64)
    カウンタ
    bad
    数値(int64)
    カウンタ


  • Google Cloud App Engine(通称GAE)(スタンダード環境)
    HTTPS接続のPOST、Android 5.0未満はHTTP接続
    • HTML https://example-project-20210219.uc.r.appspot.com/index.html
      JavaScriptXMLHttpRequestでGAEの各種APIを叩く
    • API https://example-project-20210219.uc.r.appspot.com/api
      機能 POST引数
      ログ取得 {function:"read", begin:取得日時下限, end:取得日時上限}
      書込み {function:"write", text:書き込み本文}
      いいね!カウントアップ {function:"good", time:対象の書き込み日時}
      わるいね!カウントアップ {function:"bad", time:対象の書き込み日時}

    ※負荷対策:同一IPからの一定時間内再書き込みを禁止。一度に取得可能な書き込みログの時間範囲に上限を設ける。
    ※GAEからGCFのapiを起動するには、Google Cloud Tasksを挟まないといけないため、GCFと同様のapiを設ける。

  • Google Cloud Functions(通称GCF)
    HTTPS接続のPOST、Android 5.0未満はHTTP接続
    • API https://us-central-example-project-20210219.cloudfunctions.net/api
      POST 引数はGAEと同様

 

※今回のアプリではGCFを省きGAEのみでアプリ構築可能だが、実験として双方使用する。

※今回のアプリでWebサイトが必要無いならGAEは不要。

※今回はやらないが、 GAEで表示するサイトAndroidアプリのWebView内に埋め込む のも手。

 

 

開発環境 

各種インストール方法は割愛する。

 

 

 GCP初期設定 

プロジェクトの作成とサービスのアクティベートをする。デプロイ&ビルドはGradleにて自動化されるためここでは設定不要。

リージョンは、執筆時時点で今回利用する各種プロダクトで無料枠があるus-centralで統一する。

GCPプロジェクトを作成する

  1. プロジェクト名とIDを入力する

    f:id:neet_rookie:20210219200003p:plain

    既存プロジェクトがある場合、プロジェクト選択ボタン

    f:id:neet_rookie:20210219200028p:plain

    新しいプロジェクト

    f:id:neet_rookie:20210219200057p:plain

    プロジェクト名入力
  2. プロジェクト作成完了

    f:id:neet_rookie:20210219200114p:plain

    作成されたプロジェクトのダッシュボード画面

    f:id:neet_rookie:20210219200138p:plain

    よく使うメニューをピン止め

 

GAEアプリケーションを作成する

  1. GAEを有効にする
    GCPメニューからGoogle Cloud App Engineを選択し、開いた画面でGAEを有効にする。※もしかしたら聞かれないかも
  2. GAEアプリケーションを作成する1/4

    f:id:neet_rookie:20210219200918p:plain



  3. GAEアプリケーションを作成する2/4

    f:id:neet_rookie:20210219200932p:plain



  4. GAEアプリケーションを作成する3/4

    f:id:neet_rookie:20210219200944p:plain



  5. GAEアプリケーションを作成する4/4

    f:id:neet_rookie:20210219200954p:plain

 

GCF

  1. GCFを有効にする
    GCPメニューからGoogle Cloud Functionsを選択し、開いた画面でGCFを有効にする。※もしかしたら聞かれないかも

 

Google Cloud Datastore

  1. Datastoreを有効にする
    GCPメニューからGoogle Cloud Datastoreを選択し、開いた画面でDatastoreを有効にする。※もしかしたら聞かれないかも

 

 

開発していく 

開発手順

  1. Android Studioで新しいプロジェクトを作る
  2. 共通処理用のJavaモジュールを追加する
  3. GAE用のJavaモジュールを追加する
  4. GAEをデプロイ&テストする
  5. GCF用のJavaモジュールを追加する
  6. GCFをデプロイ&テストする
  7. appモジュールを作り込む
  8. Androidアプリをエミュレータでテストする

 

1.Android Studioで新しいプロジェクトを作る 

f:id:neet_rookie:20210220133056p:plain

f:id:neet_rookie:20210220133101p:plain

f:id:neet_rookie:20210220133104p:plain

f:id:neet_rookie:20210220133438p:plain

自動生成されたプロジェクト構造

Gradle設定

  • Android Gradle Plugin 4.1.2
  • Gradle Version 6.8.3

 

 

2.共通処理用のJavaモジュールを追加する 

f:id:neet_rookie:20210220135024p:plain

f:id:neet_rookie:20210220135028p:plain

f:id:neet_rookie:20210220135033p:plain

f:id:neet_rookie:20210220135037p:plain

追加した共通処理モジュール(サブプロジェクト)の構造

 

モジュールのbuild.gradleを編集する

plugins {
    id 'java-library'
}

repositories {
    mavenCentral()
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

dependencies {
    //NonNull Nullableなどのアノテーション
    implementation "androidx.annotation:annotation:1.1.0" //Apache License 2.0

    //Datastore API
    implementation 'com.google.cloud:google-cloud-datastore:1.105.7'

}

tasks.withType(JavaCompile) {
    options.encoding = 'UTF-8'
}

 

 

モジュールのMyClass.javaを編集する

package com.example.lib;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.google.cloud.datastore.Datastore;
import com.google.cloud.datastore.DatastoreOptions;
import com.google.cloud.datastore.Entity;
import com.google.cloud.datastore.EntityQuery;
import com.google.cloud.datastore.FullEntity;
import com.google.cloud.datastore.IncompleteKey;
import com.google.cloud.datastore.KeyFactory;
import com.google.cloud.datastore.KeyQuery;
import com.google.cloud.datastore.QueryResults;
import com.google.cloud.datastore.StructuredQuery;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;

import java.net.InetAddress;
import java.util.Arrays;

public class MyClass {
    /** このクラスで対象とするDatastoreのKind */
    public static final @NonNull String KIND = "bbs";

    /** DatastoreのKIND bbsのプロパティ */
    public interface PROPERTY{ //static final変数を定義するための、名前付きスコープ的な用法
        @NonNull String IP = "ip";
        @NonNull String TIME = "time";
        @NonNull String TEXT = "text";
        @NonNull String GOOD = "good";
        @NonNull String BAD = "bad";
    }

    /** IPの文字列化 ※toString()だと結果はJavaRuntimeに依存するため、独自に定義する */
    public static @NonNull String ipToString(@NonNull InetAddress ip){
        return ip.getHostName()+"/"+ip.getHostAddress();
    }

    /** {@link #readRaw} の引数のbegin~endの範囲の最大 */
    public static final long TIME_SPAN_LIMIT = 24*60*60*1000L;

    /** 数値の引数を受けるread */
    public static @NonNull JsonArray readJson(long begin, long end, @Nullable InetAddress ip) throws Exception {
        //Datastoreから検索
        final @NonNull QueryResults<Entity> results = readRaw(begin, end, ip);

        //検索結果からJsonを作る
        final @NonNull JsonArray jsonRoot = new JsonArray(); //com.google.gson.*
        while(results.hasNext()){
            final @NonNull Entity entity = results.next();

            final @NonNull JsonObject jsonObject = new JsonObject();
            jsonRoot.add(jsonObject);

            jsonObject.add(PROPERTY.TIME, new JsonPrimitive(entity.getLong(PROPERTY.TIME)));
            jsonObject.add(PROPERTY.TEXT, new JsonPrimitive(entity.getString(PROPERTY.TEXT)));
            jsonObject.add(PROPERTY.GOOD, new JsonPrimitive(entity.contains(PROPERTY.GOOD) ? entity.getLong(PROPERTY.GOOD) : 0));
            jsonObject.add(PROPERTY.BAD, new JsonPrimitive(entity.contains(PROPERTY.BAD) ? entity.getLong(PROPERTY.BAD) : 0));
        }
        //Jsonを文字列化
        return jsonRoot;
    }

    /** DatastoreのKind bbsのデータが存在するか
     * @return true Exists / false No found */
    public static boolean existsKind() throws Exception{
        final @NonNull Datastore datastore = DatastoreOptions.getDefaultInstance().getService();
        return datastore //KINDが存在するか
                .run(KeyQuery.newKeyQueryBuilder().setKind(KIND).build())
                .hasNext();
    }

    /** DatastoreからKind bbsを取得する
     * @param begin 取得日時下限
     * @param end 取得日時上限
     * @param ip 指定した場合、==IPで抽出
     * @return クエリ実行結果 */
    public static @NonNull QueryResults<Entity> readRaw(long begin, long end, @Nullable InetAddress ip) throws Exception{
        //バリデータ
        if(end < begin){
            throw new IllegalArgumentException("End < Begin. begin="+begin+", end="+end);
        }else if(end - begin > TIME_SPAN_LIMIT){
            throw new IllegalArgumentException("Too long the time span. begin"+begin+", end="+end+", limit="+TIME_SPAN_LIMIT);
        }

        //Datastoreのインスタンスを取得する
        final @NonNull Datastore datastore = DatastoreOptions.getDefaultInstance().getService();

        //クエリを構築する
        final @NonNull EntityQuery.Builder queryBuilder = StructuredQuery.newEntityQueryBuilder().setKind(KIND);
        //filter演算子( ..)φメモメモ
        //eq equal
        //ge 以上 (greater or equal)
        //gt より大きい (greater than)
        //le 以下 (less or equal)
        //lt より少ない (less than)
        final @NonNull StructuredQuery.Filter[] filters = new StructuredQuery.Filter[ip==null ? 2 : 3];
        filters[0] = StructuredQuery.PropertyFilter.ge(PROPERTY.TIME, begin);
        filters[1] = StructuredQuery.PropertyFilter.le(PROPERTY.TIME, end);
        if(ip!=null){
            filters[2] = StructuredQuery.PropertyFilter.eq(PROPERTY.IP, ipToString(ip));
        }
        queryBuilder.setFilter(StructuredQuery.CompositeFilter.and(filters[0],
                Arrays.copyOfRange(filters, 1, filters.length)));

        queryBuilder.setOrderBy(StructuredQuery.OrderBy.desc(PROPERTY.TIME));

        //クエリを実行する
        return datastore.run(queryBuilder.build());
    }

    /** {@link #write} の同一IPからの書込み制限時間 */
    public static final long POST_INTERVAL_LIMIT = 60*1000L;

    /** DatastoreのKind bbsへInsert
     * @param ip クライアントIPアドレス
     * @param text 書込み本文
     * @param ifInvalidPostLimit 連投制限に該当した場合のコールバック */
    public static void write(@NonNull InetAddress ip, @NonNull String text, @NonNull Runnable ifInvalidPostLimit) throws Exception{
        //Datastoreのインスタンスを取得する
        final @NonNull Datastore datastore = DatastoreOptions.getDefaultInstance().getService();

        //バリデータ
        final long current = System.currentTimeMillis();
        if(existsKind() && //Kind bbs存在チェック
                readRaw(current-POST_INTERVAL_LIMIT, current, ip).hasNext()){ //INTERVAL以内に書込みを見つけたら
            ifInvalidPostLimit.run();
            return;
        }

        //エンティティを構築する
        // memo:汎用のDatastoreライブラリにはPropertyの個別Index(Built-in Index)をオフにする手段が無い。GAE用のライブラリにはsetUnindexedPropertyが存在する。
        final @NonNull KeyFactory keyFactory = datastore.newKeyFactory().setKind(KIND); //RawIDみたいな "EntityのKey"を生成するためのFactory
        final @NonNull FullEntity.Builder<IncompleteKey> entityBuilder = Entity.newBuilder()
                .setKey(keyFactory.newKey()) //未指定で新しいKeyを生成
                .set(PROPERTY.IP, ipToString(ip))
                .set(PROPERTY.TIME, System.currentTimeMillis())
                .set(PROPERTY.TEXT, text)
                //Datastoreでは "Entityごとにプロパティ構造が変わる" ことが許容されているため、必要になるまでプロパティを作らない
                //.set(PROPERTY.GOOD, 0)
                //.set(PROPERTY.BAD, 0)
                ;
        final @NonNull FullEntity<IncompleteKey> entity = entityBuilder.build();

        //Upsert (InsertまたはUpdate)
        datastore.put(entity);
        //datastore.add(entity); Insertの場合はadd
    }

    /** DatastoreのKind bbsのtimeが一致するEntityへgood
     * @param time 書込み対象の日時 */
    public static void good(long time) throws Exception{
        countUp(PROPERTY.GOOD, time);
    }

    /** DatastoreのKind bbsのtimeが一致するEntityへbad
     * @param time 書込み対象の日時 */
    public static void bad(long time) throws Exception{
        countUp(PROPERTY.BAD, time);
    }

    /** DatastoreのKind bbsの指定されたPropertyをLongでカウントアップ
     * @param time timeが一致するもの全て更新する */
    private static void countUp(@NonNull String property, long time) throws Exception{
        //Datastoreのインスタンスを取得する
        final @NonNull Datastore datastore = DatastoreOptions.getDefaultInstance().getService();

        //GQLでUPDATEができないみたいなので、トランザクションを掛けて取得Query->更新を行う
        datastore.runInTransaction(readerWriter -> {
            //既存データ取得:クエリを構築する
            final @NonNull StructuredQuery<Entity> query = StructuredQuery.newEntityQueryBuilder()
                    .setKind(KIND)
                    .setFilter(StructuredQuery.PropertyFilter.eq(PROPERTY.TIME, time))
                    .build();

            //既存データ取得:クエリを実行する
            final @NonNull QueryResults<Entity> results = datastore.run(query);

            //既存データあり?バリデータ
            if(!results.hasNext()){
                throw new IllegalArgumentException("timeに一致するレコードが見つからない。time="+time);
            }

            //取得したデータについて、全てカウントアップする
            while(results.hasNext()){
                final @NonNull Entity target = results.next();
                //UPDATE
                datastore.update(Entity.newBuilder(target)
                        .set(property, target.contains(property) ? target.getLong(property)+1 : 1)
                        .build());
            }

            return null; //ダミーオブジェクトを返す
        });
    }

}

 

 

3.GAE用のJavaモジュールを追加する 

f:id:neet_rookie:20210220162720p:plain

f:id:neet_rookie:20210220162724p:plain

追加したGAEモジュール(プロジェクト)の構造

 

モジュールのbuild.gradleを編集する。
下記コードは公式サンプル(Java8 GAE Gradle)を元にカスタマイズしたものである。
https://cloud.google.com/appengine/docs/standard/java/using-gradle?hl=ja

//このファイルの参考URL
//https://cloud.google.com/appengine/docs/standard/java/using-gradle?hl=ja

buildscript {    // Configuration for building
    repositories {
        //jcenter() 閉鎖予定のため使用しない
        mavenCentral()
    }
    dependencies {
        classpath 'com.google.cloud.tools:appengine-gradle-plugin:2.2.0' // If a newer version is available, use it
    }
}

repositories {   // repositories for Jar's you access in your code
    maven {
        url 'https://oss.sonatype.org/content/repositories/snapshots' // SNAPSHOT repository (if needed)
    }
    mavenCentral()
    //jcenter() 閉鎖予定のため使用しない
}

apply plugin: 'java'                              // standard Java tasks
apply plugin: 'war'                               // standard Web Archive plugin
apply plugin: 'com.google.cloud.tools.appengine'  // App Engine tasks

dependencies {
    //App Engine SDK
    def appengine_version = "+"
    compile "com.google.appengine:appengine-api-1.0-sdk:$appengine_version"  // Latest App Engine Api's

    //サーブレット
    providedCompile "javax.servlet:javax.servlet-api:3.1.0" //Servlet
    compile "jstl:jstl:1.2" //JSP

    //Datastore API
    implementation platform("com.google.cloud:libraries-bom:16.3.0") //guava, protobuf, grpc-java, google-http-java-client, google-cloud-java
    compile "com.google.cloud:google-cloud-datastore"

    // Add your dependencies here.
    //  compile 'com.google.cloud:google-cloud:+'   // Latest Cloud API's http://googlecloudplatform.github.io/google-cloud-java

    //共通処理
    implementation project(":lib") //ローカルでビルドするためこれで問題無い

    //NonNull Nullableなどのアノテーション
    implementation "androidx.annotation:annotation:1.1.0" //Apache License 2.0

    //テスト系
    testImplementation 'junit:junit:4.+'
    testCompile "com.google.truth:truth:1.0.1"
    testCompile "org.mockito:mockito-all:1.10.19"

    testCompile "com.google.appengine:appengine-testing:$appengine_version"
    testCompile "com.google.appengine:appengine-api-stubs:$appengine_version"
    testCompile "com.google.appengine:appengine-tools-sdk:$appengine_version"
}

// Always run unit tests
appengineDeploy.dependsOn test
appengineStage.dependsOn test

appengine {  // App Engine tasks configuration
    deploy {   // deploy configuration
        projectId = 'example-project-20210219' //デプロイ先のプロジェクト
        version = 'v1'
        stopPreviousVersion = true //前のバージョンを止める
    }
}

test {
    useJUnit()
    testLogging.showStandardStreams = true
    beforeTest { descriptor ->
        logger.lifecycle("test: " + descriptor + "  Running")
    }

    onOutput { descriptor, event ->
        logger.lifecycle("test: " + descriptor + ": " + event.message )
    }
    afterTest { descriptor, result ->
        logger.lifecycle("test: " + descriptor + ": " + result )
    }
}

group   = "com.example.appenginej8"        // Generated output GroupId
version = "1.0-SNAPSHOT"       // Version in generated output

sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

 

APIServlet.javaを編集する。

POSTにのみ応答するためdoPostをオーバーライドしている。

package com.example.gae;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.example.lib.MyClass;

import java.io.IOException;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.UnknownHostException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import static com.example.lib.MyClass.TIME_SPAN_LIMIT;

public class APIServlet extends HttpServlet {

    /** POST引数に問題があり処理を継続できない場合に使用する */
    public static class BadRequestError extends RuntimeException {
        public BadRequestError(String s) {
            super(s);
        }
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        try {
            //functionに応じて処理を切り替える
            final @Nullable String name = req.getParameter("function");
            if(name==null) throw new BadRequestError("function is null");

            final @NonNull String output;
            switch (name){
                default: throw new BadRequestError("Unknown function.");
                case "read": output = read(req);
                    break;
                case "write": output = write(req);
                    break;
                case "good": output = good(req);
                    break;
                case "bad": output = bad(req);
                    break;
            }

            //戻り値を送信する
            resp.setContentType("application/json; charset=UTF-8");
            final @NonNull PrintWriter writer = new PrintWriter(resp.getWriter());
            writer.print(output);
            //Servletは出力のClose不要

        }catch (BadRequestError e){
            //パラメータが不正、400
            resp.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
            e.printStackTrace();
        }catch (Exception e){
            //未知の例外発生、500
            resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
            e.printStackTrace();
        }

    }

    /** HttpServletRequestを受けるread */
    private @NonNull String read(@NonNull HttpServletRequest req) throws BadRequestError {
        return read(req.getParameter("begin"), req.getParameter("end"));
    }

    /** 文字列の引数を受けるread */
    private @NonNull String read(@Nullable String rawBegin, @Nullable String rawEnd) throws BadRequestError {

        //endパラメータを数値へ
        final long end;
        try {
            end = rawEnd==null ? System.currentTimeMillis() : Long.parseLong(rawEnd); //未指定の時、現在時刻
        }catch (Exception e){
            throw new BadRequestError("Invalid parameter \"end\"="+rawEnd);
        }

        //beginパラメータを数値へ
        final long begin; //未指定の時、上限値
        try {
            begin = rawBegin==null ? end- TIME_SPAN_LIMIT : Long.parseLong(rawBegin);//未指定の時、endから逆算した上限の時間範囲
        } catch (Exception e) {
            throw new BadRequestError("Invalid parameter \"begin\""+rawBegin);
        }

        //DatastoreからJson形式で取得
        try {
            return MyClass.readJson(begin, end, null).toString();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

    private @NonNull String write(@NonNull HttpServletRequest req) throws BadRequestError {
        final @NonNull InetAddress ip;
        try {
            ip = InetAddress.getByName(req.getRemoteAddr());
        } catch (UnknownHostException e) {
            throw new BadRequestError("Invalid parameter \"ip\"");
        }
        return write(ip, req.getParameter("text"));
    }

    /** BBS書込み処理
     * @param ip 接続元IP
     * @param text 書込み本文
     * @return 特になし */
    private @NonNull String write(@NonNull InetAddress ip, @Nullable String text) throws BadRequestError {
        if(text == null){
            throw new BadRequestError("Invalid parameter \"text\"");
        }
        try {
            MyClass.write(ip, text, () -> {
                throw new BadRequestError("Too many write.");
            } );
            return "";
        } catch (BadRequestError e) {
            throw e;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

    private @NonNull String good(@NonNull HttpServletRequest req) throws BadRequestError {
        return goodBad(req.getParameter("time"), true);
    }

    private @NonNull String bad(@NonNull HttpServletRequest req) throws BadRequestError {
        return goodBad(req.getParameter("time"), false);
    }

    /** Good と Bad は処理が似ているため、このメソッドで2つを兼ねる
     * @param rawTime Good/Badをカウントアップする書込み対象のtime、POST引数生データ
     * @param isGood true Good / false Bad
     * @return 特になし */
    private @NonNull String goodBad(@Nullable String rawTime, boolean isGood) throws BadRequestError {
        final long time;
        try{
            time = Long.parseLong(rawTime);
        }catch (Exception e){
            throw new BadRequestError("Invalid parameter \"time\""+rawTime);
        }

        try{
            if(isGood) {
                MyClass.good(time);
            }else{
                MyClass.bad(time);
            }
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
        return "";
    }

}

 

webサイト用のフォルダとファイルを追加する。

f:id:neet_rookie:20210222193831p:plain

  • gae\src\main\webapp\
    • WEB-INF
      • appengine-web.xml ・・・GAE用設定ファイル
      • index.yaml ・・・GAEからDatastoreへアクセスする際の複合インデックス
      • web.xml ・・・Webサーバ用設定ファイル
    • index.html ・・・Webサイトのホーム

 

appengine-web.xmlを編集する。

<?xml version="1.0" encoding="utf-8"?>
<appengine-web-app xmlns="http://appengine.google.com/ns/1.0">
  <threadsafe>true</threadsafe>
  <runtime>java8</runtime>

  <!-- 静的ファイルはApp Engineとは別サーバにキャッシュされるようになり課金除外となる -->
  <static-files>
    <!-- "*"は0個以上のファイルまたはディレクトリ、"**"は0個以上のディレクトリ
     サブディレクトリも含んで適用される。 -->
    <include path="/**.html" />
    <include path="/index.html" expiration="1m" />
    <!-- 頻繁にブラウザからアクセスされるため、ファイルがなくてもstatic指定 -->
    <include path="/favicon.ico" />
  </static-files>

  <!-- 最小インスタンスF1 -->
  <instance-class>F1</instance-class>

  <!-- 自動スケーリング設定 -->
  <automatic-scaling>
    <target-cpu-utilization>0.80</target-cpu-utilization>
    <min-instances>0</min-instances>
    <max-instances>1</max-instances>
    <max-concurrent-requests>10</max-concurrent-requests>
  </automatic-scaling>

</appengine-web-app>

 

index.yamlを編集する。

#1つ以上のプロパティへの等式フィルタと
# 1つ以上のプロパティへの不等式フィルタを組み合わせたクエリには、
# 複合インデックスが必要なため以下に定義する
# https://cloud.google.com/datastore/docs/concepts/indexes#index_configuration
# https://cloud.google.com/datastore/docs/tools/indexconfig?hl=ja#Datastore_Index_definitions
# GradleTaskのappengineDeployIndexを実行するまでGAE登録されないため注意!
indexes:

  - kind: bbs
    properties:
      - name: ip
      - name: time
        direction: desc

 

web.xmlを編集する。

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
         http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">
    <servlet>
        <servlet-name>api</servlet-name>
        <servlet-class>com.example.gae.APIServlet</servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>api</servlet-name>
        <url-pattern>/api</url-pattern>
    </servlet-mapping>
    <welcome-file-list>
        <welcome-file>index.html</welcome-file>
    </welcome-file-list>
</web-app>

 

index.htmlを編集する。

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8"/>
  <title>App Engine Example</title>
</head>
<body>
<form id="form" style="margin-top:2em">
  <textarea cols="80" rows="10" id="form_text" style="vertical-align:top"></textarea>
  <input type="button" value="Submit" id="form_submit" onclick="writeBBS()" />
</form>
<div id="log" style="margin-top:2em">Loading...</div>
<script language="JavaScript">
  //Please enable to JavaScript
  //<!--

  //Functionの排他制御用
  var mutexMap = {};

  //Response Code
  const HTTP_STATUS_OK = 200;

  //初期処理
  refreshLog();

  //XMLHttpRequestを使用した、排他制御(リポスト阻止)機構を持つ、非同期POSTメソッド
  //onDone = function(XMLHttpRequest)
  function asyncAtomicPost(url, postParameterMap, onDone, mutexName, onInvalidMutex){
    //排他制御
    if(mutexMap[mutexName] == true) {
      onInvalidMutex();
      return;
    }
    mutexMap[mutexName] = true;

    const req = new XMLHttpRequest();
    req.onreadystatechange = function() {
      if(this.readyState === XMLHttpRequest.DONE) {
        onDone(req);
        //排他制御解除
        mutexMap[mutexName] = false;
      }
    }
    //リクエストを初期化
    req.open('POST', url);
    //サーバに対して解析方法を指定する
    req.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' );
    //リクエストを送信する
    req.send( encodePostParameter(postParameterMap) );
  }

  //POSTパラメータをURLエンコード
  function encodePostParameter(data){
      const params = [];
      for(let name in data) {
          params.push(encodeURIComponent(name) + '=' + encodeURIComponent(data[name]));
      }
      return params.join('&').replace(/%20/g, '+');
  }

  //ログを取得する
  function refreshLog(begin, end){
    //POSTパラメータ
    const param = {function:"read"};
    if(begin!=null) param.begin = parseInt(begin);
    if(end!=null) param.end = parseInt(end);

    //送信
    asyncAtomicPost('/api', param, function(req){
      if(req.status == HTTP_STATUS_OK){
          //JsonをHTMLへ
          const responseJson = JSON.parse(req.response);

          const elLog = document.getElementById('log');
          elLog.innerHTML = '';

          if(responseJson.length == 0){
            elLog.innerText = new Date().toString()+"\n新しい書込みは有りません。";
            return;
          }

          try{
            for (let i=0; i<responseJson.length; i++) {
              let map = responseJson[i];

              let elChildRoot = elLog.appendChild(document.createElement("div"));
              elChildRoot.style.color = '#ffffff';
              elChildRoot.style.backgroundColor = '#000044';
              elChildRoot.style.opacity = 0.7;

              let elHeaderContainer = elChildRoot.appendChild(document.createElement("p"));
              elHeaderContainer.appendChild(document.createElement("span")).innerText = new Date(map.time).toString()+"\t";
              const funcGoodBad = function(label, count, onclick){ //Good Bad ラベルは処理が似ているので使い捨てFunctionを使う
                const span = elHeaderContainer.appendChild(document.createElement("a"));
                span.innerText = label+" "+count;
                span.href = label;
                span.style.color = '#ffffff';
                span.style.marginLeft = '2em';
                span.onclick = onclick;
              };
              funcGoodBad("good", map.good, function(){good(map.time); return false});
              funcGoodBad("bad", map.bad, function(){bad(map.time); return false});

              elChildRoot.appendChild(document.createElement("p")).innerText = map.text;

            }
          }catch(e){
            elLog.appendChild(document.createElement("div")).innerText = "Parse Error";
            alert(e);
          }
      }else{
          //ロード失敗
          alert("Read Log Error. status="+req.status+", text="+req.statusText);
      }
    }, "refreshLog", function(){
      //alert("Please wait. Updating server ...");
    });
  }

  //書込み処理
  function writeBBS(){
    const textValue = document.getElementById('form_text').value;
    document.getElementById('form_submit').value = "Writing...";
    asyncAtomicPost('/api', { function:"write", text:textValue }, function(req){
      if(req.status == HTTP_STATUS_OK){
        //書込み成功
        alert("Write BBS Success.");
        refreshLog();
      }else{
        //書込み失敗
        alert("Write BBS Error. status="+req.status+", text="+req.statusText);
      }
      document.getElementById('form_submit').value = "Submit";
    }, "writeBBS", function(){
      alert("Please wait. Updating server ...");
    });
  }

  function good(timeMillis){
    asyncAtomicPost('/api', { function:"good", time:timeMillis }, function(req){
      if(req.status == HTTP_STATUS_OK){
        alert("Good!");
        refreshLog();
      }else{
        //書込み失敗
        alert("Click \"Good\" Error. status="+req.status+", text="+req.statusText);
      }
    }, "good", function(){
      //
    });
  }

  function bad(timeMillis){
    asyncAtomicPost('/api', { function:"bad", time:timeMillis }, function(req){
      if(req.status == HTTP_STATUS_OK){
        alert("Bad!");
        refreshLog();
      }else{
        //書込み失敗
        alert("Click \"Bad\" Error. status="+req.status+", text="+req.statusText);
      }
    }, "bad", function(){
      //
    });
  }

  //-->
</script>
</body>
</html>

 

 

4.GAEをデプロイ&テストする 

複合インデックス(index.yaml)をデプロイするため、Android Studioプロジェクトへ実行・デバッグ構成を追加する

f:id:neet_rookie:20210222221721p:plain

構成追加ダイアログ起動

f:id:neet_rookie:20210222221725p:plain

Gradle構成を追加

f:id:neet_rookie:20210222221729p:plain

まずgaeプロジェクトを選択

f:id:neet_rookie:20210222221732p:plain

タスク:appengineDeployIndexを入力

 

GAEプロジェクトをデプロイするため、引き続きAndroid Studioプロジェクトへ実行・デバッグ構成を追加する

f:id:neet_rookie:20210222221735p:plain

タスク:appengineDeployも登録

 

複合インデックス(index.yaml)をデプロイする。

f:id:neet_rookie:20210222223944p:plain

appengineDeployIndexを実行する

f:id:neet_rookie:20210222223949p:plain

Gradleタスク実行結果

f:id:neet_rookie:20210223185302p:plain

GCPメニュー[Datastore]-[インデックス] サーバ側でインデックス構築にしばらくかかる

※Gradleタスク実行時間は1分程度、サーバでの処理時間は追加で10分程度かかる。

※複合インデックスは一定以上の複雑さを持つフィルタ(抽出)を行うために必須であり、未定義のまま抽出するとExceptionが発生する。

※処理が完了するのを待つこと!

※index.yamlの定義を修正する場合、gcloud datastore indexes cleanup index.yamlコマンドを実行すると 、index.yamlに存在しない既存の複合インデックスを削除することができる。

 

 GAEプロジェクトをデプロイする

f:id:neet_rookie:20210222225414p:plain

appengineDeployを実行する

f:id:neet_rookie:20210222225420p:plain

Gradleタスク実行結果

 

※Gradleタスク実行時間は"初回10分程度"で"次回以降1分程度"、サーバでの処理時間は0秒だが、staticファイルは所定のキャッシュ更新時間(includeタグexpiration属性)が経過するまで更新されないので注意。 

※※expiration属性の初期値は10mらしいが、それより短くしても正常に動作していない気がする。最少10分と思っていたほうがいい。

 

※ローカルデバッグには上記のほかに、タスク:appengineRunとタスク:appengineStopを登録すること。

※ローカルデバッグを開始する際は、appengineRunを実行し、
 ローカルデバッグを終了する際は、appengineStopを実行する。

※※appengineRunは手動で停止するまで実行し続けるので注意。ビルドログに出力されるlocalhost:8080のリンクをクリックすれば、GAEプロジェクトで作成したWebページへアクセス可能。

※※appengineStopは"appengineRunを手動停止した後"にローカルサーバを停止するために使用する。appengineStopするまでlocalhost:8080でアクセス可能なローカルサーバが起動し続ける

※ローカルデバッグの方法を書いたが、このGAEモジュールに関してはDatastoreアクセスの際に認証できなくてたぶん落ちる。

 

デプロイしたGAEサイトへアクセスする

f:id:neet_rookie:20210223185914p:plain

https://プロジェクト名.リージョンコード.r.appspot.com

f:id:neet_rookie:20210223185918p:plain

書込みテスト

 

 

5.GCF用のJavaモジュールを追加する 

f:id:neet_rookie:20210224184048p:plain

f:id:neet_rookie:20210224183634p:plain

追加したGCFモジュール(プロジェクト)の構造

モジュールのbuild.gradleを編集する。
下記コードは公式サンプル(Java11 GCF Gradle)を元にカスタマイズしたものである。
https://cloud.google.com/functions/docs/writing/specifying-dependencies-java?hl=ja

//このファイルの参考URL
//https://cloud.google.com/functions/docs/writing/specifying-dependencies-java?hl=ja

apply plugin: 'java'

//GCFサーバにはJava11のランタムしか無いらしいが、Java1.8を指定しても動く。
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
//sourceCompatibility = JavaVersion.VERSION_11
//targetCompatibility = JavaVersion.VERSION_11

repositories {
    //jcenter() 閉鎖予定のため使用しない
    mavenCentral()
    google() //for androidx.annotation
}
configurations {
    invoker
}

dependencies {
    //Google Cloud Functions SDK
    compileOnly 'com.google.cloud.functions:functions-framework-api:1.0.1'

    //Datastore API
    //implementation platform("com.google.cloud:libraries-bom:16.3.0")
    compile "com.google.cloud:google-cloud-datastore:1.105.7"

    //共通処理
    //implementation project(":lib") //サーバ側でビルドするため、共通処理のjarファイルを作ってgcf\libs\へ置く
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    //NonNull Nullableなどのアノテーション
    implementation "androidx.annotation:annotation:1.1.0" //Apache License 2.0

    //ローカルデバッグに必要
    // To run function locally using Functions Framework's local invoker
    invoker 'com.google.cloud.functions.invoker:java-function-invoker:1.0.2'

    //テスト系
    testImplementation 'com.google.cloud.functions:functions-framework-api:1.0.1'
    testImplementation 'junit:junit:4.+'
    testImplementation 'com.google.truth:truth:1.0.1'
    testImplementation 'org.mockito:mockito-core:3.4.0'

}

tasks.withType(JavaCompile) {
    options.encoding = 'UTF-8'
}

def region = "us-central1" //場所によって料金が変わるので注意
def project = "example-project-20210219"
def functionName = "api" //GCFプロジェクト内で一意、英数字 ハイフン アンダースコア
def entryPoint = "com.example.gcf.APIFunction" //起動されるHttpFunction実装クラス

// Register a "runFunction" task to run the function locally
tasks.register("runFunction", JavaExec) {
    main = 'com.google.cloud.functions.invoker.runner.Invoker'
    classpath(configurations.invoker)
    inputs.files(configurations.runtimeClasspath, sourceSets.main.output)
    args('--target', entryPoint,
            '--port', 8080
    )
    doFirst {
        args('--classpath', files(configurations.runtimeClasspath, sourceSets.main.output).asPath)
    }
}

// GCFサーバへ作成したGCFプロジェクトをデプロイするためのタスク
//tasks.register("GCFunctionDeploy", Exec) {
tasks.register("GCFunctionDeploy") { // <- [構成の編集]からこの名前で追加できるようになる
    def functionURL = "http://$region-$project"+".cloudfunctions.net/$functionName"
    println("deploy to $functionURL")
    exec {
        //Gradleからgcloudを実行する ※ターミナルから直接叩いても等価
        //gcloud functions deploy NAME --entry-point ENTRY-POINT --runtime RUNTIME TRIGGER [FLAGS...]
        commandLine 'cmd', '/c', "gcloud functions deploy " +
                " $functionName " +
                " --entry-point $entryPoint " +
                " --region $region " +
                " --runtime java11 " + //どのランタイムで実行するか 他にPython37とか
                " --trigger-http " + //Web APIトリガー
                " --memory 128MB " + //最少メモリ、増やすと無料枠を浪費する、{128, 256, 512, 1024, 2048, 4096}MB
                " --allow-unauthenticated " + //認証無しで利用できるか
                " --project $project "
        standardOutput = new ByteArrayOutputStream()
        ext.output = {
            return standardOutput.toString()
        }
    }
    println("task finished. $functionURL")
}

 

共通処理をjarビルドしてインポートする

f:id:neet_rookie:20210224201411p:plain

f:id:neet_rookie:20210224201415p:plain

f:id:neet_rookie:20210224201420p:plain

GCFプロジェクトへlib.jarをコピペ

f:id:neet_rookie:20210224201907p:plain

手動同期

APIFunction.javaを編集する。

package com.example.gcf;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.example.lib.MyClass;
import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;

import java.io.PrintWriter;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;

import static com.example.lib.MyClass.TIME_SPAN_LIMIT;

/** GCFデプロイ時のエントリポイントになるクラス */
public class APIFunction implements HttpFunction {

    /** POST引数に問題があり処理を継続できない場合に使用する */
    public static class BadRequestError extends RuntimeException {
        public BadRequestError(String s) {
            super(s);
        }
    }

    /** HttpRequest.getQueryParameters()からparamを1つだけ取り出すときに使う */
    public static @Nullable String getFirst(@Nullable List<String> list){
        return list==null || list.isEmpty() ? null : list.get(0);
    }

    @Override
    public void service(HttpRequest request, HttpResponse response) throws Exception {
        try {
            //デバッグ用にGETメソッドを受け付ける
            /* //メソッド確認
            if (!"POST".equals(request.getMethod())) {
                throw new BadRequestError("\"POST\" != "+request.getMethod());
            }*/
            //IP確認 ※HttpServletRequestと違い直接クライアントIPを知るすべが無い。IPが無いと、Entityを作れない。
            final @NonNull String xauip = "X-Appengine-User-Ip";
            final @NonNull String xff = "X-Forwarded-For";
            final @Nullable Map<String, List<String>> headers = request.getHeaders();
            @Nullable String ipString = getFirst(headers.get(xauip));
            if(ipString==null) ipString = getFirst(headers.get(xff));
            if(ipString==null || ipString.isEmpty()){
                throw new BadRequestError("client ip is nothing");
            }
            final @NonNull InetAddress ip;
            try {
                ip = InetAddress.getByName(ipString);
            }catch (Exception e){
                throw new BadRequestError("Can't parse ip = "+ipString);
            }

            //functionに応じて処理を切り替える
            final @NonNull Map<String, List<String>> params = request.getQueryParameters();
            final @Nullable String name = getFirst(params.get("function"));
            if(name==null) throw new BadRequestError("function is nothing");

            final @NonNull String output;
            switch (name){
                default: throw new BadRequestError("Unknown function.");
                case "read": output = read(ip, params);
                    break;
                case "write": output = write(ip, params);
                    break;
                case "good": output = good(ip, params);
                    break;
                case "bad": output = bad(ip, params);
                    break;
            }


            //戻り値を送信する
            response.setContentType("application/json; charset=UTF-8");
            final @NonNull PrintWriter writer = new PrintWriter(response.getWriter());
            writer.print(output);
            //サーブレットにならいCloseしない。たぶん良いはず

        }catch (BadRequestError e){
            response.setStatusCode(400);
            e.printStackTrace();
        /* 呼び出し元でExceptionは500に変換されるためここでは処理しない
        }catch (Exception e){
            response.setStatusCode(500);
            e.printStackTrace();*/
        }
    }

    /** HttpServletRequestを受けるread */
    private @NonNull String read(@NonNull InetAddress ip, @NonNull Map<String, List<String>> params) throws BadRequestError {
        return read(getFirst(params.get("begin")), getFirst(params.get("end")));
    }

    /** 文字列の引数を受けるread */
    private @NonNull String read(@Nullable String rawBegin, @Nullable String rawEnd) throws BadRequestError {

        //endパラメータを数値へ
        final long end;
        try {
            end = rawEnd==null ? System.currentTimeMillis() : Long.parseLong(rawEnd); //未指定の時、現在時刻
        }catch (Exception e){
            throw new BadRequestError("Invalid parameter \"end\"="+rawEnd);
        }

        //beginパラメータを数値へ
        final long begin; //未指定の時、上限値
        try {
            begin = rawBegin==null ? end- TIME_SPAN_LIMIT : Long.parseLong(rawBegin);//未指定の時、endから逆算した上限の時間範囲
        } catch (Exception e) {
            throw new BadRequestError("Invalid parameter \"begin\""+rawBegin);
        }

        //DatastoreからJson形式で取得
        try {
            return MyClass.readJson(begin, end, null).toString();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

    private @NonNull String write(@NonNull InetAddress ip, @NonNull Map<String, List<String>> params) throws BadRequestError {
        return write(ip, getFirst(params.get("text")));
    }

    /** BBS書込み処理
     * @param ip 接続元IP
     * @param text 書込み本文
     * @return 特になし */
    private @NonNull String write(@NonNull InetAddress ip, @Nullable String text) throws BadRequestError {
        if(text == null){
            throw new BadRequestError("Invalid parameter \"text\"");
        }
        try {
            MyClass.write(ip, text, () -> {
                throw new BadRequestError("Too many write.");
            } );
            return "";
        } catch (BadRequestError e) {
            throw e;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

    private @NonNull String good(@NonNull InetAddress ip, @NonNull Map<String, List<String>> params) throws BadRequestError {
        return goodBad(getFirst(params.get("time")), true);
    }

    private @NonNull String bad(@NonNull InetAddress ip, @NonNull Map<String, List<String>> params) throws BadRequestError {
        return goodBad(getFirst(params.get("time")), false);
    }

    /** Good と Bad は処理が似ているため、このメソッドで2つを兼ねる
     * @param rawTime Good/Badをカウントアップする書込み対象のtime、POST引数生データ
     * @param isGood true Good / false Bad
     * @return 特になし */
    private @NonNull String goodBad(@Nullable String rawTime, boolean isGood) throws BadRequestError {
        final long time;
        try{
            time = Long.parseLong(rawTime);
        }catch (Exception e){
            throw new BadRequestError("Invalid parameter \"time\""+rawTime);
        }

        try{
            if(isGood) {
                MyClass.good(time);
            }else{
                MyClass.bad(time);
            }
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
        return "";
    }

}

 

 

6.GCFをデプロイ&テストする 

デプロイ用の構成を追加する

f:id:neet_rookie:20210225162959p:plain

構成追加ダイアログ起動

 

GCFプロジェクトをデプロイする。

f:id:neet_rookie:20210225163007p:plain

Gradleタスク実行結果

※Gradleタスク実行時間は2分程度で、サーバでの処理時間は0秒。
ローカルデバッグには、"Gradleタスク:runFunction"を実行すればよいのだがAndroid Studio 4.1からは基本的に実行できないAndroid Studio 4.1はGradleタスクを自身の起動ランタイムを使用する。また、Android Studio 4.1の起動ランタイムにJava1.8を超えるバージョンを指定できない。

※GradleタスクをJava11以上で起動できればいいので、Visual Studio Codeプロジェクトを開き、必要な拡張機能(Java関係とGradle Tasks)をインストールすればタスク:runFunctionを実行できる

※※runFunctionを実行すると、ローカルサーバを起動してlocalhost:8080でGCFモジュールにアクセス可能になる。タスクを手動停止すると、ローカルサーバも停止する。GAEと違い、stopFunctionとかは無い。

※※ほかのIDEコマンドラインからも実行できるかもしれないが、試していない。

※ローカルデバッグの方法を書いたが、GAEモジュール同様にDatastoreアクセスの際に認証できなくてたぶん落ちる。

 

f:id:neet_rookie:20210225163013p:plain

デプロイ成功

f:id:neet_rookie:20210225163019p:plain

デバッグ結果 ※GETを有効にしてURL手打ち

 

 

7.appモジュールを作り込む 

appのbuild.gradleを編集する。

plugins {
    id 'com.android.application'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
        applicationId "com.example.bbs"
        minSdkVersion 16
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        //minSDKVersionがAndroid 5.0(APILevel21)より古い場合に必要、メソッド64K問題。
        // https://developer.android.com/studio/build/multidex
        multiDexEnabled true
    }

    //ViewBindingで簡単にレイアウト参照を得る
    android.buildFeatures.viewBinding = true

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    //AndroidTest用multidex
    androidTestImplementation 'androidx.multidex:multidex-instrumentation:2.0.0'
}

 

AndroidManifest.xmlを編集する。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.bbs">

    <!-- URL.openConnection()に必要 -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.BBSApplication">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

    <!-- for Android 11
     intent.resolveActivityを成功させるために必要 -->
    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:mimeType="*/*" />
        </intent>
    </queries>

</manifest>

 

リソースstrings.xmlを編集する。

<resources>
    <string name="app_name">BBS Application</string>
    <string name="menu_reload">Reload</string>
    <string name="menu_license">License</string>
    <string name="license_include_apache2">License\n\nThis software includes the work that is distributed in the Apache License 2.0.\nFor more information, please refer to the original Apache 2.0 license.\nhttps://www.apache.org/licenses/LICENSE-2.0 </string>
    <string name="url_apache2">https://www.apache.org/licenses/LICENSE-2.0 </string><!-- URL末尾の半角空白を削除すること -->
    <string name="label_close">Close</string>
    <string name="label_more">More</string>
</resources>

 

レイアウトactivity_main.xmlを編集する。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:descendantFocusability="beforeDescendants"
        android:focusableInTouchMode="true"
        android:orientation="horizontal">

        <EditText
            android:id="@+id/editText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="5"
            android:lines="5" />

        <Button
            android:id="@+id/buttonSubmit"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="submit"
            android:textAllCaps="false" />
    </LinearLayout>

    <ListView
        android:id="@+id/listBBS"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />


</LinearLayout>

 

MainActivity.javaを編集する。

package com.example.bbs;

import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.util.Pair;
import android.util.TypedValue;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.BaseAdapter;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;

import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.MutableLiveData;

import com.example.bbs.databinding.ActivityMainBinding;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

public class MainActivity extends AppCompatActivity {

    /** ViewBindingを使用したレイアウト参照 */
    ActivityMainBinding bind;

    private static final @NonNull String API_URL = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ?
            "http://us-central1-example-project-20210219.cloudfunctions.net/api" :
            "https://us-central1-example-project-20210219.cloudfunctions.net/api";

    /** BBSログのデータを保持するLiveData<br/>
     * LiveDataはobserveで変更を監視するが、ライフサイクルを考慮するため安全に処理できる。 */
    private final @NonNull MutableLiveData<JSONArray> liveBBSLog = new MutableLiveData<>();

    /** BBSログの書込み完了を通知 */
    private final @NonNull MutableLiveData<Void> liveBBSWrote = new MutableLiveData<>();

    /** BBSログのGood完了を通知 */
    private final @NonNull MutableLiveData<Void> liveBBSGood = new MutableLiveData<>();

    /** BBSログのBad完了を通知 */
    private final @NonNull MutableLiveData<Void> liveBBSBad = new MutableLiveData<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        //レイアウト生成
        bind = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(bind.getRoot());

        //初回BBSログロード
        reloadBBS();

        //Submitボタン
        bind.buttonSubmit.setOnClickListener(v -> {
            final @NonNull CharSequence text = bind.editText.getText();
            //入力をクリア
            bind.editText.setText("");
            //ボタン使用禁止に
            bind.buttonSubmit.setEnabled(false);
            //hide soft keyboard
            View view = this.getCurrentFocus();
            if (view != null) {
                InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
                imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
            }
            //write bbs
            writeBBS(text.toString());
        });

        //BBSログ取得完了時
        liveBBSLog.observe(this, jsonArray -> {
            //アダプターを作ってセット
            bind.listBBS.setAdapter(new BaseAdapter() {
                @Override
                public int getCount() {
                    return jsonArray==null ? 0 : jsonArray.length();
                }
                @Override
                public JSONObject getItem(int position) {
                    if(jsonArray==null) return null;
                    try {
                        return jsonArray.getJSONObject(position);
                    } catch (JSONException e) {
                        throw new RuntimeException(e);
                    }
                }
                @Override
                public long getItemId(int position) {
                    return getTime(position);
                }
                private long getTime(int position){
                    try {
                        return getItem(position).getLong("time");
                    } catch (JSONException e) {
                        throw new RuntimeException(e);
                    }
                }
                private long getGood(int position){
                    try {
                        return getItem(position).getLong("good");
                    } catch (JSONException e) {
                        throw new RuntimeException(e);
                    }
                }
                private long getBad(int position){
                    try {
                        return getItem(position).getLong("bad");
                    } catch (JSONException e) {
                        throw new RuntimeException(e);
                    }
                }
                private @NonNull String getText(int position){
                    try {
                        return getItem(position).getString("text");
                    } catch (JSONException e) {
                        throw new RuntimeException(e);
                    }
                }
                @Override
                public View getView(int position, View convertView, ViewGroup parent) {
                    final @NonNull ViewHolder holder;
                    if(convertView instanceof LinearLayout){
                        holder = (ViewHolder) convertView.getTag();
                    }else{
                        holder = new ViewHolder();
                        holder.container = new LinearLayout(MainActivity.this);
                        holder.container.setOrientation(LinearLayout.VERTICAL);
                        holder.container.setTag(holder);
                        holder.container.addView(holder.headerContainer = new LinearLayout(MainActivity.this));
                        holder.headerContainer.setOrientation(LinearLayout.HORIZONTAL);
                        holder.headerContainer.addView(holder.textTime = new TextView(MainActivity.this));
                        holder.headerContainer.addView(holder.textGood = new TextView(MainActivity.this));
                        holder.headerContainer.addView(holder.textBad = new TextView(MainActivity.this));
                        holder.container.addView(holder.textMain = new TextView(MainActivity.this));

                        holder.textGood.setTextColor(Color.GREEN);
                        holder.textGood.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10);
                        holder.textBad.setTextColor(Color.RED);
                        holder.textBad.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10);
                    }
                    bindData(position, holder);
                    return holder.container;
                }
                private void bindData(int position, @NonNull ViewHolder holder){
                    final long time = getTime(position);
                    holder.textTime.setText(new Date(time).toString());
                    holder.textGood.setText("Good "+ getGood(position));
                    holder.textGood.setOnClickListener(v -> goodBBS(time));
                    holder.textBad.setText("Bad "+ getBad(position));
                    holder.textBad.setOnClickListener(v -> badBBS(time));
                    holder.textMain.setText(getText(position));
                }
                class ViewHolder{
                    LinearLayout container;
                    LinearLayout headerContainer;
                    TextView textTime;
                    TextView textGood;
                    TextView textBad;
                    TextView textMain;
                }
            });
        });

        //BBS書込み完了時
        liveBBSWrote.observe(this, dummy->bind.buttonSubmit.setEnabled(true));

    }

    @AnyThread
    private void reloadBBS(){
        final @NonNull Map<String, String> postMap = new HashMap<>();
        postMap.put("function", "read");
        //final long now = System.currentTimeMillis();
        //postMap.put("end", Long.toString(now));
        //postMap.put("begin", Long.toString(now-(24*60*60*1000)));
        asyncAtomicPost(API_URL, postMap, pair -> {
                    try {
                        final int responseCode = pair.first;
                        if(responseCode == HttpURLConnection.HTTP_OK) {
                            final @NonNull JSONArray json = new JSONArray(pair.second);
                            liveBBSLog.postValue(json);
                        }else{
                            throw new RuntimeException("Response Code = "+responseCode);
                        }
                    }catch (Exception e){
                        postToastL("Failed to read.\n"+e.getClass().getSimpleName()+"\n"+e.getMessage());
                    }
                },
                e -> postToastL(e.getClass().getSimpleName()+"\n"+e.getMessage()),
                "read",
                () -> {});
    }

    @AnyThread
    private void writeBBS(@NonNull String text){
        final @NonNull Map<String, String> postMap = new HashMap<>();
        postMap.put("function", "write");
        postMap.put("text", text);
        asyncAtomicPost(API_URL, postMap,
                pair -> {
                    final int responseCode = pair.first;
                    if(responseCode == HttpURLConnection.HTTP_OK) {
                        postToastL("Write success.");
                        reloadBBS();
                    }else{
                        postToastL("Write Error. code = "+responseCode);
                    }
                    liveBBSWrote.postValue(null);
                },
                e -> {
                    postToastL(e.getClass().getSimpleName() + "\n" + e.getMessage());
                    liveBBSWrote.postValue(null);
                },
                "write",
                () -> postToastL("Please wait."));
    }

    @AnyThread
    private void goodBBS(long time){
        final @NonNull Map<String, String> postMap = new HashMap<>();
        postMap.put("function", "good");
        postMap.put("time", Long.toString(time));
        asyncAtomicPost(API_URL, postMap,
                pair -> {
                    final int responseCode = pair.first;
                    if(responseCode == HttpURLConnection.HTTP_OK) {
                        postToastL("Good success.");
                        reloadBBS();
                    }else{
                        postToastL("Good Error. code = "+responseCode);
                    }
                    liveBBSGood.postValue(null);
                },
                e -> {
                    postToastL(e.getClass().getSimpleName() + "\n" + e.getMessage());
                    liveBBSGood.postValue(null);
                },
                "good", () -> {});
    }

    @AnyThread
    private void badBBS(long time){
        final @NonNull Map<String, String> postMap = new HashMap<>();
        postMap.put("function", "bad");
        postMap.put("time", Long.toString(time));
        asyncAtomicPost(API_URL, postMap,
                pair -> {
                    final int responseCode = pair.first;
                    if(responseCode == HttpURLConnection.HTTP_OK) {
                        postToastL("Bad success.");
                        reloadBBS();
                    }else{
                        postToastL("Bad Error. code = "+responseCode);
                    }
                    liveBBSBad.postValue(null);
                },
                e -> {
                    postToastL(e.getClass().getSimpleName() + "\n" + e.getMessage());
                    liveBBSBad.postValue(null);
                },
                "bad", () -> {});
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        menu.add(0, R.string.menu_reload, 0, R.string.menu_reload).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
        menu.add(0, R.string.menu_license, 0, R.string.menu_license).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
        final int itemID = item.getItemId();
        if(itemID==R.string.menu_reload) {
            //メニュー:リロード
            reloadBBS();
            return true;
        }else if(itemID==R.string.menu_license){
            //メニュー:ライセンス
            new AlertDialog.Builder(this)
                    .setMessage(R.string.license_include_apache2)
                    .setPositiveButton(R.string.label_more, (dialog, which) -> {
                        final @NonNull Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.url_apache2)));
                        intent.addCategory(Intent.CATEGORY_BROWSABLE);
                        if(intent.resolveActivity(this.getPackageManager())==null){
                            //インテントに対応するアクティビティが存在しない
                            postToastL("Can not open URL");
                        }else{
                            startActivity(intent);
                        }
                        dialog.dismiss();
                    })
                    .setNegativeButton(R.string.label_close, (dialog, which) -> dialog.dismiss())
                    .show();
            return true;
        }else {
            return super.onOptionsItemSelected(item);
        }
    }

    private static final @NonNull Map<String, Boolean> mutexMap = new HashMap<>();

    private interface Listener<T>{
        @AnyThread
        void run(@NonNull T t);
    }

    /** HTTP POSTを非同期かつ排他実行する
     * @param param postパラメータ
     * @param onDoneListener リクエストが正常に終了した場合
     * @param onErrorListener リクエスト中に例外が発生した場合
     * @param mutexName 排他制御キー
     * @param onInvalidMutex 排他制限に該当した場合
     */
    @AnyThread
    private static void asyncAtomicPost(@NonNull String url, @NonNull Map<String, String> param,
                                        @NonNull Listener<Pair<Integer, String>> onDoneListener, @NonNull Listener<Exception> onErrorListener,
                                        @NonNull String mutexName, @NonNull Runnable onInvalidMutex){
        synchronized (mutexMap) {
            if (Boolean.TRUE.equals(mutexMap.get(mutexName))) {
                onInvalidMutex.run();
                return;
            }
            mutexMap.put(mutexName, true);
        }
        Log.d("new Thread", url);
        new Thread(()->{
            final @NonNull Pair<Integer, String> result;
            try {
                result = httpRequest(url, param);
            }catch (Exception e) {
                Log.e("asyncAtomicPost","エラー\n"+param, e);
                onErrorListener.run(e);
                return;
            }finally {
                synchronized (mutexMap) {
                    mutexMap.put(mutexName, false);
                }
            }
            onDoneListener.run(result);
        }).start();
    }

    @WorkerThread
    private static @NonNull Pair<Integer, String> httpRequest(@NonNull String strURL, @NonNull Map<String, String> param) throws Exception{

        Log.d("URL Open", strURL);

        final @NonNull URL url = new URL(strURL);

        //接続
        final @NonNull HttpURLConnection con = (HttpURLConnection) url.openConnection();

        //ヘッダ設定
        con.addRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
        con.setRequestMethod("POST");
        con.setDoOutput(true);
        //con.connect();

        //POST引数をURLエンコーディング
        final @NonNull Uri.Builder builder = new Uri.Builder();
        for(@NonNull Map.Entry<String, String> entry: param.entrySet()){
            builder.appendQueryParameter(entry.getKey() , entry.getValue());
        }
        final @NonNull String postData = builder.build().getEncodedQuery();

        //POST出力
        try(OutputStream postStream = con.getOutputStream()){
            postStream.write(postData.getBytes());
            postStream.flush();
        }

        //レスポンス取得
        final @NonNull ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try(InputStream is = con.getInputStream()){
            bufferedTransStream(is, baos);
        }catch (FileNotFoundException e){ //レスポンスコードがエラーの場合
            try(InputStream errorStream = con.getErrorStream()) {
                baos.reset();
                bufferedTransStream(errorStream, baos);
            }
        }
        //final @Nullable String contentType = con.getContentType(); ex)Content-Type: text/html; charset=UTF-8
        //final @Nullable String contentEncoding = con.getContentEncoding(); ex)Content-Encoding: gzip, identity

        return new Pair<>(con.getResponseCode(), baos.toString()); //TODO:Charsetを適用して文字列化する
    }

    /** InputStreamから読み込んでOutputStreamへ出力する */
    @AnyThread
    private static void bufferedTransStream(@NonNull InputStream is, @NonNull OutputStream os) throws Exception{
        final int BUFFER_SIZE = 1024*1024;
        final int INTERVAL_MILLIS = 500;
        final byte[] buf = new byte[BUFFER_SIZE]; //処理単位としてのバッファ
        try(BufferedInputStream bis = new BufferedInputStream(is, BUFFER_SIZE * 2)) { //ストリームのバッファ、読み込みを安定させるために byte[](処理単位のバッファ) サイズより大幅に大きくする
            try (BufferedOutputStream bos = new BufferedOutputStream(os, BUFFER_SIZE)) {
                int read;
                while (0 <= (read = bis.read(buf))) { //読み込み
                    bos.write(buf, 0, read); //書き込み
                    if (read < buf.length) { //処理単位としてのバッファを使い切れていない -> 読み込み速度が速すぎる
                        //noinspection BusyWait
                        Thread.sleep(INTERVAL_MILLIS);
                    }
                }
            }
        }
    }

    private final @NonNull AtomicReference<Handler> atomicHandler = new AtomicReference<>(null);
    /** メインスレッドのHandlerを取得する */
    @AnyThread
    private @NonNull Handler getMainHandler(){
        @Nullable Handler handler;
        synchronized (atomicHandler) {
            handler = atomicHandler.get();
            if (handler == null) {
                atomicHandler.set(handler = new Handler(Looper.getMainLooper()));
            }
        }
        return handler;
    }

    @AnyThread
    private void postToastL(@NonNull String message){
        getMainHandler().post(()->Toast.makeText(this, message, Toast.LENGTH_LONG).show());
    }

}

 

 

8.Androidアプリをエミュレータでテストする 

適当なエミュレータで実行する。

f:id:neet_rookie:20210226214916p:plain

GAE x Android リンクテスト

 

 

 

後書き 

Androidアプリのプロジェクトへ、アプリと共通処理とGAEとGCFのモジュールを入れてワンセットで管理したが、エミュレータへのデバッグ実行の際にホットスワップ関係と思われるトラブルが発生した。

MainActivityなどAPK関連のファイルを修正してapp実行してもなぜか"変更無し"と認識してるようで、再ビルドされなかった。そのため、毎回再ビルドをしてからapp実行を行う羽目になった。
このトラブルは設定やapp実行構成を編集しても改善されることは無かった。

つまり、APKのプロジェクトとGAEやGCFのプロジェクトは完全に別にするのが無難。

 

※2021/3/1追記
appの実行構成に[起動前]-[Gradle-aware Make]が抜けていたのが原因。
なぜか実行構成テンプレートから抜け落ちていた。
開発環境がバグってる。。。