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 解剖速報
- 概略、クラス構造、基本的な使い方
- GitHub - raphaelDL/spring-webflux-security-jwt: A JWT authorization and authentication implementation with Spring Reactive Webflux, Spring Boot 2 and Spring Security 5
- 認証と認可の仕組みをカスタマイズして JWT (JSON Web Token) を使うサンプル
- java - Spring Security WebFlux - body with Authentication - Stack Overflow
- 認証の仕組みをカスタマイズするサンプル
解説
仕組み
まずは 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
- 認証処理
- デフォルトでは、UserDetailsRepositoryReactiveAuthenticationManager
- securityContextRepository
- SecurityContext (Authentication を保持するオブジェクト) の保存/読み出し処理
- デフォルトでは、WebSessionServerSecurityContextRepository
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
参考情報
- オリジン間リソース共有 (CORS) - HTTP | MDN
- オリジン間リソース共有 (Cross-Origin Resource Sharing, CORS) について
- java - Enable CORS in Spring 5 Webflux? - Stack Overflow
- Spring WebFlux で CORS を有効にする方法
解説
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 が発生しました。
確認環境
参考情報
- ロケール - ArchWiki
- LC_CTYPE
- 23.2. locale — 国際化サービス — Python 3.5.4 ドキュメント
- Python 3の各種エンコーディングについて - Qiita
解説
以下のプログラム (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 Pi に SSH でログインしてから実行してもエラーは発生しません。
Terminal によって違うのは、環境変数に違いがあると思ったので比較すると、言語設定に違いが見られます。 上から順に、IntelliJ IDEA の Terminal、 macOS の Terminal、 IntelliJ IDEA の Terminal で Raspberri Pi に SSH、 macOS の Terminal で Raspberry Pi に SSH。
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-8
は Raspberry Pi Zero 環境には存在していません。
ロケールの指定に問題があり UnicodeEncodeError が発生しています。
解決策の 1 つとしては、IntelliJ IDEA の Terminal で LC_CTYPE を unset する方法があります。
intellij$ unset LC_CTYPE
もしくは、適切な LC_CTYPE に設定すればよいでしょう。
kotlin-frontend-plugin のソースコードを読んで要約してみた
概要
以下の記事の続きです。以下の記事では、ソースコードの内容をクラス単位で整理しました。 クラス単位では全体像が見えづらいため、図とリストを使って全体を要約します。
目次
確認環境
- Kotlin 1.1.4
- kotlin-frontend-plugin 0.0.21
解説
タスクの依存関係
タスクの 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 コマンドのパスとして使われる
- PATH
- プロパティ
- org.kotlin.frontend.node.dir
- node コマンドのパスとして使われる
- org.kotlin.frontend.node.dir
- project
- sourceSets
- main.output.resourcesDir
- 設定している場合には、"$buildDir/package.json" と同じファイルが resourcesDir に作成される
- main.output.resourcesDir
- dependencies
- compile Kotlin/JS library
- “$buildDir/node_modules_imported” に展開される
- compile project
- “$buildDir/webpack.config.js” の resolve に使われる
- compile Kotlin/JS library
- compileKotlin2Js
- 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 に記録される
- sourceMaps: Boolean = false
- 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) で設定する
- dependencies: MutableList
- 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 に使われる
- bundleName: String = project.name
- “$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 でパスを設定
- プロパティ org.kotlin.frontend.node.dir でパスを設定
- package.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
解説
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
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
- moduleNames が 1 つならば、その名前
- version
- project.version
- main
- moduleNames が 1 つならば、その名前
- 1 つでなければ、null
- dependencies
- 以下の 3 つを合わせた内容
- npm.dependencies
- resultNames (npm-preunpack タスクの結果) から生成された Dependency (RuntimeScope に設定)
- dependenciesProvider が生成した Dependency (RuntimeScope のみ)
- 以下の 3 つを合わせた内容
- devDependencies
- 以下の 2 つを合わせた内容
- npm.developmentDependencies
- dependenciesProvider が生成した Dependency (DevelopmentScope のみ)
- 以下の 2 つを合わせた内容
- 他
“$buildDir/.npmrc” ファイルを生成する。.npmrc の内容は以下のとおり。
progress=false # cache-min=3600
sourceSets.main.output.resourcesDir が設定されている場合には、 “${sourceSets.main.output.resourcesDir}/package.json” にも書き込む。
npm-install: NpmInstallTask
npm install
を実行する。
使用する npm コマンドは以下の順番で検索される。
- org.kotlin.frontend.node.dir プロパティで指定したパス
- “$buildDir/nodePath.txt” (NodeJsDownloadTask.nodePathTextFile) のパス
- 環境変数 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
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> }
- 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-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
参考情報
- gradle-node-plugin/docs at master · srs/gradle-node-plugin · GitHub
- gradle-node-plugin の文書
- GitHub - Kotlin/ts2kt: Converter of TypeScript definition files to Kotlin declarations (stubs)
- ts2kt の文書
解説
例として Vue.js の型情報 (*.d.ts
) を Kotlin ファイル (*.kt
) に変換する。
手順としては以下の通り。
- node をプロジェクト用にダウンロード
- npm を使い ts2kt と vue をインストール
- 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 ファイルにはいくつか問題がある。
T$0
という名前が同一パッケージ内で複数定義されることになり名前が衝突する。@nativeInvoke
は DeprecatedVNodeChildren
,Component
,AsyncComponent
などの型が定義できていない- Union Type に対応できていないため型を定義できない
- パラメータ名に
this
(Kotlin のキーワード) が使われている
Union Type に関する議論はされている様子。
既存の JavaScript ライブラリを使うのであれば ts2kt による型情報を補足が欲しいところ。
Kotlin/JS TIPS - this の扱い
概要
JavaScript の関数中の this は関数を保持しているオブジェクトによって決定されます。 この振る舞いが Kotlin/JS を使う上での問題になります。 this を扱ういくつかの方法を説明します。 最適な方法とは言えないので、より良い方法があれば教えてください。
ソースコードは前回の記事の続きになっています。 詳しくは前回の記事を参照してください。
目次
確認環境
- IntelliJ IDEA Community 2017.2
- Kotlin 1.1.3-2
- Gradle 3.5
- Groovy 1.4.10
参考情報
- 算出プロパティとウォッチャ - Vue.js
- Vue.js の算出プロパティ
解説
this の問題点
以下の JavaScript のコードは 算出プロパティとウォッチャ - Vue.js から引用したコードに少しだけ手を加えたコードである。firstName
と lastName
に値を設定すると 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")
を使う方法がある。
コード生成時に JavaScript の this
をそのまま出力させる。
以下がその例である。
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
になる。