eaglesakuraの技術ブログ

技術的な話題とか、メモとか。

GCPを使って子供関係の通知をLINEに統合する

なにをやったのか

  • 子供が保育園・小学校・学習塾に通っている
  • それぞれ、お知らせがWebに掲載される(日記とかも含む)
  • 個別連絡はメールで届く(学年単位の緊急連絡とか)
  • いちいち学校のウェブサイトで確認したりメールを検索するのが面倒なので、LINEに通知を統合した

LINEの通知はIFTTTを使う

  • もうデファクトみたいな感じのIFTTT
  • IFTTTのWeb Hook -> LINE通知を組み合わせることで簡単に行えるので省略

Web Hookへの流し込み戦略

スクレイピング

  • 保育園や小学校、当然ながらRSSのような便利ソリューションは存在しない
  • なので、Firebase Functionsでポーリングすることにした
  • Firebaseの認証処理がスキップできる(デフォルトでAdmin権限が割り当て)られるので、各種処理が楽になる
  • 基本的に httpトリガー を使って、任意のタイミングで呼び出せるようにする
    • 関数の言語はなんでもいいけど、自分はGolang 1.13を使用した。
    • スクレイピング処理には github.com/PuerkitoBio/goquery を使用。
    • Webサイトの構造は当然サイトごとに異なるので頑張って個別対応。
  • Firebase Functions自体にもスケジューリング機能はついているけど、 Cloud Scheduler でKickするほうが再設定やデバッグが楽なので、スケジューリングは設定していない
  • 学校のCMSに負荷をかけるのは本意ではない(し、そこまでWebページの更新頻度は高くない)ので、cronは1時間に1回行ってる.

キャッシュ処理

  • IFTTTのWebHookにはキャッシュがないので、単純にCron実行すると同じ通知が何度も流れてしまう
  • そのため、 Firestore に通知済みのキャッシュを放り込んで、キャッシュが存在したらHookをスキップするようにする

通知とキャッシュのサンプルコード

  • 通知のタイトル・本文・リンクURLの組み合わせをMD5化して、それをキャッシュキーにする
  • キャッシュヒットしたら、それは通知済みなのでスキップ
var firebaseApp *firebase.App
var firestoreClient *firestore.Client
var webhook iftttWebhook.IFTTT

func init() {
    if app, err := firebase.NewApp(context.Background(), nil); err != nil {
        panic(err)
    } else if fs, err := app.Firestore(context.Background()); err != nil {
        panic(err)
    } else {
        firebaseApp = app
        firestoreClient = fs
    }

    webhook = iftttWebhook.New("割り当てられたAPIキー")
}

func md5sum(s string) string {
    sum := md5.Sum([]byte(s))
    return hex.EncodeToString(sum[:])
}

func notifyWebPageLink(ctx context.Context, title string, body string, link string) {
    uid := md5sum(title + "/" + body + "/" + link)

    ref := firestoreClient.Collection("ifttt").Doc("web-notify").Collection("cache").Doc(uid)
    if _, err := ref.Get(ctx); status.Code(err) != codes.NotFound {
        log.Println(fmt.Sprintf("    * Exist Document: %v", ref.Path))
        return
    }
    log.Println(fmt.Sprintf("    * Put Document: %v", ref.Path))
    log.Println(fmt.Sprintf("    * Title: %v", title))
    log.Println(fmt.Sprintf("    * Body: %v", body))
    if _, err := ref.Create(ctx, map[string]interface{}{
        "created_at": time.Now(),
        "title":      title,
        "body":       body,
        "link":       link,
    }); err != nil {
        log.Println(fmt.Sprintf("Firestore write failed: %v", err))
    }

    webhook.Emit("通知ID", title, body, link)
}
// これをhttpトリガーでPublish
func CronHoikuenWeb(w http.ResponseWriter, r *http.Request) {
        うまいことスクレイピング()...
    w.WriteHeader(http.StatusNoContent)
}

// これをhttpトリガーでPublish
func CronShogakkouWeb(w http.ResponseWriter, r *http.Request) {
        うまいことスクレイピング()...
    w.WriteHeader(http.StatusNoContent)
}

GmailからIFTTTへの通知戦略

  • 塾も入退室時にメールで通知してくれる
  • 保育園や小学校は緊急連絡(遠足延期とか)がメールで来る
  • なので、メールもLINEにGOだ。

GASをcronで呼び出す

  • Firebase Functionsだと、自分のGmailへのアクセスが面倒(認証とか)
  • なので、Google Apps Scriptから通知を飛ばすことにした
  • GASはGmail APIを使うとOAuthが自動的に開始されるので、認証がらく。
    • 認証状態を保持したまま、Cronを走らせられる
    • 設定項目もユーザーフレンドリー(cron構文使わなくてよい)
  • メールはGoogleインフラなので、1分に1回ポーリングしても大丈夫そう
    • リアルタイム性を重視したいならpub/subに流して〜とかあるけど、面倒な割にそこまでする必要はない
  • gmail検索条件をそのまま利用できるので、 from:example@example.com newer_than:1h とかできるし、めちゃくちゃ複雑なクエリも作れる
    • けど俺はシンプルにfromと直近のn時間のみでクエリを投げた
    • LINE通知は嫁さんにも届くので、間違ってもFANZAの課金通知を飛ばすわけにはいかねぇんだよ!!
  • UrlFetchApp.fetch の投げ先は、Functionsで作ったhttpトリガー.
    • POSTリクエストでパラメータを受け取り、それをそのままさっきの notifyWebPageLink() に流すようにした
// Functions側はこう
func WebHookFromGmail(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    bodyBytes, err := ioutil.ReadAll(r.Body)
    if err != nil {
        panic(err)
    } else {
        log.Printf("read body: %v", string(bodyBytes))
    }

    var request map[string]string
    if err = json.Unmarshal(bodyBytes, &request); err != nil {
        panic(err)
    } else {
        log.Printf("parsed: %v", request)
    }

    title := request["title"]
    body := request["body"]
    link := request["link"]

    notifyWebPageLink(ctx, title, body, link)

    // ok
    w.WriteHeader(http.StatusNoContent)
}
// GAS側はこう。
function onCron() {
  notifyGmail("gmailの検索条件", "通知タイトル", "リンクURL")
}

function notifyGmail(query, from, link) {
  GmailApp.search(query).forEach(function(thread){
    thread.getMessages().forEach(function(message) {
      console.log("============================");
      console.log(message.getSubject());
      console.log(message.getBody());
      notifyExternalWebHook(from, message.getSubject() + "  ::  " + message.getBody(), link);
    });
  });
}


function notifyExternalWebHook(title, body, link) {
  var requestBody = JSON.stringify({
    title : title,
    body: body,
    link: link,
  });
  console.log(requestBody);
  var options =
  {
    method  : "POST",
    payload : requestBody,
  };
  UrlFetchApp.fetch("自分のエンドポイントURL", options);
}

最後に

  • セキュリティもくそもない書き捨てコードなので、何かあったら自己責任で頑張ろうね!
  • あと、IFTTTのWebHookにキャッシュつけてくんねかな!!

ビルドの並列化をしすぎてCPU占有率100%になってしまったAndroid Studioプロジェクトの対処

何が起きたか

  • モジュール分割や様々な工夫をしてビルド時の並列性を高めた
  • 高めすぎて、Ryzen 3950Xの 16コア 32スレッド の能力を超えた並列性を持ってしまった
  • その結果、Gradleのビルドを行うとすべてのCPUリソースを完全に食い尽くすようになった
    • ZoomとかDiscordとか、セッションが途切れるレベルで食い尽くす
    • メモリ空き容量は30GBくらいあるので大丈夫なはず

解決方法

  • /gradle.propertiesに次のオプションを加えて、プロセスの優先度を下げた
  • Gradle 5.0からのオプションらしい。デフォルトは normal が設定される。
org.gradle.priority=low

その他

  • まさか並列実行性でRyzenが遅れを取るとは思いもしなかった

Android Studio Arctic Fox Canary-11の所感

現状の利点

  • Jetpack Compose(Beta, production-ready)が使える

AS 4.1.xからの移行

  • Android Gradle Pluginバージョン変更
  • gradlewバージョン変更

基本的に素直に移行できた。

現状の問題点

Library Projectで問題が出る。 Application Projectでは使えているので、モノリシックなプロジェクト以外は導入がつらそう(Unit Testをgradlewからの実行するのであれば気合で乗り切れるかもしれない)

  • Robolectircが必ずfailする
  • Instrumentation TestをAndroid Studioから実行できない
    • だめくさい

まとめ

まだ人柱レベル。 ComposeはProduction Readyでも、ASはProduction ちょいツライ。 頑張れGoogle、応援してるぞ。

イカれた映画観客を紹介するぜ!!

持ち込んだペットボトルをごっきゅごきゅと音を立てて就活生風の男 / TOHOシネマズ上野 / ラブライブサンシャイン

  • TOHOシネマズは基本的に飲食物は持ち込み禁止のはず
  • ごっきゅごきゅと音を鳴らして飲んでクソうるせー
  • 人間ってあんな音を立てながら飲めるんだな

映画を観ながらソシャゲーする30代くらいの男 / TOHOシネマズ柏 / シン・エヴァンゲリオン

  • 映画はソシャゲーする場所じゃねぇ
  • 水筒に飲み物入れて持ち込むな、持ち込み禁止じゃボケ
  • テメーのコンボにもガチャにも興味ねぇ
  • 画面隠してるつもりか?気になるんじゃボケ
  • 映画のエンドロールはガチャする時間じゃねぇ
  • ガチャしたけりゃ外に出ろ、上映中にスマホ開くなボケ

LINEをしながら「広瀬すず!!」って叫びながら観る高校生4人組 / TOHOシネマズ流山おおたかの森 / バケモノの子

  • 俺の映画体験ぶっちぎりワースト1。
  • しかもグループで来たのに固まって観れなかったから、(俺の)席左右をはさんで会話をする
  • LINEで実況するなクズども
  • 二度と映画館に来るなクズども
  • こいつらのせいで出演者に広瀬すずの名前がいると「あいつらいるのか」とか思うようになった
  • 「映画を観に来る学生集団」が嫌いになった理由

現状とってる回避策

  • 可能な限り平日
  • 可能な限り郊外
  • 可能な限り駅近は避ける
    • 駅直結は学生集団が集まりやすい
  • 可能な限り映画の日は避ける
    • 安い日は学生集団が集まりやすい
  • 可能な限り追加料金の発生する上映回を選ぶ
    • 学生集団は追加料金を出してまで映画を観ない

それでも

  • シン・エヴァンゲリオン鑑賞を邪魔したソシャゲー男は↑を満たしても現れた
  • わざわざ4DX追加料金出したのに
    • 4DX払ってまでソシャゲーやりに来るなよボケ

以上だ!

docker-composeでgoogle/cloud-sdk:emulators(firestore emulator)を利用する

docker-compose記述

version: "3"

services:
  firestore-emulator:
    image: google/cloud-sdk:329.0.0-emulators
    ports:
      - 8004:8004
    command: gcloud beta emulators firestore start --host-port "0.0.0.0:8004"
  • これで docker-compose up で起動できる。
  • ポートは任意(8004は俺の環境)

試行錯誤で見つけた問題点(自分の環境の場合)

  • Docker / WSL2環境では --host-port 127.0.0.1:8004 で接続できた
  • Mac, Linuxでは接続できない
  • --host-port 0.0.0.0:8004 だと接続できた
  • どの場合でも、docker-composeのコンテナ間では通信できるはず

APIサーバーの実行環境の所感 2020

Google App Engine

  • 言語/ランタイムバージョンが限定される
  • 複数バージョンをデプロイして、複数の指定バージョンを簡単に試せる
  • staticファイルの配信が簡単
  • Go1.9時代(第1世代GAE)はいつも使っていた
  • 今はstaticファイルの配信に使う

Google Cloud Run

  • Runtimeが固定されない
  • デプロイが簡単、Dockerfileがそのまま使えて嬉しい
  • インスタンス起動後、10〜20分くらい生きている。その後死ぬ。

Google Cloud Run / Go

  • Go 1.13以降で主に使う
  • スピンアップが早い。コールドスタート早い。ちょっぱや。
  • Golangのサーバーサイド開発者自体を捕獲する難易度が多少高い
  • ライブラリが豊富

Google Cloud Run / Kotlin

  • Kotlinしゅき。
  • 今日試した
  • Ktor使ってスピンアップ2.3秒、以後8ms程度が最速値(Hello Worldのテキストを返すだけ)
  • まあ許せなくもない気もする
  • Cloud Schedulerを用いて1分に1回pingすることで0円インスタンスを1個キープできそう

Firebase Hosting

  • マイクロサービスアーキテクチャで作ったときにCloud Runのサービスルーティングに使う
  • staticファイルの配信としては個人的には使いにくい

Windows環境のRobolectricでUri.toFile() KTXが正常に動作しない

問題点

  • Windowsファイルシステムはドライブレター(C:とか)やパスセパレータ(¥)がLinux/Macと異なる
  • Uri.fromFile()を実行したとき、ドライブレターやバッククォート(日本語だと円マーク)を含んだ文字列がURLエンコードされる

KTXUri.toFile()を行うとどうなるか

  • toFile()は単純に Uri.path 値をFileにラップして返すだけである
  • Windows環境でRobolectricを使用し、Uri.fromFile().pathを取得するとnullになる
  • 結果、File(null)が渡されることとなり、 UnitTestを実行しているカレントディレクトリ と解釈され、ほとんどの場合はその後のテストが落ちる

どうしたか?

  • Uri.path をチェックし、問題があるようならURLデコードを行ってからFileにラップするような拡張関数を書いてやる

こんなの

internal const val SCHEME_FILE = "file://"

fun Uri.toFileCompat(): File {
    val path = this.path ?: ""
    return if (path.isEmpty()) {
        // Windows path.
        val path = toString().substring(SCHEME_FILE.length)
        File(URLDecoder.decode(path, "UTF-8"))
    } else {
        toFile()
    }
}

楽観的な話

  • そのうち公式も直すんじゃねかな?