サーバサイドKotlinをはじめよう。Ktor Tips集をまとめた。
この記事はCyberAgent Developers Advent Calendar 2018の22日目の記事です。
Ktor
KtorはKotlin純正のWebフレームワークです。APIはラムダ式を使った関数呼び出しでKotlin DSLによりルーティングを宣言的なコードで保守できます。KtorアプリケーションはTomcatなどのServlet 3.0またはスタンドアローンなJetty、Nettyなどのサーブレットコンテナでホスティングできます。KotlinConf 2018(2018/10)で1.0.0のベータが紹介され11月にはKtor 1.0.0の正式リリースを発表しました。2018/12時点では1.0.1が利用できます。
Ktor - asynchronous Web framework for Kotlin
モチベーション
これまで私はサーバサイド Kotlinを開発するフレームワークにはSpring Bootを選択してきましたがマイクロサービスを俯瞰してみると軽量なマイクロフレームワークを採用したいという気持ちがありました。例えばDBアクセスが必要ないマイクロサービスであればAPIルーティングをストレスなく開発できるフレームワークのほうが向いているかもしれません。
Spring BootもRouter Functional DSLやSpring Fuが登場するなどニーズにあった選択ができるようになってきています。しかしKotlin Nativeの登場によってiOS/Android/Serverのリソースが共有できる環境になると純正Kotlinを採用しているKtorを採用したほうが今後の潮流に順応できそうです。またマルチプラットフォームにKtorのライブラリが採用されているドキュメントなどが今後更に増えていくことを考慮するとKtorを採用することは理にかなっていると言えるでしょう。
このエントリではWebサーバのAPI開発のユースケースがKtorではどのように解決されているのかをまとめていきます。サーバサイド Kotlinをはじめようとする皆さんにとって、これから更に注目されるKtorを利用する手助けになれば幸いです。
ユースケースをまとめる
新しいフレームワークでWebサーバのAPI開発を行うときには次のようなユースケースがどのようにサポートされているか気になります。
- APIルーティング
- 環境変数マッピング
- インターセプター
- エラーハンドリング
- 認証処理(今回はJWT)
- DI
- APIテスト
Ktorではどのように実装するのでしょうか。順を追って見ていきましょう。
APIルーティング
簡単なEchoサーバを作っていきましょう。
fun main(args: Array<String>) { embeddedServer(Netty, 8008) { routing { get("echo") { call.apply { respond(request.queryParameters["q"] ?: "none") } } } }.start(wait = true) }
/echo?q=hello
にGETリクエストを送るとレスポンスにhello
と返すAPIが出来上がりました。
APIをモジュール化する
上記のコードではmain関数にroutingを宣言していて今後APIが増えていくことを考慮すると見通し良いコードに改善すべきです。
KtorではAppication
クラスに拡張関数を生やすことでAPIをモジュール化できます。
fun Application.echoModule() { routing { get("/echo") { call.apply { respond(request.queryParameters["q"] ?: "none") } } } }
routing
もApplicationクラスの拡張関数のためechoModule
の拡張関数の中でroutingが宣言できます。
このechoModuleをresources/application.confに追加しましょう。
ktor { deployment { port = 8080 } application { modules = [ com.github.soushin.ktorsample.ApplicationKt.echoModule ] } }
application.confドリブンでKtorを動くように定義したのでmain関数は次のように変化します。
fun main(args: Array<String>) { embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true) }
このようにAPIをモジュール化することでマイクロサービス内のAPIルーティングをエンドクライアントごとに分けることができます。InternalModuleやExternalModuleなど内部向け、外部向けのAPIをモジュールで切り出すことでコードの保守性が高まります。
Routingを見通しよくする
echoModule()ではroutingのDSLを直接記述していましたがRouteクラスに拡張関数を生やすことでroutingをパスごとに切り出せます。
@Location("/echo") class Echo(val q: String? = null) fun Route.echo() { get<Echo> { call.apply { respond(request.queryParameters["q"] ?: "none") } } }
パスをget("/echo")
のようにStringで定義していましたが@Location
を使うことでタイプセーブにパスを定義できます。
echo()
拡張関数を次のようにechoModule()に定義します。
fun Application.echoModule() {
install(Locations)
routing {
echo()
}
}
タイプセーフにLocationsクラスを利用したことでKtorの機能をモジュールにインストールする必要があります。install(Locations)
でLocations機能をインストールしています。
Application Features
Ktorは多くの機能(ApplicationFeature)をラインナップしています。後述するユースケースではApplicationFeatureとともに紹介していきます。
https://ktor.io/servers/features/index.html
環境変数マッピング
コンテナでアプリケーションを動かす場合には開発環境や本番環境などのDB設定値を環境変数に入れてアプリケーション側から環境変数を参照することで環境を切り分けます。Ktorでは環境変数をアプリケーションでどのように参照するのかまとめていきます。
application.confに変数を定義する
app { github { baseUrl = "https://api.github.com" token = "token" token = ${?KTOR_APP_GITHUB_TOKEN} } }
上記のようにアプリケーションで参照する変数をapplication.confに定義しました。app.github.token
はデフォルト値としてtoken
が定義され環境変数のKTOR_APP_GITHUB_TOKEN
が参照できればその値で上書きされます。
Applicationクラスで環境変数を参照する
APIルーティングで作成したechoModuleはApplicationクラスの拡張関数なのでenviroment
プロパティのApplicationConfigを参照することができます。
fun Application.echoModule() { val token = environment.config.property("app.github.token").getString() }
上記のようにenvironmentを参照できますがenvironmentはApplicationクラスのプロパティなので次のような拡張プロパティを定義できます。
val Application.gitHubToken get() = environment.config.property("app.github.token").getString()
拡張プロパティを定義することで冗長なコードを防ぎコードの短縮化の恩恵も受けれました。
import com.github.soushin.ktorsample.config.Config.gitHubToken fun Application.echoModule() { val token = gitHubToken }
インターセプター
APIにリクエストが来て本処理をする前や本処理をした後などに処理を挟むインターセプターの実装方法を見ていきましょう。このエントリではリクエストログを出力することを目的にリクエスト内容に含まれる情報をインターセプターで取得してみます。
ApplicationのEchoモジュールにintercept
を追加しましょう。
fun Application.echoModule() { val logger = KotlinLogging.logger("RequestLogger") install(Locations) intercept(ApplicationCallPipeline.Monitoring) { call.attributes.put( loggerAttributeKey, RequestLogBuilder() .name(call.request.uri) .userAgent(call.request.userAgent()) .remoteAddr(call.request.header("X-Forwarded-For") ?: "-") ) try { proceed() } catch (e: Exception) { logger.error { call.logBuilder.userId(call.user?.id).build() } throw e } logger.info { call.logBuilder.success(true).userId(call.user?.id).build() } } routing { echo() } }
interceptの引数にはPipelinePhaseオブジェクトを渡します。ApplicationCallPipeline.Monitoring
はロギングやメトリクス向きのフェーズ定義です。
KtorのPipelineは適切な場所で機能を追加することができる拡張メカニズムです。KtorアプリケーションにはSetup, Monitoring, Feature, Call, Fallbackの5つの主要なフェーズが定義されています。
リクエスト本処理前のインターセプト処理
call.attributes.put
ではApplicationCallのattributeプロパティに自作のRequestLogBuilderをセットしています。ここまででリクエスト本処理の直前のインターセプト処理を挟んでいます。インターセプト処理はRequestLogBuilderにリクエストURIやユーザエージェントなどをセットアップしています。
loggerAttributeKey
は次のようにどこからでも参照できる形で変数として定義しています。
val loggerAttributeKey = AttributeKey<RequestLogBuilder>("loggerAttributeKey")
このloggerAttributeKeyをルーティングのほうで参照することでインターセプト処理でセットしたRequestLogBuilderを取得できるようにしています。
fun Route.echo() { get<Echo> { call.apply { attributes.getOrNull(loggerAttributeKey)?.elem("q" to request.queryParameters["q"]) respond(request.queryParameters["q"] ?: "none") } } }
echoルーティングでApplicationCallのattributeプロパティからRequestLogBuilderを取得してクエリストリングをセットしています。
call.attributes.getOrNull〜
は冗長なコードになりそうなのでApplicationCallに拡張プロパティを生やしましょう。
val ApplicationCall.logBuilder get() = this.attributes[loggerAttributeKey]
fun Route.echo() { get<Echo> { call.apply { logBuilder.elem("q" to request.queryParameters["q"]) // 拡張プロパティ logBuilderを参照する respond(request.queryParameters["q"] ?: "none") } } }
リクエスト本処理後のインターセプト処理
次にリクエスト本処理後のインターセプト処理を見ていきましょう。
fun Application.echoModule() { // -- 省略 -- intercept(ApplicationCallPipeline.Monitoring) { // -- 省略 -- try { proceed() } catch (e: Exception) { logger.error { call.logBuilder.userId(call.user?.id).build() } throw e } logger.info { call.logBuilder.success(true).userId(call.user?.id).build() } } // -- 省略 -- }
proceed()
がリクエストの本処理になります。proceedをtry-catchで囲みエラーが発生したらErrorレベルでログを出力してExceptionをスローしています。エラーにならなければInfoレベルでログを出力しています。
エラーハンドリング
アプリケーション開発ではアーキテクチャに沿ってレイヤーを用意することが一般的です。各レイヤーでエラーが発生した時にはその場で例外クラスをスローしてアプリケーションがよしなにクライアントにエラーレスポンスを返すことができればコードの見通しがよくなります。
次のコードは極端な例外クラスをスローしているルーティングコードです。
@Location("/errors") class Errors() { @Location("/404") class NotFound @Location("/400") class BadRequest @Location("/500") class InternalServerError } fun Route.error() { get<Errors.NotFound> { throw NotFoundException("not found") } get<Errors.BadRequest> { throw BadRequestException("bad request") } get<Errors.InternalServerError> { throw InternalServerErrorException("internal server error") } }
/errors/404
へGETリクエストを送るとアプリケーションはNotFoundExceptionの例外クラスをスローします。このような例外クラスをアプリケーションの各レイヤーでスローして例外クラスに応じたエラーレスポンスを返す処理を集約させることができます。
StatusPagesをつかう
errorModuleにStatusPages
の機能をインストールします。exceptionの型パラメーターに例外クラスを渡してラムダで例外クラスを処理しています。ラムダ内ではApplicationCallクラスを参照できるのでレスポンスを組み立てることができます。
fun Application.errorModule() { install(Locations) install(ContentNegotiation) { jackson { configure(SerializationFeature.INDENT_OUTPUT, true) } } install(StatusPages) { exception<SystemException> { cause -> call.response.status(cause.status) call.respond(cause.response()) } } routing { error() } }
SystemExceptionクラスは次のようにNotFoundExceptionクラスの基底クラスとしています。
abstract class SystemException(message: String, ex: Exception?) : RuntimeException(message, ex) { abstract val status: HttpStatusCode fun response() = HttpError(code = status.value, message = message ?: "error") } class NotFoundException : SystemException { constructor(message: String) : super(message, null) override val status: HttpStatusCode = HttpStatusCode.NotFound }
SystemExceptionクラスを基底クラスにすることで例外クラスの分岐が1つだけになりエラーハンドリングがシンプルになりました。
認証処理
KtorではAPIリクエストの認証処理を簡単に行えるApplicationFeatureが用意されています。今回は認証にJWTを使ったコードをまとめます。
JWTのトークンにはユーザIDが含まれている想定です。
Authenticationを使う
authModuleにAuthentication
します。Authentication.Configurationの拡張関数であるjwt
を引数に認証処理を定義しています。
fun Application.authModule() { install(Authentication) { jwt { verifier(JWT.require(Algorithm.HMAC256(jwtSecret)).build()) validate { credential -> User.toUser(JWTPrincipal(credential.payload)) } } } routing { auth() } }
verifier
でJWTのVerificationをビルドします。jwtSecret
はApplicationクラスの拡張プロパティで環境変数で定義したJWTのsecretを参照しています。
validate
は認証処理が成功した後で複合された認証情報から独自のUserクラスを生成しています。
data class User(val id: String): Principal { companion object { fun toUser(principal: JWTPrincipal) = User( principal.payload.getClaim("id")?.asString() ?: throw IllegalStateException("unauthorized") ) } }
独自のUserクラスにPrincipalのインターフェースをマークすることでJWTのvalidateを通してApplicationCall#principal()
にUserクラスがセットされます。
リクエストに認証処理を追加する
authModuleのroutingに定義したauthルーティングのコードです。
class Auth() { @Location("/who") class Who } fun Route.auth() { authenticate { get<Auth.Who> { call.respond("Success, userId=${call.user?.id}") } } }
authenticate
はRouteクラスの拡張関数です。authenticate { }
で括ることで認証処理が追加できます。
call.user
はApplicationCallの拡張プロパティで先述したprincipalにセットされたUserクラスを参照しています。
val ApplicationCall.user get() = this.authentication.principal<User>()
DI
DIにはKoinを使います。KoinではなくてもDIできますがKtorにはApplicationクラスにinstallKoin
の拡張関数が用意されているので導入が簡単にできます。
次のようなUseCaseクラスにDIする手順を確認していきましょう。
interface PullRequestRepository { fun getPullRequests(owner: String, repo: String, state: String, perPage: Int): List<PullRequest> }
PullRequestRepositoryインターフェースクラスを用意します。このPullRequestRepositoryを実装したクラスが次のPullRequestRepositoryImplクラスです。
class PullRequestRepositoryImpl(private val client: GitHubClient) : PullRequestRepository { override fun getPullRequests(owner: String, repo: String, state: String, perPage: Int) = client.get(GetPullRequests(owner, repo, state, perPage)) }
PullRequestRepositoryImplのコンストラクタにはGitHubClientのインスタンスが必要になります。GitHubClientは次のようなに定義しています。
class GitHubClient( override val baseUrl: String, override val token: String, override val client: FuelManager = FuelManager.instance ) : Client() { override fun error(response: Response) { when { response.isClientError -> throw BadRequestException("GitHubClient error, ${response.responseMessage}") else -> throw InternalServerErrorException("GitHubClient error, ${response.responseMessage}") } } }
PullRequestRepositoryがGitHubClientに依存しています。この依存関係をKoinで定義しましょう。
val usecaseModule = module { // client single { GitHubClient(getProperty("github_base_url"), getProperty("github_auth_token")) } // repository single<PullRequestRepository> { PullRequestRepositoryImpl(get()) } }
KoinはDI定義をmodule毎に分割できます。moduleに名前をつけて依存解決をする時に名前を参照できます。今回のエントリでは1つのmoduleを定義しています。
single
で単一インスタンスを保証するsingletonを定義できます。getProperty
はKoinの機能でmodule内のプロパティを一意に保ちます。依存注入時にプロパティに変数を定義します。そして最後にget()
です。get()はmodule内の依存関係を判断して型推論してくれます。
DiModuleをつくる
ここまではKoinの利用方法を見てきました。ここからはKtorに定義したDI Moduleを使うコードを見ていきましょう。
次のようにApplicationクラスの拡張関数としてdiModule
を作ります。
fun Application.diModule() { val usecaseModule = module { // client single { GitHubClient(getProperty("github_base_url"), getProperty("github_auth_token")) } // repository single<RepoRepository> { RepoRepositoryImpl(get()) } single<PullRequestRepository> { PullRequestRepositoryImpl(get()) } // useCase single<RepositoryUseCase> { RepositoryUseCaseImpl(get(), get()) } } installKoin( listOf(usecaseModule), extraProperties = mapOf( "github_base_url" to gitHubBaseUrl, "github_auth_token" to gitHubToken ) ) }
先程のコードから少し依存関係が増えましたが新しく利用している機能はありません。
installKoin
がKtorで利用できる拡張関数でKoinの利用に必要なstartKoin()
を抽象化しています。installKoinの第一引数には定義したモジュールを渡します。名前付き引数のextraProperties
でgetPropertyで参照する変数を定義しています。
なぜモジュールで切り出すのか?
DI定義をdiModuleに切り出している理由は次の章で紹介するKtorのテストに関連します。モックを用いてテストするためにDI定義をモジュールで切り出してテスト時にはモック化したインスタンスで依存性の注入を行います。
モジュールで切り出したのでapplication.confに追加します。
ktor { deployment { port = 8080 } application { modules = [ com.github.soushin.ktorsample.ApplicationKt.diModule, com.github.soushin.ktorsample.ApplicationKt.mainModule ] } }
mainModule
は次のセクションで説明します。
Injectする
最後に定義した依存関係を利用するコードを紹介します。application.confのmodulesに追加したmainModuleは次のようになります。
fun Application.mainModule() { val usecase by inject<RepositoryUseCase>() routing { repository(usecase) } }
diModuleで定義したsingle<RepositoryUseCase> { RepositoryUseCaseImpl(get(), get()) }
のsingletonがinject<RepositoryUseCase>()
によって参照できます。
APIテスト
APIテストにはTestApplicationEngine
を利用します。テストコード内でTestApplicationEngineのインスタンスを生成して拡張関数のhandleRequestを呼び出しレスポンスを検証します。実際のコードをまとめていきます。
Echoサーバをテストする
「APIルーティング」のところで実装したEchoサーバをテストします。
JUnitを利用したテストコードです。
class EchoTest { private lateinit var engine: TestApplicationEngine @Before fun before() { engine = TestApplicationEngine().apply { start(wait = false) application.echoModule() } } @Test fun testEcho() { with(engine) { handleRequest(HttpMethod.Get, "/echo?q=hello").response.apply { assertEquals("hello", content) assertEquals(HttpStatusCode.OK, status()) } } } }
beforeでTestApplicationEngineのインスタンスを生成しengine変数に格納しています。apply内で定義済みのEchoモジュールであるapplication.echoModule()
を呼び出しています。これによりテストコード内のTestApplicationEngineにEchoモジュールが有効となります。
testEcho() は/echo/q=hello
のエンドポイントをテストしているコードです。with(engine)
を用いることでTestApplicationEngineをレシーバとしてメソッドを呼び出すことができます。拡張関数のhandleRequestを呼び出しレスポンスを検証しています。
KtorのAPIテストの大枠はテストコード内でTestApplicationEngineの変数を定義してTestApplicationEngineのインスタンス生成時にAPIモジュールを呼び出すことでテストを行いたいAPIを有効にしたengineを用いてエンドポイントの検証を行います。
DIが必要なAPIのテスト
先述のようなEchoサーバはシンプルなAPIですが一般的なAPIはDIを用いてビジネスロジックは別のクラスで行いルーティング層のコードはビジネスロジックのクラスを呼び出すような形になります。このようなDIが必要なAPIのテストはどのように行うのかまとめていきます。
「DI」のところで実装したコードをテスト対象にします。また外部APIをコールすることになりますモックが必要です。モックにはmockkを利用します。
「DI」のところではdiModuleを切り出した理由がここにあります。次のコードのようにDIが絡むAPIモジュールにはテスト用のDIモジュールをテストコード内で定義します。そして先述したとおりTestApplicationEngineのインスタンス生成時にテスト用のdiModuleを呼び出します。コードは次のようになります。
class RepositoryTest { private lateinit var engine: TestApplicationEngine // ① fun Application.diModuleForTest() { val gitHubClientForRepository = mockk<Client>().apply { every { executeRequest(any()).statusCode } returns 200 every { executeRequest(any()).data } returns REPOSITORIES_RESPONSE.toByteArray() }.let { FuelManager().apply { client = it } // ② }.let { GitHubClient(GITHUB_BASE_URL, GITHUB_TOKEN, it) } val gitHubClientForPullRequest = mockk<Client>().apply { every { executeRequest(any()).statusCode } returns 200 every { executeRequest(any()).data } returns PULL_REQUESTS_RESPONSE.toByteArray() }.let { FuelManager().apply { client = it } // ② }.let { GitHubClient(GITHUB_BASE_URL, GITHUB_TOKEN, it) } // ③ val usecaseModule = module { // useCase single<RepositoryUseCase> { RepositoryUseCaseImpl( RepoRepositoryImpl(gitHubClientForRepository), PullRequestRepositoryImpl(gitHubClientForPullRequest) ) } } // ④ installKoin( listOf(usecaseModule), extraProperties = mapOf( "github_base_url" to gitHubBaseUrl, "github_auth_token" to gitHubToken ) ) } @Before fun before() { engine = TestApplicationEngine().apply { // ⑤ (environment.config as MapApplicationConfig).apply { // Set here the properties put("app.github.baseUrl", GITHUB_BASE_URL) put("app.github.token", GITHUB_TOKEN) } start(wait = false) application.diModuleForTest() // ⑥ application.mainModule() } } // -- 省略 -- }
- ①でテストコード専用のDIモジュールの拡張関数の
diModuleForTest()
を定義しています。 - ②でHTTPクライアントのリクエストをモック化したインスタンスを生成しています。
- ③でKoin moduleの定義を行い①で生成したHTTPクライアントを引数に渡しています。
- ④でstartKoin()`を抽象化したinstallKoinを呼び出しています。
- ⑤で必要なapplication変数を設定しています。
- ⑥でdiModuleForTest()を呼び出しテストコードのengine変数(TestApplicationEngine)にテスト用のDIモジュールを有効にします。
上記のコードによりHTTPクライアントのリクエストがモック化されたRepositoryクラスをDIしたmainModule()がテストコード内で有効になりました。
class RepositoryTest { // -- 省略 -- @Test fun testRepository() { with(engine) { handleRequest(HttpMethod.Get, "/repository").response.apply { assertEquals(HttpStatusCode.OK, status()) } } } // -- 省略 -- }
with(engine)
を用いてmainModule()のエンドポイントを検証しています。
まとめ
- WebサーバのAPIにおけるユースケースごとにKtorではどのように実装するのかをまとめました。
- Ktorはマイクロフレームワークであり似たようなフレームワークは引き続き多く登場するでしょう。しかしKotlin/Nativeの観点からKtorが優位性を出すことを期待しています。
コード
このエントリで紹介したコードはこちらのレポジトリから参照できます。