برنامهنویس و چپترلید فرانتاند در کافهبازار
بازنویسی وبسایت کافه بازار
در پاییز ۹۷ تصمیم گرفتیم وبسایت کافهبازار را بازنویسی کنیم تا اضافه کردن امکانات جدید به آن، سادهتر شود. انگیزهی اصلی، اضافه کردن امکان تماشای ویدئو از طریق وب بود؛ ولی باید محتاطانه پیش میرفتیم تا متریکهای فعلی (نظیر میزان دانلود اپ بازار) آسیب نبیند. نسخهی قبلی legacy شده بود و توسعه روی آن راحت نبود. به مرور زمان امکانات جزئی زیادی اضافه شده بود که پشت هر کدام داستانی وجود داشت؛ از کد شامِد و دسترسیهای برنامه گرفته تا لوگوی اینماد و غیره. با بخشهایی سروکار داشتیم که معلوم نبود چرا به آن شکل نوشته شدهاند، یا متادیتاهایی که نمیدانستیم با چه هدفی اضافه شدهاند. بخشهایی از کدها توسط کسانی نوشته شده بود که دیگر در شرکت حضور نداشتند و با مشکلاتی از این دست روبهرو بودیم.
فارغ از وجود ریزهکاریها، تعداد زیاد کاربران فعال، اعمال تغییرات را به شدت ترسناک میکرد. فضای کار محصول پروداکشن متفاوت از فضای یک پروژهی استارتاپی است که هنوز کاربران چندانی ندارد و به تبع آن، اشتباهات هم هزینهی چندانی نخواهند داشت. در فضای پروداکشن باید خیلی محتاطانه و محافظهکارانه حرکت کرد؛ هر تصمیمی باید کاملاً حساب شده گرفته شود و با برنامهریزی، عملیاتی گردد.
در این مسیر به چالشهای زیادی برخورد کردیم. از مسائل مرتبط با معماری، لایهی ارتباط با بکند، پشتیبانی از مرورگرها، SEO و SSR و PWA گرفته تا استراتژی انتشار و چالشهای پرفرمنسی بعد از انتشار که در ادامه آنها را بررسی میکنیم.

گذشتهی Legacy
در گذشتههای دور، بکند کافهبازار (شامل کدهای وبسایت)، یک سرویس یکپارچه (monolithic) به نام «کافه» بود که با جنگو نوشته شده بود. داخل همان کدبیس، کدهای وبسایت هم قرار داشته است (فولدر «پنجره»). بعدها بهخاطر بزرگ شدن محصول بازار، تیمهای متفاوتی تشکیل شدند که وظایفشان به فیچر خاصی محدود میشد. کد زدن روی یک کدبیس واحد دیگر راحت نبود و معماری بکند به سمت مایکروسرویس شدن پیش رفت. به مرور، هر تیمی قسمتی از کد «کافه» را برداشت و مایکروسرویسهای جدا بالا آورد و مسئول توسعهی آنها شد. اما «پنجره» که همچنان به بخش زیادی از کدهای «کافه» وابستگی داشت، امکان جدا شدن کامل را نداشت.
بنابراین از کد «کافه» یک کپی مجزا گرفته شد و «سرویس پنجره» نام گرفت. به مرور که در کد «کافه» تغییراتی داده میشده، در پنجره هم ـ تنها در صورت لزوم ـ همزمان اعمال میشده است. تغییرات وبسایت هم فقط در «سرویس پنجره» اعمال میشد. به دلایل امنیتی، «پنجره» امکان دسترسی مستقیم به دیتابیس اطلاعات کاربران را نداشت؛ بنابراین برخی صفحات وبسایت که به این اطلاعات نیاز داشتند، داخل «کافه» فولدر «پنجره» توسعه داده شدند. یک nginx با config بزرگ و نسبتاً پیچیده هم وجود داشت که تشخیص میداد برای هر urlی از چه سرویسی باید جواب بگیرد («سرویس پنجره» یا کافه؟).

پروتکل ارتباطی کلاینت با بکند در گذشته custom json-rpc بوده؛ در واقع همان JSON-RPC که رمزنگاری کلید عمومی به آن اضافه شده بود و مربوط به زمانی بود که بهخاطر مشکلات تحریم امکان خرید ssl certificate وجود نداشت. سرویسهای بکند هم در گذشته با همین پروتکل با هم ارتباط داشتند، اما بعدها تصمیم گرفته شد که به مرور زمان به سمت استفاده از grpc حرکت کنیم.
امکان تغییرات سریع وجود نداشت؛ ممکن بود سرویسی grpc شود ولی یک سرویس قدیمیتر که با json-rpc کار میکرد به آن وابسته باشد. بنابراین یک gateway به اسم «دیلماج» (کلمهی ترکی به معنی مترجم) توسعه داده شد که سرویسها با پروتکلهای مختلف (grpc یا json-rpc) در پشت آن قرار گیرند و کلاینت از طریق هر پروتکلی (json-rpc یا grpc یا rest) بتواند به دیلماج وصل شود. در عین حال زمانی که پیادهسازی وبسایت را شروع کردیم هنوز برخی سرویسها پشت دیلماج نرفته بود و تنها راه ارتباطی با آنها custom json-rpc بود که پیادهسازی آن سمت وبسایت پیچیده بود.
مسیر آیندهی محصول این بود که اپ اندروید و وبسایت هر دو از طریق rest با بکند ارتباط برقرار کنند و برای کوتاه مدت تصمیم نداشتیم custom json-rpc را سمت وبسایت پیادهسازی کنیم؛ بنابراین سرویس جدیدی به اسم «کوشک» را کلید زدیم که در پشت آن دیلماج و سرویسهای خاص قرار میگرفت. البته بعدها که همهی سرویسها پشت دیلماج قرار گرفت، سرویس کوشک هم باید حذف میشد.

تصویر معماری خیلی خلاصه شده است و تعداد سرویسهای بکندی بیشتر از این است. این سرویسها ممکن است مستقیماً یا بهواسطهی دیلماج به هم ریکوئست بزنند. در عین حال سرویسهایی که وبسایت با آنها ارتباط مستقیم دارد اینها هستند:
اکانت: امکانات مرتبط با احراز هویت که jwt برمیگرداند، refresh token میدهد و غیره.
فهرست: لیستهای اپ/ویدئو که صفحات اصلی و لیستی (تازهها، برترینها، بروزشدهها و …) را به کمک یک سری سرویسهای دیگر تولید میکند و تحویل کلاینت میدهد.
سرچ: جستجو در اپها و فیلمها که ساختار خروجی شبیه به فهرست برمیگرداند؛ و امکان پیشگوی متن جستجو که وقتی کاربر در فیلد جستجو مشغول تایپ است یک سری موارد پیشنهادی ارائه میدهد.
سجل: یا همان شناسنامهی اپ که اطلاعات یک اپ خاص را به همراه اپهای مرتبط، لینک رسانهها، تعداد نصب و هر چیز مرتبط با اپ جمع میکند و یکجا به کلاینت تحویل میدهد.
ویدئو: فیچرهای مرتبط با ویدئو، صفحات فیلم یا اپیزود یک سریال، امکان پخش ویدئو و غیره.
پرداخت: فیچرهای مرتبط با خرید محصول (از طریق وبسایت فقط میتوان ویدئو خریداری کرد).
ظهور یک باریستا
نام سرویس وبسایت جدید کافهبازار را «باریستا» گذاشتیم؛ بالاخره کافه نیاز به یک باریستا هم داشت. استک فرانتاند را بعد از بحث و بررسی vue/nuxt انتخاب کردیم. دلیل انتخاب vue یکپارچگی با بقیهی پروژههای فرانتاندی محصول بازار بود تا در آینده که احتمالاً افراد دیگر هم درگیر میشوند، سربار یادگیری و جابجایی بین دو framework متفاوت را نداشته باشند. انتخاب nuxt هم با توجه به نیاز SSR بود که در بخش «دغدغههای SEO» به آن پرداخته خواهد شد.
چالش جدی درون وبسایت نمایش لیستهای فهرست بود. این لیستها ساختار متفاوتی دارند که از سمت بکند مشخص میشود؛ میتواند سطری یا ستونی باشد، عناصر میتوانند اپ، فیلم، سریال یا تبلیغات باشند و غیره. کامپوننتهای فهرست را طوری نوشتیم که برای «نتایج جستجو» یا «لیست اپهای مرتبط» هم بتوانیم از آنها استفاده کنیم. سرویسهای «سرچ» و «فهرست» و «سجل» (بخش اپهای مرتبط) ساختار دادهی یکسانی نداشتند و باید این دیتاها همسانسازی میشد که در لایهی api این کار را انجام دادیم (در بخش ارتباط با بکند بیشتر به این موضوع خواهیم پرداخت).
همچنین ریکوئست زدن داخل کامپوننتهای vue را تا جای ممکن به asyncData که nuxt روی کامپوننتهای page ارائه میدهد محدود کردیم و wrapper نوشتیم تا در صورت بروز هر گونه خطا در برقراری ارتباط با سرور، خطای مناسبی به کاربر نمایش دهیم (انواع خطاها نیز در لایهی api همسانسازی میشوند و همیشه ساختار یکسان و قابل پیشبینیای دارند). بدین ترتیب لازم نبود هر بار سناریوی خطاهای مختلف را بصورت جداگانه بررسی کنیم.
ارتباط با بکند
زمانی که وبسایت جدید را کلید زدیم، وضعیت چندان جالب نبود. apiها و پروتکل ارتباطی در حال تغییر بودند، فهرست هنوز پشت دیلماج نبود و باید json-rpc ریکوئست میزدیم، فیچرهایی مثل اکانت هنوز در حال توسعه بود و مایکروسرویس جدا نشده بود. علاوه بر مشکلات پروتکل ارتباطی، خود سرویسها هم خروجی متفاوتی برای موضوعات مشترک میدادند. مثلاً «لیست اپهای مرتبط» یا «نتایج جستجو» که از نظر ظاهری قرار بود شبیه «لیستهای فهرست» رندر شود، ولی ساختار دادهی متفاوتی را برمىگرداند و مواردی از این دست.
تصمیم بر این بود که در آینده این مسائل سمت بکند اصلاح شود؛ ولی همیشه مسألهی پشتیبانی از نسخههای قدیمی «کلاینت اندروید بازار» این مسیر را دشوار و سخت میکند. اگر ساختار دادهی خروجی یك api تغییر کند، باید همچنان مثل قبل به کلاینتهای قدیمی ارسال شود؛ تا زمانی که آن نسخه از کلاینت اندروید منسوخ شود و اصطلاحاً force update دهيم تا کاربرها مجبور به آپدیت به نسخهی جدید شوند. البته این force update دادن معمولاً پرهزینه است و آسان نیست (آخرین بار فکر میکنم سالها پیش دادیم). علاوه بر این minimum sdkی کلاینت اندروید بازار بالا رفته و بعضی نسخههای قدیمی اندروید را باید همچنان با نسخههای قدیمی کلاینت پشتیبانی کنیم.
با توجه به این مسائل باید از ابتدا به فکر راهکاری میبودیم تا در آینده این سبک تغییرات api هزینهی زیادی برای نگهداری وبسایت کافهبازار نداشته باشد و با هر تغییر api مجبور نباشیم تعداد زیادی کامپوننت را آپدیت کنیم. راهکاری که انتخاب کردیم این بود که داخل باریستا یك لایهی api نوشتیم که بعد از ریکوئست زدن، کل دیتاها را normalize کند و با ساختار همسان به کامپوننتها برگرداند؛ با اين روش کامپوننتها دیگر وابستگی مستقیم به apiهای بکند نداشتند. این لایهی api کاملاً مستقل از framework فرانتاندی نوشته شد تا در آینده در صورت نیاز reusable باشد. تمامی کدهایی که وظیفه normalize کردن دارند را هم wrap کردیم تا در صورت بروز خطا، صرفاً به sentry لاگ بفرستند و از قسمت مشکلدار داده (به دلیل تغییرات احتمالی بکند) صرفنظر کنند.
همچنین به این لایه امکان cache اضافه کردیم تا در کامپوننتها (تا جای ممکن) نیاز به store نداشته باشیم (سادگی پیادهسازی) و نگران بهینهسازی ریکوئست تکراری زدن هم نباشیم.

پشتیبانی از مرورگرها
با توجه به تعداد زیاد کاربران کافهبازار، گسترهی مرورگرهای مورد استفاده هم خیلی وسیع است. بیشتر کاربرها موبایلی هستند و با هدف دانلود اپ بازار وارد وبسایت میشوند؛ در نتيجه صفحهی دانلود اپ بازار مهمترین قسمت وبسایت از دید محصولی است که تحت هر شرایطی باید درست کار كند.
با توجه به این مسائل کاربران وبسایت را به دو دسته تقسیم کردیم: ۱. کاربری که از مرورگر مدرن استفاده میکند و میتوانيم وبسایت را به او نشان دهيم. ۲. کاربری که از مرورگر قدیمی استفاده میکند و باید مستقیماً به صفحهی دانلود هدایت شود.
صفحهی دانلود را بدون استفاده از library خاصی و بهگونهای که روی قدیمیترین مرورگرها هم درست کار کند نوشتیم. برای بقیهی وبسایت، browserslist داخل پروژه را با توجه به گسترهی مرورگرهایی که تصمیم داشتیم پشتیبانی کنیم کانفیگ کردیم و از پلاگین eslint-plugin-compat استفاده کردیم تا مطمئن باشیم کدهایی که مینویسیم این مرورگرها را پشتیبانی میکند. در ادامه سمت سرور و با استفاده از browserslist-useragent و شناسایی مرورگر کاربر، در صورتی که مرورگر در لیست پشتیبانی شده نبود، کاربر را به صفحهی دانلود اپ بازار هدایت کردیم (هر دوی این libraryها از browserslist داخل پروژه استفاده میکنند).
البته تحت شرایط دیگرى هم کاربر به صفحهی دانلود هدایت میشود تا تشویق به نصب شود. اگر user-agent اندروید باشد و صفحهی باز شده مرتبط به اپهای اندروید باشد، نسخه pwa را باز نکرده باشد، اگر خودش روی لینک «باز کردن وبسایت» در صفحهی دانلود کلیک نکرده باشد و غیره. آیتمهای صفحهی دانلود بازار (لینک دانلود فایل apk) هم با توجه به user-agent و نسخهی اندروید کاربر میتواند متفاوت باشد (یک فایل ejs با توجه به یک کانفیگ رندر میشود تا انتشار کلاینت نیاز به انتشار وبسایت نداشته باشد).

دغدغههای SEO
بعد از صفحهی دانلود اپ بازار، مهمترین کاربرد وبسایت، جذب کاربران از طریق موتور جستجو است. وبسایتهایی که SPA نوشته میشوند (سمت مرورگر رندر میشوند) احتمال دارد توسط برخی از crawlerها به درستی رندر نشوند (گوگل در سال ۲۰۱۵ اعلام کرده كه جاواسکریپت رندر میکند اما بعداً در سال ۲۰۱۹ پیشنهاد داده كه به هر حال بهتر است رندر سمت سرور هم داشته باشیم). علاوه بر موتور جستجو، سرویسهای دیگرى (همچون شبکههای مجازی) هم هستند که از متادیتاها (همچون پروتکل opengraph) برای نمایش بهتر لینکها استفاده میکنند.
بنابراین لازم است که صفحات سایت سمت سرور هم رندر شوند و html خالی به کلاینت ارسال نشود. این رندر کردن میتواند به روشهای مختلفی اتفاق بیافتد. یک راه حل ساده استفاده از ابزارهایی مثل puppeteer و rendertron و prerender و غیره است که به عنوان سرور جدا بالا باشند و بر اساس user-agent تصمیم بگیریم که صفحهی pre-render شده را برگردانيم یا نسخه SPA را.

مزیت این راهکار سادگی پیادهسازی است. اما CPU بیشتری مصرف میکند و مهمتر اینکه امکان ارائهی این html رندر شده به کاربر مرورگر معمولی نیست؛ چرا که امکان rehydration این html با DOM که سمت کلاینت تولید میشود وجود ندارد. در نتیجه سرعت بارگذاری محتوای اولیه (اولین لحظهاى که کاربر میتواند محتوای معنادار ببیند) در مرورگر معمولی کندتر میشود و همچنین امکان رندر برخی متاتگهای وابسته به دیتا هم نیست. مورد بعدی اینکه لاجیک رندر کردن با توجه به اینکه رندر کدام سمت اتفاق افتاده نمیتواند متفاوت باشد؛ مثلاً تگ img یا برخی کامپوننتها سمت سرور کامل رندر میشوند، اما سمت کلاینت به دلایل پرفرمنسی lazy-load هستند (در بخش «چالشهای پرفرمنسی» توضیح داده شده است).
راهکار دوم SSR است. با لحاظ کردن تفاوتهای مرورگر و nodejs میتوانيم این امکان را فراهم کنیم تا رندر شدن در یک سرور nodejs و مشابه مرورگر اتفاق بیافتد. با توجه به اینکه پیادهسازی SSR چالشها و edge-caseهای زیادی دارد تصمیم گرفتیم از nuxt استفاده کنیم تا سرعت توسعهی بالاتری برايمان به ارمغان بیاورد. به علاوه دیگر لازم نبود درگیر کارهای زیرساختی شويم (میخواستیم سریعتر به نتیجه برسیم و فرانتاند توسط یک نفر نوشته میشد).

چالش دیگر، url structure سایت بود که در نسخهی قدیمی چندان search engine friendly نبود، از طرفی هم وابستگی deep-linkهای کلاینت اندروید قدیم امکان تغییرات این url structure را سخت میکرد. با توجه به اینکه نسخهی جدید اپ اندروید هم در حال بازنویسی بود، تصمیم گرفتیم سمت وب چند مدل url structure را همزمان پشتیبانی کنیم و همچنان مدل قدیم را به عنوان canonical ست کنیم تا لینکهای گوگل همچنان امکان باز شدن توسط کلاینت اندروید را داشته باشند. به این ترتیب نسخه جدید اپ میتواند از url structure جدید پشتیبانی کند و در آینده که نسخهی قدیمی اپ منسوخ شد (force-update دادیم) آدرس canonicalها را هم اصلاح کنیم.
با توجه به اینکه nuxt ساختار url را از روی فولدرها میسازد، یک فایل routes.json جدا شامل انواع routeها ساختیم و از روی آن روتر nuxt را extend کردیم. یک تابع کمکی هم برای تولید لینکهای داخل کامپوننتها نوشتیم که اسم و پارامترهای یک route را میگیرد و با توجه به routes.json و با استفاده از path-to-regexp آدرس لینک را تولید میکند.
نسخه PWA
با توجه به اضافهشدن امکان تماشای ویدئو، تمایل داشتیم کاربران iOS را هم جذب کنیم و تصمیم گرفتیم نسخهی PWA را با کیفیت قابل قبولی ارائه دهیم. کیفیت قابل قبول به این معنا که هم Progressive باشد و هم Responsive، جابجایی بین صفحات سریع باشد (SPA)، سایت بدون دسترسی به اینترنت قابل نمایش باشد (Offline Mode)، لینک دادن به صفحات مختلف سایت ممکن باشد، قابلیت نصب توسط مرورگر به کاربر اعلام شود و ...
پیشرو (Progressive)
پیشرو بودن به این معنی است که گسترهی مرورگرهای قدیمی مورد استفاده توسط کاربران، نباید باعث شود از امکانات جدید مرورگرها استفاده نکنیم و کدبیس legacy تولید کنیم. اصل کد با syntax مدرن نوشته شود اما برای پشتیبانی مرورگرهای قدیمی transpile شود و apiهای جدید تا جای ممکن polyfill شوند. اگر در مواردی امکانش نبود (برخی امکانات css)، سایت همچنان قابل استفاده باشد و اصطلاحاً downgrade شود. البته حداقل را روی مرورگرهایی گذاشتیم که flex-box را پشتیبانی میکنند.
پاسخگو (Responsive)
برای سایز موبایل، تبلت و دسکتاپ تجربه کاربری متناسبی ارائه دهیم. نمونهی آن کامپوننت دراور موبایلی است که در سایز کوچک جایگزین navbar بالای سایت میشود. یا دکمهی back گوشی که باعث بسته شدن مودالها میشود. یا صفحات اپ/ویدئو و غیره که با توجه به سایز مرورگر، خودشان را تطبیق میدهند.
قابل کشف (Discoverable)
برای اینکه مرورگر امکان نصب نسخه PWA را به کاربر نمایش دهد، یک روش هندل کردن رویداد beforeinstallprompt توسط برنامهنویس است که پیادهسازی اصولی آن نیازمند تحقیق و بررسی بیشتر بوده و هنوز این راهکار را پیادهسازی نکردهایم.
راه دیگر، تنها ارائهی فایل manifest.json است که اپلیکیشن بودن سایت را اعلام میکند. با توجه به اینکه چندین نسخه از وبسایت همواره فعال است (استیجینگ برای تست داخلی، بتا تست بیرونی و نسخه پروداکشن)، این فایل با توجه به نسخه باید متفاوت باشد تا لوگو/عنوان نسخههای PWA متفاوت باشد.
یک راهکار این بود که این فایل در پایپلاین CI و با توجه به یک متغیر env متفاوت تولید شود، راه دیگر این بود که توسط سرور node و بهصورت داینامیک (با توجه به hostname) تولید شود. با توجه به استراتژی انتشار، راه دوم را انتخاب کردیم که در ادامه بیشتر آن را توضیح خواهیم داد.
قابل پیوند (Linkable)
تمامی صفحات سایت لینک داشته باشند و بتوان لینکها را به اشتراک گذاشت. لازمهی این کار آن است که تا جای ممکن از store یا state داخل کامپوننت استفاده نشود. حتی زبان سایت از طریق url مشخص میشود تا لینک دادن به زبانی خاص از یک صفحه ممکن باشد (در این مورد توانایی پشتیبانی از hreflang هم حائز اهمیت بود).
مستقل از اتصال به شبکه (Offline-Mode)
در صورت عدم اتصال اینترنت، نسخهی PWA باید بهصورت مینیمال باز شود و شبیه اپ واقعی رفتار کند (دایناسور معروف دیده نشود). لیست assetها در پایپلاین build تولید شده و داخل فایلی ذخیره شوند تا بعد از اولین بارگذاری pre-cache شود و در صورت نبود اینترنت سایت بالا بیاید.
همچنین نمیتوانستیم تمامی صفحات و routeها را pre-cache کنیم؛ nuxt هم بعد از رندر SSR، سمت کلاینت و بر اساس url رندر مجدد انجام نمیدهد. با توجه به این مسأله لازم بود که یک آدرس مصنوعی dummy وجود داشته باشد که نسخهی SPA است و SSR روی آن همواره غیر فعال است (سمت مرورگر و بر اساس آدرس رندر میشود). این آدرس به عنوان navigation route برای workbox رجیستر و pre-cache میشود. بدین ترتیب برای تمامی navigationهای سمت کلاینت همین آدرس رندر میشود و وقتی دسترسی به اینترنت وجود ندارد، تمامی صفحات میتوانند تشخیص دهند و پیغام خطای مناسب را در قالب اپلیکیشن نمایش دهند.
ریلود خودکار
یک مسألهی مهم در ارتباط با service worker، مشکلات عملکردی دکمه ریلود مرورگر است. مایل بودیم بعد از هر انتشار، حتی اگر چندین تب از وبسایت فعال است همگی ریلود شوند و نسخهی جدید را نمایش دهند. برای حل این موضوع زمانی که مرورگر، service worker جدید را تشخیص میدهد، از طریق service worker به همهی تبهای باز دستور ریلود شدن ارسال میکنیم.
همچنین برای مواقع ضروری یک api روی سرور node اضافه کردیم که شماره نسخه فعلی باریستا را برمیگرداند، و سمت فرانت بصورت دورهای (هر ۱۰ دقیقه یک بار) این api را فراخوانی میکنیم تا در صورتی که قسمت minor یا major در نسخه تغییر کرده بود، cache و localStorage پاک و صفحه ریلود کامل شود. این مساله برای مواقعی است که ساختار دادهای که در cache یا localStorage ذخیره میشود در نسخهی جدید عوض شده (مثلا دادهای که به صورت عددی ذخیره شده، باید آرایه باشد) و به خطا میخوریم.

استراتژی انتشار
استراتژی انتشار نهایی هم بدون چالش نبود. تصمیم داشتیم همواره یک انتشار جدا برای تست انسانی قبل از انتشارهای نهایی داشته باشیم و بعد از اطمینان از اینکه همه چیز به درستی پیادهسازی شده (فیچر، ui و غیره) همین نسخه را به پروداکشن انتقال دهیم. کاربر فعال وبسایت در لحظه چیزی بین ۳ تا ۱۲ هزار نفر است و با توجه به اینکه مدام روی وبسایت تغییرات داریم، به دنبال راهکاری بودیم تا در لحظهی انتشار down-time نداشته باشیم و هیچ کاربری به مشکل برنخورد.
استراتژی متداول انتشار سرویسهای بکندی روی کوبرنتیز Rolling Deployment است. به این ترتیب که podهای نسخهی قدیم کمکم با نسخهی جدید جایگزین میشوند و طی مدتی محدود نسخهی قدیم و جدید همزمان فعال است. این استراتژی برای وبسایت این مشکل را ایجاد میکرد که ممکن بود مثلا ریکوئست یک صفحه html و ریکوئست assetهای آن به podهای نسخههای مختلف ارسال شود و در نتیجه برخی assetها خطای ۴۰۴ بگیرد (نام فایلها content-hash است و احتمال دریافت فایل اشتباه وجود ندارد).
این مسأله بعداً از طریق آپلود فایلهای استاتیک روی CDN هم قابل حل بود (همزمان assetهای چندین نسخه میتواند در یک bucket موجود باشد)، اما باز هم مشکل برای فایل service worker وجود داشت (باید از مسیری دریافت شود که قرار است ریکوئستهایش را کنترل کند و نمیتواند روی CDN ریخته شود) و ممکن بود انتشار جدید، از service worker مربوط به انتشار قدیم استفاده کند و لیست فایلهایی که باید pre-cache شوند اشتباه باشد.
در نتیجه بهترین راهکار، پیادهسازی استراتژی انتشار آبی/سبز بود. به این شکل که در لحظه دو نسخه بهصورت موازی بالا است و بعد از trigger توسط gitlab-ci با هم جابهجا میشوند. یکی از نسخهها (آبی یا سبز) به عنوان نسخهی پروداکشن (با چند replica) و دیگری به عنوان نسخهی استیجینگ (یک replica) کاربرد خواهد داشت.

این استراتژی (جابجایی آنی deployment) روی باقی تصمیمات معماری هم تاثیرگذار است. مهمترین تاثیر آن این است که همواره فقط یک build واحد میتواند وجود داشته باشد و نمیشود با توجه به متغیرهای env خروجیهای متفاوتی تولید کرد. اگر جایی لازم باشد، باید خروجی بهصورت داینامیک و بر اساس hostname که ریکوئست میآید تولید شود. مواردی از جمله:
- مقدار ua-id که برای google analytics استفاده میشود
- عنوان نسخهی PWA که میتوان روی گوشی نصب کرد
- هدر X-Robots-Tag که ارسال میشود و مجوز crawl شدن را برای نسخههای بتا و استیجینگ به موتورهای جستجو نمیدهد
- و غیره.
چالشهای پرفرمنس
بعد از انتشار وبسایت متوجه یک سری مشکلات پرفرمنسی در مرورگر و همچنین سمت سرور شدیم که برای هر کدام باید چارهای میاندیشیدیم. سمت مرورگر اینترنت مصرفی بالا بود و بارگذاری کامل صفحات نسبتاً زمانبر بود. سمت سرور هم بهخاطر رندر SSR مصرف CPU خیلی بالا بود و هر از چندی podها توسط کوبرنتیز کُشته و منجر به downtime میشد. برای هر کدام از موارد فوق راهکارهایی در نظر گرفتیم.
استراتژی باندلینگ
حجم باندل js تولید شده بزرگ بود و تقریباً نیمی از آن مربوط به video-js بود که فقط برای صفحات ویدئو استفاده کرده بودیم. برای حل این مسأله، فایلهای js بزرگ را Dynamic Import کردیم تا تنها در زمان نیاز دانلود شوند، webpack را هم کانفیگ کردیم (از طریق nuxt) تا کدهای vendors و common بهصورت باندلهای جداگانه ساخته شوند و سمت مرورگر بهتر cache شوند. با توجه به اینکه صفحات مختلف سایت از کامپوننتهای عموماً یکسانی استفاده میکنند، جدا کردن chunk صفحات آوردهی چندانی نداشت و همه را در یک فایل build کردیم.
تصاویر Responsive و Lazy
اکثریت صفحات وبسایت دارای تصاویر زیادی هستند. در بکند چندین سایز از تصاویر اپها موجود است، سمت فرانت باید از امکانات جدید html5 برای نمایش responsive تصاویر استفاده میکردیم تا کاربر با توجه به سایز صفحه، کوچکترین تصویری که با کیفیت خوب نمایش داده میشود را دریافت کند. همچنین تصاویر باید lazy-load میشد تا فقط در صورتی که در صفحه دیده میشوند از سرور دریافت شوند.
برای حل این موضوع یک کامپوننت LazyImg نوشتیم که سایز پیشفرض را به عنوان پارامتر میگیرد و تا وقتی دیده نمیشود، به عنوان تصویر پیشفرض یک مربع خاکستری رندر میکند (یک data-uri که از روی svg string تولید شده است). بعد از اینکه تصویر دیده شد (احتمالاً بعد از اسکرول)، تگ picture با سایزهای responsive رندر میشود.
وجود تصویر پیشفرضی که نیاز به ارتباط با سرور ندارد و به عنوان src ست میشود، باعث میشود مرورگر تا لحظهای که تصویر اصلی کاملاً لود نشده، همان پیشفرض را نشان دهد و در نتیجه در حین لود شدن تصویر، لایوت صفحه جابجا نشود و حس بدی را به کاربر منتقل نکند.
در این کامپوننت همچنین امکان auto-reload را هم گذاشتیم که اگر بارگذاری تصویر به دلیل اختلالات شبکه/اینترنت به خطا خورد، پس از وقفهای کوتاه خودش تلاش مجدد را انجام دهد و نیاز به ریلود صفحه توسط کاربر نباشد. برای همهی تصاویر داخل وبسایت از این کامپوننت استفاده کردیم که حجم اینترنت مصرفی را به میزان قابل توجهی کاهش داد.

انتقال Assetها به CDN
غیر از تصاویر اپها که توسط سرویسهای دیگری نگهداری و به CDN منتقل میشود، تعداد assetهای تولید شدهی باریستا کم نبود. از فونتها و تصاویر لوگو و غیره گرفته تا فایلهای js/css که در فرایند build تولید میشوند. قبلاً این فایلها توسط nginx که داخل pod باریستا بود serve میشد. اوایل که باریستا را منتشر کردیم، service worker را غیرفعال کرده بودیم و مشکل حادی نداشتیم. اما بعداً که تصمیم گرفتیم service worker را هم فعال کنیم، بخاطر pre-cache شدن assetها تعداد ریکوئستهایی که به pod میآمد تا حدود ۲ برابر زیاد شد.
بنابراین تصمیم گرفتیم در فرایند build تمامی فایلهای static تولید شده را به CDN کپی کنیم که در نهایت باعث کاهش ۳۰درصدی مصرف CPU روی pod باریستا شد.
ماژول Cache برای رندرهای SSR
برخی صفحات خاص (مثل اپهایی که کاربران زیادی از سایتشان به بازار میآیند، یا صفحهی اصلی بازار، یا صفحاتی که روی آنها کمپین تبلیغاتی میرفتیم)، تعداد ریکوئست زیادی داشتند و هر بار رندر SSR هزینهی زیادی داشت. بنابراین برای nuxt یک ماژول cache نوشتیم که صفحات رندر شده را داخل redis به مدت یک ساعت نگه میدارد و ریکوئستهای بعدی از این cache استفاده میکنند.
رندرهای SSR هوشمند
با وجود همهی راهکارهای فوق، همچنان بار روی سرور باریستا زیاد بود. هر از چندی که ریکوئستها peak میزد (به صفحات متنوع، نه یکسان)، مصرف CPU بعضی podها بالا میرفت و کوبرنتیز pod مربوطه را میکُشت و تا pod دوباره بالا برگردد، بار روی باقی podها تقسیم میشد؛ در نتیجه قبل از بالا اومدنِ pod کشته شده، باقی podها مثل دومینو یکی بعد از دیگری کُشته میشد و بعد از چند بار کُشته شدن در CrashLoopBackOff میافتاد و وبسایت کاملاً down میشد.
برای حل این مسأله یک دقیقه زمان warmup تعیین کردیم که پس از بالا آمدن pod، در این بازه رندر SSR انجام ندهد. به این ترتیب بعد از بالا آمدن مجدد، این فرصت فراهم بود که تا زمانی که باقی podها هم بالا بیاید، مصرف کمتری داشته باشد و کُشته نشود.
در ادامه rate-limit روی رندرهای SSR گذاشتیم که تعداد رندرهای SSR برای هر IP در دقیقه را محدود کنیم. همچنین podها مصرف CPU خودشان را مانیتور کنند و در صورتی که مصرف CPU در یک ثانیه گذشته آنها بیشتر از ۸۰ درصد شد، SSR ارائه ندهند.

نتیجه نهایی
وقتی یک محصول با کیفیت مناسب پیادهسازی و اجرا شده باشد، در بهترین شرایط هم نباید توقع تمجید را از سوی کاربر داشته باشیم. درست کار کردنِ یک سیستم انتظار طبیعی هر کاربری است، اما اگر کوچکترین اشتباهی رخ دهد باید منتظر انتقاد و نارضایتی باشیم. خوشبختانه در مورد وبسایت جدید بازار (با وجود کاربران زیاد) اعلام نارضایتی ناچیز بود و آمار دانلودی که در نظر داشتیم آسیبی نبیند، حتی برای مدتی رشد ۱۰-۱۵ درصدی داشت. (البته این آمار بعداً به روال معمول قدیم برگشت).
پایداری سرویسهایی که روی کوبرنتیز هستند، نسبتاً بالاست. هر سرویس چندین replica دارد و درصورتی که هر کدام از آنها به مشکل بربخورند، باقی podها پاسخگو هستند تا اینکه کوبر pod جدیدی بالا بیاورد. بزرگترین مسألهی باریستا محدودیت منابع بود که هر از چندی منجر به down شدن میشد و بعد از پیادهسازی cache و warmup و rate-limit بهطور کامل برطرف شد. پایداری بالایی که آخر هفتهی آرام و بدون استرسی را برایمان به ارمغان آورد.
در عین حال شاید بتوان گفت که مهمترین موفقیت حاصل از بازنویسی وبسایت، خلاصی از یک سیستم legacy بود که امکان توسعهی بیشتر را واقعاً سخت کرده بود.
در کافهبازار هنوز راهی طولانی در پیش داریم و روز به روز با چالشهای جدیدتری روبهرو میشویم. سرویسهای فرانتاندی زیادی هستند که یا هنوز نوشته نشدهاند و یا نیاز به بازنویسی دارند. اگر به زمینهی فرانتاند علاقهمند هستید، برای همراهی ما در این مسیر به ما بپیوندید.
مطلبی دیگر از این انتشارات
کامپوز و کافه بازار
مطلبی دیگر از این انتشارات
از هزاران درخواست در روز به هزاران درخواست در ثانیه
مطلبی دیگر از این انتشارات
زیرساخت کافهبازار: فرهنگ دوآپس را چگونه شکل دادیم؟
https://game-a-tech.com/
مشتری پایه این اکانت کافه هستم
فقط یک چیزی را متوجه نشدم بعد از این که متوجه شدید بک ای که با جنگو نوشتن بودن خیلی سنگین و با توجه به نیاز های قبل بوده بعد شما چه کردید؟ بک را هم به یک پلتفرم دیگه تغییر دادید یا فقط فرانت را عوض کردید
اگر تغییر دادید از پایتون و جنگو به کدام سمت رفتید ؟
تشکر
در مورد SSR هم باید عرض کنم سلوشنهای Next و Nuxt برای ریاکت و Vue خوب هستند، ولی یک سلوشن برای چنین پروداکشنی نیست. برای موارد بزرگ، به عنوان یک دولوپر باید خالی خالی خودت کانفیگ کنی و Webpack و express رو تنظیم کنی، SSR هم خودت باید کانفیگ کنی، روت رو باید سمت سرور لیسن کنی و دیتا براش serve کنی. توی اون scale کمی انتخابها متفاوته.
نباید چیز Black Box ـی برات بمونه.