จาก launcher สู่ Maven และ JSON ที่ JDK ไม่มีให้
บทที่แล้ว orderctl รันได้ด้วยคำสั่งเดียวที่ไม่ต้องตั้งค่าอะไรเลย
บทนี้เราทิ้งความสะดวกนั้น แล้วแลกมาด้วยสามอย่างที่บทที่แล้วให้ไม่ได้ — เทสต์ ของที่ห่อแล้ว และ library จากข้างนอก
ตั้งแต่บรรทัดนี้ไป ทุกตัวอย่างอยู่ใน package orderctl และรันด้วย
mvn -q compile exec:java · รูปแบบของบทที่ 1 กับ 2 จะไม่กลับมาอีก
ผลลัพธ์ที่พิมพ์ในบทนี้ทั้งหมดมาจากวิธีรันแบบใหม่ · ข้อความความผิดพลาดของสองวิธีนี้ ไม่เหมือนกัน จึงคัดลอกข้ามบทไม่ได้
ทุกผลลัพธ์ในบทนี้วัดบน OpenJDK 25.0.4.1 · macOS arm64 · Apache Maven 3.9.16 เมื่อ 2026-08-27
กำแพงสามด้านของการรันจาก source
หัวข้อที่มีชื่อว่า “กำแพงสามด้านของการรันจาก source”สามข้อที่วิธีของบทที่แล้วทำไม่ได้ · บทนี้ปิดสองข้อ และเปิดทางให้ข้อแรกที่บทที่ 7 มาปิด
ข้อแรก เทสต์รันไม่ได้เลย ไม่ใช่ว่ายาก แต่คือรันไม่ได้ · JUnit เป็น library ที่โหลดมาจาก class path ส่วนโปรแกรมของบทที่แล้วถูกแปลไว้ในหน่วยความจำ · สองฝั่งนี้มองไม่เห็นกัน
ข้อที่สอง ไม่มีอะไรเหลือไว้ให้ส่งต่อ รันเสร็จแล้วไม่มี .class ไม่มี jar ไม่มีอะไรที่
copy ไปเครื่องอื่นได้ · สิ่งเดียวที่ส่งต่อได้คือ source กับคำสั่ง
ข้อที่สาม ความผิดพลาดตอนแปลโผล่กลางทางได้ ตัวรันแปล class เท่าที่จำเป็นก่อน แล้วแปล ตัวที่เหลือตอนที่มันถูกโหลดจริง · class ที่โปรแกรมเรียกทีหลัง จึงถูกตรวจทีหลังด้วย
โปรแกรมสอง file ที่ตัวที่สองถูกโหลดตอนรันด้วย Class.forName ให้ผลแบบนี้
$ java Probe.javaบรรทัดนี้พิมพ์ออกมาแล้ว/…/Late.java:2: error: incompatible types: int cannot be converted to String String text() { return 1; } ^1 errorerror: compilation failedบรรทัดแรกคือ output ของโปรแกรม บรรทัดที่เหลือคือความผิดพลาดตอนแปล · มันออกมาหลังจาก โปรแกรมทำงานไปแล้วส่วนหนึ่ง
JEP 458 เขียนไว้เองว่าตัวรันเลือกได้ว่าจะแปล class ไหนตอนไหน และวิธีเลือกนั้นเปลี่ยนได้ระหว่างรุ่น
บนเครื่องที่วัดบทนี้ ถ้า class ตัวที่สองถูกอ้างถึงตรงๆ ในโปรแกรม มันถูกตรวจตั้งแต่แรก
และความผิดพลาดออกมาก่อนบรรทัด output ทุกครั้ง · ต้องเลี่ยงการอ้างตรงๆ ด้วย
Class.forName ถึงจะเห็นแบบข้างบน
สิ่งที่เชื่อได้จึงเป็นข้อสรุปเชิงทิศทาง — วิธีนี้ไม่รับประกันว่าทั้งโปรแกรมถูกตรวจก่อนเริ่มทำงาน · ส่วน build tool แปลทั้งโปรเจกต์ให้จบก่อนเสมอ
pom.xml ยี่สิบบรรทัดที่เราพิมพ์เอง
หัวข้อที่มีชื่อว่า “pom.xml ยี่สิบบรรทัดที่เราพิมพ์เอง”ทั้ง file มีสี่ก้อน และก้อนที่ตัดออกไม่ได้คือก้อนที่หน้าตาเหมือนของไม่สำคัญที่สุด
สร้างไดเรกทอรี orderctl/ แล้วใส่ pom.xmlpom.xmlคำประกาศโปรเจกต์ของ Maven · ถ้าไม่ตั้ง `maven.compiler.release` มันย้อนไป target 1.8 แล้ว record แดงทันที ไว้ที่ราก
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>dev.kaen</groupId> <artifactId>orderctl</artifactId> <version>1.0.0</version> <properties> <maven.compiler.release>25</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>tools.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>3.2.2</version> </dependency> </dependencies></project>| ก้อน | ทำอะไร | ตัดออกได้ไหม |
|---|---|---|
modelVersion | บอกว่า pom นี้เขียนตามรูปแบบ 4.0.0 | ไม่ได้ Maven ปฏิเสธทันที |
groupId artifactId version | พิกัดสามชั้นที่ระบุโปรเจกต์นี้ในโลก | ไม่ได้ ทั้งสามตัวบังคับ |
maven.compiler.release | บอกว่าแปลให้ Java 25 | ตัดแล้วพัง ดูข้างล่าง |
project.build.sourceEncoding | บอกว่า source เป็น UTF-8 | ตัดได้ แต่จะมีคำเตือนขึ้นสองบรรทัด |
dependencies | รายชื่อ library ที่เราประกาศเอง | ตัดได้ ถ้าไม่ใช้ของข้างนอกเลย |
ก้อนที่กัดคนมากที่สุดคือ maven.compiler.release · มันดูเหมือนค่าที่ไม่ใส่ก็ได้ แต่ plugin
ที่ทำหน้าที่แปลมีค่าเริ่มต้นของตัวเองรออยู่ และค่านั้นเก่ากว่าที่คุณคิดมาก
ลบบรรทัดนั้นออกแล้วสั่ง mvn -q compile จะได้แบบนี้
[ERROR] .../src/main/java/orderctl/Line.java:[6,8] records are not supported in -source 8 (use -source 16 or higher to enable records)-source 8 ไม่ได้โผล่มาจากไหนเลย · มันคือค่าเริ่มต้นของ source และ target ใน plugin
maven-compiler-plugin 3.15.0 ส่วน release ไม่มีค่าเริ่มต้น เมื่อไม่ตั้ง มันจึงตกกลับไปที่คู่เก่า
ส่วนก้อน project.build.sourceEncoding ที่ตัดออกได้ ราคาของการตัดคือคำเตือนสองบรรทัดนี้
ทุกครั้งที่ build · -q กลืนคำเตือนไปหมด ต้องสั่ง mvn compile เปล่าๆ ถึงจะเห็น
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent![WARNING] File encoding has not been set, using platform encoding UTF-8, i.e. build is platform dependent!คำว่า platform dependent คือคำสำคัญ · บนเครื่องที่ตั้ง locale ไว้อีกแบบ ข้อความไทยในโปรแกรม จะถูกอ่านด้วย charset คนละตัว แล้วผลลัพธ์เพี้ยนโดยไม่มีอะไรแดง
คุณจะเจอ pom จำนวนมากบนเน็ตที่เขียน maven.compiler.source กับ
maven.compiler.target เป็นคู่ · นั่นคือสำนวนก่อนที่ --release จะมีใน Java 9
ต่างกันตรงนี้ — source กับ target คุมแค่ไวยากรณ์กับรูปแบบของ .class แต่ยังปล่อยให้
คุณเรียก API ที่ไม่มีอยู่ในรุ่นเป้าหมายได้ · โปรแกรมจึงแปลผ่านแล้วไปพังตอนรันบนรุ่นเก่า
--release ผูกไวยากรณ์ รูปแบบ และรายการ API เข้าด้วยกัน · คอร์สนี้ใช้
maven.compiler.release ทุกที่ และไม่มีที่ไหนใช้คู่เก่าเลย
คำสั่งสร้างโครงอัตโนมัติของ Maven เป็นทางลัดที่คนแนะนำกันมาก · เราไม่ใช้มันด้วยเหตุผลที่วัดได้
โหมดไม่ถามคำถามคาย junit:junit:3.8.1 ลงมาเป็น dependency และ ไม่ใส่
maven.compiler.release ให้ · pom ที่ได้จึงแปล record ไม่ผ่านตั้งแต่ file แรกที่คุณเขียนเอง
ส่วนโหมดที่ถามคำถามคาย pom ยาวเก้าสิบบรรทัด ซึ่งยาวกว่าที่บทนี้ต้องการสี่เท่าครึ่ง · ยี่สิบบรรทัดที่คุณพิมพ์เองแล้วอ่านออกทุกบรรทัด มีค่ามากกว่าเก้าสิบบรรทัดที่คุณไม่กล้าแตะ
แปลงร่าง จากหนึ่ง file เป็นสาม
หัวข้อที่มีชื่อว่า “แปลงร่าง จากหนึ่ง file เป็นสาม”การย้ายเข้า Maven เปลี่ยนหกอย่างพร้อมกัน และห้าอย่างในนั้นเป็นผลของการมี
package
โครงที่ Maven คาดหวังลึกสี่ชั้นก่อนจะถึง source ตัวแรก
orderctl/ pom.xml order.json ออเดอร์ตัวอย่างที่โปรแกรมรับเป็น argument src/main/java/orderctl/Line.java src/main/java/orderctl/Order.java src/main/java/orderctl/Main.java target/ Maven สร้างเอง ห้ามใส่ใน gitสี่ชั้น src/main/java/orderctl/ คือราคาที่คอร์ส Go ไม่ต้องจ่าย · module ของ Go วาง
package ไว้ที่ internal/order/ ซึ่งลึกสองชั้น และตั้งชื่อชั้นเองได้เกือบทั้งหมด
Maven กวาดเฉพาะสิ่งที่อยู่ใต้ src/main/java · ย้าย Line.java ออกไปไว้ที่อื่นแล้วมันจะ
ไม่ถูกแปลเลย และเรารู้ตัวก็ต่อเมื่อ Order.java แดงด้วย cannot find symbol: class Line
มีอีกชั้นชื่อ src/main/resources ที่เราไม่ได้ใช้ในบทนี้ · ของที่วางไว้ตรงนั้นจะถูก copy
เข้าไปอยู่ใน jar ด้วย ซึ่งเหมาะกับ file ที่ต้องเดินทางไปพร้อมโปรแกรม ไม่ใช่ข้อมูลนำเข้า
| บทที่ 2 เขียนแบบนี้ | บทนี้เขียนแบบนี้ | เพราะอะไร |
|---|---|---|
ไม่มีบรรทัด package | package orderctl; บรรทัดแรก | รูปย่อของบทที่แล้วประกาศ package ไม่ได้เลย |
void main() | public static void main(String[] args) | ไม่มี class ที่ตัวรันสร้าง instance ให้อีกแล้ว |
IO.println ของบทที่ 2 | System.out.println | class ช่วยตัวนั้นผูกกับรูปย่อ |
record Line อยู่ใน Domain.java ร่วมกับ main | Line.java แยกออกมา | public type หนึ่งตัวต่อหนึ่ง file |
ไม่ต้องเขียน import ของ java.base | เขียน import java.util.List; เอง | รูปย่อได้ java.base มาให้ฟรี รูปเต็มไม่ได้ |
| ไม่มีอะไรตกค้าง | target/classes/orderctl/*.class | นี่คือของที่บทที่แล้วไม่มี |
แถวที่สี่คือแถวที่บทที่ 2 ปูทางไว้ให้แล้ว · javac -d out Domain.java ตรงนั้นคาย
Domain$Line.class ออกมา ไม่ใช่ Line.class
ชื่อ Domain$Line เป็นส่วนหนึ่งของสัญญาที่ class อื่นต้องเรียกให้ถูก · การย้ายมาอยู่ file
ของตัวเองจึงไม่ใช่เรื่องความสวยงาม มันเปลี่ยนชื่อจริงของ type
สาม file ที่ได้ออกมา
หัวข้อที่มีชื่อว่า “สาม file ที่ได้ออกมา”และ file ที่สี่ที่ต้องย้ายตามมาด้วย เพราะบทหลังใช้มัน
Line.java ยืนอยู่ได้ด้วยตัวเอง ไม่ต้องพึ่ง type อื่นในโปรเจกต์เลย
package orderctl;
import java.util.Locale;import java.util.Objects;
public record Line(String sku, int qty, long unitSatang) {
public Line { Objects.requireNonNull(sku, "sku"); if (qty <= 0) { throw new IllegalArgumentException("qty ต้องมากกว่า 0 แต่ได้ " + qty); } if (unitSatang < 0) { throw new IllegalArgumentException("ราคาติดลบไม่ได้"); } sku = sku.trim().toUpperCase(Locale.ROOT); }
public long subtotalSatang() { return qty * unitSatang; }}เนื้อในเหมือนบทที่ 2 ทุกบรรทัด · สิ่งที่เพิ่มมาคือ package public และ import สองบรรทัด
ที่รูปย่อเคยได้มาฟรี
Order.java เป็นที่แรกที่ต้องเขียน import ของ collection เอง
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Line.java อยู่ข้างกันจึงจะแปลผ่านpackage orderctl;
import java.util.List;import java.util.Objects;
public record Order(String id, List<Line> lines) {
public Order { Objects.requireNonNull(id, "id"); lines = List.copyOf(lines); }
public long grossSatang() { long sum = 0; for (Line line : lines) { sum += line.subtotalSatang(); } return sum; }}Line ไม่ต้อง import เพราะอยู่ package เดียวกัน · ส่วน List กับ Objects อยู่คนละที่
จึงต้องเขียนออกมาทั้งคู่
Main.java คือจุดเริ่มของโปรแกรม และเป็น type เดียวในสามตัวนี้ที่ไม่ใช่ record
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Line.java กับ Order.java อยู่ข้างกันpackage orderctl;
import java.nio.file.Path;import tools.jackson.databind.json.JsonMapper;
public final class Main {
static final JsonMapper MAPPER = JsonMapper.builder().build();
public static void main(String[] args) { if (args.length < 1) { System.err.println("ใช้: orderctl <order.json>"); System.exit(2); } Order order = MAPPER.readValue(Path.of(args[0]), Order.class); System.out.println(order.id() + " รวม " + order.grossSatang() + " สตางค์"); }}นี่คือจุดที่ Jackson 3 ต่างจาก Jackson 2 มากที่สุด และเป็นจุดที่ tutorial บนเน็ตจะพาคุณหลงทาง
สำนวนของ Jackson 2 ครอบ readValue ด้วย catch (IOException e) เพราะมันประกาศ
throws IOException ไว้ · ตัวอย่างเกือบทุกอันที่คุณค้นเจอเขียนแบบนั้น
สำนวนของ Jackson 3 ไม่มี throws เลย เพราะความผิดพลาดของมันเป็น unchecked ทั้งหมด
· ถ้าคุณคัดลอก catch (IOException e) มาใส่ มันจะ ไม่แปลผ่าน เพราะไม่มีอะไรใน try
โยน checked IOException ออกมาให้จับ
readValue(Path, Class) ก็เป็นอีกอย่างที่ Jackson 2 ไม่มี · ของเก่ารับ File ไม่รับ Path
flowchart LR P["pom.xml<br/>ประกาศ jackson-databind หนึ่งบรรทัด"] --> M["Maven"] S["src/main/java/orderctl/<br/>Line · Order · Main"] --> M M -->|"ดึงมาเก็บที่ ~/.m2"| J["jackson-databind<br/>jackson-core<br/>jackson-annotations"] M -->|"compile"| C["target/classes/orderctl/"] J -.->|"อยู่บน class path ทั้งตอนแปลและตอนรัน"| C C -->|"package"| A["target/orderctl-1.0.0.jar<br/>มีแต่ class ของเราเอง"]
คำบรรยายภาพ: Maven รับสองทางเข้า คือ pom กับ source แล้วคาย class ออกมา · jar ที่ได้ปลายทางมีแต่ class ของโปรเจกต์เอง ไม่มีของที่ดึงมาจากข้างนอกอยู่ข้างใน
Discount.java ย้ายตามมาจากบทที่ 2 โดยไม่เปลี่ยนรูปเลย ได้ package เพิ่มบรรทัดเดียว
package orderctl;
public sealed interface Discount { record Percent(int pct) implements Discount {}
record Fixed(long satang) implements Discount {}
record None() implements Discount {}}บทที่ 5 จะเอาชนิดนี้ไปแปลงเป็นกฎส่วนลดที่ต่อเพิ่มได้ · ถ้าไม่ย้ายมันมาตอนนี้ บทนั้นจะอ้างชนิดที่ไม่มีอยู่ในโปรเจกต์
sealed ที่บทที่ 2 เขียนไว้ ต้องเติม public ตรงนี้ เพราะ file อื่นใน package เดียวกัน
ยังเห็นได้ก็จริง แต่ Rules ของบทที่ 5 คืนค่าที่มีชนิดนี้ออกไปเป็นส่วนหนึ่งของ API
JDK ไม่มี JSON มาให้สักก้อนเดียว
หัวข้อที่มีชื่อว่า “JDK ไม่มี JSON มาให้สักก้อนเดียว”standard library ของ Java แบ่งเป็นก้อน และไม่มีก้อนไหนเกี่ยวกับ JSON เลย
บทที่ 1 ชวนให้ลองสั่ง java --list-modules · คราวนี้กรองด้วยคำว่า json
$ java --list-modules | wc -l 69$ java --list-modules | grep -i json$หกสิบเก้าก้อน ศูนย์ผลลัพธ์ · นี่คือความต่างข้อใหญ่ที่สุดระหว่างคอร์สนี้กับคอร์ส Go
ซึ่งใช้ encoding/json ที่มาในกล่องตั้งแต่วันแรก
JEP 540 เสนอ JSON API ตัวแรกของ JDK เข้ามาเป็น incubator · สถานะปัจจุบันคือ Targeted ที่ Release 28 (อ่านหน้า JEP เมื่อ 2026-08-27)
สองข้อที่ทำให้มันไม่แทนที่บทนี้ · ข้อแรก มันเป็น incubator จึงต้องสั่ง
--add-modules jdk.incubator.json แล้วตัวรันจะพิมพ์บรรทัด
WARNING: Using incubator modules: jdk.incubator.json ให้ทุกครั้ง
ข้อที่สอง เอกสารของ JEP เขียนไว้เองในหัวข้อ Goals ว่าจะเลี่ยงความสามารถอย่าง
data bindingdata bindingการแปลงข้อความ JSON เข้าเป็นชนิดของโปรแกรมตรงๆ ด้วย `readValue(doc, Order.class)` · ต่างจาก tree API ที่ให้เดินอ่านทีละคีย์เอง · มันเป็น tree API ที่ให้เดินอ่านทีละคีย์เอง ไม่ใช่ของที่แปลง JSON
เข้า Order ให้ในบรรทัดเดียว
ย่อหน้านี้มีวันหมดอายุที่รู้แล้ว · ราวมีนาคม 2027 เมื่อ JDK 28 ออก ประโยค “JDK ไม่มี JSON”
ต้องกลับมาแก้ให้ตรง แต่ข้อสรุปว่ามันไม่แทน readValue จะยังเหมือนเดิม
หนึ่งบรรทัดใน pom ได้ jar สามใบ
หัวข้อที่มีชื่อว่า “หนึ่งบรรทัดใน pom ได้ jar สามใบ”ของที่เราประกาศเองมีตัวเดียว แต่ของที่ลงมาบน disk จริงมีสามตัว
pom.xml ข้างบนประกาศ jackson-databind ไว้ตัวเดียว · สั่ง mvn dependency:tree แล้วดู
ว่ามีอะไรลงมาบ้าง
$ mvn dependency:tree[INFO] dev.kaen:orderctl:jar:1.0.0[INFO] \- tools.jackson.core:jackson-databind:jar:3.2.2:compile[INFO] +- com.fasterxml.jackson.core:jackson-annotations:jar:2.22:compile[INFO] \- tools.jackson.core:jackson-core:jar:3.2.2:compileสองตัวที่ย่อหน้าเข้าไปคือ transitive dependencytransitive dependencyสิ่งที่พึ่งพาโดยอ้อม มาพร้อมกับตัวที่เราประกาศเอง · ประกาศ jackson-databind หนึ่งบรรทัดแล้วได้ jar สามตัวจริง · เราไม่ได้ขอมันตรงๆ มันมาเพราะ
jackson-databind ขอไว้เอง แล้ว Maven หามาให้ครบทั้งสาย
แถวกลางมีของแปลกซ่อนอยู่ · jackson-annotations ยังเป็น com.fasterxml.jackson.core
รุ่น 2.22 ขณะที่อีกสองตัวเป็น tools.jackson.core รุ่น 3.2.2
นั่นไม่ใช่ความผิดพลาดของการแก้ pom · Jackson ไม่ได้ทำ annotations สาย 3.x ขึ้นมาใหม่ โปรเจกต์เดียวจึงมีสอง prefix ปนกันอยู่จริง และทั้งคู่ถูกต้อง
สมมติคุณเลิกใช้ Maven แล้วโหลด jar เองสองใบ คือ jackson-databind กับ jackson-core
โดยลืมตัว annotations ไป
การแปลผ่านฉลุย ไม่มีคำเตือนสักบรรทัด เพราะ Main.java ไม่ได้เอ่ยชื่อ annotation ตัวไหนเลย
พอสั่งรันจริงจึงได้แบบนี้
Exception in thread "main" java.lang.NoClassDefFoundError: com/fasterxml/jackson/annotation/JsonSerializeAs at tools.jackson.databind.introspect.JacksonAnnotationIntrospector.<clinit>(JacksonAnnotationIntrospector.java:39) at tools.jackson.databind.cfg.MapperBuilder.<clinit>(MapperBuilder.java:50) at tools.jackson.databind.json.JsonMapper.builder(JsonMapper.java:152) at orderctl.Main.<clinit>(Main.java:8)NoClassDefFoundError เป็นความผิดพลาดตอนรันเสมอ · การแก้ปัญหาสายพึ่งพาให้ครบโดย
อัตโนมัติ คือสิ่งที่คุณจ่าย XML ยี่สิบบรรทัดไปเพื่อซื้อ
record รับ JSON ได้โดยไม่ติด annotation สักตัว
หัวข้อที่มีชื่อว่า “record รับ JSON ได้โดยไม่ติด annotation สักตัว”ไม่มีที่ไหนให้เขียนชื่อคีย์กำกับ ชื่อ component จึงต้องตรงกับคีย์ใน JSON ตัวต่อตัว
วางออเดอร์ตัวอย่างไว้ที่ order.json ตรงรากของโปรเจกต์ · คีย์ทุกตัวในนี้สะกดตรงกับชื่อ
component ของ Order และ Line
{ "id": "ORD-1042", "lines": [ { "sku": "CFE-01", "qty": 2, "unitSatang": 6500 }, { "sku": "TEA-03", "qty": 1, "unitSatang": 5500 } ]}unitSatang เก็บเงินเป็นสตางค์เหมือนคอร์ส Go · กาแฟแก้วละ 65 บาทจึงเขียนเป็น 6500 ไม่ใช่ 65
รันด้วยคำสั่งนี้ · คำว่า compile ตัดออกไม่ได้ เพราะเป้าหมาย exec:java ไม่ผูกกับ phase ไหน
เลย มันจึงไม่แปลอะไรให้ก่อน
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.Main -Dexec.args=order.jsonORD-1042 รวม 18500 สตางค์ครั้งแรกจะช้าและมีบรรทัดดาวน์โหลดไหลยาว เพราะ Maven ดึง plugin กับ jar ทั้งหมดลง ~/.m2
· ครั้งต่อไปมันอ่านจากที่นั่นแล้วเงียบ
readValue(path, Order.class) คือ data binding · มันไม่ได้คืน tree ให้เราเดินเอง
มันสร้าง Order ตัวจริงพร้อม List<Line> ข้างในให้เสร็จ
ตัวตรวจของ record ถูกเรียกจริง
หัวข้อที่มีชื่อว่า “ตัวตรวจของ record ถูกเรียกจริง”ลองแก้ qty ในบรรทัดแรกให้เป็น 0 แล้วรันใหม่
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.Main -Dexec.args=zero-qty.json[ERROR] ... Cannot construct instance of `orderctl.Line`, problem: qty ต้องมากกว่า 0 แต่ได้ 0[ERROR] at [Source: REDACTED ...] (through reference chain: orderctl.Order["lines"]->java.util.ArrayList[0])ข้อความหลังคำว่า problem: คือข้อความที่เราเขียนเองในตัวตรวจของ Line · Jackson ไม่ได้ยัด
ค่าลง component ตรงๆ มันเรียก constructor ตัวจริงของ record
ผลคือ Line ที่ qty เป็น 0 สร้างไม่ขึ้น ไม่ว่าค่าจะมาจาก JSON จากเทสต์ หรือจาก new
ในโปรแกรม · กฎอยู่ที่ตัวชนิดเอง ไม่ได้อยู่ที่ผู้เรียก
คอร์ส Go ก็มีตัวตรวจของโดเมนเหมือนกัน แต่มันเป็นฟังก์ชันแยกที่คนอ่าน JSON ต้องเรียกเอง
json.Unmarshal ยัดค่าลง struct ผ่าน reflection · struct ของ Go ไม่มี constructor จึงไม่มีที่ไหน
ให้แขวนตัวตรวจไว้ให้มันทำงานอัตโนมัติ
ลืมเรียกตัวตรวจหนึ่งจุด ออเดอร์ที่ผิดกฎก็ไหลเข้าระบบไปเรียบร้อย · ฝั่ง Java ลืมไม่ได้
เพราะไม่มีทางสร้าง Line ที่ผิดกฎขึ้นมาตั้งแต่แรก
สามอย่างที่พิสูจน์ได้ในโปรแกรมเดียว
หัวข้อที่มีชื่อว่า “สามอย่างที่พิสูจน์ได้ในโปรแกรมเดียว”ข้อดีข้างบนมาพร้อมข้อเสียที่ต้องรู้ · ไม่มี annotation แปลว่าไม่มีที่ให้เขียนชื่ออื่นกำกับ และค่าปริยายของ Jackson 3 เรื่องคีย์แปลกปลอมก็ไม่ใช่อย่างที่ tutorial เก่าบอก
package orderctl;
import tools.jackson.databind.DeserializationFeature;import tools.jackson.databind.json.JsonMapper;
public final class KeyProbe {
record Row(String sku, int qty) { }
static final String EXTRA = "{\"sku\":\"CFE-01\",\"qty\":2,\"note\":\"หวานน้อย\"}"; static final String MISSING = "{\"qty\":2}";
public static void main(String[] args) { JsonMapper lenient = JsonMapper.builder().build(); JsonMapper strict = JsonMapper.builder() .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build();
System.out.println("คีย์เกิน ปริยาย : " + lenient.readValue(EXTRA, Row.class)); System.out.println("คีย์ขาด ปริยาย : " + lenient.readValue(MISSING, Row.class));
try { strict.readValue(EXTRA, Row.class); } catch (RuntimeException e) { System.out.println("คีย์เกิน เข้มงวด : " + e.getClass().getSimpleName()); } try { System.out.println(lenient.readValue(MISSING, Row.class).sku().length()); } catch (NullPointerException e) { System.out.println("ใช้ค่าที่ขาด : " + e.getMessage()); } }}$ mvn -q compile exec:java -Dexec.mainClass=orderctl.KeyProbeคีย์เกิน ปริยาย : Row[sku=CFE-01, qty=2]คีย์ขาด ปริยาย : Row[sku=null, qty=2]คีย์เกิน เข้มงวด : UnrecognizedPropertyExceptionใช้ค่าที่ขาด : Cannot invoke "String.length()" because the return value of "orderctl.KeyProbe$Row.sku()" is null- คีย์
noteที่ไม่มีในRowถูกเมินเงียบตามค่าปริยายของ Jackson 3 - คีย์
skuที่ขาดไป ทำให้ component นั้นเป็นnullโดยไม่มีใครบ่นอะไร nullตัวนั้น ระเบิดตอนถูกใช้ ห่างจากจุดที่อ่าน JSON ไปแล้วหลายบรรทัด
บรรทัดสุดท้ายบอกชื่อ type กับชื่อ accessor ครบ · สองชื่อนี้อยู่ใน bytecode อยู่แล้ว
วิธีรันจึงไม่เปลี่ยนมัน ต่างจากชื่อตัวแปรเฉพาะที่ของบทที่ 2 ที่กลายเป็น <local1> เมื่อไม่มี
-g · ชื่อ KeyProbe$Row คือรูปแบบเดียวกับ Domain$Line ของบทที่แล้ว
tutorial แทบทุกอันบนเน็ตเขียนบน Jackson 2 ซึ่งโยน UnrecognizedPropertyException
ให้คีย์ที่ไม่รู้จัก ตั้งแต่ค่าปริยาย
Jackson 3 พลิกค่าปริยายของ FAIL_ON_UNKNOWN_PROPERTIES เป็น false · คีย์ที่พิมพ์ผิด
จึงหายไปเงียบ แล้ว component นั้นได้ค่าเริ่มต้นของชนิดแทน
ข้อนี้ไม่ใช่เรื่องเล็ก เพราะฝั่ง Java ไม่มี struct tag ให้เขียนชื่อคีย์กำกับ · ชื่อคีย์ใน JSON กับชื่อ component จึงผูกกันแน่น และการพิมพ์ผิดหนึ่งตัวอักษรมีราคาสูงกว่าที่ควร
เปิดความเข้มงวดกลับมา
หัวข้อที่มีชื่อว่า “เปิดความเข้มงวดกลับมา”สมมติ order.json เขียนคีย์เป็น unit แทน unitSatang ซึ่งเป็นชื่อที่คอร์ส Go ใช้
· Main.java ตัวปัจจุบันตอบแบบนี้
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.Main -Dexec.args=wrong-key.jsonORD-1042 รวม 0 สตางค์ไม่มีความผิดพลาด มีแต่คำตอบที่ผิด · unitSatang ไม่เคยได้ค่า มันจึงเป็น 0 แล้วยอดรวมก็เป็น 0
คอร์สนี้จึงเปิดความเข้มงวดกลับมาเองที่ Main.java · แก้บรรทัด MAPPER เป็นแบบนี้ แล้วเติม
import tools.jackson.databind.DeserializationFeature; ข้างบน
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · แทนที่บรรทัด MAPPER เดิมใน Main.javastatic final JsonMapper MAPPER = JsonMapper.builder() .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build();รันซ้ำด้วย file เดิม คราวนี้ได้ข้อความที่ชี้ตรงจุด
$ mvn -q compile exec:java -Dexec.mainClass=orderctl.Main -Dexec.args=wrong-key.json[ERROR] ... Unrecognized property "unit" (class orderctl.Line), not marked as ignorable (3 known properties: "sku", "qty", "unitSatang")[ERROR] at ... (through reference chain: orderctl.Order["lines"]->java.util.ArrayList[0]->orderctl.Line["unit"])มันบอกชื่อคีย์ที่ไม่รู้จัก บอกชนิดที่กำลังสร้าง และไล่รายชื่อคีย์ที่รู้จักมาให้ครบ · นี่คือความต่างระหว่างตอบผิดเงียบ กับตอบว่าไม่รู้
ราคาสองฝั่ง — บทเดียวกันของคอร์ส Go
หัวข้อที่มีชื่อว่า “ราคาสองฝั่ง — บทเดียวกันของคอร์ส Go”ฝั่งหนึ่งจ่ายด้วยเวลาตั้งค่า อีกฝั่งจ่ายด้วยขั้นตอนตรวจที่คนเรียกลืมได้
คอร์ส Go เปิดโปรเจกต์ด้วย go mod init example.com/orderctl บรรทัดเดียว แล้วได้ go.mod
สั้นๆ ที่ราก · ไม่มี XML ไม่ต้องเลือก library และไม่ต้องรู้จัก transitive dependency เลย
ราคาที่ Java จ่าย คือครึ่งแรกของบทนี้ · หัวข้อทั้งหมดตั้งแต่ pom ถึง jar สามใบ ไม่มีอะไร เกี่ยวกับภาษาเลย มันคือโครงไดเรกทอรี XML และการแก้ปัญหาสายพึ่งพา
ราคาที่ Go จ่าย อยู่ที่การตรวจความถูกต้อง · struct ไม่มี constructor ตัวตรวจจึงเป็นขั้นตอน แยกที่คนเรียกต้องจำ และคนที่ลืมจะไม่มีอะไรมาเตือน
| คอร์ส Go บทที่ 3 | คอร์สนี้ | |
|---|---|---|
| เริ่มโปรเจกต์ | go mod init หนึ่งบรรทัด | pom ยี่สิบบรรทัดที่พิมพ์เอง |
| library สำหรับ JSON | encoding/json มาในกล่อง | เลือกเอง แล้วประกาศใน pom |
| จับคู่คีย์กับ field | struct tag เขียนชื่อคีย์กำกับได้อิสระ | ชื่อ component ต้องตรงกับคีย์ |
| คีย์แปลกปลอม | ค่าปริยายข้ามให้ · ต้องย้ายไป json.NewDecoder จึงห้ามได้ | ค่าปริยายข้ามให้ · เปิด FAIL_ON_UNKNOWN_PROPERTIES ที่ mapper เดิมจึงห้ามได้ |
| ตัวตรวจตอนแปลง | ไม่มี ต้องเรียกเองแยกขั้นตอน | constructor ของ record ถูกเรียกทุกครั้ง |
| ความลึกของโครง | internal/order/ สองชั้น | src/main/java/orderctl/ สี่ชั้น |
แถวสุดท้ายคือเรื่องความรำคาญ · แถวก่อนสุดท้ายคือเรื่องที่ทำให้ระบบพังตอนตีสาม · สองแถวนี้ไม่ได้มีน้ำหนักเท่ากัน และคอร์สนี้จะไม่แกล้งทำเป็นว่ามันเท่ากัน
แถวที่สามกลับด้านกัน · Go ยอมให้ชื่อ field กับชื่อคีย์ต่างกันได้ แลกกับ tag ที่พิมพ์ผิดแล้ว เงียบ ส่วน Java ไม่มีอะไรให้พิมพ์ผิด แลกกับการที่ชื่อในโดเมนถูกล็อกกับชื่อคีย์ใน JSON
ห่อเป็น jar แล้วยังรันไม่ได้
หัวข้อที่มีชื่อว่า “ห่อเป็น jar แล้วยังรันไม่ได้”jar ที่ได้โดยปริยายขาดสองอย่าง คือจุดเริ่มของโปรแกรม และ dependency ทั้งสามใบ
ข้อร้องเรียนข้อที่สองของหัวข้อแรกคือ “ไม่มีอะไรเหลือไว้ให้ส่งต่อ” · Maven แก้ข้อนั้นด้วย คำสั่งเดียว
$ mvn -q package$ ls -l target/orderctl-1.0.0.jar-rw-r--r-- 1 nook wheel 5212 Aug 27 15:51 target/orderctl-1.0.0.jarห้ากิโลไบต์เศษ วัดจากโปรเจกต์ที่มี Main Order Line และ Discount
· ถ้าคุณยังเก็บ KeyProbe.java ไว้ด้วย ตัวเลขจะโตกว่านี้
ลองรันดูตรงๆ
$ java -jar target/orderctl-1.0.0.jar order.jsonno main manifest attribute, in target/orderctl-1.0.0.jarด่านแรกคือ jar ไม่รู้ว่าจะเริ่มที่ class ไหน · ข้อมูลนั้นอยู่ในรายการคุณสมบัติของ jar ซึ่งต้องสั่งให้ Maven ใส่ให้
ด่านที่สองรออยู่ข้างหลัง · เลี่ยงด่านแรกด้วยการชี้ class path ไปที่ target/classes ตรงๆ
แล้วยังเจอแบบนี้
$ java -cp target/classes orderctl.Main order.jsonException in thread "main" java.lang.NoClassDefFoundError: tools/jackson/databind/json/JsonMapperjar ที่ Maven ห่อให้โดยปริยาย ไม่มี dependency อยู่ข้างใน · สามใบที่เห็นใน
dependency:tree ยังนอนอยู่ที่ ~/.m2 และไม่ได้ตามไปด้วย
ปิดทั้งสองด่านพร้อมกัน
หัวข้อที่มีชื่อว่า “ปิดทั้งสองด่านพร้อมกัน”เติมก้อน build เข้าไปใน pom.xml · pom โตจากยี่สิบบรรทัดต้นเป็นสามสิบเจ็ดบรรทัด
<build> <plugins> <plugin> <artifactId>maven-jar-plugin</artifactId> <version>3.5.0</version> <configuration> <archive> <manifest> <mainClass>orderctl.Main</mainClass> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> </manifest> </archive> </configuration> </plugin> </plugins></build>mainClass ปิดด่านแรก · addClasspath กับ classpathPrefix เขียนรายชื่อ jar ที่ต้องใช้
ลงในรายการคุณสมบัติ โดยชี้ไปที่ไดเรกทอรี lib/ ข้างๆ
บรรทัด version ไม่ใช่ของแถม · plugin ที่ประกาศไว้ใน build โดยไม่บอกรุ่น ทำให้ Maven
เตือนว่าผลของ build ไม่นิ่ง และปล่อยให้รุ่นของ plugin เปลี่ยนเองในวันข้างหน้า
เหลืออีกอย่างเดียวคือเอา jar สามใบมาวางไว้ที่ lib/ จริง
$ mvn -q package dependency:copy-dependencies -DoutputDirectory=target/lib$ ls target/libjackson-annotations-2.22.jar jackson-core-3.2.2.jar jackson-databind-3.2.2.jar$ java -jar target/orderctl-1.0.0.jar order.jsonORD-1042 รวม 18500 สตางค์รายการคุณสมบัติที่ได้หน้าตาแบบนี้ · บรรทัด Class-Path คือสิ่งที่ addClasspath เขียนให้
$ unzip -p target/orderctl-1.0.0.jar META-INF/MANIFEST.MFManifest-Version: 1.0Created-By: Maven JAR Plugin 3.5.0Java-Version: 25Build-Jdk-Spec: 25Class-Path: lib/jackson-databind-3.2.2.jar lib/jackson-annotations-2.22. jar lib/jackson-core-3.2.2.jarMain-Class: orderctl.Mainทางนี้ได้ของที่ส่งต่อได้จริง แต่ยังไม่ใช่ของชิ้นเดียว · ย้าย lib/ ออกไปแล้วมันกลับไปพัง
ที่เดิมทันที
$ mv target/lib /tmp/libhold$ java -jar target/orderctl-1.0.0.jar order.jsonException in thread "main" java.lang.NoClassDefFoundError: tools/jackson/databind/json/JsonMapperสามทาง และราคาของแต่ละทาง
หัวข้อที่มีชื่อว่า “สามทาง และราคาของแต่ละทาง”| ทาง | ได้อะไร | จ่ายอะไร |
|---|---|---|
jar กับ lib/ — ทางที่บทนี้ใช้ | jar เล็ก แก้ dependency ทีละใบได้ | ต้องขนทั้งไดเรกทอรีไปด้วย และปลายทางต้องมี JVM |
uber jar ด้วย maven-shade-plugin | file เดียวจบ | ต้องเพิ่ม plugin อีกตัว · ปลายทางยังต้องมี JVM · สอง jar ที่มี resource ชื่อซ้ำจะทับกัน ถ้าไม่ตั้งตัวรวมให้ |
jlink | runtime ที่รันได้โดยปลายทางไม่ต้องมี JVM | บังคับให้ทุกชิ้นในสายเป็น module จริง ซึ่งคอร์สนี้เลี่ยงไว้ตั้งแต่ต้นด้วยการอยู่บน class path |
บทที่ 1 มีตารางห้าแถวที่บอกว่า Go กับ Java เลือกทางตรงข้ามกันตรงไหน · แถวสุดท้ายเขียนว่า Go ได้โปรแกรมเดียวที่รันบนเครื่องเปล่าได้ ส่วน Java ต้องมี JVM รออยู่ปลายทาง
หัวข้อนี้คือหน้าตาจริงของแถวนั้น · go build คายของชิ้นเดียวที่ copy ไปแล้วรันได้ทันที
ส่วนฝั่งนี้ต้องเติม pom อีกสิบเจ็ดบรรทัด บวกอีกหนึ่งคำสั่ง แล้วยังได้ของสองก้อนอยู่ดี
ทางที่สามในตารางข้างบนปิดช่องว่างนั้นได้จริง แต่มันเปลี่ยนกติกาของทั้งโปรเจกต์ · คอร์สนี้เลือกอยู่บน class path ตลอด จึงจ่ายราคาข้อนี้ไปแลกกับความเรียบง่ายของทุกบทที่เหลือ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”
orderctlมีโครงจริง มี dependency จริง และอ่านออเดอร์จาก JSON ได้แล้ว
คุณเขียน pom.xml เองทุกบรรทัด ย้าย type ของบทที่แล้วเข้าโครงของ Maven และรู้แล้วว่า
maven.compiler.release เป็นบรรทัดที่ตัดออกไม่ได้
คุณเห็นแล้วว่าหนึ่งบรรทัดใน pom ลากอะไรลงมาบ้าง และเห็นว่า record รับ JSON ได้โดยไม่ต้อง ติด annotation แต่แลกมาด้วยข้อบังคับว่าชื่อ component ต้องตรงกับคีย์
และคุณห่อ orderctl เป็น jar ที่ java -jar รันได้จริงแล้ว พร้อมรู้ว่ามันยังไม่ใช่ของชิ้นเดียว
· อีกสองทางที่ปิดช่องว่างนั้นได้ อยู่ในตารางของหัวข้อที่แล้ว
บทหน้าเราเลิกเขียนลูปด้วยมือ · grossSatang() ที่วนบวกทีละแถวจะกลายเป็นบรรทัดเดียว
แล้วเราจะดูว่าบรรทัดเดียวนั้นซ่อนอะไรไว้ และเมื่อไรที่ลูปธรรมดายังชนะอยู่
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- JEP 458: Launch Multi-File Source-Code Programs (เข้าถึง 2026-08-27) — class ที่มาจาก class path อ้างถึง class ที่แปลไว้ในหน่วยความจำไม่ได้ และตัวรันเลือกเองว่าจะแปลตอนไหน
- JEP 540: Simple JSON API (Incubator) (เข้าถึง 2026-08-27) — สถานะ Targeted ที่ Release 28 · หัวข้อ Goals เขียนเองว่าเลี่ยง data binding
- Apache Maven downloads (เข้าถึง 2026-08-27) — 3.9.16 คือรุ่นที่แนะนำสำหรับผู้ใช้ทุกคน
- Introduction to the Standard Directory Layout (Last Published 2026-08-24) — ที่มาของ
src/main/javaและsrc/main/resources - maven-compiler-plugin compile mojo (Version 3.15.0, Last Published 2026-01-27) —
sourceกับtargetมีค่าเริ่มต้น1.8ส่วนreleaseไม่มีค่าเริ่มต้น - exec-maven-plugin java mojo (เข้าถึง 2026-08-27) — เป้าหมายนี้ไม่ผูกกับ phase ไหนของวงจร build
- Jackson Release 3.0 notes (เข้าถึง 2026-08-27) — การย้าย groupId ไป
tools.jackson.coreและค่าปริยายที่เปลี่ยน - jackson-databind บน Maven Central (เข้าถึง 2026-08-27) — รุ่นที่ปล่อยล่าสุดคือ 3.2.2
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3ถ้าลบบรรทัด `maven.compiler.release` ออกจาก pom แล้วสั่ง `mvn -q compile` จะเกิดอะไรขึ้น