JSON Toolkit

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.

Zaawansowany~7 min czytaniaOpublikowano 2026-11-01Zaktualizowano 2026-11-01
Okładka artykułu o lokalizowaniu błędów w zminifikowanym JSON

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:

  • lineno zawsze wynosi 1;
  • colno degeneruje 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:

  1. Wybierz narzędzie, które wyraźnie mówi, że przetwarza wszystko w przeglądarce, nigdy takie, które POST-uje treść na backend;
  2. 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; ignoruj line 1 column N;
  • [ ] Python używa e.pos, jq łącz z cut -c — to te same przesunięcia znaków, więc nigdy nie uzgadniaj column z jq z nimi;
  • [ ] W dokumentach CJK tnij po znakach (cut -c, wycinki Pythona), nigdy head -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_constant i 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.