iT邦幫忙

2023 iThome 鐵人賽

DAY 20
0

這幾天以來,我們看過了 Ktor 怎麼發送 Get,Post,也看了怎麼傳送 Form Request

今天我們來看看 Ktor 怎麼傳輸檔案,以及背後實作是什麼

官方教學用 Ktor 傳輸檔案的寫法如下

val response: HttpResponse = client.post("http://localhost:8080/upload") {
    setBody(MultiPartFormDataContent(
        formData {
            append("description", "Ktor logo")
            append("image", File("ktor_logo.png").readBytes(), Headers.build {
                append(HttpHeaders.ContentType, "image/png")
                append(HttpHeaders.ContentDisposition, "filename=\"ktor_logo.png\"")
            })
        },
        boundary = "WebAppBoundary"
    )
    )
    onUpload { bytesSentTotal, contentLength ->
        println("Sent $bytesSentTotal bytes from $contentLength")
    }
}

這邊的 MultiPartFormDataContent 類別的簽名如下

public class MultiPartFormDataContent(
    parts: List<PartData>,
    public val boundary: String = generateBoundary(),
    override val contentType: ContentType = ContentType.MultiPart.FormData.withParameter("boundary", boundary)
) : OutgoingContent.WriteChannelContent() 

這邊的 boundary 被設置為 "WebAppBoundary"

contentType 設置為 ContentType.MultiPart.FormData.withParameter("boundary", boundary),也就是 multipart/form-data,並加上 boundary=WebAppBoundary

/**
 * Provides a list of standard subtypes of a `multipart` content type.
 */
@Suppress("KDocMissingDocumentation", "unused")
public object MultiPart {
	public val Any: ContentType = ContentType("multipart", "*")
	public val Mixed: ContentType = ContentType("multipart", "mixed")
	public val Alternative: ContentType = ContentType("multipart", "alternative")
	public val Related: ContentType = ContentType("multipart", "related")
	public val FormData: ContentType = ContentType("multipart", "form-data")
	public val Signed: ContentType = ContentType("multipart", "signed")
	public val Encrypted: ContentType = ContentType("multipart", "encrypted")
	public val ByteRanges: ContentType = ContentType("multipart", "byteranges")
}

partsformData() 組成,實作如下

/**
 * Build multipart form using [block] function.
 */
public fun formData(block: FormBuilder.() -> Unit): List<PartData> =
    formData(*FormBuilder().apply(block).build().toTypedArray())

這邊會實作出一個 FormBuilder 協助我們建立內容

也就是我們前面看到的 appendFormBuilder.append 實作如下

/**
 * Appends a pair [key]:[value] with optional [headers].
 */
public fun append(key: String, value: String, headers: Headers = Headers.Empty) {
	parts += FormPart(key, value, headers)
}

FormPart 是一個 data class

/**
 * A multipart form item. Use it to build a form in client.
 *
 * @param key multipart name
 * @param value content, could be [String], [Number], [ByteArray], [ByteReadPacket] or [InputProvider]
 * @param headers part headers, note that some servers may fail if an unknown header provided
 */
public data class FormPart<T : Any>(val key: String, val value: T, val headers: Headers = Headers.Empty)

在這邊我們可以看到,程式內的

append("image", File("ktor_logo.png").readBytes(), Headers.build {
	append(HttpHeaders.ContentType, "image/png")
	append(HttpHeaders.ContentDisposition, "filename=\"ktor_logo.png\"")
})

就會變成其中一個 FormPart,等待轉換成請求

組合出許多 FormPart 之後,就換成下一段 formData 邏輯了

/**
 * Builds a multipart form from [values].
 *
 * Example: [Upload a file](https://ktor.io/docs/request.html#upload_file).
 */
@Suppress("DEPRECATION")
public fun formData(vararg values: FormPart<*>): List<PartData> {
    val result = mutableListOf<PartData>()

    values.forEach { (key, value, headers) ->
        val partHeaders = HeadersBuilder().apply {
            append(HttpHeaders.ContentDisposition, "form-data; name=${key.escapeIfNeeded()}")
            appendAll(headers)
        }

        val part = when (value) {
            is String -> PartData.FormItem(value, {}, partHeaders.build())
            is Number -> PartData.FormItem(value.toString(), {}, partHeaders.build())
            is Boolean -> PartData.FormItem(value.toString(), {}, partHeaders.build())
            is ByteArray -> {
                partHeaders.append(HttpHeaders.ContentLength, value.size.toString())
                PartData.BinaryItem({ ByteReadPacket(value) }, {}, partHeaders.build())
            }
            is ByteReadPacket -> {
                partHeaders.append(HttpHeaders.ContentLength, value.remaining.toString())
                PartData.BinaryItem({ value.copy() }, { value.close() }, partHeaders.build())
            }
            is InputProvider -> {
                val size = value.size
                if (size != null) {
                    partHeaders.append(HttpHeaders.ContentLength, size.toString())
                }
                PartData.BinaryItem(value.block, {}, partHeaders.build())
            }
            is ChannelProvider -> {
                val size = value.size
                if (size != null) {
                    partHeaders.append(HttpHeaders.ContentLength, size.toString())
                }
                PartData.BinaryChannelItem(value.block, partHeaders.build())
            }
            is Input -> error("Can't use [Input] as part of form: $value. Consider using [InputProvider] instead.")
            else -> error("Unknown form content type: $value")
        }

        result += part
    }

    return result
}

根據輸入的數值,以我們範例內

File("ktor_logo.png").readBytes() 來說是 is ByteArray

最後切成 List<PartData>

接著我們可以看到,WriteChannelContent 定義了一個 writeTo 函數

MultiPartFormDataContent 裡面實作如下

private val BOUNDARY_BYTES = "--$boundary\r\n".toByteArray()  
private val LAST_BOUNDARY_BYTES = "--$boundary--\r\n".toByteArray()  
  
private val BODY_OVERHEAD_SIZE = LAST_BOUNDARY_BYTES.size  
private val PART_OVERHEAD_SIZE = RN_BYTES.size * 2 + BOUNDARY_BYTES.size

override suspend fun writeTo(channel: ByteWriteChannel) {
	try {
		for (part in rawParts) {
			channel.writeFully(BOUNDARY_BYTES)
			channel.writeFully(part.headers)
			channel.writeFully(RN_BYTES)

			when (part) {
				is PreparedPart.InputPart -> {
					part.provider().use { input ->
						input.copyTo(channel)
					}
				}
				is PreparedPart.ChannelPart -> {
					part.provider().copyTo(channel)
				}
			}

			channel.writeFully(RN_BYTES)
		}

		channel.writeFully(LAST_BOUNDARY_BYTES)
	} catch (cause: Throwable) {
		channel.close(cause)
	} finally {
		channel.close()
	}
}

這裡面會將 rawParts 分段送出,rawParts 實作則是從 parts 產生出來

private val rawParts: List<PreparedPart> = parts.map { part ->
	val headersBuilder = BytePacketBuilder()
	for ((key, values) in part.headers.entries()) {
		headersBuilder.writeText("$key: ${values.joinToString("; ")}")
		headersBuilder.writeFully(RN_BYTES)
	}

	val bodySize = part.headers[HttpHeaders.ContentLength]?.toLong()
	when (part) {
		is PartData.FileItem -> {
			val headers = headersBuilder.build().readBytes()
			val size = bodySize?.plus(PART_OVERHEAD_SIZE)?.plus(headers.size)
			PreparedPart.InputPart(headers, part.provider, size)
		}
		is PartData.BinaryItem -> {
			val headers = headersBuilder.build().readBytes()
			val size = bodySize?.plus(PART_OVERHEAD_SIZE)?.plus(headers.size)
			PreparedPart.InputPart(headers, part.provider, size)
		}
		is PartData.FormItem -> {
			val bytes = buildPacket { writeText(part.value) }.readBytes()
			val provider = { buildPacket { writeFully(bytes) } }
			if (bodySize == null) {
				headersBuilder.writeText("${HttpHeaders.ContentLength}: ${bytes.size}")
				headersBuilder.writeFully(RN_BYTES)
			}

			val headers = headersBuilder.build().readBytes()
			val size = bytes.size + PART_OVERHEAD_SIZE + headers.size
			PreparedPart.InputPart(headers, provider, size.toLong())
		}
		is PartData.BinaryChannelItem -> {
			val headers = headersBuilder.build().readBytes()
			val size = bodySize?.plus(PART_OVERHEAD_SIZE)?.plus(headers.size)
			PreparedPart.ChannelPart(headers, part.provider, size)
		}
	}
}

這邊會將前面所生成的 PartData.BinaryItem 變成一個一個的 PreparedPart.InputPart

然後透過 channel 送出去。

最後我們看到

onUpload { bytesSentTotal, contentLength ->
	println("Sent $bytesSentTotal bytes from $contentLength")
}

這段會在 listener 上面設置進度,每次事件發生時印出進度

/**
 * Registers listener to observe upload progress.
 */
public fun HttpRequestBuilder.onUpload(listener: ProgressListener?) {
    if (listener == null) {
        attributes.remove(UploadProgressListenerAttributeKey)
    } else {
        attributes.put(UploadProgressListenerAttributeKey, listener)
    }
}

到這邊已經過了 20 天,我們看完了 Ktor 如何上傳多媒體檔案的作法!


上一篇
Day 19:用 submitForm 以 FORM DATA 的形式傳遞資料
下一篇
Day 21:Ktor 怎麼安裝 WebSockets 與建立一個 webSocket route
系列文
深入解析 Kotlin 專案 Ktor 的程式碼,探索 Ktor 的強大功能30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言