Retrofit์ ์ฌ์ฉํ ๋๋ ์๋ฃ๊ฐ ๋ง์์ ์ ๊ฒฝ์์ผ๋ ๋ถ๋ถ์ธ๋ฐ, Ktor๋ฅผ ์ด๋ฒ์ ์ฌ์ฉํ๊ฒ ๋๋ฉด์ ๊ฒช์ ์ด์๋ค์ ์ ์ด๋ณด๊ฒ ๋ค.
๋จผ์ ํด๋ฆฐ ์ํคํ ์ณ์ ๋ํด ๊ฐ๋จํ๊ฒ ์ง๊ณ ๋์ด๊ฐ๋ณด์.
# Clean Architecture
์๋ ๊ฐ์ธ๊ฐ์ ์๊ฒฌ ์ฐจ์ด๊ฐ ์ฌํด์ ๋ ผ๋์ด ๋๋ ์ฃผ์ ๊ธฐ๋ ํ์ง๋ง, CleanArchitecture๋ฅผ ์ฌ์ฉํจ์ผ๋ก์จ ์ป๋ ๊ฐ์ ์ SOLID๋ฅผ ์งํจ๋ค๋ ๊ฒ์ด๋ค. ์ด๊ฑธ ๋์ ํด์ผ๋ ์ ๋์ ๊ท๋ชจ๊ฐ ์๋๋ฐ๋ ๋์ ํ๋ ๊ฑด ์ค๋ฒ์์ง๋์ด๋ง์ด๋ผ๊ณ ๋ค ํ์ง๋ง, ์ผ๋จ ๋๋ ํ์ต์ฉ์ผ๋ก ๋์ ํ๋ค.
Domain์๋ UseCase ์ธํฐํ์ด์ค์, ์ ์ญ์์ ์ฌ์ฉํ Model(Entities)์ด ์กด์ฌํ๋ค.
์ํคํ ์ณ๋ฅผ ์ค๋ช ํ ๋ ๋ณดํต domain, data, presenter ๋ ์ด์ด๋ก ๋๋ ์ ๋ณด๋๋ฐ, CleanArchitecture์ ๊ตฌ๊ธ์ด ๊ถ์ฅํ๋ ์ฑ ์ํคํ ์ณ์ ์ฐจ์ด๋ domain์ ์๋ค.
์๋๊ฐ ๊ถ์ฅ ์ฑ ์ํคํ ์ณ,
flowchart LR
B[Domain] --> C[Data]
C --> D[Presentation]
๊ทธ๋ฆฌ๊ณ ์ด๊ฒ ํด๋ฆฐ ์ํคํ ์ณ๋ค
flowchart LR
B[Data] --> C[Domain]
D[Presentation]--> C
์ด์ ์๋ ๋๋ฉ์ธ์ด data๋ฅผ ์๊ณ ์๋๋ฐ ๋ฐํด, ํด๋ฆฐ์ํคํ ์ณ์ ๊ฒฝ์ฐ ๋๋ฉ์ธ์ ์๋ฌด๋ ๋ชจ๋ฅธ๋ค. ๊ทธ๋์ ๋ชจ๋์ ๋ง๋ค๋๋ ์๋๋ก์ด๋ ์์กด์ฑ๋ ์ ๊ฑฐํ Kotlin, Java Library ๋ชจ๋๋ก ๋ง๋๋ ๊ฒ์ด๋ค.
ํด๋ฆฐ์ํคํ ์ณ๋ฅผ ์ ์ฉํ์ ๋ MultiPart๊ฐ ์ด๋ ค์์ง๋ ์ ์ ํ๋๋ค.
File์ parameter๋ก ๋๊ธฐ๋ ๊ฒ ์๋๋ผ, uri๋ฅผ ๋๊ธฐ๋ ๊ฒ์ด๋ผ๋ฉด ์ธํฐํ์ด์ค์ Uri๋ก ํ๋ผ๋ฏธํฐ๋ฅผ ์ ์ธํด์ผ๋๋๋ฐ ๋๋ฉ์ธ ๋ชจ๋์ android ์์กด์ฑ์ด ์๊ธฐ ๋๋ฌธ์ Uri ํ์ ์ ๊ฐ์ง ์ ์๋ค. ์ด๊ฑธ ๊ธฐ๋ฐ์ผ๋ก ์ด์ Ktor๋ก Multipart ์ ์ฉํ๊ธฐ๋ฅผ ์์ํด๋ณด๊ฒ ๋ค.
# Ktor์์ ํ์ผ์ ์ ๋ก๋ ํ๋ ๋ฐฉ๋ฒ
Ktor์์ ํ์ผ์ ์ ๋ก๋ํ๋ ๋ฐฉ๋ฒ์ 2๊ฐ์ง๋ค. ๊ณตํต์ ์ formData๋ก ๋น์ฐํ ๋ฌถ์ด์ผํ๋ค๋ ๊ฒ์ธ๋ฐ `submitFormWithBinaryData`์ post ์์ฒญ์ `MultiPartFormDataContent`๋ก ์ค์ ํ๋ ๋ฐฉ์์ ์ฐจ์ด์ ์ ํ ๋ฒ ์ดํด๋ณด์. ์์ ์ฝ๋๋ ๊ณต์๋ฌธ์์์ ๊ฐ์ ธ์จ ๊ฒ์ด๋ค.
`submitFormWithBinaryData`
val client = HttpClient(CIO)
val response: HttpResponse = client.submitFormWithBinaryData(
url = "http://localhost:8080/upload",
formData = 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\"")
})
}
)
์ด๋ฏธ์ง๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋ฐ์ด๋๋ฆฌ๋ฐ์ดํฐ๋ค. ๊ทธ๋์ Ktor์์๋ ์ด๊ฑธ ๊ทธ๋ฅ ์ฝ๊ฒ ๋ณด๋ผ ์ ์๋๋ก ํด์ฃผ๋๋ฐ, ByteArray๋ก ๋ณํ๋ File์ ํผ๋ฐ์ดํฐ์ ๋ฃ์ด์ฃผ๊ณ , Mime Type๊ณผ ContentDisposition ํค๋๋ฅผ ์ค์ ํ์ฌ ํ์ผ๋ก ์ธ์๋ ์ ์๋๋ก ํ๋ค.
๋ด๋ถ๊ตฌ์กฐ๋ฅผ ์ดํด๋ณด๋ฉด suspend ํจ์๋ก ๋์ด์๋ค.
inline suspend fun HttpClient.submitFormWithBinaryData(
url: String,
formData: List<PartData>,
block: HttpRequestBuilder.() -> Unit = {}
): HttpResponse
๊ธฐ๋ณธ์ ์ผ๋ก post๋ก ๋์ํ๋ค. ํผ ๋ฐ์ดํฐ์ ๊ฒฝ๊ณ๋ฅผ Ktor๊ฐ ์์์ ๊ด๋ฆฌ ํด์ฃผ๊ณ , ๋ฐ๋ก ์งํ์ํฉ ๋ชจ๋ํฐ๋ง์ ์ง์ํ์ง์๋๋ค. ๋ฐ์ด๋๋ฆฌ ๋ฐ์ดํฐ๋ฅผ ์ ์กํ๋ ์ญํ ์ ํ๊ธฐ๋๋ฌธ์, ์ด๋ฏธ์ง๊ฐ ์๋๋๋ผ๋ ์ผ๋จ ๋ฐ์ด๋๋ฆฌ๋ก ๊ด๋ฆฌํ ์ ์๋ ๋ชจ๋ ํ์ผ์ ์ ์กํ ์ ์๋ค.
## Boundary? ๊ฒฝ๊ณ?
๋ฐฉ๊ธ ์ค๋ช ์์ ํผ๋ฐ์ดํฐ์ ๊ฒฝ๊ณ๋ผ๋ ๊ฒ ์์๋ค. ์ด๊ฒ ๋ญ์ง ํ ๋ฒ ์ดํด๋ณด์.
๋ฉํฐํํธ ํผ ๋ฐ์ดํฐ์์ ๋ฐ์ด๋๋ฆฌ๋ ๊ฐ ํํธ๋ฅผ ๊ตฌ๋ถํ๋ ์ญํ ์ ํ๋ ๊ณ ์ ํ ๋ฌธ์์ด์ด๋ค. ๋ฉํฐํํธ ํ์์ ํ๋์ HTTP ์์ฒญ ๋ณธ๋ฌธ ์์ ์ฌ๋ฌ ๊ฐ์ ํ์ผ์ด๋ ํผ ํ๋๋ฅผ ํฌํจํ ์ ์๋๋ก ์ค๊ณ๋์ด ์์ผ๋ฉฐ, ๊ฐ ํํธ๋ฅผ ์๋ก ๊ตฌ๋ถํ๊ธฐ ์ํด ๊ฒฝ๊ณ๊ฐ ํ์ํ๋ฐ, ์๋์ ๊ฐ๋ค.
Content-Type: multipart/form-data; boundary=----WebAppBoundary
HTTP ์์ฒญ ๋ณธ๋ฌธ์ ์ฌ๋ฌ ๊ฐ์ ํ์ผ์ด๋ ํ ์คํธ ํํธ๊ฐ ํฌํจ๋ ๋, ๋ฐ์ด๋๋ฆฌ ๋ฌธ์์ด์ ์ฌ์ฉํ์ฌ ๊ฐ ํํธ๋ฅผ ๊ตฌ๋ถํ ์ ์๋ค. ์๋ฅผ ๋ค์ด, ํ ์คํธ ์ค๋ช ๊ณผ ์ด๋ฏธ์ง ํ์ผ์ ๋์์ ๋ณด๋ผ ๋ ๊ฐ๊ฐ์ ํํธ๋ฅผ ๋ถ๋ฆฌํ๊ธฐ ์ํด ๊ฒฝ๊ณ๋ฅผ ์ฌ์ฉํ๋ค. ๋ ํธ๋กํ์์ `@Part`๋ก ๊ตฌ๋ถํ๋๊ฒ ์ด๋ฐ ์ญํ ์ ํ๋ค๊ณ ๋ณด๋ฉด ๋๋ค.
์ฌ๋ฌ๊ฐ๋ก ์ชผ๊ฐ๊ฒ ๋๋ฉด ์๋์ ๊ฐ์ด ์์ฒญ์ด ๊ฐ๋ค.
------WebAppBoundary
Content-Disposition: form-data; name="description"
Ktor logo
------WebAppBoundary
Content-Disposition: form-data; name="image"; filename="ktor_logo.png"
Content-Type: image/png
๊ธฐ๋ณธ์ ์ผ๋ก ๋ฐ์ด๋๋ฆฌ๋ ์๋์์ฑ์ด๊ธด ํ์ง๋ง, `submitFormWithBinaryData`๋ ์๋์ค์ ๋ง ์ง์ํ๋ค. ์ด์ ํ๋ฒ MultiPartFormDataContent๋ฅผ ๋ด๋ณด์
`MultiPartFormDataContent`
val client = HttpClient(CIO)
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")
}
}
์ด๊ฑด post์์ฒญ์ Body๋ก ๋ฃ๋, ์ด์ ์ ์๊ณ ์๋ ๋ฐฉ์์ ๊ฐ๊น๋ค. post ์์ฒญ์ MultiPartFormDataContent๋ฅผ ์ฌ์ฉํ์ฌ ๋ฉํฐํํธ ํผ ๋ฐ์ดํฐ๋ฅผ ์ง์ ์ค์ ํ๊ณ setBody ํจ์๋ก ์๋ฒ์ ์ ์กํ๋ค.
๋ด๋ถ ๊ตฌ์กฐ๋ฅผ ๋ณด๋ฉด ๋ค๋ฅธ ์ ์ด ๋ฑ ๋ณด์ธ๋ค.
class MultiPartFormDataContent(
parts: List<PartData>,
val boundary: String = generateBoundary(),
val contentType: ContentType = ContentType.MultiPart.FormData.withParameter("boundary", boundary)
) : OutgoingContent.WriteChannelContent
boundary๋ฅผ ์๋์ผ๋ก ์ค์ ํ ์ ์๋ค. ๋ฌผ๋ก ๊ธฐ๋ณธ ์์ฑ์๊ฐ ์์ด์ ๋ฐ๋ก ๋ง๋ค๊ฒ ์๋๋ฉด ๋๋ฌ๋ ๋๋ค.
์ด์ ์ค์ํ ์ฐจ์ด์ ์ด ๋ฑ์ฅํ๋ค. onUpload์ธ๋ฐ, MultiPartFormDataContent๋ ๋ด๋ถ์ ์ผ๋ก ํ์ผ ๋ฐ์ดํฐ๋ฅผ ์ฒญํฌ๋จ์๋ก ๋ถํ ํ์ฌ ์ ์กํ๋ ๋ฐฉ์์ผ๋ก ์๋ํ๊ณ , ์ด ๊ณผ์ ์์ ๊ฐ ์ฒญํฌ๊ฐ ์ ์ก๋ ๋๋ง๋ค onUpload ์ฝ๋ฐฑ์ ํธ์ถํ ์ ์๋ค. submitFormWithBinaryData๋ ์ฒญํฌ๋จ์๋ก ์ ์กํ๋ ๊ฒ ์๋๊ธฐ ๋๋ฌธ์ post + MultiPartFormDataContent์ผ๋๋ง ๊ฐ๋ฅํ๋ค.
์ข ๋ ์์ธํ ๋ณด๋ฉด MultiPartFormDataContent๋ OutgoingContent.WriteChannelContent๋ฅผ ์์ํ๋ ๊ฑธ ๋ณผ ์ ์๋ค. ์ด ์์์ ํตํด ๋น๋๊ธฐ ์คํธ๋ฆฌ๋ฐ ๋ฐฉ์์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์ ์กํ ์ ์์ต๋๋ค. ์ฆ, ๋ชจ๋ ๋ฐ์ดํฐ๋ฅผ ํ๊บผ๋ฒ์ ๋ฉ๋ชจ๋ฆฌ์ ๋ก๋ํ์ง ์๊ณ , ์ฒญํฌ ๋จ์๋ก ๋ฐ์ดํฐ๋ฅผ ์คํธ๋ฆฌ๋ฐํ์ฌ ์๋ฒ๋ก ์ ์กํ๋ค.
onUpload๋ง ๋ ๋ผ์ ๋ณด์.
onUpload { bytesSentTotal, contentLength ->
println("Sent $bytesSentTotal bytes from $contentLength")
}
bytesSentTotal(์ง๊ธ๊น์ง ์ ์ก๋ ํฌ๊ธฐ), contentLength(์ ์กํ ํ์ผ ์ ์ฒด ํฌ๊ธฐ)๋ก ์งํ์ํฉ์ ๋ณด๊ณ ์์ ์ ์์ด์, UI์ ์งํ์ํฉ์ ๋์ธ ์ ๋ ์์ ๊ฒ์ผ๋ก ๋ณด์ธ๋ค.
suspend fun uploadImage(
image: Image,
file: ByteArray,
onProgress: (bytesSentTotal: Long, contentLength: Long) -> Unit
): CommonResponse<FileResponse> {
return client.post("your_url") {
setBody(MultiPartFormDataContent(
formData {
append("image", file, Headers.build {
append(HttpHeaders.ContentType, ContentType.Image.PNG)
})
}
))
onUpload { bytesSentTotal, contentLength ->
onProgress(bytesSentTotal, contentLength)
}
}.body()
}
์ด๋ ๊ฒ ์ฐ๋ฉด onProgress๋ก ์ํ๊ฐ ๊ณ์ ์์ชฝ์ผ๋ก ์ด๋ํด์ ๊ฐ์งํ ์ ์์ ๊ฒ ๊ฐ๋ค.
์ด์ ๋ ๋ฐฉ์์ ๋ชจ๋ ์ดํด๋ดค์ผ๋, ์ค์ ๋ก ํ๋ฒ ๋ฐ์ดํฐ๋ฅผ ๋ณด๋ด๋ณด๊ฒ ๋ค.
Ktor๋ก ์ด๋ฏธ์ง ์ ๋ก๋ ํด๋ณด์
presenteter์์ ๋จ์ํ uri๋ง ๋์ง๊ณ ๋๋จธ์ง๋ฅผ ๋ค data ๋ ์ด์ด์์ ์ฒ๋ฆฌํ๋ ๊ตฌ์กฐ๋ก ์งํํ๋ค. uri๊ฐ ์ธํฐํ์ด์ค์์ ์ ์ธ๋ ์ ์์ผ๋๊น `setProfileImageUseCase(contentUri = contentUri.toString())` ์ด๋ ๊ฒ String์ผ๋ก ๋ณํํด์ ๋๊ธด๋ค.
3๋จ๊ณ๋ก ๋์ํ๋ค.
- ๊ฐ์ ธ์จ contentUri๋ก ์ค๊ธฐ๊ธฐ ์์ ์๋ ์ด๋ฏธ์ง๋ฅผ ๊ฐ์ ธ์จ๋ค์
- ์ด๊ฑธ InputStream์ผ๋ก ๋ฐ๊ฟ ByteArray๋ก ๋ณํํ๋ ๊ณผ์ ์ ๊ฑฐ์ณ์
- client์ formData๋ก ๋๊ธด๋ค.
๋จผ์ ์ด๋ฏธ์ง๋ฅผ ๊ฐ์ ธ์ค๋ ค๋ฉด contentResolver๋ฅผ ์ฌ์ฉํด์ผ๋๋ค.
val uri = Uri.parse(contentUri)
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.MIME_TYPE
)
val cursor = context.contentResolver.query(
uri,
projection,
null,
null,
null
)
return cursor?.use { c->
c.moveToNext() // ์ปค์๋ฅผ ํ์นธ ๋ด๋ ค์ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
val idIdx = c.getColumnIndexOrThrow(projection[0])
val nameIdx = c.getColumnIndexOrThrow(projection[1])
val sizeIdx = c.getColumnIndexOrThrow(projection[2])
val typeIdx = c.getColumnIndexOrThrow(projection[3])
// idx๋ฅผ ์์์ผ ์ปค์๊ฐ ์ปฌ๋ผ์ ๋ํ ๊ฐ์ ๊ฐ์ ธ์ฌ ์ ์๋ค
val id = c.getLong(idIdx)
val name = c.getString(nameIdx)
val size = c.getLong(sizeIdx)
val type = c.getString(typeIdx)
Image(contentUri, name, size, type)
}
์ด ๊ณผ์ ์ ๊ฑฐ์น๋ฉด cursor๋ฅผ ํตํด ํ์ผ์ ๋ํ ์ ๋ณด๋ฅผ ๋ชจ๋ ๊ฐ์ ธ์ฌ ์ ์๋ค. ํ์ผ ๊ธธ์ด, ํ์ผ ์ด๋ฆ, mimetype๊น์ง ๋ชจ๋ ๊ฐ์ ธ์ฌ ์ ์๊ธฐ ๋๋ฌธ์ ๋ฐ๋ก ๋งค๋ฒ mimeType์ ์๋์ ์ง์ ํด์ค์ผํ๋ ๋ถํธํจ์ ๋ ์ ์๋ค.
์ด๋ ๊ฒ ๊ฐ์ ธ์จ ์ด๋ฏธ์ง ์ ๋ณด๋ค์ multipart ์์ฒญ ๋ ์ฌ์ฉํ ๊ฒ์ด๊ณ , contentUri๋ก ๊ฐ์ ธ์จ Uri๋ฅผ InputStream์ผ๋ก ByteArray ๋ณํํ๋ ๊ณผ์ ์ ๋ ๋ฐ๋ก ํด์ค๋ค.
val uri = Uri.parse(contentUri)
context.contentResolver.openInputStream(uri) ?: throw IllegalStateException("๋น์ด์์")
์ฝํ ํธ ๋ฆฌ์กธ๋ฒ๋ก inputStream์ ์ด์ด์ ๋ฐํํ๋ค. ๊ทผ๋ฐ ์ด๊ฑด ๋ฆฌ์์ค๋ฅผ ์ด๊ธฐ ๋๋ฌธ์ ์ฌ์ฉํ๊ฒ ๋๋ฉด ๊ผญ use๋ฅผ ์จ์ memory leak์๋๊ฒ auto close ํด์ค์ผํ๋ค.
val byteArray = getInputStreamUseCase(image.uri).getOrElse {
Log.e("UploadImageUseCase", "Failed to get InputStream: ${it.message}")
throw it
}.use { inputStream ->
inputStream.readBytes()
}
// readBytes ๋ด๋ถ
public fun InputStream.readBytes(): ByteArray {
val buffer = ByteArrayOutputStream(maxOf(DEFAULT_BUFFER_SIZE, this.available()))
copyTo(buffer)
return buffer.toByteArray()
}
์ด๋ ๊ฒ ํ๋ฉด๊ฐ์ ธ์จ inputStream์ ByteArray๋ก ๋ฐ๊ฟ ์ ์๋ค.
์ด์ ktor ํด๋ผ์ด์ธํธ ํธ์ถ๋ถ๋ฅผ ๋ณด์.
@Serializable // Data ๋ชจ๋์์ ์ฌ์ฉํ๊ธฐ ์ํด ์ง๋ ฌํ๋ฅผ ํด์คฌ์
data class Image(
val uri: String,
val name: String,
val size: Long,
val mimeType: String
)
suspend fun uploadImage(imageFile: Image, file: ByteArray): CommonResponse<FileResponse> {
return client.post("files") {
setBody(
MultiPartFormDataContent(
formData {
append("fileName", imageFile.name)
append("file", file, Headers.build {
append(HttpHeaders.ContentType, imageFile.mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"${imageFile.name}\"")
})
},
)
)
onUpload { bytesSentTotal, contentLength ->
println("Sent $bytesSentTotal bytes from $contentLength")
}
}.body()
}
์ด ์ฝ๋์์ฒด๋ ์์์ฝ๋์ ์ ๋ง ๋ค๋ฅผ ๊ฒ ์๋ค. mimeType๊ณผ contentDisposition์ contentResolver๋ก ๊ฐ์ ธ์จ๊ฑธ ์ ์ธํ๋ฉด ๋๊ฐ๋ค.
onUpload๋ฅผ ์ฌ์ฉํด๋ณด๊ธฐ ์ํด์ ๋๋ MultiPartFormDataContent๋ฅผ ์ฌ์ฉํ๋๋ฐ ๋ก๊ทธ์ ์๋์ ๊ฐ์ด ์ฐํ๋ค.
Sent 4096 bytes from 233983
Sent 8192 bytes from 233983
Sent 12288 bytes from 233983
Sent 16384 bytes from 233983
Sent 20480 bytes from 233983
Sent 24576 bytes from 233983
Sent 28672 bytes from 233983
Sent 32768 bytes from 233983
Sent 36864 bytes from 233983
Sent 40960 bytes from 233983
Sent 45056 bytes from 233983
Sent 49152 bytes from 233983
Sent 53248 bytes from 233983
... ์๋ตํ๊ฒ ๋ค
4096Bytes ๋จ์ ์ฒญํฌ๋ก ์ ์ก๋๊ณ ์๋ ๋ชจ์ต์ ๋ณผ ์ ์๋ค!
๋์์ด ๋๋ค๋ฉด ๋๊ธ์ด๋ ๊ณต๊ฐ ๋ฒํผ ํ ๋ฒ์ฉ ๋๋ฅด๊ณ ๊ฐ์ฃผ์ธ์!
'Ktor๐ฅ๏ธ > ์ฝ์งโ๏ธ' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
H2, Exposed Deprecated ๊ฑท์ด๋ด๊ธฐ, H2 Datasource ์ถ๊ฐํ๊ธฐ (0) | 2024.10.17 |
---|---|
Serializable ์ค๋ฅ(LocalDateTime, kotlin.serialization) (0) | 2024.10.16 |