前回の Ktor HttpClientでGET に続いて今回はPostを動かしてみました。
前回の Get 同様、ファクトリークラスには CIO, Apache などがありますが、後述するように CIO は気になるところがあったので、今回は Apache をメインに進めたいと思います。
フォームデータを POST
build.gradle の内容は GET の時と同じです。
group 'HttpPostFormDataApache'
version '1.0-SNAPSHOT'
buildscript {
ext {
kotlin_version = '1.2.21'
ktor_version = '0.9.1'
}
repositories {
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'kotlin'
apply plugin: 'application'
sourceCompatibility = 1.8
mainClassName = 'HttpClientPostFormDataApacheKt'
repositories {
mavenCentral()
maven { url "http://dl.bintray.com/kotlin/ktor" }
maven { url "https://dl.bintray.com/kotlin/kotlinx" }
}
dependencies {
// Kotlin
compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version"
// Ktor
compile "io.ktor:ktor-client-apache:$ktor_version"
// Log
compile "ch.qos.logback:logback-classic:1.2.1"
// Testing
testCompile group: 'junit', name: 'junit', version: '4.12'
}
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}
kotlin {
experimental {
coroutines "enable"
}
}
そして Kotlin のソースは次のようになります。
import io.ktor.client.HttpClient
import io.ktor.client.engine.apache.Apache
import io.ktor.client.request.post
import kotlinx.coroutines.experimental.runBlocking
import io.ktor.http.HeadersBuilder
fun main( args: Array<String> ) {
HttpClient( Apache ).use {
val response = runBlocking {
it.post<String>( scheme = "http", host = "localhost", port = 8000,
path = "/postform", body = "data=hoge",
block = {
headers{
append( "Content-Type", "application/x-www-form-urlencoded" )
}
}
)
}
println( response )
}
}
GET では省略していた body と block のパラメーターを今回 POST では使っています。
- body にはフォームデータとして "data=hoge" を指定しています。
- block では HTTPヘッダーを追加しています。
blockパラメーターの型は HttpRequestBuilder.() になります。
HTTPヘッダーは post のパラメーターに指定できないため、block に HTTPヘッダー追加処理を記述する必要があります。
上記のコード
block = {
headers{
append( "Content-Type", "application/x-www-form-urlencoded" )
}
}
は、最初は次のように、HeadersBuilder.() 関数を headersBuilder 変数に代入してから post の block パラメーターに渡すように書いていました。
val httpRequestBuilder: HttpRequestBuilder.() -> Unit = {
val headersBuilder: HeadersBuilder.() -> Unit = {
append( "Content-Type", "application/x-www-form-urlencoded" )
}
headers( headersBuilder )
}
it.post<String>( scheme = "http", host = "localhost", path = "/postform",
port = 8000, body = "data=hoge" , block = httpRequestBuilder )
まず HeadersBuilder.() をラムダ式として定義し、直接 headers に渡すようにしたのが、次のソースになります。
val httpRequestBuilder: HttpRequestBuilder.() -> Unit = {
headers {
append( "Content-Type", "application/x-www-form-urlencoded" )
}
}
it.post<String>( scheme = "http", host = "localhost", path = "/postform",
port = 8000, body = "data=hoge" , block = httpRequestBuilder )
さらに HttpRequestBuilder.() もラムダ式として定義し、block パラメーターに直接渡すようにしたが先ほどの、このソースになります。
block = {
headers {
append( "Content-Type", "application/x-www-form-urlencoded" )
}
}
バイト配列を POST(ByteArrayContent)
次はバイト配列を POST してみます。
build.gradle の内容は同じです(クラス名以外は)。
Kotlin ソースは次のようになります。
import io.ktor.client.HttpClient
import io.ktor.client.engine.apache.Apache
import io.ktor.client.request.post
import kotlinx.coroutines.experimental.runBlocking
import java.io.File
import java.io.FileInputStream
import io.ktor.content.OutgoingContent.ByteArrayContent
fun main( args: Array<String> ) {
HttpClient( Apache ).use {
val file = File( "sample.bin" )
val buf = ByteArray( file.length().toInt())
FileInputStream( file ).use {
it.read( buf )
}
val response = runBlocking {
it.post<String>( scheme = "http", host = "localhost", path = "/postbytes", port = 8000,
body = object: ByteArrayContent() {
override fun bytes() = buf
}
)
}
println( response )
}
}
OutgoingContent.kt では、contentLength プロパティが次のように定義されていました。
/**
* Specifies content length in bytes for this resource.
*
* If null, the resources will be sent as `Transfer-Encoding: chunked`
*/
open val contentLength: Long? get() = null
ここでは contentLength プロパティには何もセットせず null のままにしていますので、自動的に Transfer-Encoding: chunked ヘッダーが送信されます。
サイズの大きなデータを渡したところ、チャンクサイズは 4096バイトで送信されていました。
一方、body パラメーターで次のように contentLength をオーバーライドして定義すると、Content-Length ヘッダーが自動的に送信されます。
body = object: ByteArrayContent() {
override val contentLength = buf.size.toLong()
override fun bytes() = buf
}
ファクトリークラスに CIO を指定した場合は、Content-Length ヘッダーの有無をチェックして自動的に Transfer-Encoding をセットするロジックがなく、Transfer-Encoding も Content-Length もセットされないまま送信されましたので、どちらかをセットしておく必要がありました。
(GitHub での最新ソースを見たところかなり変わってましたので、次のリリースではまだ異なる動きになっているかも知れません。)
試しに次のように両方設定したところ、Transfer-Encoding ヘッダーと Content-Length ヘッダーの両方が送信されました。。
HttpClient( CIO ).use {
//(略)
val response = runBlocking {
it.post<String>( scheme = "http", host = "localhost", path = "/postbin.php", port = 8000,
body = object: ByteArrayContent() {
override val contentLength get() = file.length()
override fun bytes() = buf
},
block = {
headers{
append( "Transfer-Encoding", "chunked" )
}
}
)
}
}
抽象クラス ByteArrayContent の具象クラスは、 io.ktor.content.ByteArrayContent として ktor-server-core にも定義されているのですが、クライアント側を作成するのにサーバー側モジュールを build.gradle に追加するのもどうかと思ったので、サンプルでは object 宣言を使って実装しています。
ByteArrayContent は次のように定義されていて、インスタンス時に追加実装(override)が必要なのは bytes() 関数だけです。
abstract class ByteArrayContent : OutgoingContent() {
abstract fun bytes(): ByteArray
}
ファイルを POST(ReadChannelContent)
ファイルをそのまま送信する場合は、body に ReadChannelContent を指定するのがよさそうです。
import io.ktor.client.HttpClient
import io.ktor.client.engine.apache.Apache
import io.ktor.client.request.post
import kotlinx.coroutines.experimental.runBlocking
import java.io.File
import io.ktor.content.OutgoingContent.ReadChannelContent
import kotlinx.coroutines.experimental.io.ByteReadChannel
import io.ktor.cio.readChannel
fun main( args: Array<String> ) {
HttpClient( Apache ).use {
val file = File( "sample.bin" )
println( runBlocking {
it.post<String>( scheme = "http", host = "localhost", path = "/postfile", port = 8000,
body = object: ReadChannelContent() {
override fun readFrom(): ByteReadChannel = file.readChannel()
}
)
})
}
}
抽象クラス ReadChannelContent を使うには、
abstract fun readFrom(): ByteReadChannel
を実装する必要がありますが、ktor-utils の FileChannels.kt に ByteReadChannel を返す File の拡張関数が定義されてるので、これを使うと便利です。
fun File.readChannel(
start: Long = 0,
endInclusive: Long = -1,
coroutineContext: CoroutineContext = Unconfined
): ByteReadChannel {
// (以下略)
ファイルをマルチパートで POST(WriteChannelContent)
ファイルをそのまま送信する場合は ReadChannelContent が便利ですが、マルチパートのように内容を組み立てながら送信する場合は WriteChannelContent が良さそうです。
import io.ktor.client.HttpClient
import io.ktor.client.engine.apache.Apache
import io.ktor.client.request.post
import kotlinx.coroutines.experimental.runBlocking
import java.io.File
import java.io.FileInputStream
import java.util.Random
import io.ktor.content.OutgoingContent.WriteChannelContent
import kotlinx.coroutines.experimental.io.ByteWriteChannel
import kotlinx.coroutines.experimental.io.writeStringUtf8
import io.ktor.http.ContentType
fun main( args: Array<String> ) {
HttpClient( Apache ).use {
val file = File( "sample.bin" )
println( runBlocking {
it.post<String>( scheme = "http", host = "localhost", path = "/postfile", port = 8000,
body = object: WriteChannelContent() {
private val boundary = generateBoundary()
override val contentType = ContentType( "multipart", "form-data; boundary=$boundary" )
override suspend fun writeTo( channel: ByteWriteChannel ) {
channel.writeStringUtf8( "--$boundary\r\n" )
channel.writeStringUtf8( "Content-Disposition: form-data; name=\"file\"; filename=\"sample.bin\"\r\n" )
channel.writeStringUtf8( "Content-Type: application/octet-stream\r\n\r\n" )
val bufSize = 4096
val buf = ByteArray( bufSize )
var readedBytes = 0
FileInputStream( file ).use {
while( run{ readedBytes = it.read( buf ); readedBytes >= 0 } ) {
channel.writeFully( buf, 0, readedBytes )
}
}
channel.writeStringUtf8( "\r\n--$boundary--\r\n" )
}
private fun generateBoundary(): String {
val chars = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"
val rand = Random()
val boundary = StringBuilder( 40 )
repeat( 40 ) {
val r = rand.nextInt( chars.length )
boundary.append( chars.substring( r, r + 1 ))
}
return boundary.toString()
}
}
)
})
}
}
先ほどの ReadChannelContent では readFrom() を実装する必要があったように、WriteChannelContent では writeTo() を実装する必要があります。
abstract suspend fun writeTo(channel: ByteWriteChannel)
フォームデータ送信では block に渡したラムダ式の中で Content-Type ヘッダーをセットしていましたが、WriteChannelContent を使う場合は contentType プロパティに値をセットすることで、自動的に Content-Type ヘッダーとして送信されるようになっています。
(フォームデータ送信では post の block パラメーターに Content-Type の処理を渡しているのに対して、contentType プロパティにセットする処理は body パラメーターに渡していることに注意してください。)
override val contentType = ContentType( "multipart", "form-data; boundary=$boundary" )
contentType は WriteChannelContent のスーパークラスである、OutgoingContent(sealed クラス)のプロパティとして定義されていますので、OutgoingContent の派生クラス(ByteArrayContent, ReadChannelContent や NoContent)で使うことができます。
上記では body パラメーターに無名のインスタンスを渡していますが、そこそこ行数があるので、別の場所に定義して body パラメーターに渡すようにした方が見やすいかなと思いますので次のようにしました。
import io.ktor.client.HttpClient
import io.ktor.client.engine.apache.Apache
import io.ktor.client.request.post
import kotlinx.coroutines.experimental.runBlocking
import java.io.File
import java.io.FileInputStream
import java.util.Random
import io.ktor.content.OutgoingContent.WriteChannelContent
import kotlinx.coroutines.experimental.io.ByteWriteChannel
import kotlinx.coroutines.experimental.io.writeStringUtf8
import io.ktor.http.ContentType
fun main( args: Array<String> ) {
HttpClient( Apache ).use {
val file = File( "sample.bin" )
val contentWriter = object: WriteChannelContent() {
private val boundary = generateBoundary()
override val contentType = ContentType( "multipart", "form-data; boundary=$boundary" )
override suspend fun writeTo( channel: ByteWriteChannel ) {
channel.writeStringUtf8( "--$boundary\r\n" )
channel.writeStringUtf8( "Content-Disposition: form-data; name=\"file\"; filename=\"sample.bin\"\r\n" )
channel.writeStringUtf8( "Content-Type: application/octet-stream\r\n\r\n" )
val bufSize = 4096
val buf = ByteArray( bufSize )
var readedBytes = 0
FileInputStream( file ).use {
while( run{ readedBytes = it.read( buf ); readedBytes >= 0 } ) {
channel.writeFully( buf, 0, readedBytes )
}
}
channel.writeStringUtf8( "\r\n--$boundary--\r\n" )
}
private fun generateBoundary(): String {
val chars = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"
val rand = Random()
val boundary = StringBuilder( 40 )
repeat( 40 ) {
val r = rand.nextInt( chars.length )
boundary.append( chars.substring( r, r + 1 ))
}
return boundary.toString()
}
}
println( runBlocking {
it.post<String>( scheme = "http", host = "localhost", path = "/postfile", port = 8000,
body = contentWriter
)
})
}
}
バウンダリ文字列の生成は、以前あるサイトを参考にさせていただき Kotlin化したものを使ってますが、参考にしたのはおそらくこのサイトだったと思います。
バイト配列を POST のところで、ファクトリークラスとして CIO よりも Apache を使った方が良いと思った理由として、contentLength プロパティの扱いを挙げましたが、もうひとつの理由として Keep-Alive があります。
ファクトリークラスに CIO を指定した場合は HTTPヘッダーのひとつに Connection: close が送信されますが、Apache を指定した場合は Connection: Keep-Alive が送信されます。
Ktor HttpClient を使う動機として、一度に多くの HTTPリクエストを効率よく送信したいということがあると思いますが、そういった意味でも Keep-Alive が有効になる Apache の方が良いと思いました(今後のリリースで状況はまた変わるかもしませんが現時点では)。