Debugowanie zminifikowanego JSON: od wiersza 1, kolumny 23611 do dokładnego uszkodzonego bajtu
Podsumowanie: Zminifikowany JSON o rozmiarze 4 MB w jednym wierszu zgłasza „wiersz 1, kolumna 23611”, co nic nie mówi. Na lokalnie zmierzonych próbkach wyjaśniamy, dlaczego Python, Node i jq podają trzy różne liczby, jak przesunięcie znaku prowadzi prosto do uszkodzonego bajtu i które trzy pułapki po naprawie składni cicho psują dane.

Najważniejsze: numery wierszy i kolumn coś znaczą tylko w dokumentach wielowierszowych. Gdy JSON jest zminifikowany do jednego wiersza, każdy błąd mieszka w wierszu 1 — liczbą, której potrzebujesz, nie jest wiersz, a indeks znaku.
Zminifikowany dokument JSON ma dokładnie jeden wiersz, więc para wiersz/kolumna w komunikacie o błędzie ściska czteromilionową pozycję w jedną bezużyteczną frazę. Obejściem jest całkowite pominięcie wiersza/kolumny i czytanie przesunięcia znaku — skromne
(char 23610)na końcu komunikatu jest liczbą, która naprawdę cię ratuje.
Wszystkie liczby poniżej powstały lokalnie. Próbka to jednowierszowy dokument zawierający 13 000 zdarzeń rozliczeniowych, łącznie 2 011 084 znaki, z jednym przecinkiem celowo usuniętym po event_id zdarzenia nr 154. Środowisko to Python 3.11.1, Node 22.13.1 i jq 1.7.
1. Najpierw fakty: trzy parsery, trzy liczby
Ten sam uszkodzony plik podany trzem narzędziom:
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
Obok siebie:
| Narzędzie | Zgłaszana wartość | Jednostka | Od czego liczone | Dlaczego wiersz/kolumna zawodzi |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | Przesunięcie znaków | Początek bufora | Jedyna, której można ufać |
Python lineno / colno |
1 / 23611 | Wiersz i kolumna | Początek wiersza | Tylko jeden wiersz, więc nic nie mówi |
Node position |
23610 | Przesunięcie znaków | Początek bufora | Wiarygodne, choć komunikat ukrywa pole |
jq column |
23619 | Przesunięcie znaków | Początek problematycznego tokenu | Różni się od Pythona o 9 |
Wniosek jest bezpośredni: char N w Pythonie i position N w Node to ta sama liczba i rzeczywiście się zgadzają. jq używa własnego schematu — nie trać czasu na jego uzgadnianie.
Trzy liczby różnią się nie dlatego, że ktoś się pomylił, ale dlatego, że liczą różne rzeczy. Python i Node zgłaszają, gdzie parser się zatrzymał; jq zgłasza, gdzie zaczyna się token, którego nie udało się sparsować. Między oboma siedzi garść już zużytych znaków — w tej próbce 9.
2. Dlaczego „wiersz 1, kolumna N” w takim pliku to szum
Numery wierszy to wielkość pochodna, która zakłada „od ostatniego przejścia do nowego wiersza minęło trochę czasu”. Zminifikowany dokument ma jeden \n, zwykle na końcu, więc:
linenozawsze wynosi 1;colnodegeneruje się do „ile znaków od początku tego jednego wiersza”;- a ten wiersz ma szerokość czterech milionów znaków.
Przejdź do kolumny 23611 w edytorze i nic użytecznego nie zobaczysz. Gorzej: narzędzia takie jak less i vim pełzną na bardzo długich wierszach; przesuwanie kursora strzałkami spala dwadzieścia minut.
Odwróć to: przesunięcie jest wielkością natywną, a wiersz/kolumna są z niego obliczane. Używaj wielkości natywnej.
3. Podejście pierwsze: w Pythonie użyj e.pos i zignoruj lineno
JSONDecodeError niesie trzy liczby i tylko pos jest warte czytania. lineno i colno to wartości pochodne, które w jednowierszowym dokumencie zapadają się do niczego.
# 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')
Uruchom go na uszkodzonym pliku:
$ 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",
^
Daszek wskazuje miejsce, w którym brakuje przecinka. Dwadzieścia minut zmienia się w jedną komendę.
Jak szerokie powinno być okno: ±60 znaków prawie zawsze obejmuje pełną parę klucz/wartość. Zbyt wąskie i tracisz kontekst; zbyt szerokie i wynik zalewa ekran. Głęboko zagnieżdżone dokumenty mogą przejść do ±120.
4. Podejście drugie: brak Pythona w kontenerze — jq plus cut
Kontenery produkcyjne często nie mają Pythona, ale prawie zawsze mają jq albo zdobędą go jedną komendą apt-get install jq. To, co daje jq, to głośna porażka:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
Kod wyjścia 5 oznacza niepoprawne dane wejściowe, więc pod set -e przerywa skrypt tak samo, jak grep zwracający 1. To cecha.
jq nie ma flagi do wypisania problematycznych bajtów, więc przekaż pozycję do 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",
Gdy przecinek wróci, jq może robić to, w czym naprawdę jest dobry:
$ 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. Podejście trzecie: brak runtime albo dane, które nie mogą dotknąć dysku — wklej je do przeglądarki
Pierwsze dwa podejścia wymagają czegoś zainstalowanego i prawa do zapisywania plików. Jeśli jesteś na czystym laptopie albo payload jest pełen klienckich e-maili i adresów IP, a „wkleiłem to na jakąś stronę” to zdanie, którego nie chcesz mieć w raporcie z incydentu, pasuje trzecia opcja.
Wklej cały łańcuch do online parsera JSON, a on postawi daszek na problematycznym znaku i oznaczy wiersz oraz kolumnę; zaletą lokalnego narzędzia jest to, że te 4 MB nigdy nie opuszczają maszyny i nigdy nie trafiają do cudzych logów.
Dwie uwagi:
- Wybierz narzędzie, które wyraźnie mówi, że przetwarza wszystko w przeglądarce, nigdy takie, które POST-uje treść na backend;
- Wklejanie znosi bardzo duże pliki, ale powyżej kilkudziesięciu megabajtów pamięć zjada już sama karta — wtedy wróć do pierwszego podejścia.
6. Przesunięcie znaku to nie przesunięcie bajtu: druga pułapka w danych CJK
Ta pułapka gryzie oba podejścia przy mieszanych chińsko-angielskich payloadach.
JSONDecodeError.pos to przesunięcie znaku (liczba punktów kodowych Unicode), bo powstaje przy parsowaniu str. cut -c tnie po znakach i zgadza się z nim. Ale head -c, tail -c i dd tną po bajtach, a pod UTF-8 jedna pomyłka przesuwa wszystko.
Chińska próbka pokazuje to konkretnie (5 zamówień, 148 znaków, 222 bajty):
>>> 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","'
To samo przesunięcie: widok znakowy trafia na nazwę pola, widok bajtowy na koniec identyfikatora zamówienia. Pojedynczy znak CJK zajmuje 3 bajty w UTF-8, a dryf narasta wraz z pozycją.
Reguła: po odczytaniu e.pos tnij przez cut -c lub wycinek Pythona, nigdy przez head -c / dd bs=1 skip=N. Jeśli masz tylko narzędzia bajtowe, najpierw przekonwertuj: len(raw[:error.pos].encode('utf-8')).
7. Składnia naprawiona, dane wciąż złe: trzy ciche pułapki
Właśnie dlatego debugowanie nie może kończyć się na „to się sparsowało”. Każda z poniższych pułapek albo nigdy nie zgłasza błędu, albo zgłasza go długo po użyciu wartości.
Pułapka 1: zduplikowane klucze — wygrywa ostatni, nikt nie protestuje
RFC 8259 sekcja 4 jest jasna: nazwy w obrębie obiektu POWINNY być unikatowe, a gdy nie są, zachowanie jest nieprzewidywalne — większość implementacji zachowuje ostatnie wystąpienie, inne zgłaszają błąd. Wszystkie trzy mierzone tu narzędzia zachowują ostatnie:
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 jednego ostrzeżenia. Jeśli nadpisane pole to amount_cents, dowiesz się o tym, gdy uzgadnianie rozjedzie się o trzy centy.
Pułapka 2: NaN i Infinity — Python je akceptuje, Node nie
RFC 8259 sekcja 6 mówi wprost, że literały liczbowe nie zawierają NaN ani Infinity. json.loads w Pythonie akceptuje oba domyślnie, bo jego własny float ma takie wartości:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
Przekaż ten wynik Node'owi i dostaniesz:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
Innymi słowy: złe dane przechodzą przez Python i wybuchają u odbiorcy poniżej.
Pułapka 3: liczby całkowite ponad 2^53 cicho tracą precyzję
Jeden dokument, dwie różne wartości:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
Powód w jednej linii: Number.MAX_SAFE_INTEGER w JavaScript to 9007199254740991 (2^53-1). MDN wyraźnie zaznacza, że reviver działa po tym, jak wartość została już sparsowana do liczby JavaScript, więc precyzja ginie przed reviverem — nie da się jej odzyskać po stronie deserializacji. Jedyna naprawa to serializowanie dużych ID jako łańcuchów przez producenta.
Porównanie trzech pułapek:
| Pułapka | Czy coś zgłasza błąd? | Kto zauważy pierwszy | Jak przechwycić |
|---|---|---|---|
| Zduplikowane klucze | Nie | Uzgadnianie poniżej | Jawna kontrola przez object_pairs_hook |
| NaN / Infinity | Node zgłasza, Python milczy | Odbiorcy poniżej | Odrzucać na granicy przez parse_constant |
| Liczby całkowite ponad 2^53 | Nie | Kod sprawdzający równość | Przesyłać je jako łańcuchy |
Złap pierwsze dwie w jednym przebiegu
# 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')
Koszt, uczciwie: na tej samej próbce o 2 011 084 znakach wersja ścisła osiąga 35,2–36,2 ms wobec 9,4–9,9 ms dla łagodnej, około 3,7 razy wolniej (3,6–3,8 razy w pięciu seriach po pięć przebiegów, mediana w serii) — object_pairs_hook uruchamia Counter dla każdego obiektu. Trzymaj ją poza „szczęśliwą ścieżką” CI i przełączaj się na nią tylko wtedy, gdy dostawa wygląda podejrzanie.
8. Lista kontrolna
Po naprawieniu pliku kusi, by go sformatować dla czytelności, ale ta sama próbka ma 2 011 084 znaków zminifikowana (separators=(',',':')) i 2 869 100 znaków przy indent=2 — o 43% więcej. Ten ciężar wędruje do historii Git, cache CI i transferu sieciowego, więc trzymaj ładną wersję lokalnie i przed wydaniem ściśnij ją z powrotem do jednego wiersza.
- [ ] Najpierw szukaj
(char N)/position N; ignorujline 1 column N; - [ ] Python używa
e.pos, jq łącz zcut -c— to te same przesunięcia znaków, więc nigdy nie uzgadniajcolumnz jq z nimi; - [ ] W dokumentach CJK tnij po znakach (
cut -c, wycinki Pythona), nigdyhead -c/dd; - [ ] Skrypty w kontenerze powinny traktować kod wyjścia jq 5 jako „niepoprawne dane wejściowe”;
- [ ] Sprawdzaj podejrzane dane przez
object_pairs_hook+parse_constanti akceptuj koszt około 3,7 razy; - [ ] Każde ID ponad 2^53 przesyłaj jako łańcuch; nie oczekuj, że reviver je uratuje;
- [ ] Wynik z ładnym formatowaniem (zmierzone +43%) zostaje lokalnie: użyj /minify, by ściśnąć z powrotem do jednego wiersza przed wydaniem lub commitem; powyżej kilku MB przełącz się na strumieniowanie, omówione w Parsing, rendering and streaming large JSON.
FAQ
Dlaczego wszystkie trzy narzędzia podają inną pozycję?
Bo liczą różne rzeczy. char N w Pythonie i position N w Node znaczą „gdzie parser się zatrzymał” — na tej próbce oba wynoszą 23610, więc się zgadzają. jq podaje, gdzie zaczyna się problematyczny token, tu 23619, dziewięć dalej. Nikt się nie pomylił i żadna poprawka nie jest potrzebna; porównuj liczby tylko w obrębie jednego narzędzia.
Czy używać cut -c, czy head -c?
Używaj cut -c. JSONDecodeError.pos to przesunięcie liczone w znakach Unicode, a cut -c tnie po znakach, więc oba się zgadzają. head -c, tail -c i dd tną po bajtach, co dryfuje w dokumentach UTF-8 zawierających tekst CJK i może wręcz przeciąć znak na pół. Na chińskiej próbce to samo przesunięcie daje 城市":"杭州"}, w widoku znakowym i DD00000"," w widoku bajtowym.
Czy obsługa zduplikowanych kluczy naprawdę wszędzie jest niechroniona?
Tak. RFC 8259 sekcja 4 mówi jedynie, że nazwy POWINNY być unikatowe, i stwierdza, że zachowanie jest nieprzewidywalne, gdy nie są. Python, Node i jq — wszystkie tu mierzone — stosują ostatni-wygrywa, nie emitując żadnego ostrzeżenia. Złapanie tego wymaga własnego object_pairs_hook, kosztem około 3,7 razy dłuższego parsowania.
Dlaczego Python czyta NaN bez problemu, a Node rzuca błąd?
RFC 8259 sekcja 6 zabrania NaN i Infinity jako literałów liczbowych. json.loads w Pythonie akceptuje je domyślnie, bo własny float Pythona ma takie wartości, więc złe dane przechodzą przez Python i od razu są zapisywane z powrotem. Node'owy JSON.parse ściśle trzyma się specyfikacji i rzuca SyntaxError na takim wyniku. Dodanie parse_constant na wejściu odrzuca je natychmiast.
Czy 20-cyfrowe numery zamówień zawsze tracą precyzję w JSON?
Tylko gdy odbiorca parsuje je do double IEEE 754. int w Pythonie ma dowolną precyzję i zachowuje 12345678901234567890; Number.MAX_SAFE_INTEGER w JavaScript to 9007199254740991, więc to samo pole staje się 12345678901234567000. MDN wyraźnie zauważa, że reviver działa po parsowaniu, więc poniżej nie da się nic odzyskać — jedyna niezawodna naprawa to emitowanie łańcuchów przez producenta.