یک rate-limiter برای grammY
تجربیات ابتدایی من در برنامه نویسی بیشتر در حوزه ی نوشتن ربات های تلگرامی خلاصه می شد و در آن زمان یکی از بهترین فریم ورک ها برای این کار (در صورتی که از node.js استفاده می کردید) فریم ورکی به نام Telegraf بود اما maintainer اصلی پروژه از توسعه ی فعال دست کشیده و فقط در شرایط خاص برخی مشکلات را پچ می کرد. قدمت پروژه ی Telegraf زیاد بود و در زمان نگارشش حتی تایپ اسکریپت آنچنان مطرح نبود بنابراین سورس کد پروژه به جاوا اسکریپت خالی نوشته شده بود و بعدا type declaration ها به آن اضافه شده بودند. از طرف دیگر API تلگرام در آن سال ها در حال تغییر و تحول و معرفی قابلیت های جدید بود. همچنین طراحی Telegraf بر اساس دیزاین پترنی به نام Builder بود و باعث سنگین شدن کل کتابخانه شده بود.
این مسائل باعث شد که یکی از توسعه دهندگان حاضر در گروه تلگرامی به نام Steffen Trog دست به کار شود و فریم ورک خودش grammY را بسازد. در آن سال ها من هم همراهشان بوده و نظارهگر فرآیند ساخت بودم. یک خاطره ی جالب از آن زمان که هنوز سایت ساخته نشده بود مشکل دامنه ی grammy.dev بود. نام grammY به نوعی بازی با کلمه ی Telegram بود اما شباهتی هم با مراسم جوایز گرمی داشت. به همین دلیل استفن مجبور به امضای چند تعهدنامه شد که از این دامنه برای سوء استفاده از نام مراسم گرمی استفاده نکند. اگر دوست دارید در مورد فرآیند ساخت این فریم ورک بیشتر بدانید می توانید به پادکست PodRocket از وب سایت رسمی PodRocket یا Apple Podcast گوش کنید. پس از صحبت هایی که با برخی اعضای تیم تلگرام در گروه تلگرامی TDLib Chat داشتیم به این فریم ورک علاقه نشان دادند و آن را در وب سایت رسمی تلگرام و در بخش فریم ورک های کامیونیتی (زیربخش تایپ اسکریپت) قرار بدهند.
قبل از تمام این اتفاقات در سال ۱۴۰۰ بود که تصمیم به ساخت پلاگین ratelimiter گرفتم. آن زمان تعداد کاربران بسیار کمتر بود اما همچنان نیاز به این قابلیت حس می شد.
این پکیج یک پلاگین با قابلیت شخصی سازی بالا است که از هر دو فریم ورک grammY و Telegraf پشتیبانی می کند. grammY و Telegraf فریم ورک هایی پیشرفته برای ساخت ربات های تلگرامی هستند که به ترتیب با runtime های deno و node ساخته شده اند (البته grammy از node نیز پشتیبانی می کند). حوزه ی ربات های تلگرامی در زبان PHP همیشه بسیار قدرتمند تر از جاوا اسکریپت بوده است چرا که زبان PHP در بین کشور هایی که از تلگرام استفاده می کنند رایج تر است (مثل ایران)، خصوصا در آن سال ها! با اینکه فریم ورک های تلگرامی برای جاوا اسکریپت ساخته شده بود اما در چنین بستری خلاء خاصی برای یک پروژه مدرن و قوی احساس می شد. با ساخت grammY بخش بزرگی از آن حل شد اما فلسفه ی آن طراحی مینیمال با کوچک ترین فوت پرینت ممکن بود بنابراین تمام قابلیت های اضافی باید به عنوان پلاگین معرفی می شد.
یکی از این قابلیت ها، قابلیت جلوگیری از پردازش درخواست های مکرر کاربران بود. از آنجایی که اکثر ربات های تلگرامی بسیار کوچک بوده و بار پردازشی خاصی ندارند، روی سرور های ضعیف پیاده می شوند. این مسئله اهمیت محدود کردن نرخ درخواست را چند برابر می کند. با اینکه خود سرور های تلگرام نوعی از ratelimiting را پیاده سازی کرده اند اما این محدودیت برای یک سرور ضعیف ربات شما بیش از حد آزاد است (به طور مثال نرخ محدودیت ارسال درخواست از سمت ربات های تلگرامی حدود حداکثر ۳۰ درخواست بر ثانیه است اما برای نرخ دریافت درخواست عدد خاصی اعلام نشده است). در صورتی که بار پردازشی ربات شما برای هر درخواست سنگین باشد، این محدودیت کافی نیست. فرض کنید ربات شما به کاربران اجازه می دهد که متن خاصی را بین کتاب های مختلف جست و جو کنند (Full Text Search). اگر هر کاربر ۱۰ درخواست متوالی و بدون وقفه ارسال کند و ۵۰ کاربر فعال داشته باشید، سرور ضعیف شما زیر بار درخواست ها خفه خواهد شد. هدف پلاگین ratelimiter برطرف کردن این نگرانی خاص بین توسعه دهندگان grammY است.
طبیعتا توسعه دهندگان ربات های تلگرام به سرور های تلگرام دسترسی ندارند و این سرور های تلگرام هستند که آپدیت های جدید را به سرور ربات شما ارسال می کنند (چه از Long-Polling استفاده کرده باشید و چه از Webhooks). با این حساب rate-limiting عادی (به معنای محدودیت نرخ قبول درخواست در یک API) توسط ما تنظیم نمی شود.
پلاگین rateLimiter برای رفع این مشکل به جای اعمال محدودیت در نرخ درخواست برای سرور های تلگرام، درخواست های ورودی به سرور ربات را rate-limit می کند (به عبارتی به جای سرور، کاربران rate-limit می شوند). همچنین این پلاگین دو اینترفیس برای رهگیری درخواست ها در حافظه دارد که یکی مموری خود سرور و دیگری استفاده از پایگاه داده ی Redis است اما یک لایه abstraction برای هر دو ساخته شده است تا کاربران (به غیر از انتخاب یکی از این دو) پیکربندی خاصی را انجام ندهند.
زمانی که درخواستی به سرور شما ارسال می شود، در فضای مموری و توسط یکی از این دو اینترفیس، هویت ثابت کاربر یا همان شناسه یکتا (id) به صورت موقت ثبت می شود. این شناسه در سرور های تلگرام همیشه یکتا باقی می ماند حتی اگر کاربری حساب کاربری خود را به صورت کامل حذف کرده و یک حساب جدید با همان شماره تلفن بسازد. البته توسعه دهندگان می توانند خصوصیت دیگری به جز id را به عنوان شناسه ی رهگیری انتخاب کنند. از طرفی یک مقدار ثابت به عنوان تعداد درخواست بر میلی ثانیه در پلاگین تعریف شده است که توسط توسعه دهندگان قابل تغییر است. در صورتی که یک شناسه از این محدودیت تخطی کند به صورت پیش فرض یک پیام هشدار به او ارسال می شود. این پیام تماما قابل ویرایش و شخصی سازی است و حتی می تواند هیچ کاری نکند! با این کار درخواست های «غیر مجاز» از سد اولیه ربات گذر نکرده و نمی توانند بار پردازشی اضافه ای را بر ربات شما تحمیل کنند.
ساخت این پروژه در عین سادگی ظاهری، با تصمیمات خاص و چالش های جالبی همراه بوده است که مهم ترین آن ها در این بخش توضیح داده می شوند.
آیا استفاده از مموری (RAM) برای رهگیری شناسه های یکتا توجیه پذیر است؟ با توجه به ماهیت درخواست های ارسالی به سرور برای یک ربات تلگرامی و همچنین تعریف مبحث ratelimiting باید نکات زیر را در دفاع از این انتخاب ذکر کرد:
- حجم بسیار بالا write (و البته read)
- گذرا بودن ماهیت داده ها در رهگیری (Transient Data)
بیایید هر دو مورد را بررسی کنیم.
ارسال یا دریافت درخواست ها در هر API با سرعت بسیار زیادی انجام می شود (یکی از روش های بنچمارک گرفتن از API ها نیز همین است!) و با پردازش درخواست های ارسال شده تفاوت زیادی دارد. به همین دلیل به فضایی نیاز داریم که read و write در آن با سرعت بسیار بالایی اجرا شود. طبیعتا مموری در هر شرایطی از سریع ترین حافظه های NVME نیز سریع تر است چرا که برای همین کار طراحی شده است. بسیاری از ربات های تلگرامی پایگاه داده دارند و اکثریت پایگاه های داده به غیر از تعدادی محدود مانند redis با دیسک تعامل دارند. از طرفی برای رهگیری عملیات های write بسیار زیاد هستند و در صورتی که دیسک سرور را با این نوع درخواست ها درگیر کنیم باعث ایجاد تاخیر شدید روی عملیات های اصلی ربات مثل ثبت نام کاربران یا جست و جو یا ثبت داده هایشان و ... می شویم.
همچنین به دلیل استفاده از middleware، پلاگین باید سریعا رهگیری را انجام داده و next() را صدا بزند تا ادامه ی اسکریپت اجرا شود. هر تاخیری که به واسطه ی پردازش در ratelimiter انجام شود باعث ایجاد تاخیر در تمام برنامه خواهد شد چرا که تمام برنامه منتظر عبور از middleware است. این فرآیند به اصطلاح توسعه دهندگان node.js یک فرآیند بلاکینگ می باشد، یعنی حلقه ی رویداد (event loop) را مسدود می کند و از آنجایی که node.js یک رانتایم تک ریسمانی (single threaded) است تا زمانی که نتیجه ای حاصل نشود روند اجرای ربات کاملا متوقف می شود.
با این حساب سرعت در چنین سیستمی حرف اول را می زند. برای حل این مشکل از مموری به عنوان فضای ذخیره سازی و رهگیری کاربران استفاده کرده ایم اما درباره ی ساختمان داده (data structure) نیز باید تصمیم گیری می شد که بهترین گزینه ساختمان داده ی Map در جاوا اسکریپت است چرا که به طور خاص برای read و write های متوالی طراحی شده است و از اشیاء ساده در جاوا اسکریپت سریع تر عمل می کند.
مسئله ی بعدی گذرا بودن ماهیت داده های رهگیری است. داده های گذرا (transient data) داده هایی هستند که موقت و همانطور که از نامشان مشخص است «گذرا» حساب می شوند و به اصطلاح Fault Intolerance هستند یعنی با از دست دادنشان مشکل خاصی پیش نمی آید. فرض کنید برق دیتاسنتری که ربات شما در آن میزبانی می شود قطع شده و سرور خاموش می شود. طبیعتا با خاموش شدن سرور RAM به طور کامل خالی می شود و تمام داده هایش از بین می رود. از طرفی ratelimiting در بازه ی زمانی خاص تعریف می شود و بدون بازه ی زمانی معین، هیچ معنی ندارد. بنابراین زمانی که سرور خاموش بشود، ربات هم از دسترس خارج خواهد شد بنابراین مشکلی ایجاد نمی شود و از دست داده های رهگیری هیچ اختلالی در روند اجرای برنامه ایجاد نمی کند.
چالش دیگر ساخت این پلاگین پشتیبانی از هر دو فریم ورک Telegraf و grammY بود. باید توجه داشت که Telegraf برای node.js و grammY برای deno نوشته شده است بنابراین این پلاگین نیز باید از deno و node پشتیبانی کند. حتی اگر پشتیبانی از Telegraf را کنار بگذاریم باز هم باید از node.js پشتیبانی کنیم چرا که grammY روی هر دو رانتایم اجرا شده و از هر دو پشتیبانی می کند. برای پورت کردن کد های deno به node از پکیج deno2node استفاده شده است. جالب است بدانید پکیج deno2node توسط یکی از توسعه دهندگان اصلی در هسته ی grammY و از صفر نوشته شده است و به طور خاص برای حل مشکل پشتیبانی همزمان از دو رانتایم ذکر شده توسعه داده شد.
مسئله ی حذف وابستگی ها (dependency) از این جهت است که این پلاگین از دو رانتایم Deno و Node پشتیبانی می کند بنابراین کاربران باید برای استفاده از درایورهای Redis که در deno و node متفاوت اند آزاد باشند. حتی اگر یک رانتایم را در نظر بگیریم (مثلا node) کتابخانه های مختلف اینترفیس های مختلفی برای کار با redis ساخته اند که ممکن است در برخی مواقع متفاوت باشند، چه برسد به کتابخانه هایی که برای دو رانتایم مختلف نوشته شده اند. همچنین تایپ های استفاده شده در Telegraf و grammY متفاوت اند بنابراین نمی توان از یکی از آن ها استفاده کرد. برای حل این مشکل مجبور به حذف «وابستگی به صورت مقدار» هستیم. در واقع پلاگین باید driver agnostic (بی تفاوت به درایور استفاده شده) باشد تا بتواند روی هر دو رانتایم اجرا شود. برای حل این مشکل یک اینترفیس جداگانه برای ردیس ساخته شد که فقط متد های استفاده شده در ratelimiter را، آن هم به صورت کلی، ذکر می کرد. با این کار می توانیم از هر دو درایور ioredis برای node و redis برای deno استفاده کنیم.
مستند سازی این پروژه، طبق رسم تیم grammY، توسط سازنده ی پلاگین (خودم) نوشته و با یک pull req به وب سایت رسمی اضافه شد که در آدرس grammy.dev/plugins/ratelimiter قابل مشاهده است. از آنجایی که این پلاگین یکی از اولین پلاگین هایی بود که هنگام توسعه ی هسته ی فریم ورک ساخته شد، زحمت نوشتن مستندات آموزشی برای ساخت پلاگین نیز بر عهده ی من قرار گرفت که در آدرس grammy.dev/plugins/guide قابل مشاهده است و یک ریپوزیتوری قالب (template repository) نیز برای آن ساخته شد.
لایسنس این پلاگین و تمامی پروژه های این تیم MIT می باشد.