Kotlin/JSでLINE Botを動かす
Kotlin/JSを扱う題材としてCloud FunctionsでLINE Botを動かしてみたのですが、前回の記事がモリモリになってしまうので導入と実践を分けました。今回が実践編です。
前回の記事はこちら
環境は次のとおりです
題材
LINE Botを動かしてみることにしました。Slackもそうですが、inputとoutputが同じ場所で見えるチャット系サービスはCloud Functionsと相性が良いです。
Messaging API overview | LINE Developers
- ユーザーが、LINE公式アカウントにメッセージを送信します。
- LINEプラットフォームからボットサーバーのWebhook URLに、Webhookイベントが送信されます。
- Webhookイベントに応じて、ボットサーバーからユーザーにLINEプラットフォームを介して応答します。
今回は応答メッセージを使って、ユーザがLINE Botにメッセージを送ったときにオウム返しをするBotを作成します。
LINE Bot作成で必要なこと
LINE Botでは次のことが必要です
- リクエストからデータを受け取り
- 応答メッセージの作成
- LINEサーバにメッセージの送信
Cloud Functionsで動かす部分は同じです。
開発を便利にするためにいくつかの準備をします。
型の用意
せっかくKotlinで書くので型の恩恵を得られるようにします。
デプロイする関数の引数であるRequestとResponseの型を定義してあげます。今回利用する最低限度のものしか用意してないです。
- Wrapper.kt
external interface Request { val body: Any } external interface Response { fun status(code: Int): Response fun send(message: String): Response }
デプロイする関数を用意して、関数型として渡します。
- main.kt
import wrapper.Request import wrapper.Response external val exports: dynamic fun main() { exports.message = ::message } fun message(req: Request, res: Response) { TODO("実装を書く") }
これでエントリーポイントと実装が分けられてスッキリします。
kotlin-extentionsの導入
Kotlin/JSを扱うに当たり、ヘルパーが用意されてるので導入します。
@jetbrains/kotlin-extensions
という名前のnpmパッケージです。
- build.gradle.kts
dependencies { ... implementation(npm("@jetbrains/kotlin-extensions", "^1.0.1-pre.91")) ... }
npmパッケージが用意されてるので利用はできるのですが、デプロイを考えたときにちょっと問題があります。
生成されるJSファイルでは、require('kotlin-extensions')
となってしまうので利用時にエラーになります。
なので、JSファイルを生成する際に一部書き換えて対応します。
- build.gradle.kts
compileKotlinJs { doLast { // workaround for use kotlin-extensions val jsFile = File("build/js/packages/${project.name}/kotlin/${project.name}.js") val text = jsFile.readText() val rep = text.replace("require('kotlin-extensions')", "require('@jetbrains/kotlin-extensions')") jsFile.writeText(rep) } }
正直ちょっと手間ではあるので、利用したい部分のコードをローカルにコピーしてくる運用でも良いかもしれません。
環境変数の利用
トークンなどはソースコード管理に含めたくないので環境変数を経由して利用します。
js
関数でJSのコードを実行して利用します。
例えば
$ export CHANNEL_ACCESS_TOKEN=hogehoge
としていたら、Kotlinからは次のように利用できます。
js("process.env.CHANNEL_ACCESS_TOKEN") // hogehoge
型はdynamicです。
kotlinx.serializationの導入
通信の際にJSONデータを扱うので、手動でSerialize/deserializeしなくていいようにライブラリを利用します。
公式の通りにやれば追加できます。
注意として、バージョンは 0.14.0
と 0.20.0
しか対応していません。
package.jsonには kotlinx-serialization-kotlinx-serialization-runtime
というnpmパッケージで探しに行くので、次のnpmパッケージが一致して解決されます。
ちょっと気になるのは、このnpmパッケージが公式のものなのかどうかわからない点です。
npmパッケージの中身はbuild/package_imported
に生成されるJSファイルと同じなので気のせいかもしれませんが。
issueが閉じられてないのも気になります。
できればextentionと同じようにscopeを切って公式とわかるようにして、苦なく利用できるような形になっててほしいです。
通信ライブラリの導入
Axiosがdukatでいい感じに利用できたので、Axiosを採用します。
dukatについてはこちら
こんな感じでPOSTリクエストを送れます。HeaderにAuthorizationを追加してます。
- main.kt
private fun postReply(reply: Reply): Promise<JsonObject> { val axios = Axios.create(object : AxiosRequestConfig { override var method: String = "POST" override var responseType: String = "json" override var headers: Any? = kotlinext.js.js { this.Authorization = "Bearer ${js("process.env.CHANNEL_ACCESS_TOKEN")}" } as? Any }) return axios.post<Unit, JsonObject>(url = "https://api.line.me/v2/bot/message/reply", data = reply) }
kotlinext.js.js
は前述のkotlin-extentionsです。
JSON.stringifyでの循環参照回避
JSON.stringify(o: Any?)
を利用することで、JSのオブジェクトをJSON文字列に変換できます。
JVMの世界では起きなかったが、JSの世界では起きる問題があります。
stringifyしようとしたJSオブジェクトが、循環参照している場合例外が発生します。
それを解決するために第2引数に replacer: ((key: String, value: Any?) -> Any?)
という関数が用意されています。
こちらで循環参照してしまうものを除外して解決します。
次の例では、 request
というkeyが循環参照していたので除外しています。
- main.kt
fun message(req: Request, res: Response) { val webhookEvent = parseRequest(req) val reply = createReply(webhookEvent) postReply(reply) .then { console.log("then ${JSON.stringify(it) { key: String, value: Any? -> when (key) { "request" -> "" else -> value } }}") res.status(200).send("Success!!") }.catch { console.log("catch ${JSON.stringify(it)}") res.status(400).send("Error!!") } }
普段JSを書いていないので、慣れている人からしたら当たり前かもしれません。自分は困りました。
Cloud Functionsへのデプロイ
デプロイ時に環境変数を渡すのを忘れないようにします。
$ ./gradlew clean cKJ // compileKotlinJsの略 $ cd functions functions$ gcloud functions deploy message --region asia-northeast1 --trigger-http --runtime nodejs8 --update-env-vars CHANNEL_ACCESS_TOKEN="YOUR_ACCESS_TOKEN"
まとめ
JSならではの部分や、エコシステム周りで足りない部分も感じますが、準備さえしてしまえばわりと解決できる問題が多いです。
Kotlin/JSでHello World以上のサンプルがまだ少ないので、いくらか参考になると思います。
READMEにLINE Botの友だち追加のQRを置いてあるので、気になった方は試すことはできます。
参考
Messaging API overview | LINE Developers
Kotlin/JSでCloud Functionsを動かす
最近Kotlin/JSを触ってます。
実はCloud FunctionsがまだJavaをサポートしていないのでKotlinでCloud Functionsを動かそうと思ったらJSに変換する必要があります。
DartでもJSに変換してCloud Functionsを動かしたことがあったので、同じ用に動かせるのではないかと思いトライしました。
現在はEAPが稼働しているので、時期に対応されますが待ちきれない!という方には参考になると思います。
環境は次のとおりです
Cloud Functions
まずイメージを掴むために、JSのCloud Functionsの公式サンプル実装を見てみます。
const escapeHtml = require('escape-html'); /** * HTTP Cloud Function. * * @param {Object} req Cloud Function request context. * More info: https://expressjs.com/en/api.html#req * @param {Object} res Cloud Function response context. * More info: https://expressjs.com/en/api.html#res */ exports.helloHttp = (req, res) => { res.send(`Hello ${escapeHtml(req.query.name || req.body.name || 'World')}!`); };
これはhelloHttp
という関数のサンプルです。helloHttp
に、request
とresponse
を引数に持つ関数を代入して、レスポンスを返しているコードになります。
同じようなコードをKotlinで書いてみます。
ライブラリの追加
Kotlin 1.3.70からdependenciesに直接依存ライブラリを定義できるようになりました。
- build.gradle.kts
dependencies { implementation(kotlin("stdlib-js")) implementation(npm("escape-html", "1.0.3")) implementation(npm("@types/escape-html", "0.0.20")) }
escape-html
はdukatが使えたので利用します。
- build.gradle.kts
kotlin { sourceSets["main"].kotlin.srcDir("src/main/external") target { nodejs {} useCommonJs() } }
$ dukat -d src/main/external/ build/js/node_modules/@types/escape-html/index.d.ts
helloHttp関数を作成
helloHttp
にラムダ式を代入しています。引数の型はJSから渡されるのでdynamicにしています。
req.query
などは存在しなくてもエラーになりません。変数として扱う限りundefinedなだけです。
- Main.kt
external val exports: dynamic fun main() { exports.helloHttp = { req: dynamic, res: dynamic -> res.send(createMessage(req)) } } private fun createMessage(req: dynamic): String { val message = when { req.query.name !== undefined -> escapeHTML(req.query.name) req.body.name !== undefined -> escapeHTML(req.body.name) else -> "World" } return "Hello $message" }
ローカルで実行
次のコマンドでNode.jsで実行できます。
$ ./gradlew nodeRun
ただし先程の例だと何も出力されません。helloHttp関数が呼ばれていないからです。Cloud Functionsをローカルで確認するには @google-cloud/functions-framework
を使うと確認できます。
現状、build.gradle.kts
のdependenciesからはdevDependencies
に追加できないので、npx
を使ってコマンドを実行することで回避します。
$ cd build/js/packages/${PROJECT_NAME} $ npx @google-cloud/functions-framework --target=helloHttp npx: installed 52 in 3.789s Serving function... Function: helloHttp URL: http://localhost:8080/
ローカルに立ち上がるので、アクセスするとhelloHttp関数が呼ばれます。
$ curl -X POST -H "Content-Type: application/json" "http://localhost:8080/" Hello World
nameクエリをつけてアクセスしてみます。
$ curl -X POST -H "Content-Type: application/json" "http://localhost:8080/?name=\"Kotlin/JS\"" Hello "Kotlin/JS"
しっかりescape-htmlも動いています。
Cloud Functionsデプロイのためのプロジェクト構成
Cloud Functionsのデプロイに必要なものはJSのコードと、ライブラリを利用していたらpackage.jsonが必要です。
2つのファイルをまとめるタスクを定義します。
タスク定義
- build.gradle.kts
tasks { val packaging by creating(Copy::class) { from("build/js/packages/${project.name}/kotlin/${project.name}.js", "build/js/packages/${project.name}/package.json") into("functions") rename { it.replace("${project.name}.js", "index.js") } doLast { val jsonFile = file("functions/package.json") val texts = jsonFile.readLines() .map { it.replace("kotlin/${project.name}.js", "index.js") } jsonFile.writeText(texts.joinToString("\n")) } } }
functions
フォルダを生成して配置してます。jsファイルは、Cloud Functionsの制約があるのでindex.js
となるようにリネームしています。合わせて、package.jsonのエントリーポイントをindex.js
となるように書き換えてます。置き換え後のpackage.jsonは次のようになってます。
デプロイ先で依存の解決がされるので、npm install
する必要はないです。
- functions/package.json
{ "main": "index.js", "devDependencies": { "source-map-support": "0.5.16" }, "dependencies": { "kotlin": "1.3.72", "escape-html": "1.0.3", "@types/escape-html": "0.0.20" }, "peerDependencies": {}, "optionalDependencies": {}, "bundledDependencies": [], "name": "kotlin-js-cloud-functions", "version": "1.0.0-SNAPSHOT" }
次のように実行することでfunctions
フォルダに必要なファイルを 設置できます。
$ ./gradlew clean compileKotlinJs $ ./gradlew packaging
JSファイルを生成するだけならnodeRun
ではなくてcompileKotlinJs
で生成できます。
生成してから置き換えタスクを実行します。
実行後は次のような配置になります。
$ tree -L 2 . ├── build │ ├── js │ ├── kotlin │ └── tmp ├── build.gradle.kts ├── functions │ ├── index.js │ └── package.json ├── gradle │ └── wrapper ├── gradle.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src ├── main └── test
デプロイ
gcloudでデプロイします。
取り掛かる前に、GCPのConfigurationが意図しているものかどうか確認しておいてください。
$ gcloud config list
functionsフォルダに移動してからコマンドを実行します。
$ cd functions $ gcloud functions deploy helloHttp --region=asia-northeast1 --trigger-http --runtime=nodejs8 --allow-unauthenticated
サクッと確認したいため、--allow-unauthenticated
フラグを付けてpublicにしています。
次のようなURLが排出されると思います。
https://{REGION_NAME}-{PROJECT_NAME}.cloudfunctions.net/{FUNCTION_NAME}
まとめ
コードとしてはシンプルに導入できます。
デバッグも組み込まれてはいないですが、npxを利用することで回避できます。 デプロイに関しても、必要な処理は今回用意したタスクでまかなえるのでそこまで手間にならないと思います。
リポジトリはこちらです。
参考
Google Cloud Blog - News, Features and Announcements
Functions Framework | Cloud Functions Documentation | Google Cloud
gcloud functions deploy | Cloud SDK Documentation | Google Cloud
dukatでKotlin/JSを快適にする
前回、Kotlinで気持ちよくJSのライブラリを利用するには、モジュールを作成してあげる必要があると書きました。
しかしこれをちゃんとやろうとすると、結構な手間になります。
定義して使い回しできるようにしている人がチラホラいます。
公式で出してくれれば揺らぎもなくてよいのになと思っていたら、公式からでてました。
dukat
d.tsの型定義ファイルからKotlinのコードを自動生成してくれます。
まだexperimentalですが、kotlin js pluginに含まれており、ビルドに組み込むことができます。
- gradle.properties
kotlin.js.experimental.generateKotlinExternals=true
- build.gradle.kts
plugins { kotlin("js") version "1.3.61" } repositories { jcenter() } dependencies { implementation(kotlin("stdlib-js")) } kotlin { sourceSets["main"].dependencies { // 利用したいライブラリを書く } target { nodejs { this.runTask { // バージョンを指定してあげる this.nodeJs.versions.dukat.version = "0.0.28" } } useCommonJs() // commonjs styleのJSを指定 } }
dukatは目下開発中のため、kotlin js pluginに組み込まれているバージョンは古いです。なので自分でバージョン指定を行い、最新バージョンにしてあげます。
次のコマンドでktファイルが自動生成されます。
$ ./gradlew generateExternals
生成されたktファイルは build/externals/${module}/src
に配置されます。
ライブラリにd.tsファイルが同梱されているものしかktファイルは生成されません。ない場合は型定義ファイルも合わせて依存関係に追加すると使えます。
使ってみる(Plugin)
文字列操作ライブラリの voca
に対して使ってみます。
型定義ファイルが別途用意されてるのでそちらも依存に加えます。こちらで検索できます。
- build.gradle.kts
kotlin { sourceSets["main"].dependencies { implementation(npm("voca", "^1.4.0")) implementation(npm("@types/voca", "^1.4.0")) } ... }
generateExternals
タスクを実行すると今回は4つのktファイルが生成されます。
index.module_${library_name}kt
ファイルにエントリーポイントやinterfaceなどが定義されています。ファイルの生成され方は型定義ファイルによって異なります。
今回利用する部分だけ抜粋します。
- index.module_voca.kt
@JsModule("voca") external val v: v.VocaStatic
- index.v.module_voca.kt
@file:JsQualifier("v") @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS", "EXTERNAL_DELEGATION") package v ... external interface Chain { ... fun chain(): ExplicitChain<String> ... } ... external interface ExplicitChain<T> { ... fun upperCase(): ExplicitChain<String> ... fun trim(whitespace: String = definedExternally): ExplicitChain<String> ... }
このように定義されているので、次のように利用できます。
- main.kt
fun main() { println("Hello Kotlin/${v.chain(" js ").trim().upperCase()}") }
v.
でIDEなどでサジェストが効くようになっています。たまんないですね。もちろん型情報もあります。
実行すると期待通りの変換がされます。
$ ./gradlew nodeRun > Task :generateExternals UP-TO-DATE tests > Task :nodeRun Hello Kotlin/JS
現状の問題点
今回のようにうまくいけばいいのですが、experimentalなだけあってまだまだ問題はたくさんあります。
ktファイルの生成に失敗するケースが結構あります。ktファイルの生成はできたがコンパイルができないケースもあります。
また、複数ライブラリを使う場合、全てのd.tsファイルを捜査して変換を試みるので、一つでも失敗するものがあるとktファイルが生成されません。
後者はdukatをPluginを使わずに利用することで回避できます。
使ってみる(CLI)
dukatをインストールします。利用できればglobalインストールじゃなくてもnpxでも構いません。
$ npm install -g dukat
プロジェクトルートにいることを想定して、対象の型定義ファイルとktファイルの展開先を指定します。
$ dukat -d src/main/external/ build/js/node_modules/\@types/voca/index.d.ts nonDeclarations.v.nonDeclarations.kt index.module_voca.kt index.v.module_voca.kt lib.es2015.iterable.module_dukat.kt
src/main/external/
というpathにktファイルを展開させました。src/main/kotlin/
でも良いのですが、自動生成されたファイルと分けたいので別にしました。
src/main/external/
というフォルダはソースと認識されないので、ソースとして追加します。Pluginを使ってる場合は自動で build/externals/${module}/src
をソースに追加してくれてるので問題になりませんでした。
- build.gradle.kts
kotlin { sourceSets["main"].kotlin.srcDir("src/main/external") sourceSets["main"].dependencies { implementation(npm("voca", "^1.4.0")) implementation(npm("@types/voca", "^1.4.0")) } target { nodejs() useCommonJs() } }
これで実行してみます。gradle.propertiesの kotlin.js.experimental.generateKotlinExternals=true
は削除するかコメントアウトしてください。
$ ./gradlew nodeRun > Task :nodeRun Hello Kotlin/JS
無事に実行できました。
まとめ
dukatがあるのでKotlin/JSの未来は明るいです。ですが明るい未来はまだ遠いので、手元で使えるライブラリを選定して部分的に利用していくのが良いです。
今回動かしたリポジトリはこちらです。
参考
Kotlin 1.3.50 released | Kotlin Blog