ดีบัก JSON ที่ถูกบีบอัด: จากบรรทัด 1 คอลัมน์ 23611 ถึงไบต์ที่พังจริง
สรุปสำคัญ: ไฟล์ JSON ขนาด 4 MB ที่มีบรรทัดเดียวรายงานว่าอยู่บรรทัด 1 คอลัมน์ 23611 ซึ่งไม่บอกอะไรเลย คู่มือนี้ใช้ตัวอย่างที่วัดในเครื่องอธิบายว่าทำไม Python, Node และ jq รายงานเลขสามค่าต่างกัน character offset พาคุณไปถึงไบต์ที่พังได้อย่างไร และมีกับดักสามข้อใดที่ทำข้อมูลเสียหายเงียบ ๆ หลังไวยากรณ์ถูกแก้แล้ว

สรุปสั้น: เลขบรรทัดและคอลัมน์มีความหมายเฉพาะในเอกสารหลายบรรทัด เมื่อ JSON ถูกบีบอัดเหลือบรรทัดเดียว error ทุกตัวจึงอยู่บนบรรทัด 1 — ตัวเลขที่คุณต้องการไม่ใช่บรรทัด แต่คือดัชนีของอักขระ
เอกสาร JSON ที่ถูกบีบอัดมีเพียงบรรทัดเดียว คู่บรรทัด/คอลัมน์ในข้อความ error จึงบีบตำแหน่งสี่ล้านไบต์ให้เหลือประโยคไร้ประโยชน์ประโยคเดียว ทางออกคือข้ามบรรทัด/คอลัมน์ไปทั้งหมดแล้วอ่าน character offset แทน —
(char 23610)เรียบ ๆ ท้ายข้อความคือตัวเลขที่ช่วยคุณได้จริง
ตัวเลขทุกตัวด้านล่างวัดในเครื่องนี้ ตัวอย่างคือเอกสารบรรทัดเดียวบรรจุเหตุการณ์เรียกเก็บเงิน 13,000 รายการ รวม 2,011,084 ตัวอักษร โดยลบเครื่องหมายจุลภาคหนึ่งตัวออกจากหลัง event_id ของเหตุการณ์ที่ #154 อย่างจงใจ สภาพแวดล้อมคือ Python 3.11.1, Node 22.13.1 และ jq 1.7
1. ข้อเท็จจริงก่อน: ตัวแยกวิเคราะห์สามตัว สามตัวเลข
ไฟล์ที่พังไฟล์เดียวกัน ป้อนให้เครื่องมือสามตัว:
Python 3.11.1 Expecting ',' delimiter: line 1 column 23611 (char 23610)
Node 22.13.1 Expected ',' or '}' after property value in JSON at position 23610 (line 1 column 23611)
jq 1.7 Expected separator between values at line 1, column 23619 exit code 5
เทียบกันข้าง ๆ:
| เครื่องมือ | ค่าที่รายงาน | หน่วย | นับจาก | ทำไมบรรทัด/คอลัมน์ถึงใช้ไม่ได้ |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | Character offset | ต้นบัฟเฟอร์ | ตัวเดียวที่เชื่อถือได้ |
Python lineno / colno |
1 / 23611 | บรรทัดและคอลัมน์ | ต้นบรรทัด | มีบรรทัดเดียว จึงไม่บอกอะไร |
Node position |
23610 | Character offset | ต้นบัฟเฟอร์ | เชื่อถือได้ แม้ข้อความจะซ่อนฟิลด์นี้ไว้ |
jq column |
23619 | Character offset | ต้น token ที่มีปัญหา | ต่างจาก Python อยู่ 9 |
ข้อสรุปตรง ๆ: char N ของ Python กับ position N ของ Node เป็นเลขเดียวกันและตรงกัน ส่วน jq ใช้ระบบของตัวเอง — อย่าเสียเวลาไปปรับให้ตรงกัน
เลขสามค่าต่างกันไม่ใช่เพราะใครคำนวณผิด แต่เพราะนับคนละอย่าง Python และ Node รายงานว่าตัวแยกวิเคราะห์หยุดที่ไหน; jq รายงานว่า token ที่แยกวิเคราะห์ไม่สำเร็จเริ่มที่ไหน ระหว่างสองจุดมีอักขระที่ถูกอ่านไปแล้วจำนวนหนึ่งคั่นอยู่ — ในตัวอย่างนี้คือ 9 ตัว
2. ทำไม "บรรทัด 1 คอลัมน์ N" ถึงไร้ความหมายกับไฟล์แบบนี้
เลขบรรทัดเป็นค่าที่ได้จากการคำนวณ ซึ่งสมมติว่า "ผ่านมาไม่นานตั้งแต่ขึ้นบรรทัดใหม่ครั้งล่าสุด" เอกสารที่ถูกบีบอัดมี \n เพียงตัวเดียว มักอยู่ท้ายไฟล์ ดังนั้น:
linenoเป็น 1 เสมอ;colnoเสื่อมลงเป็น "ผ่านต้นบรรทัดนั้นมาแล้วกี่ตัวอักษร";- และบรรทัดนั้นกว้างถึงสี่ล้านตัวอักษร
กระโดดไปคอลัมน์ 23611 ในเอดิเตอร์ก็ไม่เห็นอะไรที่มีประโยชน์ ยิ่งกว่านั้นเครื่องมืออย่าง less และ vim คลานช้ามากบนบรรทัดที่ยาวมาก การขยับเคอร์เซอร์ด้วยปุ่มลูกศรจะเผาเวลายี่สิบนาที
พลิกความคิด: offset คือค่าพื้นฐาน และบรรทัด/คอลัมน์คำนวณจากมัน ให้ใช้ค่าพื้นฐาน
3. วิธีที่หนึ่ง: ใน Python ใช้ e.pos และเพิกเฉย lineno
JSONDecodeError พาเลขสามตัวมา และมีเพียง pos ที่ควรอ่าน lineno และ colno เป็นค่าที่สืบเนื่องมาและยุบเหลือศูนย์บนเอกสารบรรทัดเดียว
# locate.py - Python 3.11.1
import json
import sys
path = sys.argv[1]
raw = open(path, encoding='utf-8').read()
try:
json.loads(raw)
except json.JSONDecodeError as error:
print(f'{type(error).__module__}.{type(error).__name__}: {error}')
start = max(0, error.pos - 60)
end = min(len(raw), error.pos + 60)
print(f'\nchar {start}..{end} of {len(raw)}:')
print(raw[start:end])
print(' ' * (error.pos - start) + '^')
sys.exit(1)
print('valid JSON')
รันกับไฟล์ที่พัง:
$ python3 locate.py bench/minified-json-error-position/broken.json
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 23611 (char 23610)
char 23550..23670 of 2011083:
ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
^
เครื่องหมาย ^ ชี้จุดที่เครื่องหมายจุลภาคหายไป ยี่สิบนาทีกลายเป็นคำสั่งเดียว
หน้าต่างควรกว้างแค่ไหน: ±60 ตัวอักษรแทบทุกครั้งครอบคลุมคู่คีย์/ค่าครบหนึ่งคู่ แคบเกินไปจะเสียบริบท กว้างเกินไปจะเต็มหน้าจอ เอกสารที่ซ้อนลึกอาจขยายเป็น ±120
4. วิธีที่สอง: ในคอนเทนเนอร์ไม่มี Python — ใช้ jq ร่วมกับ cut
คอนเทนเนอร์โปรดักชันมักไม่มี Python แต่เกือบทุกที่มี jq หรือติดตั้งได้ด้วย apt-get install jq ครั้งเดียว สิ่งที่ jq ให้คือ ความล้มเหลวที่ชัดเจน:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
exit code 5 หมายถึงอินพุตไม่ถูกต้อง ดังนั้นภายใต้ set -e สคริปต์จะถูกยกเลิกเหมือนกับ grep ที่คืนค่า 1 นั่นคือคุณสมบัติที่ดี
jq ไม่มีแฟล็กสำหรับพิมพ์ไบต์ที่มีปัญหา จึงต้องส่งตำแหน่งให้ cut:
# Note -c (characters), not -b (bytes)
$ cut -c23550-23670 bench/minified-json-error-position/broken.json
"ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
เมื่อเติมเครื่องหมายจุลภาคกลับแล้ว jq ก็ทำในสิ่งที่มันเก่งจริงได้:
$ jq -r '.events | length' bench/minified-json-error-position/payload.json
13000
$ jq -r '.events[154] | "\(.event_id) \(.plan) \(.amount_cents)"' bench/minified-json-error-position/payload.json
evt_000154 pro 74479
5. วิธีที่สาม: ไม่มี runtime หรือข้อมูลห้ามแตะดิสก์ — วางในเบราว์เซอร์
สองวิธีแรกต้องมีอะไรติดตั้งไว้และมีสิทธิ์เขียนไฟล์ ถ้าคุณอยู่บนแล็ปท็อปเปล่า ๆ หรือ payload เต็มไปด้วยอีเมลและ IP ของลูกค้า และ "ผมวางมันลงในเว็บไซต์แห่งหนึ่ง" คือประโยคที่คุณไม่อยากเห็นในรายงานเหตุการณ์ ทางเลือกที่สามจึงเข้ากว่า
วางสตริงทั้งหมดลงใน ตัวแยกวิเคราะห์ JSON ออนไลน์ แล้วมันจะวางเครื่องหมาย ^ ที่อักขระที่มีปัญหาและระบุบรรทัดและคอลัมน์; ข้อดีของเครื่องมือในเครื่องคือ 4 MB นั้นไม่เคยออกจากเครื่องและไม่ไปโผล่ใน log ของคนอื่น
ข้อควรระวังสองข้อ:
- เลือกเครื่องมือที่ระบุชัดว่าประมวลผลทั้งหมดในเบราว์เซอร์ ไม่ใช่ตัวที่ POST เนื้อหาของคุณไปยังแบ็กเอนด์;
- การวางรองรับไฟล์ใหญ่มากได้ แต่เมื่อเกินหลายสิบ MB แท็บเองก็กินหน่วยความจำ — ถึงจุดนั้นให้กลับไปใช้วิธีที่หนึ่ง
6. Character offset ไม่ใช่ byte offset: กับดักที่สองกับข้อมูล CJK
ข้อนี้กัดทั้งสองวิธีเมื่อ payload ผสมจีนกับอังกฤษ
JSONDecodeError.pos เป็น character offset (นับเป็น Unicode code point) เพราะเกิดจากการแยกวิเคราะห์ str cut -c ตัดตามตัวอักษรและตรงกัน แต่ head -c, tail -c และ dd ตัดตาม ไบต์ และภายใต้ UTF-8 การพลาดครั้งเดียวก็ทำให้ทุกอย่างเลื่อน
ตัวอย่างภาษาจีนทำให้เห็นชัด (5 ออเดอร์ 148 ตัวอักษร 222 ไบต์):
>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35] # characters 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35] # bytes 25-35
b'DD00000","'
offset เดียวกัน: มุมมองตัวอักษรตกที่ชื่อฟิลด์ มุมมองไบต์ตกที่ท้ายรหัสออเดอร์ อักขระ CJK หนึ่งตัวกิน 3 ไบต์ภายใต้ UTF-8 และความคลาดเคลื่อนสะสมตามตำแหน่ง
กฎ: หลังอ่าน e.pos ให้ตัดด้วย cut -c หรือ slice ของ Python ห้ามใช้ head -c / dd bs=1 skip=N ถ้ามีแต่เครื่องมือระดับไบต์ ให้แปลงก่อน: len(raw[:error.pos].encode('utf-8'))
7. ไวยากรณ์แก้แล้ว แต่ข้อมูลยังผิด: กับดักเงียบสามข้อ
นี่คือเหตุผลที่การดีบักหยุดแค่ "มันแยกวิเคราะห์ผ่าน" ไม่ได้ แต่ละข้อด้านล่างไม่เคย throw เลย หรือ throw หลังจากค่านั้นถูกใช้ไปนานแล้ว
กับดัก 1: คีย์ซ้ำ — ตัวสุดท้ายชนะ ไม่มีใครบ่น
RFC 8259 หมวด 4 ชัดเจน: ชื่อภายใน object SHOULD ไม่ซ้ำกัน และเมื่อซ้ำ พฤติกรรมคาดเดาไม่ได้ — การนำไปใช้งานส่วนใหญ่เก็บตัวสุดท้าย ตัวอื่น throw ทั้งสามเครื่องมือที่วัดนี้เก็บตัวสุดท้ายทั้งหมด:
Input {"order_id":"A-1001","total":4200,"order_id":"A-1002"}
Node {"order_id":"A-1002","total":4200}
Python {'order_id': 'A-1002', 'total': 4200}
jq {"order_id":"A-1002","total":4200}
ไม่มีคำเตือนสักคำ ถ้าฟิลด์ที่ถูกเขียนทับคือ amount_cents คุณจะรู้ก็ตอนกระทบยอดคลาดกันสามเซนต์
กับดัก 2: NaN และ Infinity — Python รับ Node ไม่รับ
RFC 8259 หมวด 6 ระบุตรง ๆ ว่าค่าคงที่ของตัวเลขไม่มี NaN และไม่มี Infinity json.loads ของ Python รับทั้งคู่โดยค่าเริ่มต้น เพราะ float ของ Python เองมีค่าเหล่านี้:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
ส่งเอาต์พุตนั้นให้ Node จะได้:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
พูดอีกอย่าง: ข้อมูลเสียผ่าน Python ได้สบาย แล้วไประเบิดที่ผู้บริโภคปลายน้ำ
กับดัก 3: จำนวนเต็มที่เกิน 2^53 สูญเสียความแม่นยำอย่างเงียบ ๆ
เอกสารเดียว สองค่าที่ต่างกัน:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
เหตุผลในหนึ่งบรรทัด: Number.MAX_SAFE_INTEGER ของ JavaScript คือ 9007199254740991 (2^53-1) MDN ระบุชัดว่า reviver ทำงาน หลังจาก ค่าถูกแยกวิเคราะห์เป็น number ของ JavaScript แล้ว ความแม่นยำจึงหายไปก่อนถึง reviver — กู้คืนฝั่งดีซีเรียลไลซ์ไม่ได้ ทางแก้เดียวคือผู้ผลิตต้องทำให้ ID ขนาดใหญ่เป็นสตริง
กับดักทั้งสามเมื่อเทียบกัน:
| กับดัก | มีอะไร throw ไหม | ใครรู้ก่อน | วิธีสกัดกั้น |
|---|---|---|---|
| คีย์ซ้ำ | ไม่ | การกระทบยอดปลายน้ำ | ตรวจชัดเจนด้วย object_pairs_hook |
| NaN / Infinity | Node throw, Python เงียบ | ผู้บริโภคปลายน้ำ | ปฏิเสธที่ขอบด้วย parse_constant |
| จำนวนเต็มที่เกิน 2^53 | ไม่ | โค้ดที่ตรวจความเท่ากัน | ส่งเป็นสตริง |
จับสองข้อแรกในรอบเดียว
# check.py - Python 3.11.1
import json
import sys
from collections import Counter
def no_dupes(pairs):
counts = Counter(key for key, _ in pairs)
dupes = sorted(key for key, n in counts.items() if n > 1)
if dupes:
raise ValueError(f'duplicate key(s): {dupes}')
return dict(pairs)
def reject_constant(name):
raise ValueError(f'non-standard JSON constant: {name}')
raw = open(sys.argv[1], encoding='utf-8').read()
try:
data = json.loads(raw, object_pairs_hook=no_dupes, parse_constant=reject_constant)
except json.JSONDecodeError as error:
start, end = max(0, error.pos - 60), min(len(raw), error.pos + 60)
print(f'{error}\n\nchar {start}..{end} of {len(raw)}:')
print(raw[start:end])
print(' ' * (error.pos - start) + '^')
sys.exit(1)
except ValueError as error:
print(f'rejected: {error}')
sys.exit(1)
print(f'valid JSON, {len(raw)} chars')
ต้นทุนที่ต้องบอกตามตรง: บนตัวอย่าง 2,011,084 ตัวอักษรเดียวกัน เวอร์ชันเข้มงวดนี้ใช้เวลา 35.2-36.2 ms เทียบกับ 9.4-9.9 ms ของเวอร์ชันผ่อนปรน ช้ากว่าประมาณ 3.7 เท่า (3.6x ถึง 3.8x จากการทดสอบห้ารอบ รอบละห้าครั้ง ใช้ค่ามัธยฐานแต่ละรอบ) — เพราะ object_pairs_hook รัน Counter ให้ทุก object ให้เก็บมันออกจากเส้นทางปกติของ CI และเปิดใช้เฉพาะเมื่อข้อมูลที่ส่งมาดูไม่น่าไว้วางใจ
8. รายการตรวจสอบ
หลังแก้ไฟล์แล้วก็อยากจัดรูปแบบให้อ่านง่าย แตตัวอย่างเดิมวัดได้ 2,011,084 ตัวอักษรเมื่อบีบอัด (separators=(',',':')) และ 2,869,100 ตัวอักษรที่ indent=2 — มากกว่า 43% น้ำหนักนั้นไหลเข้า Git history แคชของ CI และการรับส่งผ่านเครือข่าย ดังนั้นให้เก็บเวอร์ชันจัดรูปแบบไว้ในเครื่องและบีบกลับเหลือบรรทัดเดียวก่อนส่งมอบ
- [ ] มองหา
(char N)/position Nก่อน; เพิกเฉยline 1 column N; - [ ] Python ใช้
e.pos, jq ใช้คู่กับcut -c— character offset เดียวกัน จึงห้ามเทียบcolumnของ jq กับค่าเหล่านี้; - [ ] เอกสาร CJK ให้ตัดตามตัวอักษร (
cut -c, slice ของ Python) ห้ามhead -c/dd; - [ ] สคริปต์ในคอนเทนเนอร์ควรถือว่า exit code 5 ของ jq คือ "อินพุตไม่ถูกต้อง";
- [ ] ตรวจข้อมูลที่น่าสงสัยซ้ำด้วย
object_pairs_hook+parse_constantและยอมรับต้นทุนราว 3.7 เท่า; - [ ] ส่ง ID ใดที่เกิน 2^53 เป็นสตริง; อย่าหวังให้ reviver ช่วยกู้;
- [ ] เอาต์พุตที่จัดรูปแบบแล้ว (วัดได้ +43%) ให้เก็บไว้ในเครื่อง: ใช้ /minify บีบกลับเหลือบรรทัดเดียวก่อนส่งมอบหรือคอมมิต; เมื่อเกินหลาย MB ให้เปลี่ยนไปใช้สตรีมมิ่ง ซึ่งกล่าวถึงใน การแยกวิเคราะห์ การเรนเดอร์ และสตรีมมิ่ง JSON ขนาดใหญ่
คำถามที่พบบ่อย
ทำไมเครื่องมือทั้งสามรายงานตำแหน่งต่างกัน
เพราะนับคนละอย่าง char N ของ Python กับ position N ของ Node ต่างหมายถึง "ที่ที่ตัวแยกวิเคราะห์หยุด" — บนตัวอย่างนี้ทั้งคู่เป็น 23610 จึงตรงกัน jq รายงานว่า token ที่มีปัญหาเริ่มที่ไหน ในที่นี้คือ 23619 ไกลออกไปอีกเก้า ไม่มีใครคำนวณผิดและไม่ต้องแก้ค่าใด ๆ ให้เทียบตัวเลขภายในเครื่องมือเดียวกันเท่านั้น
ควรใช้ cut -c หรือ head -c
ใช้ cut -c JSONDecodeError.pos เป็น offset ที่นับเป็นอักขระ Unicode และ cut -c ตัดตามตัวอักษร สองอย่างจึงตรงกัน head -c, tail -c และ dd ตัดตามไบต์ ซึ่งเลื่อนบนเอกสาร UTF-8 ที่มีข้อความ CJK และอาจตัดอักขระขาดครึ่งได้ บนตัวอย่างภาษาจีน offset เดียวกันให้ 城市":"杭州"}, จากมุมมองตัวอักษร และ DD00000"," จากมุมมองไบต์
การจัดการคีย์ซ้ำไร้การป้องกันจริง ๆ ทุกที่หรือไม่
จริง RFC 8259 หมวด 4 บอกเพียงว่าชื่อ SHOULD ไม่ซ้ำกัน และระบุว่าพฤติกรรมคาดเดาไม่ได้เมื่อซ้ำ Python, Node และ jq ที่วัดทั้งหมดใช้หลักตัวสุดท้ายชนะโดยไม่แจ้งคำเตือน การจับได้ต้องใช้ object_pairs_hook ของคุณเอง ด้วยต้นทุนเวลาการแยกวิเคราะห์ราว 3.7 เท่า
ทำไม Python อ่าน NaN ได้ปกติ แต่ Node throw
RFC 8259 หมวด 6 ไม่อนุญาต NaN และ Infinity เป็นค่าคงที่ของตัวเลข json.loads ของ Python รับโดยค่าเริ่มต้นเพราะ float ของ Python มีค่าเหล่านี้ ข้อมูลเสียจึงผ่าน Python และถูกเขียนกลับออกมาตรง ๆ JSON.parse ของ Node ทำตามมาตรฐานอย่างเคร่งครัดและ throw SyntaxError กับเอาต์พุตนั้น การเพิ่ม parse_constant ที่จุดเข้าใช้งานจะปฏิเสธทันที
เลข order 20 หลักสูญเสียความแม่นยำผ่าน JSON เสมอหรือไม่
เฉพาะเมื่อผู้บริโภคแยกวิเคราะห์เป็น IEEE 754 double int ของ Python มีความแม่นยำไม่จำกัดและเก็บ 12345678901234567890 ไว้ได้ ขณะที่ Number.MAX_SAFE_INTEGER ของ JavaScript คือ 9007199254740991 ฟิลด์เดียวกันจึงกลายเป็น 12345678901234567000 MDN ระบุชัดว่า reviver ทำงานหลังการแยกวิเคราะห์ จึงกู้คืนอะไรฝั่งปลายทางไม่ได้ ทางแก้ที่เชื่อถือได้เดียวคือผู้ผลิตต้องส่งออกเป็นสตริง