บรรทัดคำสั่ง สถานะบน disk และเทสต์ตัวแรก
หกบทที่ผ่านมา orderctl มีโดเมนครบแล้ว — มี record ที่ตรวจตัวเองได้ อ่าน JSON เข้าเป็น
Order ได้ คิดยอดเป็น และรายงานทุกข้อที่ผิดได้
สิ่งที่มันยังไม่มีคือทุกอย่างที่ทำให้เครื่องมือหนึ่งตัวอยู่รอดในมือคนอื่น คือการรับ argument การคืนเลขให้สคริปต์อ่าน และการจำสิ่งที่ทำไปแล้วโดยไม่ทำของเดิมพัง
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.CharsetProbedefault charset = UTF-8sun.jnu.encoding = UTF-8stdout.encoding = UTF-8ชื่อที่อ่านกลับมา = ออเดอร์.jsonเนื้อที่อ่านกลับมา = ผัดไทยกุ้งสดFiles.writeString = 36 ไบต์FileWriter = 36 ไบต์ผลชุดนี้ไม่บอกอะไรเลย เพราะเครื่องที่วัดตั้ง locale เป็น UTF-8 อยู่แล้ว · ของจริงเห็นได้เมื่อรันซ้ำในสภาพแวดล้อมที่ผู้ใช้พลิกค่าเอง
$ LANG=C java -Dfile.encoding=COMPAT -cp target/classes orderctl.CharsetProbedefault charset = US-ASCIIsun.jnu.encoding = UTF-8stdout.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”
วัดบน 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
ทั้งบทนี้เรียกโปรแกรมผ่านชื่อสั้นตัวเดียว ซึ่งประกาศไว้ครั้งเดียวแบบนี้
mvn -q compilemvn -q dependency:build-classpath -Dmdep.outputFile=cp.txtorderctl() { java -cp "target/classes:$(cat cp.txt)" orderctl.Main "$@"; }ตัวอย่างสามใบที่บทนี้ใช้ต้องเขียนใหม่ · order.json ของบทที่ 3 ยังไม่มีคีย์ category
ซึ่งบทที่ 4 เพิ่มเข้าไปใน Line แล้วบังคับว่าห้ามเป็น null
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.json2 รายการ รวม 15500 สตางค์$ orderctl total order2.json state.json1 รายการ รวม 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คอร์ส 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 ไม่ทำ เพราะสิ่งที่มันกัน
คือคำสั่งที่ล้มกลางทาง ไม่ใช่ไฟดับ
main ที่บาง กับ Cli.run ที่เทสต์ได้
หัวข้อที่มีชื่อว่า “main ที่บาง กับ Cli.run ที่เทสต์ได้”ตรรกะทั้งหมดอยู่ในฟังก์ชันที่คืน
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 สตางค์ ครบทุกตัวอักษร
exit code — เลขที่คนเรียกใช้จริง
หัวข้อที่มีชื่อว่า “exit code — เลขที่คนเรียกใช้จริง”exception ที่หลุดออกจาก
mainให้เลข 1 เสมอ เลขอื่นต้องจับแล้วเรียกSystem.exitเอง
สคริปต์ที่เรียก orderctl อ่านเลขเดียวคือ $? · ข้อความที่เราพิมพ์สวยแค่ไหนก็ไม่มีผลกับมัน
| เลข | ความหมายใน orderctl | ใครเป็นคนกำหนด |
|---|---|---|
| 0 | ทำงานจบตามปกติ | Cli.run |
| 1 | exception ที่ไม่มีใครดัก | 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=$?"ไม่รู้จักคำสั่ง explodeexit=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.jsonexit=4
$ orderctl validate order.json 4 ; echo "exit=$?"ผ่านexit=0สังเกตว่าวิธีใช้กับข้อความความผิดพลาดไปที่ stderr ส่วนผลของงานไปที่ stdout
· คนที่เขียน orderctl total x.json > total.txt ต้องได้แค่ยอด ไม่ใช่ยอดปนคำเตือน
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รอบที่สองไม่พิมพ์ 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.javapackage 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.javapackage 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 ที่คุณเขียนจริง
ด่านที่ไม่มีใครรันคือด่านที่ไม่มีวันแดง และมันดูเหมือนด่านที่แข็งแรงทุกประการ
วัดบน 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” และ propertystdout.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` บนจอ ควรสรุปว่าอย่างไร