JSON Toolkit

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.

Lanjutan~7 min bacaanDiterbitkan 2026-11-01Dikemas kini 2026-11-01
Kulit untuk mencari kesilapan dalam JSON diminify

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:

  • lineno sentiasa 1;
  • colno merosot 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:

  1. Pilih alat yang menyatakan secara eksplisit bahawa ia memproses segala-galanya dalam pelayar, bukan alat yang menghantar kandungan anda ke bahagian belakang melalui POST;
  2. 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 N dahulu; abaikan line 1 column N;
  • [ ] Python menggunakan e.pos, jq berpasangan dengan cut -c — offset aksara yang sama, jadi jangan sekali-kali menyelaraskan column jq terhadapnya;
  • [ ] Bagi dokumen CJK hirislah mengikut aksara (cut -c, hirisan Python), jangan sekali-kali head -c / dd;
  • [ ] Skrip bekas harus melayan kod keluar jq 5 sebagai "input tidak sah";
  • [ ] Semak semula data yang mencurigakan dengan object_pairs_hook + parse_constant dan 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.