Spring WebFlux で Spring Security の認証と認可を使う

概要

Spring Security を使う場合、Spring WebFlux と Spring MVC では仕組みが異なっています。このため、Spring MVC と併せて使う場合のカスタマイズ方法と Spring WebFlux と併せて使う場合のカスタマイズ方法も異なります。Spring Security を Spring WebFlux と併せて使う場合のカスタマイズ方法を少しだけ調べました。なお、コードは Kotlin で書かれています。

目次

確認環境

  • Spring Boot 2.0.3
  • Kotlin 1.2.50

参考情報

解説

仕組み

まずは Spring Security 5.0 解剖速報 の内容を見るのが良いと思います。Form-based Authentication (認証) に関わるクラスとインターフェースが整理されています。また、設定の方法も記載されており、それを参考にして書いた設定例が以下です。

@Configuration
@EnableWebFluxSecurity
class ExampleSecurityConfig {

    @Bean
    fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()

    /**
     * インメモリでユーザー情報を保持する場合の設定
     */
    @Bean
    fun userDetailsService(passwordEncoder: PasswordEncoder): ReactiveUserDetailsService {
        return MapReactiveUserDetailsService(
                User.withUsername("user")
                        .passwordEncoder(passwordEncoder::encode)
                        .password("password")
                        .roles("USER")
                        .build(),
                User.withUsername("admin")
                        .passwordEncoder(passwordEncoder::encode)
                        .password("admin")
                        .roles("USER", "ADMIN")
                        .build()
        )
    }

    @Bean
    fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        http.authorizeExchange()
                .pathMatchers("/login").permitAll()
                .pathMatchers("/admin").hasRole("ADMIN")
                .anyExchange().authenticated()
        http.formLogin()
                .loginPage("/login")
        return http.build()
    }

withDefaultPasswordEncoder メソッドは非推奨になっているため、withUsername に変えています。withDefaultPasswordEncoder メソッドの実装を見ると PasswordEncoderFactories.createDelegatingPasswordEncoder() によってエンコーダーを取得しているので、同様にしています。

springSecurityFilterChain では WebFilter の登録を行っています。ServerHttpSecurity クラスの build メソッドを実行すると、AuthorizeExchangeSpec, FormLoginSpec, LogoutSpec, CsrfSpec などのクラスの configure メソッドが実行され、各 configure メソッドでは ServerHttpSecurity クラスの addFilterAt メソッドを実行して WebFilter を登録します。これらは、ServerHttpSecurity クラスのコード で確認できます。

例えば、authorizeExchange メソッドを実行すると、AuthorizeExchangeSpec オブジェクトが生成されます。AuthorizeExchangeSpec クラスの pathMatchers, anyExchange メソッドで設定を変更し、ServerHttpSecurity クラスの build メソッドを実行すると AuthorizeExchangeSpec クラスの configure メソッドが実行され、設定に応じた AuthorizationWebFilter が登録されます。

登録される WebFilter を整理すると以下の通りになっています。(一部です。詳しくは ServerHttpSecurity クラスのコード参照。)

No. 登録される WebFilter WebFilter を登録する Spec クラス Spec オブジェクトを生成するタイミング
1 SecurityContextServerWebExchange WebFilter - -
2 ExceptionTranslationWebFilter - -
3 AuthorizationWebFilter AuthorizeExchangeSpec authorizeExchange メソッド実行時
4 CsrfWebFilter CsrfSpec ServerHttpSecurity 初期化時
5 ServerRequestCacheWebFilter RequestCacheSpec ServerHttpSecurity 初期化時
6 AuthenticationWebFilter HttpBasicSpec httpBasic メソッド実行時
7 AuthenticationWebFilter FormLoginSpec formLogin メソッド実行時
8 HttpHeaderWriterWebFilter HeaderSpec ServerHttpSecurity 初期化時
9 LogoutWebFilter LogoutSpec logout メソッド実行時

(1) SecurityContextServerWebExchangeWebFilter は ServerHttpSecurity クラスの build メソッド実行時に登録されます。(2) ExceptionTranslationWebFilter は AuthorizeExchangeSpec オブジェクトが生成されている場合のみ登録されます。(3-9) これら以外の WebFilter は、各 Spec クラスの configure メソッドで登録されます。

上で示したとおり、各 Spec クラスのオブジェクトは、ServerHttpSecurity が初期化される時に生成される場合とメソッドを呼び出した時に生成される場合に分かれます。各 Spec オブジェクトは、各 Spec の disable メソッドで無効にできます。無効にした場合には、基本的には WebFilter が登録されません。但し、ServerRequestCacheWebFilter だけは例外で、無効にしても WebFilter 自体は登録されます。(機能としては無効になります。)AuthorizationWebFilter, AuthenticationWebFilter, LogoutWebFilter はデフォルトで無効であり、WebFilter は登録されません。CsrfWebFilter, ServerRequestCacheWebFilter, HttpHeaderWriterWebFilter はデフォルトで有効となるため、無効にしたい場合には以下のとおりにします。

http.csrf().disable()
http.headers().disable()
http.requestCache().disable()

各 Spec を使わずに、ServerHttpSecurity に直接 WebFilter を登録することもできます。

http.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)

2 つめの引数は SecurityWebFiltersOrder、つまり WebFilter の適用順です。指定できる値としては以下が用意されています。各 WebFilter との対応も併せて示します。(WebFilter は一部のみ。)

SecurityWebFiltersOrder WebFilter
FIRST
HTTP_HEADERS_WRITER HttpHeaderWriterWebFilter
CSRF CsrfWebFilter
REACTOR_CONTEXT
HTTP_BASIC AuthenticationWebFilter
FORM_LOGIN AuthenticationWebFilter
AUTHENTICATION
LOGIN_PAGE_GENERATING
LOGOUT_PAGE_GENERATING
SECURITY_CONTEXT_SERVER_WEB_EXCHANGE SecurityContextServerWebExchange WebFilter
SERVER_REQUEST_CACHE ServerRequestCacheWebFilter
LOGOUT LogoutWebFilter
EXCEPTION_TRANSLATION ExceptionTranslationWebFilter
AUTHORIZATION AuthorizationWebFilter
LAST

カスタマイズ

様々な WebFilter を ServerHttpSecurity に登録することで認証や認可が行われています。カスタマイズするためには、これらの WebFilter の登録を有効/無効にしたり、WebFilter の挙動を変更すればよさそうです。実際に、カスタマイズを行っているサンプルでは、そのようにしています。

WebFilter の登録を無効にするには、既に述べたとおりに disable メソッドを使うか、Spec オブジェクトを生成するメソッドを実行しないようにします。有効にするには、その反対を行います。WebFilter の挙動を変更する方法としては、Spec の設定を変える方法と WebFilter を新規作成して登録する方法が考えられます。

Spec の設定を変える

例として、FormLoginSpec の設定を変更します。

@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    http.formLogin()
            .loginPage("/login")
            .requiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login"))
            .authenticationEntryPoint(EntryPoint())
            .authenticationFailureHandler(FailureHandler())
            .authenticationSuccessHandler(SuccessHandler())
    return http.build()
}

formLogin メソッドは、FormLoginSpec オブジェクトを生成します。そして、生成されたオブジェクトの requiresAuthenticationMatcher, authenticationEntryPoint などのメソッドを使えば設定を変更できます。FormLoginSpec での基本的な設定項目は以下の 6 つです。デフォルトは、formLogin メソッドだけを実行した場合の設定です。

  • requiresAuthenticationMatcher
    • この Matcher でマッチした場合に認証処理を行う
    • デフォルトでは、/login に POST メソッドでリクエストした場合に認証処理を行う
  • authenticationEntryPoint
    • 認可で拒否された場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationEntryPoint("/login") であり、/login にリダイレクトする
    • AuthorizeExchangeSpec オブジェクトが生成されている場合、ここで設定した処理が ExceptionTranslationWebFilter に設定される
  • authenticationFailureHandler
    • 認証に失敗した場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationFailureHandler("/login?error") であり、/login にリダイレクトする
  • authenticationSuccessHandler
    • 認証に成功した場合に実行される処理
    • デフォルトでは、RedirectServerAuthenticationSuccessHandler("/") であり、/ にリダイレクトする
  • authenticationManager
  • securityContextRepository

loginPage メソッドは、これらの項目の一部をまとめて設定します。変数 loginPage は loginPage メソッドの引数です。

  • requiresAuthenticationMatcher
    • ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, loginPage)
  • authenticationEntryPoint
    • RedirectServerAuthenticationEntryPoint(loginPage)
  • authenticationFailureHandler
    • RedirectServerAuthenticationFailureHandler(loginPage + "?error")

authenticationEntryPoint が設定されていない場合には、変数 loginPage の値を "/login" として loginPage メソッドが実行されます。そのため、デフォルトが "/login" になっています。

AuthenticationWebFilter は、これら 6 項目の設定に基づいて生成されます。しかし、AuthenticationWebFilter のコードを確認すると、上記とは一部が異なる以下の 6 項目を設定します。

No. 設定項目 設定方法
1 AuthenticationManager ServerHttpSecurity
コンストラク
2 SecurityContextRepository ServerHttpSecurity ::
securityContextRepository
3 RequiresAuthenticationMatcher FormLoginSpec ::
requiresAuthenticationMatcher
4 ServerAuthenticationFailureHandler FormLoginSpec ::
authenticationFailureHandler
5 ServerAuthenticationSuccessHandler FormLoginSpec ::
authenticationSuccessHandler
6 AuthenticationConverter 設定不可

authenticationEntryPoint がなく、代わりに AuthenticationConverter があります。authenticationEntryPoint は ExceptionTranslationWebFilter に設定されるため、AuthenticationWebFilter の設定項目には含まれません。

(3-5) RequiresAuthenticationMatcher, ServerAuthenticationFailureHandler, ServerAuthenticationSuccessHandler は formLogin メソッドを使って設定した内容が適用されます。(1-2) また、AuthenticationManager, SecurityContextRepository は ServerHttpSecurity のコンストラクタやメソッドで設定可能です。(6) しかし、AuthenticationConverter は設定を変更できません。

AuthenticationConverter は認証に使用する username と password を抽出する処理を持つオブジェクトを設定します。formLogin の場合には、ServerFormLoginAuthenticationConverter オブジェクトが設定されており、FormData から username と password を抽出する処理になっています。AuthenticationConverter の設定を変えられないため、例えば、request body に JSON 形式で username と password を指定するといった事ができません。この例の方法を使いたい場合には、WebFilter を新規作成する必要があります。これは後ほど説明します。

WebFilter を新規作成して登録する

Spec の設定を変える方法では、AuthenticationConverter を変更できなかったり、各設定項目が複雑に絡み合ってカスタマイズが困難になるといった問題があります。この問題を解決するための一つの方法としては、自作 WebFilter を登録する方法があります。例えば、以下のとおりです。

@Configuration
@EnableWebFluxSecurity
class ExampleSecurityConfig {
    // ...

    private val loginPath = "/login"

    @Bean
    fun securityFilterChain(
            http: ServerHttpSecurity,
            authenticationManager: ReactiveAuthenticationManager,
            serverCodecConfigurer: ServerCodecConfigurer
    ): SecurityWebFilterChain {
        // ...

        // 認証(authentication)の設定
        val authenticationFilter = authenticationWebFilter(
                authenticationManager,
                serverCodecConfigurer,
                ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, loginPath)
        )
        http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)

        // 認可(authorization)で拒否した場合の応答
        http.exceptionHandling()
                .authenticationEntryPoint(EntryPoint())

        return http.build()
    }

    // @Bean は付けない (WebFilter が 2 重で登録されてしまう)
    fun authenticationWebFilter(
            authenticationManager: ReactiveAuthenticationManager,
            serverCodecConfigurer: ServerCodecConfigurer,
            loginPath: ServerWebExchangeMatcher
    ): WebFilter {
        return AuthenticationWebFilter(authenticationManager).apply {
            // 認証処理を行うリクエスト
            setRequiresAuthenticationMatcher(loginPath)
            // 認証処理における認証情報を抽出方法
            setAuthenticationConverter(JsonBodyAuthenticationConverter(serverCodecConfigurer.readers))
            // 認証成功/失敗時の処理
            setAuthenticationSuccessHandler(SuccessHandler())
            setAuthenticationFailureHandler(FailureHandler())
            // セキュリティコンテキストの保存方法
            setSecurityContextRepository(WebSessionServerSecurityContextRepository())
        }
    }

    // authenticationManager をカスタマイズしない場合には不要
    @Bean
    fun authenticationManager(
            userDetailsService: UserDetailsService,
            passwordEncoder: PasswordEncoder
    ): ReactiveAuthenticationManager {
        return UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService).apply {
            setPasswordEncoder(passwordEncoder) // 設定しない場合は DelegatingPasswordEncoder になる
        }
    }

    // ステータスコード FORBIDDEN、本文は空でレスポンスを返す
    inner class EntryPoint : ServerAuthenticationEntryPoint {
        override fun commence(exchange: ServerWebExchange, e: AuthenticationException): Mono<Void> {
            return Mono.fromRunnable {
                exchange.response.statusCode = HttpStatus.FORBIDDEN
            }
        }
    }

    // ステータスコード OK、本文は空でレスポンスを返す
    inner class SuccessHandler : ServerAuthenticationSuccessHandler {
        override fun onAuthenticationSuccess(
                webFilterExchange: WebFilterExchange,
                authentication: Authentication
        ): Mono<Void> = Mono.fromRunnable {
            webFilterExchange.exchange.response.statusCode = HttpStatus.OK
        }
    }

    // ステータスコード FORBIDDEN、本文は空でレスポンスを返す
    inner class FailureHandler : ServerAuthenticationFailureHandler {
        override fun onAuthenticationFailure(
                webFilterExchange: WebFilterExchange,
                exception: AuthenticationException
        ): Mono<Void> = Mono.fromRunnable {
            webFilterExchange.exchange.response.statusCode = HttpStatus.FORBIDDEN
        }
    }

    // 想定するリクエストの本文は { "mail_address": "user@example.com", "password": "123456" } といった JSON
    inner class JsonBodyAuthenticationConverter(
            val messageReaders: List<HttpMessageReader<*>>
    ) : Function<ServerWebExchange, Mono<Authentication>> {

        override fun apply(exchange: ServerWebExchange): Mono<Authentication> {
            return BodyExtractors.toMono(AuthenticationInfo::class.java)
                    .extract(exchange.request, object : BodyExtractor.Context {
                        override fun messageReaders(): List<HttpMessageReader<*>> = messageReaders
                        override fun serverResponse(): Optional<ServerHttpResponse> = Optional.of(exchange.response)
                        override fun hints(): Map<String, Any> = mapOf()
                    })
                    .map { it.toToken() }
        }
    }

    // 認証リクエスト本文の JSON
    data class AuthenticationInfo(
            @JsonProperty("mail_address")
            val mailAddress: String,
            val password: String
    ) {
        fun toToken() = UsernamePasswordAuthenticationToken(mailAddress, password)
    }

最も重要な部分は

http.addFilterAt(authenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)

です。この 1 文を実行するために、様々な処理を行っています。登録している AuthenticationWebFilter は "/login"

{ "mail_address": "user@example.com", "password": "123456" }

の様な JSON を POST メソッドで送信して認証します。認証に成功した場合はステータスコード OK を返し、失敗した場合は FORBIDDEN を返します。認可に失敗した場合は FORBIDDEN が返されます。

登録する AuthenticationWebFilter オブジェクトは authenticationManager メソッドで生成しています。authenticationManager メソッドに @Bean アノテーションを付けると SecurityFilterChain 中に 1 つ、後に 1 つ登録され、2 重登録されてしまいます。

authenticationManager メソッドは無くても構いませんが、カスタマイズする場合の例として記述してあります。passwordEncoder を Bean として設定している場合には注意が必要です。UserDetailsRepositoryReactiveAuthenticationManager に passwordEncoder を設定しておく必要があります。設定しない場合には、DelegatingPasswordEncoder が使われます。

また、AuthenticationWebFilter だけではなく、authenticationEntryPoint の設定をしておきます。authenticationEntryPoint には、認可に失敗した場合のレスポンスを設定しています。formLogin メソッドを使う場合には、AuthenticationWebFilter の登録と併せて行われていました。

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