免責事項
この記事は有料サービスであるGoogle Cloud Platformを無料で使う実験的試みである。
この記事の内容を実践したり、参考にしたことによって、
間接的、直接的を問わず、何らかの損害が発生したとしても
執筆者は一切保証しない。
同意できない場合、今すぐ記事を離れること。
対象読者
以下の全てに該当する人
- Androidアプリを自力で開発・公開できる
- Java、Servlet、HTML、JavaScriptを最低限扱える
- Google Cloud Platformを"開始"している
前書き
Androidクライアントとサーバ連携させてなんかアプリ作ったろ。
->日本語対応の良さそうな無料サーバ無い??
-->Googleがやってるクラウドサービスでサーバっぽいのあるらしい
--->サービスの負荷が少ないうちは無料で使える!?
各無料枠については公式を参照されたい。
執筆時時点でus-centralリージョンを使えば、無料でクライアント・サーバアプリを実現できそう。
なお、Android Studioでコードを書いて、Google Cloud Platformに配置
これだけで作るため、言語はJava縛り(`・ω・´)
以降で出現する、GCPプロジェクトは、この記事のために一時的に作成したものであるため、現在はアクセスすることはできない。
コードをコピペする場合は、適宜リージョン名やプロジェクト名を置き換える事。
アプリ設計
ここではサンプルとして単一スレッド(スレ立てできないということ)のBBS(掲示板)を構築する。
Androidクライアント
- BBS表示・書き込み
Google Cloud Platform
- BBS用のデータベース
- Web APIによるDBアクセス
- Web サイト
詳細設計
Androidクライアント
- 単一画面(単一アクティビティ)
- 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
文字列
書込元IPtime
数値(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
JavaScriptのXMLHttpRequestで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を設ける。 - HTML https://example-project-20210219.uc.r.appspot.com/index.html
- Google Cloud Functions(通称GCF)
HTTPS接続のPOST、Android 5.0未満はHTTP接続
※今回のアプリではGCFを省きGAEのみでアプリ構築可能だが、実験として双方使用する。
※今回のアプリでWebサイトが必要無いならGAEは不要。
※今回はやらないが、 GAEで表示するサイト を AndroidアプリのWebView内に埋め込む のも手。
開発環境
各種インストール方法は割愛する。
- Windows10
全て執筆時時点の最新版を使用する- Android Studio 4.1
- Python 3.91 ※SDK Shellに必要。後でGitを導入する際にも使える。
- Google Cloud SDK Shell
GCFのローカルデバッグをする場合は以下も追加
※Android Studio 4.1のGradle起動に使用するJDKを選択できないためVisual Studio Codeを使う
※※ちなみにAndroid Studio 4.1自体を、JDK 8を超えるバージョンで起動することもできない
※※※Gradle Wrapperをいじればできるかもしれないが、力不足で方法不明
※Android Studio 4.2からJDK11に対応するらしい。
Android Studio プレビュー版の新機能 | Android デベロッパー | Android Developers- Visual Studio Code ※汎用エディタ 兼 汎用IDE
- Open JDK 11以上(15しか配布されてない?)
- Gradle 6.8.3 ※ビルドシステム
- Google Cloud Platform
Webインタフェースのためインストール不要。
GAEやGCFは様々な言語で開発することができるが、今回は全てAndroid Studio上で開発するため、Java一本で進めていく。
GCP初期設定
プロジェクトの作成とサービスのアクティベートをする。デプロイ&ビルドはGradleにて自動化されるためここでは設定不要。
リージョンは、執筆時時点で今回利用する各種プロダクトで無料枠があるus-centralで統一する。
GCPプロジェクトを作成する
- プロジェクト名とIDを入力する
- プロジェクト作成完了
GAEアプリケーションを作成する
- GAEを有効にする
GCPメニューからGoogle Cloud App Engineを選択し、開いた画面でGAEを有効にする。※もしかしたら聞かれないかも - GAEアプリケーションを作成する1/4
- GAEアプリケーションを作成する2/4
- GAEアプリケーションを作成する3/4
- GAEアプリケーションを作成する4/4
GCF
Google Cloud Datastore
開発していく
開発手順
- Android Studioで新しいプロジェクトを作る
- 共通処理用のJavaモジュールを追加する
- GAE用のJavaモジュールを追加する
- GAEをデプロイ&テストする
- GCF用のJavaモジュールを追加する
- GCFをデプロイ&テストする
- appモジュールを作り込む
- Androidアプリをエミュレータでテストする
1.Android Studioで新しいプロジェクトを作る
2.共通処理用のJavaモジュールを追加する
モジュールの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モジュールを追加する
モジュールの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サイト用のフォルダとファイルを追加する。
- gae\src\main\webapp\
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プロジェクトへ実行・デバッグ構成を追加する
GAEプロジェクトをデプロイするため、引き続きAndroid Studioプロジェクトへ実行・デバッグ構成を追加する
複合インデックス(index.yaml)をデプロイする。
※Gradleタスク実行時間は1分程度、サーバでの処理時間は追加で10分程度かかる。
※複合インデックスは一定以上の複雑さを持つフィルタ(抽出)を行うために必須であり、未定義のまま抽出するとExceptionが発生する。
※処理が完了するのを待つこと!
※index.yamlの定義を修正する場合、gcloud datastore indexes cleanup index.yamlコマンドを実行すると 、index.yamlに存在しない既存の複合インデックスを削除することができる。
GAEプロジェクトをデプロイする
※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サイトへアクセスする
5.GCF用のJavaモジュールを追加する
モジュールの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ビルドしてインポートする
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をデプロイ&テストする
デプロイ用の構成を追加する
GCFプロジェクトをデプロイする。
※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アクセスの際に認証できなくてたぶん落ちる。
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アプリをエミュレータでテストする
後書き
Androidアプリのプロジェクトへ、アプリと共通処理とGAEとGCFのモジュールを入れてワンセットで管理したが、エミュレータへのデバッグ実行の際にホットスワップ関係と思われるトラブルが発生した。
MainActivityなどAPK関連のファイルを修正してapp実行してもなぜか"変更無し"と認識してるようで、再ビルドされなかった。そのため、毎回再ビルドをしてからapp実行を行う羽目になった。
このトラブルは設定やapp実行構成を編集しても改善されることは無かった。
つまり、APKのプロジェクトとGAEやGCFのプロジェクトは完全に別にするのが無難。
※2021/3/1追記
appの実行構成に[起動前]-[Gradle-aware Make]が抜けていたのが原因。
なぜか実行構成テンプレートから抜け落ちていた。
開発環境がバグってる。。。