Minified JSON को डिबग करना: पंक्ति 1 स्तंभ 23611 से ठीक टूटे बाइट तक
मुख्य बात: 4 MB की एक-पंक्ति JSON फ़ाइल पंक्ति 1 स्तंभ 23611 बताती है, जो कुछ नहीं बताता। स्थानीय रूप से मापे गए नमूनों से यह मार्गदर्शिका समझाती है कि Python, Node और jq तीन अलग संख्याएँ क्यों देते हैं, एक अक्षर offset आपको सीधे टूटे बाइट तक कैसे ले जाता है, और व्याकरण ठीक होने के बाद कौन से तीन जाल चुपचाप डेटा बिगाड़ते हैं।

सार: पंक्ति और स्तंभ संख्याएँ केवल बहु-पंक्ति दस्तावेज़ों में अर्थ रखती हैं। JSON minify होकर एक पंक्ति में आ जाए, तो हर त्रुटि पंक्ति 1 पर ही रहती है — आपको पंक्ति नहीं, अक्षर सूचकांक (character index) चाहिए।
Minified JSON दस्तावेज़ की ठीक एक पंक्ति होती है, इसलिए त्रुटि संदेश की पंक्ति/स्तंभ जोड़ी चालीस लाख बाइट के स्थान को एक बेकार वाक्यांश में निचोड़ देती है। उपाय यह है कि पंक्ति/स्तंभ पूरी तरह छोड़कर अक्षर offset पढ़ें — संदेश के अंत में छिपा विनम्र
(char 23610)ही वह संख्या है जो वास्तव में आपको बचाती है।
नीचे की हर संख्या स्थानीय रूप से उत्पन्न की गई है। नमूना एक-पंक्ति दस्तावेज़ है जिसमें 13,000 बिलिंग इवेंट हैं, कुल 2,011,084 अक्षर, और ईवेंट #154 के event_id के बाद एक comma जानबूझकर हटा दिया गया है। वातावरण Python 3.11.1, Node 22.13.1 और jq 1.7 है।
1. पहले तथ्य: तीन parser, तीन संख्याएँ
एक ही टूटी फ़ाइल, तीन टूल को दी गई:
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
आमने-सामने:
| टूल | बताया गया मान | इकाई | कहाँ से गिना | पंक्ति/स्तंभ क्यों विफल है |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | अक्षर offset | बफ़र की शुरुआत | केवल यही भरोसेमंद है |
Python lineno / colno |
1 / 23611 | पंक्ति और स्तंभ | पंक्ति की शुरुआत | केवल एक पंक्ति, इसलिए कुछ नहीं बताता |
Node position |
23610 | अक्षर offset | बफ़र की शुरुआत | भरोसेमंद, हालाँकि संदेश फ़ील्ड छिपा देता है |
jq column |
23619 | अक्षर offset | दोषपूर्ण token की शुरुआत | Python से 9 अलग |
सीख स्पष्ट है: Python का char N और Node का position N एक ही संख्या हैं और मेल खाते हैं। jq अपनी अलग योजना इस्तेमाल करता है — उसे मिलाने में समय न गँवाएँ।
तीनों संख्याएँ इसलिए नहीं अलग हैं कि किसी ने गणना गलत की, बल्कि इसलिए कि वे अलग चीज़ें गिनते हैं। Python और Node बताते हैं कि parser कहाँ रुका; jq बताता है कि जिस token को पार्स करने में वह विफल हुआ, वह कहाँ से शुरू होता है। दोनों के बीच पहले से खपत किए कुछ अक्षर होते हैं — इस नमूने में 9।
2. इस तरह की फ़ाइल पर पंक्ति 1 स्तंभ N शोर क्यों है
पंक्ति संख्या एक व्युत्पन्न राशि है जो मान लेती है "आखिरी newline से काफ़ी समय बीत चुका है"। Minified दस्तावेज़ में एक ही \n होता है, आमतौर पर अंत में, इसलिए:
linenoहमेशा 1 है;colno"उस एक पंक्ति की शुरुआत से कितने अक्षर आगे" में बदल जाता है;- और वह पंक्ति चालीस लाख अक्षर चौड़ी है।
संपादक में स्तंभ 23611 पर कूदें और आपको कुछ उपयोगी नहीं दिखेगा। इससे बुरा, less और vim जैसे टूल बहुत लंबी पंक्तियों पर रेंगते हैं; तीर कुंजियों से कर्सर आगे खिसकाने में बीस मिनट जल जाते हैं।
उल्टा सोचें: offset मूल राशि है, और पंक्ति/स्तंभ उससे गणना करके बनाए जाते हैं। मूल राशि इस्तेमाल करें।
3. तरीका एक: Python में e.pos इस्तेमाल करें और lineno छोड़ दें
JSONDecodeError तीन संख्याएँ लिए होता है, और पढ़ने योग्य केवल pos है। lineno और colno व्युत्पन्न मान हैं जो एक-पंक्ति दस्तावेज़ पर शून्य हो जाते हैं।
# 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')
टूटी फ़ाइल पर इसे चलाएँ:
$ 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",
^
Caret उस जगह को चिह्नित करता है जहाँ comma गायब है। बीस मिनट एक कमांड बन जाते हैं।
विंडो कितनी चौड़ी हो: ±60 अक्षर लगभग हमेशा एक पूरा key/value जोड़ा समेट लेते हैं। बहुत संकीर्ण रखें तो संदर्भ खो जाता है; बहुत चौड़ा रखें तो आउटपुट स्क्रीन भर देता है। गहरे nesting वाले दस्तावेज़ों में ±120 तक जा सकते हैं।
4. तरीका दो: कंटेनर में Python नहीं — jq और cut इस्तेमाल करें
उत्पादन कंटेनर अक्सर Python के बिना आते हैं, पर उनमें लगभग हमेशा jq होता है, या एक apt-get install jq से आ जाता है। jq जो देता है वह है ज़ोरदार विफलता:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
Exit code 5 का अर्थ है अवैध इनपुट, इसलिए set -e के अंतर्गत यह स्क्रिप्ट को उसी तरह रोक देता है जैसे grep का 1 लौटाना करता है। यह एक फ़ायदा है।
jq के पास दोषपूर्ण बाइट छापने का कोई flag नहीं है, इसलिए स्थान 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",
Comma वापस आते ही jq वह कर सकता है जिसमें वह वाकई अच्छा है:
$ 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. तरीका तीन: कोई runtime नहीं, या ऐसा डेटा जिसे डिस्क छूना नहीं — ब्राउज़र में चिपकाएँ
पहले दो तरीकों के लिए कुछ इंस्टॉल होना और फ़ाइलें लिखने की अनुमति चाहिए। यदि आप खाली लैपटॉप पर हैं, या payload ग्राहक के ईमेल और IP पतों से भरा है और "मैंने उसे किसी वेबसाइट में चिपका दिया" ऐसा वाक्य है जो आप घटना रिपोर्ट में नहीं चाहते, तो तीसरा विकल्प ठीक बैठता है।
पूरी स्ट्रिंग को ऑनलाइन JSON parser में चिपकाएँ और वह caret दोषपूर्ण अक्षर पर रखकर पंक्ति व स्तंभ बताता है; स्थानीय टूल का फ़ायदा यह है कि वे 4 MB कभी मशीन से बाहर नहीं जाते और किसी और के लॉग में नहीं पड़ते।
दो सावधानियाँ:
- ऐसा टूल चुनें जो स्पष्ट रूप से कहे कि वह सब कुछ ब्राउज़र में संसाधित करता है, वह कभी नहीं जो आपकी सामग्री किसी backend को POST करे;
- चिपकाना बहुत बड़ी फ़ाइलों को सह लेता है, पर कुछ दसियों MB के बाद टैब स्वयं स्मृति खाने लगता है — उस बिंदु पर तरीका एक पर लौटें।
6. अक्षर offset, बाइट offset नहीं: CJK डेटा के साथ दूसरा जाल
यह चीनी/अंग्रेज़ी मिश्रित payload पर दोनों तरीकों को काटता है।
JSONDecodeError.pos एक अक्षर offset है (Unicode code point की गिनती), क्योंकि यह str पार्स करने से आता है। cut -c अक्षर से काटता है और उससे मेल खाता है। पर head -c, tail -c और dd बाइट से काटते हैं, और UTF-8 में एक फिसलन सब कुछ बिगाड़ देती है।
चीनी नमूना इसे मूर्त बनाता है (5 ऑर्डर, 148 अक्षर, 222 बाइट):
>>> 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: अक्षर दृश्य फ़ील्ड नाम पर पहुँचता है, बाइट दृश्य ऑर्डर id की पूँछ पर। UTF-8 में एक CJK अक्षर 3 बाइट लेता है, और विचलन स्थान के साथ जुड़ता जाता है।
नियम: e.pos पढ़ने के बाद cut -c या Python slice से काटें, कभी head -c / dd bs=1 skip=N से नहीं। यदि आपके पास केवल बाइट टूल हैं, तो पहले बदलें: len(raw[:error.pos].encode('utf-8'))।
7. व्याकरण ठीक, डेटा फिर भी गलत: तीन चुपचाप जाल
यही कारण है कि डिबगिंग "पार्स हो गया" पर रुक नहीं सकती। नीचे की हर बात या तो कभी त्रुटि नहीं देती, या मान इस्तेमाल होने के बहुत बाद देती है।
जाल 1: दोहराई गई key — अंतिम जीतता है, कोई शिकायत नहीं
RFC 8259 की धारा 4 स्पष्ट है: object के भीतर नाम अद्वितीय होने चाहिए (SHOULD), और जब वे न हों तो व्यवहार अनिश्चित है — अधिकांश implementation अंतिम बार आने वाला रखते हैं, कुछ त्रुटि देते हैं। यहाँ मापे गए तीनों टूल अंतिम रखते हैं:
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}
एक भी चेतावनी नहीं। यदि ढका गया फ़ील्ड amount_cents था, तो आपको तब पता चलता है जब reconciliation तीन सेंट से बेमेल निकलता है।
जाल 2: NaN और Infinity — Python स्वीकार करता है, Node नहीं
RFC 8259 की धारा 6 स्पष्ट कहती है कि संख्या शाब्दिक रूपों में NaN और Infinity नहीं होते। Python का json.loads दोनों को डिफ़ॉल्ट रूप से स्वीकार करता है, क्योंकि उसके अपने float में ये मान होते हैं:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
वह आउटपुट Node को दें और मिलता है:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
दूसरे शब्दों में: गंदा डेटा Python से निकल जाता है और downstream उपभोक्ता पर फटता है।
जाल 3: 2^53 से बड़े पूर्णांक चुपचाप परिशुद्धता खोते हैं
एक दस्तावेज़, दो अलग मान:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
कारण एक पंक्ति में: JavaScript का Number.MAX_SAFE_INTEGER है 9007199254740991 (2^53-1)। MDN विशेष रूप से बताता है कि reviver मान के पहले ही JavaScript number में पार्स हो जाने के बाद चलता है, इसलिए परिशुद्धता reviver से पहले खो जाती है — डिसेरिएलाइज़िंग पक्ष पर उसे वापस नहीं पाया जा सकता। एकमात्र उपाय यह है कि उत्पादक बड़े ID को स्ट्रिंग के रूप में serialize करे।
तीनों जालों की तुलना:
| जाल | क्या कोई त्रुटि आती है? | सबसे पहले कौन देखता है | कैसे रोकें |
|---|---|---|---|
| दोहराई गई key | नहीं | Downstream reconciliation | object_pairs_hook से स्पष्ट जाँच |
| NaN / Infinity | Node त्रुटि देता है, Python चुप | Downstream उपभोक्ता | किनारे पर parse_constant से अस्वीकार करें |
| 2^53 से बड़े पूर्णांक | नहीं | Equality जाँच करने वाला कोड | उन्हें स्ट्रिंग के रूप में भेजें |
पहले दो को एक ही पास में पकड़ें
# 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')
लागत सच-सच बताएँ: उसी 2,011,084-अक्षर नमूने पर यह सख्त संस्करण 35.2-36.2 ms पर आता है, जबकि ढीला संस्करण 9.4-9.9 ms पर, लगभग 3.7 गुना धीमा (पाँच-पाँच रन के पाँच दौरों में 3.6 से 3.8 गुना, प्रति दौर माध्यिका) — object_pairs_hook हर object पर Counter चलाता है। इसे CI के सामान्य पथ से बाहर रखें और तभी अपनाएँ जब कोई डिलीवरी संदिग्ध लगे।
8. चेकलिस्ट
फ़ाइल ठीक करने के बाद उसे पठनीयता के लिए pretty-print करने का मन करता है, पर वही नमूना minified रूप में 2,011,084 अक्षर (separators=(',',':')) और indent=2 पर 2,869,100 अक्षर मापा जाता है — 43% अधिक। यह वज़न Git इतिहास, CI कैश और नेटवर्क ट्रांसफ़र में जाता है, इसलिए pretty संस्करण स्थानीय रखें और भेजने से पहले उसे वापस एक पंक्ति में दबाएँ।
- [ ] पहले
(char N)/position Nदेखें;line 1 column Nछोड़ें; - [ ] Python
e.posइस्तेमाल करता है, jqcut -cके साथ जोड़ी बनाता है — वही अक्षर offset, इसलिए jq केcolumnको उनसे कभी न मिलाएँ; - [ ] CJK दस्तावेज़ों के लिए अक्षर से काटें (
cut -c, Python slices), कभीhead -c/ddनहीं; - [ ] कंटेनर स्क्रिप्ट jq के exit code 5 को "इनपुट अवैध" मानें;
- [ ] संदिग्ध डेटा को
object_pairs_hook+parse_constantसे दोबारा जाँचें और लगभग 3.7 गुना लागत स्वीकार करें; - [ ] 2^53 से ऊपर कोई भी ID स्ट्रिंग के रूप में भेजें; reviver से उसे बचाने की उम्मीद न रखें;
- [ ] Pretty-printed आउटपुट (मापा गया +43%) स्थानीय रहे: भेजने या commit करने से पहले एक पंक्ति में दबाने के लिए /minify इस्तेमाल करें; कुछ MB से ऊपर स्ट्रीमिंग अपनाएँ, जिसे बड़े JSON का पार्सिंग, रेंडरिंग और स्ट्रीमिंग में समझाया गया है।
अक्सर पूछे जाने वाले प्रश्न
तीनों टूल अलग-अलग स्थान क्यों बताते हैं?
क्योंकि वे अलग चीज़ें गिनते हैं। Python का char N और Node का position N दोनों का अर्थ है "parser कहाँ रुका" — इस नमूने पर दोनों 23610 हैं, इसलिए मेल खाते हैं। jq बताता है कि दोषपूर्ण token कहाँ से शुरू होता है, यहाँ 23619, नौ आगे। किसी ने गणना गलत नहीं की और कोई सुधार ज़रूरी नहीं; संख्याओं की तुलना केवल एक ही टूल के भीतर करें।
cut -c इस्तेमाल करूँ या head -c?
cut -c इस्तेमाल करें। JSONDecodeError.pos Unicode अक्षरों में गिना गया offset है, और cut -c अक्षर से काटता है, इसलिए दोनों सहमत हैं। head -c, tail -c और dd बाइट से काटते हैं, जो CJK पाठ वाले UTF-8 दस्तावेज़ों पर खिसक जाता है और किसी अक्षर को आधा भी काट सकता है। चीनी नमूने पर वही offset अक्षर दृश्य से 城市":"杭州"}, और बाइट दृश्य से DD00000"," देता है।
क्या दोहराई गई key का व्यवहार वाकई हर जगह असंरक्षित है?
है। RFC 8259 की धारा 4 केवल कहती है कि नाम अद्वितीय होने चाहिए (SHOULD) और बताती है कि जब वे न हों तो व्यवहार अनिश्चित है। यहाँ मापे गए Python, Node और jq तीनों बिना कोई चेतावनी दिए last-one-wins लागू करते हैं। इसे पकड़ने के लिए अपना object_pairs_hook चाहिए, जिसकी कीमत लगभग 3.7 गुना पार्स समय है।
Python NaN को ठीक पढ़ लेता है जबकि Node त्रुटि क्यों देता है?
RFC 8259 की धारा 6 संख्या शाब्दिक रूपों के रूप में NaN और Infinity मना करती है। Python का json.loads उन्हें डिफ़ॉल्ट रूप से स्वीकार करता है क्योंकि Python के अपने float में ये मान हैं, इसलिए गंदा डेटा Python से निकलकर सीधे वापस लिखा जाता है। Node का JSON.parse विनिर्देश का सख्ती से पालन करता है और उस आउटपुट पर SyntaxError फेंकता है। प्रवेश बिंदु पर parse_constant जोड़ने से यह तुरंत अस्वीकार हो जाता है।
20-अंकीय ऑर्डर संख्याएँ JSON से गुज़रते समय हमेशा परिशुद्धता खोती हैं?
केवल तब जब उपभोक्ता उन्हें IEEE 754 double में पार्स करे। Python का int मनमानी परिशुद्धता का है और 12345678901234567890 रखता है; JavaScript का Number.MAX_SAFE_INTEGER 9007199254740991 है, इसलिए वही फ़ील्ड 12345678901234567000 बन जाता है। MDN स्पष्ट नोट करता है कि reviver पार्सिंग के बाद चलता है, इसलिए downstream कुछ भी वापस नहीं पाया जा सकता — एकमात्र भरोसेमंद उपाय यह है कि उत्पादक स्ट्रिंग उत्पन्न करे।