โซนที่ข้ามสายไม่ได้
ออเดอร์ A-1042 ถูกสั่งที่ร้านใน Bangkok เวลา 09:00 ของวันที่ 2026-10-02 ตามนาฬิกาของร้าน
ซึ่งอยู่ในโซน Asia/Bangkok
เวลานี้ต้องเดินทางต่อ service ที่ตัดรอบต้องรู้มัน ใบเสร็จต้องพิมพ์มัน และระบบจัดรถต้องคำนวณจากมัน ทุกทางผ่าน JSON
คำถามของบทนี้คือ เวลาของออเดอร์ข้าม JSON ไปแล้วเหลืออะไร ในสตริงบนสายทุกตัวที่ probe ตรวจ สิ่งที่เหลือคือ offset หรือ UTC และไม่มีชื่อโซนติดไปด้วยเลย
orderctl คือเครื่องมือประมวลผลออเดอร์ที่คอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ
ระหว่างนี้ทุกค่าในบทนี้รันเองได้ด้วย npm run verify:time-semantics
ซึ่งเรียก python3 scripts/time-semantics/probe.py · ไม่ต้องมี Docker แต่การรันครั้งแรก
ต้องต่อเน็ตเพื่อดึง chrono กับ chrono-tz จาก crates.io
JSON ไม่มีชนิดของเวลา
หัวข้อที่มีชื่อว่า “JSON ไม่มีชนิดของเวลา”JSON ไม่มีชนิดของเวลา ในทรงที่ probe วัด ทุกภาษาที่เขียนได้จึงเขียนเวลาเป็นสตริง และ RFC 3339 ที่สตริงบนสายเหล่านั้นใช้ไม่มีช่องให้ชื่อโซน
JSON มีชนิดของค่าอยู่ไม่กี่ชนิด และไม่มีชนิดไหนเป็นเวลา ภาษาที่ส่งเวลาข้าม JSON จึงต้องตัดสินใจเองว่า จะเขียนเวลานั้นเป็นค่าแบบไหน ในทรงที่ probe วัด ค่านั้นเป็นสตริงทุกตัว
สตริงบนสายที่ probe วัดในบทนี้อ่านได้ตาม grammar ของ RFC 3339RFC 3339รูปแบบสตริงของเวลาที่ใช้บนสายเป็นส่วนใหญ่ และเป็น profile ที่แคบกว่าของ ISO 8601 · ช่อง offset ของมันมีแค่ชั่วโมงกับนาที offset ที่มีเศษวินาทีจึงเขียนลงไปตรงๆ ไม่ได้ (รูปแบบสตริงของเวลาบนอินเทอร์เน็ต) ในรูปแบบนั้น ส่วนที่บอกว่าเวลานี้เป็นของที่ไหนมีแค่ช่องเดียว คือ offset และ grammar ของ RFC 3339 เขียนช่องนี้ไว้ว่า
time-numoffset = ("+" / "-") time-hour ":" time-minutetime-offset = "Z" / time-numoffsetUTC offsetUTC offsetระยะห่างจาก UTC ณ จุดหนึ่งบนเส้นเวลา · offset ถูกต้องเฉพาะจุดนั้นจุดเดียว มันไม่รู้ว่าวันถัดไปโซนเดียวกันจะใช้ offset อะไร การบวกเวลาบน offset จึงไม่เดินตาม DST (ระยะห่างจาก UTC) อย่าง +07:00 ถูกต้องเฉพาะจุดเดียวบนเส้นเวลา
มันไม่รู้ว่าวันพรุ่งนี้หรือปี 1900 โซนเดียวกันใช้ offset อะไร คนที่รู้คือชื่อโซน และ grammar ข้างบนไม่มีที่ให้ชื่อโซนเลย
RFC 3339 เลือก offset ที่เป็นตัวเลข หัวข้อ 4.2 เขียนเหตุผลไว้ว่า “Attempts to label local offsets with
alphabetic strings have resulted in poor interoperability in the past” แล้วสรุปว่า RFC2822
“has made numeric offsets mandatory” ป้ายในประโยคนี้คือป้ายของ offset ไม่ใช่ชื่อโซนอย่าง Asia/Bangkok
ชื่อโซนที่ต้องเดินทางไปกับเวลาจึงเป็นงานของผู้ส่ง ไม่ใช่ของรูปแบบสตริง บทนี้เรียกมันว่า zone on the wirezone on the wireชื่อโซนที่ต้องเดินทางไปกับเวลาเมื่อส่งข้ามระบบ · JSON ไม่มีชนิดของเวลา ถ้าไม่ส่งชื่อโซนไปด้วยอย่างตั้งใจ ปลายทางได้แค่ offset หรือ UTC และคำนวณต่อตามกฎของโซนเดิมไม่ได้ (ชื่อโซนที่ส่งไปกับเวลา) ถ้าไม่มีใครส่งมันอย่างตั้งใจ ปลายทางก็ไม่ได้มัน
flowchart LR
A["เวลาของออเดอร์ · 09:00 ที่ Asia/Bangkok"] --> B{"เขียนเป็นสตริงก่อนขึ้นสาย"}
B -->|"เก็บ offset"| C["2026-10-02T09:00:00+07:00"]
B -->|"แปลงไป UTC"| D["2026-10-02T02:00:00.000Z · Node ที่ TZ=Asia/Bangkok"]
B -->|"ไม่ยอมเขียน"| E["TypeError"]
C --> F["ปลายทางได้ +07:00 แต่ไม่ได้ชื่อโซน"]
D --> G["ปลายทางได้ instant แต่ไม่ได้ทั้ง offset และชื่อโซน"]
คำบรรยายภาพ: ทั้งสองกิ่งที่ส่งได้ ส่ง instant ที่ถูกต้องไปถึงปลายทาง สิ่งที่หายระหว่างทางไม่ใช่จุดบนเส้นเวลา แต่คือชื่อโซนที่บอกว่าจุดถัดไปของร้านนี้ใช้ offset อะไร
สี่ภาษา กับเวลาของออเดอร์ค่าเดียว
หัวข้อที่มีชื่อว่า “สี่ภาษา กับเวลาของออเดอร์ค่าเดียว”แต่ละภาษาได้รับ
09:00ของ2026-10-02ที่Asia/Bangkokเหมือนกัน แล้วเขียนลงสายคนละแบบ
ทุกการเรียกข้างล่างตัดมาจาก runner ของ probe ใน scripts/time-semantics/runners/ และค่าในคอมเมนต์
คือค่าที่ probe วัดได้ ทรงนี้ probe วัดสี่ภาษา คือ Go, Node, Python และ Java
Rust ไม่อยู่ในทรงนี้ เพราะ crate ของ probe ตั้ง chrono กับ chrono-tz ไว้ที่ features = ["std"]
และไม่มี crate สำหรับ JSON ทรงนี้จึงไม่มีค่าของ Rust ให้พิมพ์
Go เก็บ offset ไว้
หัวข้อที่มีชื่อว่า “Go เก็บ offset ไว้”bkk, err := time.LoadLocation("Asia/Bangkok")// ...b, err := json.Marshal(time.Date(2026, 10, 2, 9, 0, 0, 0, bkk))string(b) // "2026-10-02T09:00:00+07:00"เอกสารของ Time.MarshalJSON เขียนว่า “The time is a quoted string in the RFC 3339 format with
sub-second precision.” สตริงที่ได้จึงมี 09:00 และ +07:00 ครบ ปลายทางรู้ว่าร้านเห็นนาฬิกาบอกเวลาเท่าไร
แต่คำว่า Asia/Bangkok ไม่อยู่ในสตริงนี้ เพราะ grammar ของ RFC 3339 ไม่มีที่ให้มัน location ที่ bkk ถือไว้
หายไปตอน json.Marshal เหลือแค่ offset ของจุดเดียวนี้
Node แปลงไป UTC และได้คนละจุดตาม TZ
หัวข้อที่มีชื่อว่า “Node แปลงไป UTC และได้คนละจุดตาม TZ”// เดือนของ Date เริ่มที่ 0 · เลข 9 คือเดือนตุลาคมJSON.stringify({ t: new Date(2026, 9, 2, 9, 0) });Date สร้างเวลาจากวันที่และชั่วโมงได้แค่ในโซนของ process ซึ่งมาจาก TZ probe จึงรัน file เดียวกันสองครั้ง
ต่างกันที่ TZ อย่างเดียว
TZ | JSON.stringify(...) |
|---|---|
America/New_York | {"t":"2026-10-02T13:00:00.000Z"} |
Asia/Bangkok | {"t":"2026-10-02T02:00:00.000Z"} |
ทั้งสองแถวลงท้ายด้วย Z ไม่มีแถวไหนเก็บ offset ไว้ MDN อธิบายค่าที่ toJSON() คืนว่า “A string representing
the given date in the date time string format according to universal time” และเขียนว่า
“The toJSON() method is automatically called by JSON.stringify() when a Date object is stringified.”
แถวที่ TZ=Asia/Bangkok คือเวลาของออเดอร์ที่ถูกต้อง แต่ตัวเลข 09 ไม่อยู่ในสตริงนั้นเลย
ปลายทางที่อยากพิมพ์ 09:00 บนใบเสร็จต้องรู้ชื่อโซนจากที่อื่น
แถวที่ TZ=America/New_York เป็นคนละจุดตั้งแต่ก่อนขึ้นสาย มันคือ 09:00 ของ New York ไม่ใช่ของ Bangkok
ปัญหานี้เป็นเรื่องเดียวกับที่ บทที่ 1 วัดไว้
คือ Date สร้างเวลาของโซนอื่นนอกจากโซนของ process ไม่ได้
Python ไม่ยอมเขียน
หัวข้อที่มีชื่อว่า “Python ไม่ยอมเขียน”import jsonfrom datetime import datetimefrom zoneinfo import ZoneInfo
bkk = ZoneInfo("Asia/Bangkok")json.dumps({"t": datetime(2026, 10, 2, 9, 0, tzinfo=bkk)})บรรทัดสุดท้ายโยน exception และ runtime พิมพ์ว่า
TypeError: Object of type datetime is not JSON serializableในสี่ภาษาของทรงนี้ Python เป็นภาษาเดียวที่แดง เอกสารของ json เขียนกฎนี้ไว้ที่ช่อง default ว่า
“If None (the default), TypeError is raised.”
ความดังนี้มีราคา Python ไม่ได้เลือกรูปแบบให้ ผู้เขียนต้องเลือกเองว่าจะส่งอะไร และ probe
ไม่ได้วัดทางเลือกที่ผู้เขียนเลือก บทนี้จึงพูดได้แค่ว่า json.dumps ไม่ยอมเดาแทนคุณ
Java มีสองสตริงให้เลือก
หัวข้อที่มีชื่อว่า “Java มีสองสตริงให้เลือก”Java ไม่มี JSON ใน library มาตรฐาน probe จึงวัดสองสตริงที่ ZonedDateTime ตัวเดียวกันเขียนออกมาได้
ZoneId bkk = ZoneId.of("Asia/Bangkok");ZonedDateTime e = ZonedDateTime.of(2026, 10, 2, 9, 0, 0, 0, bkk);e.toString(); // 2026-10-02T09:00+07:00[Asia/Bangkok]DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(e); // 2026-10-02T09:00:00+07:00toString() เป็นสตริงเดียวในบทนี้ที่มีชื่อโซน Javadoc ของมันเขียนว่า “If the ZoneId is not the same as
the offset, then the ID is output.” ส่วน ISO_OFFSET_DATE_TIME ได้สตริงหน้าตาเดียวกับของ Go
และไม่มีชื่อโซน
สตริงของ toString() ไม่มีวินาที ขณะที่ grammar ของ RFC 3339 บังคับช่องวินาทีไว้ใน
partial-time = time-hour ":" time-minute ":" time-second [time-secfrac] มันจึงไม่ใช่สตริงตามรูปแบบที่ RFC 3339 กำหนด
ตารางเดียว สี่ภาษา บนเครื่องที่วัด
หัวข้อที่มีชื่อว่า “ตารางเดียว สี่ภาษา บนเครื่องที่วัด”รวมสตริงที่แต่ละภาษาเขียนลงสายไว้ในตารางเดียว พร้อมรุ่นของ tzdata ที่แต่ละภาษาอ่านจริง
| ภาษา | การเรียก | ค่าที่วัดได้ | tzdata ที่ใช้ |
|---|---|---|---|
Go go1.27.0 | json.Marshal(...) | "2026-10-02T09:00:00+07:00" | 2026c · /usr/share/zoneinfo ของระบบ |
Node v24.19.0 ที่ TZ=Asia/Bangkok | JSON.stringify(...) | {"t":"2026-10-02T02:00:00.000Z"} | 2026b · ICU ที่ฝังใน node |
Node v24.19.0 ที่ TZ=America/New_York | JSON.stringify(...) | {"t":"2026-10-02T13:00:00.000Z"} | 2026b · ICU ที่ฝังใน node |
Python 3.14.6 | json.dumps(...) | TypeError: Object of type datetime is not JSON serializable | 2026c · /usr/share/zoneinfo ของระบบ |
Java openjdk 25.0.4.1 | ISO_OFFSET_DATE_TIME.format(e) | 2026-10-02T09:00:00+07:00 | 2026b · tzdb.dat ของ JDK |
Java openjdk 25.0.4.1 | e.toString() | 2026-10-02T09:00+07:00[Asia/Bangkok] | 2026b · tzdb.dat ของ JDK |
probe ตรวจสี่สตริงในตารางว่าเป็นสตริงบนสาย คือของ Go ของ Node ทั้งสองแถว และของ ISO_OFFSET_DATE_TIME
ทั้งสี่ไม่มีคำว่า Asia/Bangkok อยู่เลย
แถวที่มีชื่อโซนมีแถวเดียว คือ toString() ของ Java ซึ่งผู้เขียนต้องเลือกใช้มันเอง และมันไม่ใช่สตริงตาม RFC 3339
สี่ภาษาบนเครื่องเดียวกันอ่าน tzdata สองรุ่นในตารางนี้ คือ 2026b กับ 2026c probe ไม่ได้วัดว่าสองรุ่นนี้ต่างกันตรงไหน
แต่ตารางที่รวมหลายภาษายังต้องพิมพ์รุ่นของทุกภาษา เพราะ tzdata คือข้อมูลที่ปลายทางใช้ตีความชื่อโซน
สิ่งที่ปลายทางได้คืนมา
หัวข้อที่มีชื่อว่า “สิ่งที่ปลายทางได้คืนมา”ปลายทางได้ instant ที่ถูกต้อง แต่ไม่ได้กฎของโซน และทุกการคำนวณที่ต้องใช้กฎนั้นต้องหามันจากที่อื่น
สตริงที่มี +07:00 บอกจุดบนเส้นเวลาได้ครบ RFC 3339 หัวข้อ 4.2 นิยาม offset ว่าเป็น “local time minus UTC”
ถ้าหัก offset ออกตามนั้น ค่า 09:00+07:00 ของ Go คือจุดเดียวกับ 02:00 ใน UTC ของ Node ที่ TZ=Asia/Bangkok
ความเสียหายจึงไม่ได้อยู่ที่จุดที่ส่งไป แต่อยู่ที่การคำนวณครั้งถัดไปของปลายทาง
- บวกเวลาตามปฏิทิน · บทที่ 3 วัดแล้วว่า การบวกหนึ่งวันบน offset ที่มากับสาย กับการบวกบนโซน ได้คนละจุดเมื่อข้ามคืนที่เปลี่ยน DST
- ย้อนไปวันที่ในอดีต · บทที่ 7
วัดว่า Bangkok ไม่ได้ใช้
+07:00มาตั้งแต่แรก offset ของวันนี้จึงตอบแทนวันในอดีตไม่ได้ - แสดงเวลาให้คนในร้านเห็น · สตริง
Zของ Node ที่TZ=Asia/Bangkokไม่มีตัวเลขบนนาฬิกาของร้านอยู่ในนั้น ปลายทางต้องรู้เองว่าร้านอยู่โซนไหน
ทั้งสามข้อต้องใช้ชื่อโซน และชื่อโซนคือสิ่งเดียวที่ไม่ได้ข้ามสายมา
ทางออกคือส่ง instant กับชื่อโซนเป็นสองช่องแยกกันใน JSON เช่นช่องหนึ่งเป็น UTC อีกช่องเป็น Asia/Bangkok
บทที่ 8 วัดท่อแบบนั้นทั้งเส้น
ราคามีสามข้อ
- สองช่องต้องไปด้วยกันทุกทาง service ไหนที่ส่งต่อแค่ช่องเดียว ก็ทำให้ชื่อโซนหายเหมือนเดิม
- ปลายทางต้องมี tzdata ของตัวเอง เพื่อแปลงชื่อโซนกลับเป็นกฎ และตารางข้างบนแสดงแล้วว่าสองภาษาบนเครื่องเดียวกันอ่านคนละรุ่นได้
- JSON ไม่ตรวจว่าสตริงในช่องนั้นเป็นชื่อโซนที่มีอยู่จริง ปลายทางต้องตรวจเอง
RFC 9557 เพิ่มรูปแบบที่ต่อชื่อโซนในวงเล็บเหลี่ยมท้ายสตริงของ RFC 3339 แต่ probe ของคอร์สนี้ ไม่ได้วัดว่าภาษาไหนเขียนหรืออ่านรูปแบบนั้นได้
บทที่ 6 ของ integer-semantics วัด body JSON ใบเดียวที่เดินไปหาปลายทางหลายแห่ง คอร์สนั้นเป็นเจ้าของตัวเลขบน JSON
คอร์สนี้เป็นเจ้าของเวลาบน JSON บทนี้จึงไม่วัด orderId หรือยอดเงิน และคอร์สนั้นไม่วัดสตริงของเวลา
จุดที่สาม · offset ที่มากับสาย มองจากฝั่งที่ส่ง
หัวข้อที่มีชื่อว่า “จุดที่สาม · offset ที่มากับสาย มองจากฝั่งที่ส่ง”บทที่ 3 วัดฝั่งที่รับ offset มาจากสาย บทนี้วัดฝั่งที่เขียน offset ลงไป และทั้งสองฝั่งนับเป็นจุดที่สามจุดเดียวกัน
ธีสิสของคอร์สนี้คือ wall-clock time ค่าหนึ่งไม่ได้ระบุจุดเดียวบนเส้นเวลา มันอาจไม่มีจุดเลย มีสองจุด หรือมีจุดเดียวที่แต่ละภาษาเลือกต่างกัน และในทรงที่ probe วัด มีภาษาเดียวจากห้าภาษาที่บอกคุณ
จุดที่สามคือ offset ที่มากับสาย ฝั่งที่ส่งเป็นต้นเหตุของมัน ในทรงที่ probe วัด สี่สตริงบนสายไม่มีชื่อโซนสักตัว และภาษาเดียวที่แดงคือ Python ซึ่งแดงเพราะไม่ยอมเขียนอะไรเลย ไม่ใช่เพราะมันเห็นว่าชื่อโซนกำลังจะหาย
บทที่ 7 ใช้ชื่อโซนที่บทนี้บอกว่าหายไป แล้วถามว่า Asia/Bangkok เคยมี offset อะไรบ้าง
และภาษาไหนพิมพ์มันผิด
-
RFC 3339 — Date and Time on the Internet: Timestamps (เข้าถึง 2026-10-03) — ที่มาของ grammar
time-numoffsetและpartial-timeและของเหตุผลในหัวข้อ 4.2 ที่เลือก offset แบบตัวเลข “Attempts to label local offsets with alphabetic strings have resulted in poor interoperability in the past” -
RFC 9557 — Date and Time on the Internet: Timestamps with Additional Information (เข้าถึง 2026-10-03) — ที่มาของรูปแบบที่ต่อชื่อโซนท้ายสตริงของ RFC 3339 · บทคัดย่อเขียนว่า “This document defines an extension to the timestamp format defined in RFC 3339 for representing additional information, including a time zone.”
-
Go — package time, func (Time) MarshalJSON (เข้าถึง 2026-10-03) — ที่มาของประโยค “The time is a quoted string in the RFC 3339 format with sub-second precision.”
-
MDN — Date.prototype.toJSON() (เข้าถึง 2026-10-03) — ที่มาของข้อที่
toJSON()คืนสตริงตาม universal time และของข้อที่ “The toJSON() method is automatically called by JSON.stringify() when a Date object is stringified.” -
Python — json (เข้าถึง 2026-10-03) — ที่มาของกฎที่ช่อง
defaultเป็นNoneแล้ว “TypeError is raised.” -
Java SE 25 — ZonedDateTime (เข้าถึง 2026-10-03) — ที่มาของกฎของ
toString()“If the ZoneId is not the same as the offset, then the ID is output.” -
Java SE 25 — DateTimeFormatter (เข้าถึง 2026-10-03) — ที่มาของ
ISO_OFFSET_DATE_TIMEซึ่งเขียนวันที่ เวลา และ offset โดยไม่มีชื่อโซน -
ค่าทุกค่าในบทนี้มาจาก
scripts/time-semantics/measured.jsonซึ่งscripts/time-semantics/probe.pyเขียนเมื่อ 2026-10-02 บน darwin/arm64 · probe วัดทั้งชุดสามรอบต่อการเรียกหนึ่งครั้ง และ Node รันด้วยTZที่ probe ตั้งเองทุกครั้ง
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3`json.Marshal(time.Date(2026, 10, 2, 9, 0, 0, 0, bkk))` ของ Go ที่ `bkk` คือ `Asia/Bangkok` เขียนอะไรลงสาย