JSON Toolkit

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.

Pokročilý~7 min čteníPublikováno 2026-11-01Aktualizováno 2026-11-01
Titulní obrázek článku o hledání chyb v minifikovaném JSON

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:

  • lineno je vždy 1;
  • colno degeneruje 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í:

  1. Vyberte nástroj, který výslovně říká, že vše zpracovává v prohlížeči, nikdy ne ten, který obsah POSTuje na backend;
  2. 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; ignorujte line 1 column N;
  • [ ] V Pythonu použijte e.pos, jq zkombinujte s cut -c — stejné offsety znaků, takže column z jq s nimi nikdy neslaďujte;
  • [ ] U CJK dokumentů krájejte po znacích (cut -c, řezy v Pythonu), nikdy head -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_constant a 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.