Ladění minifikovaného JSON: od řádku 1, sloupce 23611 k přesnému poškozenému bajtu
Shrnutí: Minifikovaný JSON o 4 MB na jednom řádku hlásí „řádek 1, sloupec 23611“, což neříká nic. Na lokálně naměřených vzorcích vysvětlujeme, proč Python, Node a jq hlásí tři různá čísla, jak offset znaku vede přímo k poškozenému bajtu a které tři pasti po opravě syntaxe tiše poškozují data.

Stručně: čísla řádků a sloupců mají smysl jen ve víceřádkových dokumentech. Jakmile je JSON zminifikován do jednoho řádku, každá chyba žije na řádku 1 — číslo, které potřebujete, není řádek, ale index znaku.
Minifikovaný dokument JSON má přesně jeden řádek, takže dvojice řádek/sloupec ve zprávě o chybě slisuje čtyřmilionovou pozici do jedné zbytečné fráze. Řešením je řádek/sloupec úplně přeskočit a číst místo toho offset znaku — skromné
(char 23610)na konci zprávy je číslo, které vás skutečně zachrání.
Všechna čísla níže vznikla lokálně. Vzorek je jednořádkový dokument s 13 000 událostmi fakturace, celkem 2 011 084 znaků, s jednou čárkou záměrně smazanou za event_id události č. 154. Prostředí je Python 3.11.1, Node 22.13.1 a jq 1.7.
1. Nejprve fakta: tři parsery, tři čísla
Stejný poškozený soubor předaný třem nástrojům:
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
Vedle sebe:
| Nástroj | Hlášená hodnota | Jednotka | Odkud se počítá | Proč řádek/sloupec selhává |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | Offset znaků | Začátek bufferu | Jediné, čemu lze věřit |
Python lineno / colno |
1 / 23611 | Řádek a sloupec | Začátek řádku | Jen jeden řádek, takže neříká nic |
Node position |
23610 | Offset znaků | Začátek bufferu | Spolehlivé, i když zpráva pole skrývá |
jq column |
23619 | Offset znaků | Začátek problematického tokenu | Liší se od Pythonu o 9 |
Závěr je přímočarý: char N v Pythonu a position N v Node jsou stejné číslo a skutečně se shodují. jq používá vlastní schéma — neztrácejte čas jeho sladěním.
Tři čísla se liší ne proto, že by někdo špatně počítal, ale proto, že počítají různé věci. Python a Node hlásí, kde se parser zastavil; jq hlásí, kde začíná token, který se nepodařilo naparsovat. Mezi oběma sedí hrst už spotřebovaných znaků — v tomto vzorku 9.
2. Proč je „řádek 1, sloupec N“ u takového souboru šum
Čísla řádků jsou odvozená veličina, která předpokládá „od posledního zalomení řádku už uplynul nějaký čas“. Minifikovaný dokument má jeden \n, obvykle na konci, takže:
linenoje vždy 1;colnodegeneruje na „kolik znaků od začátku onoho jediného řádku“;- a ten řádek je široký čtyři miliony znaků.
Přejděte v editoru na sloupec 23611 a neuvidíte nic užitečného. Hůř, nástroje jako less a vim lezou na extrémně dlouhých řádcích; posouvání kurzoru šipkami spálí dvacet minut.
Otočte to: offset je nativní veličina a řádek/sloupec se z něj počítají. Používejte nativní veličinu.
3. Přístup první: v Pythonu použijte e.pos a ignorujte lineno
JSONDecodeError nese tři čísla a jen pos stojí za přečtení. lineno a colno jsou odvozené hodnoty, které se na jednořádkovém dokumentu slijí v nic.
# 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')
Spusťte ho proti poškozenému souboru:
$ 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",
^
Stříška označuje místo, kde chybí čárka. Dvacet minut se mění v jediný příkaz.
Jak široké má okno být: ±60 znaků téměř vždy pokryje celou dvojici klíč/hodnota. Příliš úzké a ztratíte kontext; příliš široké a výstup zaplní obrazovku. Hluboce vnořené dokumenty mohou jít na ±120.
4. Přístup druhý: v kontejneru není Python — použijte jq a cut
Produkční kontejnery často nemají Python, ale téměř vždy mají jq, nebo ho získají jedním apt-get install jq. Co jq dává, je hlasité selhání:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
Kód ukončení 5 znamená neplatný vstup, takže pod set -e přeruší skript stejně jako grep vracející 1. To je vlastnost.
jq nemá přepínač pro vypsání problematických bajtů, takže pozici předávejte 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",
Když je čárka zpět, jq může dělat to, v čem je skutečně dobrý:
$ 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. Přístup třetí: žádný runtime, nebo data, která se nesmějí dotknout disku — vložte je do prohlížeče
První dva přístupy vyžadují něco nainstalovaného a oprávnění zapisovat soubory. Pokud jste na holém notebooku, nebo je payload plný zákaznických e-mailů a IP adres a věta „vložil jsem to na nějaký web“ je věta, kterou nechcete mít v hlášení o incidentu, hodí se třetí možnost.
Vložte celý řetězec do online parseru JSON a ten postaví stříšku na problematický znak a označí řádek a sloupec; výhodou lokálního nástroje je, že těch 4 MB nikdy neopustí počítač a nikdy neskončí v cizích logech.
Dvě upozornění:
- Vyberte nástroj, který výslovně říká, že vše zpracovává v prohlížeči, nikdy ne ten, který obsah POSTuje na backend;
- Vkládání zvládá velmi velké soubory, ale nad několik desítek megabajtů už paměť žere samotná záložka — v tu chvíli se vraťte k prvnímu přístupu.
6. Offset znaků není offset bajtů: druhá past u CJK dat
Tato past kouše oba přístupy u smíšených čínsko-anglických payloadů.
JSONDecodeError.pos je offset znaků (počet kódových bodů Unicode), protože vzniká při parsování str. cut -c krájí po znacích a ladí s ním. Ale head -c, tail -c a dd krájí po bajtech a pod UTF-8 jedna chyba posune všechno.
Čínský vzorek to ukáže konkrétně (5 objednávek, 148 znaků, 222 bajtů):
>>> 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","'
Stejný offset: znakový pohled přistane na názvu pole, bajtový pohled na konec ID objednávky. Jediný CJK znak zabírá v UTF-8 3 bajty a posun se s pozicí sčítá.
Pravidlo: po přečtení e.pos krájejte pomocí cut -c nebo řezu v Pythonu, nikdy pomocí head -c / dd bs=1 skip=N. Pokud máte jen bajtové nástroje, nejprve převedte: len(raw[:error.pos].encode('utf-8')).
7. Syntaxe opravena, data stále špatná: tři tiché pasti
Právě proto ladění nesmí skončit u „ono se to naparsovalo“. Každá z následujících pastí buď nikdy nevyvolá chybu, nebo ji vyvolá dávno poté, co byla hodnota použita.
Past 1: duplicitní klíče — vyhraje poslední, nikdo si nestěžuje
RFC 8259 sekce 4 je jasná: názvy v objektu MĚLY by být jedinečné, a když nejsou, chování je nepředvídatelné — většina implementací ponechá poslední výskyt, jiné vyvolají chybu. Všechny tři zde měřené nástroje ponechají poslední:
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}
Ani jedno varování. Pokud přepsané pole bylo amount_cents, zjistíte to, když se odsouhlasení rozjede o tři centy.
Past 2: NaN a Infinity — Python je přijímá, Node ne
RFC 8259 sekce 6 říká jasně, že číselné literály neobsahují NaN ani Infinity. json.loads v Pythonu přijímá obojí ve výchozím nastavení, protože jeho vlastní float tyto hodnoty má:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
Předejte ten výstup Node a dostanete:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
Jinými slovy: špatná data projdou Pythonem a vybuchnou u spotřebitele níže.
Past 3: celá čísla nad 2^53 tiše ztrácejí přesnost
Jeden dokument, dvě různé hodnoty:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
Důvod v jedné řádce: Number.MAX_SAFE_INTEGER v JavaScriptu je 9007199254740991 (2^53-1). MDN výslovně upozorňuje, že reviver běží poté, co byla hodnota již naparsována do čísla JavaScriptu, takže přesnost se ztratí před reviverem — na straně deserializace ji nelze obnovit. Jediná oprava je, aby producent serializoval velká ID jako řetězce.
Srovnání tří pastí:
| Past | Vyvolá něco chybu? | Kdo si všimne první | Jak to zachytit |
|---|---|---|---|
| Duplicitní klíče | Ne | Odsouhlasení níže | Explicitní kontrola přes object_pairs_hook |
| NaN / Infinity | Node vyvolá, Python mlčí | Spotřebitelé níže | Odmítnout na hranici přes parse_constant |
| Celá čísla nad 2^53 | Ne | Kód provádějící kontrolu rovnosti | Posílat je jako řetězce |
Zachyťte první dvě v jednom průchodu
# 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')
Cena, řečeno upřímně: na stejném vzorku o 2 011 084 znacích se striktní verze dostane na 35,2–36,2 ms proti 9,4–9,9 ms u benevolentní, zhruba 3,7krát pomalejší (3,6–3,8krát v pěti kolech po pěti bězích, medián v kole) — object_pairs_hook spouští Counter pro každý objekt. Držte ji mimo šťastnou cestu CI a přepněte se na ni jen tehdy, když dodávka vypadá podezřele.
8. Kontrolní seznam
Po opravě souboru je lákavé ho kvůli čitelnosti naformátovat, ale stejný vzorek měří 2 011 084 znaků zminifikovaný (separators=(',',':')) a 2 869 100 znaků při indent=2 — o 43 % více. Ta váha putuje do historie Git, cache CI a síťového přenosu, takže krásnou verzi držte lokálně a před dodáním ji zmačkejte zpět do jednoho řádku.
- [ ] Nejdříve hledejte
(char N)/position N; ignorujteline 1 column N; - [ ] V Pythonu použijte
e.pos, jq zkombinujte scut -c— stejné offsety znaků, takžecolumnz jq s nimi nikdy neslaďujte; - [ ] U CJK dokumentů krájejte po znacích (
cut -c, řezy v Pythonu), nikdyhead -c/dd; - [ ] Skripty v kontejneru mají kód ukončení jq 5 považovat za „neplatný vstup“;
- [ ] Podezřelá data znovu zkontrolujte přes
object_pairs_hook+parse_constanta přijměte cenu zhruba 3,7krát; - [ ] Každé ID nad 2^53 posílejte jako řetězec; nečekejte, že ho reviver zachrání;
- [ ] Výstup s pěkným formátováním (naměřeno +43 %) zůstává lokálně: použijte /minify ke zmačkání zpět do jednoho řádku před dodáním nebo commitem; nad několik MB přepněte na streamování, o němž je řeč v Parsing, rendering and streaming large JSON.
FAQ
Proč všechny tři nástroje hlásí jinou pozici?
Protože počítají různé věci. char N v Pythonu a position N v Node oba znamenají „kde se parser zastavil“ — na tomto vzorku jsou oba 23610, takže se shodují. jq hlásí, kde začíná problematický token, zde 23619, o devět dál. Nikdo nepočítal špatně a žádná oprava není potřeba; porovnávejte čísla jen uvnitř stejného nástroje.
Mám použít cut -c, nebo head -c?
Použijte cut -c. JSONDecodeError.pos je offset počítaný ve znacích Unicode a cut -c krájí po znacích, takže se shodují. head -c, tail -c a dd krájí po bajtech, což u UTF-8 dokumentů obsahujících CJK text driftuje a dokonce může rozpůlit znak. Na čínském vzorku stejný offset dává 城市":"杭州"}, ze znakového pohledu a DD00000"," z bajtového.
Je zpracování duplicitních klíčů opravdu všude nechráněné?
Je. RFC 8259 sekce 4 jen říká, že názvy MĚLY by být jedinečné, a uvádí, že chování je nepředvídatelné, když nejsou. Python, Node i jq zde všechny měřené uplatňují poslední-vyhrává, aniž by vydaly varování. Zachytit to vyžaduje vlastní object_pairs_hook, za cenu zhruba 3,7krát delšího času parsování.
Proč Python čte NaN v pohodě, zatímco Node vyhodí chybu?
RFC 8259 sekce 6 zakazuje NaN a Infinity jako číselné literály. json.loads v Pythonu je přijímá ve výchozím nastavení, protože vlastní float Pythonu tyto hodnoty má, takže špatná data projdou Pythonem a hned se zapíšou zpět. JSON.parse v Node se striktně drží specifikace a na ten výstup vyhodí SyntaxError. Přidání parse_constant na vstupním bodě to okamžitě odmítne.
Ztrácejí dvacetičíselná čísla objednávek přes JSON vždy přesnost?
Jen když je spotřebitel parsuje do IEEE 754 double. int v Pythonu má libovolnou přesnost a ponechá 12345678901234567890; Number.MAX_SAFE_INTEGER v JavaScriptu je 9007199254740991, takže stejné pole se stane 12345678901234567000. MDN výslovně poznamenává, že reviver běží po parsování, takže níže nelze nic obnovit — jediná spolehlivá oprava je, aby producent vydával řetězce.