Debugging JSON yang Diminifikasi: Dari baris 1 kolom 23611 hingga Byte yang Rusak Tepat
Intinya: File JSON satu baris berukuran 4 MB melaporkan baris 1 kolom 23611, yang tidak memberi tahu apa pun. Menggunakan sampel yang diukur secara lokal, panduan ini menjelaskan mengapa Python, Node, dan jq melaporkan tiga angka berbeda, bagaimana offset karakter membawa Anda langsung ke byte yang rusak, dan tiga jebakan mana yang diam-diam merusak data setelah sintaks diperbaiki.

Intinya: nomor baris dan kolom hanya bermakna pada dokumen multi-baris. Setelah JSON diminifikasi menjadi satu baris, setiap error berada di baris 1 — angka yang Anda butuhkan bukan barisnya, melainkan indeks karakter.
Dokumen JSON yang diminifikasi hanya memiliki satu baris, sehingga pasangan baris/kolom dalam pesan error meruntuhkan posisi empat juta byte menjadi satu frasa yang tak berguna. Jalan keluarnya adalah melewati baris/kolom sepenuhnya dan membaca offset karakter —
(char 23610)sederhana di akhir pesan justru angka yang benar-benar menyelamatkan Anda.
Setiap angka di bawah dihasilkan secara lokal. Sampel adalah dokumen satu baris berisi 13.000 peristiwa penagihan, total 2.011.084 karakter, dengan satu koma sengaja dihapus setelah event_id dari event #154. Lingkungannya Python 3.11.1, Node 22.13.1, dan jq 1.7.
1. Pertama, faktanya: tiga parser, tiga angka
File rusak yang sama, diberikan ke tiga alat:
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
Bersebelahan:
| Alat | Nilai yang dilaporkan | Satuan | Dihitung dari | Mengapa baris/kolom gagal |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | Offset karakter | Awal buffer | Satu-satunya yang bisa dipercaya |
Python lineno / colno |
1 / 23611 | Baris dan kolom | Awal baris | Hanya satu baris, jadi tidak memberi tahu apa pun |
Node position |
23610 | Offset karakter | Awal buffer | Andal, meskipun pesannya menyembunyikan field ini |
jq column |
23619 | Offset karakter | Awal token yang bermasalah | Berbeda 9 dari Python |
Kesimpulannya lugas: char N milik Python dan position N milik Node adalah angka yang sama dan memang sejajar. jq memakai skema sendiri — jangan buang waktu menyejajarkannya.
Ketiga angka berbeda bukan karena seseorang salah menghitung, tetapi karena mereka menghitung hal yang berbeda. Python dan Node melaporkan di mana parser berhenti; jq melaporkan di mana token yang gagal di-parse dimulai. Beberapa karakter yang sudah dikonsumsi berada di antara keduanya — 9 pada sampel ini.
2. Mengapa baris 1 kolom N hanya derau pada file seperti ini
Nomor baris adalah besaran turunan yang mengasumsikan "sudah lama sejak baris baru terakhir". Dokumen yang diminifikasi punya satu \n, biasanya di akhir, sehingga:
linenoselalu 1;colnomerosot menjadi "berapa karakter setelah awal baris tunggal itu";- dan baris itu lebarnya empat juta karakter.
Lompat ke kolom 23611 di editor dan Anda tidak melihat hal berguna. Lebih buruk lagi, alat seperti less dan vim merangkak pada baris yang sangat panjang; menggeser kursor dengan tombol panah membakar dua puluh menit.
Balikkan perspektifnya: offset adalah besaran asli, dan baris/kolom dihitung darinya. Gunakan besaran asli.
3. Pendekatan satu: di Python, gunakan e.pos dan abaikan lineno
JSONDecodeError membawa tiga angka, dan hanya pos yang layak dibaca. lineno dan colno adalah nilai turunan yang runtuh menjadi tidak berarti pada dokumen satu baris.
# 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')
Jalankan terhadap file rusak:
$ 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",
^
Tanda kursor menandai tempat koma yang hilang. Dua puluh menit berubah menjadi satu perintah.
Seberapa lebar jendelanya: ±60 karakter hampir selalu mencakup satu pasangan kunci/nilai lengkap. Terlalu sempit dan Anda kehilangan konteks; terlalu lebar dan output memenuhi layar. Dokumen yang sangat bersarang bisa memakai ±120.
4. Pendekatan dua: tidak ada Python di container — gunakan jq plus cut
Container produksi sering tidak membawa Python, tetapi hampir selalu punya jq, atau bisa mendapatkannya dengan satu apt-get install jq. Yang diberikan jq adalah kegagalan yang nyaring:
$ 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 berarti input tidak valid, sehingga di bawah set -e skrip dibatalkan dengan cara yang sama seperti grep yang mengembalikan 1. Itu sebuah fitur.
jq tidak punya flag untuk mencetak byte yang bermasalah, jadi serahkan posisinya ke 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",
Setelah koma kembali, jq dapat melakukan hal yang memang menjadi keahliannya:
$ 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. Pendekatan tiga: tanpa runtime, atau data yang tidak boleh menyentuh disk — tempelkan ke browser
Dua pendekatan pertama membutuhkan sesuatu yang terpasang dan izin menulis file. Jika Anda berada di laptop kosong, atau payload penuh email dan alamat IP pelanggan dan "saya menempelkannya ke situs web" adalah kalimat yang tidak ingin Anda lihat dalam laporan insiden, opsi ketiga cocok.
Tempelkan seluruh string ke parser JSON online dan alat ini menempatkan kursor pada karakter yang bermasalah serta memberi label baris dan kolom; keunggulan alat lokal adalah 4 MB itu tidak pernah meninggalkan mesin dan tidak pernah mendarat di log orang lain.
Dua catatan:
- Pilih alat yang secara eksplisit menyatakan memproses semuanya di browser, jangan pernah yang meng-POST konten Anda ke backend;
- Menempel mentoleransi file sangat besar, tetapi di atas beberapa puluh megabyte tab itu sendiri memakan memori — pada titik itu kembali ke pendekatan satu.
6. Offset karakter bukan offset byte: jebakan kedua pada data CJK
Yang ini menggigit kedua pendekatan pada payload campuran Tionghoa/Inggris.
JSONDecodeError.pos adalah offset karakter (hitungan code point Unicode), karena dihasilkan dari parsing str. cut -c mengiris per karakter dan sejajar dengannya. Tetapi head -c, tail -c, dan dd mengiris per byte, dan di bawah UTF-8 satu kesalahan membuat semuanya bergeser.
Sampel Tionghoa membuatnya konkret (5 order, 148 karakter, 222 byte):
>>> 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 yang sama: tampilan karakter mendarat pada nama field, tampilan byte mendarat pada ekor sebuah order id. Satu karakter CJK menempati 3 byte di bawah UTF-8, dan pergeserannya menumpuk seiring posisi.
Aturan: setelah membaca e.pos, iris dengan cut -c atau slice Python, jangan pernah dengan head -c / dd bs=1 skip=N. Jika alat byte adalah semua yang Anda punya, konversi dulu: len(raw[:error.pos].encode('utf-8')).
7. Sintaks diperbaiki, data tetap salah: tiga jebakan senyap
Inilah tepatnya mengapa debugging tidak boleh berhenti pada "sudah berhasil di-parse". Masing-masing berikut entah tidak pernah memunculkan error, atau memunculkannya jauh setelah nilainya digunakan.
Jebakan 1: kunci duplikat — yang terakhir menang, tidak ada yang mengeluh
RFC 8259 bagian 4 jelas: nama di dalam object SHOULD unik, dan jika tidak, perilakunya tak terprediksi — sebagian besar implementasi menyimpan kemunculan terakhir, yang lain memunculkan error. Ketiga alat yang diukur di sini menyimpan yang terakhir:
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}
Tidak ada satu pun peringatan. Jika field yang ditimpa adalah amount_cents, Anda baru tahu saat rekonsiliasi meleset tiga sen.
Jebakan 2: NaN dan Infinity — Python menerimanya, Node tidak
RFC 8259 bagian 6 menyatakan dengan jelas bahwa literal number tidak memuat NaN dan tidak memuat Infinity. json.loads milik Python menerima keduanya secara default, karena float miliknya sendiri memiliki nilai-nilai itu:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
Berikan output itu ke Node dan Anda mendapat:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
Dengan kata lain: data buruk lolos dari Python dan meledak di konsumen hilir.
Jebakan 3: integer di atas 2^53 kehilangan presisi secara diam-diam
Satu dokumen, dua nilai berbeda:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
Alasannya dalam satu baris: Number.MAX_SAFE_INTEGER milik JavaScript adalah 9007199254740991 (2^53-1). MDN secara khusus menunjukkan bahwa reviver berjalan setelah nilai sudah di-parse menjadi number JavaScript, sehingga presisi hilang sebelum reviver — tidak dapat dipulihkan di sisi deserialisasi. Satu-satunya perbaikan adalah produsen menyerialisasi ID besar sebagai string.
Ketiga jebakan dibandingkan:
| Jebakan | Apakah ada yang memunculkan error? | Siapa yang menyadari lebih dulu | Cara mencegat |
|---|---|---|---|
| Kunci duplikat | Tidak | Rekonsiliasi hilir | Pemeriksaan eksplisit via object_pairs_hook |
| NaN / Infinity | Node memunculkan, Python senyap | Konsumen hilir | Tolak di tepi dengan parse_constant |
| Integer di atas 2^53 | Tidak | Kode yang melakukan pemeriksaan kesetaraan | Kirim sebagai string |
Tangkap dua yang pertama dalam satu kali jalan
# 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')
Biayanya, disampaikan dengan jujur: pada sampel 2.011.084 karakter yang sama, versi ketat ini berada di 35,2-36,2 ms dibandingkan 9,4-9,9 ms untuk versi longgar, sekitar 3,7x lebih lambat (3,6x hingga 3,8x pada lima putaran masing-masing lima kali jalan, median per putaran) — object_pairs_hook menjalankan Counter untuk setiap object. Jauhkan dari jalur bahagia CI dan beralihlah ke sana hanya saat sebuah pengiriman tampak mencurigakan.
8. Daftar periksa
Setelah memperbaiki file, menggoda untuk mem-pretty-print-nya demi keterbacaan, tetapi sampel yang sama terukur 2.011.084 karakter saat diminifikasi (separators=(',',':')) dan 2.869.100 karakter pada indent=2 — 43% lebih banyak. Bobot itu berpindah ke riwayat Git, cache CI, dan transfer jaringan, jadi simpan versi pretty secara lokal dan rapatkan kembali menjadi satu baris sebelum dikirim.
- [ ] Cari
(char N)/position Nlebih dulu; abaikanline 1 column N; - [ ] Python memakai
e.pos, jq dipasangkan dengancut -c— offset karakter yang sama, jadi jangan pernah menyejajarkancolumnjq terhadapnya; - [ ] Untuk dokumen CJK iris per karakter (
cut -c, slice Python), jangan pernahhead -c/dd; - [ ] Skrip container harus memperlakukan exit code 5 jq sebagai "input tidak valid";
- [ ] Periksa ulang data mencurigakan dengan
object_pairs_hook+parse_constantdan terima biaya sekitar 3,7x; - [ ] Kirim ID apa pun di atas 2^53 sebagai string; jangan berharap reviver menyelamatkannya;
- [ ] Output pretty-print (terukur +43%) tetap lokal: gunakan /minify untuk memadatkan kembali menjadi satu baris sebelum mengirim atau commit; di atas beberapa MB beralih ke streaming, dibahas di Parsing, rendering, dan streaming JSON besar.
FAQ
Mengapa ketiga alat melaporkan posisi yang berbeda?
Karena mereka menghitung hal yang berbeda. char N milik Python dan position N milik Node keduanya berarti "di mana parser berhenti" — pada sampel ini keduanya 23610, jadi cocok. jq melaporkan di mana token yang bermasalah dimulai, di sini 23619, sembilan lebih jauh. Tidak ada yang salah menghitung dan tidak perlu koreksi; bandingkan angka hanya dalam alat yang sama.
Haruskah saya menggunakan cut -c atau head -c?
Gunakan cut -c. JSONDecodeError.pos adalah offset yang dihitung dalam karakter Unicode, dan cut -c mengiris per karakter, sehingga keduanya sepakat. head -c, tail -c, dan dd mengiris per byte, yang bergeser pada dokumen UTF-8 berisi teks CJK dan bahkan dapat memotong karakter menjadi dua. Pada sampel Tionghoa, offset yang sama menghasilkan 城市":"杭州"}, dari tampilan karakter dan DD00000"," dari tampilan byte.
Apakah penanganan kunci duplikat benar-benar tanpa pengaman di mana-mana?
Benar. RFC 8259 bagian 4 hanya menyatakan nama SHOULD unik dan menyatakan perilakunya tak terprediksi jika tidak. Python, Node, dan jq yang diukur di sini semuanya menerapkan yang-terakhir-menang tanpa mengeluarkan peringatan apa pun. Menangkapnya membutuhkan object_pairs_hook Anda sendiri, dengan biaya sekitar 3,7x waktu parse.
Mengapa Python membaca NaN dengan baik sementara Node melempar error?
RFC 8259 bagian 6 melarang NaN dan Infinity sebagai literal number. json.loads milik Python menerimanya secara default karena float milik Python memiliki nilai-nilai itu, sehingga data buruk lolos dari Python dan ditulis langsung kembali. JSON.parse milik Node mengikuti spesifikasi secara ketat dan melempar SyntaxError pada output itu. Menambahkan parse_constant di titik masuk akan langsung menolaknya.
Apakah nomor order 20 digit selalu kehilangan presisi lewat JSON?
Hanya jika konsumen mem-parsing-nya menjadi double IEEE 754. int milik Python berpresisi arbitrer dan mempertahankan 12345678901234567890; Number.MAX_SAFE_INTEGER milik JavaScript adalah 9007199254740991, sehingga field yang sama menjadi 12345678901234567000. MDN mencatat secara eksplisit bahwa reviver berjalan setelah parsing, jadi tidak ada yang bisa dipulihkan di hilir — satu-satunya perbaikan yang andal adalah produsen mengeluarkan string.