Penyahbugan JSON Diminify: Daripada baris 1 lajur 23611 kepada Bait Rosak yang Tepat
Inti pati: Fail JSON satu baris bersaiz 4 MB melaporkan baris 1 lajur 23611, yang tidak memberitahu apa-apa. Menggunakan sampel yang diukur secara setempat, panduan ini menerangkan mengapa Python, Node dan jq melaporkan tiga nombor berbeza, bagaimana offset aksara membawa anda terus kepada bait rosak, dan tiga perangkap yang merosakkan data secara senyap selepas sintaks dibetulkan.

Kesimpulan utama: nombor baris dan lajur hanya bermakna dalam dokumen berbilang baris. Sebaik sahaja JSON diminify menjadi satu baris, setiap kesilapan berada pada baris 1 — nombor yang anda perlukan bukan baris, tetapi indeks aksara.
Dokumen JSON diminify mempunyai tepat satu baris, jadi pasangan baris/lajur dalam mesej kesilapan meruntuhkan kedudukan empat juta bait menjadi satu frasa tidak berguna. Jalan penyelesaiannya ialah melangkau baris/lajur sepenuhnya dan membaca offset aksara sebaliknya —
(char 23610)yang sederhana pada penghujung mesej ialah nombor yang benar-benar menyelamatkan anda.
Setiap nombor di bawah dihasilkan secara setempat. Sampel ialah dokumen satu baris yang memuatkan 13,000 peristiwa bil, 2,011,084 aksara secara keseluruhan, dengan satu koma sengaja dipadam selepas event_id peristiwa #154. Persekitarannya ialah Python 3.11.1, Node 22.13.1 dan jq 1.7.
1. Pertama, fakta: tiga penghurai, tiga nombor
Fail rosak yang sama, diberikan kepada 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 dilaporkan | Unit | Dikira dari | Mengapa baris/lajur gagal |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | Offset aksara | Permulaan penimbal | Satu-satunya yang boleh dipercayai |
Python lineno / colno |
1 / 23611 | Baris dan lajur | Permulaan baris | Hanya satu baris, jadi ia tidak bermakna apa-apa |
Node position |
23610 | Offset aksara | Permulaan penimbal | Boleh dipercayai, walaupun mesej menyembunyikan medan itu |
jq column |
23619 | Offset aksara | Permulaan token yang menjadi masalah | Berbeza daripada Python sebanyak 9 |
Pengajarannya tegas: char N Python dan position N Node ialah nombor yang sama dan benar-benar sejajar. jq menggunakan skema sendiri — jangan buang masa menyelaraskannya.
Ketiga-tiga nombor berbeza bukan kerana seseorang salah kira, tetapi kerana ia mengira perkara yang berbeza. Python dan Node melaporkan di mana penghurai berhenti; jq melaporkan di mana token yang gagal dihurai bermula. Beberapa aksara yang sudah dimakan terletak di antara keduanya — 9 dalam sampel ini.
2. Mengapa baris 1 lajur N ialah bunyi bising pada fail jenis ini
Nombor baris ialah kuantiti terbitan yang menganggap "sudah lama sejak baris baharu terakhir". Dokumen diminify mempunyai satu \n, biasanya di hujung, jadi:
linenosentiasa 1;colnomerosot menjadi "berapa banyak aksara selepas permulaan satu baris itu";- dan baris itu selebar empat juta aksara.
Lompat ke lajur 23611 dalam editor dan anda tidak melihat apa-apa yang berguna. Lebih teruk, alat seperti less dan vim merangkak pada baris yang sangat panjang; menolak kursor dengan kekunci anak panah menghabiskan dua puluh minit.
Balikkan keadaan: offset ialah kuantiti asli, dan baris/lajur dikira daripadanya. Gunakan kuantiti asli.
3. Pendekatan satu: dalam Python, gunakan e.pos dan abaikan lineno
JSONDecodeError membawa tiga nombor, dan hanya pos yang berbaloi dibaca. lineno dan colno ialah nilai terbitan yang runtuh menjadi tiada 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')
Jalankannya terhadap fail rosak:
$ 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 karetn menandakan tempat koma yang hilang. Dua puluh minit menjadi satu arahan.
Selebar mana tetingkap sepatutnya: ±60 aksara hampir selalu merangkumi satu pasangan kunci/nilai yang lengkap. Terlalu sempit dan anda kehilangan konteks; terlalu lebar dan output memenuhi skrin. Dokumen bersarang dalam boleh pergi ke ±120.
4. Pendekatan dua: tiada Python dalam bekas — gunakan jq dengan cut
Bekas pengeluaran sering dihantar tanpa Python, tetapi hampir selalu mempunyai jq, atau boleh mendapatkannya dengan satu apt-get install jq. Apa yang jq berikan kepada anda ialah kegagalan yang lantang:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
Kod keluar 5 bermaksud input tidak sah, jadi di bawah set -e ia membatalkan skrip sama seperti grep yang memulangkan 1. Itu ciri yang berguna.
jq tidak mempunyai pilihan untuk mencetak bait yang menjadi masalah, jadi berikan kedudukan itu kepada 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",
Sebaik sahaja koma dikembalikan, jq boleh melakukan apa yang sebenarnya ia pandai:
$ 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: tiada runtime, atau data yang tidak boleh menyentuh cakera — tampal ke dalam pelayar
Dua pendekatan pertama memerlukan sesuatu yang dipasang dan kebenaran menulis fail. Jika anda menggunakan komputer riba kosong, atau muatan itu penuh dengan e-mel pelanggan dan alamat IP dan "saya tampal ke dalam beberapa laman web" ialah ayat yang anda tidak mahu ada dalam laporan insiden, pilihan ketiga sesuai.
Tampal keseluruhan rentetan ke dalam penghurai JSON dalam talian dan ia meletakkan karetn pada aksara yang menjadi masalah dan melabel baris serta lajur; kelebihan alat setempat ialah 4 MB itu tidak pernah meninggalkan mesin dan tidak pernah mendarat dalam log orang lain.
Dua kaveat:
- Pilih alat yang menyatakan secara eksplisit bahawa ia memproses segala-galanya dalam pelayar, bukan alat yang menghantar kandungan anda ke bahagian belakang melalui POST;
- Menampal boleh menampung fail yang sangat besar, tetapi melepasi beberapa puluh megabait tab itu sendiri memakan memori — kembali ke pendekatan satu pada ketika itu.
6. Offset aksara bukan offset bait: perangkap kedua dengan data CJK
Yang ini menggigit kedua-dua pendekatan pada muatan bercampur Cina/Inggeris.
JSONDecodeError.pos ialah offset aksara (kiraan titik kod Unicode), kerana ia terhasil daripada menghurai str. cut -c menghiris mengikut aksara dan sejajar dengannya. Tetapi head -c, tail -c dan dd menghiris mengikut bait, dan di bawah UTF-8 satu kesilapan kecil meletakkan segala-galanya di luar kedudukan.
Sampel Cina menjadikannya konkrit (5 pesanan, 148 aksara, 222 bait):
>>> 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: paparan aksara mendarat pada nama medan, paparan bait mendarat pada hujung ID pesanan. Satu aksara CJK menempati 3 bait di bawah UTF-8, dan hanyutan itu terkumpul mengikut kedudukan.
Peraturan: selepas membaca e.pos, hirislah dengan cut -c atau hirisan Python, jangan sekali-kali dengan head -c / dd bs=1 skip=N. Jika alat bait adalah semua yang anda ada, tukar dahulu: len(raw[:error.pos].encode('utf-8')).
7. Sintaks dibetulkan, data masih salah: tiga perangkap senyap
Inilah sebabnya penyahbugan tidak boleh berhenti pada "ia telah dihurai". Setiap yang berikut sama ada tidak pernah melambungkan, atau melambungkan jauh selepas nilai digunakan.
Perangkap 1: kunci pendua — yang terakhir menang, tiada siapa merungut
Bahagian 4 RFC 8259 jelas: nama dalam sesuatu objek SHOULD unik, dan apabila ia tidak unik, tingkah lakunya tidak dapat diramal — kebanyakan pelaksanaan mengekalkan kejadian terakhir, yang lain melambungkan. Ketiga-tiga alat yang diukur di sini mengekalkan 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}
Tiada satu pun amaran. Jika medan yang ditimpa ialah amount_cents, anda mengetahuinya apabila penyesuaian tidak seimbang tiga sen.
Perangkap 2: NaN dan Infinity — Python menerimanya, Node tidak
Bahagian 6 RFC 8259 menyatakan dengan jelas bahawa literal nombor tidak mengandungi NaN dan tidak mengandungi Infinity. json.loads Python menerima keduanya secara lalai, kerana floatnya sendiri mempunyai nilai tersebut:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
Berikan output itu kepada Node dan anda mendapat:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
Dengan kata lain: data buruk melepasi Python dan meletup pada pengguna hiliran.
Perangkap 3: integer melebihi 2^53 kehilangan ketepatan secara senyap
Satu dokumen, dua nilai berbeza:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
Alasannya dalam satu baris: Number.MAX_SAFE_INTEGER JavaScript ialah 9007199254740991 (2^53-1). MDN menunjukkan secara khusus bahawa reviver berjalan selepas nilai sudah dihurai menjadi nombor JavaScript, jadi ketepatan hilang sebelum reviver — ia tidak boleh dipulihkan di sebelah penyahserilan. Satu-satunya pembetulan ialah pengeluar menyerialkan ID besar sebagai rentetan.
Ketiga-tiga perangkap dibandingkan:
| Perangkap | Adakah apa-apa melambung? | Siapa perasan dahulu | Cara memintas |
|---|---|---|---|
| Kunci pendua | Tidak | Penyesuaian hiliran | Semakan eksplisit melalui object_pairs_hook |
| NaN / Infinity | Node melambung, Python senyap | Pengguna hiliran | Tolak di pinggir dengan parse_constant |
| Integer melebihi 2^53 | Tidak | Kod yang melakukan semakan kesamaan | Hantarnya sebagai rentetan |
Tangkap dua yang pertama dalam satu pas
# 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')
Kosnya, dinyatakan dengan jujur: pada sampel 2,011,084 aksara yang sama, versi ketat ini mendarat pada 35.2-36.2 ms berbanding 9.4-9.9 ms untuk versi longgar, kira-kira 3.7x lebih perlahan (3.6x hingga 3.8x merentas lima pusingan lima larian setiap satu, median setiap pusingan) — object_pairs_hook menjalankan Counter untuk setiap objek. Jauhkannya daripada laluan normal CI dan beralih kepadanya hanya apabila sesuatu penghantaran kelihatan mencurigakan.
8. Senarai semak
Selepas membetulkan fail, godaan untuk mencetak cantiknya demi kebolehbacaan memang kuat, tetapi sampel yang sama berukuran 2,011,084 aksara diminify (separators=(',',':')) dan 2,869,100 aksara pada indent=2 — 43% lebih. Berat itu bergerak ke dalam sejarah Git, cache CI dan pemindahan rangkaian, jadi simpan versi cantik secara setempat dan mampatkannya semula menjadi satu baris sebelum dihantar.
- [ ] Cari
(char N)/position Ndahulu; abaikanline 1 column N; - [ ] Python menggunakan
e.pos, jq berpasangan dengancut -c— offset aksara yang sama, jadi jangan sekali-kali menyelaraskancolumnjq terhadapnya; - [ ] Bagi dokumen CJK hirislah mengikut aksara (
cut -c, hirisan Python), jangan sekali-kalihead -c/dd; - [ ] Skrip bekas harus melayan kod keluar jq 5 sebagai "input tidak sah";
- [ ] Semak semula data yang mencurigakan dengan
object_pairs_hook+parse_constantdan terima kos kira-kira 3.7x itu; - [ ] Hantar mana-mana ID melebihi 2^53 sebagai rentetan; jangan jangka reviver menyelamatkannya;
- [ ] Output cetak cantik (diukur +43%) kekal setempat: gunakan /minify untuk memampatkan semula menjadi satu baris sebelum dihantar atau di-commit; melepasi beberapa MB beralih kepada penstriman, yang dibincangkan dalam Penghuraian, pemaparan dan penstriman JSON besar.
Soalan Lazim
Mengapa ketiga-tiga alat melaporkan kedudukan yang berbeza?
Kerana ia mengira perkara yang berbeza. char N Python dan position N Node kedua-duanya bermaksud "di mana penghurai berhenti" — pada sampel ini kedua-duanya 23610, jadi ia sepadan. jq melaporkan di mana token yang menjadi masalah bermula, 23619 di sini, sembilan lebih jauh. Tiada sesiapa salah kira dan tiada pembetulan diperlukan; bandingkan nombor hanya dalam alat yang sama.
Patutkah saya menggunakan cut -c atau head -c?
Gunakan cut -c. JSONDecodeError.pos ialah offset yang dikira dalam aksara Unicode, dan cut -c menghiris mengikut aksara, jadi kedua-duanya sependapat. head -c, tail -c dan dd menghiris mengikut bait, yang hanyut pada dokumen UTF-8 yang mengandungi teks CJK dan boleh memotong aksara separuh. Pada sampel Cina, offset yang sama menghasilkan 城市":"杭州"}, daripada paparan aksara dan DD00000"," daripada paparan bait.
Adakah pengendalian kunci pendua benar-benar tidak berpandu di mana-mana?
Ya. Bahagian 4 RFC 8259 hanya menyatakan nama SHOULD unik dan menyatakan bahawa tingkah laku tidak dapat diramal apabila ia tidak unik. Python, Node dan jq yang diukur di sini semuanya menggunakan "yang terakhir menang" tanpa mengeluarkan sebarang amaran. Menangkapnya memerlukan object_pairs_hook anda sendiri, pada kos kira-kira 3.7x masa penghuraian.
Mengapa Python membaca NaN dengan baik manakala Node melambung?
Bahagian 6 RFC 8259 tidak membenarkan NaN dan Infinity sebagai literal nombor. json.loads Python menerimanya secara lalai kerana float Python sendiri mempunyai nilai tersebut, jadi data buruk melepasi Python dan ditulis semula terus. JSON.parse Node mengikut spesifikasi dengan ketat dan melambungkan SyntaxError pada output itu. Menambah parse_constant pada titik masuk menolaknya serta-merta.
Adakah nombor pesanan 20 digit sentiasa kehilangan ketepatan melalui JSON?
Hanya apabila pengguna menghurainya menjadi double IEEE 754. int Python berketepatan sewenang-wenangnya dan mengekalkan 12345678901234567890; Number.MAX_SAFE_INTEGER JavaScript ialah 9007199254740991, jadi medan yang sama menjadi 12345678901234567000. MDN mencatat secara eksplisit bahawa reviver berjalan selepas penghuraian, jadi tiada apa yang boleh dipulihkan di hiliran — satu-satunya pembetulan yang boleh dipercayai ialah pengeluar memancarkan rentetan.