Spring WebFlux で開発した REST API を Swagger Editor から実行するために CORS を有効にする

概要

Swagger Editor の Try it out から REST API を実行できる様にするために、Spring の設定を変更して CORS を有効にする方法を説明します。但し、Spring MVC ではなく、Spring WebFlux を使う場合の方法です。コードは Kotlin で書かれています。

目次

確認環境

  • Spring Boot 2.0.3
  • Kotlin 1.2.50

参考情報

解説

Swagger Editor で発生するエラー

Swagger Editor の Try it out から REST API を実行すると CORS に関するエラーが発生します。ブラウザの画面の Swagger Editor には、以下のエラーが表示されます。(読みやすくなるよう、途中に改行を入れています。)

Possible cross-origin (CORS) issue?
The URL origin (http://localhost:8080) does not match the page (http://editor.swagger.io).
Check the server returns the correct 'Access-Control-Allow-*' headers.

Spring のデフォルト設定では CORS は無効になっており、swagger.io のドメイン(オリジン)で動作している Web アプリケーションから、REST API を実行するドメイン(オリジン)へのリソース要求は拒否されます。

CORS を有効にする方法

WebFilter を実装したクラスを作成

CORS を有効にするには以下のとおりにします。

import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.web.cors.reactive.CorsUtils
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono

@Component
class CorsFilter : WebFilter {

    private val allowedOrigin = "http://editor.swagger.io"
    private val allowedMethods = "GET, PUT, POST, DELETE, OPTIONS"
    private val allowedHeaders = "Content-Type"
    private val maxAge = "3600"

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        val request = exchange.request
        if (CorsUtils.isCorsRequest(request)) {
            val response = exchange.response
            response.headers.apply {
                add("Access-Control-Allow-Origin", allowedOrigin)
                add("Access-Control-Allow-Methods", allowedMethods)
                add("Access-Control-Allow-Headers", allowedHeaders)
                add("Access-Control-Max-Age", maxAge)
            }
            if (request.method == HttpMethod.OPTIONS) {
                response.statusCode = HttpStatus.OK
                return Mono.empty<Void>()
            }
        }
        return chain.filter(exchange)
    }
}

上記のコードでは WebFilter インターフェースを実装したクラスを作成して、Bean として登録しています。filter メソッドの処理では、request に Origin ヘッダーが設定されているとき (CorsUtils.isCorsRequest(request) が true のとき)、response header に Access-Control-* ヘッダーを追加しています。更に、プリフライト要求の場合には OPTIONS メソッドでの request が送られるため、その場合には response body を空のままで応答します。単純要求の場合には Access-Control-* ヘッダーを追加して、次のフィルタに処理を委譲します。CORS 要求でない場合にも、次のフィルタに処理を委譲します。フィルタが委譲を繰り返し、いずれ応答します。

この WebFilter によって、異なるオリジンから Origin ヘッダーを付加して送信された要求に対して、Access-Control-* ヘッダーを付加した応答が返されます。

Configure で Bean を登録

別の方法として、以下の方法でも設定できます。

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.web.cors.reactive.CorsUtils
import org.springframework.web.server.WebFilter
import reactor.core.publisher.Mono

@Configuration
class CorsConfiguration {

    private final val allowedOrigin = "http://editor.swagger.io"
    private final val allowedMethods = "GET, PUT, POST, DELETE, OPTIONS"
    private final val allowedHeaders = "Content-Type"
    private final val maxAge = "3600"

    @Bean
    fun corsFilter(): WebFilter = WebFilter { exchange, chain ->
        val request = exchange.request
        if (CorsUtils.isCorsRequest(request)) {
            val response = exchange.response
            response.headers.apply {
                add("Access-Control-Allow-Origin", allowedOrigin)
                add("Access-Control-Allow-Methods", allowedMethods)
                add("Access-Control-Allow-Headers", allowedHeaders)
                add("Access-Control-Max-Age", maxAge)
            }
            if (request.method == HttpMethod.OPTIONS) {
                response.statusCode = HttpStatus.OK
                return@WebFilter Mono.empty<Void>()
            }
        }
        return@WebFilter chain.filter(exchange)
    }
}

WebFilter の適用順をカスタマイズしたい場合には、こちらの方法よりも WebFilter を実装したクラスを作成した方が簡単に対応できます。

SSH で Python を実行したら UnicodeEncodeError になった

概要

ローカル環境で動作していた Python プログラムを Raspberry Pi にコピーして、 ローカル環境から SSH 経由でそのプログラムを実行したところ、 UnicodeEncodeError が発生しました。

確認環境

参考情報

解説

以下のプログラム (sample.py) を作成します。

print('\xa7')

IntelliJ IDEA の Terminal にて、Raspberry Pi Zero にコピーして、SSH で実行します。

intellij$ scp sample.py pi@raspberrypi.local:
intellij$ ssh pi@raspberrypi.local python3 sample.py
Traceback (most recent call last):
  File "sample.py", line 1, in <module>
    print('\xa7')
UnicodeEncodeError: 'ascii' codec can't encode character '\xa7' in position 0: ordinal not in range(128)

ちなみに、macOS の Terminal にて、同様に実行してもエラーは発生しません。 また、Raspberry PiSSH でログインしてから実行してもエラーは発生しません。

Terminal によって違うのは、環境変数に違いがあると思ったので比較すると、言語設定に違いが見られます。 上から順に、IntelliJ IDEA の Terminal、 macOS の Terminal、 IntelliJ IDEA の Terminal で Raspberri Pi に SSHmacOS の Terminal で Raspberry PiSSH

intellij$ printenv
LC_CTYPE=ja_JP.UTF-8
...
mac$ printenv
LANG=ja_JP.UTF-8
...
intellij$ ssh pi@raspberrypi.local printenv
LANG=en_GB.UTF-8
LC_CTYPE=ja_JP.UTF-8
...
mac$ ssh pi@raspberrypi.local printenv
LANG=en_GB.UTF-8
...

ローカル環境のロケールを確認します。

intellij$ locale -a | grep UTF-8
...
ja_JP.UTF-8
...

数が多いので、UTF-8 を文字に含む行だけを表示しています。 それでも、沢山のロケール名が表示されます。

Raspberry Pi Zero 環境のロケールを確認します。

intellij$ ssh pi@raspberrypi.local locale -a 
locale: Cannot set LC_CTYPE to default locale: No such file or directory
C
C.UTF-8
en_GB.utf8
POSIX

Raspberry Pi Zero 環境のロケールは全部で 4 つです。 LC_CTYPE をデフォルトロケールに使用できず、エラーが発生しています。 IntelliJ IDEA の Terminal では LC_CTYPE=ja_JP.UTF-8 ですが、ロケール ja_JP.UTF-8Raspberry Pi Zero 環境には存在していません。 ロケールの指定に問題があり UnicodeEncodeError が発生しています。

解決策の 1 つとしては、IntelliJ IDEA の Terminal で LC_CTYPE を unset する方法があります。

intellij$ unset LC_CTYPE

もしくは、適切な LC_CTYPE に設定すればよいでしょう。

kotlin-frontend-plugin のソースコードを読んで要約してみた

概要

以下の記事の続きです。以下の記事では、ソースコードの内容をクラス単位で整理しました。 クラス単位では全体像が見えづらいため、図とリストを使って全体を要約します。

nosix.hatenablog.com

目次

確認環境

  • Kotlin 1.1.4
  • kotlin-frontend-plugin 0.0.21

github.com

解説

タスクの依存関係

タスクの dependsOn による依存関係を整理すると以下の図のようになる。

データフロー

各タスクと一部のクラスでの処理と入出力の関係を図にすると以下のようになる。 前者は npm 関連のタスク、後者は webpack 関連のタスク。

requiredDependencies には以下の 3 つの依存関係が含まれている。

  • webpack (version: *, scope: DevelopmentScope)
  • webpack-dev-server (version: *, scope: DevelopmentScope)
  • source-map-loader (version: *, scope: DevelopmentScope)
    • kotlinFrontend.sourceMaps が true の場合のみ

入出力の関係

全てのタスクの入力と出力の関係を以下のリストに整理する。

  • 環境変数
    • PATH
      • node コマンドのパスとして使われる
  • プロパティ
    • org.kotlin.frontend.node.dir
      • node コマンドのパスとして使われる
  • project
    • name
      • “$buildDir/package.json” の name に使われる
    • version
      • “$buildDir/package.json” の version に使われる
  • sourceSets
    • main.output.resourcesDir
      • 設定している場合には、"$buildDir/package.json" と同じファイルが resourcesDir に作成される
  • dependencies
    • compile Kotlin/JS library
      • “$buildDir/node_modules_imported” に展開される
    • compile project
      • “$buildDir/webpack.config.js” の resolve に使われる
  • compileKotlin2Js
    • kotlinOptions.outputFile
      • sourceSets.main.output に追加される
      • “$buildDir/package.json” の name, main に使われる
      • “$buildDir/webpack.config.js” の context, entry value, resolve に使われる
      • “$buildDir/WebPackHelper.js” の bundlePath, moduleName に使われる
      • “$buildDir/webpack-dev-server-run.js” の bundlePath, moduleName に使われる
  • kotlinFrontend
    • sourceMaps: Boolean = false
      • “$buildDir/WebPackHelper.js” の sourceMap に使われる
      • “$buildDir/webpack-dev-server-run.js” の sourceMap に使われる
    • downloadNodeJsVersion: String = “”
      • 空でなければ nodejs-download タスクを行う
      • nodejs-download.version に設定される
    • nodeJsMirror: String = “”
      • nodejs-download.mirror に設定される
    • define(name: String, value: Any?)
      • “$buildDir/webpack.config.js” の defined (plugin) に使われる
      • “$buildDir/.run-webpack-dev-server.txt” の exts に記録される
  • npm
    • dependencies: MutableList
      • dependency(name: String, version: String = “*”) で設定する
      • “$buildDir/package.json” の dependencies に使われる
    • developmentDependencies: MutableList
      • devDependency(name: String, version: String = “*”) で設定する
      • “$buildDir/package.json” の devDependencies に使われる
    • versionReplacements: MutableList
      • replaceVersion(name: String, version: String) で設定する
  • webpackBundle
    • bundleName: String = project.name
      • “$buildDir/webpack.config.js” の entry key に使われる
    • sourceMapEnabled: Boolean = kotlinFrontend.sourceMaps
      • “$buildDir/WebPackHelper.js” の sourceMap に使われる
      • “$buildDir/webpack-dev-server-run.js” の sourceMap に使われる
    • contentPath: File? = null
      • “$buildDir/WebPackHelper.js” の contentPath に使われる
      • “$buildDir/webpack-dev-server-run.js” の contentPath に使われる
    • publicPath: String = “/”
      • “$buildDir/webpack.config.js” の publicPath に使われる
      • “$buildDir/WebPackHelper.js” の publicPath に使われる
      • “$buildDir/webpack-dev-server-run.js” の publicPath に使われる
    • port: Int = 8088
      • “$buildDir/WebPackHelper.js” の port に使われる
      • “$buildDir/webpack-dev-server-run.js” の port に使われる
      • “$buildDir/.run-webpack-dev-server.txt” の port に記録される
    • proxyUrl: String = “”
      • “$buildDir/WebPackHelper.js” の proxyUrl に使われる
      • “$buildDir/webpack-dev-server-run.js” の proxyUrl に使われる
    • stats: String = “errors-only”
      • “$buildDir/WebPackHelper.js” の stats に使われる
      • “$buildDir/webpack-dev-server-run.js” の stats に使われる
    • webpackConfigFile: Any? = null
      • 設定されている場合、webpack-config タスクを行わない
      • 設定されている場合のみ、webpack-helper タスクを行う
      • “$buildDir/WebPackHelper.js” の webPackConfig に使われる
      • “$buildDir/webpack-dev-server-run.js” の webPackConfig に使われる
  • “$projectDir/package.json.d” 配下の JSON ファイル
    • “$buildDir/package.json” にマージされる
  • “$projectDir/webpack.config.d” 配下の JavaScript ファイル
    • “$buildDir/webpack.config.js” の拡張スクリプトとして使われる

まとめ

kotlin-frontend-plugin を使うにあたって重要なファイルは、以下の 4 つであると思われる。

  • node コマンド
  • package.json
  • webpack.config.js
  • webpack-dev-server-run.js

kotlin-frontend-plugin では、 package.json の設定により環境を整え、 webpack.config.js の設定により JavaScript のバンドルを作成し、 webpack-dev-server-run.js の設定により webpack-dev-server を起動する。 これらを使う上で、node コマンドは必須である。

これら 4 つのファイルに関わる主要な入力は以下のようになっている。

  • node コマンド
    • プロパティ org.kotlin.frontend.node.dir でパスを設定
      • 最優先されるパス
      • 別途 node.js をインストールして使用する場合に使うとよい
    • ~/.gradle/nodejs
      • Gradle でインストールさせる場合のパス
      • kotlinFrontend.downloadNodeJsVersion を指定することで有効になる
    • 環境変数 PATH でパスを設定
  • package.json
    • project の name, version
    • dependencies で compile 指定した Kotlin/JS library が node_modules として展開される
    • compileKotlin2Js.kotlinOptions.outputFile が name, main に使用される
    • npm の dependencies, developmentDependencies で依存モジュールを指定する
    • “$projectDir/package.json.d” 配下の JSON ファイル で設定を拡張する
  • webpack.config.js
    • dependencies で compile 指定した Project が resolve に含まれる
    • compileKotlin2Js.kotlinOptions.outputFile が複数の設定に使用される
    • kotlinFrontend の define(name: String, value: Any?) メソッドで plugin の設定を追加する
    • webpackBundle の bundleName, publicPath, webpackConfigFile
      • webpackConfigFile を指定すれば、指定したファイルが使用される
      • webpackConfigFile を指定しなければ、生成したファイルが使用される
    • “$projectDir/webpack.config.d” 配下の JavaScript ファイルで設定を拡張する
  • webpack-dev-server-run.js
    • compileKotlin2Js.kotlinOptions.outputFile が bundlePath, moduleName に使用される
    • kotlinFrontend の sourceMaps が source-map-loader を追加する
    • webpackBundle の各設定
    • スクリプトのなかでは HotModuleReplacement の設定を追加する

また、実行されるコマンドは以下のとおりであり、設定が上手くいかに場合には以下のコマンドを試すとよい。

// npm-install
npm install

// webpack-bundle
node $buildDir/node_modules/webpack/bin/webpack.js --config $buildDir/webpack.config.js

// webpack-run
node $buildDir/webpack-dev-server-run.js

// webpack-stop (実際には curl は使用せず URL にアクセスしている)
curl http://localhost:$port/webpack/dev/server/shutdown

このあたりの関係を押さえておくと設定に迷うことが少なくなるのではないかと思う。

kotlin-frontend-plugin のソースコードを読んでみた

概要

kotlin-frontend-plugin は Kotlin/JS を用いてのフロントエンド開発を助ける Gradle プラグインです。 node.js を使うようになっており、webpack, karma などの node.js ライブラリを使った開発を助けます。 Kotlin の公式プラグインですが、現在は EAP となっています。

文書が少なく、何をどのように行っているのか分からなかったため、ソースコードを読みました。 本記事では、ソースコードから読み取った処理の要点を紹介します。 各々の関係を把握するには適していないため、別途図を作成する予定です。

目次

確認環境

  • Kotlin 1.1.4
  • kotlin-frontend-plugin 0.0.21

github.com

解説

class FrontendPlugin

org.jetbrains.kotlin.gradle.frontend.FrontendPlugin で行われている処理を書き記す。

project.extensions に kotlinFrontend (型は KotlinFrontendExtension) を追加する。

KotlinFrontendExtension で指定されている設定項目は以下のとおり。

  • sourceMaps: Boolean = false
  • downloadNodeJsVersion: String = “”
  • nodeJsMirror: String = “”

project.tasks に以下のタスクを追加する。

  • packages (group: build)
  • bundle (group: build)
  • run (group: run)
  • stop (group: run)
  • nodejs-download (型は NodeJsDownloadTask)
    • kotlinFrontend.downloadNodeJsVersion が空でない場合のみ追加
    • タスクの以下の項目を設定する
      • version = kotlinFrontend.downloadNodeJsVersion
      • mirror = kotlinFrontend.nodeJsMirror (nodeJsMirror の設定が存在する場合のみ)

タスクに依存関係を追加する。 ATask.dependsOn(BTask)BTask > ATask として書くと以下になる。

nodejs-download > packages
packages > compileKotlin2Js
packages > compileTestKotlin2Js
packages > bundle
packages > run
compileKotlin2Js > compileTestKotlin2Js
compileKotlin2Js > bundle
compileKotlin2Js > run
bundle > assemble
stop > clean

NpmPackageManager に対して以下を行う。

  • apply(task packages)
    • packages タスクを渡しているが使用されていない
  • install(project)
    • buildFinished & not failure & taskGraph is null の場合のみ実行される

kotlinFrontend extension で設定した *Bundle に対応する Bundler に対して以下を行う。 Bundler は webpack (WebPackBundler) か rollup (RollupBundler) のいずれか、もしくは両方。 * 部分は webpack か rollup を指定する。

  • apply(project, NpmPackageManager, task bundle, task run, task stop)

Launcher に対して以下を行う。 Launcher は WebPackLauncher, KtorLauncher, KarmaLauncher の 3 つ。

  • apply(NpmPackageManager, project, task run, task stop)

sourceSets.main.output に compileKotlin2Js.kotlinOptions.outputFile を登録する)。 但し、compileKotlin2Js.kotlinOptions.outputFile が存在するときのみ。

以降では、NpmPackageManager、Bundler から 1 つ (WebPackBundler)、Launcher から 1 つ (WebPackLauncher) 、 関連しているタスクについての調査結果を書く。 (全て調べるのは大変なので)

nodejs-download: NodeJsDownloadTask

~/.gradle/nodejs ディレクトリに node.js をダウンロードする。

node 実行ファイルのパスを “$buildDir/nodePath.txt” に書き込む。

class NpmPackageManager

org.jetbrains.kotlin.gradle.frontend.npm.NpmPackageManager で行われている処理を書き記す。

apply

project.extensions に npm (型は NpmExtension) を追加する。

NpmExtension で指定されている設定項目は以下のとおり。

  • dependencies: MutableList
  • developmentDependencies: MutableList
  • versionReplacements: MutableList

設定するためのメソッドは以下のとおり。

  • dependency(name: String, version: String = “*”)
  • devDependency(name: String, version: String = “*”)
  • replaceVersion(name: String, version: String)

以下のいずれかの条件に該当する場合のみ、後に示す様々な処理を行う。

条件:

  • npm.dependencies が空ではない
  • npm.developmentDependencies が空ではない
  • “$projectDir/package.json.d” ディレクトリが存在する
  • requiredDependencies が空ではない
    • require メソッドで設定される

処理:

compile.dependencies に NpmDependenciesTask.results のディレクトリを追加する。 (AbstractFileCollection として追加されており、具体的なディレクトリは遅延評価されると思われる。)

project.tasks に以下のタスクを追加する。

  • npm-preunpack (型は UnpackGradleDependenciesTask)
    • タスクの以下の項目を設定する
      • dependenciesProvider = requiredDependencies (を返す関数)
  • npm-configure (group: NPM, 型は GeneratePackagesJsonTask)
    • タスクの以下の項目を設定する
      • dependenciesProvider = requiredDependencies (を返す関数)
      • packageJsonFile = “$buildDir/package.json
      • npmrcFile = “$buildDir/.npmrc”
  • npm-install (group: NPM, 型は NpmInstallTask)
    • タスクの以下の項目を設定する
      • packageJsonFile = “$buildDir/package.json
  • npm-index (型は NpmIndexTask)
  • npm-deps (型は NpmDependenciesTask)
  • npm (group: NPM)

タスクに依存関係を追加する。

nodejs-download > npm-configure
npm-preunpack > npm-configure
npm-configure > npm-install
npm-install > npm-index
npm-index > npm-deps
npm-deps > npm
npm > packages

install

以下のタスクのうち、state が EXECUTED, SKIPPED, UP-TO-DATE ではないタスクの execute() を呼ぶ。

  • UnpackGradleDependenciesTask (= npm-preunpack)
  • GeneratePackagesJsonTask (= npm-configure)
  • NpmInstallTask (= npm-install)
  • NpmIndexTask (= npm-index)
  • NpmDependenciesTask (= npm-deps)

npm-* タスク

npm-preunpack: UnpackGradleDependenciesTask

npm.dependencies、npm.developmentDependencies、dependenciesProvider の返す Dependency、 いずれかの依存関係が存在するときのみ以下を行う。

compile configuration (build.gradle の compile 指定の依存設定) のうち、 Kotlin Java Script Library を “$buildDir/node_modules_imported” ディレクトリに展開する。 展開する際に package.json を生成する。

resultNames (型は MutableList) に展開したライブラリの名前、バージョン、URLが保存される。 resultNames の内容は “$buildDir/.unpack.txt” に書き込まれる。

npm-configure: GeneratePackagesJsonTask

npm.dependencies、npm.developmentDependencies、dependenciesProvider の返す Dependency、 いずれかの依存関係が存在するときのみ以下を行う。

“$buildDir/package.json” ファイルを生成する。package.json の内容は以下のとおり。

  • name
    • moduleNames が 1 つならば、その名前
      • moduleNames は compileKotlin2Js.kotlinOptions.outputFile のファイル名 (拡張子除く) のリスト
    • 1 つでなければ、project.name
    • いずれの名前も使えなければ、noname
  • version
    • project.version
  • main
    • moduleNames が 1 つならば、その名前
    • 1 つでなければ、null
  • dependencies
    • 以下の 3 つを合わせた内容
      • npm.dependencies
      • resultNames (npm-preunpack タスクの結果) から生成された Dependency (RuntimeScope に設定)
      • dependenciesProvider が生成した Dependency (RuntimeScope のみ)
  • devDependencies
    • 以下の 2 つを合わせた内容
      • npm.developmentDependencies
      • dependenciesProvider が生成した Dependency (DevelopmentScope のみ)
    • “$projectDir/package.json.d” ディレクトリ配下の JSON ファイルの内容がマージされる
      • マージ順序はファイル名により決定される (拡張子前の数字で整列、数字がなければ 0 扱い)

“$buildDir/.npmrc” ファイルを生成する。.npmrc の内容は以下のとおり。

progress=false
# cache-min=3600

sourceSets.main.output.resourcesDir が設定されている場合には、 “${sourceSets.main.output.resourcesDir}/package.json” にも書き込む。

npm-install: NpmInstallTask

npm install を実行する。

使用する npm コマンドは以下の順番で検索される。

  1. org.kotlin.frontend.node.dir プロパティで指定したパス
  2. “$buildDir/nodePath.txt” (NodeJsDownloadTask.nodePathTextFile) のパス
  3. 環境変数 PATH で指定したパス

npm-index: NpmIndexTask

“$buildDir/.modules.with.kotlin.txt” と “$buildDir/.modules.with.types.txt” を生成する。

“$buildDir/node_modules” のファイルを検査して、モジュールの絶対パスを .modules.with.*.txt ファイルに保存する。 kotlin と types それぞれで、以下に該当するファイルを探し、[module] の絶対パスを保存する。

  • kotlin
    • [module].jar
    • [module]/META-INF/*.kotlin_module
    • [module]/*.meta.js (但し、// Kotlin.kotlin_module_metadata で始まる)
  • types
    • [module]/typings.json
    • [module]/package.json (但し、typings をキーに含む)

npm-deps: NpmDependenciesTask

“$buildDir/.modules.with.kotlin.txt” に記載されているディレクトリを results に読み込む。 results は NpmPackageManager の apply メソッドで使用される。

object WebPackBundler

apply(project, NpmPackageManager, task bundle, task run, task stop) で行われている処理を書き記す。

NpmPackageManager.require により以下のモジュール依存関係を追加する。

  • webpack (version: *, scope: DevelopmentScope)
  • webpack-dev-server (version: *, scope: DevelopmentScope)
  • source-map-loader (version: *, scope: DevelopmentScope)
    • kotlinFrontend.sourceMaps が true の場合のみ

project.tasks に以下のタスクを追加する。

  • webpack-config (型は GenerateWebPackConfigTask)
  • webpack-helper (型は GenerateWebpackHelperTask)
  • webpack-bundle (groupd: webpack, 型は WebPackBundleTask)

タスクに依存関係を追加する。

webpack-config > webpack-helper
webpack-config > webpack-bundle
webpack-helper > webpack-bundle
RelativizeSourceMapTask > webpack-bundle
webpack-bundle > bundle

webpack-* タスク

webpack-config: GenerateWebPackConfigTask

kotlinFrontend extension で WebPackExtension が設定されており、 WebPackExtension.webpackConfigFile が設定されていない場合のみ以降の処理を行う。

以下に示す “$buildDir/webpack.config.js” を生成する。 <Part A|B|C> は後述する内容が挿入される。

'use strict';

var webpack = require('webpack');

var config = <Part A>;

var defined = <Part B>;
config.plugins.push(new webpack.DefinePlugin(defined));

module.exports = config;

<Part C>

Part A には以下の内容を持つ JSON が挿入される。

  • context: compileKotlin2Js.kotlinOptions.outputFile の親ディレクト
  • entry: 以下の内容を持つ JSON
    • WebPackExtension.bundleName: “./${compileKotlin2Js.kotlinOptions.outputFile (拡張子を除く)}”
  • output: 以下の内容を持つ JSON
    • path: “$buildDir/bundle”
    • filename: “[name].bundle.js”
    • chunkFilename: “[id].bundle.js”
    • publicPath: WebPackExtension.publicPath
  • module: { rules: [ ] }
  • resolve: { modules: <resolveRoots> }
    • <resolveRoots> は以下の内容を持つリスト
      • buildDir をカレントとしたときの、compileKotlin2Js.kotlinOptions.outputFile の親ディレクトリへの相対パス
      • “node_modules”
      • “$buildDir/node_modules”
      • compile configuration (build.gradle の compile 指定の依存設定) で指定された ProjectDependency から以下を抽出
        • buildDir をカレントとしたときの、compileKotlin2Js.outputFile の親ディレクトリへの相対パス
        • 但し、現状のコードだとタスク名を !contains(“test”) でフィルタしているため、compileTestKotlin2Js.outputFile の親ディレクトへの相対パスも含まれる
  • plugins: [ ]

Part B には kotlinFrontend extension の define(name: String, value: Any?) メソッドで設定された内容を持つ JSON が挿入される。

Part C には “$projectDir/webpack.config.d” 配下のファイルの内容が以下の形式で挿入される。 it はファイルを指す。 挿入順序はファイル名により決定される (拡張子前の数字で整列、数字がなければ 0 扱い)

// from file ${it.path}
<ファイルの内容>
WebPackExtension の設定

kotlinFrontend extension には、bundle メソッドと allBundles メソッドが存在する。 いずれも BundleConfig を設定するメソッドである。

  • bundle(id: String, configure: BundleConfig.() -> Unit)
    • id: Bundler の種別 (webpack, rollup)
    • id で指定した Bundler の設定を行う
  • allBundles(block: BundleConfig.() -> Unit)
    • 全ての Bundler の共通設定を行う

BundleConfig のインスタンスは WebPackBundler クラスで生成される WebPackExtension インスタンスである。 WebPackExtension クラスは BundleConfig インターフェースを実装している。

kotlinFrontend extension にて webpackBundle { ... } を実行すると bundle("webpack") { ... } が実行される。

WebPackExtension で設定できる項目は以下のとおり。

  • bundleName: String = project.name
  • sourceMapEnabled: Boolean = project.kotlinFrontend.sourceMaps
  • contentPath: File? = null
  • publicPath: String = “/”
  • port: Int = 8088
  • proxyUrl: String = “”
  • stats: String = “errors-only”
  • webpackConfigFile: Any? = null

webpack-helper: GenerateWebpackHelperTask

WebPackExtension.webpackConfigFile が設定されている場合のみ以降の処理を行う。

“$buildDir/WebPackHelper.js” を生成する。内容は以下のとおり。

module.exports = <JSON>

<JSON> には以下の内容を持つ JSON が挿入される。

  • port: WebPackExtension.port
  • shutDownPath: “/webpack/dev/server/shutdown” (WebPackRunTask.ShutDownPath)
  • webPackConfig: WebPackExtension.webpackConfigFile
  • contentPath: WebPackExtension.contentPath
  • proxyUrl: WebPackExtension.proxyUrl (空ならば null に設定される)
  • publicPath: WebPackExtension.publicPath
  • sourceMap: kotlinFrontend.sourceMaps && WebPackExtension.sourceMapEnabled
  • stats: WebPackExtension.stats
  • bundlePath: compileKotlin2Js.kotlinOptions.outputFile
  • moduleName: compileKotlin2Js.kotlinOptions.outputFile のファイル名 (拡張子を除く)

webpack-bundle: WebPackBundleTask

node $buildDir/node_modules/webpack/bin/webpack.js --config $buildDir/webpack.config.js を実行する。

WebPackExtension.webpackConfigFile が設定されている場合は、"$buildDir/webpack.config.js" は設定したファイルが指定される。

object WebPackLauncher

apply(NpmPackageManager, project, task run, task stop) で行われている処理を書き記す。

project.tasks に以下のタスクを追加する。

  • webpack-run (group: webpack, 型は WebPackRunTask)
    • タスクの以下の項目を設定する
      • start = true
  • webpack-stop (group: webpack, 型は WebPackRunTask)
    • タスクの以下の項目を設定する
      • start = false

タスクに依存関係を追加する。

webpack-config > webpack-run
RelativizeSourceMapTask > webpack-run
webpack-run > run
webpack-stop > stop

webpack-run: WebPackRunTask

node $buildDir/webpack-dev-server-run.js を実行する。 実行する際に、起動するサーバーの情報を JSON 形式で “$buildDir/.run-webpack-dev-server.txt” に保存する。

“$buildDir/webpack-dev-server-run.js” はサーバー起動前に生成される。 kotlin-frontend-plugin 中の resource である kotlin/webpack/webpack-dev-server-launcher.js をテンプレートとして、 テンプレート中の require('$RunConfig$') を GenerateWebpackHelperTask で生成している JSON に置き換える。 但し、JSON 中の内容において、WebPackExtension.webpackConfigFile が設定されていない場合は “$buildDir/webpack.config.js” が使用される。

サーバーの起動に成功すると “$buildDir/.run-webpack-dev-server.txt” が生成される。 以下の内容を持つ JSON ファイルである。

  • port: WebPackExtension.port
  • exts: kotlinFrontend extension の define メソッドで設定した内容
  • hashes: 以下の内容を持つ JSON
    • webpack.config.js: ファイルの SHA1
    • webpack-dev-server-run.js: ファイルの SHA1

webpack-stop: WebPackRunTask

http://localhost:$port/webpack/dev/server/shutdown にリクエストを送信する。 $port は “$buildDir/.run-webpack-dev-server.txt” が保持している port。

ts2kt を Gradle で実行する

概要

TypeScript の型定義を Kotlin ファイルに変換する Node.js スクリプトである ts2kt を Gradle で実行できるようにします。

2017/8/31 現在、ts2kt は使える状態ではありません。 生成された Kotlin ファイルでは多数のエラーが発生します。

目次

確認環境

  • IntelliJ IDEA Community 2017.2
  • Kotlin 1.1.4-2
  • Gradle 3.5
    • Groovy 2.4.10

参考情報

解説

例として Vue.js の型情報 (*.d.ts) を Kotlin ファイル (*.kt) に変換する。

手順としては以下の通り。

  1. node をプロジェクト用にダウンロード
  2. npm を使い ts2kt と vue をインストール
  3. ts2kt で *.d.ts*.kt に変換

まずは build.gradle を以下の通りに記述する。

buildscript {
    ext.gradle_node_version = '1.2.0'

    repositories {
        jcenter()
        maven {
            // gradle-node-plugin
            url "https://plugins.gradle.org/m2/"
        }
    }

    dependencies {
        classpath "com.moowork.gradle:gradle-node-plugin:$gradle_node_version"
    }
}

apply plugin: 'com.moowork.node'

node {
    // node をプロジェクト用にダウンロード
    download = true
}

task ts2kt(type: NodeTask) {
    String nodeDir = "${project.projectDir}/node_modules"
    script = file("$nodeDir/ts2kt/ts2kt.js")

    String destDir = 'src/main/kotlin/org/vuejs'
    file(destDir).mkdir()

    args = ['-d', destDir] + file("$nodeDir/vue/types").listFiles().collect { it.absolutePath }
}

npm の設定として pakage.json を以下の通りに記述する。

{
  "name": "Vue.kt",
  "version": "2.4.2",
  "description": "Vue.kt that is Vue.js in Kotlin",
  "devDependencies": {
    "ts2kt": "0.0.14",
    "vue": "2.4.2"
  }
}

設定が完了したら以下のコマンドを実行する。

$ ./gradlew npmInstall ts2kt

src/main/kotlin/org/vuejs ディレクトリに Kotlin ファイルが生成される。

例えば vue.kt の一部。

external interface `T$0` {
    @nativeInvoke
    operator fun invoke(): VNode
    @nativeInvoke
    operator fun invoke(tag: String, children: VNodeChildren): VNode
    @nativeInvoke
    operator fun invoke(tag: String, data: VNodeData? = definedExternally /* null */, children: VNodeChildren? = definedExternally /* null */): VNode
    @nativeInvoke
    operator fun invoke(tag: Component, children: VNodeChildren): VNode
    @nativeInvoke
    operator fun invoke(tag: Component, data: VNodeData? = definedExternally /* null */, children: VNodeChildren? = definedExternally /* null */): VNode
    @nativeInvoke
    operator fun invoke(tag: AsyncComponent, children: VNodeChildren): VNode
    @nativeInvoke
    operator fun invoke(tag: AsyncComponent, data: VNodeData? = definedExternally /* null */, children: VNodeChildren? = definedExternally /* null */): VNode
}
... <snip> ...
    open fun <T> `$watch`(expOrFn: (this: Vue /* this */) -> T, callback: WatchHandler<Vue /* this */, T>, options: WatchOptions? = definedExternally /* null */): () -> Unit = definedExternally
... <snip> ...

元は vue.d.ts のこの部分。

export type CreateElement = {
  // empty node
  (): VNode;

  // element or component name
  (tag: string, children: VNodeChildren): VNode;
  (tag: string, data?: VNodeData, children?: VNodeChildren): VNode;

  // component constructor or options
  (tag: Component, children: VNodeChildren): VNode;
  (tag: Component, data?: VNodeData, children?: VNodeChildren): VNode;

  // async component
  (tag: AsyncComponent, children: VNodeChildren): VNode;
  (tag: AsyncComponent, data?: VNodeData, children?: VNodeChildren): VNode;
}
... <snip> ...
  $watch<T>(
    expOrFn: (this: this) => T,
    callback: WatchHandler<this, T>,
    options?: WatchOptions
  ): (() => void);
... <snip> ...

生成された Kotlin ファイルにはいくつか問題がある。

  1. T$0 という名前が同一パッケージ内で複数定義されることになり名前が衝突する。
  2. @nativeInvoke は Deprecated
  3. VNodeChildren, Component, AsyncComponent などの型が定義できていない
    • Union Type に対応できていないため型を定義できない
  4. パラメータ名に this (Kotlin のキーワード) が使われている

Union Type に関する議論はされている様子。

discuss.kotlinlang.org

既存の JavaScript ライブラリを使うのであれば ts2kt による型情報を補足が欲しいところ。

Kotlin/JS TIPS - this の扱い

概要

JavaScript の関数中の this は関数を保持しているオブジェクトによって決定されます。 この振る舞いが Kotlin/JS を使う上での問題になります。 this を扱ういくつかの方法を説明します。 最適な方法とは言えないので、より良い方法があれば教えてください。

ソースコードは前回の記事の続きになっています。 詳しくは前回の記事を参照してください。

nosix.hatenablog.com

目次

確認環境

  • IntelliJ IDEA Community 2017.2
  • Kotlin 1.1.3-2
  • Gradle 3.5
    • Groovy 1.4.10

参考情報

解説

this の問題点

以下の JavaScript のコードは 算出プロパティとウォッチャ - Vue.js から引用したコードに少しだけ手を加えたコードである。firstNamelastName に値を設定すると fullName が算出されるサンプルである。

var vm = new Vue({
  el: '#example',
  data: {
    firstName: 'Foo',
    lastName: 'Bar'
  },
  computed: {
    fullName: function () {
      return this.firstName + ' ' + this.lastName
    }
  }
})
vm.firstName = 'Taro'
vm.lastName = 'Yamada'

この処理を Kotlin/JS で書いてみる。SampleClient.kt を以下に変更する。

@JsNonModule
@JsModule("vue")
external open class Vue(option: VueOption)

external interface VueOption : Json {
    var el: Any
    var data: Json
    var computed: Json
}

fun <T : Json> Json(): T = js("({})")
fun <T : Json> Json(init: T.() -> Unit): T = Json<T>().apply(init)

fun Vue(init: VueOption.() -> Unit): Vue = Vue(Json<VueOption>().apply(init))

external interface Model : Json {
    var firstName: String
    var lastName: String
}

fun run() {
    val vm: dynamic = Vue {
        el = "#example"
        data = Json<Model> {
            firstName = "Foo"
            lastName = "Bar"
        }
        computed = Json {
            // this は Json クラスのインスタンス
            this["fullName"] = {
                // this は Vue クラスのインスタンス
                this as Model // firstName, lastName を参照したいのでキャスト
                "${this.firstName} ${this.lastName}"
            }
        }
    }
    vm.firstName = "Taro"
    vm.lastName = "Yamada"
}

sample-client/index.html には以下を記述して、ブラウザで開いてみる。

<div id="example">{{ fullName }}</div>

ブラウザに表示される内容は undefined undefined である。 残念ながら上記のコードではやりたいことは行えない。 ${this.firstName} ${this.lastName}this に問題がある。

JavaScript と Kotlin での this の違い

先の例では、JavaScript の関数中の this は関数を保持しているオブジェクトを示している。 実行時に決定されるので、this は Vue クラスのインスタンスを示すことになる。

一方 Kotlin の例では、Kotlin のラムダ中の this はそのラムダの外側のスコープの this になっている。 つまり、Json クラスのインスタンスを示すことになる。

レシーバ付き関数リテラル

実行時に this を決定したいのであれば、レシーバ付き関数リテラルを使用する。 SampleClient.kt を以下に変更する。

fun run() {
    val vm: dynamic = Vue {
        el = "#example"
        data = Json<Model> {
            firstName = "Foo"
            lastName = "Bar"
        }
        computed = Json {
            // this は Json クラスのインスタンス
            this["fullName"] = fun Model.(): String {
                // this は実行時に決定される
                return "${this.firstName} ${this.lastName}"
            }
        }
    }
    vm.firstName = "Taro"
    vm.lastName = "Yamada"
}

external との併用における問題点

external interface を使うことで、firstName =, lastName = の様な簡潔な記述を実現している。 fullName も同様に簡潔な記述にするために以下のコードを追加したくなる。

external interface Computed : Json {
    var fullName: Model.() -> String
}

残念ながらコンパイルエラーとなる。 external 宣言中ではレシーバ付き関数型は禁止されているとエラーメッセージが表示される。 (禁止している理由を知っている方がいたら教えてください。)

external との併用

レシーバ付き関数リテラルを使えば this の扱いを動的に変更できるが、 external 宣言と併せて使うことができない。 external 宣言を使う場合にはレシーバなしの関数リテラルにする必要がある。 例えば、以下のとおり。

external interface Computed : Json {
    var fullName: () -> String
}

そうすると最初の問題に戻ってしまう。 回避するための苦肉の策として js("this") を使う方法がある。 コード生成時に JavaScriptthis をそのまま出力させる。 以下がその例である。

inline fun <T> thisAs(): T = js("this")

fun run() {
    val vm: dynamic = Vue {
        el = "#example"
        data = Json<Model> {
            firstName = "Foo"
            lastName = "Bar"
        }
        computed = Json<Computed> {
            // this は Json クラスのインスタンス
            fullName = {
                val self = thisAs<Model>()
                // self は実行時に決定される
                "${self.firstName} ${self.lastName}"
            }
        }
    }
    vm.firstName = "Taro"
    vm.lastName = "Yamada"
}

inline を付けないと thisAs 関数が生成され、thisAs 関数を保持しているオブジェクトが存在しないために thisAs 関数の結果は undefined になる。

ソースコード

Release 2017-08-01-164107 · nosix/kotlin-js · GitHub

Kotlin/JS TIPS - JavaScript ライブラリへの型情報の付与

概要

JavaScript ライブラリを使用する際に、型情報を付与する方法について説明します。 前回の記事で JavaScript ライブラリの使用方法を説明しましたが、 静的型付けによる恩恵を得ずに JavaScript の柔軟性を重視した使用方法としていました。 型情報を付与することによって、以下の恩恵を得られる様にしていきます。

ライブラリ開発者の手間を増やして、ライブラリ利用者の手間を減らしていきます。

前回の記事:

nosix.hatenablog.com

目次

確認環境

  • IntelliJ IDEA Community 2017.2
  • Kotlin 1.1.3-2
  • Gradle 3.5
    • Groovy 1.4.10

参考情報

解説

json 関数と Json インターフェース

JavaScript では {} によってオブジェクト生成を行う。 Kotlin/JS では json 関数によってオブジェクトを生成できる。 生成されたオブジェクトは Json インターフェース を実装したオブジェクトであり、 [] による操作を行える。 以下は SampleClient.kt の一部である。

fun run() {
    val option = json()
    option["el"] = "#example"
    option["data"] = json("message" to "Hello World")
}

生成される JavaScript は以下の様になっている。

function run() {
    var option = json([]);
    option['el'] = '#example';
    option['data'] = json([to('message', 'Hello World')]);
}

json 関数のソースコード (Kotlin) は以下になっており、js("({})") でオブジェクトを生成していることがわかる。

public fun json(vararg pairs: Pair<String, Any?>): Json {
    val res: dynamic = js("({})")
    for ((name, value) in pairs) {
        res[name] = value
    }
    return res
}

また、Json インターフェースのソースコード (Kotlin) は以下になっている。

public external interface Json {
    operator fun get(propertyName: String): Any?
    operator fun set(propertyName: String, value: Any?): Unit
}

external を使うことで JavaScript のコードをそのまま使用することができる。 get/set を定義することで [] による参照/代入を可能にしている。

型情報の付与

json 関数の仕組みを応用して以下のとおりに記述することで、Json コンストラクタに見せかけることができる。

fun Json(): Json = js("({})")

fun run() {
    val option = Json()
    option["el"] = "#example"
}

さらに、external を使うことで JavaScript のコードをそのまま使用できる性質を利用して、 [] を使わずにオブジェクトに値を設定できる様にする。 例えば、以下の様に。

fun <T : Json> Json(): T = js("({})")

external interface VueOption : Json {
    var el: Any
    var data: Json
}

external interface Model : Json {
    var message: String
}

fun run() {
    val option = Json<VueOption>()
    option.el = "#example"
    option.data = Json<Model>().apply {
        message = "Hello World"
    }
    option["computed"] = Json() // VueOption は Json インターフェースを実装しているので [] を使える
}

生成された JavaScript は以下のとおり。 external を使用した部分は簡潔なコードとなっている。 コード上に VueOptionModel インターフェースは現れない。

  function Json() {
    return {};
  }

  function run() {
    var option = Json();
    option.el = '#example';
    var $receiver = Json();
    $receiver.message = 'Hello World';
    option.data = $receiver;
    option['computed'] = Json();
  }

さらに、初期化処理を行う関数を引数 (init) にとる Json 関数を追加することで、 ()apply を省略できる。

fun <T : Json> Json(init: T.() -> Unit): T = Json<T>().apply(init)

fun run() {
    val option = Json<VueOption> {
        el = "#example"
        data = Json<Model> {
            message = "Hello World"
        }
        this["computed"] = Json()
    }

external interface を使うことで型情報を付与し、 補完機能の恩恵を得えつつ、 誤った型の値を代入する危険性を減らすことができる。

Vue.js に適用する

さらに、Vue 関数を SampleClient.kt に追加すると、Vue の初期化が簡潔になる。

@JsNonModule
@JsModule("vue")
external class Vue(option: VueOption)

fun <T : Json> Json(): T = js("({})")
fun <T : Json> Json(init: T.() -> Unit): T = Json<T>().apply(init)

external interface VueOption : Json {
    var el: Any
    var data: Json
}

external interface Model : Json {
    var message: String
}

fun Vue(init: VueOption.() -> Unit): Vue = Vue(Json<VueOption>().apply(init))

fun run() {
    val vm: dynamic = Vue {
        el = "#example"
        data = Json<Model> {
            message = "Hello World"
        }
    }
    vm.message = "Hello Kotlin World"
}

前回の記事と比べると随分と簡潔になったと思う。 しかし、検討すべき課題は残っている。 補完機能の恩恵は受けられるようになったが、型安全という面では十分とは言えない。 上記の例で言えば、elString もしくは HTMLElement であるため、Any にしている。 また、vm.message を参照するために dynamic にしている。 JavaScript の動的な側面を扱う方法については検討が必要である。

ソースコード

Release 2017-08-01-143728 · nosix/kotlin-js · GitHub