こんにちは!高橋 です。
Goole I/O 2017 の KeynoteでKotlinがAndroid開発の公式言語としてサポートされることが発表されてから2年が経ちました。
当時は、JetBrains社の開発した言語というイメージが強く、まだJavaでAndroidの開発をしている所が多かったと思います。
そして2019年、ついにKotlinはAndroid開発において推奨言語になりました。🎉
ちょうどその頃、私はJVM上で動作する他のJava系統の開発言語を採用しているプロジェクトから離れたので、次のプロジェクトで使用する技術選定をしているところでした。
検討した結果、社内ではKotlinを一部使っているプロジェクトがあったと聞いていたことや、保守性の高さや、コンパイルの時間が早いといった理由から採用することにしました。
初めてKotlinを触るので、開発のしやすさは正直半信半疑だったのですが、実際の所は3ヶ月くらいでKotlin+Ktorでスムーズに開発ができるようになりました。
この記事では、その時学習した内容をぎゅっと一ヶ月分に絞ってまとめたものです。 Kotlinの初学者のため、間違いなどあるとおもいますのではてなブックマークなどでご指摘いただけると幸いです。
前提
この記事では、サーバサイドのプロジェクトにKotlinを導入しようと検討している方を対象とした記事となっています。 そのため、Androidの話は出てこないのでご了承ください🙏
サーバサイドの開発では、Webアプリケーションフレームワークとして Ktor(ケイターと読みます) と、 Postgresのアクセスに exposedを採用しました。
Ktorは、Ktor自体がKotlinで書かれているので、KotlinのCoroutinesがとても使いやすく設計されています。
また、IDEとして IntelliJ IDEA
、環境は Mac を使用しています。
環境構築: プロジェクトの新規作成
Kotlinでサーバサイド開発を始めるひな形としてKtor Project Generatorがありますが、IntelliJ IDEAを使用している場合は、IDEからひな形を作成することができます。
IntelliJ IDEA を起動し、メニューから File -> New -> Project
を選択し、Ktorのひな形を作ることができます。
ここから、一緒に使用したいライブラリと合わせて環境構築を行っていきましょう。
Server
と Client
の項目がありますが、それぞれ役割が異なります。
- Server: HTTP等のリクエストを受け取る場合に使用されるライブラリ
- Client: HTTP等のリクエストを送る場合に使用されるライブラリ
認証にJWTが使えたり、JSONの処理などもGSONかJacksonを選べます。ここでは、全てインストールせず最低限必要になりそうな、 ライブラリのみをインストールしていくことにします。
Server
- HTML DSL
- CSS DSL
- Static Content
- Locations
- Sessions
- Compression
- DataConversion
- Rooting
- CallLogging
- Status Pages
- Shutdown URL
- Authentication JWT
- Authentication
- GSON
- ContentNegotiation
Client
- HttpClient Engine
- CIO HttpClient Engine
- Json serialization for HttpClient
- User agent feature
- Logging feature
他に Projectは Gradle
、Usingは Netty
、Ktor Version
は、 1.2.2
を選択しました。
Project SDKは 1.8.0_144
を選択しています。
選択が終わったら、Next
を押して次に進みます。
環境構築: GroupIdとArtifactId
ここでは、GroupId
と ArtifactId
と Version
が求められます。
GroupIdは、プロジェクトのチーム名などにし、会社の場合は会社のドメインを逆にしたものにするとわかりやすいです。
ArtifactIdは、jarファイルの名前になるのでプロジェクト名を英数時にしたものを入力するとよさそうです。 ArtifactIdにはバージョンは含めなくて大丈夫です。
またここで、Add Swagger Model
を押すと、SwaggerのSchemasを読み取ってAPIを自動作成してくれるようです。
環境構築: プロジェクト名
Project name
と Project location
を入力します。ここでは example
としてみました。 Finish
を押すとプロジェクトが作成されます。
環境構築: Gradleの設定
最初に Import Module from Gradle
が表示されるので、 Automatically import this project on changes in build script files
にチェックをいれて OK
を押します。
Gradleのファイルが変更されると、自動的にモジュールを再読み込みしてくれます。
プロジェクトが作成されてすぐにGradleのダウンロードが開始されますが、うまく読み込まれない場合は手動でロードしてください。
IDEの画面右から、Gradle
を選択し、画像の左上の Reimport All Projects
を押して再読み込みします。
build.gradleの中身
build.gradleの中身を見ると、いくつかのブロックが確認できます。
- buildscript
- sourceSets
- repositories
- dependencies
buildscript
は、Gradleの機能を拡張するためのライブラリを指定します。デフォルトで、dependencies
に "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
が入っていました。
こちらは、 apply plugin: 'kotlin'
の1行でプラグインを有効にして、kotlinのプロジェクトをGradleでコンパイルできるようにするものです。
sourceSets
では、プロジェクトのソースコードとテストコードがどのディレクトリに配置されているか指定します。デフォルトでは .kt
ファイルと .java
ファイル両方が混在できるようになっています。
repositories
は ライブラリを落としてくるリポジトリを指定します。デフォルトで指定されているものの他にも
google()
mavenCentral()
などが指定できます。
dependencies
はプロジェクトにインポートするライブラリを指定します。
Gradleの バージョン 3.4 で compile
と testCompile
は非推奨となっているようで、ここを下記のように書き換えます。
compile
-->implementation
testCompile
-->testImplementation
implementation
は、compile
と違い、依存関係を伝搬させないため、上流でインポートされているライブラリを気づかずに使用してしまうといったことを防ぐことができます。
もしも、従来のcompile
のような挙動が良い場合は代わりにapi
を使用します。基本的には、implementation
を使っていくと良いでしょう。
gradle内で使用されるプロパティ
build.gradleを見ていくと、$ktor_version
というプロパティがありました。
これはgradle.properties
ファイルで直接定義されています。
ktor_version=1.2.2 kotlin.code.style=official kotlin_version=1.3.40 logback_version=1.2.1
詳しいことは割愛しますが、Kotlinではバージョンの統合が進んでいてKotlin自体やKtorにおいても、バージョンが合わせられているので、こうして複数のライブラリのバージョンを一つのプロパティで統一できています。
単一のJarを作成する
依存関係のあるライブラリをすべて含めたjarファイルのことをFatJarと呼びます。
イクロサービスでjarを運用する場合などにおいては、FatJarに固めてコンテナイメージを作成するケースが増えているため、FatJarを作成できる環境を用意します。
buildscript { repositories { jcenter() } dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.github.jengelman.gradle.plugins:shadow:5.0.0' } ext.kotlin_version = '1.3.30' }
FatJarを作成できるようにするには classpath 'com.github.jengelman.gradle.plugins:shadow:5.0.0'
をbuildscriptに追加します。
Gradle Shadow というプラグインで、簡単にFatJarを作成できます。
次に、apply plugin: 'kotlin'
の下に、下記の行を追加します。
apply plugin: 'com.github.johnrengelman.shadow'
ここで、GradleのReimportが走ると思いますが、エラーが発生しました。
Build file '/Users/path/to/example/build.gradle' line: 19 A problem occurred evaluating root project 'example'. > Failed to apply plugin [class 'com.github.jengelman.gradle.plugins.shadow.ShadowBasePlugin'] > This version of Shadow supports Gradle 5.0+ only. Please upgrade.
Gradleのバージョンが古く、実行できないようです。
この場合はGradle自体のアップデートを行います。
ディレクトリの、gradle -> wrapper -> gradle-wrapper.properties
の distributionUrl
の行を
distributionUrl=https\://services.gradle.org/distributions/gradle-5.3.1-all.zip
に書き換えます。
改めて、Gradle をReimportすると、Gradleのダウンロードが開始されます。
CONFIGURE SUCCESSFUL in 1m 13s
無事に、GradleのReimportが完了しました🎉
環境構築: コンパイル
この状態で、 ./gradlew build
をしてみると、自動的に shadowJar
Taskが実行されます。
build/libs 配下に ****-all.jar
ファイルが作成されるので、こちらをjavaコマンドで実行することでKtorを起動することができます。
java -jar build/libs/example-0.0.1-all.jar
ログに Application started
が表示されたら、起動に成功しています🎉
2019-09-03 12:11:25.016 [main] INFO Application - Application started: io.ktor.application.Application@2eee3069
このjarファイルの中には、必要なライブラリがすべて含まれているので、javaが動くコンテナイメージに入れるだけですぐに動かすことができるので便利です。
サーバサイドKotlin開発ことはじめ
環境構築がおわったことで、いよいよここから開発に入っていきます。
アプリケーションの実行を、IDE上から行うには、Application.kt
の fun main
の左にあるボタンをクリックし、 Run 'com.example.Applicat...'
を選択することで実行することができます。
一度実行すると、ツールバーに Configuration
が作成されるのであとはここから選択すればいつでも実行できるようになります。
停止する場合は、赤い四角いボタンを押してください。
ですが、自分はテスト駆動で開発するのが好きなのであまりこの機能は使いませんでした。
ひな形には、すでにE2Eテストを実行する機能が用意されていて、test
ディレクトリに ApplicationTest.kt
が用意されているのでそのファイルの開いて、先ほどと同じように Run
ボタンを押してテストを実行します。
GradleでテストのConfigurationが作成されてテストが実行されました。
しかし、自分の環境では Test events were not received
と表示されてしまいました・・・。
そこで、GradleのテストからJUnit5を使ったテストに切り替えることにしました。
切り替えは、 IntelliJ IDEA から Preferences
を開き、 Gradle
で検索して BuildTools の Gradle を表示します。
こちらを、Gradleから IntelliJ IDEA
に変更します。
これで、JUnitでテストが実行され、実行ログも表示されるようになりました🎉
ためしに、テストを書き換えてみると、正しくエラーになってくれました。
実際に、起動させて http://localhost:8080/
にアクセスしてみると HELLO WORLD!
と表示されているとおもいます。
ここまでできたら、実際にAPIを作っていく作業に入ります。
配列を逆にした結果を返すAPIを作成する
KtorでAPIを作成し、URLに含まれたカンマ区切りの配列を逆にしてJSONで返すAPIを作ります。
まず、Application.kt
を開いて、 HELLO WORLD!
を探してください。
見つけることができたでしょうか? Ktorでは、このように { ... }
によって宣言的な階層構造になっており、 URLのルーティングをDSLで書けるようになっています。
Locationで配列を受け取る
Ktorには Location という受け取ったパラメータをClassで型定義されたインスタンスで受け取る機能があります。 これで配列をうけ取れるようにします。
こちらのコードを、ソースコード下部に書きます。
class ReverseList(val list: List<String>) @Location("/reverse/{list}") class ReverseListLocation(val list: ReverseList)
@Location
というのはデコレータで、通常のClassをLocationクラスとして使えるようにします。
そして、ルーティング側の HELLO WORLD!
の下に、
get<ReverseListLocation> {
call.respond(it.list.list.asReversed())
}
を書いて、実行し http://localhost:8080/reverse/1,2,3
にアクセスしてみます。
想定どおりなら、 [3,2,1]
と表示されるところですが・・・結果としては "[1,2,3]"
となり、うまく,
がパースされていないようでした。
このような場合、パースするには Data Conversion
を使って変換を行うと便利です。
すでに、install(DataConversion)
がソースコードに存在していると思いますが、こちらに変換処理を追加していきます。
install(DataConversion) { convert<ReverseList> { decode { values, _ -> ReverseList(values.first().split(",")) } } }
DataConversionは、ルーティングではなく、その内部で使われている値オブジェクトのClassに対してマッチします。
ここでは、ReverseList型はlist: List<String>
を持っているので、その値が values
に入っています。中身は、うまくパースされていない状態で、 ["1,2,3"]
というデータになっていて、これは,
がパースされずに最初の配列に全部含まれている状態になっているのです。
これにより、 .first()
によって最初の配列の値のStringを取り出し、.split(",")
で区切っています。 そして改めて ReverseList
を作成しています。
流れとしては、このようになっています。
["1,2,3"] ---> ["1", "2", "3"] ---> ["3", "2", "1"]
これでAPIを作成できました。🎉
テストを書く場合は、下記のようにします。
@Test fun testReverseList() { withTestApplication({ module(testing = true) }) { handleRequest(HttpMethod.Get, "/reverse/1,2,3").apply { assertEquals(HttpStatusCode.OK, response.status()) assertTrue { arrayOf( "3", "2", "1" ) contentEquals Gson().fromJson(response.content!!, Array<String>::class.java) } } } }
HTMLをDSLで書いてみる
Ktorを起動して、 http://localhost:8080/html-dsl
にアクセスしてみてください。
HTMLという文字と、1〜10までの連番が描画されました。 こちらは、 get("/html-dsl")
のルーティングに定義されていて、HTMLではなく、KotlinのDSLでHTMLを再現できるようになっています。
body { h1 { +"HTML" } ul { for (n in 1..10) { li { +"$n" } } } }
HTMLのタグの一つ一つがkotlinの関数になっており、引数として関数を持っています。Kotlinでは最後の引数が関数だと(...)
の外に出すことができるので、 このような書き方ができるようになっています。
これにより、KotlinによるDSL表現が可能になっています。 h1 や ul も関数です。 li関数はforによって繰り返され、1〜10までの数字が画面にリストとして出力されています。
属性(attributes)を追加する
DSLでタグを書く場合、各タグの第一引数はクラスリストになります。
属性はどこに書いていくのかというと、ブロックの中になります。
h1("title class-name") { attributes["style"] = "color: red;" +"HTML" }
attributesというMapがあるので、こちらに追加していくことで、HTMLタグの属性がつきます。
idを追加する
idも、attributes同様に、 id関数のsetterに対して代入することで、設定できます。
h1 { id = "header" +"HTML" }
このような汎用的に使われている属性に関しては前もってgetter/settgerが用意されています。
enableTheming, enableViewState, skinID, visible, accessKey, classes, contentEditable, contextMenu, dataFolderName, dataMsgId, dir, draggable, hidden, id, itemProp, lang, onAbort, onBlur, onCanPlay, onCanPlayThrough, onChange, onClick, onContextMenu, onDoubleClick, onDrag, onDragEnd, onDragEnter, onDragLeave, onDragOver, onDragStart, onDrop, onDurationChange, onEmptied, onEnded, onError, onFocus, onFocusIn, onFocusOut, onFormChange, onFormInput, onInput, onInvalid, onKeyDown, onKeyPress, onKeyUp, onLoad, onLoadedData, onLoadedMetaData, onLoadStart, onMouseDown, onMouseMove, onMouseOut, onMouseOver, onMouseUp, onMouseWheel, onPause, onPlay, onPlaying, onProgress, onRateChange, onReadyStateChange, onScroll, onSearch, onSeeked, onSeeking, onSelect, onShow, onStalled, onSubmit, onSuspend, onTimeUpdate, onTouchCancel, onTouchEnd, onTouchMove, onTouchStart, onVolumeChange, onWaiting, onWheel, role, runAt, spellCheck, style, subject, tabIndex, title,
テンプレート
DSLで複数ページを作り続けていくと、ヘッダーやフッターなど共通のコードが増えていきます。
こういった用途のためにテンプレート機能が用意されています。
get("/html-dsl") { call.respondHtmlTemplate(LayoutTemplate()) { body { div { +"Body" } } } }
こちらが宣言部で、LayoutTemplateのClassからbody部分のみを定義しています。
このClassの実装は下のようになっています。
class LayoutTemplate( val header: HeaderTemplate = HeaderTemplate("Title"), val footer: FooterTemplate = FooterTemplate() ) : Template<HTML> { val body = Placeholder<FlowContent>() override fun HTML.apply() { body { insert(header) {} insert(body) insert(footer) {} } } } class HeaderTemplate(val titleText: String) : Template<FlowContent> { override fun FlowContent.apply() { div { +"Header: ${titleText}" } } } class FooterTemplate() : Template<FlowContent> { override fun FlowContent.apply() { div { +"Footer" } } }
insert関数で、テンプレートを挿入しています。
insertでも {}
がある場合と無い場合があり {}
がある場合はすでに定義されているテンプレートを呼び出します。
無い場合は、このClass自体を呼び出した側で挿入されるプレースホルダーの扱いになります。 同じ構文ですが、テンプレートの挿入と、プレースホルダーの挿入によって挙動が異なります。
HeaderTemplate
では titleText
という引数を渡しているので引数の値をテンプレート内に展開することももちろんできます。
オリジナルのタグを定義する
HTMLであれば、単純に好きな名前のタグを作ればよいですが、Kotlinx.htmlでこれを行うには、Classと関数の定義が必要になります。
もっと良い定義の仕方があるかもしれませんが、下記コードによってオリジナルのHTMLタグを使用できるようになります。
@Suppress("unused") open class Header(initialAttributes : Map<String, String>, override val consumer : TagConsumer<*>) : HTMLTag("Header", consumer, initialAttributes, null, false, false), HtmlBlockTag @HtmlTagMarker fun FlowContent.Header(classes: String? = "", block: Header.() -> Unit = {}): Unit = Header(attributesMapOf("class", "$classes"), consumer).visit(block)
この例では、Header
というタグを作成しました。 FlowContent
は body { ... }
内で使用できる関数を定義する時に指定します。
特に使う機会はあまりないかもしれませんが、覚えておくと便利なこともあるかもしれません。
scriptタグを使う
HTML内でJavaScriptを実行するにはscriptタグを使いますが、Ktorでscriptタグを使うにはunsafeを指定する必要があります。
script { unsafe { +""" alert(1); """.trimIndent() } }
スクリプト内部は、普通の文字列になっています。
scriptタグとTemplateを組み合わせる
ここまでは、TemplateでKtorのDSLによってHTMLで出力する方法をやってきました。では、JavaScript内にHTMLを出力することはできるのでしょうか?
こちらに、先程のLayoutTemplateを少し書き換えたサンプルを載せてみます。
class LayoutTemplate( val header: HeaderTemplate = HeaderTemplate("Title"), val footer: FooterTemplate = FooterTemplate() ) : Template<HTML> { val body = Placeholder<FlowContent>() val reactDOM = Placeholder<FlowContent>() override fun HTML.apply() { head { script { attributes["crossorigin"] src = "https://unpkg.com/react@16/umd/react.development.js" } script { attributes["crossorigin"] src = "https://unpkg.com/react-dom@16/umd/react-dom.development.js" } script { attributes["crossorigin"] src = "https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.21.1/babel.min.js" } } body { insert(header) {} insert(body) script { type = "text/babel" unsafe { //language=JSX Harmony +""" const Header = props => { return ( <div> {props.children} </div> ) }; """.trimIndent() } unsafe { +""" class App extends React.Component { constructor(props) { super(props); this.state = { text: 'Hello World!' }; } render() { return ( <div> """.trimIndent() } insert(reactDOM) unsafe { +""" </div> ) } } ReactDOM.render(<App />,document.getElementById('app')); """.trimIndent() } } insert(footer) {} } } }
長いので、ポイントだけ説明すると、unsafeの間にinsertを使っています。
これによってHTMLをscriptの中にinsertすることができます。
処理速度は落ちますが、Babelを使用すると、ブラウザ上でも type = "text/babel"
のスクリプトが実行できるため、JSXの実行が可能になります。
これによって、Kotlin側で定義したオリジナルのタグHeader
を、フロント側でも定義し、使用することができるようになりました。
get("/html-dsl") { call.respondHtmlTemplate(LayoutTemplate()) { body { div { id = "app" +"Body" } } reactDOM { Header { +"Test" } } } }
ルーティング側では、bodyのプレースホルダーと、reactDOMのプレースホルダーを定義し、reactDOMで定義したHTMLをbody側の <div id="app" />
に書き込んでいます。
ちょっとやり過ぎではありますが、Kotlinの強力なDSL表現を最大限に活かすことができます。
スタイルシートを書いてみる
スタイルシートも、KtorではDSLで書けるようになっています。
get("/styles.css") { call.respondCss { body { backgroundColor = Color.red } p { fontSize = 2.em } rule("p.myclass") { color = Color.blue } } }
セレクタに対して、cssを指定する場合は、 rule関数を使います。
設定できる属性は下記のとおりです。
alignContent, alignItems, alignSelf, animation, background, backgroundAttachment, backgroundColor, backgroundImage, backgroundPosition, backgroundRepeat, backgroundSize, border, borderTop, borderRight, borderBottom, borderLeft, borderSpacing, borderRadius, borderTopLeftRadius, borderTopRightRadius, borderBottomLeftRadius, borderBottomRightRadius, borderStyle, borderTopStyle, borderRightStyle, borderBottomStyle, borderLeftStyle, borderWidth, borderTopWidth, borderRightWidth, borderBottomWidth, borderLeftWidth, borderColor, borderTopColor, borderRightColor, borderBottomColor, borderLeftColor, bottom, boxSizing, boxShadow clear, color, content, cursor, direction, display, filter, flexDirection, flexGrow, flexShrink, flexBasis, flexWrap, float, fontFamily, fontSize, fontWeight, fontStyle, height, hyphens, justifyContent, left, letterSpacing, lineHeight, listStyleType, margin, marginTop, marginRight, marginBottom, marginLeft, minWidth, maxWidth, minHeight, maxHeight, opacity, outline, overflow, overflowX, overflowY, overflowWrap, padding, paddingTop, paddingRight, paddingBottom, paddingLeft, pointerEvents, position, right, scrollBehavior, textAlign, textDecoration, textOverflow, textTransform, top, transform, transition, verticalAlign, visibility, whiteSpace, width, wordBreak, wordWrap, userSelect, tableLayout, borderCollapse,zIndex
スタイルタグを埋め込む
<style>
タグで直接HTMLコード内にスタイルシートを埋め込む場合は、ひな形にある styleCss
を使います
fun FlowOrMetaDataContent.styleCss(builder: CSSBuilder.() -> Unit) { style(type = ContentType.Text.CSS.toString()) { +CSSBuilder().apply(builder).toString() } }
FlowOrMetaDataContent
というのは FlowまたはMetaData内で使用できるタグとして定義するという意味になります。 このMetaDetaは body {...}
だけでなく head { ... }
でも使用できるタグということになります。
スタイル属性を設定する
divタグなどに直接style属性を指定することができるようにする関数も予めひな形が用意されています。
fun CommonAttributeGroupFacade.style(builder: CSSBuilder.() -> Unit) { this.style = CSSBuilder().apply(builder).toString().trim() }
CommonAttributeGroupFacade
はどのようなタグからも呼び出すことができる属性を定義することができます。
もともとstyle属性は定義されているのですが、少々使いづらいので使いやすくするためにfun CommonAttributeGroupFacade.style
が再定義されているようでした。
class HeaderTemplate(val titleText: String) : Template<FlowContent> { override fun FlowContent.apply() { div { style { backgroundColor = Color.white fontSize = 24.px color = Color.black } h1 { +"Header: ${titleText}" } } } }
静的ファイルへのアクセス
静的ファイルへのアクセスも簡単に行うことができます。
static("/static") { resources("static") default("resources/static/index.html") }
defaultを指定しないと、/static
へアクセスしても404が返るので、必要に応じて指定してあげると良いです。パスは環境によって変わるようなので要確認。
一連の通信・計算処理をカプセル化する ApplicationCall
Ktorの強力なDSL表現によって、宣言的にUIを構築することができることがわかってきました。 HTMLやCSS、そして属性の一つ一つにおいてもClassが定義されています。
どのようなタグが使えるのか、IDEの機能によってサジェストされるのも大きいです。
KtorではUIに限らず通信自体もDSLとして呼び出せるように設計されています。
suspend inline fun ApplicationCall.respondCss(builder: CSSBuilder.() -> Unit) { this.respondText(CSSBuilder().apply(builder).toString(), ContentType.Text.CSS) }
ひな形にあったこの respondCss
もそのひとつです。
スタイルシートは、CSSBuilderでDSLからスタイルシート文字列に変換する処理が必要で、その処理をまとめ、ルーティング側のDSLをシンプルにしています。
get("/styles.css") { call.respondCss { body { color = Color.white backgroundColor = Color.black } p { fontSize = 2.em } rule("p.myclass") { color = Color.blue } } }
DSL部に見づらくなってしまう処理を隠し、より宣言的に書けるようにする仕組みとも言えます。
処理を返すプログラムが複雑になってしまった場合は、ApplicationCall を使うと良いでしょう。
セッション
セッションは、ユーザーがサイトにアクセスしてから離脱するまでを1セッションと呼びますが、ここでいうセッションは、Cookieを利用してアクセスしてきたユーザーが 再び訪れた時に、そのユーザーの情報を格納していく領域を指します。難しそうなイメージがありますが、Cookieに情報を書き込んでいるだけです。
http://localhost:8080/session/increment
こちらにアクセスすると、アクセスした回数だけカウントしてくれます。これはサーバー数値が保存されているのではなく、Cookieに数字が書き込まれています。
Sessionは格納する情報を任意の型に変換して扱います。
data class MySession(val count: Int = 0)
実際にCookieに保存されるキーは、 install(Sessions)
にある MY_SESSION
です。
install(Sessions) { cookie<MySession>("MY_SESSION") { cookie.extensions["SameSite"] = "lax" } }
セッションは、call.sessions.set
によって新しく上書きされてセットされます。
下の例では、session.countの数値に1を加算して、その結果を表示しています。
get("/session/increment") { val session = call.sessions.get<MySession>() ?: MySession() call.sessions.set(session.copy(count = session.count + 1)) call.respondText("Counter is ${session.count}. Refresh to increment.") }
そのままCookieに書き込んでいるだけなので、ブラウザでユーザーはこの値を書き換えることができます。
使用する際には注意が必要です。
SessionにMACハッシュ値を追加する
先程のコードに transform(SessionTransportTransformerMessageAuthentication(secretHashKey, "HmacSHA256"))
を追加するとCookieに署名が追加されることで、Cookieの改変が困難になります。secretHashKeyが第三者にばれてしまうと改変されてしまうので、secretHashKeyは長めにしたほうがよさそうです。
また、secretHashKeyはhexである必要があります。
install(Sessions) { val secretHashKey = hex("0bf5f1d005c304aecab77370c9afb5bc01def1bdcb11b8049f5438fa5e326eaee1c3de5767705cdaf25844ae9fc337ecb52577817fb2b456f4957461") cookie<MySession>("MY_SESSION") { cookie.extensions["SameSite"] = "lax" transform(SessionTransportTransformerMessageAuthentication(secretHashKey, "HmacSHA256")) } }
認証された人だけがページを見れるようにする
Ktorの io.ktor:ktor-auth
には 指定されたルーティングのプロックに、認証された人しかアクセスできないようにする authenticate { ... }
があります。
この機能をつかって、認証された人だけが見れるページを作っていきます。
data class MySession( val count: Int = 0, val isLogged: Boolean = false ): Principal
isLogged
という変数を追加しました。(※あくまで例です。productionでは、session_id などにハッシュ値を指定しましょう🙆)
そして、アクセスするだけでログイン/ログアウトできるパスを用意します。 Principal を継承する必要があります。
get("/login") { val session = call.sessions.get<MySession>() ?: MySession() call.sessions.set(session.copy(isLogged = true)) call.respondText("login ok!", contentType = ContentType.Text.Plain) } get("/logout") { val session = call.sessions.get<MySession>() ?: MySession() call.sessions.set(session.copy(isLogged = false)) call.respondText("logout ok!", contentType = ContentType.Text.Plain) }
認証している人だけが見れるルーティングは下のように書きます。
install(Authentication) { session<MySession>("isLogged") { challenge { session -> call.respondText( "認証に失敗しました", contentType = ContentType.Text.Plain, status = HttpStatusCode.Unauthorized ) } validate { session -> if (session.isLogged) { session } else { null } } }
session には MySessionが入っています。この場合は、 isLogged
が true になっている場合は認証OKとしています。
/login → /auth, /logout → /auth で、結果が変わったことを確認できたと思います。 challenge がないと、 nullを返してもエラー画面にならないのでご注意ください。
認証でJWTを使う
JWTとはJSON Web Tokenの略で、Jsonフォーマットで表現されたトークンの仕様です。
KtorのAuthenticationには io.ktor:ktor-auth-jwt
を入れることで JWTを使うことができます。CookieではなくHeaderに Authorization: Bearer <JWT-TOKEN>
をいれて使用するため、フロントエンド側のライブラリでヘッダをセットする必要があります。
val jwtSecret = "secret" // シークレット val jwtIssuer = "example-issuer" // JWTの発行者情報(ドメインが良い) val jwtAudience = "example-audience" // JWTの対象利用者 val jwtRealm = "example-realm" // 領域 install(Authentication) { jwt("jwt") { realm = jwtRealm challenge { bearer, realm -> call.respondText( "認証に失敗しました", contentType = ContentType.Text.Plain, status = HttpStatusCode.Unauthorized ) } verifier( JWT.require(Algorithm.HMAC512(jwtSecret)) .withIssuer(jwtIssuer) .build() ) validate { credential -> if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null } } }
これにより、JWTによる認証を行うことができるようになります。実際のバリデーションは、 validate
で行われています。
続いて認証部分まわりのコードです。
このコードではJWT用のトークンを新規発行し返しています。 Cookieに直接返すわけではないので、別途トークンを管理する仕組みが必要です。
get("/login_jwt") { var exp = Date() val cal = Calendar.getInstance() cal.time = exp cal.add(Calendar.HOUR, 1) exp.time = cal.time.time cal.clear() val token = JWT.create() .withSubject("Authentication") .withIssuer(jwtIssuer) .withAudience(jwtAudience) .withExpiresAt(exp) .withClaim("hoge", "hoge") .withClaim("test", "test") .withArrayClaim("values", arrayOf(1L, 2L, 3L, 4L)) .withArrayClaim("values", arrayOf("1", "2", "3")) .sign(Algorithm.HMAC512(jwtSecret)) call.respondText("jwt token: ${token}", contentType = ContentType.Text.Plain) } authenticate("jwt") { get("/auth_jwt") { call.respondText("JWT見えてる", contentType = ContentType.Text.Plain) } }
直接、ルーティングに実装を書いていますが実際に使用する場合は、別途JWT用のClassを作成しまとめておくと運用しやすいと思います。
JWTにはトークンの期限を設定する機能が標準でついており、 withExpiresAt
によって設定することができます。 Claim(属性)には 他にも withHeader
によって値にMapが使用できます。
curl -v "http://localhost:8080/auth_jwt" -H "Authorization: Bearer <JWT-TOKEN>"
実際に、curlで叩いてみると、正常にJWTが見えていることが確認できます。
JWTトークンは、 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBdXRoZW50aWNhdGlvbiIsImF1ZCI6ImV4YW1wbGUtYXVkaWVuY2UiLCJ0ZXN0IjoidGVzdCIsImhvZ2UiOiJob2dlIiwidmFsdWVzIjpbIjEiLCIyIiwiMyJdLCJpc3MiOiJleGFtcGxlLWlzc3VlciIsImV4cCI6MTU2Nzc2NDc3OH0.1WmBGxVaNR0WU8WuOyJ0RWm27Cd6QCByA92XFPiYLs6SZtCe5p74OqYqY0AKNMgIBCMBJAVPF4anIIxt6EPpZA
このように長い形式ですが、Base64でエンコードされており、ドットで区切られ3つのブロックにわけることができるようになっています。
1: {"typ":"JWT","alg":"HS512"} 2: {"sub":"Authentication","aud":"example-audience","test":"test","hoge":"hoge","values":["1","2","3"],"iss":"example-issuer","exp":1567764778} 3: 1WmBGxVaNR0WU8WuOyJ0RWm27Cd6QCByA92XFPiYLs6SZtCe5p74OqYqY0AKNMgIBCMBJAVPF4anIIxt6EPpZA
1ではどのようなアルゴリズムが使われているかのアルゴリズム、2ではペイロードがJSON形式でそのまま入っており、3には電子署名が入っています。
1と2は、フロント側でもデコードすることができるので、ユーザーにも見せられないようなものは入れないようにするのが望ましいです。
JSONを返す
以前、APIを作る際に配列を返せるようにしていましたが、もちろん { ... }
といった形式のレスポンスを返すこともできます。
get("/json/gson") { call.respond(mapOf("hello" to "world")) }
mapOf形式で返すだけで、自動的にJSON形式に変換してくれます。こちらはgsonを内部的に使用されているため、変換されます。
data class
のClassも同様に、JSON形式で返してくれます。
//=> {"hello":"world"}
get("/json/session") { val session = call.sessions.get<MySession>() ?: MySession() call.respond(session) } //=> {"count":0,"isLogged":false}
pathやqueryから値を受け取る
基本的には Location
デコレータを使用しますが、特にLocationを使うまでもない場面があると思います。
そういった時は、pathから値を受け取る場合は、 call.parameters["..."]
を使用します。
get("/{model}/{id}") { call.respond(mapOf( "model" to call.parameters["model"], "id" to call.parameters["id"] )) } // => {"model":"aaa","id":"111"}
query paramsから値を受け取る場合は、 call.request.queryParameters["..."]
を使用します。
get("/search") { call.respond(mapOf( "keyword" to call.request.queryParameters["keyword"] )) } // http://localhost:8080/search?keyword=test //=> {"keyword":"test"} // http://localhost:8080/search?dummy=test //=> {}
もし値がnullだった場合は、keyも一緒に消える仕様になっています。
ファイルをアップロードする
ファイルのアップロードは 公式の Uploads - Servers - Ktor にサンプルがあります。
この例では、こちらをベースに手を加えたものが下サンプルコードになります。
post("/file_upload") { _ -> if (call.request.contentType().toString().startsWith("multipart/")) { var files: MutableList<String> = mutableListOf() val multipart = call.receiveMultipart() multipart.forEachPart { part -> if (part is PartData.FileItem) { val name = part.originalFileName!! val file = createTempFile(suffix = "-${name}") files.add(file.absolutePath) part.streamProvider() .use { input -> file.outputStream().buffered().use { output -> input.copyTo(output) } } } part.dispose() } call.respond( HttpStatusCode.Created, mapOf( "file" to files ) ) } else { call.respond( HttpStatusCode.BadRequest, mapOf( "error" to "アップロードに失敗しました" ) ) } }
ファイルのアップロードをする場合は、 contentTypeが multipart/
から始まる必要があります。
この例では、アップロードされたファイルの一時ファイルの絶対パスが返ります。
非同期レスポンスを実現する
こちらでは、 公式の Asynchronous - Samples - Ktor について説明していきます。
Ktorの公式ドキュメントを読んでいくと、suspend
というキーワードが頻出します。 これは、サスペンド関数といわれるものでコルーチンを中断させることができる関数です。Ktor、そしてKotlinでのサーバサイド開発をする上で欠かせない重要なものです。
コルーチンとは何か、suspending function(suspend関数) とは何かを理解するにはかなりの時間がかかります。もしかしたらこれだけで数ヶ月かかってしまうかもしれません。
どういったものなのか、サンプルコードを例に紹介します。
... typealias DelayProvider = suspend (ms: Int) -> Unit fun Application.module( testing: Boolean = false, random: Random = Random(), delayProvider: DelayProvider = { delay(it.toLong()) } ) { ...
val compute = newFixedThreadPoolContext(4, "compute") routing { val startTime = System.currentTimeMillis() var number = 0 val loopCount = 300 val delayTime = 10 var originalTime = loopCount * delayTime get("/async_calc") { val computeTime = measureTimeMillis { withContext(compute) { for (index in 0 until loopCount) { delayProvider(delayTime) number += random.nextInt(100) } } } val endtTime = System.currentTimeMillis() - startTime call.respond(mapOf( "startTime" to startTime, "endTime" to endtTime, "computeTime" to computeTime, "originalTime" to originalTime, "number" to number )) } get("/sync_calc") { val computeTime = measureTimeMillis { for (index in 0 until loopCount) { sleep(delayTime.toLong()) number += random.nextInt(100) } } val endTime = System.currentTimeMillis() - startTime call.respond(mapOf( "startTime" to startTime, "endTime" to endTime, "computeTime" to computeTime, "originalTime" to originalTime, "number" to number )) } }
/async_calc
と /sync_calc
の2つのパスを新しく作成しました。
このパスに対して、並列で100リクエストを送ってみることで、挙動の違いを確認します。
time (for i in {1..100}; do curl -s -o /dev/null http://localhost:8080/async_calc && echo $i & done; wait)
こちらのコマンドを、GNU bash で実行してください。
real 0m3.976s user 0m0.803s sys 0m1.259s
結果はこちらでした。およそ4秒くらいで処理が終わりました。
続いて、/sync_calc
のほうを叩いてみます。
time (for i in {1..100}; do curl -s -o /dev/null http://localhost:8080/sync_calc && echo $i & done; wait)
real 0m42.815s user 0m0.816s sys 0m1.374s
結果は、こちらでした。
およそ43秒も掛かってしまいました。
この2つの違いは、 一定時間待つ処理として delayを使っているのか、sleepを使っているのかといった部分が異なっています。 ちなみに、delayProvider(delayTime)
は delay(delayTime.toLong())
に置き換えても問題ありません。
sleepは、java.lang.Thread.sleep
のことでjavaから元からある 指定された時間分スレッドをブロックする処理を行う関数です。スレッドをブロックしているので、コルーチンのスレッドはすぐに埋まってしまい、curlの並列リクエストは詰まり、42秒も掛かりました。
では、delayとは何者なのか。 delayは kotlinx.coroutines.delay
として定義されているコルーチンのための一定時間待機する suspending function で、こちらはスレッドをブロックするのではなく、使っていない間はスレッドをスレッドプールに返却し、再び使う時になったらまたスレッドプールからスレッドを借りる挙動をします。
このような挙動により、suspending function はコルーチン内の処理を中断しつつもスレッドはブロックせずにスレッドプールを効率よく使うことができるようにする仕組みを提供しているのです。
様々な suspending function
suspending function を使っているからといって完全にブロッキングされなくなるわけではなく、 suspending function 内部で java.lang.Thread.sleep を使うと同じようにブロッキングしてしまいます。
そして、スレッドをノンブロッキングで中断できることが強みではありますが、delayだけでは実用には耐えません。 もちろん、delay関数以外にも様々な suspending function が用意されています。
Channel
Channelは筒のようなもので、 中断することができる送り口(.send)と、中断することができる受け取り口(.receive)を持っています。 中に決められた型の値を、指定された個数分だけ溜めることができます。
デフォルト値では、0なのでどこかで channel.receive()
が実行されるまで、中断されます。 Channel.UNLIMITED
が指定された場合は、溜めることができる数の制限がなくなります。
val channel = Channel<String>(100) get("/receive") { call.respondTextWriter(ContentType.Text.Plain) { withContext(compute) { while(true) { val value = channel.receive() appendln(value) flush() } } } } get("/send/{message}") { withContext(compute) { channel.send(call.parameters["message"] as String) } call.respond( mapOf( "send" to "ok" ) ) }
こちらを追加して、実行してみます。
# 受け取る (アイドル状態が続くとタイムアウトします) while :; do curl -s http://localhost:8080/channel/receive; echo "restart"; done
# 送る time (for i in {1..100}; do curl -s -o /dev/null http://localhost:8080/channel/send/$RANDOM && echo $i & done; wait)
受け取る処理は複数設置しても構いません。 試しにたくさん受け取り口を設置しても、スレッドはブロックされることはありませんでした。
しかし Channel は delayと違い一瞬で終わってしまう処理。たまたまスレッド数が間に合っていたりしてうまくいっていた可能性があるのではないかと思ってしまうでしょう。 そこで検証してみることにします。
get("/receive") { call.respondTextWriter(ContentType.Text.Plain) { withContext(compute) { while(true) { val value = channel.receive() log.info("---- current thread: ${Thread.currentThread().name} / ${Thread.activeCount()}") appendln(value) flush() } } } }
新しく log.info
行を追加しました。
これで、実際に使用されているスレッド名がわかります。
そして、念の為使用できるスレッド数も2にしてみます。
val compute = newFixedThreadPoolContext(2, "compute")
そして、本来であれば3個以上設置するとブロッキングされるはずなので受け取る処理を3つ設置します。
そして再送してみると、下記のようなログになりました。
2019-09-10 14:32:35.079 [nioEventLoopGroup-4-8] INFO Application - 200 OK: GET - /channel/send/15840 2019-09-10 14:32:35.080 [compute-2] INFO Application - ---- current thread: compute-2 / 54 2019-09-10 14:32:35.080 [nioEventLoopGroup-4-4] INFO Application - 200 OK: GET - /channel/send/12205 2019-09-10 14:32:35.080 [compute-1] INFO Application - ---- current thread: compute-1 / 54 2019-09-10 14:32:35.080 [nioEventLoopGroup-4-5] INFO Application - 200 OK: GET - /channel/send/29952 2019-09-10 14:32:35.089 [compute-2] INFO Application - ---- current thread: compute-2 / 54 2019-09-10 14:32:35.089 [nioEventLoopGroup-4-5] INFO Application - 200 OK: GET - /channel/send/30861 2019-09-10 14:32:35.089 [compute-1] INFO Application - ---- current thread: compute-1 / 54 2019-09-10 14:32:35.090 [nioEventLoopGroup-4-2] INFO Application - 200 OK: GET - /channel/send/14931 2019-09-10 14:32:35.091 [compute-2] INFO Application - ---- current thread: compute-2 / 54 2019-09-10 14:32:35.092 [nioEventLoopGroup-4-3] INFO Application - 200 OK: GET - /channel/send/27226 2019-09-10 14:32:35.097 [compute-1] INFO Application - ---- current thread: compute-1 / 54 2019-09-10 14:32:35.097 [nioEventLoopGroup-4-6] INFO Application - 200 OK: GET - /channel/send/7179 2019-09-10 14:32:35.110 [compute-2] INFO Application - ---- current thread: compute-2 / 54 2019-09-10 14:32:35.110 [nioEventLoopGroup-4-7] INFO Application - 200 OK: GET - /channel/send/2636
compute-**
というのがスレッド名です。 ちなみに newFixedThreadPoolContext
のスレッド上限数を1にすると compute
だけになります。
このログでは compute-1
と compute-2
が確認できました。 もしも、 3つ以上受け取り用のリクエストのループを設置している場合は、suspending function でない関数で .receive()
していた場合は 3個目でブロッキングされているので受け取り待機状態になれなかったはずです。
このように、 .receive()
は suspending function で新しくChannelに送られてくるまで ノンブロッキングで中断し 待機状態となります。 これによって、スレッドの再利用性を高めスレッドプールの資源を有効活用できるように設計されています。
Async/Await
Async/Await 複数の処理を並列で処理することで、高速に処理ができるようになったり、実行する順番を意識する必要がなくなります。
route("/async_await") { get { withContext(compute) { val a = async { log.info("---- current thread: ${Thread.currentThread().name} / ${Thread.activeCount()}") sleep(1000L) 1 } val b = async { log.info("---- current thread: ${Thread.currentThread().name} / ${Thread.activeCount()}") sleep(2000L) 2 } var total = 0 val computeTime = measureTimeMillis { listOf(a, b).awaitAll().fold(0, { acc, v -> acc + v }) } call.respond(mapOf("total" to total, "computeTime" to computeTime)) } } }
こちらのコマンドでは2つの async関数が実行されていて、Deferred を返しています。 この例だと、
この時点ではまだこの2つの処理は実行されておらず、 listOf(a,b).awaitAll()
によって2つの処理が並列で実行されています。
.awaitAll()
の他にも、それぞれのDeferredに対して .await()
で実行し値を得ることもできます。 ちょうどこの2つが、中断することができる suspending function になります。
curl -s http://localhost:8080/async_await
2019-09-10 18:56:46.736 [nioEventLoopGroup-4-1] INFO Application - 200 OK: GET - /async_await 2019-09-10 18:56:49.480 [compute-3] INFO Application - ---- current thread: compute-3 / 11 2019-09-10 18:56:49.480 [compute-4] INFO Application - ---- current thread: compute-4 / 11
ログを確認すると、2つのスレッドで動いていることが確認できました。
{"total":0,"computeTime":2005}
レスポンスはどうかというと、上のようになり、1秒かかる処理と2秒かかる処理が同時に実行されたので、foldによって2秒で合計値を返す事ができました。
ここでは、よく使われそうな suspending function を紹介しました。他にも actor というCoroutineScopeのsendやActorScope という suspending functionがあります。 と言っても、ActorScope は高階関数のsuspending functionでsuspending scopeと言ったほうがより正確かもしれません。
どうしてもブロッキング処理がしたい
Kotlinのコルーチンを最大限有効活用するには、ブロッキング処理を極力減らし、リソースが枯渇しないように設計することが良いと考えられますが、開発時にどうしても長時間のブロッキングを避けられない場合があると思います。
長時間のブロッキングは、スレッドプールを食いつぶし、リクエストの処理ができなくなってしまうので問題がありますが、これは単純にスレッドプールのスレッドの制限を緩和するだけで改善できます。
先程の/async_await
にはブロッキング処理であるsleepが含まれているので、試しに並列で100回実行してみます。
time (for i in {1..100}; do curl -s -o /dev/null http://localhost:8080/async_await && echo $i & done; wait)
一瞬にして詰まってしまい、ほとんどレスポンスが返って来てないと思います。
スレッドが、コルーチンから返却されず、sleepによってブロッキングされてスレッドプールが枯渇した状態です。
上記処理では、同時に100リクエスト送るので、100スレッドまでにスレッドプールを増やしてみます。
val compute = newFixedThreadPoolContext(100, "compute")
スレッド数を変更後、改めて実行してみると、おおよそ5秒で実行が完了しました。
... 2019-09-10 19:16:44.741 [compute-71] INFO Application - ---- current thread: compute-71 / 116 2019-09-10 19:16:44.741 [compute-100] INFO Application - ---- current thread: compute-100 / 116 2019-09-10 19:16:44.741 [compute-36] INFO Application - ---- current thread: compute-36 / 116 ...
ログを抜粋すると、実際に100スレッド実行されたようです
APIの状況に応じて、 どれくらいのスレッド数を許可するかを予め決めておいて適切なリソース管理をすると、安定した運用が可能になっていくと思います。
データベース: マイグレーション
ここからは、データベースのスキーマ管理に入っていきます。
今回は、kotlinでDBのmigrationを管理するために、 harmonica を使用しました。
まずは下のように環境設定を行います。こちらの設定は、別Projectに切り分けても問題ありません。
gradle関連
# gradle.properties ... harmonicaVersion=1.1.17 ...
最新バージョンは確認してください
// build.gradle buildscript { repositories { ... } dependencies { ... classpath "com.improve_future:harmonica:$harmonicaVersion" } } ... apply plugin: 'jarmonica' ... dependencies { ... runtimeOnly 'org.postgresql:postgresql:42.2.5' implementation "com.improve_future:harmonica:$harmonicaVersion" implementation group: 'org.reflections', name: 'reflections', version: '0.9.11' implementation "org.jetbrains.exposed:exposed:0.13.2" ... } ... extensions.extraProperties["migrationPackage"] = [project.group, project.rootProject.name, project.name].join(".") extensions.extraProperties["directoryPath"] = "src/db" extensions.extraProperties["migrationPackage"] = "db" ...
- buildscript に
classpath "com.improve_future:harmonica:$harmonicaVersion"
を追加します apply plugin: 'jarmonica'
を追加しますdependencies
runtimeOnly 'org.postgresql:postgresql:42.2.5'
を追加しますimplementation "com.improve_future:harmonica:$harmonicaVersion"
を追加しますimplementation group: 'org.reflections', name: 'reflections', version: '0.9.11'
を追加します(バージョンは確認してください)implementation "org.jetbrains.exposed:exposed:0.13.2"
を追加します(バージョンは確認してください)
extensions各行を追加します
Config関係
src/db/migration.kt
package db.migration import com.improve_future.harmonica.core.DbConfig import com.improve_future.harmonica.core.Dbms import util.Environment import util.env import util.environment class Default : DbConfig({ dbms = Dbms.PostgreSQL dbName = when(environment) { Environment.Production -> "example_production" Environment.Development -> "example_development" Environment.Staging -> "example_staging" Environment.Test -> "example_test" } host = env.host user = env.user password = env.password })
src/util/environment.kt
package util enum class Environment(val value: String) { Production("production"), Development("development"), Staging("staging"), Test("test") } val environment: Environment = when (System.getenv("KTOR_ENV")) { "production" -> Environment.Production "development" -> Environment.Development "staging" -> Environment.Staging "test" -> Environment.Test else -> Environment.Development } object env { val host: String = System.getenv("DATABASE_HOST") val user: String = System.getenv("DATABASE_USER") val password: String = System.getenv("DATABASE_PASSWORD") }
これで、環境変数に定義された接続情報によって、データベースマイグレーションを行います。
マイグレーションファイルの作成
src/db/migration
こちらのディレクトリを作成してください
その後、IntelliJ IDEA の Run/Debug Configurations
で 新しいGradleのConfigurationを作成します。
- Name: jarmonicaCreate
- Gradle project: example
- Tasks: jarmonicaCreate
- Environment variables:
DATABASE_HOST=127.0.0.1;DATABASE_PASSWORD=test;DATABASE_USER=test;KTOR_ENV=test
- DATABASE_HOST=127.0.0.1
- DATABASE_PASSWORD=test
- DATABASE_USER=test
- KTOR_ENV=test
サンプルでは、 postgresで example_test
というdatabaseが必要になるので作成をお願いします。
そして confingrationsのjarmonicaCreate
を実行します。
BUILD SUCCESSFUL
と表示され、 src/db/migration/
に M2019*************_Migration.kt
というファイルが作成されたら成功です。 ファイル名の Migration
の部分は、テーブルの作成の場合は Create***
、カラムの追加の場合は AddColumnTo***
など付けておくと良いでしょう。
今回はbooks
テーブルを作成してみます。
src/db/migration/M20190910200451150_CreateBooks.kt
package db.migration import com.improve_future.harmonica.core.AbstractMigration class M20190910200451150_CreateBooks : AbstractMigration() { override fun up() { createTable("books") { varchar("title", size = 255) varchar("author", size = 255) varchar("isbn", size = 13) integer("genre") } } override fun down() { dropTable("books") } }
idカラムは自動生成されますが、桁数が少ないので、必要があれば下の一行をcreateTableブロックの下に追加します。
executeSql("alter table books alter column id type bigint using id::bigint")
マイグレーションの実行
マイグレーションの実行は、先程作成した configuration の jarmonicaUp
を複製し、下の部分を変更します。
- Name:
test: jarmonicaUp
- Tasks:
jarmonicaUp
== [Start] Migrate up 20190910200451150 == Create Table: books 2019-09-10 20:15:35.415 [main] DEBUG Exposed - CREATE TABLE books ( id SERIAL PRIMARY KEY, title VARCHAR(255), author VARCHAR(255), isbn VARCHAR(13), genre INTEGER ); 2019-09-10 20:15:35.425 [main] DEBUG Exposed - INSERT INTO harmonica_migration(version) VALUES('20190910200451150'); == [End] Migrate up 20190910200451150 ==
このようなログが表示されたら、テーブルの作成に成功しています。 同様に、jarmonicaDown
によって1ステップ分のロールバックが可能です。
データベース: SQLライブラリ Exposed を導入する
Kotlinのプロジェクトから安全にデータベースを触るには、Exposedを使うのがおすすめです。
// build.gradle dependencies { ... implementation "org.jetbrains.exposed:exposed:0.13.2" ... }
はじめに、build.gradle
に implementation "org.jetbrains.exposed:exposed:0.13.2"
を入れます。
環境構築はインストールだけです。サンプルなので、接続先情報等はハードコーディングしていきます。
接続先情報を管理する場合は konf を使うのが便利です。
DBへの接続
だいぶ端折った方法ではありますが、 Application.module
の引数としてDBに接続できるようにします。
fun Application.module( testing: Boolean = false, random: Random = Random(), delayProvider: DelayProvider = { delay(it.toLong()) }, database: Database = Database.connect( "jdbc:postgresql://localhost:5432/example_test", driver = "org.postgresql.Driver", user = "test", password = "test" ) ) { ...
booksテーブルを操作するためのDAOを作成する
src/dao/book.kt
はじめに、DAOを作成します。このDAOを介して、データベースにアクセスします。
package dao import org.jetbrains.exposed.dao.EntityID import org.jetbrains.exposed.dao.IntEntity import org.jetbrains.exposed.dao.IntEntityClass import org.jetbrains.exposed.dao.IntIdTable object Books : IntIdTable("books") { val title = varchar("title", 255) val author = varchar("author", 255) val isbn = varchar("isbn", 13) val genre = integer("genre") } class Book(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<Book>(Books) var title by Books.title var author by Books.author var isbn by Books.isbn var genre by Books.genre }
マイグレーション時のスキーマで、bigintにした場合は、IntEntity を Long に変更しておきます。
Application.kt
下のコードを ルーティングに追加します。
... route("/books") { route("/register") { get { call.respondHtml { body { h1 { +"本の登録" } form(method = FormMethod.post, action = call.request.path()) { div { p { +"タイトル" } input(type = InputType.text, name = "title") } div { p { +"著者" } input(type = InputType.text, name = "author") } div { p { +"ISBN" } input(type = InputType.text, name = "isbn") } div { p { +"ジャンル" } select { name = "genre" genreCodes.map { (index, name) -> option { value = index.toString(); +name } } } } div { p { button(type = ButtonType.submit) { +"登録" } } } } } } } post { val params = call.receiveParameters() runCatching { require(params.contains("title") && params["title"]!!.isNotBlank()) { "title is empty" } require(params.contains("author") && params["author"]!!.isNotBlank()) { "author is empty" } require(params.contains("isbn") && params["isbn"]!!.isNotBlank()) { "isbn is empty" } require(params.contains("genre") && params["genre"]!!.isNotBlank()) { "genre is empty" } BookParams( title = params["title"]!!, author = params["author"]!!, isbn = params["isbn"]!!, genre = params["genre"]!!.toInt() ) }.fold({ bookParams -> val book = transaction { Book.new { title = bookParams.title author = bookParams.author isbn = bookParams.isbn genre = bookParams.genre } } call.respondText("book id: ${book.id}", status = HttpStatusCode.OK) }, { call.respondText("error: ${it.message}", status = HttpStatusCode.BadRequest) }) } } } ...
ブラウザで、 http://localhost:8080/books/register
にアクセスすると、本の登録画面が表示されます。
ここで、本を登録することでpost側のルーティングに行きます。
postでLocationを使う方法もあるようなのですが、どうしてもエラーを消せなかったため call.receiveParameters()
から formdataのfieldを取得します。
runCatching
は内部で try~catch を使っており、Result型に変換してくれます。 Result型は foldを持っているため、そこから成功処理と失敗処理を分けて書くことができます。
requireは必須項目があるか確認する関数で、false の場合は IllegalArgumentException を履きます。 他にも、 check関数があり、こちらはIllegalStateExceptionを履きます。
簡単なバリデーションに使えるのでおすすめです。
本を登録する
実際にブラウザでアクセスし、 本を作成してみてください。
val book = transaction {
Book.new {
title = bookParams.title
author = bookParams.author
isbn = bookParams.isbn
genre = bookParams.genre
}
}
本の作成には、 DAO.new の形でDSL表現で行うことができます。select相当のクエリでも、transactionで囲む必要があるようです。
サンプルでは、ルーティング上で直接作成を行いましたが、Clean Architecture などのデザインパターンを適用するのが望ましいと思います。
本を更新する
続いて本の更新を行います。
route("/books") { ... route("/{id}") { get { runCatching { val book = transaction { Book.findById(EntityID(call.parameters["id"]!!.toInt(), Books)) } require(book != null) { "book id: ${call.parameters["id"]!!} is not found" } book!! }.fold({ book -> call.respondHtml { body { h1 { +"本の登録" } form(method = FormMethod.post, action = call.request.path()) { div { p { +"タイトル" } input(type = InputType.text, name = "title") { value = book.title } } div { p { +"著者" } input(type = InputType.text, name = "author") { value = book.author } } div { p { +"ISBN" } input(type = InputType.text, name = "isbn") { value = book.isbn } } div { p { +"ジャンル" } select { name = "genre" genreCodes.map { (index, name) -> option { value = index.toString() selected = book.genre == index +name } } } } div { p { button(type = ButtonType.submit) { +"更新" } } } } } } }, { call.respondText("error: ${it.message}") }) } post { val params = call.receiveParameters() runCatching { val book = transaction { Book.findById(EntityID(call.parameters["id"]!!.toInt(), Books)) } require(book != null) { "book id: ${call.parameters["id"]!!} is not found" } require(params.contains("title") && params["title"]!!.isNotBlank()) { "title is empty" } require(params.contains("author") && params["author"]!!.isNotBlank()) { "author is empty" } require(params.contains("isbn") && params["isbn"]!!.isNotBlank()) { "isbn is empty" } require(params.contains("genre") && params["genre"]!!.isNotBlank()) { "genre is empty" } Pair( book, BookParams( title = params["title"]!!, author = params["author"]!!, isbn = params["isbn"]!!, genre = params["genre"]!!.toInt() ) ) }.fold({ (book, bookParams) -> transaction { Books.update({ Books.id eq book.id }) { it[title] = bookParams.title it[author] = bookParams.author it[isbn] = bookParams.isbn it[genre] = bookParams.genre } } call.respondText("book id ${book.id} was updated", status = HttpStatusCode.OK) }, { call.respondText("error: ${it.message}", status = HttpStatusCode.BadRequest) }) } } }
登録が完了していれば、ブラウザで http://localhost:8080/books/1
にアクセスできるようになっていると思います。
更新時は、 Books.update
を使用します。 バリデーションを通過時に、bookも渡す必要があったのでPairを使っています。Pairはタプルのようなもので、他にもTripleなどがあります。
最後に
Kotlinは、Android開発のイメージが強いですが、 Multiplatform Projects (MPP) というマルチプラットフォームでKotlinを動かすプロジェクトも活発です。
プラットフォームに依存する部分は、expect/actual mechanism によって書き分けることができる柔軟性を持っていたり、異なるプラットフォーム間のデータ受け渡しや変換も容易にする同じMPPの kotlinx.serialization の開発も進んできています。
近年では、ドメイン駆動開発(DDD)での開発の重要性も更に広まっていると思いますが、その観点から見ると、ビジネスロジックの共通化といった部分においてもMPPは価値があるものだと感じています。
ぜひKotlinでのサーバサイド開発に挑戦してみてください!