メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

2020年を振り返って

目標とか

英語

年始は本を読んだり洋書(絵本)に手を出してたのですが、わりと早い段階で途切れてしまっていました。しかしTwitterで知り合いが英語をコツコツ継続して勉強してるのを見て、自分もやらねば!と復活しました。

Duolingoというアプリで英語を学んでいます。割と破壊的な変更が予告なく行われる点はうーむといった感じなのですが、自分のスタイルとあっているみたいで継続はできています。

ただ、学んでて感じるのは単語力が必要なのと、リスニング、作文がDuolingoだと学ぶのが難しいです。

このあたりは実際にリスニングや作文をしないと身につかないので、最近はHelloTalkというアプリを教わったので使ってます。

きちんと学んでいる人とつながれば、ネイティブスピーカーと会話できるので良いなと感じています。また、日本語を学んでいる外国人の投稿も見れるので、お互い得意な言語で文章を修正し合ったり、そういうところを疑問に思うんだなというのが見え、気づきがあって面白いです。

リングフィットアドベンチャー

今年といえばコロナがあり、尚の事外に出る機会も減り、運動が必要になる年でした。

しかし、部屋のレイアウトを変更してから全くできていません。こちらは習慣が切れてしまったのでなんとか巻き直す必要があります。

同僚が毎日リングフィットを継続できているみたいで、そこから刺激をもらってやっていきたい。

Flutter

全然触れてなかったです。Kotlin/JSで小さく動くものを作ってばかりだった気がします。

この1年でFlutterの事例なども無数に増えて、そこまで珍しいものでは無くなっている感じがします。

使われて当たり前の技術になっていくのであれば、尚の事抑えたいのでこちらも継続して取り組んでいきたいことには変わらないです。

やったこと

Kotlin/JS

いくつか動くものを作りました。

オウム返しするだけのLINE Bot

sakebook.hatenablog.com

任意のURLのキャプチャをSlackに投稿してくれるやつ

sakebook.hatenablog.com

Server Side Kotlin

業務でKtor + Firebase + Cloud Runでサーバレスな通知基盤とCRUDAPIを用意したりしました。

普段あまりサーバに触れないので経験としてよかったです。

Kotlin/JSでサブシステムも作ったりはしていたのですが、本運用はしていません。

シニアエンジニアへの道

会社で求められることも少し変わったりしました。今はスクラムマスター的なことにも取り組んでいます。 社内で明確に次のステップが用意されてるのはありがたく思います。

Contribute

Exposure Notifications APIが公開され、動かしてコードを見てたところ、少し直せそうなところがあったので最速でPR作りました。

しかし内部で修正され、マージするに至りませんでした。

github.com

趣味とかその他

Minecraftにハマった

名前は聞いたことあったのですが、やったことはなかったです。

会社の人と始めたのですが、自分はドハマリしてかなりやってました。

エンダードラゴンを倒してエリトラを入手したのですが、帰路で全ロスしその後エンチャントを付与したダイヤ装備を2連続で全ロスして心が折れました。

傷が癒えた頃に再びやりだして、ダイヤ装備と厳選したエンチャント、エメラルドを安定して入手できる仕組みを作るまではやりましたがそこで止まっています。

家の環境整備

リモートワークがメインになったので、家の環境を整えました。

昇降デスクは2020年のベストバイでした。

sakebook.hatenablog.com

コーヒーを飲む機会も増え、いろんな抽出機材を買ったりしましたが今はまたハンドドリップに落ち着いています。自家焙煎にチャレンジしたい。

また、年内には届かなかったのですが良い椅子も買いました。多分2020年で一番高い買い物でした。

お菓子みたいな、つまむものを置いておく場所が家になかったので、スツールを買ってみたのですが思った以上に具合が良かったので追加でもう一つ買ってしまいました。

一つはお菓子BOXでソファーのオットマンとして利用し、もう一つは飲料水などを入れて玄関でちょっと腰掛けられる場所として利用してます。

自分が購入したのはこちらですが、サイズやデザインが若干違うものが無数にあるので気になったものが見つかればそれを選ぶと良いと思います。

完全栄養食

朝はCOMP DRINKにして、昼はガッツリ食べて夜は日中お菓子を食べているので抜きにするか追加でお菓子を食べるという生活をしていたのですが、体重がかなり落ちました。

お菓子も、ポテチかナッツというのが多かったのですがジャンキーなのは体に良くないと考え、ポテチはBASE BREADに差し替えました。

以前試したときは、冷凍でかつ小分けになっていなかったのでやめたのですが、今はそれらの問題が解決され、味も増えているので継続できそうです。

夜飯を完全に置き換えるというよりは、罪悪感のないお菓子くらいな感覚で利用しています。

こう書くとストイックそうに見えますが、土日は好きなものだけ食べたり、お酒の席では暴飲暴食をしています。

本を読む

本を読む習慣をつけたいと思い、家だとその切り替えがなかなかできないため、特別感を出すためにカフェで読むことが多いです。

カフェ自体は行くのであまり特別感はないのですが、そこで世界のビールを飲むことで特別感を出しています。

金曜の夜に敢えてカフェに行くことで、三密を避けることができています。

個人的には好きな時間になってるのでなるべく継続したいなと思っています。

ボドゲ

今年はやる機会も大変少なく、あまり購入していません。

2021年

2020年にやりたいと思ってたことは継続してやりたいことなので引き続きやっていきます。

定量的に見る場合、

  • 英語はDuolingoで全スキルを5にする
  • リングフィットはストーリーは終わらせたいのと習慣化(週1とか?)できているかどうか
  • Flutterは何らかのアプリを作る

とかですかね。

あと、漠然としてますが何か新しいこともやりたいですね。やりたいというより、やっておかないと諸々まずいなという漠然とした焦燥感かもしれません。

Kotlin/JSでHeadless Chromeを使って画面をキャプチャしてSlackに投稿する

ちょっと利用したくなったのでKotlin 1.4のキャッチアップも兼ねて作りました。

環境は

  • Kotlin 1.4.21
  • dukat 0.5.8-rc.3
  • Node 12

です

こんな感じで任意のURLのキャプチャがSlackに投稿されるものです。

f:id:sakebook:20201220231715p:plain

前回触った記事はこちら

sakebook.hatenablog.com

kotlin 1.4でどう変わった

KotlinのJSの実装は package_imported/ というフォルダに展開されていました。しかしプロジェクト の package.json にその参照はされていませんでした。なので、解決のためにはnpm registryから取得してきていました。

しかし、1.4から package.json に、ローカルpathが参照されるようになりました(file:/XXX)。なので、以前のように外部から無理やり依存関係を解決しなくても良くなりました。

ただしこれはこれでCloud Functiionsへのデプロイ時に一部修正が必要です(後述)。

1.4以前

{
  "main": "kotlin/kotlin-js-cloud-function-linebot.js",
  "devDependencies": {
    "source-map-support": "0.5.16"
  },
  "dependencies": {
    "kotlin": "1.3.72",
    "kotlinx-serialization-kotlinx-serialization-runtime": "0.20.0",
    "@jetbrains/kotlin-extensions": "^1.0.1-pre.91",
    "axios": "^0.19.2"
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": [],
  "name": "kotlin-js-cloud-function-linebot",
  "version": "1.0.0-SNAPSHOT"
}

1.4以後

{
  "main": "kotlin/kotlin-js-headless-chrome.js",
  "devDependencies": {
    "dukat": "0.5.8-rc.3",
    "source-map-support": "0.5.19"
  },
  "dependencies": {
    "axios": "0.21.0",
    "puppeteer": "5.5.0",
    "form-data": "3.0.0",
    "@google-cloud/secret-manager": "3.2.2",
    "kotlin": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin/1.4.21",
    "kotlinx-coroutines-core": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-coroutines-core/1.3.9",
    "kotlinx-atomicfu": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-atomicfu/0.14.4",
    "kotlinx-serialization-kotlinx-serialization-core-jsLegacy": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-serialization-kotlinx-serialization-core-jsLegacy/1.0.0-RC",
    "17def1782b5ee417-kotlinx-nodejs-jsLegacy": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/17def1782b5ee417-kotlinx-nodejs-jsLegacy/0.0.7",
    "kotlin-test-js-runner": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin-test-js-runner/1.4.21",
    "kotlin-test": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin-test/1.4.21"
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": [],
  "name": "kotlin-js-headless-chrome",
  "version": "0.0.1"
}

dukatはKotlin/JSのpluginに統合され、利用しやすくなりました。具体的には、次のように引数で指定してあげることで generateExternalsIntegrated タスクが実行され、ソースコードを生成してくれます。

implementation(npm("axios", "0.21.0", generateExternals = true))

なので package.json のdevDependenciesに含まれているわけです。

また、CoroutineやSerializationも同様に利用しやすくなっています。

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC")

採用したのはPuppeteer

Cloud FunctionsでPuppeteerが利用可能になったのは知っていたので、同様にHeadless Browserを扱えるPlaywrightを利用しようとしたのですが、結論うまくいきませんでした。

github.com

github.com

デプロイ時にchromiumを含めると、ファイルサイズの制約でデプロイに失敗します。なので、デプロイ後のmain関数実行時にchromiumをDLする流れを取っているPuppeteerを採用しました。

関数実行時にchromiumをDLする流れ

Playwrightでも環境変数を指定してあげれば同じ流れになるはずなのですがうまく行っていません。issueは上げたのでどこかのバージョンで解決されると思います。

ちなみにどうしてもPlaywrightを使いたい場合、playwright-aws-lambdaを利用すれば可能です。こちらはchromiumを含んでいますが、圧縮することでファイルサイズの制約を回避しています。

github.com

Cloud Functionsへのデプロイ

Cloud Functionsへのデプロイは、今回はCloud Buildで行いました。

Kotlin/JSをCloud Functionsで動かすには次の3パターンの方法が考えられます。

  • すべて込みのjsファイルを作る
  • package-lock.json込みであげて依存関係を解決する
  • package.json込みでdeploy時に実行してもらう

すべて込みのjsファイルを作る

こちらはひとつのjsファイルにすべての実装を詰め込んだものを用意して動かすパターンです。あまり筋が良くないので却下しました。

package-lock.json込みであげて依存関係を解決する

こちらは node_modules/ をデプロイ時のソースコードに含むものです。前述の通り、chromiumを含むことになるのでファイルサイズの問題で却下しました。

package.json込みでdeploy時に実行してもらう

なので消去法でこちらの方法でデプロイします。

Cloud Functionsデプロイのために、タスクを定義します。

  • build.gradle.kts
tasks {
    val packaging by creating(Copy::class) {
        val directory = "functions"
        from("build/js/packages/${project.name}/kotlin/${project.name}.js", "build/js/", "build/js/packages/${project.name}/package.json")
        exclude("**/packages/", "node_modules/", "node_modules.state", "yarn.lock")
        into("$directory/")
        rename { it.replace("${project.name}.js", "index.js") }

        doLast {
            val jsonFile = file("$directory/package.json")
            val texts = jsonFile.readLines()
                .map { it.replace("kotlin/${project.name}.js", "index.js") }
                .map { it.replace("file:${project.projectDir.absolutePath}/build/js/", "") }
            jsonFile.writeText(texts.joinToString("\n"))
        }
    }
}

Kotlin関連の実装はローカルの絶対pathを参照しているので、このままだと環境によって差分が出てしまいます。それを埋めるために、相対pathを参照するようにし、デプロイ時に必要なものだけをまとめるディレクトリを生成しています。

これによってfunctionsフォルダが生成され、次の構成になります。

├── functions
│   ├── index.js
│   ├── package.json
│   └── packages_imported
│       ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy
│       │   └── 0.0.7
│       │       ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy.js
│       │       ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy.js.map
│       │       └── package.json
│       ├── kotlin
│       │   └── 1.4.21
│       │       ├── kotlin.js
│       │       ├── kotlin.js.map
│       │       └── package.json
│       ├── kotlin-test
│       │   └── 1.4.21
│       │       ├── kotlin-test.js
│       │       ├── kotlin-test.js.map
│       │       └── package.json
│       ├── kotlin-test-js-runner
│       │   └── 1.4.21
│       │       ├── karma-debug-framework.js
│       │       ├── karma-debug-runner.js
│       │       ├── karma-kotlin-reporter.js
│       │       ├── kotlin-test-karma-runner.js
│       │       ├── kotlin-test-karma-runner.js.map
│       │       ├── kotlin-test-nodejs-runner.js
│       │       ├── kotlin-test-nodejs-runner.js.map
│       │       ├── mocha-kotlin-reporter.js
│       │       ├── package.json
│       │       ├── tc-log-appender.js
│       │       └── tc-log-error-webpack.js
│       ├── kotlinx-atomicfu
│       │   └── 0.14.4
│       │       ├── kotlinx-atomicfu.js
│       │       ├── kotlinx-atomicfu.js.map
│       │       └── package.json
│       ├── kotlinx-coroutines-core
│       │   └── 1.3.9
│       │       ├── kotlinx-coroutines-core.js
│       │       ├── kotlinx-coroutines-core.js.map
│       │       └── package.json
│       └── kotlinx-serialization-kotlinx-serialization-core-jsLegacy
│           └── 1.0.0-RC
│               ├── kotlinx-serialization-kotlinx-serialization-core-jsLegacy.js
│               ├── kotlinx-serialization-kotlinx-serialization-core-jsLegacy.js.map
│               └── package.json

functionsディレクトリには、ローカルで参照するKotlin関連の実装と、Cloud Functionsの規則に合わせたindex.jsというデプロイしたい関数の実装ファイルと、package.jsonがあります。

package.jsonは次のようになります。

{
  "main": "index.js",
  "devDependencies": {
    "dukat": "0.5.8-rc.3"
  },
  "dependencies": {
    "axios": "0.21.0",
    "puppeteer": "5.5.0",
    "form-data": "3.0.0",
    "@google-cloud/secret-manager": "3.2.2",
    "kotlin": "packages_imported/kotlin/1.4.21",
    "kotlinx-coroutines-core": "packages_imported/kotlinx-coroutines-core/1.3.9",
    "kotlinx-atomicfu": "packages_imported/kotlinx-atomicfu/0.14.4",
    "kotlinx-serialization-kotlinx-serialization-core-jsLegacy": "packages_imported/kotlinx-serialization-kotlinx-serialization-core-jsLegacy/1.0.0-RC",
    "17def1782b5ee417-kotlinx-nodejs-jsLegacy": "packages_imported/17def1782b5ee417-kotlinx-nodejs-jsLegacy/0.0.7",
    "kotlin-test-js-runner": "packages_imported/kotlin-test-js-runner/1.4.21",
    "kotlin-test": "packages_imported/kotlin-test/1.4.21"
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": [],
  "name": "kotlin-js-headless-chrome",
  "version": "0.0.1"
}

mainが変わり、絶対pathだった部分が相対pathになっています。

デプロイについてですが、cloudbuild.yamlは次のようになっています。

steps:
  - name: gradle:6.3-jdk8
    entrypoint: gradle
    args: ['cKJ', 'packaging']
  - name: 'gcr.io/cloud-builders/gcloud'
    dir: 'functions/'
    args:
      - functions
      - deploy
      - capture
      - --source=.
      - --region=asia-northeast1
      - --trigger-http
      - --runtime=nodejs12
      - --allow-unauthenticated
      - --memory
      - 512MB
      - --service-account=secret-manager@able-source-341.iam.gserviceaccount.com
      - --update-env-vars
      - PROJECT_ID=able-source-341,SLACK_CHANNEL_KEY=capture_slack_channel,SLACK_TOKEN_KEY=capture_slack_token

Gradleが実行可能なDockerコンテナでjsファイルの生成とデプロイの準備をし、gcloudコマンドが利用可能なDockerコンテナでCloud Functionsのデプロイコマンドを実行します。

不要なファイルをアップロードしないように dir で作業ディレクトリを移動しています。

Secret Managerの利用

Cloud Functionsで環境変数に機密性の高い値を置くのは不適切とされています。

cloud.google.com

YAML ファイル、デプロイ スクリプト、ソース管理に重要な認証情報は保存しないでください。

前回は環境変数に置いてしまっていたので、今回はSecret Managerで管理する方法を取りました。

cloud.google.com

dukatでそのまま生成はできなかったので、次のようなファイルを用意してKotlinから利用しました。

  • SecretManager.kt
@JsModule("@google-cloud/secret-manager")
external object SecretManager {
    class SecretManagerServiceClient {
        fun accessSecretVersion(json: kotlin.js.Json = definedExternally): Promise<Array<AccessSecretVersionResponse>>
    }
}

@Serializable
external class AccessSecretVersionResponse {
    @SerialName("name")
    val name: String
    val payload: Data
}

@JsName("data")
external class Data {
    val data: Buffer
}

external class Buffer {
    fun toString(str: String): String
}

fun Array<AccessSecretVersionResponse>.value(): String {
    return this[0].payload.data.toString("utf8")
}

利用するには

  • Secret Manager のシークレット アクセサーのロール
  • PROJECT_ID
  • 保存しているSecretのKey名

が必要になります。Cloud Buildでは次の部分がそれに当たります。

      - --service-account=secret-manager@able-source-341.iam.gserviceaccount.com
      - --update-env-vars
      - PROJECT_ID=able-source-341,SLACK_CHANNEL_KEY=capture_slack_channel,SLACK_TOKEN_KEY=capture_slack_token

Cloud Functionsのランタイムサービスアカウントは、デフォルトだと PROJECT_ID@appspot.gserviceaccount.com となっています。なので、そのサービスアカウントにSecret Manager のシークレット アクセサーのロールを与えるか、Secret Manager のシークレット アクセサーのロールを持ったサービスアカウントを別途用意する必要があります。今回は後者のほうで実現しています。

cloud.google.com

ロールの設定

少し複雑ですが、

  • Cloud Buildを実行するメンバー
  • Cloud Functionsを呼び出すメンバー
  • Cloud Functionsを実行するメンバー

はそれぞれ別です。

Cloud Buildを実行するメンバーはデフォルトだと PROJECT_NUMBER@cloudbuild.gserviceaccount.com というサービスアカウントです。Cloud Functionsのリソースにアクセスするので Cloud Functions 開発者 のロールを追加しておく必要があります。

cloud.google.com

Cloud Functionsを呼び出すメンバーには、Cloud Functions 起動元のロールが必要です。publicにしたい場合は allUsers メンバーにCloud Functions 起動元のロールを追加してあげると良いです。

今回は定期実行させたかったので、 Cloud Schedulerから呼び出すことにしました。

cloud.google.com

AuthヘッダーにOIDCトークンを選択し、Cloud Functions 起動元のロールをつけたサービスアカウントを設定することで安全に呼び出しています。

Cloud Functionsを実行するメンバーは、 前項で設定しているサービスアカウントです。

注意が必要なのは、Cloud Functionsを呼び出すメンバーと実行するメンバーが異なるということです。呼び出すメンバーにSecret Manager のシークレット アクセサーのロールは不要です。逆に持っていたとしても、実行するメンバーにそのロールが与えられていなければ読み取りエラーになります。

ファイルの保存

今回はSlackに投稿するに当たり、一旦キャプチャした画像をファイルとして保存してから投稿するようにしました。 Cloud Functionsでは、/tmp ディレクトリ以外は書き込み権限がありません。ローカルでも同様に確認できるようにするため、os.tmpdirで保存先を確認するようにしています。

cloud.google.com

form-dataでファイルを投稿する

ハマってしまったのですが、Node.jsからファイルを送る場合form-data形式で送る必要があります。Kotlin/JSでもFormDataは用意されてるのですが、こちらはブラウザでの利用を想定されているものでNode.jsから動かした場合正しく機能しません。 form-dataを依存関係に追加して利用する必要があります。

www.npmjs.com

Kotlinから利用するに当たり、最低限次のように定義しました。

  • FormData.kt
@JsModule("form-data")
external class FormData {
    fun getBoundary(): String
    fun append(string: String, dynamic: dynamic)
}

まとめ

ローカルで動かすだけなら比較的すんなりいくのですが、Cloud Functionsで動かそうとすると考えることがとても増えて苦しみました。 IAM周りの話や、Headless Chromeを扱うようなときは参考にできると思います。

リポジトリはこちらです。

github.com

参考

品質とパフォーマンスに注力した Kotlin 1.4 をリリースしました | JetBrains Blog

Specifying dependencies in Node.js  |  Cloud Functions Documentation

Quotas and limits  |  Firebase

https://rominirani.com/using-puppeteer-in-google-cloud-functions-809a14856e14

playwright/installation.md at master · microsoft/playwright · GitHub

Secret Managerを使ってみる@Cloud Functions編 | apps-gcp.com

Slack

javascript - Google Clould Functions deploy: EROFS: read-only file system - Stack Overflow

Node.js上からmultipart/form-data形式でHTTPリクエストをする - Qiita

pub.dev用にcredentials.jsonを再取得する

pub.devにpackageを上げる際にはpub.devの認可が必要です。まだであればpublishする際にブラウザが立ち上がり、ローカルにcredentials.jsonが保持されます。

CIなどに組み込みたい場合、このcredentials.json環境変数に渡します。そして、有効期限が切れた際は再び必要になります。

今回は再び必要になった時に再取得する方法の紹介です。

確認したバージョンは次の通りです。

  • flutter: v1.20.0
  • pub: v2.9

credentials.jsonのpath

$ pub として実行した時はユーザルート配下の.pub-cacheディレクトに生成されます。DartにBundleされてるのでDartのインストール状況によっては異なるかもしれません。

$ cat ~/.pub-cache/credentials.json

$ flutter pub として実行した時はflutterリポジトリのルート配下の.pub-cacheディレクトに生成されます。公開しようとしているパッケージの方ではなく、ローカルマシンにインストールしてあるflutterの方です。

$ cat <YOUR FLUTTER PATH>/.pub-cache/credentials.json

専用のコマンドは無い

現状専用のコマンドはありませんでした。

なので再びpublishのフローを行うことで生成します。

この時、すでに公開済みのバージョンにしてpublishすることで、credentials.jsonを生成しつつ公開作業に失敗させることができます。

pub.devのpackageの命名規則pub.dev/packages/<your_package>/versions/<version> となってます。

—dry-runオプションも存在はしてるのですが、あくまでpubspec.yamlのフォーマットの確認など、ローカルで完結するものしか確認できないのでcredentials.jsonの再生成用途には使えません。

GitHub Actionsのワークフローからpublishする

自作のActionですが、こちらが使えます。GitHubで検索するとちらほら使われていて嬉しいです。

github.com

まとめ

credentials.jsonの有無を確認したい時はユーザルートとflutterをインストールしてあるディレクトリを確認する

専用のコマンドはないのでワークアラウンド的な方法で取得する

参考

Publishing packages | Dart

Troubleshooting pub | Dart