บรรทัดคำสั่ง สถานะบน disk และเทสต์ตัวแรก
หกบทที่ผ่านมา orderctl มีโดเมนครบแล้ว — มี data class ที่สร้างสถานะผิดไม่ขึ้น
อ่าน JSON เข้าเป็น Order ได้ คิดยอดเป็น และรายงานทุกข้อที่ผิดในรอบเดียวได้
สิ่งที่มันยังไม่มีคือทุกอย่างที่ทำให้เครื่องมือหนึ่งตัวอยู่รอดในมือคนอื่น คือการรับ argument การคืนเลขให้สคริปต์อ่าน และการจำสิ่งที่ทำไปแล้วโดยไม่ทำของเดิมพัง
Main เหลืองานสองอย่างคือเปิด stream ที่ผูก UTF-8 แล้วแปลงเลขเป็นรหัสออก
· งานจริงย้ายไป Cli.run(Array<String>, PrintStream, PrintStream): Int ที่เทสต์เรียกได้ตรงๆ
เพิ่ม Store ที่บันทึกสถานะแบบล้มกลางทางแล้วของเดิมไม่พัง เพิ่ม State ที่เก็บยอดสะสม
ต่อออเดอร์ และเพิ่มเทสต์ชุดแรกของทั้งคอร์ส
บทนี้ไม่แตะ build.gradle.kts เลยสักบรรทัด · สิบสามบรรทัดของบทที่ 3 มีของครบอยู่แล้ว
รวมทั้งสองบรรทัดของเทสต์ที่เพิ่งได้ใช้จริงในบทนี้
ภาษาไทยที่เพี้ยน — Kotlin ปิดชั้นแรกให้ แล้วเปิดชั้นสุดท้ายทิ้งไว้
หัวข้อที่มีชื่อว่า “ภาษาไทยที่เพี้ยน — Kotlin ปิดชั้นแรกให้ แล้วเปิดชั้นสุดท้ายทิ้งไว้”เนื้อใน file ชื่อของ file และตัวอักษรบนจอ ใช้ charset คนละตัวกัน และ Kotlin แก้ให้แค่ชั้นเดียว
คอร์ส Java เจอเรื่องนี้เป็นสามชั้นที่พังคนละแบบ · ฝั่ง Kotlin ชั้นแรกหายไปจากทางหลัก
เพราะ Path.writeText กับ Path.readText เขียน Charsets.UTF_8 ไว้เป็นค่าปริยายในลายเซ็น
ทางที่ยังพาไปเจอ charset ของเครื่องได้คือการเรียก class ของ Java ตรงๆ เช่น FileWriter
· probe ข้างล่างเขียนข้อความเดียวกันสองทาง แล้วชั่งผลออกมาเป็นไบต์
// src/main/kotlin/orderctl/CharsetProbe.ktpackage orderctl
import java.io.FileWriterimport java.nio.charset.Charsetimport kotlin.io.path.createTempDirectoryimport kotlin.io.path.fileSizeimport kotlin.io.path.readTextimport kotlin.io.path.writeText
fun main() { val dir = createTempDirectory("orderctl-charset") val thai = "ผัดไทยกุ้งสด"
val bySpec = dir.resolve("ออเดอร์.json") bySpec.writeText(thai) // UTF-8 อยู่ในลายเซ็น
val byDefault = dir.resolve("by-default.txt") FileWriter(byDefault.toFile()).use { it.write(thai) } // ผูกกับ charset ของเครื่อง
println("default charset = " + Charset.defaultCharset()) println("sun.jnu.encoding = " + System.getProperty("sun.jnu.encoding")) println("stdout.encoding = " + System.getProperty("stdout.encoding")) println("ชื่อที่อ่านกลับมา = " + bySpec.fileName) println("เนื้อที่อ่านกลับมา = " + bySpec.readText()) println("Path.writeText = " + bySpec.fileSize() + " ไบต์") println("FileWriter = " + byDefault.fileSize() + " ไบต์")}ทุก transcript ของบทนี้รันจากโปรเจกต์ที่ gradle installDist ประกอบไว้แล้ว
· หัวข้อ Store.save ข้างล่างอธิบายคำสั่งนั้นไว้ครบ
บนเครื่องที่ตั้ง locale เป็น UTF-8 อยู่แล้ว สองทางให้ผลเท่ากัน และผลชุดนี้ไม่บอกอะไรเลย
$ java -cp "build/install/orderctl/lib/*" orderctl.CharsetProbeKtdefault charset = UTF-8sun.jnu.encoding = UTF-8stdout.encoding = UTF-8ชื่อที่อ่านกลับมา = ออเดอร์.jsonเนื้อที่อ่านกลับมา = ผัดไทยกุ้งสดPath.writeText = 36 ไบต์FileWriter = 36 ไบต์ของจริงเห็นได้เมื่อผู้ใช้พลิกค่าเอง · รันซ้ำใต้ locale ที่ไม่ใช่ UTF-8
$ LANG=C java -Dfile.encoding=COMPAT -cp "build/install/orderctl/lib/*" orderctl.CharsetProbeKtdefault charset = US-ASCIIsun.jnu.encoding = UTF-8stdout.encoding = US-ASCII????????????????? = ???????.json?????????????????? = ????????????Path.writeText = 36 ????FileWriter = 12 ????สามชั้นแยกกันชัดในผลชุดนี้ และแต่ละชั้นเสียหายคนละระดับ
- เนื้อใน file
Path.writeTextยังได้ 36 ไบต์เท่าเดิม ส่วนFileWriterเหลือ 12 ไบต์ เพราะมันแปลงอักษรไทยทุกตัวเป็นเครื่องหมายคำถามตัวเดียว ข้อมูลตายจริงตรงนี้ - ชื่อของ file
sun.jnu.encodingยังเป็น UTF-8 ชื่อออเดอร์.jsonจึงกลับมาครบทุกไบต์ ต่อให้จอแสดงมันเป็น???????.json - ตัวอักษรบนจอ
stdout.encodingเป็น US-ASCII ทุกอย่างที่พิมพ์ออกมาจึงกลายเป็น?รวมทั้งของที่ยังดีอยู่
String.toByteArray() ของ Kotlin ประกาศ charset: Charset = Charsets.UTF_8 ไว้ในลายเซ็น
· String.getBytes() ของ Java ไม่มี argument นั้น มันไปหยิบ charset ของเครื่องมาใช้แทน
วัดข้อความเดียวกันใต้ LANG=C กับ -Dfile.encoding=COMPAT แล้วได้เลขคนละตัว
| การเรียก | ปกติ | ใต้ COMPAT |
|---|---|---|
"ผัดไทยกุ้งสด".toByteArray() ของ Kotlin | 36 | 36 |
"ผัดไทยกุ้งสด".getBytes() ของ Java | 36 | 12 |
นี่คือความหมายของประโยคที่ว่า Kotlin ปิดชั้นแรกให้ · มันไม่ได้เปลี่ยน JVM เลยสักที่ มันเปลี่ยนค่าปริยายของ function ที่คุณเรียกทุกวัน
ชั้นที่สาม Kotlin ไม่ได้แก้ให้ println ยังวิ่งลง System.out ตัวเดิม
· ทางแก้จึงเป็นสองบรรทัดเดียวกับที่คอร์ส Java ใช้ และมันอยู่ใน Main ของหัวข้อถัดๆ ไป
การเขียนทับทำลายของเก่าตั้งแต่จังหวะที่เปิด
หัวข้อที่มีชื่อว่า “การเขียนทับทำลายของเก่าตั้งแต่จังหวะที่เปิด”วัดขนาดของปลายทางตอนเพิ่งเปิด ก่อนเขียนอะไรลงไปแม้แต่ไบต์เดียว
Path.writeText ที่ไม่ระบุ option เรียก Files.newOutputStream ด้วยชุด CREATE
บวก TRUNCATE_EXISTING บวก WRITE · เปิดชุดเดียวกันนั้นด้วยมือแล้ววัดขนาดทันที
// src/main/kotlin/orderctl/TruncateProbe.ktpackage orderctl
import java.nio.file.Filesimport java.nio.file.StandardOpenOptionimport kotlin.io.path.createTempDirectoryimport kotlin.io.path.fileSizeimport kotlin.io.path.writeText
fun main() { val state = createTempDirectory("orderctl-truncate").resolve("state.json") state.writeText("""{"totals":{"A-1":15500}}""") println("ก่อนเปิด " + state.fileSize() + " ไบต์")
Files.newOutputStream( state, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE, ).use { out -> println("เปิดแล้ว " + state.fileSize() + " ไบต์") out.write("""{"totals":{}}""".toByteArray()) } println("เขียนจบ " + state.fileSize() + " ไบต์")}$ java -cp "build/install/orderctl/lib/*" orderctl.TruncateProbeKtก่อนเปิด 24 ไบต์เปิดแล้ว 0 ไบต์เขียนจบ 13 ไบต์บรรทัดกลางคือปัญหาทั้งหมด สถานะเก่าหายไปตั้งแต่ก่อนที่ข้อมูลใหม่จะถูกเขียนสักตัว · โปรแกรมที่ตายตรงจังหวะนั้นทิ้ง file เปล่าไว้ให้ผู้ใช้ ไม่ใช่ file เก่า
ช่วงนั้นสั้น แต่มันไม่ใช่ศูนย์ และงานที่รันวันละหลายพันครั้งจะเจอมันเข้าสักวัน · เลขสามตัวนี้ตรงกับที่คอร์ส Java วัดไว้ทุกตัว เพราะทั้งสองภาษาเปิด option ชุดเดียวกัน
เขียนข้างๆ แล้วสลับชื่อ
หัวข้อที่มีชื่อว่า “เขียนข้างๆ แล้วสลับชื่อ”ท่า atomic คือไม่แตะปลายทางเลยจนกว่าของใหม่จะครบ แล้วสลับชื่อในก้าวเดียว
atomic file writeatomic file writeเขียนลงชื่อชั่วคราวใน directory เดียวกับปลายทางก่อน แล้วย้ายด้วย `ATOMIC_MOVE` · `moveTo(target, overwrite = true)` ไม่ใช่รูปนี้ ตัว stdlib แปลมันเป็น `REPLACE_EXISTING` ตัวเดียว ย้ายจังหวะเสี่ยงออกไปจากปลายทาง · เขียนลงชื่อชั่วคราวจนจบก่อน
แล้วค่อยย้ายด้วย ATOMIC_MOVE
flowchart TD A["createTempFile<br/>ใน directory เดียวกับปลายทาง"] --> B["writeText<br/>เขียนข้อมูลทั้งก้อน"] B --> C["setPosixFilePermissions<br/>createTempFile ให้ rw------- มา"] C --> D["moveTo กับ ATOMIC_MOVE<br/>สลับชื่อในก้าวเดียว"] D --> E["ผู้อ่านเห็นของเก่าครบ<br/>หรือของใหม่ครบ"] B -. "ล้ม" .-> X["deleteIfExists<br/>ปลายทางไม่เคยถูกแตะ"] C -. "ล้ม" .-> X D -. "ล้ม" .-> X
คำบรรยายภาพ: ทางปกติไล่จาก createTempFile ไป writeText ไป setPosixFilePermissions แล้วจบที่ moveTo · ทุกจุดที่ล้มกลางทางกลับไปลบชื่อชั่วคราวทิ้ง โดยปลายทางไม่ถูกแตะเลย
สถานะที่เราเก็บคือยอดรวมต่อออเดอร์ · หนึ่ง data class หนึ่ง Map
// src/main/kotlin/orderctl/State.ktpackage orderctl
import kotlinx.serialization.Serializable
@Serializabledata class State(val totals: Map<String, Long>)Store ทำท่าทั้งชุดให้ และใช้ Json ตัวเดียวกันทั้งขาอ่านและขาเขียน
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี State อยู่ในโปรเจกต์ด้วย// src/main/kotlin/orderctl/Store.ktpackage orderctl
import kotlinx.serialization.json.Jsonimport java.nio.file.Filesimport java.nio.file.Pathimport java.nio.file.StandardCopyOptionimport java.nio.file.attribute.PosixFileAttributeViewimport java.nio.file.attribute.PosixFilePermissionsimport kotlin.io.path.createParentDirectoriesimport kotlin.io.path.deleteIfExistsimport kotlin.io.path.existsimport kotlin.io.path.getPosixFilePermissionsimport kotlin.io.path.moveToimport kotlin.io.path.readTextimport kotlin.io.path.writeText
/** เก็บสถานะแบบได้ทั้งหมดหรือไม่ได้เลย · ผู้อ่านเห็นของเก่าครบ หรือของใหม่ครบ */object Store { val json = Json { prettyPrint = true }
fun save(target: Path, state: State) { val full = target.toAbsolutePath() full.createParentDirectories()
// ชื่อชั่วคราวต้องอยู่ใน directory เดียวกับปลายทาง ไม่ใช่ java.io.tmpdir val tmp = Files.createTempFile(full.parent, full.fileName.toString(), ".tmp") try { tmp.writeText(json.encodeToString(state)) applyPermissions(full, tmp) tmp.moveTo(full, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } catch (e: Throwable) { tmp.deleteIfExists() // ล้มกลางทางแล้วต้องไม่ทิ้งขยะ .tmp ไว้ throw e } }
fun load(source: Path): State = json.decodeFromString(source.readText())
/** createTempFile ให้สิทธิ์ rw------- มา · ปลายทางที่ยังไม่มี ต้องตั้งค่าเองให้ชัด */ private fun applyPermissions(target: Path, tmp: Path) { Files.getFileAttributeView(tmp, PosixFileAttributeView::class.java) ?: return Files.setPosixFilePermissions( tmp, if (target.exists()) target.getPosixFilePermissions() else PosixFilePermissions.fromString("rw-r--r--"), ) }}สามรายละเอียดในนั้นกัดจริง และข้อกลางมีเทสต์ของหัวข้อท้ายบทตรึงไว้แล้วหนึ่งข้อ
Files.createTempFileแบบสาม argument argument แรกคือ directory ปลายทาง · แบบสอง argument วางของไว้ที่java.io.tmpdirซึ่งเป็นบั๊กที่รอวันแสดงตัวapplyPermissionsไม่ใช่ของฟุ่มเฟือย javadoc ของcreateTempFileเขียนว่า file ที่ได้ “may have more restrictive access permissions” และบน macOS มันคือrw-------REPLACE_EXISTINGที่ใส่คู่กับATOMIC_MOVEเป็นการประกาศเจตนาให้คนอ่าน ไม่ใช่ คำสัญญาของสเปก · javadoc เขียนว่าATOMIC_MOVEทำให้ “all other options are ignored”
รูปสั้นที่คนชอบใช้ ไม่ใช่ท่า atomic
หัวข้อที่มีชื่อว่า “รูปสั้นที่คนชอบใช้ ไม่ใช่ท่า atomic”
kotlin.io.pathมีmoveToสองรูป และรูปที่สั้นกว่าไม่ได้ส่งATOMIC_MOVEไปเลยสักครั้ง
moveTo มี overload สองตัว · ตัวหนึ่งรับ vararg ของ CopyOption ตามที่ Store.save ใช้
· อีกตัวรับ Boolean ชื่อ overwrite ซึ่งสั้นกว่ามากและอ่านเหมือนพอกัน
มันไม่พอกัน และหลักฐานอยู่ใน bytecode ของ kotlin-stdlib เอง ไม่ใช่ในเอกสาร
$ unzip -o -q "$KOTLIN_HOME/lib/kotlin-stdlib.jar" 'kotlin/io/path/*'$ javap -c -p kotlin/io/path/PathsKt__PathUtilsKt.class \ | grep -A 20 'moveTo(java.nio.file.Path, java.nio.file.Path, boolean)' \ | grep 'CopyOption' 17: anewarray #181 // class java/nio/file/CopyOption 25: getstatic #187 // Field java/nio/file/StandardCopyOption.REPLACE_EXISTING:Ljava/nio/file/StandardCopyOption; 35: anewarray #181 // class java/nio/file/CopyOptionอ่านสามบรรทัดนี้ได้ตรงๆ · เมื่อ overwrite เป็นจริง มันสร้าง array หนึ่งช่องแล้วใส่
REPLACE_EXISTING ลงไปช่องเดียว · เมื่อเป็นเท็จ มันสร้าง array เปล่า
· ไม่มีกิ่งไหนเอ่ยถึง ATOMIC_MOVE เลย
ความต่างนี้มองไม่เห็นบนเครื่องเดียว เพราะทั้งสองรูปย้ายสำเร็จเหมือนกัน · มันแยกกันเมื่อต้นทางกับปลายทางอยู่คนละอุปกรณ์
วัดโดยสร้าง ram disk แบบ HFS+ ขึ้นมาแล้วย้ายจากที่นั่นไปยัง APFS บน Kotlin 2.4.10 กับ JDK 25 บน macOS arm64 เมื่อ 2026-08-28 · probe ตัวเล็กที่เรียกสองรูปเรียงกันพิมพ์สองบรรทัดนี้ออกมา
overwrite = true -> okATOMIC_MOVE -> java.nio.file.AtomicMoveNotSupportedException: /private/tmp/ramd7/b.tmp -> /private/tmp/near/b.json: Cross-device linkรูปที่ดูใจดีกว่าคือรูปที่แย่กว่า มันไม่บ่นเพราะมันไปคัดลอกแล้วลบต้นทาง ซึ่งเป็นสองก้าว ไม่ใช่ก้าวเดียว · ระหว่างสองก้าวนั้นปลายทางมีสภาพครึ่งใบได้
ATOMIC_MOVE เลือกที่จะแดงแทนที่จะเงียบ · และเสียงแดงนั้นคือเหตุผลที่ชื่อชั่วคราว
ต้องอยู่ใน directory เดียวกับปลายทางเสมอ ไม่ใช่ที่ /tmp
Cli.run ที่คืน Int กับ main ที่บาง
หัวข้อที่มีชื่อว่า “Cli.run ที่คืน Int กับ main ที่บาง”ตรรกะทั้งหมดอยู่ใน function ที่คืน
Intและรับ stream สองตัวเข้ามา เทสต์จึงเรียกมันได้ตรงๆ
fun main(args: Array<String>) ไม่คืนอะไร เทสต์จึงไม่มีทางดูผลของมันได้
· ทางแก้คือย้ายทุกอย่างไป function ที่คืน Int แล้วให้ main ทำหน้าที่เดียวคือแปลงเป็นรหัสออก
out กับ err ต้องเป็น argument ด้วย ไม่ใช่ println ที่เขียนตรงๆ
· เทสต์จะได้ส่ง ByteArrayOutputStream เข้าไปแทน แล้วอ่านสิ่งที่โปรแกรมพิมพ์
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · Line Totals validate load Failure และ OrderException มาจากบทก่อนหน้าpackage orderctl
import kotlinx.serialization.Serializableimport kotlinx.serialization.SerializationExceptionimport java.io.PrintStreamimport java.nio.file.Pathimport kotlin.io.path.exists
@Serializabledata class Order(val id: String, val lines: List<Line>)
/** ตรรกะทั้งหมดของบรรทัดคำสั่ง · ไม่มี exitProcess และไม่มี println เลยสักตัว */object Cli { private const val USAGE = "ใช้: orderctl validate <order.json> <table> | orderctl total <order.json> [state.json]"
fun run(args: Array<String>, out: PrintStream, err: PrintStream): Int { if (args.size < 2) { err.println(USAGE) return 3 } return try { when (args[0]) { "validate" -> { val table = args.getOrNull(2)?.toIntOrNull() if (table == null) { err.println(USAGE) return 3 } val order = readOrder(args[1]) validate(order.id, table, order.lines) out.println("ผ่าน") 0 } "total" -> { val order = readOrder(args[1]) out.println(Totals.receiptLine(order.lines)) if (args.size > 2) recordTotal(Path.of(args[2]), order) 0 } else -> { err.println("ไม่รู้จักคำสั่ง ${args[0]}") 3 } } } catch (e: OrderException) { err.println("orderctl: ${e.message}") for (f in e.failures) err.println(" - ${f.describe()}") e.failures.first().exitCode() } }
/** load ของบทที่ 6 ห่อ file ที่หายไปให้แล้ว · เหลือแค่รูป JSON ที่ผิดที่ต้องห่อเอง */ private fun readOrder(path: String): Order = try { Store.json.decodeFromString<Order>(load(path)) } catch (e: SerializationException) { throw OrderException.wrapping("ข้อมูลใน $path ใช้ไม่ได้", Failure.Io(path, "รูปไม่ตรง"), e) }
private fun recordTotal(statePath: Path, order: Order) { try { val totals = LinkedHashMap<String, Long>() if (statePath.exists()) totals.putAll(Store.load(statePath).totals) totals[order.id] = Totals.grossSatang(order.lines) Store.save(statePath, State(totals)) } catch (e: java.io.IOException) { throw OrderException.wrapping( "เขียนสถานะลง $statePath ไม่ได้", Failure.Io(statePath.toString(), "$e"), e, ) } }}run มี catch ก้อนเดียว และตัวแปลง Failure เป็นเลขก็มีที่เดียว คือ exitCode()
ที่บทที่ 6 เขียนไว้แล้ว · ทุกเส้นทางที่ผิดจึงไหลมารวมกันที่บรรทัดเดียวกันเสมอ
Order ตัวใหม่ถือ List<Line> แทน totalSatang ก้อนเดียวของบทที่ 3
· Domain.kt ของบทที่ 4 จึงต้องติด @Serializable เพิ่มสามที่ และตั้งชื่อคีย์อีกหนึ่งที่
// src/main/kotlin/orderctl/Domain.ktpackage orderctl
import kotlinx.serialization.SerialNameimport kotlinx.serialization.Serializable
@Serializable@JvmInlinevalue class Sku(val code: String) { init { require(code.isNotBlank()) { "sku ว่างไม่ได้" } }}
@Serializable@JvmInlinevalue class Satang(val v: Long) { init { require(v >= 0) { "ราคาติดลบไม่ได้" } }}
@Serializabledata class Line( val sku: Sku, val category: String, val qty: Int, @SerialName("unitSatang") val unit: Satang,) { init { require(qty > 0) { "qty ต้องมากกว่า 0 แต่ได้ $qty" } }
fun subtotal(): Satang = Satang(unit.v * qty)}
fun lineOf(sku: String, category: String, qty: Int, unitSatang: Long) = Line(Sku(sku.trim().uppercase()), category.trim().lowercase(), qty, Satang(unitSatang))value classvalue classชนิดที่ห่อค่าเดียวแล้วถูกคลี่เป็นค่าดิบตอนแปล บน JVM ต้องติด `@JvmInline` · `javap` แสดงลายเซ็นเป็น `long` ล้วน แต่มันกลับเข้ากล่องเมื่อเป็น nullable อยู่ใน generic หรือถูกมองผ่าน interface ที่ติด @Serializable ถูกเขียนลง JSON เป็นค่าดิบข้างใน ไม่ใช่เป็น object
ที่มีคีย์เดียว · @SerialName("unitSatang") จึงทำให้ order.json ใบเดียวกันป้อนได้ทั้งคอร์สนี้
และคอร์ส Java · ฝั่ง Go ตั้งคีย์ไว้ว่า unit ตั้งแต่บทที่ 3 ใบเดียวกันจึงข้ามไปไม่ได้
Main ที่เหลือมีงานสองอย่าง คือเปิด stream ที่ผูก UTF-8 แล้วแปลงเลขที่ได้เป็นรหัสออก
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · เรียก Cli จาก block ข้างบน// src/main/kotlin/orderctl/Main.ktpackage orderctl
import java.io.FileDescriptorimport java.io.FileOutputStreamimport java.io.PrintStreamimport kotlin.system.exitProcess
fun main(args: Array<String>) { // System.out ใช้ charset ของ console ภาษาไทยจึงกลายเป็น ? บนเครื่องที่ไม่ใช่ UTF-8 val out = PrintStream(FileOutputStream(FileDescriptor.out), true, Charsets.UTF_8) val err = PrintStream(FileOutputStream(FileDescriptor.err), true, Charsets.UTF_8) exitProcess(Cli.run(args, out, err))}สอง PrintStream นั้นคือคำตอบของหัวข้อแรก ที่ขอบของโปรแกรมแทนที่จะไปแก้ที่ผู้ใช้
· วัดผลของทั้งสองรูปใต้ locale ที่ไม่ใช่ UTF-8 แล้วได้คนละบรรทัด
$ LANG=C JAVA_OPTS="-Dfile.encoding=COMPAT" build/install/orderctl/bin/orderctl total order.json2 รายการ รวม 15500 สตางค์ถอดสองบรรทัดนั้นออกแล้วส่ง System.out กับ System.err เข้าไปแทน คำสั่งเดิมให้ผลนี้
$ LANG=C JAVA_OPTS="-Dfile.encoding=COMPAT" build/install/orderctl/bin/orderctl total order.json2 ?????? ??? 15500 ??????สถานะที่ Store.save ทิ้งไว้บน disk
หัวข้อที่มีชื่อว่า “สถานะที่ Store.save ทิ้งไว้บน disk”
installDistให้สคริปต์ที่เรียกได้ แล้ว file ที่มันทิ้งไว้ต่างจากของคอร์ส Java ไบต์ต่อไบต์
ทั้งบทนี้เรียกโปรแกรมผ่านชื่อสั้นตัวเดียว ซึ่งประกาศไว้ครั้งเดียวแบบนี้
gradle -q installDistorderctl() { build/install/orderctl/bin/orderctl "$@"; }installDist มาจาก application plugin ที่ build.gradle.kts ของบทที่ 3 เปิดไว้แล้ว
· มันวางสคริปต์ที่รันได้ไว้ให้ ซึ่งเป็นสิ่งที่หัวข้อถัดไปต้องใช้และ gradle run ให้ไม่ได้
ตัวอย่างสองใบที่บทนี้ใช้ มีคีย์ category ที่บทที่ 4 เพิ่มเข้าไปใน Line แล้วครบทั้งสองใบ
cat > order.json <<'JSON'{"id":"A-1","lines":[ {"sku":"phad-thai","category":"food","qty":2,"unitSatang":6000}, {"sku":"cha-yen","category":"drink","qty":1,"unitSatang":3500}]}JSON
cat > order2.json <<'JSON'{"id":"A-2","lines":[{"sku":"kaeng-keaw-wan","category":"food","qty":2,"unitSatang":9000}]}JSON$ orderctl total order.json state.json2 รายการ รวม 15500 สตางค์$ orderctl total order2.json state.json1 รายการ รวม 18000 สตางค์$ cat state.json ; echo{ "totals": { "A-1": 15500, "A-2": 18000 }}$ /bin/ls -l state.json-rw-r--r--@ 1 nook wheel 68 Aug 28 23:06 state.json; echo ในบรรทัดที่สามไม่ใช่ของประดับ · Json { prettyPrint = true } ไม่ปิดท้ายด้วย
newline ถ้าไม่มี echo ตัวปิดวงเล็บจะไปติดกับ prompt ถัดไป
โครงของข้อมูลเหมือนกันทั้งสามคอร์ส แต่สามภาษาจัดรูปแบบคนละแบบ
| คอร์ส | ตัวจัดรูปแบบ | ผลที่ต่าง |
|---|---|---|
| Go | json.MarshalIndent(s, "", " ") | เยื้องสองช่อง ไม่มีช่องว่างหน้า colon |
| Java | writerWithDefaultPrettyPrinter() | เยื้องสองช่อง มีช่องว่างทั้งสองข้างของ colon |
| Kotlin | Json { prettyPrint = true } | เยื้องสี่ช่อง มีช่องว่างหลัง colon อย่างเดียว |
state.json ของ Kotlin จึงยาว 68 ไบต์ ขณะที่ file ที่ถือข้อมูลชุดเดียวกันของคอร์ส Java
ยาว 59 ไบต์ · ถ้าคุณเทียบผลของสองคอร์สด้วย diff ให้ผ่าน jq ก่อน อย่าเทียบไบต์
ตารางรหัสออก — และคำสั่งที่กลืนมันทิ้ง
หัวข้อที่มีชื่อว่า “ตารางรหัสออก — และคำสั่งที่กลืนมันทิ้ง”สคริปต์ที่เรียก
orderctlอ่านเลขเดียวคือ$?และgradle runไม่ยอมส่งเลขนั้นออกมา
| เลข | ความหมายใน orderctl | ใครเป็นคนกำหนด |
|---|---|---|
| 0 | ทำงานจบตามปกติ | Cli.run |
| 1 | exception ที่ไม่มีใครดัก | JVM ให้มาเอง ห้ามเปลี่ยน |
| 2 | ออเดอร์ไม่ผ่านการตรวจ | exitCode ของบทที่ 6 |
| 3 | พิมพ์คำสั่งผิด | Cli.run ของบทนี้ |
| 4 | อ่านหรือเขียน file ไม่ได้ | exitCode ของบทที่ 6 |
เลข 3 ที่ดูแปลกคือของจริง ธรรมเนียม Unix ให้ 2 กับการพิมพ์คำสั่งผิด แต่บทที่ 6 จองเลข 2 ไปแล้ว · ตารางรหัสออกต้องมีเจ้าของคนเดียว บทนี้จึงหยิบเลขที่ยังว่างแทน
ห้าท่าข้างล่างคือทั้งตารางที่รันจริง · สังเกตว่าวิธีใช้กับข้อความความผิดพลาดไปที่ stderr ส่วนผลของงานไปที่ stdout
$ orderctl ; echo "exit=$?"ใช้: orderctl validate <order.json> <table> | orderctl total <order.json> [state.json]exit=3
$ orderctl validate order.json 99 ; echo "exit=$?"orderctl: ออเดอร์ A-1 ไม่ผ่านการตรวจ 1 ข้อ - field table = "99": ต้องอยู่ระหว่าง 1 ถึง 40exit=2
$ orderctl validate order.json 4 ; echo "exit=$?"ผ่านexit=0
$ orderctl total no-such.json ; echo "exit=$?"orderctl: อ่านออเดอร์ไม่สำเร็จ - อ่าน no-such.json ไม่ได้: ไม่พบ fileexit=4
$ orderctl explode a b ; echo "exit=$?"ไม่รู้จักคำสั่ง explodeexit=3ท่าที่บทที่ 3 ถึงบทที่ 6 ใช้คือ gradle run และมันยังใช้ได้อยู่ตราบใดที่งานสำเร็จ
$ gradle -q run --args="validate order.json 4"ผ่านแต่พอรหัสออกไม่ใช่ศูนย์ Gradle ถือว่า task ล้ม แล้วคืนรหัสของตัวเองแทน
$ gradle run --args="validate order.json 99" > /dev/null 2>&1 ; echo "exit=$?"exit=1เลข 2 ตัวจริงไม่ได้หายไปเสียทีเดียว มันไปโผล่กลางประโยคภาษาอังกฤษบรรทัดหนึ่งที่ว่า
finished with non-zero exit value 2 ซึ่งไม่มีสคริปต์ไหนอ่าน
นี่คือเหตุผลที่ตารางข้างบนต้องวัดผ่าน installDist สคริปต์ที่ application plugin
วางไว้ให้ส่งรหัสออกของโปรแกรมออกมาตรงๆ · ตารางเดียวกันที่วัดผ่าน gradle run ได้ 1 สี่แถว
กับ 0 หนึ่งแถว ซึ่งกลืนความต่างระหว่างเลข 2 กับเลข 3 กับเลข 4 ไปหมด
exitProcessexitProcessคำสั่งจบโปรแกรมที่ประกาศชนิดคืนค่าเป็น `Nothing` จึงวางท้าย elvis ได้ · มันข้ามทั้ง `finally` และ `use` ทั้งก้อน เหลือแต่ shutdown hook ที่ยังทำงาน ข้าม finally และข้าม use ด้วย
หัวข้อที่มีชื่อว่า “exitProcess ข้าม finally และข้าม use ด้วย”บทที่ 6 เพิ่งสอนว่า
useปิดทรัพยากรให้เสมอ · บทนี้ต้องเติมคำว่า “เกือบ” เข้าไป
exitProcess ประกาศชนิดคืนค่าเป็น Nothing มันจึงวางท้าย elvis ได้และ compiler
ยอมรับว่า code ที่ตามมาไม่มีวันทำงาน · สิ่งที่มันไม่ได้บอกคือของที่มันข้ามไปด้วย
// src/main/kotlin/orderctl/ExitProbe.ktpackage orderctl
import java.io.Closeableimport kotlin.system.exitProcess
class Noisy(private val name: String) : Closeable { override fun close() = println("ปิด $name")}
fun main(args: Array<String>) { Noisy("state.json").use { try { if (args.isNotEmpty()) exitProcess(4) error("ไม่มีใครดักตัวนี้") } finally { println("finally ได้ทำงาน") } }}รอบแรกปล่อยให้ exception หลุดออกไป · ทั้ง finally และ close ได้ทำงานครบตามที่บทที่ 6 สัญญาไว้
$ java -cp "build/install/orderctl/lib/*" orderctl.ExitProbeKt ; echo "exit=$?"finally ได้ทำงานปิด state.jsonException in thread "main" java.lang.IllegalStateException: ไม่มีใครดักตัวนี้ at orderctl.ExitProbeKt.main(ExitProbe.kt:15)exit=1รอบที่สองใส่ argument เข้าไปหนึ่งตัว เพื่อให้เส้นทางวิ่งเข้า exitProcess(4)
$ java -cp "build/install/orderctl/lib/*" orderctl.ExitProbeKt x ; echo "exit=$?"exit=4ไม่มีอะไรพิมพ์ออกมาเลยสักบรรทัด ทั้ง finally ได้ทำงาน และ ปิด state.json หายไปทั้งคู่
exitProcess เป็นตัวห่อ System.exit ของ Java · javadoc ของ exit เขียนว่ามันเริ่ม
ลำดับการปิดของ JVM แล้วบล็อกไปตลอด โดยไม่คืนค่าและไม่โยน exception
finally รอจังหวะที่ไม่มีวันมาถึง และ use ก็แปลเป็น try/finally ตัวหนึ่งเหมือนกัน
· สิ่งที่บทที่ 6 สอนไว้ยังจริงทุกข้อ ยกเว้นข้อเดียวคือเมื่อมีคนสั่งจบ process กลางทาง
ที่ยังทำงานคือ shutdown hook เท่านั้น · เติม Runtime.getRuntime().addShutdownHook
เข้าไปใน probe ตัวเดิมแล้วรันซ้ำ จอพิมพ์ shutdown hook ทำงาน ออกมาบรรทัดเดียว
แล้วจบด้วยรหัส 4 ส่วน finally กับ close ยังเงียบเหมือนเดิมทั้งคู่
แต่ hook ไม่ได้ถือทรัพยากรที่คุณเปิดไว้ มันจึงปิดของให้คุณไม่ได้
ข้อสรุปมีข้อเดียว — เรียก exitProcess ที่เดียวคือใน main หลังทุกอย่างคืนค่ากลับมาแล้ว
ซึ่งเป็นเหตุผลที่ Cli.run คืน Int แทนที่จะจบ process เอง
คนที่มาจาก Python จะสะดุดตรงนี้ เพราะ sys.exit() โยน SystemExit ซึ่งเป็น exception จริง
finally จึงได้ทำงาน · คนที่มาจาก Go เจอกฎเดียวกับ Kotlin เพราะเอกสารของ os.Exit
เขียนเองว่า deferred function จะไม่ถูกเรียก
เทสต์ตัวแรกของคอร์ส และชื่อมันเป็นภาษาไทย
หัวข้อที่มีชื่อว่า “เทสต์ตัวแรกของคอร์ส และชื่อมันเป็นภาษาไทย”
kotlin-testให้@Testกับ assert มา แต่ตัวรันจริงมาจาก artifact อีกใบที่บรรทัดเดียวเลือกให้
kotlin-testkotlin-testชั้น API กลางของการทดสอบที่ผูกกับ framework จริงด้วย typealias · ตัวมันเองไม่มี `@Test` ให้ ต้องมี artifact ของ framework คู่กันเสมอ ไม่งั้นได้ `unresolved reference` เข้ามาที่บทนี้ ไม่ใช่ก่อนหน้านี้ · แต่สองบรรทัดที่มันต้องการอยู่ใน
build.gradle.kts ตั้งแต่บทที่ 3 แล้ว บทนี้จึงไม่ต้องเติมอะไรเลย
dependencies { testImplementation(kotlin("test")) }tasks.test { useJUnitPlatform() }สองบรรทัดนี้ทำงานคนละอย่าง และการมีแค่บรรทัดเดียวไม่พอ
- บรรทัดแรกให้ API ถอดออกแล้ว
gradle testตอบUnresolved reference 'test'.ที่บรรทัดimportและตอบUnresolved reference 'Test'.ที่ทุก@Test - บรรทัดที่สองเลือกตัวรัน ถอดออกแล้วยังเขียว แต่
kotlin("test")เปลี่ยนไปลาก ตัวของ JUnit 4 มาแทนตัวของ JUnit 5 โดยไม่มีคำเตือนสักบรรทัด
ข้อที่สองคือเหตุผลที่ glossary เรียก kotlin-test ว่าชั้น API · ตัวมันเองไม่มี @Test
ให้ kotlin.test.Test เป็น typealias ที่ชี้ไปหา annotation ของ framework ที่บรรทัดล่างเลือก
Cli.run ที่รับทั้ง argument และ stream ครบ ถูกเรียกจากเทสต์ได้เหมือน function ธรรมดา
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Cli กับชนิดของโดเมนอยู่ในโปรเจกต์ด้วย// src/test/kotlin/orderctl/CliTest.ktpackage orderctl
import java.io.ByteArrayOutputStreamimport java.io.PrintStreamimport java.nio.file.Pathimport kotlin.io.path.createTempDirectoryimport kotlin.io.path.writeTextimport kotlin.test.Testimport kotlin.test.assertEqualsimport kotlin.test.assertTrue
class CliTest { private val outBytes = ByteArrayOutputStream() private val errBytes = ByteArrayOutputStream()
private fun run(vararg args: String): Int = Cli.run( arrayOf(*args), PrintStream(outBytes, true, Charsets.UTF_8), PrintStream(errBytes, true, Charsets.UTF_8), )
private fun out() = outBytes.toString(Charsets.UTF_8) private fun err() = errBytes.toString(Charsets.UTF_8)
private fun order(): Path { val p = createTempDirectory("orderctl").resolve("order.json") p.writeText(ORDER) return p }
@Test fun `ไม่ใส่อะไรเลย ได้รหัส 3 วิธีใช้ไปที่ stderr และ stdout ต้องว่าง`() { assertEquals(3, run()) assertTrue(err().startsWith("ใช้: orderctl"), err()) assertEquals("", out()) }
@Test fun `ปลายทางที่ไม่มีอยู่ ได้รหัส 4 และรายงานของบทที่ 6 ยังอ่านออก`() { assertEquals(4, run("total", "no-such.json")) assertTrue(err().contains("ไม่พบ file"), err()) }
@Test fun `total พิมพ์ยอดที่ stdout และไม่แตะ stderr เลย`() { assertEquals(0, run("total", order().toString())) assertEquals("2 รายการ รวม 15500 สตางค์\n", out()) assertEquals("", err()) }
@Test fun `validate โต๊ะ 4 ได้ 0 ส่วนโต๊ะ 99 ได้ 2`() { val p = order().toString() assertEquals(0, run("validate", p, "4")) assertEquals(2, run("validate", p, "99")) }
private companion object { const val ORDER = """{"id":"A-1","lines":[ {"sku":"phad-thai","category":"food","qty":2,"unitSatang":6000}, {"sku":"cha-yen","category":"drink","qty":1,"unitSatang":3500}]}""" }}ชื่อ function ใน backtick เข้าไปเป็นชื่อ method ของ JVM ตรงๆ · รายงานที่พิมพ์ชื่อ method จึงพิมพ์ประโยคไทยนั้นออกมาโดยไม่ต้องตั้งอะไรเลย
ฝั่ง Java ชื่อ method เป็นอังกฤษเสมอ ประโยคไทยจึงต้องไปอยู่ใน @DisplayName
แล้วเปิดด้วย usePhrasedTestCaseMethodName ที่ statelessTestsetReporter อีกชั้น
· และคอร์ส Java วัดแล้วว่าจอไม่มีทางแสดงมันเลย เพราะ option เดียวกันบน
statelessTestsetInfoReporter ซึ่งเป็นตัวที่คุมจอ ถูกปฏิเสธด้วย
Cannot find 'usePhrasedTestCaseMethodName' แล้ว build ล้ม
kotlin-test ไม่มีของแบบ @TempDir ให้ · ตัวที่ใช้แทนคือ createTempDirectory()
ของ kotlin.io.path ซึ่งไม่ลบตัวเองให้เมื่อเทสต์จบ นั่นคือราคาของชั้น API ที่บางกว่า
เทสต์อีกชุดจับ Store โดยตรง ซึ่งเป็นที่เดียวที่ข้อสังเกตเรื่องสิทธิ์ถูกตรึงไว้เป็นด่าน
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Store กับ State อยู่ในโปรเจกต์ด้วย// src/test/kotlin/orderctl/StoreTest.ktpackage orderctl
import java.nio.file.attribute.PosixFilePermissionsimport kotlin.io.path.createTempDirectoryimport kotlin.io.path.getPosixFilePermissionsimport kotlin.io.path.setPosixFilePermissionsimport kotlin.io.path.writeTextimport kotlin.test.Testimport kotlin.test.assertEquals
class StoreTest { private val sample = State(mapOf("A-1" to 15500L))
@Test fun `save แล้ว load กลับได้ค่าเดิม และสิทธิ์เดิมของปลายทางไม่หาย`() { val state = createTempDirectory("orderctl").resolve("state.json") state.writeText("""{"totals":{}}""") state.setPosixFilePermissions(PosixFilePermissions.fromString("rw-rw-r--"))
Store.save(state, sample)
assertEquals("rw-rw-r--", PosixFilePermissions.toString(state.getPosixFilePermissions())) assertEquals(sample, Store.load(state)) }
@Test fun `ปลายทางที่ยังไม่มี ได้ rw-r--r-- ไม่ใช่ rw------- ของ createTempFile`() { val state = createTempDirectory("orderctl").resolve("state.json")
Store.save(state, sample)
assertEquals("rw-r--r--", PosixFilePermissions.toString(state.getPosixFilePermissions())) }}จอที่เขียวไม่ยอมบอกว่ารันไปกี่ข้อ
หัวข้อที่มีชื่อว่า “จอที่เขียวไม่ยอมบอกว่ารันไปกี่ข้อ”Gradle พิมพ์จำนวนเทสต์ให้ดูเฉพาะตอนแดง · รอบที่เขียวมันเงียบสนิท
รันชุดที่เขียนไว้ทั้งหกข้อ แล้วนี่คือทุกบรรทัดที่หน้าจอให้กลับมา
$ gradle -q test ; echo "exit=$?"exit=0ไม่มีเลข 6 อยู่ที่ไหนเลย ผู้อ่านจึงแยกไม่ออกระหว่าง “รันหกข้อแล้วผ่านหมด” กับ “ไม่มีเทสต์ให้รันสักข้อ” ซึ่งเป็นสองสถานะที่หน้าตาเหมือนกันเป๊ะ
ตัวเลขมีอยู่จริง แต่มันอยู่ในรายงาน ไม่ได้อยู่บนจอ · ดึงออกมาด้วยคำสั่งเดียว
$ grep -o 'tests="[0-9]*" skipped="[0-9]*" failures="[0-9]*"' build/test-results/test/*.xmlbuild/test-results/test/TEST-orderctl.CliTest.xml:tests="4" skipped="0" failures="0"build/test-results/test/TEST-orderctl.StoreTest.xml:tests="2" skipped="0" failures="0"ทำให้ข้อหนึ่งแดงแล้วรันซ้ำ · คราวนี้ทั้งชื่อภาษาไทยและจำนวนขึ้นมาบนจอพร้อมกัน
$ gradle test 2>&1 | grep -E '^CliTest|tests completed'CliTest > ไม่ใส่อะไรเลย ได้รหัส 3 วิธีใช้ไปที่ stderr และ stdout ต้องว่าง() FAILED6 tests completed, 1 failedรายงาน XML เก็บชื่อไว้ครบทุกอักษรโดยไม่ต้องตั้งค่าอะไรเลย
$ grep -o 'testcase name="[^"]*"' build/test-results/test/TEST-orderctl.StoreTest.xmltestcase name="ปลายทางที่ยังไม่มี ได้ rw-r--r-- ไม่ใช่ rw------- ของ createTempFile()"testcase name="save แล้ว load กลับได้ค่าเดิม และสิทธิ์เดิมของปลายทางไม่หาย()"วงเล็บว่างท้ายชื่อคือของแถมจาก JUnit 5 ซึ่งเติมให้ทุกชื่อ ไม่ใช่ของที่เราเขียนไว้
ด่านที่ไม่มีใครรันคือด่านที่ไม่มีวันแดง และรอบที่เขียวเงียบสนิทแบบนี้ทำให้
“ไม่มีเทสต์เลย” ดูเหมือน “เทสต์ผ่านหมด” ได้ทั้งปี · อ่านเลขจากรายงานทุกครั้ง
แล้วเทียบกับจำนวน @Test ที่คุณเขียนจริง
จุดเทียบกับ Go และ Java
หัวข้อที่มีชื่อว่า “จุดเทียบกับ Go และ Java”โจทย์เดียวกันนี้อยู่ในบทที่ 7 ของอีกสองคอร์ส และทั้งสามภาษาจ่ายคนละใบ
เครื่องมือทดสอบ go test อยู่ในกล่องของ Go ตั้งแต่วันแรก ไม่ต้องเติมอะไรเลย
· Java ต้องเติม dependency หนึ่งก้อนกับตั้งค่า surefire อีกชุดเพื่อให้ชื่อภาษาไทยขึ้น
· Kotlin ต้องมีสองบรรทัดใน build file เหมือนกัน แต่บทที่ 3 ใส่ไว้แล้ว และชื่อภาษาไทยได้มาฟรี
charset บทที่ 7 ของคอร์ส Java
ต้องเล่าทั้งสามชั้นเพราะ FileWriter กับ getBytes() เป็นทางที่คนเดินจริง
· ฝั่ง Kotlin ทางที่คนเดินจริงตรึง UTF-8 ไว้แล้ว ชั้นนั้นจึงเหลือเฉพาะตอนเรียก class ของ Java ตรงๆ
· ส่วนชั้นของจอยังต้องแก้ด้วยสองบรรทัดเดียวกันกับ Java
ท่า atomic บทที่ 7 ของคอร์ส Go เขียน
os.CreateTemp แล้ว os.Rename ซึ่งเป็นท่าเดียวกันทุกก้าว
· ต่างกันที่ Go มีทางเดียวให้เดิน ส่วน Kotlin มีสองทางและทางที่สั้นกว่าคือทางที่ผิด
รหัสออก run ของคอร์ส Go คืน error ตัวเดียว main จึงเรียก os.Exit(1) ที่เดียว
· ราคาคือสคริปต์แยกไม่ออกว่าออเดอร์ผิดหรือ file หาย ต้อง grep ข้อความเอา
Cli.run ของเราคืน Int เหมือนของคอร์ส Java จึงแยกได้ตั้งแต่ต้นทาง
· ราคาคือมีตารางเลขหนึ่งใบที่ต้องดูแล และตารางที่ไม่มีเจ้าของจะกลายเป็นสองมาตรฐานทันที
ทั้งสามภาษาสรุปลงที่เดียวกัน — สิ่งที่ผู้อ่านคนอื่นเห็นต้องเปลี่ยนในก้าวเดียว
และทั้งสามภาษาไม่รับประกันเรื่องไฟดับให้ · moveTo กับ os.Rename เปลี่ยนสิ่งที่
directory ชี้ไป ไม่ได้แปลว่าข้อมูลลง disk แล้ว
งานที่ต้องทนไฟดับต้องสั่งลง disk ด้วยตัวเอง · orderctl ไม่ทำ เพราะสิ่งที่มันกัน
คือคำสั่งที่ล้มกลางทาง ไม่ใช่ไฟดับ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้ไม่ได้เพิ่มความสามารถของโดเมนเลย มันเพิ่มสิ่งที่ทำให้เครื่องมืออยู่รอดในมือคนอื่น
Path.writeText กับ String.toByteArray() ตรึง UTF-8 ไว้ในลายเซ็น ชั้นที่ยังพังคือจอ
· สอง PrintStream ที่ขอบของ main แก้ชั้นนั้น และแก้ที่เดียวจบ
Store.save เขียนลงชื่อชั่วคราวใน directory เดียวกันก่อน แล้วย้ายด้วย ATOMIC_MOVE
· รูปสั้นที่รับ Boolean ไม่ใช่ท่านี้ และ bytecode ของ stdlib บอกเราตรงๆ ว่าไม่ใช่
Cli.run คืน Int และรับ stream สองตัว main จึงเหลืองานเดียวคือแปลงเป็นรหัสออก
· นั่นคือเหตุผลเดียวที่เทสต์สี่ข้อของ CliTest เรียกโปรแกรมทั้งตัวได้โดยไม่สร้าง process ใหม่
บทหน้าเป็นบทปิดคอร์ส — เปิด endpoint ด้วย JDK เปล่า แล้วประมวลผลหลายออเดอร์พร้อมกัน
· หลักการแยกตรรกะออกจากขอบที่ใช้กับ Cli.run วันนี้ จะกลับมาอีกครั้งกับ HttpHandler
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- kotlin.io.path —
moveTo,writeText,readText(เข้าถึง 2026-08-28) — overload สองรูปของmoveToและค่าปริยายCharsets.UTF_8ในลายเซ็นของwriteText - kotlin.system —
exitProcess(เข้าถึง 2026-08-28) — ลายเซ็นfun exitProcess(status: Int): Nothingและคำอธิบายที่ว่า “This method never returns normally.” · หน้านี้ไม่ได้เอ่ยถึงSystem.exitเลย ตัวที่บอกว่ามันเป็นตัวห่อคือต้นทางของ stdlib เอง (jvmMain/kotlin/system/Process.ktในkotlin-stdlib-sources.jarซึ่งเรียกSystem.exit(status)แล้วโยนRuntimeExceptionต่อท้าย) - java.nio.file.Files —
moveและcreateTempFile(เข้าถึง 2026-08-28) —ATOMIC_MOVEเขียนว่า “The move is performed as an atomic file system operation and all other options are ignored” ส่วนcreateTempFileเขียนว่า file ที่ได้ “may have more restrictive access permissions” - java.lang.System (Java SE 25) (เข้าถึง 2026-08-28) —
exit“neither returns nor throws an exception” และ propertystdout.encoding - Gradle — the Application plugin (เข้าถึง 2026-08-28) —
installDistกับสคริปต์ที่มันวางไว้ให้ - บทที่ 7 ของคอร์ส Java — โจทย์เดียวกันด้วย
Files.moveกับ JUnit Jupiter - บทที่ 7 ของคอร์ส Go —
internal/storeที่แก้โจทย์เดียวกันด้วยos.CreateTempแล้วos.Rename - ตัวเลขและ transcript ทุกชุดในบทนี้วัดเองบน Kotlin 2.4.10 · JDK 25 · Gradle 9.7.1 · macOS arm64 เมื่อ 2026-08-28
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3`moveTo` ของ kotlin.io.path มีรูปที่รับ `overwrite: Boolean` ซึ่งสั้นกว่ามาก ใช้แทน `ATOMIC_MOVE` ได้ไหม