اشکالزدایی JSON فشرده: از line 1 column 23611 تا بایت شکسته
جمعبندی: یک فایل JSON 4 MB تکخطی «line 1 column 23611» را گزارش میکند که هیچ چیزی نمیگوید. این راهنما با نمونههای اندازهگیریشده محلی توضیح میدهد چرا Python، Node و jq سه عدد متفاوت گزارش میکنند، افست کاراکتر چگونه مستقیم به بایت شکسته میرسد و کدام سه دام پس از درست شدن نحو، داده را بیصدا خراب میکنند.

جمعبندی: شماره خط و ستون فقط در سندهای چندخطی معنا دارد. وقتی JSON به یک خط فشرده میشود، هر خطا در line 1 زندگی میکند — عددی که نیاز دارید خط نیست، اندیس کاراکتر است.
یک سند JSON فشرده دقیقاً یک خط دارد، پس جفت خط/ستون در پیام خطا موقعیتی چهار میلیون بایتی را به یک عبارت بیفایده تبدیل میکند. راه حل این است که خط/ستون را کامل کنار بگذارید و بهجای آن افست کاراکتر را بخوانید — همان
(char 23610)فروتن در انتهای پیام، عددی است که واقعاً نجاتتان میدهد.
هر عددی که در ادامه میآید بهصورت محلی تولید شده است. نمونه سندی تکخطی با 13,000 رویداد صورتحساب، در مجموع 2,011,084 کاراکتر است که یک ویرگول بهعمد پس از event_id رویداد #154 حذف شده. محیط Python 3.11.1، Node 22.13.1 و jq 1.7 است.
۱. نخست واقعیتها: سه تجزیهکننده، سه عدد
همان فایل شکسته به سه ابزار داده میشود:
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 | افست کاراکتر | آغاز بافر | تنها چیزی که میتوان به آن اعتماد کرد |
Python lineno / colno |
1 / 23611 | خط و ستون | آغاز خط | فقط یک خط، پس هیچ نمیگوید |
Node position |
23610 | افست کاراکتر | آغاز بافر | قابل اعتماد، هرچند پیام فیلد را پنهان میکند |
jq column |
23619 | افست کاراکتر | آغاز توکن خطادار | با Python به اندازه 9 تفاوت دارد |
نتیجه صریح است: char N در Python و position N در Node همان عدد هستند و بر هم منطبقاند. jq طرح خودش را دارد — برای همتراز کردنش وقت تلف نکنید.
این سه عدد متفاوتاند نه چون کسی اشتباه محاسبه کرده، بلکه چون چیزهای متفاوتی میشمارند. Python و Node گزارش میکنند تجزیهکننده کجا ایستاده؛ jq گزارش میکند توکنی که نتوانسته تجزیه کند از کجا آغاز میشود. بین این دو چند کاراکتر مصرفشده نشسته است — در این نمونه 9.
۲. چرا line 1 column N روی این نوع فایل نویز است
شماره خط یک کمیت مشتق است که فرض میکند «از آخرین خط جدید مدتها گذشته». سند فشرده یک \n دارد، معمولاً در انتها، پس:
linenoهمیشه 1 است؛colnoبه «چند کاراکتر پس از آغاز همان یک خط» تنزل مییابد؛- و آن خط چهار میلیون کاراکتر عرض دارد.
در ویرایشگر به ستون 23611 بپرید و چیز مفیدی نمیبینید. بدتر آنکه ابزارهایی مثل less و vim روی خطهای بسیار بلند میخزند؛ جابهجایی مکاننما با کلیدهای جهت بیست دقیقه را میسوزاند.
برعکسش را ببینید: افست کمیت بومی است و خط/ستون از آن محاسبه میشود. از کمیت بومی استفاده کنید.
۳. روش یک: در 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) جایی را نشان میدهد که ویرگول کم است. بیست دقیقه به یک فرمان تبدیل میشود.
پنجره چقدر باید پهن باشد: ±60 کاراکتر تقریباً همیشه یک جفت کلید/مقدار کامل را پوشش میدهد. خیلی باریک شود زمینه را از دست میدهید؛ خیلی پهن شود خروجی صفحه را پر میکند. سندهای خیلی تودرتو میتوانند تا ±120 بروند.
۴. روش دو: 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
کد خروج 5 یعنی ورودی نامعتبر، پس زیر set -e اسکریپت را همانگونه که grep با بازگرداندن 1 متوقف میکند، متوقف میکند. این یک ویژگی است.
jq هیچ فلگی برای چاپ بایتهای خطادار ندارد، پس موقعیت را به 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",
وقتی ویرگول برگشت، 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
۵. روش سه: محیط اجرا نیست، یا داده نباید روی دیسک نوشته شود — آن را در مرورگر بچسبانید
دو روش نخست به چیزی نصبشده و اجازه نوشتن فایل نیاز دارند. اگر روی لپتاپی خالی هستید، یا بار داده پر از ایمیل و نشانی IP مشتریان است و «آن را در یک وبسایت چسباندم» جملهای است که در گزارش حادثه نمیخواهید، گزینه سوم مناسب است.
کل رشته را در یک تجزیهکننده آنلاین JSON بچسبانید؛ کارت را روی کاراکتر خطادار میگذارد و خط و ستون را برچسب میزند. مزیت ابزار محلی این است که آن 4 MB هرگز ماشین را ترک نمیکند و در لاگ کس دیگری فرود نمیآید.
دو هشدار:
- ابزاری را انتخاب کنید که صریحاً بگوید همه چیز را در مرورگر پردازش میکند، هرگز ابزاری که محتوای شما را به بکاند POST میکند؛
- چسباندن فایلهای بسیار بزرگ را تحمل میکند، اما پس از چند ده مگابایت خود تب حافظه میخورد — آن وقت به روش یک برگردید.
۶. افست کاراکتر با افست بایت یکی نیست: دام دوم با داده CJK
این دام روی بارهای داده مختلط چینی/انگلیسی هر دو روش را میگزد.
JSONDecodeError.pos یک افست کاراکتر است (شمارش کدپوینتهای Unicode)، چون از تجزیه یک 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","'
همان افست: نمای کاراکتری روی یک نام فیلد فرود میآید، نمای بایتی روی دنباله یک شناسه سفارش. یک کاراکتر CJK در UTF-8 سه بایت اشغال میکند و رانش با موقعیت انباشته میشود.
قاعده: پس از خواندن e.pos، با cut -c یا برش Python ببرید، هرگز با head -c / dd bs=1 skip=N. اگر فقط ابزار بایتی دارید، نخست تبدیل کنید: len(raw[:error.pos].encode('utf-8')).
۷. نحو درست شد، داده هنوز غلط است: سه دام بیصدا
دقیقاً همین دلیل است که اشکالزدایی نمیتواند روی «تجزیه شد» متوقف شود. هر یک از موارد زیر یا هرگز خطا نمیدهد، یا خیلی پس از استفاده از مقدار خطا میدهد.
دام ۱: کلیدهای تکراری — آخرین برنده است، هیچکس شکایت نمیکند
بند 4 RFC 8259 روشن است: نامها درون یک object باید یکتا باشند (SHOULD) و وقتی نیستند رفتار پیشبینیناپذیر است — بیشتر پیادهسازیها آخرین تکرار را نگه میدارند، برخی خطا میدهند. هر سه ابزار اندازهگیریشده اینجا آخرین را نگه میدارند:
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 بود، وقتی مطابقتسازی سه سنت اختلاف پیدا کند میفهمید.
دام ۲: NaN و Infinity — Python میپذیرد، Node نه
بند 6 RFC 8259 صریح میگوید که لیترالهای عددی NaN و Infinity ندارند. 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 عبور میکند و نزد مصرفکننده پاییندست منفجر میشود.
دام ۳: عددهای صحیح بزرگتر از 2^53 بیصدا دقت را از دست میدهند
یک سند، دو مقدار متفاوت:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
دلیل در یک خط: Number.MAX_SAFE_INTEGER در JavaScript برابر 9007199254740991 است (2^53-1). MDN بهویژه یادآوری میکند که reviver پس از آنکه مقدار به یک number جاوااسکریپت تجزیه شد اجرا میشود، پس دقت پیش از reviver از دست میرود — در سمت دیسریالسازی بازیابی نمیشود. تنها راه، آن است که تولیدکننده شناسههای بزرگ را بهصورت رشته سریال کند.
سه دام در مقایسه:
| دام | آیا چیزی خطا میدهد؟ | چه کسی اول متوجه میشود | چگونه مهار کنیم |
|---|---|---|---|
| کلیدهای تکراری | نه | مطابقتسازی پاییندست | بررسی صریح با object_pairs_hook |
| NaN / Infinity | Node خطا میدهد، Python ساکت | مصرفکنندههای پاییندست | رد کردن در مرز با parse_constant |
| عددهای صحیح بالاتر از 2^53 | نه | کدی که مقایسه تساوی میکند | بهصورت رشته منتقلشان کنید |
دو مورد اول را در یک پاس بگیرید
# 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.6x تا 3.8x در پنج دور پنج اجرایی، میانه هر دور) — چون object_pairs_hook برای هر object یک Counter اجرا میکند. آن را از مسیر خوش CI بیرون نگه دارید و فقط وقتی تحویلی مشکوک به نظر میرسد به آن سوئیچ کنید.
۸. چکلیست
پس از درست کردن فایل وسوسه میشوید برای خوانایی آن را pretty-print کنید، اما همان نمونه 2,011,084 کاراکتر در حالت فشرده (separators=(',',':')) و 2,869,100 کاراکتر با indent=2 اندازه میگیرد — 43% بیشتر. این وزن به تاریخچه Git، کش CI و انتقال شبکه میرود، پس نسخه خوانا را محلی نگه دارید و پیش از انتشار دوباره به یک خط فشردهاش کنید.
- [ ] نخست
(char N)/position Nرا بجویید؛line 1 column Nرا نادیده بگیرید؛ - [ ] Python از
e.posاستفاده میکند، jq باcut -cجفت میشود — همان افست کاراکتر، پس هرگزcolumnدر jq را با آنها همتراز نکنید؛ - [ ] برای سندهای CJK بر اساس کاراکتر ببرید (
cut -c، برش Python)، هرگزhead -c/dd؛ - [ ] اسکریپتهای کانتینر کد خروج 5 در jq را «ورودی نامعتبر» بدانند؛
- [ ] داده مشکوک را با
object_pairs_hook+parse_constantدوباره بررسی کنید و هزینه حدود 3.7 برابری را بپذیرید؛ - [ ] هر شناسه بالای 2^53 را بهصورت رشته بفرستید؛ انتظار نداشته باشید reviver نجاتش دهد؛
- [ ] خروجی pretty-print (اندازهگیری +43%) محلی میماند: پیش از انتشار یا کامیت با /minify دوباره به یک خط فشرده کنید؛ بالای چند MB بهجای آن به جریانسازی بروید که در تجزیه، رندر و جریانسازی JSON بزرگ آمده است.
پرسشهای متداول
چرا هر سه ابزار موقعیت متفاوتی گزارش میکنند؟
چون چیزهای متفاوتی میشمارند. char N در Python و position N در Node هر دو یعنی «جایی که تجزیهکننده ایستاد» — در این نمونه هر دو 23610 هستند، پس منطبقاند. jq گزارش میکند توکن خطادار کجا آغاز میشود، اینجا 23619، نه واحد جلوتر. کسی اشتباه محاسبه نکرده و اصلاحی لازم نیست؛ فقط عددها را درون یک ابزار مقایسه کنید.
از cut -c استفاده کنم یا head -c؟
از cut -c استفاده کنید. JSONDecodeError.pos افستی است که با کاراکترهای Unicode شمرده میشود و cut -c بر اساس کاراکتر میبرد، پس این دو همراهاند. head -c، tail -c و dd بر اساس بایت میبرند که روی سندهای UTF-8 دارای متن CJK رانش میکند و حتی میتواند کاراکتری را نصف کند. روی نمونه چینی، همان افست از نمای کاراکتری 城市":"杭州"}, و از نمای بایتی DD00000"," به دست میآید.
آیا رفتار کلید تکراری واقعاً همهجا بیمحافظ است؟
هست. بند 4 RFC 8259 فقط میگوید نامها SHOULD یکتا باشند و رفتار وقتی نیستند پیشبینیناپذیر است. Python، Node و jq — همه اندازهگیریشده در اینجا — سیاست «آخرین برنده» را بدون هیچ هشدار اعمال میکنند. گرفتنش به object_pairs_hook خودتان نیاز دارد، با هزینه تقریباً 3.7 برابر زمان تجزیه.
چرا Python مقدار NaN را بیمشکل میخواند و Node خطا میدهد؟
بند 6 RFC 8259 NaN و Infinity را بهعنوان لیترال عددی ممنوع میکند. json.loads پایتون آنها را بهطور پیشفرض میپذیرد چون float خود پایتون این مقادیر را دارد، پس داده بد از Python عبور میکند و مستقیم دوباره نوشته میشود. JSON.parse در Node سختگیرانه از مشخصات پیروی میکند و روی آن خروجی SyntaxError میدهد. افزودن parse_constant در نقطه ورود بیدرنگ ردش میکند.
آیا شماره سفارش 20 رقمی همیشه از JSON دقت را از دست میدهد؟
فقط وقتی مصرفکننده آن را به double در IEEE 754 تجزیه کند. int در Python دقت دلخواه دارد و 12345678901234567890 را نگه میدارد؛ Number.MAX_SAFE_INTEGER در JavaScript برابر 9007199254740991 است، پس همان فیلد 12345678901234567000 میشود. MDN صریحاً میگوید reviver پس از تجزیه اجرا میشود، پس پاییندست هیچ چیزی بازیابی نمیشود — تنها رفع قابل اعتماد این است که تولیدکننده رشته بدهد.