فاز 19 · درس 22

پیاده‌سازی پروتکل JSON-RPC 2.0 روی ورودی و خروجی استاندارد

نوع
ساختنی
زمان
حدود 90 دقیقه
زبان‌ها
Python
یک نقاله صنعتی فلزی با قطعات مجزای بسته‌بندی شده که روی خطوط تمیز با فواصل منظم در حال حرکت هستند
فهرست مطالب
  1. اهداف یادگیری
  2. چرا JSON-RPC همچنان زبان مشترک سیستم‌هاست؟
  3. ساختار داده روی خط ارتباطی
  4. پنج کد خطای استاندارد
  5. فریم‌بندی با خط جدید و دموی BytesIO
  6. هدایت متدها (Method Dispatch)
  7. رفتار جریان داده هنگام مواجهه با خطا
  8. اعلان‌ها و جریان‌های کاری نامتقارن
  9. راهنمای مطالعه‌ی کد
  10. برای مطالعه‌ی بیشتر

در ساخت ایجنت‌های مدرن، ارتباط پایدار میان مدل زبانی و ابزارهای محلی نقشی حیاتی دارد. استاندارد JSON-RPC 2.0 سال‌هاست که زبان مشترک اجرای رویه‌های راه دور و بسترهای انتقال محلی محسوب می‌شود. زمانی که ابزارها را در قالب فرایندهای مجزا اجرا می‌کنید، استفاده از ورودی و خروجی استاندارد (stdio) ساده‌ترین و در عین حال تمیزترین راهکار برای ایجاد این پل ارتباطی است.

اگر لایه‌ی انتقال و فریم‌بندی پیام‌ها به‌درستی پیاده‌سازی نشود، بروز یک خطای ساده در داده‌ی JSON ورودی می‌تواند کل ارتباط را قطع کند یا به بن‌بست‌های اجرایی منجر شود. در این درس یاد می‌گیرید که چگونه یک بستر انتقال مبتنی بر خط جدید (newline-delimited) بسازید که انواع پیام‌ها، اعلان‌ها و خطاهای استاندارد را بدون آلوده کردن ادامه‌ی جریان داده مدیریت کند.

بستر انتقال میان کلاینتِ مدل و سرور ابزار، پروتکل JSON-RPC روی ورودی و خروجی استاندارد (stdio) است. اگر یک بار خودتان آن را پیاده‌سازی کنید، متوجه می‌شوید لایه‌های فریم‌بندی داده دقیقاً چه هزینه‌ای به همراه دارند.

اهداف یادگیری

  • کار با پروتکل JSON-RPC 2.0 در قالب JSON خط‌به‌خط (newline-delimited) روی ورودی استاندارد (stdin) و خروجی استاندارد (stdout).
  • نگاشت پنج کد خطای استاندارد (32700-، 32600-، 32601-، 32602- و 32603-) و بازگرداندن آن‌ها با بار معنایی درست.
  • تفکیک درخواست‌ها، پاسخ‌ها، اعلان‌ها (notifications) و دسته‌ها (batches) بدون افزودن کلیدهای جدید به ساختار پیام.
  • مدیریت یک خطای تجزیه در هر خط بدون آلوده کردن ادامه‌ی جریان داده (stream).
  • ساخت یک دموی خودپایان با استفاده از io.BytesIO برای اجرای درس بدون نیاز به ایجاد فرایند فرزند.
JSON-RPC روی STDIOفریم‌های جداشده با خط جدید
JSON-RPC OVER STDIOEvery message is one JSON object on one line. A request carries an id and gets exactly one response with the same id; a notification carries no id and must get nothing back. A garbled line earns a -32700 parse error with id null, and the very next line parses normally: one bad frame never poisons the stream.کلاینتسروریک فریم به ازای هر خط n\{"id":7,"method":"tools/call"}{"id":7,"result":{...}}{"method":"progress"}اعلان: بدون id، بدون پاسخ{"id":9,"met###{"id":null,"error":{"code":-32700}}{"id":10,"method":"ping"}یک خط خراب، ادامه جریان
هر پیام یک شیء JSON در یک خط است. یک درخواست شامل id است و دقیقاً یک پاسخ با همان id دریافت می‌کند؛ یک اعلان فاقد id است و نباید هیچ پاسخی دریافت کند. یک خط مخدوش خطای تجزیه 32700- با id برابر با null دریافت می‌کند و بلافاصله خط بعدی به صورت عادی تجزیه می‌شود: یک فریم خراب هرگز جریان را مسموم نمی‌کند.

چرا JSON-RPC همچنان زبان مشترک سیستم‌هاست؟

یک ایجنت برنامه‌نویس در سال 2026 شاید در طول یک نشست کاری با دوازده سرور ابزار مختلف ارتباط برقرار کند. هر سرور یا یک فرایند مجزاست یا یک اندپوینت راه‌دور. قالب تبادل داده روی خط ارتباطی از سال 2013 دست‌نخورده باقی مانده است. مستندات JSON-RPC 2.0 فقط دو صفحه است؛ اما با این حال دوام آورده، چون گزینه‌های جایگزین (مثل gRPC، درخواست‌های HTTP مجزا برای هر فراخوانی، یا قالب‌های باینری سفارشی) همگی نوعی مصالحه را تحمیل می‌کنند که JSON-RPC از آن آزاد است: آن‌ها مجبورتان می‌کنند بین استریمینگ، ارسال دسته‌ای، یا وابستگی شدید به بستر انتقال یکی را انتخاب کنید. در مقابل، JSON-RPC روی stdio، سوکت‌ها، وب‌سوکت‌ها و HTTP کاملاً متقارن عمل می‌کند؛ به‌طوری‌که اگر هر دو طرف به مشخصات پایبند باشند، کلاینت می‌تواند به‌راحتی سروری را که تا به حال ندیده هدایت کند.

در این درس نسخه‌ی مبتنی بر stdio را می‌سازیم؛ یعنی JSON تفکیک‌شده با خط جدید. هر درخواست یک خط است، هر پاسخ یک خط، و مرز بستر انتقال فقط نویسه‌ی \n خواهد بود.

ساختار داده روی خط ارتباطی

چهار ساختار پیام وجود دارد: دو ساختار را کلاینت ارسال می‌کند و دو ساختار را سرور.

سرورکلاینتسرورکلاینتبدون پاسخ برای اعلان‌ها"درخواست {jsonrpc:\"2.0\", id:7, method:\"foo\", params:{...}}""موفقیت {jsonrpc:\"2.0\", id:7, result:{...}}""اعلان {jsonrpc:\"2.0\", method:\"bar\", params:{...}} (بدون شناسه)"درخواستی که ناموفق است"خطا {jsonrpc:\"2.0\", id:7 or null, error:{code, message, data?}}"

اعلان فاقد شناسه (id) است؛ بنابراین سرور نباید به آن پاسخ دهد. اگر سروری به یک اعلان پاسخ بفرستد، کلاینت هیچ راهی برای انتساب آن به محل فراخوانی نخواهد داشت. همین یک قانون ساده، محاسبات فریم‌بندی پیام را بسیار سرراست نگه می‌دارد.

درخواست دسته‌ای در واقع یک آرایه‌ی JSON از درخواست‌ها یا اعلان‌هاست. سرور نیز در پاسخ، آرایه‌ای از پاسخ‌ها را با هر ترتیبی، دقیقاً به ازای هر ورودیِ غیرِاعلان برمی‌گرداند. اگر تمام ورودی‌های یک دسته از نوع اعلان باشند، سرور در پاسخ هیچ چیزی ارسال نمی‌کند.

پنج کد خطای استاندارد

-32700  Parse error      JSON could not be parsed
-32600  Invalid Request  Envelope shape is wrong
-32601  Method not found
-32602  Invalid params
-32603  Internal error

کدهای بین 32000- تا 32099- برای خطاهای تعریف‌شده توسط سرور رزرو شده‌اند. بقیه‌ی کدها را برنامه تعریف می‌کند. در این درس به همین پنج کد اصلی پایبند می‌مانیم. اگر تابع رسیدگی‌کننده‌ی شما استثنایی بالا بیاورد، بستر انتقال آن را با کد 32603- و همراه با نام کلاس استثنا در data.exception بسته‌بندی می‌کند.

خطای تجزیه یک قانون ویژه دارد: شناسه‌ی id در پاسخ برابر با null خواهد بود، چرا که درخواست اصلاً به‌اندازه‌ای تجزیه نشده که بتوان شناسه‌ای از درون آن بیرون کشید.

فریم‌بندی با خط جدید و دموی BytesIO

بستر انتقال داده‌ها را خط‌به‌خط می‌خواند؛ یعنی بایت‌ها را تا نویسه‌ی \n (شامل خود آن) دریافت می‌کند. اگر خطی تجزیه نشود، بستر انتقال یک پاسخ 32700- با id: null می‌نویسد و به کارش ادامه می‌دهد. به‌این‌ترتیب، جریان داده آلوده نمی‌شود و خط بعدی به‌طور مستقل و از نو تجزیه خواهد شد.

برای این درس، یک جفت شیء io.BytesIO را در قالب stdin و stdout می‌پیچیم. سرور درخواست‌ها را تا رسیدن به پایان فایل (EOF) می‌خواند، برای هر کدام پاسخی می‌نویسد و کارش را تمام می‌کند. کلاینت هم در ادامه پاسخ‌ها را بازخوانی می‌کند. نه فرایندی ایجاد می‌شود و نه نیازی به تایم‌اوت هست؛ رفتار بستر انتقال دقیقاً شبیه به یک پایپ واقعی در زیرفرایندها خواهد بود، چون واسط io در پایتون قرارداد متدهای .readline() و .write() را دقیقاً به همان صورت پیاده کرده است.

هدایت متدها (Method Dispatch)

بستر انتقال از وجود متدها بی‌خبر است؛ همه‌چیز را به یک شیء قابل‌فراخوانی به نام handler(method, params) می‌سپارد که بستر آزمایشی در اختیارش گذاشته است. این رسیدگی‌کننده یا نتیجه را برمی‌گرداند یا استثنا ایجاد می‌کند. کدهای خطا از دل سه کلاس استثنای مشخص بیرون می‌آیند.

MethodNotFound -> -32601
InvalidParams  -> -32602
Anything else  -> -32603 with exception name in data

بستر انتقال هرگز سر و کاری با رجیستری ابزار ندارد؛ رجیستری پشت رسیدگی‌کننده قرار می‌گیرد. این همان لایه‌بندی مدنظر ماست: بستر انتقال به زبان JSON-RPC صحبت می‌کند، رجیستری ساختار ابزارها را می‌شناسد، و توزیع‌کننده (درس بیست‌وسوم) این دو بخش را به هم پیوند می‌زند.

رفتار جریان داده هنگام مواجهه با خطا

client writes              server reads             server writes
---------------            -----------              -------------
{...valid request...}      parses ok                {...response, id matches...}
{...broken json...         parse fails              {id:null, error: -32700}
{...valid request...}      parses ok                {...response, id matches...}
{...missing method...}     invalid envelope         {id:X, error: -32600}

یک خط JSON خراب حلقه‌ی اجرا را متوقف نمی‌کند. نبودِ فیلد method حلقه را از کار نمی‌اندازد. وقوع استثنا در رسیدگی‌کننده نیز باعث توقف حلقه نمی‌شود. بستر انتقال به خواندن داده‌ها تا رسیدن به EOF ادامه می‌دهد.

اعلان‌ها و جریان‌های کاری نامتقارن

اعلان‌ها از نوع «بفرست و فراموش کن» (fire-and-forget) هستند. بستر آزمایشی از اعلان‌ها برای ارسال وقایع مربوط به پیشرفت، سیگنال‌های لغو و ثبت لاگ‌ها استفاده می‌کند. با همین اعلان‌هاست که یک ابزار زمان‌بر می‌تواند وضعیت خود را به‌شکل استریم گزارش دهد، بدون اینکه لازم باشد برای هر کدام منتظر یک رفت‌وبرگشت کامل بماند.

در این درس یک تابع کمکی برای ارسال اعلان به بیرون پیاده می‌کنیم: write_notification. سرور از این تابع برای اعلام وضعیت پیشرفت، درست حین اجرای درخواست استفاده می‌کند. دمو این الگو را به نمایش می‌گذارد: درخواستی دریافت می‌شود، رسیدگی‌کننده دو اعلانِ پیشرفت می‌فرستد و سپس پاسخ نهایی را می‌نویسد.

راهنمای مطالعه‌ی کد

در فایل code/main.py کلاس StdioTransport، تابع کمکی تجزیه (parse_request)، سه تابع کمکی نوشتن داده (write_response، write_error و write_notification) و حلقه‌ی توزیع متد (serve) تعریف شده‌اند. ثابت‌های مربوط به کدهای خطا در دامنه‌ی ماژول قرار دارند.

فایل code/tests/test_transport.py بخش‌های مختلف را بررسی می‌کند: پنج کد خطا، اعلان‌ها (بدون نوشتن پاسخ)، دسته‌ها (ورودی آرایه، خروجی آرایه، نادیده‌گرفتن اعلان‌ها)، خطوط JSON خراب (خطای تجزیه و سپس ادامه‌ی مسیر) و جریان کاری نامتقارنی که در آن رسیدگی‌کننده حین فراخوانی یک اعلان می‌فرستد.

برای مطالعه‌ی بیشتر

این بستر انتقال نیاز درس‌های بعدی را برطرف می‌کند. بسترهای انتقالی که در محیط عملیاتی (پروداکشن) استفاده می‌شوند، سه قابلیت دیگر نیز به همراه دارند: فیلدی برای شناسه‌ی تطبیق که هنگام هدایت پیام به گره‌های دیگر از بین نرود (شناسه‌ی id همین کار را می‌کند، ولی در یک شبکه‌ی مش به یک شناسه‌ی ردگیری بیرونی هم نیاز دارید)، کانالی برای لغو عملیات (اعلانی مثل $/cancelRequest همراه با شناسه‌ی فراخوانیِ در حال اجرا)، و سازوکار دست‌دادن برای توافق روی نوع محتوا (content-type negotiation) تا همان سوکت بتواند هم‌زمان از JSON-RPC و پروتکل HTTP جریانی پشتیبانی کند. هیچ‌کدام از این موارد ساختار پایه‌ای داده روی خط را تغییر نمی‌دهند؛ فقط فراداده به آن اضافه می‌کنند.

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درسچه چیزی یک اعلان (notification) را در پروتکل JSON-RPC از یک درخواست هنگام ارسال روی بستر ارتباطی متمایز می‌کند؟
  2. پیش از درسچرا فرمت JSON جداشده با خط جدید (newline-delimited) برای فریم‌بندی در stdio کافی است؟
  3. حین درسهنگامی که تجزیه JSON با شکست مواجه می‌شود، بستر انتقال چه کد خطایی برمی‌گرداند؟
  4. حین درساگر یک هندلر خطای ساده‌ی ValueError صادر کند، بستر انتقال چه پاسخی برمی‌گرداند؟
  5. حین درسهنگامی که یک بسته (batch) فقط شامل اعلان‌ها باشد، سرور چه چیزی برمی‌گرداند؟
  6. پس از درسیک خط با JSON خراب در میانه یک نشست دریافت می‌شود. چه اتفاقی برای خطوط بعدی می‌افتد؟
  7. پس از درسچرا بستر انتقال به طور مستقیم رجیستری ابزار را فراخوانی نمی‌کند؟

سؤالات متداول

چرا سرور نباید به پیام‌های اعلان (notification) در JSON-RPC پاسخ دهد؟

پیام اعلان فاقد شناسه (id) است؛ در نتیجه کلاینت هیچ سازوکاری برای تطبیق پاسخ دریافتی با نقطه فراخوانی اولیه ندارد و سرور باید از ارسال هرگونه پاسخ برای آن خودداری کند.

هنگام مواجهه با خطای تجزیه JSON (کد 32700-) مقدار id در پاسخ چه خواهد بود؟

مقدار id در پاسخ برابر null قرار می‌گیرد، زیرا خط ارسال‌شده آن‌قدر ناقص یا خراب بوده که استخراج فیلد id از داخل آن امکان‌پذیر نبوده است.

چگونه می‌توان تست بستر stdio را بدون ایجاد فرایند فرزند در پایتون اجرا کرد؟

با استفاده از دو شیء io.BytesIO به عنوان stdin و stdout مجازی؛ زیرا رابط کاربری این کلاس‌ها در پایتون قرارداد متدهای readline و write را کاملاً مشابه پایپ‌های واقعی پیاده‌سازی می‌کند.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.