عنوان:

‫چرا MCP؟ (بخش دوم- LSP)


نویسنده: سپهر شمسائی
تاریخ: ۱۴۰۴/۰۲/۲۶ ۰۷:۴۰
آدرس: www.dntips.ir
در بخش اول بطور خلاصه مروری به چند نمونه از روند‌های تکرار شونده در دنیای تکنولوژی که نهایتاً به «استاندارد سازی» ختم میشد انجام شد. در جهت تکمیل موضوع پرداختن به یک پروتکل تاریخ‌ساز دیگر (LSP) ضروری بنظر می‌رسد.

چرا LSP (Language Server Protocol)؟
فرض کنیم سال 2015 میلادی است و هنوز پروتکلی به نام LSP وجود ندارد. از سه دیدگاه مختلف موضوع را تصور کنیم:
  • برنامه نویس
  • توسعه دهنده یک زبان جدید
  • توسعه دهنده ویرایش گر متنی

برنامه نویس
فرض کنیم که یک توسعه‌دهندهٔ نرم‌افزار علاقه دارد از ویرایشگر Vim برای نوشتن کدهای پایتون استفاده کند. او به محیط سبک و قابل شخصی‌سازی Vim علاقه‌مند است، اما در عین حال نیاز دارد هنگام برنامه‌نویسی، امکاناتی مانند تکمیل خودکار، نمایش خطاهای نحوی، رفتن به تعریف توابع، و قابلیت بازسازی کد (refactoring) در اختیار داشته باشد.
با این حال، او خیلی زود با چالش‌هایی جدی مواجه می‌شود. Vim به‌صورت پیش‌فرض از چنین قابلیت‌هایی برای زبان‌های برنامه‌نویسی مختلف پشتیبانی نمی‌کند. برای دستیابی به آن‌ها مجبور است چندین افزونهٔ جانبی مانند YouCompleteMe، jedi-vim، و syntastic را به دقت نصب و پیکربندی کند. هر کدام از این افزونه‌ها روش خاصی برای تحلیل کد دارند و معمولاً با یکدیگر سازگاری کامل ندارند. در نتیجه، حتی پس از صرف زمان طولانی برای پیکربندی، ممکن است نتیجه نهایی ناپایدار و ناقص باشد.
همچنین، او متوجه می‌شود که اگر بخواهد به زبان جدیدی مانند Go یا Rust مهاجرت کند، باید همهٔ این مسیر را از ابتدا برای آن زبان نیز طی کند. این یعنی دوباره تحقیق، نصب پلاگین‌های جدید، رفع ناسازگاری‌ها، و آزمون و خطا برای رسیدن به تجربه‌ای قابل قبول.
افزون بر این، چون هر پلاگین به‌صورت مستقل و خارج از یک استاندارد مشخص توسعه یافته‌است، بسیاری از آن‌ها همزمان نمی‌توانند با هم کار کنند یا رفتار مشابهی برای زبان‌های مختلف ارائه دهند. نتیجه این است که تجربهٔ کدنویسی در Vim، بسته به زبان انتخابی، ممکن است بسیار متفاوت باشد و حتی در مواردی ناکارآمد یا آزاردهنده تلقی شود.

توسعه دهنده یک زبان جدید
فرض کنیم توسعه‌دهنده‌ای تصمیم می‌گیرد زبان برنامه‌نویسی جدیدی به نام FlowScript طراحی کند؛ زبانی مدرن و ساده برای برنامه‌نویسی همزمان. برای پذیرش این زبان در میان برنامه‌نویسان، او می‌داند که ارائهٔ پشتیبانی مناسب در ویرایشگرهای متداول مانند ٍEclipse ، Vim و Emacs ضروری است.
با این حال، با چالشی بزرگ مواجه می‌شود. برای هر محیط توسعه یا ویرایشگر، باید افزونه یا ابزار مخصوصی طراحی کند:
  • برای Eclipse، باید با استفاده از Java افزونه‌ای توسعه دهد و از API اختصاصی این محیط بهره بگیرد.
  • برای Emacs، باید یک «حالت» (mode) جدید با زبان Emacs Lisp طراحی کند.
  • برای Vim، باید اسکریپت‌هایی مبتنی بر VimL یا حتی ابزارهای جانبی مانند YouCompleteMe را پیکربندی کند.
در واقع برای هر قابلیت ساده مانند تکمیل خودکار، رفتن به تعریف توابع یا نمایش خطاها، باید پیاده‌سازی‌هایی جداگانه، متناسب با معماری و زبان هر ویرایشگر انجام دهد. این کار نه تنها زمان‌بر است، بلکه نگهداری و پشتیبانی از این ابزارها نیز دشوار و پرهزینه خواهد بود. در نتیجه او زمان و انرژی خود را به جای توسعه زبان FlowScript، صرف مسائل جانبی مربوط به ابزارهای مختلف می‌کند.

توسعه دهنده ویرایش گر متنی
در همان زمان، گروهی از توسعه‌دهندگان (فرضی) تصمیم می‌گیرند ویرایشگر جدیدی به نام FeatherEdit طراحی کنند. هدف آن‌ها ساخت یک ویرایشگر سبک و مدرن است که بتواند از چندین زبان برنامه‌نویسی پشتیبانی کند و تجربه‌ای حرفه‌ای در اختیار کاربران قرار دهد.
اما در همان مراحل ابتدایی توسعه متوجه می‌شوند که برای هر زبان، نیاز به طراحی و پیاده‌سازی جداگانه‌ای دارند. اگر بخواهند Python را پشتیبانی کنند، باید یک موتور تحلیل نحوی و معنایی طراحی کنند یا از ابزارهای خارجی خاصی استفاده نمایند. اگر بخواهند زبان‌های دیگری مانند JavaScript، Go یا Ruby را نیز پشتیبانی کنند، این فرایند باید برای هر زبان به‌طور مستقل تکرار شود. این رویکرد بسیار پرهزینه و زمان‌بر است و مانعی جدی بر سر راه توسعه و رشد ویرایشگر خواهد بود.

بررسی عمیق تر مشکلات
همچنان قبل از تولد Vs Code یعنی قبل از آوریل سال 2015 میلادی را در ذهن داشته باشیم.
ابزارهایی مانند Eclipse برای پشتیبانی از زبان‌های مختلف، از سیستم پلاگین‌پذیر خود استفاده می‌کردند. برای هر زبان، یک افزونه‌ی مستقل توسعه داده می‌شد که شامل تمام امکانات زبانی مورد نیاز است، مانند:
    • تکمیل خودکار
    • هایلایت نحوی
    • ناوبری بین نمادها
    • بازسازی کد (Refactoring)
    • تحلیل استاتیک کد
    • نمایش خطاها و هشدارها
مثلاً:
    • برای Java از JDT (Java Development Tools) استفاده می‌شد.
    • برای Adnroid از ADT(Android Development Tools) استفاده می‌شد.
    • برای C/C++ از CDT (C/C++ Development Tooling) بهره گرفته می‌شد.
    • برای PHP از PDT (PHP Development Tools) استفاده می‌شد.

این افزونه‌ها اغلب به صورت عمیق با زیرساخت IDE (مثل Eclipse) یکپارچه بودند. در نتیجه توسعه و نگهداری آن‌ها دشوار بود و برای پشتیبانی از زبان جدید نیاز به دانش عمیق از API های داخلی IDE وجود داشت. همچنین این ابزار ها و افزونه ها قابل استفاده مجدد در IDEهای دیگر مثل vim یا Emacs نبودند.
هر افزونه بسته به طراحی IDE مربوطه و ویژگی‌های زبان، ساختار، مدل AST، و تحلیل معنایی مخصوص به خود را داشت. این باعث می‌شد هر IDE برای هر زبان باید افزونه‌ی مخصوص خود را داشته باشد و امکان استفاده از یک موتور زبانی واحد در چندین ویرایشگر به راحتی وجود نداشته باشد.

شاید یکی از راه حل هایی که به ذهن خطور میکند این است که «چطور است این مشکل را با IDE حل کنیم»؟
در یک زبان برنامه‌نویسی مبتنی بر متن، ممکن است برنامه‌نویس بخواهد یک متد به نام read را تغییر نام دهد. او می‌تواند به‌صورت دستی کد منبع را ویرایش کرده و نام قدیمی را به نام جدید تغییر دهد، یا از قابلیت بازسازی IDE استفاده کند تا تمام تغییرات لازم به‌صورت خودکار اعمال شوند. برای پشتیبانی از چنین قابلیت‌هایی، IDE باید درک پیچیده‌ای از زبان برنامه‌نویسی مورد استفاده داشته باشد. ابزاری که فاقد این درک باشد مانند ابزاری که تنها یک جست‌وجو و جایگزینی ساده انجام می‌دهد ممکن است باعث ایجاد خطا شود. مثلاً هنگام تغییر نام متد read، ابزار نباید واژه‌ای مانند readyState را تغییر دهد، یا بخشی از یک توضیح (comment) را که حاوی کلمه "already" است جایگزین کند. همچنین، تغییر نام یک متغیر محلی با نام read نباید باعث تغییر متغیرهای هم‌نام در حوزه‌های (scope) دیگر شود. همچنین با این کار صرفاً پلاگین ها را با IDE جایگزین کرده ایم که در واقع از نظر زمانی مربوط به قبل از استفاده گسترده از افزونه ها است.

و راه حل دیگر اینکه «چطور است این مشکل را با کمک مفسر یا کامپایلر یا حل کنیم»؟
کامپایلرها یا مفسرهای سنتی برای زبان‌های برنامه‌نویسی معمولاً نمی‌توانند چنین خدمات زبانی را فراهم کنند، چرا که هدف آن‌ها تبدیل کد منبع به کد ماشینی یا اجرای آنی برنامه است. افزون بر این، خدمات زبانی باید قادر به پردازش کدی باشند که هنوز کامل نشده مثلاً وقتی برنامه‌نویس در حال نوشتن یک کلاس یا یک تابع است. همچنین، تغییرات کوچک هنگام تایپ معمولاً معنای برنامه را تغییر می‌دهند. بنابراین برای ارائهٔ بازخورد فوری به کاربر، ابزار ویرایشگر باید بتواند خیلی سریع پیامدهای نحوی و معنایی تغییرات را تحلیل کند. از این‌رو کامپایلرها و مفسرها گزینه‌های مناسبی برای تأمین اطلاعات لازم جهت ابزارهای ویرایشی نیستند.

تولد LSP
برنامه نویس، توسعه دهنده زبان و توسعه دهنده ویرایش گر متنی در فرض فوق احتمالاً با خودشان فکر میکنند چه میشد اگر راه حل استانداردی برای کار با زبان های مختلف وجود میداشت تا زحمت این میزان تحقیق و کار تکراری و سفارشی کمتر میشد، اگر راه حلی وجود میداشت تا بین این دنیا های پراکنده زبان مشترکی بوجود می‌‌آمد و مانند یک پل بین آنها ارتباط برقرار میکرد.
مایکروسافت در سال 2016 همین زبان مشترک یعنی پروتکل LSP را معرفی کرد. اساس کار LSP بر پایه مفهومی به نام «سرور زبانی» است که در واقع یک پردازه (process) در پس زمینه (deamon) است. بنابراین می‌توان بطور موقت معماری کلاینت-سرور را تا خواندن انتهای مطلب جاری تا حدودی فراموش کرد.
ایده پشت سرور زبانی (Language Server) این است که امکانات Smart (مثل autocomplete) و قابلیت‌های ویژه هر زبان برنامه‌نویسی را درون یک سرور قرار دهد که بتواند از طریق یک پروتکل ارتباط بین process ها (IPC یا inter-process communication) با ابزارهای توسعه (ویرایشگر های متنی) ارتباط برقرار کند. بنابراین ابزارهای توسعه دیگر نیازی به پیاده سازی منطق و دانستن جزئیات یک زبان برنامه نویسی ندارند و کافی است ارتباط موثر با سرور زبانی برقرار نمایند. به این ترتیب میتوان LSP را شامل سه جزء دانست:
  • سرور زبانی Language Server یا به اختصار LS (که یک process روی سیستم است و توسط افراد مختلفی برای زبان های مختلف ممکن است پیاده سازی شده باشد)
  • کلاینت زبانی Language Client (مثل vs code یا vim)
  • پروتکل Protocol ( که مشخصات آن توسط مایکروسافت بصورت منبع باز ارائه شده است)

همانطور که در تصویر مشخص است بدون LSP در صورتی که m ویرایشگر متنی و n زبان داشته باشیم با یک مسئله m*n مواجهیم حال آنکه با ابداع LSP این مسئله تبدیل به m+n گردیده.

درخواست و پاسخ در LSP
این پرتکل از json-rpc برای ارتباط بین کلاینت و سرور استفاده میکند و از این جهت که دارای یک بخش header و یک بخش content است شبیه به http بنظر می‌رسد.
برای نمونه، یک درخواست از نوع textDocument/completion که برای دریافت پیشنهادهای تکمیل خودکار ارسال می‌شود را بررسی می‌کنیم:

درخواست Request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "textDocument/completion",
  "params": {
    "textDocument": {
      "uri": "file:///home/user/project/example.py"
    },
    "position": {
      "line": 10,
      "character": 5
    },
    "context": {
      "triggerKind": 1
    }
  }
}

  • "jsonrpc": "2.0": مشخص می‌کند که از نسخه ۲.۰ پروتکل JSON-RPC استفاده می‌شود.
  • "id": 1: شناسهٔ یکتای این درخواست. پاسخ باید شامل همین شناسه باشد.
  • "method": "textDocument/completion": نوع درخواستی که ارسال می‌شود (در اینجا، درخواست تکمیل خودکار).
در بخش "params":
  • "textDocument.uri": مسیر فایل منبعی که کاربر در حال ویرایش آن است.
  • "position": موقعیت نشانگر (cursor) در متن – در اینجا، سطر ۱۰، ستون ۵.
  • "context.triggerKind": نشان‌دهنده نوع محرک (Trigger) برای تکمیل. مقدار 1 یعنی درخواست توسط کاربر به‌صورت دستی انجام شده است.

پاسخ Response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "isIncomplete": false,
    "items": [
      {
        "label": "print",
        "kind": 3,
        "detail": "built-in function",
        "documentation": "Prints the given object to the standard output."
      },
      {
        "label": "property",
        "kind": 7,
        "detail": "class",
        "documentation": "The property decorator class."
      }
    ]
  }
}

"jsonrpc": "2.0": همان نسخه JSON-RPC.
"id": 1: تطابق با شناسهٔ درخواست ارسال‌شده.
"isIncomplete": اگر true باشد، یعنی نتایج کامل نیستند و ممکن است به‌روزرسانی شوند.
"items": لیستی از پیشنهادهای تکمیل کد.
هر آیتم شامل:
"label": متن نمایشی برای پیشنهاد.
"kind": نوع نماد (symbol kind). عدد 3 معمولاً به معنی تابع، و 7 برای کلاس است.
"detail": توضیح مختصر دربارهٔ آیتم.
"documentation": مستندات بیشتر برای نمایش در Tooltip

ارتباط بین ویرایشگر (مثلاً VS Code) و سرور زبان از طریق JSON-RPC انجام می‌شود. هر درخواست (مثلاً دریافت پیشنهادات تکمیل کد، یافتن تعریف یک تابع، نمایش خطاها و غیره) با استفاده از یک method مشخص ارسال می‌شود، و سرور پاسخ آن را با همان id باز می‌گرداند. این ساختار باعث می‌شود زبان‌ها و ویرایشگرها بتوانند به‌صورت استاندارد و مستقل از یکدیگر تعامل داشته باشند.

دلایل پذیرش LSP:
طبق گفته اکانت رسمی مایکروسافت در گیت‌هاب ابداع سرور زبانی اتفاق جدیدی نبود و سالها قبل تر vim و sublime در حال استفاده از آن بودند. پس چه چیزی باعث موفقیت و پذیرش LSP شد، تا حدی که حتی vim و sublime هم آن را پذیرفتند؟

تفکیک مسئولیت‌ها (Decoupling Responsibilities)
LSP این امکان را فراهم می‌کند که توسعه زبان‌های برنامه‌نویسی و توسعه ویرایشگرها به‌صورت مجزا انجام گیرد. یعنی تیمی که روی زبان برنامه‌نویسی کار می‌کند فقط کافی است یک «Language Server» بسازد و دیگر نیازی نیست برای هر ویرایشگر (مثل VS Code، Atom، Sublime، Vim، Emacs و...) جداگانه افزونه بنویسد.

پشتیبانی چند زبانه در ویرایشگرها
به کمک LSP، یک ویرایشگر فقط یک بار نیاز به پیاده‌سازی کلاینت پروتکل دارد و پس از آن می‌تواند از صدها زبان برنامه‌نویسی پشتیبانی کند، بدون اینکه نیاز باشد هر زبان به صورت خاص در ویرایشگر پیاده‌سازی شود. این باعث صرفه‌جویی زیاد در زمان توسعه و نگهداری ویرایشگر می‌شود.

استانداردسازی تعامل ویرایشگر با زبان
پیش از LSP، تعامل ویرایشگرها با زبان‌های برنامه‌نویسی فاقد استاندارد مشخص بود و به‌صورت پراکنده و ناسازگار انجام می‌شد. LSP یک استاندارد باز و دقیق برای چنین ارتباطی ارائه می‌دهد که شامل امکاناتی مانند تکمیل خودکار(completion)، رفتن به تعریف (go to definition)، پیدا کردن ارجاع‌ها (find references)، نمایش مستندات (hover info)، بازسازی کد (refactoring) میشود.

کاهش بار توسعه و نگهداری برای توسعه‌دهندگان ابزارها
توسعه‌دهندگان ابزارهای برنامه‌نویسی (ویرایشگرها و IDEها) دیگر نیازی به توسعه جداگانهٔ پشتیبانی از زبان‌های مختلف ندارند. آن‌ها فقط یک کلاینت LSP پیاده‌سازی می‌کنند و می‌توانند از هر Language Server موجود استفاده کنند. این موضوع باعث افزایش سرعت توسعه و کاهش هزینه‌های نگهداری می‌شود.

پشتیبانی از کدهای ناقص
بر خلاف کامپایلرها که نیاز دارند کد کامل و بدون خطا باشد، LSP طراحی شده است تا بتواند با کدهایی که هنوز کامل نشده‌اند یا حاوی خطا هستند (مثلاً در حال تایپ)، کار کند. این موضوع تجربهٔ برنامه‌نویسی تعاملی و روان‌تری فراهم می‌آورد.

اکوسیستم باز و قابل توسعه
LSP یک پروتکل متن‌باز و گسترش‌پذیر است. این ویژگی باعث شده است که توسعه‌دهندگان زبان‌های جدید یا زبان‌های خاص دامنه (DSLs) نیز بتوانند به راحتی Language Server خود را ساخته و به جامعه ارائه دهند.

حمایت شرکت‌ها و پروژه‌های بزرگ
پروتکل LSP ابتدا توسط مایکروسافت برای Visual Studio Code طراحی شد، اما خیلی زود توسط سایر پروژه‌های بزرگ مانند Eclipse، JetBrains، GitHub، و جامعهٔ Emacs/Vim پذیرفته شد. این حمایت گسترده باعث تسریع پذیرش پروتکل در سطح جهانی شد.

کاهش تکرار در پیاده‌سازی ابزارهای زبان
پیش از LSP، اگر یک زبان می‌خواست در ۵ ویرایشگر مختلف پشتیبانی شود، نیاز به ۵ پیاده‌سازی متفاوت از ابزارهای زبان وجود داشت. اما با LSP، تنها یک Language Server ساخته می‌شود و همهٔ ویرایشگرهای سازگار با آن می‌توانند از آن استفاده کنند.