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

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

หกบท​ที่​ผ่าน​มา orderctl มี​โดเมน​ครบ​แล้ว — มี record ที่​ตรวจ​ตัว​เอง​ได้ อ่าน JSON เข้า​เป็น Order ได้ คิด​ยอด​เป็น และ​รายงาน​ทุก​ข้อ​ที่​ผิด​ได้

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

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

Main เหลือ​งาน​เดียว​คือ​แปลง​เลข​เป็น exit code · งาน​จริง​ย้าย​ไป Cli.run(String[], PrintStream, PrintStream) → int ที่​เทสต์​เรียก​ได้​โดย​ไม่​ต้อง​สร้าง process ใหม่

เพิ่ม Store ที่​บันทึก​สถานะ​แบบ​ล้ม​กลาง​ทาง​แล้ว​ของ​เดิม​ไม่​พัง เพิ่ม State ที่​เก็บ​ยอด​สะสม ต่อ​ออ​เด​อร์ และ​เพิ่ม​เทสต์​ชุด​แรก​ของ​ทั้ง​คอร์ส

การ​ห่อ​โปรแกรม​เป็น jar ไม่​ได้​อยู่​ใน​บท​นี้ · มัน​อยู่​ใน​บท​ที่ 3 คู่​กับ Maven ซึ่ง​เป็น​เจ้าของ​เรื่อง​นั้น

ภาษา​ไทย​ที่​เพี้ยน — สาม​ชั้น​ที่​ต้อง​แยก​ให้​ออก

หัวข้อ​ที่​มีชื่อ​ว่า “ภาษา​ไทย​ที่​เพี้ยน — สาม​ชั้น​ที่​ต้อง​แยก​ให้​ออก”

เนื้อ​ใน file ชื่อ​ของ file และ​ตัว​อักษร​บน​จอ ใช้ charset คนละ​ตัว​กัน และ​พัง​คนละ​แบบ

Files.readString กับ Files.writeString ผูก UTF-8 ไว้​ใน​สเปก ส่วน new FileWriter(f) และ str.getBytes() ผูก​กับ default charsetdefault charsetcharset ที่ API เก่า​อย่าง `FileWriter` และ `getBytes()` ใช้ · เป็น UTF-8 ตั้งแต่ JDK 18 แต่​ผู้​ใช้​พลิก​กลับ​ได้ ส่วน `Files.readString` ผูก UTF-8 ไว้​ใน​สเปก ซึ่ง​ผู้​ใช้​พลิก​ได้​จาก​บรรทัด​คำ​สั่ง

ประเด็น​จึง​ไม่ใช่​ว่า default charset เป็น​ค่า​อะไร บน JDK 18 ขึ้น​ไป​มัน​คือ UTF-8 อยู่​แล้ว · ประเด็น​คือ ใคร​เป็น​คน​กำหนด และ​คำ​ตอบ​คือ​คน​ที่​รัน​โปรแกรม ไม่ใช่​คน​ที่​เขียน

package orderctl;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
/** สามชั้นที่แยกกัน · เนื้อใน file ชื่อของ file และตัวอักษรบนจอ */
public final class CharsetProbe {
private CharsetProbe() {}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("orderctl-charset");
String thai = "ผัดไทยกุ้งสด";
Path bySpec = dir.resolve("ออเดอร์.json");
Files.writeString(bySpec, thai); // UTF-8 เสมอ สั่งเปลี่ยนไม่ได้
Path byDefault = dir.resolve("by-default.txt");
try (Writer w = new FileWriter(byDefault.toFile())) { // ผูกกับ default charset
w.write(thai);
}
System.out.println("default charset = " + Charset.defaultCharset());
System.out.println("sun.jnu.encoding = " + System.getProperty("sun.jnu.encoding"));
System.out.println("stdout.encoding = " + System.getProperty("stdout.encoding"));
System.out.println("ชื่อที่อ่านกลับมา = " + bySpec.getFileName());
System.out.println("เนื้อที่อ่านกลับมา = " + Files.readString(bySpec));
System.out.println("Files.writeString = " + Files.size(bySpec) + " ไบต์");
System.out.println("FileWriter = " + Files.size(byDefault) + " ไบต์");
}
}

บน​เครื่อง​นัก​พัฒนา​ทั้ง​สอง​ทาง​ให้​ผล​เท่า​กัน ข้อความ​ไทย​สิบ​สอง​ตัว​กิน​สามสิบ​หก​ไบต์​ทั้ง​คู่

$ mvn -q compile
$ java -cp target/classes orderctl.CharsetProbe
default charset = UTF-8
sun.jnu.encoding = UTF-8
stdout.encoding = UTF-8
ชื่อที่อ่านกลับมา = ออเดอร์.json
เนื้อที่อ่านกลับมา = ผัดไทยกุ้งสด
Files.writeString = 36 ไบต์
FileWriter = 36 ไบต์

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

$ LANG=C java -Dfile.encoding=COMPAT -cp target/classes orderctl.CharsetProbe
default charset = US-ASCII
sun.jnu.encoding = UTF-8
stdout.encoding = US-ASCII
????????????????? = ???????.json
?????????????????? = ????????????
Files.writeString = 36 ????
FileWriter = 12 ????

สาม​ชั้น​แยก​กัน​ชัด​ใน​ผล​ชุด​นี้ และ​แต่ละ​ชั้น​เสีย​หาย​คนละ​ระดับ

  • เนื้อ​ใน file Files.writeString ยัง​ได้ 36 ไบต์​เท่า​เดิม ส่วน FileWriter เหลือ 12 ไบต์ เพราะ​มัน​แปลง​ตัว​อักษร​ไทย​ทุก​ตัว​เป็น​เครื่องหมาย​คำถาม​ตัว​เดียว ข้อมูล​ตาย​จริง​ตรง​นี้
  • ชื่อ​ของ file sun.jnu.encoding ยัง​เป็น UTF-8 ชื่อ ออเดอร์.json จึง​กลับ​มา​ครบ​ทุก​ไบต์ ต่อ​ให้​จอแส​ดง​มัน​เป็น ???????.json
  • ตัว​อักษร​บน​จอ stdout.encoding เป็น US-ASCII ทุก​อย่าง​ที่​พิมพ์​ออก​มา​จึง​กลาย​เป็น ? รวม​ทั้ง​ของ​ที่​ยัง​ดี​อยู่
เห็น `?????` แล้ว​อย่า​เพิ่ง​สรุป​ว่า​ข้อมูล​เสีย

เติม -Dstdout.encoding=UTF-8 เข้าไป​แล้ว​รัน​ซ้ำ จอก​ลับ​มา​อ่าน​ออก​ทันที แต่​บรรทัด FileWriter = 12 ไบต์ ยัง​เป็น 12 อยู่​เหมือน​เดิม

นั่น​คือ​วิธี​แยก​สอง​เรื่อง​ออก​จาก​กัน · การ​แก้​ที่​จอ​ไม่​ทำให้​ข้อมูล​ที่​ตาย​ไป​แล้ว​ฟื้น และ​การ​แก้​ที่​ข้อมูล​ก็​ไม่​ทำให้​จอ​อ่าน​ออก

ความ​เสีย​หาย​ที่​ข้อมูล​จริง​จะ​ดัง​กว่า​เครื่องหมาย​คำถาม​มาก · Files.readString บน​ไบต์​ที่​ไม่ใช่ UTF-8 โยน java.nio.charset.MalformedInputException: Input length = 1 ออก​มา​ตรงๆ

JEP 400 อธิบาย​ว่า -Dfile.encoding=COMPAT คือ​คำ​สั่ง​ให้​ย้อน​กลับ​ไป​ใช้​อัล​กอ​ริ​ทึม​ของ JDK 17 ซึ่ง​เลือก charset จาก​ระบบ​ปฏิบัติ​การ​และ locale ของ​ผู้​ใช้

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

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

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

Files.writeString ที่​ไม่​ระบุ option ใช้​ชุด CREATE บวก TRUNCATE_EXISTING บวก WRITE · เปิด​ชุด​เดียวกัน​นั้น​ด้วย​มือ​แล้ว​วัด​ขนาด​ทันที จะ​เห็น​สิ่ง​ที่​เกิด​ขึ้น​จริง

package orderctl;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/** วัดขนาดของปลายทาง ณ จังหวะที่เพิ่งเปิด ก่อนเขียนอะไรลงไปแม้แต่ไบต์เดียว */
public final class TruncateProbe {
private TruncateProbe() {}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("orderctl-truncate");
Path state = dir.resolve("state.json");
Files.writeString(state, "{\"totals\":{\"A-1\":13950}}");
System.out.println("ก่อนเปิด " + Files.size(state) + " ไบต์");
// ชุด option เดียวกับที่ Files.writeString ใช้เมื่อไม่ระบุอะไร
try (OutputStream out = Files.newOutputStream(state,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
System.out.println("เปิดแล้ว " + Files.size(state) + " ไบต์");
out.write("{\"totals\":{}}".getBytes(StandardCharsets.UTF_8));
}
System.out.println("เขียนจบ " + Files.size(state) + " ไบต์");
}
}
$ java -cp target/classes orderctl.TruncateProbe
ก่อนเปิด 24 ไบต์
เปิดแล้ว 0 ไบต์
เขียนจบ 13 ไบต์

บรรทัด​กลาง​คือ​ปัญหา​ทั้งหมด สถานะ​เก่า​หาย​ไป​ตั้งแต่​ก่อน​ที่​ข้อมูล​ใหม่​จะ​ถูก​เขียน​สัก​ตัว · โปรแกรม​ที่​ตาย​ตรง​จังหวะ​นั้น ทิ้ง file เปล่า​ไว้​ให้​ผู้​ใช้ ไม่ใช่ file เก่า

ช่วง​เวลา​นั้น​สั้น แต่​มัน​ไม่ใช่​ศูนย์ และ​งาน​ที่​รัน​วัน​ละ​หลาย​พัน​ครั้ง​จะ​เจอ​มัน​เข้า​สัก​วัน

ท่า atomic คือ​ไม่​แตะ​ปลาย​ทาง​เลย​จนกว่า​ของ​ใหม่​จะ​ครบ แล้ว​สลับ​ชื่อ​ใน​ก้าว​เดียว

atomic file writeatomic file writeเขียน​ลงชื่อ​ชั่วคราว​ใน directory เดียว​กับ​ปลาย​ทาง​ก่อน แล้ว `Files.move` ด้วย `ATOMIC_MOVE` · ข้าม filesystem ไม่​ได้ และ​ต้อง​คัด​สิทธิ์​เดิม​มา​ใส่ แก้​ปัญหา​ข้าง​บน​ด้วย​การ​ย้าย​จังหวะ​เสี่ยง​ออก​ไป​จาก​ปลาย​ทาง · เขียน​ลงชื่อ​ชั่วคราว​จน​จบ​ก่อน แล้ว​ค่อย​เรียก Files.move ด้วย ATOMIC_MOVE

flowchart TD
  A["createTempFile<br/>ใน directory เดียวกับปลายทาง"] --> B["writeValue<br/>เขียนข้อมูลทั้งก้อน"]
  B --> C["setPermissions<br/>createTempFile ให้ rw------- มา"]
  C --> D["Files.move + ATOMIC_MOVE<br/>สลับชื่อในก้าวเดียว"]
  D --> E["ผู้อ่านเห็นของเก่าครบ<br/>หรือของใหม่ครบ"]
  B -. "ล้ม" .-> X["deleteIfExists<br/>ปลายทางไม่เคยถูกแตะ"]
  C -. "ล้ม" .-> X
  D -. "ล้ม" .-> X

คำ​บรรยาย​ภาพ: ทาง​ปกติ​ไล่​จาก createTempFile ไป writeValue ไป setPermissions แล้ว​จบ​ที่ Files.move · ทุก​จุด​ที่​ล้ม​กลาง​ทาง​กลับ​ไป​ลบ​ชื่อ​ชั่วคราว​ทิ้ง โดย​ปลาย​ทาง​ไม่​ถูก​แตะ​เลย

สถานะ​ที่​เรา​เก็บ​คือ​ยอด​รวม​ต่อ​ออ​เด​อร์ · หนึ่ง record หนึ่ง map

package orderctl;
import java.util.Map;
/** สถานะสะสมของ orderctl · คีย์คือรหัสออเดอร์ ค่าคือยอดรวมเป็นสตางค์ */
public record State(Map<String, Long> totals) {}

Store ทำท่า​ทั้ง​ชุด​ให้ และ​รับ Object เพราะ Jackson แปลง record อะไร​ก็ได้​ที่​ส่ง​เข้า​มา

package orderctl;
import java.io.IOException;
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 tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/** เก็บสถานะแบบ all-or-nothing · ผู้อ่านเห็นของเก่าครบ หรือของใหม่ครบ ไม่มีสภาพครึ่งใบ */
public final class Store {
// ความเข้มงวดเดียวกับที่บทที่ 3 เปิดไว้ที่ Main · ตั้งแต่บทนี้ทุกการอ่านผ่าน load
static final ObjectMapper MAPPER = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
private Store() {}
public static void save(Path target, Object state) throws IOException {
Path dir = target.toAbsolutePath().getParent();
Files.createDirectories(dir);
// ชื่อชั่วคราวต้องอยู่ใน directory เดียวกับปลายทาง ไม่ใช่ java.io.tmpdir
Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp");
try {
MAPPER.writerWithDefaultPrettyPrinter().writeValue(tmp, state);
applyPermissions(target, tmp);
Files.move(tmp, target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (RuntimeException | IOException e) {
Files.deleteIfExists(tmp); // ล้มกลางทางแล้วต้องไม่ทิ้งขยะ .tmp ไว้
throw e;
}
}
public static <T> T load(Path source, Class<T> type) {
return MAPPER.readValue(source, type);
}
/** createTempFile ให้สิทธิ์ rw------- มา · ปลายทางที่ยังไม่มี ต้องตั้งค่าเองให้ชัด */
private static void applyPermissions(Path target, Path tmp) throws IOException {
PosixFileAttributeView view =
Files.getFileAttributeView(tmp, PosixFileAttributeView.class);
if (view == null) {
return; // Windows ไม่มี POSIX view — ข้ามไป
}
view.setPermissions(Files.exists(target)
? Files.getPosixFilePermissions(target)
: PosixFilePermissions.fromString("rw-r--r--"));
}
}

MAPPER ตัว​นี้​ต้อง​เปิด FAIL_ON_UNKNOWN_PROPERTIES ด้วย · บท​ที่ 3 เปิด​มัน​ไว้​ที่ Main แต่​ตั้งแต่​บท​นี้​เป็นต้น​ไป​ทุก​การ​อ่าน​ผ่าน Store.load · ถ้า​ไม่​ยก​มา​ด้วย คีย์​ที่​พิมพ์​ผิด​จะ​กลับ​ไป หาย​เงียบ​อีก​ครั้ง​โดยที่​ไม่มี​บท​ไหน​บอก​ว่า​มัน​หาย​ไป​ตอน​ไหน

สาม​บรรทัด​ใน​นั้น​มี​ราย​ละเอียด​ที่​กัด​จริง และ​ทุก​ข้อ​วัด​ได้​ด้วย​การ​เปลี่ยน​แล้ว​รัน​ซ้ำ

  • createTempFile แบบ​สาม argument argument แรก​คือ directory ปลาย​ทาง · แบบ​สอง argument วาง​ของ​ไว้​ที่ java.io.tmpdir ซึ่ง​เป็น​บั๊ก​ที่​รอ​วัน​แสดง​ตัว
  • applyPermissions ไม่ใช่​ของ​ฟุ่มเฟือย javadoc ของ createTempFile เขียน​ว่า file ที่​ได้ “may have more restrictive access permissions” และ​บน macOS/APFS มัน​คือ rw-------
  • REPLACE_EXISTING ที่​ใส่​คู่​กับ ATOMIC_MOVE เป็นการ​ประกาศ​เจตนา​ให้​คน​อ่าน ไม่ใช่ การ​รับ​ประกัน​ของ​สเปก · javadoc เขียน​ว่า ATOMIC_MOVE ทำให้ “all other options are ignored”
ชื่อ​ชั่วคราว​ข้าม filesystem ไม่​ได้

วัด​บน OpenJDK 25.0.4.1 macOS arm64 เมื่อ 2026-08-27 โดย​สร้าง HFS+ ram disk ขึ้น​มา แล้ว ATOMIC_MOVE จาก​ที่​นั่น​ไป​ยัง /var/folders/... บน APFS

java.nio.file.AtomicMoveNotSupportedException: /private/tmp/ramd/far.tmp -> /var/folders/.../moved.json: Cross-device link

นี่​คือ​เหตุผล​ที่ Files.createTempFile(dir, ...) ต้อง​รับ dir ของ​ปลาย​ทาง​เข้าไป · การ​วาง​ชื่อ​ชั่วคราว​ไว้​ที่ /tmp ทำงาน​ได้​บน​เครื่อง​นัก​พัฒนา แล้วไป​ล้ม​บน container ที่ mount volume แยก

คำ​สั่ง total ที่​ใช้​ข้าง​ล่าง​มา​จาก Cli ซึ่ง​หัวข้อ​ถัด​ไป​จะ​เขียน​ที​ละ​บรรทัด · ตรง​นี้​เรา​สนใจ​แค่​สิ่ง​ที่​มัน​ทิ้ง​ไว้​บน disk

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

Terminal window
mvn -q compile
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
orderctl() { java -cp "target/classes:$(cat cp.txt)" orderctl.Main "$@"; }

ตัวอย่าง​สาม​ใบ​ที่​บท​นี้​ใช้​ต้อง​เขียน​ใหม่ · order.json ของ​บท​ที่ 3 ยัง​ไม่มี​คีย์ category ซึ่ง​บท​ที่ 4 เพิ่ม​เข้าไป​ใน Line แล้ว​บังคับ​ว่า​ห้าม​เป็น null

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
cat > empty.json <<'JSON'
{"id":"A-1","lines":[]}
JSON

ผล​ของ writerWithDefaultPrettyPrinter() มี​ช่อง​ว่าง​หน้า colon และ​เขียน​ภาษา​ไทย​เป็น​ไบต์ UTF-8 ตรงๆ ไม่​หนี​ไป​เป็น \uXXXX

$ orderctl total order.json state.json
2 รายการ รวม 15500 สตางค์
$ orderctl total order2.json state.json
1 รายการ รวม 18000 สตางค์
$ cat state.json
{
"totals" : {
"A-1" : 15500,
"A-2" : 18000
}
}
$ ls -l state.json
-rw-r--r--@ 1 nook wheel 59 Aug 27 15:34 state.json
file ของ​สอง​คอร์ส​ไม่​เท่า​กัน​ไบต์​ต่อ​ไบต์

คอร์ส Go สร้าง orderctl-state.json ด้วย json.MarshalIndent(s, "", " ") ซึ่ง​เขียน "A1": 13950 — ไม่มี​ช่อง​ว่าง​หน้า colon

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

รูป​ที่​ใช้​กัน​คือ​หนึ่ง JSON หนึ่ง​บรรทัด แล้ว​อ่าน​ที​ละ​บรรทัด​ด้วย Files.lines · javadoc ของ​มัน​ใช้​คำ​ว่า “must” ตรงๆ เพราะ stream ที่​คืน​มา​ถือ file ที่​เปิด​ค้าง​ไว้

package orderctl;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
/** อ่านทีละบรรทัด · stream ถือ handle ค้างไว้ จึงต้องอยู่ใน try-with-resources เสมอ */
public final class LinesProbe {
private LinesProbe() {}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("orderctl-ndjson");
Path log = dir.resolve("lines.ndjson");
Files.writeString(log, """
{"sku":"phad-thai","qty":2}
{"sku":"cha-yen","qty":1}
""");
try (Stream<String> lines = Files.lines(log)) {
System.out.println("อ่านได้ " + lines.filter(s -> !s.isBlank()).count() + " บรรทัด");
}
}
}
$ java -cp target/classes orderctl.LinesProbe
อ่านได้ 2 บรรทัด

ถอด try ออก​แล้ว compiler ไม่​ว่า​อะไร​เลย และ​ผล​ก็​ยัง​ถูก · -Xlint:all เห็น​เฉพาะ​ทรัพยากร ที่​ประกาศ​ใน try แล้ว​ไม่​ถูก​ใช้ ไม่​ได้​เห็น​ทรัพยากร​ที่​ไม่​เคย​ถูก​ประกาศ

จุด​เทียบ​กับ​คอร์ส Go — ทั้ง​สอง​ฝั่ง​จ่าย​คนละ​อย่าง

หัวข้อ​ที่​มีชื่อ​ว่า “จุด​เทียบ​กับ​คอร์ส Go — ทั้ง​สอง​ฝั่ง​จ่าย​คนละ​อย่าง”

เรื่อง​เดียวกัน​นี้​อยู่​ใน internal/store ของ​คอร์ส Go และ​สอง​ภาษา​จ่าย​ราคา​คนละ​ใบ

บท​ที่ 7 ของ​คอร์ส Go เขียน Save ด้วย​ท่า​เดียวกัน คือ os.CreateTemp แล้ว os.Rename · ความ​ต่าง​อยู่​ที่​ราย​ละ​เอียด​รอบๆ ท่า​นั้น

สิทธิ์​ของ file os.WriteFile(path, b, 0o644) ของ Go รับ mode มา​เป็น argument ที่​สาม คุณ​จึง​ถูก​บังคับ​ให้​ตอบ​คำถาม​นี้​ทุก​ครั้ง​ที่​เขียน

Files.writeString ไม่มี argument mode เลย มัน​จึง​ไม่​เคย​ถาม​คุณ — และ​ท่า atomic ที่​ไม่มี applyPermissions ก็​ลาก​สิทธิ์ rw------- ไป​ทับ​ปลาย​ทาง​เงียบๆ ไม่มี error สัก​ตัว

ราคา​ของ Go คือ​คุณ​ต้อง​พิมพ์ 0o644 ซ้ำ​ทุก​จุด​ที่​เขียน และ os.CreateTemp ก็​ยัง​ให้ 0600 มา​อยู่ดี บท​ที่ 7 ของ​คอร์ส Go จึง​ต้อง​เรียก os.Chmod เอง​เหมือน​กัน

ข้อความ​ของ​ความ​ผิด​พลาด os.ReadFile ของ Go คืน error ที่​มี​คำ​กริยา​อยู่​ใน​ข้อความ​เลย · บท​ที่ 3 ของ​คอร์ส Go พิมพ์​ผล​จริง​ออก​มา​เป็น open testdata/missing.json: no such file or directory

NoSuchFileException.getMessage() คืน​แค่ path เปล่าๆ · คำ​ว่า “เปิด​ไม่​ได้” ซ่อน​อยู่​ใน​ชื่อ class ซึ่ง​ผู้​ใช้​ไม่​เคย​เห็น การ​ห่อ​พร้อม​เติม​บริบท​จึง​จำเป็น​ใน Java มากกว่า​ใน Go

การ​ปิด​ทรัพยากร defer f.Close() เป็น​สำนวน​ที่​คน​ทั้ง​วงการ Go เขียน​เหมือน​กัน​หมด · ราคา​คือ​มัน​ทิ้ง error ของ Close ทุก​ครั้ง และ​บท​ที่ 7 ของ​คอร์ส Go เตือน​ข้อ​นี้​ไว้​เอง

Java เก็บ error ตอน​ปิด​ไว้​ให้​ครบ​ใน getSuppressed() แต่​ก็​ต้อง​แลก​มา​ด้วย​การ​เพิ่ม syntax เข้าไป​ใน​ภาษา เพราะ​สำนวน try/finally แบบ​เดิม​กลืน error ของ​ตัว​จริง​ทิ้ง

กฎ​ที่​สอง​คอร์ส​เห็น​ตรง​กัน

ทั้ง​สอง​ภาษา​สรุป​ลง​ที่​เดียวกัน — สิ่ง​ที่​ผู้​อ่าน​คน​อื่น​เห็น​ต้อง​เปลี่ยน​ใน​ก้าว​เดียว

และ​ทั้ง​สอง​ภาษา​ไม่​รับ​ประกัน​เรื่อง​ไฟ​ดับ​ให้ · Files.move กับ os.Rename เปลี่ยน​สิ่ง​ที่ directory ชี้​ไป ไม่​ได้​แปล​ว่า​ข้อมูล​ลง disk แล้ว

งาน​ที่​ต้อง​ทน​ไฟ​ดับ​ต้อง force ลง disk ด้วย​ตัว​เอง · orderctl ไม่​ทำ เพราะ​สิ่ง​ที่​มัน​กัน คือ​คำ​สั่ง​ที่​ล้ม​กลาง​ทาง ไม่ใช่​ไฟ​ดับ

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

public static void main(String[] args) ไม่​คืน​อะไร เทสต์​จึง​ไม่มี​ทาง​ดู​ผล​ของ​มัน​ได้ · ทาง​แก้​คือ​ย้าย​ทุก​อย่าง​ไป​ฟังก์ชัน​ที่​คืน int แล้ว​ให้ main ทำ​หน้าที่​เดียว​คือ​แปลง​เป็น exit code

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

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · Order Totals Orders Failure และ OrderException มาจากบทก่อนหน้า
package orderctl;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import tools.jackson.core.JacksonException;
import tools.jackson.core.exc.JacksonIOException;
/** ตรรกะทั้งหมดของบรรทัดคำสั่ง · ไม่มี System.exit และไม่มี System.out เลยสักตัว */
public final class Cli {
private static final String USAGE =
"ใช้: orderctl validate <order.json> <table> | orderctl total <order.json> [state.json]";
private Cli() {}
public static int run(String[] args, PrintStream out, PrintStream err) {
if (args.length < 2) {
err.println(USAGE);
return 3;
}
Path path = Path.of(args[1]);
try {
return switch (args[0]) {
case "validate" -> {
if (args.length < 3) {
err.println(USAGE);
yield 3;
}
Order order = load(path);
Orders.validate(order.id(), Integer.parseInt(args[2]), order.lines());
out.println("ผ่าน");
yield 0;
}
case "total" -> {
Order order = load(path);
out.println(Totals.receiptLine(order.lines()));
if (args.length > 2) {
recordTotal(Path.of(args[2]), order);
}
yield 0;
}
default -> {
err.println("ไม่รู้จักคำสั่ง " + args[0]);
yield 3;
}
};
} catch (NumberFormatException e) {
err.println("เลขโต๊ะต้องเป็นจำนวนเต็ม แต่ได้ " + args[2]);
return 3;
} catch (OrderException e) {
err.println("orderctl: " + e.getMessage());
for (Failure f : e.failures()) {
err.println(" - " + Failure.describe(f));
}
return Failure.exitCode(e.failures().getFirst());
}
}
/** ห่อความล้มเหลวของ Jackson เป็น Failure.Io · getMessage ของ NoSuchFileException มีแต่ path */
private static Order load(Path path) {
try {
return Store.load(path, Order.class);
} catch (JacksonIOException e) {
throw OrderException.wrapping("เปิด " + path + " ไม่ได้",
new Failure.Io(path.toString(), String.valueOf(e.getCause())), e);
} catch (JacksonException e) {
throw OrderException.wrapping("ข้อมูลใน " + path + " ใช้ไม่ได้",
new Failure.Io(path.toString(), e.getOriginalMessage()), e);
}
}
private static void recordTotal(Path statePath, Order order) {
try {
Map<String, Long> totals = new LinkedHashMap<>();
if (Files.exists(statePath)) {
totals.putAll(Store.load(statePath, State.class).totals());
}
totals.put(order.id(), order.grossSatang());
Store.save(statePath, new State(totals));
} catch (IOException e) {
throw OrderException.wrapping("เขียนสถานะลง " + statePath + " ไม่ได้",
new Failure.Io(statePath.toString(), String.valueOf(e)), e);
}
}
}

load ห่อ​ความ​ล้ม​เหลว​ของ Jackson เป็น Failure.Io ของ​บท​ที่ 6 แทนที่​จะ​ปล่อย​ขึ้น​ไป​ดิบๆ · ลำดับ​ของ catch สอง​ก้อน​ใน​นั้น​บังคับ เพราะ JacksonIOException เป็น​ลูก​ของ JacksonException การ​สลับ​ที่​กัน​ทำให้ compiler ตอบ​ว่า exception has already been caught

ผล​คือ run มี catch ของ​โดเมน​ก้อน​เดียว และ​ตัว​แปลง Failure เป็น​เลข​ก็​มี​ที่​เดียว​เช่น​กัน คือ Failure.exitCode ที่​บท​ที่ 6 เขียน​ไว้​แล้ว

Main ที่​เหลือ​มี​งาน​สอง​อย่าง​คือ​สร้าง stream ที่​ผูก UTF-8 ไว้ แล้ว​แปลง​เลข​ที่​ได้​เป็น exit code

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · เรียก Cli จาก block ข้างบน
package orderctl;
import java.io.FileDescriptor;
import java.io.FileOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
public final class Main {
private Main() {}
public static void main(String[] args) {
// System.out ใช้ charset ของ console ภาษาไทยจึงกลายเป็น ? บนเครื่องที่ไม่ใช่ UTF-8
var out = new PrintStream(new FileOutputStream(FileDescriptor.out), true, StandardCharsets.UTF_8);
var err = new PrintStream(new FileOutputStream(FileDescriptor.err), true, StandardCharsets.UTF_8);
System.exit(Cli.run(args, out, err));
}
}

สอง PrintStream นั้น​คือ​คำ​ตอบ​ของ​หัวข้อ​แรก ที่​ขอบ​ของ​โปรแกรม​แทนที่​จะ​ไป​แก้​ที่​ผู้​ใช้ · รัน​ภาย​ใต้ LANG=C แล้ว​ยัง​ได้ 2 รายการ รวม 15500 สตางค์ ครบ​ทุก​ตัว​อักษร

exception ที่​หลุด​ออก​จาก main ให้​เลข 1 เสมอ เลข​อื่น​ต้อง​จับ​แล้ว​เรียก System.exit เอง

สคริปต์​ที่​เรียก orderctl อ่าน​เลข​เดียว​คือ $? · ข้อความ​ที่​เรา​พิมพ์​สวย​แค่​ไหน​ก็​ไม่มี​ผล​กับ​มัน

เลขความ​หมาย​ใน orderctlใคร​เป็น​คน​กำหนด
0ทำงาน​จบ​ตาม​ปกติCli.run
1exception ที่​ไม่มี​ใคร​ดักJVM ให้​มา​เอง ห้าม​เปลี่ยน
2ออ​เด​อร์​ไม่​ผ่าน​การ​ตรวจFailure.exitCode ของ​บท​ที่ 6
3พิมพ์​คำ​สั่ง​ผิดCli.run ของ​บท​นี้
4อ่าน​หรือ​เขียน file ไม่​ได้Failure.exitCode ของ​บท​ที่ 6

เลข 3 ที่​ดู​แปลก​คือ​ของ​จริง ธรรมเนียม Unix ให้ 2 กับ​การ​พิมพ์​คำ​สั่ง​ผิด แต่​บท​ที่ 6 จอง​เลข 2 ไป​แล้ว​ให้ BadField กับ NoLines · ตาราง​เลข​ออก​ต้อง​มี​เจ้าของ​คน​เดียว บท​นี้​จึง​หยิบ​เลข​ที่​ยัง​ว่าง​แทนที่​จะ​เขียน​กฎ​ซ้อน​กัน

$ orderctl ; echo "exit=$?"
ใช้: orderctl validate <order.json> <table> | orderctl total <order.json> [state.json]
exit=3
$ orderctl explode order.json ; echo "exit=$?"
ไม่รู้จักคำสั่ง explode
exit=3
$ orderctl validate empty.json 99 ; echo "exit=$?"
orderctl: ออเดอร์ A-1 ไม่ผ่านการตรวจ 2 ข้อ
- field table = "99": ต้องอยู่ระหว่าง 1 ถึง 40
- ออเดอร์ A-1 ไม่มีรายการ
exit=2
$ orderctl total no-such.json ; echo "exit=$?"
orderctl: เปิด no-such.json ไม่ได้
- อ่าน no-such.json ไม่ได้: java.nio.file.NoSuchFileException: no-such.json
exit=4
$ orderctl validate order.json 4 ; echo "exit=$?"
ผ่าน
exit=0

สังเกต​ว่า​วิธี​ใช้​กับ​ข้อความ​ความ​ผิด​พลาด​ไป​ที่ stderr ส่วน​ผล​ของ​งาน​ไป​ที่ stdout · คน​ที่​เขียน orderctl total x.json > total.txt ต้อง​ได้​แค่​ยอด ไม่ใช่​ยอด​ปน​คำ​เตือน

Go จบ​ด้วย​เลข​เดียว Java แจก​เลข​หลาย​ตัว

run ของ​คอร์ส Go คืน error ตัว​เดียว main จึง​เรียก os.Exit(1) ที่​เดียว​และ​จบ · ราคา​คือ​สคริปต์​แยก​ไม่​ออก​ว่า​ออ​เด​อร์​ผิด​หรือ file หาย ต้อง grep ข้อความ​เอา

Cli.run ของ​เรา​คืน int จึง​แยก​ได้​ตั้งแต่​ต้นทาง · ราคา​คือ​มี​ตาราง​เลข​หนึ่ง​ใบ​ที่​ต้อง​ดูแล และ​ตาราง​ที่​ไม่มี​เจ้าของ​จะ​กลาย​เป็น​สอง​มาตรฐาน​ภายใน​โปร​เจ​กต์​เดียว​ทันที

package orderctl;
/** สองทางที่โปรแกรมจบ · ทางหนึ่งปล่อยให้ exception หลุด อีกทางเรียก System.exit เอง */
public final class ExitProbe {
private ExitProbe() {}
public static void main(String[] args) {
try {
if (args.length > 0) {
System.exit(4);
}
throw new IllegalStateException("ไม่มีใครดักตัวนี้");
} finally {
System.out.println("finally ได้ทำงาน");
}
}
}
$ java -cp target/classes orderctl.ExitProbe ; echo "exit=$?"
finally ได้ทำงาน
Exception in thread "main" java.lang.IllegalStateException: ไม่มีใครดักตัวนี้
at orderctl.ExitProbe.main(ExitProbe.java:13)
exit=1
$ java -cp target/classes orderctl.ExitProbe x ; echo "exit=$?"
exit=4
`System.exit()` ข้าม `finally` ทั้งหมด

รอบ​ที่​สอง​ไม่​พิมพ์ finally ได้ทำงาน เลย ทั้ง​ที่ System.exit(4) อยู่​ใน​บล็อก try ที่​มี finally ครอบ​อยู่

javadoc ของ System.exit อธิบาย​ว่า​มัน​เริ่ม​ลำดับ​การ​ปิด​ของ JVM แล้ว​บล็อก​ไป​ตลอด โดย​ไม่​คืน​ค่า​และ​ไม่​โยน exception จึง​ไม่​จบ​ลง​ทั้ง​แบบ​ปกติ​และ​แบบ​ผิด​ปกติ · finally รอ​จังหวะ​ที่​ไม่มี​วัน​มา​ถึง

คน​ที่มา​จาก Python จะ​สะดุด​ตรง​นี้ sys.exit() โยน SystemExit ซึ่ง​เป็น exception จริง finally จึง​ได้​ทำงาน​ตาม​ปกติ · คน​ที่มา​จาก Go เจอ​กฎ​เดียว​กับ Java เอกสาร​ของ os.Exit เขียน​ไว้​เอง​ว่า deferred function จะ​ไม่​ถูก​เรียก

ข้อ​สรุป​สำหรับ​ทั้ง​สอง​ภาษา​เหมือน​กัน — เรียก​มัน​ที่​เดียว​คือ​ใน main หลัง​ทุก​อย่าง​คืน​ค่า​กลับ​มา​แล้ว

Cli.run ที่​รับ​ทั้ง argument และ stream ครบ ถูก​เรียก​จาก​เทสต์​ได้​เหมือน​ฟังก์ชัน​ธรรมดา

JUnit JupiterJUnit Jupiterชั้น API ของ JUnit ที่​เขียน `@Test` และ `assertThrows` · ต้อง​ประกาศ artifact รวม `junit-jupiter` ไม่ใช่ `junit-jupiter-api` ไม่​งั้น​ไม่มี​อะไร​ถูก​รัน​แต่​ผล​เขียว เข้า​มา​ที่​บท​นี้ ไม่ใช่​ก่อน​หน้า​นี้ เพราะ source launcher ของ​บท​ที่ 1 กับ 2 รัน​มัน​ไม่​ได้ · เทสต์​จึง​เริ่ม​ได้​หลัง​จาก​มี Maven เท่านั้น

เติม dependency ก้อน​นี้​ลง​ใน pom.xml ที่​บท​ที่ 3 เขียน​ไว้

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>

ต้อง​เป็น junit-jupiter ตัว​รวม ไม่ใช่ junit-jupiter-api · ตัว​รวม​ลาก junit-jupiter-params มา​ให้​ด้วย ซึ่ง​เป็น​เจ้าของ @ParameterizedTest กับ @CsvSource

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · อยู่ที่ src/test/java/orderctl/CliTest.java
package orderctl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class CliTest {
private static final String ORDER = """
{"id":"A-1","lines":[
{"sku":"phad-thai","category":"food","qty":2,"unitSatang":6000},
{"sku":"cha-yen","category":"drink","qty":1,"unitSatang":3500}]}""";
private static final String EMPTY = """
{"id":"A-1","lines":[]}""";
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
private final ByteArrayOutputStream err = new ByteArrayOutputStream();
private int run(String... args) {
return Cli.run(args,
new PrintStream(out, true, StandardCharsets.UTF_8),
new PrintStream(err, true, StandardCharsets.UTF_8));
}
private String out() {
return out.toString(StandardCharsets.UTF_8);
}
private String err() {
return err.toString(StandardCharsets.UTF_8);
}
private Path write(Path dir, String name, String body) throws Exception {
Path p = dir.resolve(name);
Files.writeString(p, body);
return p;
}
@Test
@DisplayName("ไม่ใส่อะไรเลย ได้รหัส 3 พร้อมวิธีใช้ที่ stderr และ stdout ต้องว่าง")
void usageGoesToStderr() {
assertEquals(3, run());
assertTrue(err().startsWith("ใช้: orderctl"), err());
assertEquals("", out());
}
@Test
@DisplayName("ปลายทางที่ไม่มีอยู่ ได้รหัส 4 และข้อความมีคำกริยาที่ NoSuchFileException ไม่ให้")
void missingFileIsFour(@TempDir Path dir) {
assertEquals(4, run("total", dir.resolve("no-such.json").toString()));
assertTrue(err().contains("เปิด"), err());
}
@Test
@DisplayName("total พิมพ์ยอดที่ stdout และไม่แตะ stderr เลย")
void totalPrintsToStdout(@TempDir Path dir) throws Exception {
assertEquals(0, run("total", write(dir, "order.json", ORDER).toString()));
assertEquals("2 รายการ รวม 15500 สตางค์" + System.lineSeparator(), out());
assertEquals("", err());
}
@ParameterizedTest
@CsvSource({"4, 0", "99, 2"})
@DisplayName("validate แปลง Failure เป็น exit code ด้วยตารางของบทที่ 6")
void validateMapsFailureToExitCode(String table, int want, @TempDir Path dir) throws Exception {
Path order = write(dir, "order.json", ORDER);
assertEquals(want, run("validate", order.toString(), table));
}
@Test
@DisplayName("ออเดอร์ที่ไม่มีรายการเลย ได้รหัส 2 และรายงานครบทั้งสองข้อ")
void reportsEveryFailure(@TempDir Path dir) throws Exception {
Path order = write(dir, "empty.json", EMPTY);
assertEquals(2, run("validate", order.toString(), "99"));
assertTrue(err().contains("field table"), err());
assertTrue(err().contains("ไม่มีรายการ"), err());
}
}

@TempDir ให้ directory เปล่า​มา​หนึ่ง​ชุด​แล้ว​ลบ​ทิ้ง​ให้​เอง​เมื่อ​เทสต์​จบ · มัน​คือ​คู่หู​ของ Store.save ที่​ต้อง​เขียน​ลง disk จริง และ​เป็น​ตัว​เดียว​กับ t.TempDir() ของ Go

เทสต์​อีก​ชุด​จับ Store โดยตรง ซึ่ง​เป็น​ที่​เดียว​ที่​ข้อ​สังเกต​เรื่อง​สิทธิ์​ถูก​ตรึง​ไว้​เป็น​ด่าน

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · อยู่ที่ src/test/java/orderctl/StoreTest.java
package orderctl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StoreTest {
private static final State SAMPLE = new State(Map.of("A-1", 15500L));
@Test
@DisplayName("save แล้ว load กลับได้ค่าเดิม และสิทธิ์ของปลายทางเดิมไม่หาย")
void keepsExistingPermissions(@TempDir Path dir) throws Exception {
Path state = dir.resolve("state.json");
Files.writeString(state, "{}");
Files.setPosixFilePermissions(state, PosixFilePermissions.fromString("rw-rw-r--"));
Store.save(state, SAMPLE);
assertEquals("rw-rw-r--",
PosixFilePermissions.toString(Files.getPosixFilePermissions(state)));
assertEquals(SAMPLE, Store.load(state, State.class));
}
@Test
@DisplayName("ปลายทางที่ยังไม่มี ได้ rw-r--r-- ไม่ใช่ rw------- ของ createTempFile")
void freshFileIsNotPrivate(@TempDir Path dir) throws Exception {
Path state = dir.resolve("state.json");
Store.save(state, SAMPLE);
assertEquals("rw-r--r--",
PosixFilePermissions.toString(Files.getPosixFilePermissions(state)));
}
}
$ mvn -o test
[INFO] --- surefire:3.5.4:test (default-test) @ orderctl ---
[INFO] Running orderctl.StoreTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.131 s -- in orderctl.StoreTest
[INFO] Running orderctl.CliTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.040 s -- in orderctl.CliTest
[INFO] Results:
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0

แปด​ไม่ใช่​เจ็ด เพราะ @CsvSource สอง​แถว​นับ​เป็น​สอง​ครั้ง · อ่าน​เลข​นั้น​ทุก​ครั้ง แล้ว​เทียบ​กับ​จำนวน @Test ที่​คุณ​เขียน​จริง

ด่าน​ที่​ไม่มี​ใคร​รัน​คือ​ด่าน​ที่​ไม่มี​วัน​แดง และ​มัน​ดู​เหมือน​ด่าน​ที่​แข็ง​แรง​ทุก​ประการ

`@DisplayName` ภาษา​ไทย​ไม่​ได้​โผล่​มา​ที่​จอ​เอง

วัด​บน Maven 3.9.16 กับ surefire 3.5.4 เมื่อ 2026-08-27 · จอ​ที่​รัน​เขียว​ไม่มี​ชื่อ method สัก​ตัว มัน​มี​แค่​ชื่อ class กับ​บรรทัด Tests run: ตาม​ที่​เห็น​ข้าง​บน · ชื่อ​ที่​เรา​ตั้ง​ไว้​ไป โผล่​ใน​รายงาน XML แทน และ​เป็น​ชื่อ method ที่​นั่น​ด้วย คือ <testcase name="usageGoesToStderr" ...>

เปิด​ชื่อ​ที่​เขียน​ไว้​ได้​ด้วย usePhrasedTestCaseMethodName ใน statelessTestsetReporter ของ surefire · เปิด​แล้ว​รายงาน XML เขียน <testcase name="ไม่ใส่อะไรเลย ได้รหัส 3 พร้อมวิธีใช้ที่ stderr และ stdout ต้องว่าง" ...> ครบ​ทุก​ตัว​อักษร เพราะ project.build.sourceEncoding ใน pom เป็น UTF-8 อยู่​แล้ว

ที่​ไม่​ทำงาน​คือ​การ​ใส่​ค่า​เดียวกัน​นี้​ให้ statelessTestsetInfoReporter ตัว​ที่​คุม​จอ · surefire ตอบ​ว่า Cannot find 'usePhrasedTestCaseMethodName' แล้ว build ล้ม

บท​นี้​ไม่​ได้​เพิ่ม​ความ​สามารถ​ของ​โดเมน​เลย มัน​เพิ่ม​สิ่ง​ที่​ทำให้​เครื่อง​มือ​อยู่​รอด​ใน​มือ​คน​อื่น

Files.readString กับ Files.writeString ผูก UTF-8 ไว้​ใน​สเปก ส่วน FileWriter ผูก​กับ default charset ที่​ผู้​ใช้​พลิก​ได้ · เครื่องหมาย​คำถาม​บน​จอ​กับ​ข้อมูล​ที่​ตาย​แล้ว เป็น​คนละ​เรื่อง​กัน และ​แก้​คนละ​ที่

การ​เขียน​ทับ​ทำลาย​ของ​เก่า​ตั้งแต่​วินาที​ที่​เปิด Store.save จึง​เขียน​ลงชื่อ​ชั่วคราว​ใน directory เดียวกัน​ก่อน แล้ว​สลับ​ชื่อ​ด้วย ATOMIC_MOVE พร้อม​คัด​สิทธิ์​เดิม​มา​ใส่​เอง

Cli.run คืน int และ​รับ stream สอง​ตัว main จึง​เหลือ​งาน​เดียว​คือ​แปลง​เป็น exit code · นั่น​คือ​เหตุผล​เดียว​ที่​เทสต์​แปด​ตัว​เรียก​โปรแกรม​ทั้ง​ตัว​ได้​โดย​ไม่​สร้าง process ใหม่

บท​หน้า​เป็น​บท​ปิด​คอร์ส — เปิด endpoint ด้วย stdlib ล้วน แล้ว​ประมวล​ผล​หลา​ยอ​อเด​อร์​พร้อม​กัน ด้วย virtual thread · หลัก​การ​แยก​ตรรกะ​ออก​จาก​ขอบ​ที่​ใช้​กับ Cli.run วัน​นี้ จะ​กลับ​มา​อีก​ครั้ง​กับ HttpHandler


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • java.nio.file.Files — readString และ lines (เข้า​ถึง 2026-08-27) — readString เขียน​ว่า “decoding from bytes to characters using the UTF-8 charset” ส่วน API Note ของ lines เขียน​ว่า “This method must be used within a try-with-resources statement […]”
  • java.nio.file.Files — move และ createTempFile (เข้า​ถึง 2026-08-27) — 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”
  • JEP 400: UTF-8 by Default (เข้า​ถึง 2026-08-27) — sun.jnu.encoding ใช้​กับ​ชื่อ​ของ file “as opposed to file contents” และ -Dfile.encoding=COMPAT ย้อน​ไป​ใช้​อัล​กอ​ริ​ทึม​ของ JDK 17
  • java.lang.System (Java SE 25) (เข้า​ถึง 2026-08-27) — exit “neither returns nor throws an exception” และ property stdout.encoding
  • บท​ที่ 7 ของ​คอร์ส Go — internal/store ที่​แก้​โจทย์​เดียวกัน​ด้วย os.CreateTemp แล้ว os.Rename
  • Maven Surefire Plugin — Using JUnit 5 Platform (เข้า​ถึง 2026-08-27) — หัวข้อ “Configuration for @DisplayName” ที่​วาง usePhrasedTestCaseMethodName ไว้​ใน statelessTestsetReporter

เช็กความเข้าใจ — บทที่ 7

ข้อ 1 / 3

รันแล้วเห็นชื่อ file ภาษาไทยกลายเป็น `???????.json` บนจอ ควรสรุปว่าอย่างไร