ข้าม​ไป​ยัง​เนื้อหา

บรรทัด​คำ​สั่ง สถานะ​บน disk และ​เทสต์​ตัว​แรก

หกบท​ที่​ผ่าน​มา orderctl มี​โดเมน​ครบ​แล้ว — มี data class ที่​สร้าง​สถานะ​ผิด​ไม่​ขึ้น อ่าน JSON เข้า​เป็น Order ได้ คิด​ยอด​เป็น และ​รายงาน​ทุก​ข้อ​ที่​ผิด​ใน​รอบ​เดียว​ได้

สิ่ง​ที่​มัน​ยัง​ไม่มี​คือ​ทุก​อย่าง​ที่​ทำให้​เครื่อง​มือ​หนึ่ง​ตัวอยู่​รอด​ใน​มือ​คน​อื่น คือ​การ​รับ argument การ​คืน​เลข​ให้​สคริปต์​อ่าน และ​การ​จำ​สิ่ง​ที่​ทำ​ไป​แล้ว​โดย​ไม่​ทำ​ของ​เดิม​พัง

📦 orderctl หลัง​จบ​บท​นี้

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.kt
// src/main/kotlin/orderctl/CharsetProbe.kt
package orderctl
import java.io.FileWriter
import java.nio.charset.Charset
import kotlin.io.path.createTempDirectory
import kotlin.io.path.fileSize
import kotlin.io.path.readText
import 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.CharsetProbeKt
default charset = UTF-8
sun.jnu.encoding = UTF-8
stdout.encoding = UTF-8
ชื่อที่อ่านกลับมา = ออเดอร์.json
เนื้อที่อ่านกลับมา = ผัดไทยกุ้งสด
Path.writeText = 36 ไบต์
FileWriter = 36 ไบต์

ของ​จริง​เห็น​ได้​เมื่อ​ผู้​ใช้​พลิก​ค่า​เอง · รัน​ซ้ำ​ใต้ locale ที่​ไม่ใช่ UTF-8

$ LANG=C java -Dfile.encoding=COMPAT -cp "build/install/orderctl/lib/*" orderctl.CharsetProbeKt
default charset = US-ASCII
sun.jnu.encoding = UTF-8
stdout.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 ทุก​อย่าง​ที่​พิมพ์​ออก​มา​จึง​กลาย​เป็น ? รวม​ทั้ง​ของ​ที่​ยัง​ดี​อยู่
`toByteArray()` ของ Kotlin ตรึง UTF-8 ส่วน `getBytes()` ของ Java ไม่​ตรึง

String.toByteArray() ของ Kotlin ประกาศ charset: Charset = Charsets.UTF_8 ไว้​ใน​ลาย​เซ็น · String.getBytes() ของ Java ไม่มี argument นั้น มัน​ไป​หยิบ charset ของ​เครื่อง​มา​ใช้​แทน

วัด​ข้อความ​เดียวกัน​ใต้ LANG=C กับ -Dfile.encoding=COMPAT แล้ว​ได้​เลข​คนละ​ตัว

การ​เรียกปกติใต้ COMPAT
"ผัดไทยกุ้งสด".toByteArray() ของ Kotlin3636
"ผัดไทยกุ้งสด".getBytes() ของ Java3612

นี่​คือ​ความ​หมาย​ของ​ประโยค​ที่​ว่า Kotlin ปิด​ชั้น​แรก​ให้ · มัน​ไม่​ได้​เปลี่ยน JVM เลย​สัก​ที่ มัน​เปลี่ยน​ค่า​ปริยาย​ของ function ที่​คุณ​เรียก​ทุก​วัน

ชั้น​ที่​สาม Kotlin ไม่​ได้​แก้​ให้ println ยัง​วิ่ง​ลง System.out ตัว​เดิม · ทาง​แก้​จึง​เป็น​สอง​บรรทัด​เดียว​กับ​ที่​คอร์ส Java ใช้ และ​มัน​อยู่​ใน Main ของ​หัว​ข้อ​ถัดๆ ไป

การ​เขียน​ทับ​ทำลาย​ของ​เก่า​ตั้งแต่​จังหวะ​ที่​เปิด

หัวข้อ​ที่​มีชื่อ​ว่า “การ​เขียน​ทับ​ทำลาย​ของ​เก่า​ตั้งแต่​จังหวะ​ที่​เปิด”

วัด​ขนาด​ของ​ปลาย​ทาง​ตอน​เพิ่ง​เปิด ก่อน​เขียน​อะไร​ลง​ไป​แม้แต่​ไบต์​เดียว

Path.writeText ที่​ไม่​ระบุ option เรียก Files.newOutputStream ด้วย​ชุด CREATE บวก TRUNCATE_EXISTING บวก WRITE · เปิด​ชุด​เดียวกัน​นั้น​ด้วย​มือ​แล้ว​วัด​ขนาด​ทันที

src/main/kotlin/orderctl/TruncateProbe.kt
// src/main/kotlin/orderctl/TruncateProbe.kt
package orderctl
import java.nio.file.Files
import java.nio.file.StandardOpenOption
import kotlin.io.path.createTempDirectory
import kotlin.io.path.fileSize
import 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.kt
// src/main/kotlin/orderctl/State.kt
package orderctl
import kotlinx.serialization.Serializable
@Serializable
data class State(val totals: Map<String, Long>)

Store ทำท่า​ทั้ง​ชุด​ให้ และ​ใช้ Json ตัว​เดียวกัน​ทั้ง​ขา​อ่าน​และ​ขา​เขียน

src/main/kotlin/orderctl/Store.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี State อยู่ในโปรเจกต์ด้วย
// src/main/kotlin/orderctl/Store.kt
package orderctl
import kotlinx.serialization.json.Json
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFileAttributeView
import java.nio.file.attribute.PosixFilePermissions
import kotlin.io.path.createParentDirectories
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.getPosixFilePermissions
import kotlin.io.path.moveTo
import kotlin.io.path.readText
import 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”

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 -> ok
ATOMIC_MOVE -> java.nio.file.AtomicMoveNotSupportedException: /private/tmp/ramd7/b.tmp -> /private/tmp/near/b.json: Cross-device link

รูป​ที่​ดูใจ​ดี​กว่า​คือ​รูป​ที่​แย่​กว่า มัน​ไม่​บ่น​เพราะ​มัน​ไป​คัด​ลอก​แล้ว​ลบ​ต้นทาง ซึ่ง​เป็น​สอง​ก้าว ไม่ใช่​ก้าว​เดียว · ระหว่าง​สอง​ก้าว​นั้น​ปลาย​ทาง​มี​สภาพ​ครึ่ง​ใบ​ได้

ATOMIC_MOVE เลือก​ที่​จะ​แดง​แทนที่​จะ​เงียบ · และ​เสียง​แดง​นั้น​คือ​เหตุผล​ที่​ชื่อ​ชั่วคราว ต้อง​อยู่​ใน directory เดียว​กับ​ปลาย​ทาง​เสมอ ไม่ใช่​ที่ /tmp

ตรรกะ​ทั้งหมด​อยู่​ใน function ที่​คืน Int และ​รับ stream สอง​ตัว​เข้า​มา เทสต์​จึง​เรียก​มัน​ได้​ตรงๆ

fun main(args: Array<String>) ไม่​คืน​อะไร เทสต์​จึง​ไม่มี​ทาง​ดู​ผล​ของ​มัน​ได้ · ทาง​แก้​คือ​ย้าย​ทุก​อย่าง​ไป function ที่​คืน Int แล้ว​ให้ main ทำ​หน้าที่​เดียว​คือ​แปลง​เป็น​รหัส​ออก

out กับ err ต้อง​เป็น argument ด้วย ไม่ใช่ println ที่​เขียน​ตรงๆ · เทสต์​จะ​ได้​ส่ง ByteArrayOutputStream เข้าไป​แทน แล้ว​อ่าน​สิ่ง​ที่​โปรแกรม​พิมพ์

src/main/kotlin/orderctl/Cli.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · Line Totals validate load Failure และ OrderException มาจากบทก่อนหน้า
package orderctl
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import java.io.PrintStream
import java.nio.file.Path
import kotlin.io.path.exists
@Serializable
data 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.kt
// src/main/kotlin/orderctl/Domain.kt
package orderctl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JvmInline
value class Sku(val code: String) {
init { require(code.isNotBlank()) { "sku ว่างไม่ได้" } }
}
@Serializable
@JvmInline
value class Satang(val v: Long) {
init { require(v >= 0) { "ราคาติดลบไม่ได้" } }
}
@Serializable
data 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 แล้ว​แปลง​เลข​ที่​ได้​เป็น​รหัส​ออก

src/main/kotlin/orderctl/Main.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · เรียก Cli จาก block ข้างบน
// src/main/kotlin/orderctl/Main.kt
package orderctl
import java.io.FileDescriptor
import java.io.FileOutputStream
import java.io.PrintStream
import 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.json
2 รายการ รวม 15500 สตางค์

ถอด​สอง​บรรทัด​นั้น​ออก​แล้ว​ส่ง System.out กับ System.err เข้าไป​แทน คำ​สั่ง​เดิม​ให้​ผล​นี้

$ LANG=C JAVA_OPTS="-Dfile.encoding=COMPAT" build/install/orderctl/bin/orderctl total order.json
2 ?????? ??? 15500 ??????

installDist ให้​สคริปต์​ที่​เรียก​ได้ แล้ว file ที่​มัน​ทิ้ง​ไว้​ต่าง​จาก​ของ​คอร์ส Java ไบต์​ต่อ​ไบต์

ทั้ง​บท​นี้​เรียก​โปรแกรม​ผ่าน​ชื่อ​สั้น​ตัว​เดียว ซึ่ง​ประกาศ​ไว้​ครั้ง​เดียว​แบบ​นี้

Terminal window
gradle -q installDist
orderctl() { build/install/orderctl/bin/orderctl "$@"; }

installDist มา​จาก application plugin ที่ build.gradle.kts ของ​บท​ที่ 3 เปิด​ไว้​แล้ว · มัน​วาง​สคริปต์​ที่​รัน​ได้​ไว้​ให้ ซึ่ง​เป็น​สิ่ง​ที่​หัวข้อ​ถัด​ไป​ต้อง​ใช้​และ gradle run ให้​ไม่​ได้

ตัวอย่าง​สอง​ใบ​ที่​บท​นี้​ใช้ มี​คีย์ category ที่​บท​ที่ 4 เพิ่ม​เข้าไป​ใน Line แล้ว​ครบ​ทั้ง​สอง​ใบ

Terminal window
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.json
2 รายการ รวม 15500 สตางค์
$ orderctl total order2.json state.json
1 รายการ รวม 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 ถัด​ไป

file ของ​สาม​คอร์ส​ไม่​เท่า​กัน​ไบต์​ต่อ​ไบต์

โครง​ของ​ข้อมูล​เหมือน​กัน​ทั้ง​สาม​คอร์ส แต่​สาม​ภาษา​จัด​รูป​แบบ​คนละ​แบบ

คอร์สตัว​จัด​รูป​แบบผล​ที่​ต่าง
Gojson.MarshalIndent(s, "", " ")เยื้อง​สอง​ช่อง ไม่มี​ช่อง​ว่าง​หน้า colon
JavawriterWithDefaultPrettyPrinter()เยื้อง​สอง​ช่อง มี​ช่อง​ว่าง​ทั้ง​สอง​ข้าง​ของ colon
KotlinJson { prettyPrint = true }เยื้อง​สี่​ช่อง มี​ช่อง​ว่าง​หลัง colon อย่าง​เดียว

state.json ของ Kotlin จึง​ยาว 68 ไบต์ ขณะ​ที่ file ที่​ถือ​ข้อมูล​ชุด​เดียวกัน​ของ​คอร์ส Java ยาว 59 ไบต์ · ถ้า​คุณ​เทียบ​ผล​ของ​สอง​คอร์ส​ด้วย diff ให้​ผ่าน jq ก่อน อย่า​เทียบ​ไบต์

สคริปต์​ที่​เรียก orderctl อ่าน​เลข​เดียว​คือ $? และ gradle run ไม่​ยอม​ส่ง​เลข​นั้น​ออก​มา

เลขความ​หมาย​ใน orderctlใคร​เป็น​คน​กำหนด
0ทำงาน​จบ​ตาม​ปกติCli.run
1exception ที่​ไม่มี​ใคร​ดัก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 ถึง 40
exit=2
$ orderctl validate order.json 4 ; echo "exit=$?"
ผ่าน
exit=0
$ orderctl total no-such.json ; echo "exit=$?"
orderctl: อ่านออเดอร์ไม่สำเร็จ
- อ่าน no-such.json ไม่ได้: ไม่พบ file
exit=4
$ orderctl explode a b ; echo "exit=$?"
ไม่รู้จักคำสั่ง explode
exit=3
`gradle run` เปลี่ยน​รหัส​ออก​ของ​คุณ​เป็น 1 ทุก​ครั้ง​ที่​ไม่ใช่​ศูนย์

ท่า​ที่​บท​ที่ 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.kt
// src/main/kotlin/orderctl/ExitProbe.kt
package orderctl
import java.io.Closeable
import 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.json
Exception 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 แล้ว บท​นี้​จึง​ไม่​ต้อง​เติม​อะไร​เลย

build.gradle.kts — สอง​บรรทัด​ที่​บท​นี้​เพิ่ง​ได้​ใช้
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 ธรรมดา

src/test/kotlin/orderctl/CliTest.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Cli กับชนิดของโดเมนอยู่ในโปรเจกต์ด้วย
// src/test/kotlin/orderctl/CliTest.kt
package orderctl
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import java.nio.file.Path
import kotlin.io.path.createTempDirectory
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import 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 โดยตรง ซึ่ง​เป็น​ที่​เดียว​ที่​ข้อ​สังเกต​เรื่อง​สิทธิ์​ถูก​ตรึง​ไว้​เป็น​ด่าน

src/test/kotlin/orderctl/StoreTest.kt
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Store กับ State อยู่ในโปรเจกต์ด้วย
// src/test/kotlin/orderctl/StoreTest.kt
package orderctl
import java.nio.file.attribute.PosixFilePermissions
import kotlin.io.path.createTempDirectory
import kotlin.io.path.getPosixFilePermissions
import kotlin.io.path.setPosixFilePermissions
import kotlin.io.path.writeText
import kotlin.test.Test
import 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/*.xml
build/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 ต้องว่าง() FAILED
6 tests completed, 1 failed
ชื่อ​ภาษา​ไทย​ได้​มา​ฟรี แต่​จำนวน​ต้อง​ไป​ตาม​เอง

รายงาน XML เก็บ​ชื่อ​ไว้​ครบ​ทุก​อักษร​โดย​ไม่​ต้อง​ตั้ง​ค่า​อะไร​เลย

$ grep -o 'testcase name="[^"]*"' build/test-results/test/TEST-orderctl.StoreTest.xml
testcase name="ปลายทางที่ยังไม่มี ได้ rw-r--r-- ไม่ใช่ rw------- ของ createTempFile()"
testcase name="save แล้ว load กลับได้ค่าเดิม และสิทธิ์เดิมของปลายทางไม่หาย()"

วงเล็บ​ว่าง​ท้าย​ชื่อ​คือ​ของ​แถม​จาก JUnit 5 ซึ่ง​เติม​ให้​ทุก​ชื่อ ไม่ใช่​ของ​ที่​เรา​เขียน​ไว้

ด่าน​ที่​ไม่มี​ใคร​รัน​คือ​ด่าน​ที่​ไม่มี​วัน​แดง และ​รอบ​ที่​เขียว​เงียบ​สนิท​แบบ​นี้​ทำให้ “ไม่มี​เทสต์​เลย” ดู​เหมือน “เทสต์​ผ่าน​หมด” ได้​ทั้ง​ปี · อ่าน​เลข​จาก​รายงาน​ทุก​ครั้ง แล้ว​เทียบ​กับ​จำนวน @Test ที่​คุณ​เขียน​จริง

โจทย์​เดียวกัน​นี้​อยู่​ใน​บท​ที่ 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” และ property stdout.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` ได้ไหม