حالت تاریک
فصل ۲ — یکپارچهسازی
درست انجام دادن یکپارچهسازی، بهنظر من مهمترین جنبهٔ فناوری مرتبط با میکروسرویسهاست. اگر خوب انجامش دهید، میکروسرویسهایتان autonomy خود را حفظ میکنند و میتوانید آنها را مستقل از کل سیستم تغییر دهید و release کنید. اگر اشتباه انجامش دهید، فاجعه در انتظار است. امیدوارم پس از خواندن این فصل یاد بگیرید چگونه از برخی بزرگترین pitfalls که تلاشهای دیگر برای SOA را آزار داده و ممکن است در سفر شما به سمت میکروسرویسها در انتظار باشد اجتناب کنید.
جستجوی فناوری ایدهآل یکپارچهسازی
گزینههای گیجکنندهای برای نحوهٔ گفتوگوی یک میکروسرویس با دیگری وجود دارد. اما کدام درست است: SOAP؟ XML-RPC؟ REST؟ Protocol buffers؟ بهزودی به آنها میپردازیم، اما پیش از آن بیایید ببینیم از هر فناوری که انتخاب میکنیم چه میخواهیم.
اجتناب از تغییرات شکننده
گاهی ممکن است تغییری بدهیم که consumerها هم باید تغییر کنند. بعداً نحوهٔ مدیریت این موضوع را بحث میکنیم، اما میخواهیم فناوریای انتخاب کنیم که این اتفاق تا حد امکان کم رخ دهد. مثلاً اگر میکروسرویس فیلدهای جدیدی به دادهای که میفرستد اضافه کند، consumerهای موجود نباید تحت تأثیر قرار گیرند.
APIهای technology-agnostic نگه دارید
اگر بیش از ۱۵ دقیقه در صنعت IT کار کردهاید، نیازی نیست بگویم در فضایی کار میکنیم که بهسرعت در حال تغییر است. تنها قطعیت، تغییر است. ابزار، framework و زبانهای جدید مدام ظهور میکنند و ایدههای تازهای را پیاده میکنند که میتوانند سریعتر و مؤثرتر کار کنیم. شاید الان یک فروشگاه .NET باشید. اما یک سال یا پنج سال بعد چطور؟ اگر بخواهید با stack فناوری جایگزین آزمایش کنید که شاید productiveتر باشد؟
من طرفدار زیاد نگه داشتن گزینهها هستم؛ به همین دلیل طرفدار میکروسرویسها هستم. همچنین فکر میکنم بسیار مهم است APIهایی که برای ارتباط بین میکروسرویسها استفاده میشود technology-agnostic باشند. یعنی از فناوری یکپارچهسازیای اجتناب کنیم که تعیین کند برای پیادهسازی میکروسرویسها چه stackهایی میتوانیم استفاده کنیم.
سرویس را برای consumerها ساده کنید
میخواهیم استفاده از سرویس برای consumerها آسان باشد. داشتن میکروسرویس زیبا refactorشده اگر هزینهٔ استفاده بهعنوان consumer آسمانخراش باشد، ارزش زیادی ندارد! پس بیایید ببینیم چه چیزی استفاده از سرویس تازهٔ عالی ما را آسان میکند. ایدهآل این است که به clientها آزادی کامل در انتخاب فناوری بدهیم، اما از طرف دیگر ارائهٔ client library میتواند پذیرش را آسان کند. اغلب، با این حال، چنین کتابخانههایی با چیزهای دیگری که میخواهیم به دست آوریم ناسازگارند. مثلاً ممکن است از client library برای آسانتر کردن کار consumerها استفاده کنیم، اما این میتواند به هزینهٔ coupling بیشتر منجر شود.
جزئیات پیادهسازی داخلی را پنهان کنید
نمیخواهیم consumerها به پیادهسازی داخلی ما وابسته باشند. این به coupling بیشتر منجر میشود. یعنی اگر بخواهیم چیزی درون میکروسرویس تغییر دهیم، میتوانیم consumerها را بشکنیم چون آنها هم باید تغییر کنند. هزینهٔ تغییر را افزایش میدهد — دقیقاً نتیجهای که میخواهیم از آن اجتناب کنیم. همچنین یعنی احتمالاً کمتر میخواهیم تغییر بدهیم از ترس upgrade کردن consumerها، که میتواند به technical debt بیشتر درون سرویس منجر شود. پس هر فناوریای که ما را به expose کردن جزئیات representation داخلی سوق دهد باید اجتناب شود.
تعامل با مشتریها
حالا که چند راهنما برای انتخاب فناوری خوب برای یکپارچهسازی بین سرویسها داریم، بیایید برخی رایجترین گزینهها را ببینیم و بفهمیم کدام برای ما بهتر است. برای کمک به تفکر، مثال واقعی از MusicCorp را انتخاب میکنیم.
ایجاد مشتری در نگاه اول میتواند مجموعهای ساده از عملیات CRUD به نظر برسد، اما برای بیشتر سیستمها پیچیدهتر از آن است. ثبتنام مشتری جدید ممکن است فرآیندهای اضافی مثل راهاندازی پرداختهای مالی یا ارسال ایمیل خوشآمدگویی را آغاز کند. و وقتی مشتری را تغییر یا حذف میکنیم، فرآیندهای کسبوکار دیگر هم ممکن است trigger شوند.
پس با این در نظر، باید راههای مختلفی که ممکن است بخواهیم با مشتریان در سیستم MusicCorp کار کنیم را ببینیم.
پایگاه داده مشترک
رایجترین شکل یکپارچهسازی که من یا همکارانم در صنعت میبینیم، یکپارچهسازی پایگاه داده (DB) است. در این دنیا، اگر سرویسهای دیگر اطلاعات از یک سرویس بخواهند، به پایگاه داده دست میبرند. و اگر بخواهند آن را تغییر دهند، به پایگاه داده دست میبرند! وقتی اول به آن فکر میکنید واقعاً ساده است و احتمالاً سریعترین شکل یکپارچهسازی برای شروع — که احتمالاً محبوبیتش را توضیح میدهد.
شکل ۲-۱. استفاده از یکپارچهسازی DB برای دسترسی و تغییر اطلاعات مشتری
شکل ۲-۱ UI ثبتنام ما را نشان میدهد که با انجام عملیات SQL مستقیم روی پایگاه داده مشتری ایجاد میکند. همچنین اپلیکیشن مرکز تماس را نشان میدهد که با اجرای SQL روی پایگاه داده دادهٔ مشتری را میبیند و ویرایش میکند. و انبار با query کردن پایگاه داده اطلاعات سفارشهای مشتری را بهروز میکند. الگوی رایجی است، اما پر از دشواری.
اول، به طرفهای بیرونی اجازه میدهیم جزئیات پیادهسازی داخلی را ببینند و به آن bind شوند. ساختارهای دادهای که در DB ذخیره میکنم برای همه fair game است؛ بهطور کامل با همهٔ طرفهایی که به پایگاه داده دسترسی دارند به اشتراک گذاشته میشوند. اگر تصمیم بگیرم schema را برای بازنمایی بهتر داده یا نگهداری آسانتر سیستم تغییر دهم، میتوانم consumerها را بشکنم. DB عملاً یک API بسیار بزرگ و مشترک است که همچنین quite brittle است. اگر بخواهم منطق مرتبط با مثلاً نحوهٔ مدیریت مشتریان توسط helpdesk را تغییر دهم و این به تغییر پایگاه داده نیاز داشته باشد، باید بسیار مراقب باشم بخشی از schema که سرویسهای دیگر استفاده میکنند را نشکنم. این وضعیت معمولاً به نیاز به regression testing زیاد منجر میشود.
دوم، consumerها به انتخاب فناوری خاصی وابستهاند. شاید الان ذخیرهٔ مشتریان در پایگاه دادهٔ رابطهای منطقی باشد، پس consumerها از driver مناسب (احتمالاً DB-specific) برای گفتوگو با آن استفاده میکنند. اگر با گذشت زمان بفهمیم ذخیره در پایگاه دادهٔ غیررابطهای بهتر است چه؟ آیا میتواند آن تصمیم را بگیرد؟ پس consumerها بهطور صمیمی به پیادهسازی سرویس مشتری وابستهاند. همانطور که قبلاً بحث کردیم، واقعاً میخواهیم جزئیات پیادهسازی از consumerها پنهان بماند تا سرویس ما سطحی از autonomy در نحوهٔ تغییر درونساختارش در طول زمان داشته باشد. خداحافظ، loose coupling.
در نهایت، بیایید لحظهای به رفتار فکر کنیم. منطقی مرتبط با نحوهٔ تغییر مشتری وجود خواهد داشت. آن منطق کجاست؟ اگر consumerها مستقیماً DB را دستکاری کنند، باید منطق مرتبط را مالک باشند. منطق انجام همان دستکاریها روی مشتری ممکن است اکنون بین چند consumer پخش شود. اگر انبار، UI ثبتنام و UI مرکز تماس همگی باید اطلاعات مشتری را ویرایش کنند، باید باگ را در سه جای مختلف درست کنم و آن تغییرات را هم deploy کنم. خداحافظ، cohesion.
یادتان هست وقتی دربارهٔ اصول پشت میکروسرویسهای خوب صحبت کردیم؟ cohesion قوی و loose coupling — با یکپارچهسازی پایگاه داده، هر دو را از دست میدهیم. یکپارچهسازی پایگاه داده اشتراک داده بین سرویسها را آسان میکند، اما دربارهٔ اشتراک رفتار کاری نمیکند. representation داخلی ما روی wire به consumerها expose میشود و اجتناب از تغییرات شکننده میتواند بسیار دشوار باشد، که ناگزیر به ترس از هر تغییری منجر میشود. تا حد ممکن (تقریباً) همهجا اجتناب کنید.
برای بقیهٔ فصل، سبکهای مختلف یکپارچهسازی را بررسی میکنیم که سرویسهای همکار را در بر میگیرد و خودشان representationهای داخلی را پنهان میکنند.
همزمان در مقابل ناهمزمان
پیش از ورود به جزئیات انتخابهای فناوری مختلف، باید یکی از مهمترین تصمیمات دربارهٔ نحوهٔ همکاری سرویسها را بحث کنیم. آیا ارتباط باید synchronous باشد یا asynchronous؟ این انتخاب بنیادی ناگزیر ما را به سمت جزئیات پیادهسازی خاصی هدایت میکند.
با ارتباط synchronous، فراخوانی به سرور remote انجام میشود که تا تکمیل عملیات block میکند. با ارتباط asynchronous، caller منتظر تکمیل عملیات قبل از برگشتن نمیماند و شاید حتی اهمیتی ندهد عملیات اصلاً تکمیل شود یا نه.
ارتباط synchronous میتواند استدلال دربارهٔ آن آسانتر باشد. میدانیم چه زمانی چیزها با موفقیت یا شکست تمام شدند. ارتباط asynchronous برای jobهای long-running بسیار مفید است، جایی که باز نگه داشتن اتصال برای مدت طولانی بین client و server عملی نیست. همچنین وقتی latency پایین لازم است خوب کار میکند، جایی که block کردن فراخوانی در انتظار نتیجه میتواند چیزها را کند کند. بهخاطر ماهیت شبکهها و دستگاههای موبایل، ارسال درخواستها و فرض اینکه چیزها کار کردهاند (مگر خلافش گفته شود) میتواند UI را responsive نگه دارد حتی اگر شبکه highly laggy باشد. از طرف دیگر، فناوری مدیریت ارتباط asynchronous میتواند کمی پیچیدهتر باشد، همانطور که بهزودی بحث میکنیم.
این دو حالت ارتباط میتوانند دو سبک idiom همکاری متفاوت را فعال کنند: request/response یا event-based. با request/response، client درخواست را آغاز میکند و منتظر پاسخ میماند. این مدل بهوضوح با ارتباط synchronous همراستاست، اما برای asynchronous هم میتواند کار کند. ممکن است عملیاتی را آغاز کنم و callback ثبت کنم و از سرور بخواهم وقتی عملیاتم تمام شد به من بگوید.
با همکاری event-based، چیزها را معکوس میکنیم. بهجای اینکه client درخواستها را آغاز کند و بخواهد کارها انجام شود، میگوید این اتفاق افتاد و انتظار دارد طرفهای دیگر بدانند چه کار کنند. هرگز به کسی نمیگوییم چه کار کند. سیستمهای event-based بهطور ذاتی asynchronous هستند. هوشها توزیعتر است — یعنی منطق کسبوکار در مغزهای مرکزی متمرکز نیست، بلکه بهطور یکنواختتر به همکاران مختلف push میشود. همکاری event-based همچنین highly decoupled است. client که event emit میکند راهی ندارد بداند چه کسی یا چه چیزی واکنش نشان میدهد، که همچنین یعنی میتوانید subscriberهای جدید به این eventها اضافه کنید بدون اینکه client هرگز بداند.
آیا driver دیگری هست که ما را به یک سبک بر دیگری سوق دهد؟ یک عامل مهم این است که این سبکها چقدر برای حل مسئلهای که اغلب پیچیده است مناسباند: چگونه فرآیندهایی که مرز سرویس را درمینوردند و ممکن است long-running باشند را مدیریت کنیم؟
ارکستراسیون در مقابل رقصآرایی
وقتی منطق پیچیدهتر و بیشتری مدل میکنیم، با مسئلهٔ مدیریت فرآیندهای کسبوکار که مرز سرویسهای فردی را درمینوردند مواجه میشویم. و با میکروسرویسها، زودتر از معمول به این حد میرسیم. بیایید مثالی از MusicCorp بگیریم و ببینیم وقتی مشتری ایجاد میکنیم چه میشود:
- رکورد جدیدی در بانک امتیاز وفاداری برای مشتری ایجاد میشود.
- سیستم پستی ما بستهٔ خوشآمدگویی میفرستد.
- ایمیل خوشآمدگویی به مشتری میفرستیم.
این را بهعنوان flowchart مدل کردن مفهومی بسیار آسان است، همانطور که در شکل ۲-۲ انجام دادیم.
شکل ۲-۲. فرآیند ایجاد مشتری جدید
وقتی به پیادهسازی این flow میرسیم، دو سبک معماری میتوانیم دنبال کنیم. با orchestration، به مغز مرکزی تکیه میکنیم که فرآیند را هدایت و پیش میبرد، مثل رهبر ارکستر. با choreography، به هر بخش سیستم کارش را میگوییم و جزئیات را به خودش میگذاریم، مثل رقصندگان که راه خود را پیدا میکنند و به دیگران واکنش نشان میدهند.
بیایید ببینیم راهحل orchestration برای این flow چگونه است. احتمالاً سادهترین کار این است که سرویس مشتری ما بهعنوان مغز مرکزی عمل کند. هنگام ایجاد، با بانک امتیاز وفاداری، سرویس ایمیل و سرویس پستی همانطور که در شکل ۲-۳ میبینیم از طریق سریای از فراخوانیهای request/response صحبت میکند. خود سرویس مشتری میتواند پیگیری کند مشتری در این فرآیند کجاست. میتواند بررسی کند آیا حساب مشتری راهاندازی شده، ایمیل فرستاده شده یا پست تحویل داده شده. flowchart شکل ۲-۲ را میتوانیم مستقیماً در کد مدل کنیم. حتی میتوانیم از ابزاری استفاده کنیم که این را برای ما پیاده میکند، شاید با rules engine مناسب. ابزارهای تجاری برای همین منظور بهصورت نرمافزار مدلسازی فرآیند کسبوکار وجود دارند. اگر از request/response synchronous استفاده کنیم، حتی میتوانیم بدانیم هر مرحله کار کرده یا نه.
شکل ۲-۳. مدیریت ایجاد مشتری از طریق orchestration
نقطهٔ ضعف رویکرد orchestration این است که سرویس مشتری میتواند بیش از حد بهعنوان مرجع حاکم مرکزی شود. میتواند hub وسط تار شود و نقطهٔ مرکزی که منطق شروع به زندگی در آن میکند. این رویکرد را دیدهام که به تعداد کمی سرویس خدایانگار هوشمند منجر شود که به سرویسهای anemic مبتنی بر CRUD میگویند چه کار کنند.
با رویکرد choreographed، میتوانیم سرویس مشتری بهصورت asynchronous event بفرستد که میگوید Customer created. سرویس ایمیل، سرویس پستی و بانک امتیاز وفاداری به این eventها subscribe میکنند و متناسب واکنش نشان میدهند، همانطور که در شکل ۲-۴ است. این رویکرد بهطور قابلتوجهی decoupledتر است. اگر سرویس دیگری باید به ایجاد مشتری واکنش نشان دهد، فقط باید به eventها subscribe کند و وقتی لازم است کارش را انجام دهد. نقطهٔ ضعف این است که نمای صریح فرآیند کسبوکار که در شکل ۲-۲ میبینیم اکنون فقط بهصورت ضمنی در سیستم ما بازتاب یافته.
شکل ۲-۴. مدیریت ایجاد مشتری از طریق choreography
این یعنی کار اضافی لازم است تا مطمئن شویم میتوانیم نظارت کنیم و پیگیری کنیم چیزهای درست اتفاق افتادهاند. مثلاً آیا میدانستید اگر بانک امتیاز وفاداری باگ داشت و بهخاطری حساب درست راهاندازی نکرد؟ رویکردی که دوست دارم ساخت سیستم monitoring است که صریحاً با نمای فرآیند کسبوکار در شکل ۲-۲ مطابقت دارد، اما آنچه هر سرویس بهعنوان موجودیت مستقل انجام میدهد را پیگیری میکند و به شما اجازه میدهد استثناهای عجیب را روی جریان فرآیند صریحتر ببینید. flowchart که قبلاً دیدیم نیروی محرکه نیست، فقط یکی از لنزها برای دیدن رفتار سیستم است.
بهطور کلی، سیستمهایی که بیشتر به سمت رویکرد choreographed متمایلاند loosely coupledترند و انعطافپذیرتر و برای تغییر مناسبترند. اما باید کار اضافی برای نظارت و پیگیری فرآیندها در مرزهای سیستم انجام دهید. پیادهسازیهای heavily orchestrated را بسیار brittle یافتهام، با هزینهٔ تغییر بالاتر. با این در نظر، قویاً ترجیح میدهم به سمت سیستم choreographed هدف بگیریم، جایی که هر سرویس بهاندازهٔ کافی هوشمند است نقشش در کل رقص را بفهمد.
عوامل زیادی برای unpack کردن وجود دارد. فراخوانیهای synchronous سادهترند و بلافاصله میفهمیم کار کرده یا نه. اگر semantics request/response را دوست داریم اما با فرآیندهای longer-lived سر و کار داریم، میتوانیم درخواستهای asynchronous آغاز کنیم و منتظر callback بمانیم. از طرف دیگر، همکاری event asynchronous به ما کمک میکند رویکرد choreographed را بپذیریم که میتواند سرویسهای بهطور قابلتوجهی decoupledتری yield کند — چیزی که میخواهیم برای اطمینان از independently releasable بودن سرویسها تلاش کنیم.
البته آزادیم mix and match کنیم. برخی فناوریها طبیعیتر به یک سبک یا دیگری میخورند. اما باید برخی جزئیات پیادهسازی فنی متفاوت را درک کنیم که بیشتر به انتخاب درست کمک میکند.
برای شروع، بیایید دو فناوری را ببینیم که وقتی request/response را در نظر میگیریم خوب مینشینند: remote procedure call (RPC) و REpresentational State Transfer (REST).
فراخوانی رویه از راه دور (RPC)
Remote procedure call به تکنیک فراخوانی محلی و اجرای آن روی سرویس remote در جایی اشاره دارد. انواع مختلفی از فناوری RPC وجود دارد. برخی به داشتن interface definition تکیه دارند (SOAP، Thrift، protocol buffers). استفاده از interface definition جدا میتواند تولید client و server stub برای stackهای فناوری مختلف را آسانتر کند؛ مثلاً میتوانم سرور Java با interface SOAP داشته باشم و client .NET از تعریف Web Service Definition Language (WSDL) interface تولید شود. فناوری دیگر مثل Java RMI به coupling محکمتر بین client و server نیاز دارد و هر دو باید همان فناوری زیرین را استفاده کنند اما نیاز به interface definition مشترک ندارند. همهٔ این فناوریها، با این حال، یک ویژگی هستهٔ مشترک دارند: فراخوانی محلی را شبیه فراخوانی remote میکنند.
بسیاری از این فناوریها binary هستند، مثل Java RMI، Thrift یا protocol buffers، در حالی که SOAP از XML برای فرمت پیام استفاده میکند. برخی پیادهسازیها به پروتکل شبکهٔ خاصی وابستهاند (مثل SOAP که بهصورت اسمی از HTTP استفاده میکند)، در حالی که دیگران ممکن است اجازه دهند از انواع مختلف پروتکل شبکه استفاده کنید که خود میتوانند ویژگیهای اضافی بدهند. مثلاً TCP تضمینهایی دربارهٔ تحویل میدهد، در حالی که UDP نمیدهد اما overhead بسیار کمتری دارد. این به شما اجازه میدهد برای use caseهای مختلف از فناوری شبکهٔ متفاوت استفاده کنید.
پیادهسازیهای RPC که اجازه میدهند client و server stub تولید کنید به شروع بسیار سریع کمک میکنند. میتوانم در کمترین زمان محتوا از مرز شبکه بفرستم. اغلب یکی از نکات فروش اصلی RPC سهولت استفادهاش است. اینکه فقط یک فراخوانی method عادی بدهم و در تئوری بقیه را نادیده بگیرم مزیت بزرگی است.
برخی پیادهسازیهای RPC، با این حال، معایبی دارند که میتواند مشکلساز شود. این مشکلات همیشه از ابتدا آشکار نیستند، اما میتوانند بهقدری شدید باشند که مزایای شروع سریع را خنثی کنند.
coupling فناوری
برخی مکانیزمهای RPC مثل Java RMI بهشدت به platform خاصی وابستهاند که میتواند فناوری قابل استفاده در client و server را محدود کند. Thrift و protocol buffers پشتیبانی چشمگیری از زبانهای جایگزین دارند که این معایب را تا حدی کاهش میدهد، اما آگاه باشید گاهی فناوری RPC با محدودیتهای interoperability همراه است.
بهنوعی، این coupling فناوری میتواند شکلی از expose کردن جزئیات پیادهسازی فنی داخلی باشد. مثلاً استفاده از RMI نه فقط client را به JVM وابسته میکند، بلکه سرور را هم.
فراخوانیهای محلی مثل فراخوانیهای remote نیستند
ایدهٔ هستهٔ RPC پنهان کردن پیچیدگی فراخوانی remote است. بسیاری از پیادهسازیهای RPC، با این حال، بیش از حد پنهان میکنند. تمایل در برخی اشکال RPC به شبیهسازی فراخوانی method remote به فراخوانی method محلی این واقعیت را پنهان میکند که این دو چیز بسیار متفاوتاند. میتوانم تعداد زیادی فراخوانی محلی in-process بدون نگرانی زیاد از performance انجام دهم. با RPC، با این حال، هزینهٔ marshalling و unmarshalling payload میتواند قابلتوجه باشد، بدون ذکر زمان ارسال روی شبکه. این یعنی باید متفاوت دربارهٔ طراحی API برای interfaceهای remote در مقابل local فکر کنید. فقط گرفتن API محلی و تبدیل آن به مرز سرویس بدون فکر بیشتر احتمالاً دردسر میآورد. در بدترین مثالها، developerها ممکن است بدون دانستن از فراخوانیهای remote استفاده کنند اگر abstraction بیش از حد opaque باشد.
باید به شبکه فکر کنید. مشهور است که اولین fallacy محاسبات توزیعشده «شبکه reliable است» است. شبکهها reliable نیستند. میتوانند و خواهند fail کردند، حتی اگر client و سروری که با آن صحبت میکنید سالم باشند. میتوانند fast fail کنند، slow fail کنند و حتی packetهایتان را malform کنند. باید فرض کنید شبکهها پر از موجودات بدخواهی هستند که آمادهٔ unleash کردن خشمشان در هر لحظه. پس failure modeهایی که میتوانید انتظار داشته باشید متفاوتاند. failure میتواند بهخاطر برگشت خطا از سرور remote یا فراخوانی بد شما باشد. میتوانید تفاوت را تشخیص دهید و اگر بله، کاری میتوانید بکنید؟ و وقتی سرور remote فقط کند پاسخ میدهد چه میکنید؟ وقتی دربارهٔ resilience در فصل ۳ صحبت میکنیم این موضوع را پوشش میدهیم.
شکنندگی
برخی محبوبترین پیادهسازیهای RPC میتوانند به اشکال ناخوشایند شکنندگی منجر شوند، Java RMI مثال بسیار خوبی است. بیایید interface Java بسیار سادهای را در نظر بگیریم که تصمیم گرفتهایم API remote برای سرویس مشتریمان کنیم.
مثال ۲-۱ methodهایی را که remote expose میکنیم اعلام میکند. Java RMI سپس client و server stub برای method ما تولید میکند.
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface CustomerRemote extends Remote {
public Customer findCustomer(String id) throws RemoteException;
public Customer createCustomer(String firstname, String surname, String emailAddress)
throws RemoteException;
}در این interface، findCustomer نام، نام خانوادگی و آدرس ایمیل میگیرد. اگر تصمیم بگیریم اجازه دهیم شیء Customer فقط با آدرس ایمیل هم ایجاد شود چه؟ میتوانیم method جدید بهراحتی اضافه کنیم:
...
public Customer createCustomer(String emailAddress) throws RemoteException;
...مشکل این است که اکنون باید client stubها را هم دوباره تولید کنیم. clientهایی که method جدید را consume میکنند stubهای جدید میخواهند و بسته به ماهیت تغییرات specification، consumerهایی که method جدید لازم ندارند هم ممکن است نیاز به upgrade stub داشته باشند. این تا حدی قابل مدیریت است، اما تا یک نقطه. واقعیت این است که چنین تغییراتی fairly رایجاند. endpointهای RPC اغلب methodهای زیادی برای روشهای مختلف ایجاد یا تعامل با اشیاء دارند. تا حدی بهخاطر این است که هنوز این فراخوانیهای remote را مثل محلی فکر میکنیم.
شکنندگی دیگری هم هست. بیایید ببینیم شیء Customer ما چگونه است:
public class Customer implements Serializable {
private String firstName;
private String surname;
private String emailAddress;
private String age;
}اگر معلوم شود که اگرچه فیلد age را در اشیاء Customer expose میکنیم، هیچ consumerای از آن استفاده نمیکند چه؟ تصمیم میگیریم فیلد را حذف کنیم. اما اگر پیادهسازی سرور age را از تعریف این type حذف کند و همین کار را برای همهٔ consumerها نکنیم، حتی اگر هرگز از فیلد استفاده نکردهاند، کد deserialize کردن شیء Customer در سمت consumer میشکند. برای rollout این تغییر باید هم سرور جدید و هم clientها را همزمان deploy کنم. این چالش کلیدی با هر مکانیزم RPC است که استفاده از تولید binary stub را ترویج میکند: نمیتوانید deploymentهای client و server را جدا کنید. اگر از این فناوری استفاده کنید، releaseهای lock-step ممکن است در آیندهٔ شما باشد.
مشکلات مشابه اگر بخواهم شیء Customer را restructure کنم حتی اگر فیلد حذف نکنم — مثلاً اگر بخواهم firstName و surname را در type نامگذاری جدید encapsulate کنم تا مدیریت آسانتر شود. البته میتوانم با عبور dictionary typeها بهعنوان پارامتر فراخوانیها درست کنم، اما در آن نقطه بسیاری از مزایای stubهای تولیدشده را از دست میدهم چون هنوز باید فیلدهای مورد نظر را دستی match و extract کنم.
در عمل، اشیائی که در serialization باینری روی wire استفاده میشوند را میتوان expand-only type در نظر گرفت. این شکنندگی باعث میشود typeهایی که روی wire expose میشوند تودهای از فیلدها شوند، برخی دیگر استفاده نمیشوند اما نمیتوان با اطمینان حذفشان کرد.
آیا RPC وحشتناک است؟
با وجود کاستیها، تا این حد که RPC را وحشتناک بنامم نمیروم. برخی پیادهسازیهای رایجی که دیدهام میتوانند به مشکلاتی که اینجا شرح دادم منجر شوند. بهخاطر چالشهای استفاده از RMI، قطعاً به آن فناوری فاصلهٔ زیاد میدهم. بسیاری از عملیاتها بهخوبی در مدل مبتنی بر RPC مینشینند و مکانیزمهای مدرنتر مثل protocol buffers یا Thrift برخی گناهان گذشته را با اجتناب از نیاز به releaseهای lock-step کد client و server کاهش میدهند.
فقط اگر این مدل را انتخاب میکنید از برخی pitfalls احتمالی RPC آگاه باشید. فراخوانیهای remote را تا جایی abstract نکنید که شبکه کاملاً پنهان شود و مطمئن شوید میتوانید interface سرور را بدون اصرار به upgradeهای lock-step برای clientها تکامل دهید. یافتن تعادل درست برای کد client مهم است. مطمئن شوید clientها نادان نیستند که فراخوانی شبکه قرار است انجام شود. client libraryها اغلب در زمینهٔ RPC استفاده میشوند و اگر درست ساختار نگیرند میتوانند مشکلساز باشند. بهزودی بیشتر دربارهٔ آنها صحبت میکنیم.
در مقایسه با یکپارچهسازی پایگاه داده، RPC قطعاً بهبودی است وقتی به گزینههای همکاری request/response فکر میکنیم. اما گزینهٔ دیگری هم هست که باید در نظر بگیریم.
REST
REpresentational State Transfer (REST) سبک معماری الهامگرفته از وب است. اصول و محدودیتهای زیادی پشت سبک REST هست، اما روی آنهایی تمرکز میکنیم که واقعاً وقتی با چالشهای یکپارچهسازی در دنیای میکروسرویس روبهرو میشویم و به دنبال سبک جایگزین RPC برای interfaceهای سرویس هستیم کمک میکنند.
مهمترین مفهوم، resource است. resource را میتوانید چیزی بدانید که خود سرویس دربارهاش میداند، مثل Customer. سرور representationهای مختلف این Customer را درخواست میکند. نحوهٔ نمایش resource به بیرون کاملاً از نحوهٔ ذخیرهٔ داخلی decouple است. client ممکن است representation JSON از Customer بخواهد، مثلاً، حتی اگر بهصورت کاملاً متفاوت ذخیره شده باشد. وقتی client representation این Customer را دارد، میتواند درخواست تغییر بدهد و سرور ممکن است موافقت کند یا نه.
سبکهای مختلف REST زیاد است و اینجا فقط بهاختصار به آنها میپردازم. Richardson Maturity Model را قویاً توصیه میکنم، جایی سبکهای مختلف REST مقایسه میشوند.
خود REST واقعاً دربارهٔ پروتکلهای زیرین صحبت نمیکند، اگرچه اغلب روی HTTP استفاده میشود. پیادهسازی REST با پروتکلهای بسیار متفاوت دیدهام، مثل serial یا USB، اگرچه این میتواند کار زیادی بخواهد. برخی ویژگیهایی که HTTP بهعنوان بخشی از specification میدهد، مثل verbها، پیادهسازی REST روی HTTP را آسانتر میکند، در حالی که با پروتکلهای دیگر باید این ویژگیها را خودتان handle کنید.
REST و HTTP
خود HTTP قابلیتهای مفیدی تعریف میکند که با سبک REST خوب کار میکند. مثلاً HTTP verbها (مثل GET، POST و PUT) در specification HTTP معنای شناختهشدهای دارند دربارهٔ نحوهٔ کار با resourceها. سبک معماری REST در واقع میگوید methodها باید روی همهٔ resourceها یکسان رفتار کنند و specification HTTP بهاتفاق methodهایی تعریف میکند که میتوانیم استفاده کنیم. GET یک resource را بهصورت idempotent بازیابی میکند و POST resource جدید ایجاد میکند. این یعنی میتوانیم از methodهای زیاد createCustomer یا editCustomer اجتناب کنیم. بهجای آن فقط representation مشتری را POST میکنیم تا سرور resource جدید ایجاد کند و درخواست GET برای بازیابی representation resource آغاز میکنیم. مفهومی، در این موارد یک endpoint بهصورت resource Customer وجود دارد و عملیاتی که میتوانیم روی آن انجام دهیم در پروتکل HTTP bake شده است.
HTTP همچنین ecosystem بزرگی از ابزار و فناوری پشتیبان میآورد. میتوانیم از HTTP caching proxy مثل Varnish و load balancer مثل mod_proxy استفاده کنیم و بسیاری از ابزارهای monitoring از قبل پشتیبانی زیادی از HTTP دارند. این بلوکهای ساختمانی به ما اجازه میدهند حجم زیادی ترافیک HTTP را handle کنیم و بهصورت نسبتاً شفاف route کنیم. همچنین همهٔ کنترلهای امنیتی موجود HTTP را برای امن کردن ارتباطات استفاده میکنیم. از basic auth تا client cert، ecosystem HTTP ابزارهای زیادی برای آسانتر کردن فرآیند امنیت میدهد و در (ارجاع آینده) بیشتر بررسی میکنیم. با این حال، برای این مزایا باید HTTP را خوب استفاده کنید. بد استفاده کنید میتواند بهاندازهٔ هر فناوری دیگر ناامن و سخت برای scale باشد. درست استفاده کنید، کمک زیادی میگیرید.
توجه کنید HTTP میتواند برای پیادهسازی RPC هم استفاده شود. SOAP مثلاً روی HTTP route میشود، اما متأسفانه از specification کم استفاده میکند. verbها نادیده گرفته میشوند، همانطور که چیزهای ساده مثل HTTP error codeها. بارها به نظر میرسد استانداردها و فناوریهای موجود و شناختهشده نادیده گرفته میشوند به نفع استانداردهای جدیدی که فقط با فناوری کاملاً جدید قابل پیادهسازیاند — که خوشبختانه توسط همان شرکتهایی که در طراحی استانداردهای جدید کمک میکنند ارائه میشود!
Hypermedia بهعنوان موتور حالت برنامه
اصل دیگری که در REST معرفی شد و میتواند از coupling بین client و server جلوگیری کند، مفهوم hypermedia as the engine of application state است (اغلب بهاختصار HATEOAS، و واقعاً به اختصار نیاز داشت). این wording نسبتاً dense و مفهوم جالبی است، پس بیایید کمی بازش کنیم.
Hypermedia مفهومی است که قطعهای از محتوا لینک به قطعات مختلف محتوای دیگر در فرمتهای گوناگون (مثل متن، تصویر، صدا) دارد. باید برایتان آشنا باشد، چون کاری است که صفحهٔ وب معمولی انجام میدهد: لینکها را دنبال میکنید که شکلی از کنترل hypermedia هستند تا محتوای مرتبط ببینید. ایدهٔ پشت HATEOAS این است که clientها باید تعاملات (که ممکن است به state transition منجر شود) با سرور از طریق این لینکها به resourceهای دیگر انجام دهند. نیازی نیست بدانند مشتریان دقیقاً کجای سرور هستند با دانستن کدام URI را بزنند؛ بهجای آن client لینکها را پیدا و navigate میکند.
این مفهوم کمی عجیب است، پس اول بیایید ببینیم مردم چگونه با صفحهٔ وب تعامل میکنند، که قبلاً گفتیم غنی از کنترل hypermedia است. به سایت خرید Amazon.com فکر کنید. محل سبد خرید در طول زمان تغییر کرده. گرافیک تغییر کرده. لینک تغییر کرده. اما بهعنوان انسان بهاندازهٔ کافی هوشمندیم که هنوز سبد خرید را ببینیم، بدانیم چیست و با آن تعامل کنیم. درک داریم سبد خرید یعنی چه، حتی اگر شکل دقیق و کنترل زیرین نمایش آن تغییر کرده باشد. میدانیم اگر بخواهیم سبد را ببینیم، این کنترلی است که میخواهیم با آن تعامل کنیم. به همین دلیل صفحات وب میتوانند بهتدریج در طول زمان تغییر کنند. تا وقتی این قراردادهای ضمنی بین مشتری و وبسایت برقرار بماند، تغییرات نیازی به breaking change ندارند.
با کنترلهای hypermedia، میخواهیم همان سطح هوشمندی را برای consumerهای الکترونیکی به دست آوریم. بیایید کنترل hypermedia که ممکن است برای MusicCorp داشته باشیم ببینیم. به resourceای دسترسی پیدا کردهایم که catalog entry برای آلبوم مشخصی را نشان میدهد در مثال ۲-۲. همراه اطلاعات آلبوم، چند کنترل hypermedia میبینیم.
مثال ۲-۲. کنترلهای hypermedia در فهرست آلبوم
<album>
<name>Give Blood</name>
<link rel="/artist" href="/artist/theBrakes" />
<description>
Awesome, short, brutish, funny and loud. Must buy!
</description>
<link rel="/instantpurchase" href="/instantPurchase/1234" />
</album>این کنترل hypermedia نشان میدهد کجا اطلاعات هنرمند را پیدا کنیم.
و اگر بخواهیم آلبوم را بخریم، اکنون میدانیم کجا برویم.
در این سند دو کنترل hypermedia داریم. client خواندن چنین سندی باید بداند کنترلی با relation برابر artist جایی است که برای اطلاعات هنرمند باید navigate کند و instantpurchase بخشی از پروتکلی است که برای خرید آلبوم استفاده میشود. client باید semantics API را درک کند تقریباً همانطور که انسان باید درک کند در وبسایت خرید سبد جایی است که اقلام خرید قرار میگیرند.
بهعنوان client، نیازی نیست بدانم کدام URI scheme را برای خرید آلبوم بزنم، فقط باید به resource دسترسی پیدا کنم، کنترل buy را پیدا کنم و به آن navigate کنم. کنترل buy میتواند محلش تغییر کند، URI تغییر کند یا سایت حتی مرا به سرویس دیگری بفرستد و بهعنوان client اهمیتی نمیدهم. این decoupling بسیار زیادی بین client و server میدهد.
اینجا بهشدت از جزئیات زیرین abstract شدهایم. میتوانیم پیادهسازی نحوهٔ نمایش کنترل را کاملاً تغییر دهیم تا وقتی client هنوز کنترلی پیدا کند که با درکش از پروتکل مطابقت دارد، همانطور که کنترل سبد خرید میتواند از لینک ساده به کنترل JavaScript پیچیدهتر برود. همچنین آزادید کنترلهای جدید به سند اضافه کنید، شاید state transitionهای جدیدی که میتوانیم روی resource انجام دهیم. فقط وقتی consumerها را میشکنیم که semantics یکی از کنترلها را بهشکل بنیادی تغییر دهیم تا بسیار متفاوت رفتار کند، یا کنترلی را کاملاً حذف کنیم.
استفاده از این کنترلها برای decouple کردن client و server مزایای قابلتوجهی در طول زمان میدهد که بهمراتب از افزایش کوچک زمان راهاندازی این پروتکلها بیشتر است. با دنبال کردن لینکها، client بهتدریج API را کشف میکند که هنگام پیادهسازی clientهای جدید capability بسیار مفیدی است.
یکی از معایب این است که navigate کردن این کنترلها میتواند quite chatty باشد، چون client باید لینکها را دنبال کند تا عملیات مورد نظر را پیدا کند. در نهایت این trade-off است. پیشنهاد میکنم اول clientها را وادار کنید این کنترلها را navigate کنند، بعد در صورت لزوم optimize کنید. یادتان باشد با استفاده از HTTP کمک زیادی out of the box داریم که قبلاً بحث کردیم. شرارتهای premature optimization قبلاً خوب مستند شدهاند، پس اینجا گسترش نمیدهم. همچنین توجه کنید بسیاری از این رویکردها برای ساخت سیستمهای hypertext توزیعشده توسعه یافتند و همهٔ آنها نمینشینند! گاهی فقط RPC قدیمی خوب میخواهید.
شخصاً طرفدار استفاده از لینک برای اجازهٔ navigate کردن consumerها به endpointهای API هستم. مزایای کشف تدریجی API و coupling کمتر میتواند قابلتوجه باشد. با این حال، واضح است همه متقاعد نشدهاند، چون نمیبینم بهاندازهٔ دلخواهم استفاده شود. فکر میکنم بخش بزرگی این است که کار upfront اولیه لازم است، اما پاداشها اغلب بعداً میآید.
JSON، XML یا چیز دیگر؟
استفاده از فرمتهای متنی استاندارد به clientها انعطاف زیادی در نحوهٔ consume کردن resource میدهد و REST روی HTTP به ما اجازه میدهد فرمتهای گوناگون استفاده کنیم. مثالهایی که دادم XML بود، اما در این مرحله JSON content type بسیار محبوبتری برای سرویسهایی است که روی HTTP کار میکنند.
اینکه JSON فرمت بسیار سادهتری است یعنی consume کردن هم آسانتر است. برخی طرفداران compact بودن نسبی آن در مقایسه با XML را هم عامل برنده میدانند، اگرچه این اغلب مسئلهٔ دنیای واقعی نیست.
JSON معایبی هم دارد. XML کنترل لینکی که قبلاً بهعنوان کنترل hypermedia استفاده کردیم را تعریف میکند. استاندارد JSON چیز مشابهی تعریف نمیکند، پس اغلب سبکهای in-house برای جا دادن این مفهوم استفاده میشود. Hypertext Application Language (HAL) تلاش میکند با تعریف استانداردهای مشترک برای hyperlinking در JSON (و XML هم، اگرچه XML کمتر کمک میخواهد) این را درست کند. اگر استاندارد HAL را دنبال کنید، میتوانید از ابزارهایی مثل HAL browser مبتنی بر وب برای کاوش کنترلهای hypermedia استفاده کنید که کار ساخت client را آسانتر میکند.
البته به این دو فرمت محدود نیستیم. میتوانیم تقریباً هر چیزی روی HTTP بفرستیم، حتی binary. میبینم افراد بیشتری فقط HTML را بهجای XML استفاده میکنند. برای برخی interfaceها HTML میتواند هم UI و هم API باشد، اگرچه pitfalls اجتنابناپذیر است چون تعامل انسان و کامپیوتر کاملاً متفاوت است! اما ایدهٔ جذابی است. parserهای HTML زیادی وجود دارد.
شخصاً هنوز طرفدار XML هستم. برخی پشتیبانی ابزار بهتر است. مثلاً اگر فقط بخشهایی از payload را میخواهم (تکنیکی که در «نسخهبندی» بیشتر بحث میکنیم) میتوانم از XPATH استفاده کنم که استاندارد شناختهشده با پشتیبانی ابزار زیاد است، یا حتی CSS selector که بسیاری آسانتر مییابند. با JSON، JSONPATH دارم اما پشتیبانی گسترده ندارد. عجیب مییابم مردم JSON را بهخاطر lightweight بودن انتخاب میکنند، بعد مفاهیمی مثل کنترل hypermedia که در XML وجود دارد را به آن push میکنند. میپذیرم احتمالاً در اقلیتم و JSON فرمت انتخاب اکثر مردم است!
مراقب راحتی بیش از حد باشید
با محبوبتر شدن REST، frameworkهایی که ساخت RESTful web service را کمک میکنند هم بیشتر شدند. با این حال، برخی از این ابزارها برای سود کوتاهمدت در برابر درد بلندمدت trade-off میکنند؛ در تلاش برای شروع سریع، رفتارهای بد را تشویق میکنند. مثلاً برخی frameworkها واقعاً آسان میکنند representationهای پایگاه داده اشیاء را deserialize کنید به اشیاء in-process و مستقیماً خارج expose کنید. یادم است در کنفرانسی این با Spring Boot نشان داده شد و بهعنوان مزیت بزرگ ذکر شد. coupling ذاتی که این setup ترویج میکند در بیشتر موارد درد بسیار بیشتری از تلاش decouple کردن درست این مفاهیم ایجاد میکند.
مشکل عمومیتری اینجاست. نحوهٔ ذخیرهٔ داده و نحوهٔ expose کردن آن به consumerها بهراحتی تفکر ما را dominate میکند. الگویی که یکی از تیمها مؤثر استفاده کرد تأخیر پیادهسازی persistence درست برای میکروسرویس تا interface بهاندازهٔ کافی پایدار شود بود. برای دورهٔ میانی، entityها فقط در فایل روی دیسک محلی persist میشدند که obviously راهحل بلندمدت مناسبی نیست. این اطمینان میداد طراحی و پیادهسازی توسط نحوهٔ استفادهٔ consumerها از سرویس هدایت شود. استدلال، که در نتایج تأیید شد، این بود که خیلی آسان است نحوهٔ ذخیرهٔ domain entity در backing store بهوضوح مدلهایی که روی wire به همکاران میفرستیم تأثیر بگذارد. یکی از معایب این رویکرد این است که کار wire up کردن data store را به تعویق میاندازیم. فکر میکنم برای مرزهای سرویس جدید این trade-off قابل قبول است.
معایب REST روی HTTP
از نظر سهولت consume، نمیتوانید بهراحتی client stub برای application protocol REST روی HTTP مثل RPC تولید کنید. البته استفاده از HTTP یعنی از همهٔ client libraryهای عالی HTTP بهره میبرید، اما اگر بخواهید بهعنوان client کنترلهای hypermedia را پیاده و استفاده کنید تقریباً تنهایید. شخصاً فکر میکنم client libraryها میتوانند در این بهتر از آنچه هستند عمل کنند و قطعاً الان بهتر از گذشتهاند، اما دیدهام این پیچیدگی ظاهری باعث backsliding به smuggling RPC روی HTTP یا ساخت client library مشترک شود. کد مشترک بین client و server میتواند بسیار خطرناک باشد، همانطور که در «DRY و خطرات بازاستفادهٔ کد در دنیای میکروسرویس» بحث میکنیم.
نکتهٔ جزئیتر این است که برخی web server frameworkها همهٔ HTTP verbها را خوب پشتیبانی نمیکنند. یعنی شاید handler برای درخواست GET یا POST آسان باشد، اما ممکن است برای PUT یا DELETE مجبور باشید از حلقه بپرید. frameworkهای REST درست مثل Jersey این مشکل را ندارند و معمولاً میتوانید دورش بزنید، اما اگر به frameworkهای خاصی قفل شدهاید ممکن است سبک RESTی که میتوانید استفاده کنید محدود شود.
Performance هم ممکن است مسئله باشد. payloadهای REST روی HTTP میتوانند از SOAP فشردهتر باشند چون فرمتهای جایگزین مثل JSON یا حتی binary را پشتیبانی میکند، اما هنوز بهمراتب leanتر از پروتکل باینری مثل Thrift نخواهد بود. overhead HTTP برای هر درخواست هم ممکن است برای نیازهای low-latency نگرانکننده باشد. HTTP، اگرچه برای حجم زیاد ترافیک خوب است، برای ارتباطات low-latency در مقایسه با پروتکلهای جایگزین روی Transmission Control Protocol (TCP) یا فناوری شبکهٔ دیگر عالی نیست. با وجود نام، WebSockets ارتباط کمی با وب دارد. پس از handshake اولیهٔ HTTP، فقط اتصال TCP بین client و server است، اما میتواند راه کارآمدتری برای stream کردن داده برای مرورگر باشد. اگر به این علاقهمندید، توجه کنید واقعاً از HTTP زیاد استفاده نمیکنید، چه برسد به REST.
برای ارتباط server-to-server، اگر latency بسیار پایین یا اندازهٔ پیام کوچک مهم است، ارتباطات HTTP بهطور کلی شاید ایدهٔ خوبی نباشد. شاید پروتکلهای زیرین متفاوت مثل User Datagram Protocol (UDP) را برای performance مورد نظر انتخاب کنید و بسیاری از frameworkهای RPC خوشحال روی پروتکلهای شبکهٔ غیر از TCP اجرا میشوند.
consume کردن خود payloadها کار بیشتری میخواهد نسبت به برخی پیادهسازیهای RPC که serialization و deserialization پیشرفته پشتیبانی میکنند. اینها میتوانند نقطهٔ coupling بین client و server شوند چون پیادهسازی tolerant reader کار نontrivial است (بهزودی بحث میکنیم)، اما از نظر شروع سریع میتوانند بسیار جذاب باشند.
با وجود این معایب، REST روی HTTP انتخاب پیشفرض منطقی برای تعاملات service-to-service است. اگر بیشتر میخواهید بدانید، REST in Practice (O'Reilly) را توصیه میکنم که REST روی HTTP را عمیق پوشش میدهد.
پیادهسازی همکاری ناهمزمان مبتنی بر رویداد
مدتی دربارهٔ فناوریهایی صحبت کردیم که الگوهای request/response را پیاده میکنند. ارتباط event-based و asynchronous چطور؟
انتخابهای فناوری
دو بخش اصلی باید در نظر بگیریم: راهی برای emit کردن event توسط میکروسرویسها، و راهی برای consumerها که بفهمند آن eventها رخ دادهاند. بهطور سنتی، message brokerهایی مثل RabbitMQ تلاش میکنند هر دو مسئله را handle کنند. producerها از API برای publish کردن event به broker استفاده میکنند. broker subscriptionها را مدیریت میکند و به consumerها اطلاع میدهد وقتی event میرسد. این brokerها حتی state consumerها را handle میکنند، مثلاً با کمک به پیگیری چه پیامهایی قبلاً دیدهاند. این سیستمها معمولاً scalable و resilient طراحی میشوند، اما رایگان نیست. میتواند پیچیدگی فرآیند توسعه را افزایش دهد چون سیستم دیگری است که شاید برای توسعه و تست سرویسها باید اجرا کنید. ماشین و تخصص اضافی هم ممکن است برای نگهداشتن این infrastructure لازم باشد. اما وقتی کار کند، راه فوقالعادهای برای پیادهسازی معماریهای loosely coupled و event-driven است. بهطور کلی طرفدارم.
با این حال، مراقب دنیای middleware باشید که message broker فقط بخش کوچکی از آن است. queueها بهخودیخود چیزهای منطقی و مفیدی هستند. با این حال، vendorها تمایل دارند نرمافزار زیادی با آنها بستهبندی کنند که میتواند به push کردن هوش بیشتر و بیشتر به middleware منجر شود، همانطور که Enterprise Service Bus نشان میدهد. مطمئن شوید میدانید چه میگیرید: middleware را dumb نگه دارید و هوش را در endpointها نگه دارید.
رویکرد دیگر استفاده از HTTP برای propagate کردن event است. ATOM specification سازگار با REST است که semantics (از جمله) publish کردن feedهای resource را تعریف میکند. client libraryهای زیادی برای ساخت و consume کردن این feedها وجود دارد. پس سرویس مشتری میتواند وقتی سرویس مشتری تغییر میکند event را به چنین feedی publish کند. consumerها feed را poll میکنند و دنبال تغییرات میگردند. از یک طرف، استفاده مجدد از specification ATOM و libraryهای مرتبط مفید است و میدانیم HTTP scale را خوب handle میکند. از طرف دیگر، HTTP برای low latency خوب نیست (جایی message brokerها excel میکنند) و هنوز باید consumerها پیامهای دیدهشده را پیگیری کنند و برنامهٔ polling خود را مدیریت کنند.
دیدهام مردم زمان زیادی صرف پیادهسازی رفتارهایی میکنند که out of the box با message broker مناسب میگیرید تا ATOM برای برخی use caseها کار کند. مثلاً الگوی Competing Consumer روشی توصیف میکند که چند instance worker برای رقابت روی پیامها بالا میآورید که برای scale up تعداد workerها برای لیست jobهای مستقل خوب است. اما میخواهیم از حالتی که دو یا چند worker همان پیام را ببینند اجتناب کنیم چون کار را بیش از نیاز تکرار میکنیم. با message broker، queue استاندارد این را handle میکند. با ATOM باید state مشترک بین همهٔ workerها را خودمان مدیریت کنیم تا احتمال تکرار تلاش را کاهش دهیم.
اگر message broker خوب و resilient در دسترس دارید، برای publish و subscribe به eventها در نظر بگیرید. اما اگر ندارید، ATOM را ببینید و از sunk-cost fallacy آگاه باشید. اگر بیشتر و بیشتر پشتیبانیای که message broker میدهد میخواهید، در نقطهای شاید بخواهید رویکرد را عوض کنید.
از نظر آنچه روی این پروتکلهای asynchronous میفرستیم، همان ملاحظات ارتباط synchronous اعمال میشود. اگر الان با JSON برای encode کردن request و response راضیاید، همان را نگه دارید.
پیچیدگیهای معماریهای ناهمزمان
بخشی از این چیزهای asynchronous جالب به نظر میرسد، درست؟ معماریهای event-driven به سیستمهای decoupledتر و scalableتر منجر میشوند. و میتوانند. اما این سبکهای برنامهنویسی به افزایش پیچیدگی منجر میشوند. نه فقط پیچیدگی مدیریت publish و subscribe که بحث کردیم، بلکه مشکلات دیگری که ممکن است با آنها روبهرو شویم. مثلاً وقتی request/response async طولانی را در نظر میگیریم، باید فکر کنیم وقتی پاسخ برمیگردد چه کنیم. آیا به همان nodeای که درخواست را آغاز کرده برمیگردد؟ اگر بله، اگر آن node down باشد چه؟ اگر نه، باید اطلاعات را جایی ذخیره کنم تا متناسب واکنش نشان دهم؟ async کوتاهمدت اگر API درست دارید آسانتر مدیریت میشود، اما حتی آن هم برای برنامهنویسانی که به فراخوانی پیام synchronous درونفرآیندی عادت دارند روش فکر متفاوتی است.
وقت یک داستان هشداردهنده است. در ۲۰۰۶ روی ساخت سیستم قیمتگذاری برای بانک کار میکردم. به eventهای بازار نگاه میکردیم و میفهمیدیم کدام آیتمهای portfolio باید reprice شوند. وقتی لیست کار را مشخص کردیم، همه را روی message queue گذاشتیم. از grid برای ایجاد pool از workerهای قیمتگذاری استفاده میکردیم تا farm قیمتگذاری را on demand scale up و down کنیم. این workerها از الگوی competing consumer استفاده میکردند و تا وقتی چیزی برای پردازش نبود پیامها را تا حد ممکن سریع میبلعیدند.
سیستم بالا بود و احساس غرور میکردیم. یک روز، درست بعد از push کردن release، به مشکل ناخوشایندی خوردیم. workerها مدام میمردند. و میمردند. و میمردند.
در نهایت مشکل را پیدا کردیم. باگی آمده بود که نوع خاصی از درخواست قیمتگذاری باعث crash worker میشد. از transacted queue استفاده میکردیم: وقتی worker میمرد، lock روی درخواست timeout میشد و درخواست قیمتگذاری دوباره روی queue گذاشته میشد — فقط تا worker دیگری آن را بردارد و بمیرد. مثال کلاسیکی از آنچه Martin Fowler catastrophic failover مینامد.
جدا از خود باگ، حداکثر retry برای job روی queue مشخص نکرده بودیم. باگ را درست کردیم و حداکثر retry را configure کردیم. اما فهمیدیم راهی برای دیدن و احتمالاً replay کردن این پیامهای بد لازم است. message hospital (یا dead letter queue) پیاده کردیم که پیامهای fail شده به آن میرفتند. همچنین UI برای دیدن آن پیامها و retry در صورت نیاز ساختیم. این مشکلات اگر فقط با ارتباط synchronous point-to-point آشنا باشید فوراً آشکار نیستند.
پیچیدگی مرتبط با معماریهای event-driven و برنامهنویسی asynchronous بهطور کلی باعث میشود با احتیاط بیشتری این ایدهها را بپذیرید. monitoring خوب داشته باشید و استفاده از correlation ID را جدی در نظر بگیرید که اجازه میدهد requestها را در مرز فرآیند trace کنید، همانطور که در (ارجاع آینده) عمیق پوشش میدهیم.
همچنین Enterprise Integration Patterns (Addison-Wesley) را قویاً توصیه میکنم که جزئیات بیشتری دربارهٔ الگوهای برنامهنویسی مختلفی که در این فضا باید در نظر بگیرید دارد.
سرویسها بهعنوان ماشینهای حالت
چه REST ninja شوید یا با مکانیزم مبتنی بر RPC مثل SOAP بمانید، مفهوم هستهٔ سرویس بهعنوان state machine قدرتمند است. قبلاً (احتمالاً تا حد ملالت) گفتیم سرویسهایمان حول bounded contextها ساخته میشوند. میکروسرویس مشتری تمام منطق مرتبط با رفتار در این context را مالک است.
وقتی consumer میخواهد مشتری را تغییر دهد، درخواست مناسب به سرویس مشتری میفرستد. سرویس مشتری بر اساس منطقش تصمیم میگیرد آن درخواست را بپذیرد یا نه. سرویس مشتری تمام lifecycle eventهای مرتبط با خود مشتری را کنترل میکند. میخواهیم از سرویسهای dumb و anemic که کمتر از wrapper CRUD هستند اجتناب کنیم. اگر تصمیم دربارهٔ تغییرات مجاز روی مشتری از خود سرویس مشتری leak کند، cohesion را از دست میدهیم.
مدلسازی صریح lifecycle مفاهیم کلیدی domain به این شکل قدرتمند است. نه فقط یک جا برای برخورد با برخورد state داریم (مثلاً کسی میخواهد مشتری حذفشده را update کند)، بلکه جایی برای attach کردن رفتار بر اساس آن تغییرات state.
هنوز فکر میکنم REST روی HTTP فناوری یکپارچهسازی بسیار منطقیتری از بسیاری دیگر است، اما هر چه انتخاب کنید این ایده را در نظر داشته باشید.
Reactive Extensions
Reactive extensions، اغلب بهاختصار Rx، مکانیزمی برای compose کردن نتایج چند فراخوانی و اجرای عملیات روی آنهاست. خود فراخوانیها میتوانند blocking یا nonblocking باشند. در هستهٔ Rx، جریانهای سنتی معکوس میشوند. بهجای درخواست داده و سپس عملیات روی آن، نتیجهٔ عملیات (یا مجموعهای از عملیاتها) را observe میکنید و وقتی چیزی تغییر کرد واکنش نشان میدهید. برخی پیادهسازیهای Rx اجازه میدهند روی این observableها تابع اجرا کنید، مثل RxJava که تابعهای سنتی مثل map یا filter را اجازه میدهد.
پیادهسازیهای مختلف Rx در سیستمهای توزیعشده جای بسیار خوشی یافتهاند. به ما اجازه میدهند جزئیات نحوهٔ فراخوانی را abstract کنیم و راحتتر استدلال کنیم. نتیجهٔ فراخوانی به سرویس downstream را observe میکنم. اهمیتی نمیدهد blocking بود یا nonblocking، فقط منتظر پاسخ میمانم و واکنش نشان میدهم. زیبایی این است که میتوانم چند فراخوانی را compose کنم و مدیریت فراخوانیهای همزمان به سرویسهای downstream را بسیار آسانتر کنم.
وقتی فراخوانیهای سرویس بیشتری میکنید، بهویژه وقتی برای یک عملیات چند فراخوانی میکنید، reactive extensions برای stack فناوری انتخابیتان را ببینید. شاید تعجب کنید زندگیتان چقدر سادهتر میشود.
DRY و خطرات بازاستفادهٔ کد در دنیای میکروسرویس
یکی از acronymهایی که developerها زیاد میشنوند DRY است: don't repeat yourself. اگرچه تعریفش گاهی به اجتناب از تکرار کد ساده میشود، DRY دقیقتر یعنی میخواهیم از تکرار رفتار سیستم و دانش اجتناب کنیم. این توصیهٔ بسیار منطقی است. داشتن خطوط زیاد کد که همان کار را میکنند codebase را بزرگتر از نیاز میکند و استدلال را سختتر. وقتی میخواهید رفتار را تغییر دهید و آن رفتار در بخشهای زیادی از سیستم تکرار شده، آسان است جاهایی که باید تغییر دهید را فراموش کنید که به باگ منجر میشود. پس استفاده از DRY بهعنوان mantra بهطور کلی منطقی است.
DRY ما را به ساخت کد قابل reuse سوق میدهد. کد تکراری را به abstraction میکشیم که از چند جا فراخوانی کنیم. شاید تا ساخت library مشترک که همهجا استفاده کنیم! این رویکرد، با این حال، در معماری میکروسرویس فریبنده و خطرناک است.
یکی از چیزهایی که میخواهیم به هر قیمت از آن اجتناب کنیم، coupling بیش از حد میکروسرویس و consumerهاست که هر تغییر کوچک در خود میکروسرویس باعث تغییر غیرضروری consumer شود. گاهی، با این حال، استفاده از کد مشترک همین coupling را ایجاد میکند. مثلاً در یک client کتابخانهای از domain objectهای مشترک داشتیم که entityهای هستهٔ سیستم را نشان میداد. همهٔ سرویسها از آن استفاده میکردند. اما وقتی یکی تغییر میکرد، همهٔ سرویسها باید بهروز میشدند. سیستم از طریق message queue ارتباط برقرار میکرد که باید محتوای نامعتبرش drain میشد و وای بر حال کسی که فراموش کند.
اگر استفاده از کد مشترک از مرز سرویس leak کند، شکل بالقوهای از coupling معرفی کردهاید. استفاده از کد مشترک مثل libraryهای logging خوب است چون مفاهیم داخلی هستند که برای دنیای بیرون نامرئیاند. RealEstate.com.au از service template سفارشی برای bootstrap کردن سرویس جدید استفاده میکند. بهجای shared کردن این کد، شرکت آن را برای هر سرویس جدید کپی میکند تا coupling leak نکند.
قاعدهٔ کلی من: DRY را درون میکروسرویس نقض نکنید، اما دربارهٔ نقض DRY در همهٔ سرویسها relaxed باشید. شرارتهای coupling بیش از حد بین سرویسها بسیار بدتر از مشکلات تکرار کد است. یک use case خاص هست که ارزش بررسی بیشتر دارد.
Client Libraryها
با بیش از یک تیم صحبت کردهام که اصرار داشتند ساخت client library برای سرویسها بخش ضروری ساخت سرویس است. استدلال این است که استفاده از سرویس را آسان میکند و از تکرار کد لازم برای consume کردن سرویس جلوگیری میکند.
مشکل البته این است که اگر همان افراد server API و client API را بسازند، خطر وجود دارد منطقی که باید روی سرور باشد به client leak کند. باید بدانم: خودم این کار را کردهام. هرچه منطق بیشتری به client library میرود، cohesion بیشتر میشکند و برای rollout اصلاحات سرور باید چند client را تغییر دهید. انتخاب فناوری را هم محدود میکنید، بهویژه اگر mandate کنید client library باید استفاده شود.
مدل client library که دوست دارم مدل Amazon Web Services (AWS) است. فراخوانیهای SOAP یا REST web service زیرین میتواند مستقیم انجام شود، اما همه از یکی از software development kit (SDK)های مختلف استفاده میکنند که abstraction روی API زیرین میدهند. این SDKها، با این حال، توسط community یا افراد AWS غیر از کسانی که روی خود API کار میکنند نوشته میشوند. این درجهٔ جداسازی کار میکند و از برخی pitfalls client library اجتناب میکند. بخشی از دلیل کارکرد خوب این است که client تصمیم میگیرد چه وقت upgrade کند. اگر خودتان مسیر client library را میروید، مطمئن شوید اینطور است.
Netflix بهویژه تأکید ویژهای روی client library دارد، اما نگرانم مردم آن را فقط از لنز اجتناب از تکرار کد ببینند. در واقع client libraryهای Netflix بهاندازه (اگر نه بیشتر) دربارهٔ اطمینان از reliability و scalability سیستمهاست. client libraryهای Netflix service discovery، failure mode، logging و جنبههای دیگری را handle میکنند که واقعاً دربارهٔ ماهیت خود سرویس نیست. بدون این clientهای مشترک، سخت بود اطمینان حاصل شود هر بخش ارتباط client/server در مقیاس عظیمی که Netflix کار میکند خوب رفتار کند. استفاده در Netflix قطعاً شروع سریع و productivity را افزایش داده و اطمینان میدهد سیستم خوب رفتار میکند. با این حال، طبق حداقل یک نفر در Netflix، با گذشت زمان به درجهای coupling بین client و server منجر شده که مشکلساز بوده.
اگر رویکرد client library را در نظر دارید، جدا کردن کد client برای handle کردن پروتکل انتقال زیرین که میتواند service discovery و failure را مدیریت کند از چیزهای مرتبط با سرویس مقصد مهم است. تصمیم بگیرید آیا اصرار میکنید client library استفاده شود یا اجازه میدهید stackهای فناوری مختلف به API زیرین فراخوانی کنند. و در نهایت مطمئن شوید clientها تصمیم میگیرند چه وقت client library را upgrade کنند: باید توانایی release مستقل سرویسها را حفظ کنیم!
دسترسی با مرجع
یک ملاحظهای که میخواهم لمس کنم نحوهٔ عبور دادن اطلاعات دربارهٔ domain entityهایمان است. باید ایده را بپذیریم که میکروسرویس lifecycle entityهای کلیدی domain مثل Customer را در بر میگیرد. قبلاً دربارهٔ اهمیت نگه داشتن منطق مرتبط با تغییر این Customer در سرویس مشتری صحبت کردیم و اگر بخواهیم تغییرش دهیم باید درخواست به سرویس مشتری بدهیم. اما همچنین باید سرویس مشتری را منبع حقیقت برای Customerها در نظر بگیریم.
وقتی resource Customer مشخصی را از سرویس مشتری بازیابی میکنیم، میبینیم آن resource هنگام درخواست چگونه بود. ممکن است پس از درخواست آن resource Customer، چیز دیگری آن را تغییر داده باشد. در عمل حافظهای از آنچه resource Customer یکبار بود داریم. هرچه این حافظه را بیشتر نگه داریم، احتمال بیشتر اینکه حافظه نادرست باشد. البته اگر از درخواست داده بیش از نیاز اجتناب کنیم، سیستمها میتوانند بسیار کارآمدتر شوند.
گاهی این حافظه کافی است. گاهی باید بدانید تغییر کرده یا نه. پس چه تصمیم بگیرید حافظهٔ آنچه entity یکبار بود را عبور دهید، مطمئن شوید مرجع به resource اصلی هم هست تا state جدید بازیابی شود.
مثال را در نظر بگیریم که از سرویس ایمیل میخواهیم وقتی سفارش ارسال شد ایمیل بفرستد. میتوانیم در درخواست به سرویس ایمیل آدرس ایمیل، نام و جزئیات سفارش مشتری را بفرستیم. اما اگر سرویس ایمیل درخواستها را queue میکند یا از queue میکشد، در این فاصله چیزها میتوانند تغییر کنند. شاید منطقیتر فقط URI برای resourceهای Customer و Order بفرستیم و بگذاریم سرور ایمیل وقتی وقت ارسال است آنها را lookup کند.
نقطهٔ مقابل خوبی وقتی همکاری event-based را در نظر میگیریم ظهور میکند. با event میگوییم این اتفاق افتاد، اما باید بدانیم چه اتفاقی افتاد. اگر بهخاطر تغییر resource Customer بهروزرسانی میگیریم، مثلاً، میتواند ارزشمند باشد بدانیم Customer هنگام event چگونه بود. تا وقتی مرجع به خود entity را هم داریم تا state فعلی را lookup کنیم، بهترین هر دو دنیا را داریم.
trade-offهای دیگری هم هست وقتی با مرجع دسترسی میکنیم. اگر همیشه به سرویس مشتری برویم برای اطلاعات مرتبط با Customer مشخص، بار روی سرویس مشتری میتواند زیاد شود. اگر هنگام بازیابی resource اطلاعات اضافی بدهیم که بگوید resource در چه زمانی در state دادهشده بود و شاید چقدر این اطلاعات را fresh در نظر بگیریم، با caching میتوانیم کار زیادی انجام دهیم. HTTP بسیاری از این پشتیبانی را out of the box با انواع cache control میدهد که در فصل ۳ بیشتر بحث میکنیم.
مشکل دیگر این است که برخی سرویسها شاید کل resource Customer را لازم نداشته باشند و با اصرار به lookup کردن coupling را افزایش میدهیم. میتوان گفت مثلاً سرویس ایمیل باید dumbتر باشد و فقط آدرس ایمیل و نام مشتری را بفرستیم. قانون سختوسری نیست، اما بسیار مراقب باشید داده را در درخواستها عبور دهید وقتی تازگی آن را نمیدانید.
نسخهبندی
در هر سخنرانی که دربارهٔ میکروسرویس دادهام، پرسیده میشود versioning چطور؟ مردم نگرانی مشروع دارند که نهایتاً باید interface سرویس را تغییر دهند و میخواهند بدانند چگونه مدیریتش کنند. بیایید مسئله را بشکنیم و گامهایی که میتوانیم برداریم ببینیم.
تا حد امکان به تعویق بیندازید
بهترین راه کاهش اثر تغییرات شکننده، اجتناب از آنهاست. بخش زیادی با انتخاب فناوری یکپارچهسازی درست به دست میآید، همانطور که در سراسر فصل بحث کردیم. یکپارچهسازی پایگاه داده مثال خوبی از فناوری است که اجتناب از تغییرات شکننده را سخت میکند. REST از طرف دیگر کمک میکند چون تغییرات جزئیات پیادهسازی داخلی کمتر به تغییر interface سرویس منجر میشود.
کلید دیگر به تعویق انداختن تغییر شکننده، تشویق رفتار خوب در clientها و اجتناب از bind شدن بیش از حد به سرویسهاست. سرویس ایمیل را در نظر بگیریم که گاهی ایمیل به مشتریان میفرستد. از آن خواسته میشود ایمیل سفارش ارسالشده به مشتری با ID 1234 بفرستد. میرود مشتری با آن ID را بازیابی میکند و چیزی شبیه پاسخ مثال ۲-۳ میگیرد.
مثال ۲-۳. نمونه پاسخ از سرویس مشتری
<customer>
<firstname>Sam</firstname>
<lastname>Newman</lastname>
<email>sam@magpiebrain.com</email>
<telephoneNumber>555-1234-5678</telephoneNumber>
</customer>برای ارسال ایمیل فقط به firstname، lastname و email نیاز داریم. به telephoneNumber نیاز نداریم. میخواهیم فقط فیلدهایی که به آنها اهمیت میدهیم را بکشیم و بقیه را نادیده بگیریم. برخی فناوری binding، بهویژه در زبانهای strongly typed، ممکن است همهٔ فیلدها را bind کند چه consumer بخواهد چه نه. اگر بفهمیم کسی از telephoneNumber استفاده نمیکند و آن را حذف کنیم چه؟ این میتواند باعث شکست غیرضروری consumerها شود.
همینطور اگر بخواهیم شیء Customer را restructure کنیم تا جزئیات بیشتری پشتیبانی کند، شاید ساختار بیشتری اضافه کنیم همانطور که در مثال ۲-۴ است؟ دادهای که سرویس ایمیل میخواهد هنوز هست و با همان نام، اما اگر کد فرضهای صریح دربارهٔ محل firstname و lastname داشته باشد، دوباره میشکند. در این مورد میتوانیم از XPath برای کشیدن فیلدهای مورد نظر استفاده کنیم و نسبت به محل فیلدها بیتفاوت باشیم تا زمانی که پیدایشان کنیم. این الگو — پیادهسازی readerای که تغییراتی که به آنها اهمیت نمیدهیم را نادیده بگیرد — همان چیزی است که Martin Fowler Tolerant Reader مینامد.
مثال ۲-۴. resource Customer restructureشده: داده هنوز هست، اما آیا consumerها پیدایش میکنند؟
<customer>
<naming>
<firstname>Sam</firstname>
<lastname>Newman</lastname>
<nickname>Magpiebrain</nickname>
<fullname>Sam "Magpiebrain" Newman</fullname>
</naming>
<email>sam@magpiebrain.com</email>
</customer>مثال clientای که تا حد امکان انعطافپذیر در consume کردن سرویس است قانون Postel را نشان میدهد (که robustness principle هم نامیده میشود): «در آنچه میکنید محافظهکار باشید، در آنچه از دیگران میپذیرید آزاد باشید.» زمینهٔ اصلی این حکمت تعامل دستگاهها روی شبکه بود، جایی باید انتظار چیزهای عجیب داشت. در زمینهٔ تعامل request/response ما را بهترین تلاش برای اجازهٔ تغییر سرویس consumeشده بدون نیاز به تغییر خودمان سوق میدهد.
تغییرات شکننده را زود بگیرید
حیاتی است تغییراتی که consumerها را میشکنند را هرچه زودتر بگیریم، چون حتی با بهترین فناوری ممکن است شکست رخ دهد. قویاً طرفدار consumer-driven contracts هستم که در (ارجاع آینده) پوشش میدهیم تا این مشکلات زود مشخص شوند. اگر چند client library مختلف پشتیبانی میکنید، اجرای تست با هر library در برابر آخرین سرویس تکنیک دیگری است. وقتی فهمیدید consumer را میشکنید، انتخاب دارید: یا از شکست اجتناب کنید یا آن را بپذیرید و گفتوگوی درست با مسئولان سرویسهای consuming را شروع کنید.
از Semantic Versioning استفاده کنید
آیا عالی نیست اگر بهعنوان client فقط با نگاه به شمارهٔ نسخهٔ سرویس بدانید آیا میتوانید یکپارچه شوید؟ semantic versioning specificationی است که همین را ممکن میکند. با semantic versioning، هر شمارهٔ نسخه بهصورت MAJOR.MINOR.PATCH است. وقتی MAJOR افزایش مییابد، یعنی تغییرات backward incompatible داده شده. وقتی MINOR افزایش مییابد، قابلیت جدید backward compatible اضافه شده. تغییر PATCH یعنی bug fix روی قابلیت موجود.
برای دیدن مفید بودن semantic versioning، use case ساده را ببینیم. اپلیکیشن helpdesk ما برای کار با نسخهٔ 1.2.0 سرویس مشتری ساخته شده. اگر قابلیت جدید اضافه شود و سرویس مشتری به 1.3.0 برود، اپلیکیشن helpdesk نباید تغییر رفتار ببیند و انتظار تغییری ندارد. نمیتوانیم تضمین کنیم با نسخهٔ 1.1.0 سرویس مشتری کار کنیم، چون ممکن است به قابلیتی در release 1.2.0 وابسته باشیم. همچنین میتوانیم انتظار تغییر در اپلیکیشن داشته باشیم اگر release جدید 2.0.0 سرویس مشتری بیاید.
شاید semantic version برای سرویس داشته باشید، یا حتی برای endpoint فردی روی سرویس اگر آنها را coexist میکنید همانطور که در بخش بعد میآید.
این scheme نسخهبندی اجازه میدهد اطلاعات و انتظارات زیادی در فقط سه فیلد بگنجانیم. specification کامل بهسادگی انتظارات clientها از تغییر این اعداد را شرح میدهد و فرآیند ارتباط دربارهٔ تأثیر تغییرات روی consumerها را ساده میکند. متأسفانه این رویکرد را در زمینهٔ سیستمهای توزیعشده بهاندازهٔ کافی ندیدهام.
coexist کردن endpointهای مختلف
اگر همهٔ کار ممکن را برای اجتناب از تغییر interface شکننده کردیم، کار بعدی محدود کردن اثر است. چیزی که میخواهیم از آن اجتناب کنیم مجبور کردن consumerها به upgrade همزمان با ما است، چون همیشه میخواهیم توانایی release مستقل میکروسرویسها را حفظ کنیم. رویکردی که با موفقیت استفاده کردهام coexist کردن interface قدیم و جدید در همان سرویس در حال اجراست. پس اگر تغییر شکننده release میکنیم، نسخهٔ جدید سرویس را deploy میکنیم که هم endpoint قدیم و هم جدید را expose میکند.
این به ما اجازه میدهد میکروسرویس جدید را هرچه زودتر با interface جدید بیرون بدهیم، اما به consumerها زمان برای مهاجرت بدهیم. وقتی همهٔ consumerها دیگر از endpoint قدیم استفاده نمیکنند، میتوانید آن و کد مرتبط را حذف کنید، همانطور که در شکل ۲-۵ نشان داده شده.
شکل ۲-۵. coexist کردن نسخههای مختلف endpoint به consumerها اجازهٔ مهاجرت تدریجی میدهد
آخرین باری که این رویکرد را استفاده کردم، بهخاطر تعداد consumerها و تعداد تغییرات شکنندهای که داده بودیم کمی گیر کرده بودیم. یعنی واقعاً سه نسخهٔ مختلف endpoint را coexist میکردیم. این چیزی نیست که توصیه کنم! نگه داشتن همهٔ کد و تست لازم برای اطمینان از کار همهٔ آنها بار اضافی مطلق بود. برای قابل مدیریتتر کردن، درخواستهای V1 را داخلاً به V2 و سپس V2 را به V3 تبدیل میکردیم. این به ما اجازه میداد کدی که با مرگ endpoint قدیم retire میشد را واضح جدا کنیم.
این در عمل مثالی از الگوی expand and contract است که اجازه میدهد تغییرات شکننده را بهتدریج فاز کنیم. قابلیتها را expand میکنیم و هر دو روش قدیم و جدید را پشتیبانی میکنیم. وقتی consumerهای قدیم به روش جدید میروند، API را contract میکنیم و قابلیت قدیم را حذف میکنیم.
اگر endpointها را coexist میکنید، راهی لازم است تا callerها درخواستها را route کنند. برای سیستمهای HTTP این را با شمارهٔ نسخه در header درخواست و همچنین در خود URI دیدهام — مثلاً /v1/customer/ یا /v2/customer/. دربارهٔ اینکه کدام منطقیتر است دو دل هستم. از یک طرف دوست دارم URIها opaque باشند تا clientها URI template را hardcode نکنند، از طرف دیگر این رویکرد چیزها را بسیار واضح میکند و request routing را ساده میکند.
برای RPC کمی سختتر است. با protocol buffers methodها را در namespaceهای مختلف گذاشتم — مثلاً v1.createCustomer و v2.createCustomer — اما وقتی سعی میکنید نسخههای مختلف همان typeها را روی شبکه پشتیبانی کنید، واقعاً دردناک میشود.
استفاده از چند نسخهٔ همزمان سرویس
راهحل versioning دیگری که اغلب ذکر میشود داشتن نسخههای مختلف سرویس بهطور همزمان است و consumerهای قدیمیتر ترافیک را به نسخهٔ قدیمتر و جدیدترها به نسخهٔ جدید route میکنند، همانطور که در شکل ۲-۶ است. این رویکردی است که Netflix بهندرت در موقعیتهایی که هزینهٔ تغییر consumerهای قدیمیتر خیلی زیاد است استفاده میکند، بهویژه در موارد نادر که دستگاههای legacy هنوز به نسخههای قدیم API وابستهاند.
شکل ۲-۶. اجرای چند نسخه از همان سرویس برای پشتیبانی endpointهای قدیم
شخصاً طرفدار این ایده نیستم و میفهمم چرا Netflix بهندرت استفاده میکند. اول، اگر باید باگ داخلی در سرویس درست کنم، اکنون باید دو مجموعه سرویس مختلف را درست و deploy کنم. احتمالاً یعنی codebase سرویس را branch کنم که همیشه مشکلساز است. دوم، یعنی هوش لازم برای هدایت consumerها به میکروسرویس درست. این رفتار ناگزیر جایی در middleware یا مجموعهای از اسکریپت nginx مینشیند و استدلال دربارهٔ رفتار سیستم را سختتر میکند. در نهایت، state پایدار persistent که سرویس ممکن است مدیریت کند را در نظر بگیرید. مشتریانی که با هر نسخهٔ سرویس ایجاد شدهاند باید ذخیره و برای همهٔ سرویسها visible باشند، صرفنظر از نسخهای که برای ایجاد داده استفاده شده. این منبع پیچیدگی اضافی است.
coexist کردن نسخههای همزمان سرویس برای مدت کوتاه میتواند کاملاً منطقی باشد، بهویژه وقتی blue/green deployment یا canary release انجام میدهید (این الگوها را در (ارجاع آینده) بیشتر بحث میکنیم). در این موقعیتها شاید فقط چند دقیقه یا چند ساعت نسخهها coexist باشند و معمولاً فقط دو نسخهٔ مختلف سرویس همزمان حضور دارند. هرچه upgrade consumerها به نسخهٔ جدید و release طولانیتر شود، بیشتر باید به coexist کردن endpointهای مختلف در همان میکروسرویس نگاه کنید تا coexist کردن نسخههای کاملاً متفاوت سرویس. برای پروژهٔ متوسط همچنان متقاعد نشدهام که این کار ارزش دارد.
رابطهای کاربری
تا اینجا واقعاً به دنیای رابط کاربری نپرداختیم. برخی از شما شاید فقط API سرد و سخت بالینی به مشتریان بدهید، اما بسیاری از ما میخواهیم رابطهای کاربری زیبا و کاربردی بسازیم که مشتریان را خوشحال کند. اما واقعاً باید آنها را در زمینهٔ یکپارچهسازی در نظر بگیریم. رابط کاربری جایی است که همهٔ این میکروسرویسها را با هم میبافیم تا برای مشتریان معنا پیدا کند.
در گذشته، وقتی اول شروع به محاسبات کردم، بیشتر دربارهٔ clientهای بزرگ و چاق روی دسکتاپ صحبت میکردیم. ساعتها با Motif و بعد Swing گذراندم تا نرمافزارم تا حد ممکن خوشایند باشد. اغلب این سیستمها فقط برای ایجاد و دستکاری فایلهای محلی بودند، اما بسیاری component سمت سرور هم داشتند. اولین کارم در ThoughtWorks ساخت سیستم electronic point-of-sale مبتنی بر Swing بود که فقط بخشی از اجزای متحرک زیاد بود، بیشترش روی سرور.
بعد وب آمد. شروع کردیم UIها را thin در نظر بگیریم، با منطق بیشتر سمت سرور. در ابتدا برنامههای سمت سرور کل صفحه را render میکردند و به مرورگر client میفرستادند که کار کمی میکرد. هر تعاملی سمت سرور با GET و POST ناشی از کلیک لینک یا پر کردن فرم handle میشد. با گذشت زمان JavaScript گزینهٔ محبوبتری برای رفتار پویا در UI مرورگر شد و برخی اپلیکیشنها میتوانند بهاندازهٔ clientهای دسکتاپ قدیمی fat استدلال شوند.
به سمت دیجیتال
در چند سال اخیر، سازمانها از تفکر متفاوت بودن وب و موبایل فاصله گرفتهاند و بهجای آن دیجیتال را holisticتر میبینند. بهترین راه برای مشتریان استفاده از سرویسهای ما چیست؟ و این به معماری سیستم چه میکند؟ درک اینکه نمیتوانیم دقیقاً پیشبینی کنیم مشتری چگونه با شرکت تعامل میکند، پذیرش APIهای granularتر مثل آنچه میکروسرویسها میدهند را هدایت کرده. با ترکیب قابلیتهایی که سرویسها expose میکنند به روشهای مختلف، تجربههای متفاوتی برای مشتریان روی اپ دسکتاپ، دستگاه موبایل، موبایل وب، تبلت یا حتی بهصورت فیزیکی اگر به فروشگاه مراجعه کنند میبافیم.
پس رابطهای کاربری را لایههای compositional در نظر بگیرید — جاهایی که رشتههای مختلف قابلیتهایی که ارائه میدهیم را میبافیم. با این در نظر، چگونه همهٔ این رشتهها را با هم میکشیم؟
محدودیتها
محدودیتها اشکال مختلفی هستند که کاربران با سیستم تعامل میکنند. در اپلیکیشن وب دسکتاپ، مثلاً محدودیتهایی مثل مرورگر بازدیدکننده یا resolution را در نظر میگیریم. اما موبایل محدودیتهای جدید زیادی آورد. نحوهٔ ارتباط اپلیکیشن موبایل با سرور میتواند اثر بگذارد. فقط نگرانی bandwidth خالص نیست که محدودیتهای شبکهٔ موبایل نقش دارد. انواع مختلف تعامل میتواند عمر باتری را تخلیه کند و به برخی مشتریان آسیب بزند.
ماهیت تعامل هم تغییر میکند. روی تبلت بهراحتی right-click نمیکنم. روی موبایل شاید interface را برای استفادهٔ عمدتاً یکدستی طراحی کنم، با اکثر عملیاتها تحت کنترل انگشت شست. جای دیگر ممکن است اجازه دهیم مردم از طریق SMS با سرویسها تعامل کنند جایی bandwidth گران است — استفاده از SMS بهعنوان interface در جنوب جهانی عظیم است.
پس اگرچه سرویسهای هسته — پیشنهاد هسته — ممکن است یکسان باشد، راهی لازم است آنها را برای محدودیتهای هر نوع interface تطبیق دهیم. وقتی سبکهای مختلف composition رابط کاربری را میبینیم، باید مطمئن شویم این چالش را برطرف میکنند. بیایید چند مدل رابط کاربری ببینیم.
ترکیب API
فرض کنیم سرویسها از قبل با XML یا JSON روی HTTP با هم صحبت میکنند، گزینهٔ واضح این است که رابط کاربری مستقیماً با این APIها تعامل کند، همانطور که در شکل ۲-۷ است. UI مبتنی بر وب میتواند با درخواست GET JavaScript داده بازیابی کند یا POST برای تغییر. حتی برای اپلیکیشن موبایل native، شروع ارتباط HTTP نسبتاً ساده است. UI سپس componentهای مختلف interface را میسازد و state را با سرور sync میکند. اگر برای ارتباط service-to-service از پروتکل باینری استفاده میکردیم، برای clientهای وب سختتر بود، اما برای دستگاه موبایل native میتوانست خوب باشد.
شکل ۲-۷. استفاده از چند API برای ارائهٔ رابط کاربری
چند معایب دارد. اول، توانایی کمی برای tailor کردن پاسخها برای انواع مختلف دستگاه داریم. مثلاً وقتی رکورد مشتری را بازیابی میکنم، آیا همان داده را برای فروشگاه موبایل و اپلیکیشن helpdesk میکشم؟ یک راهحل اجازه دادن به consumerها برای مشخص کردن فیلدهایی است که هنگام درخواست میخواهند، اما این فرض میکند هر سرویس این شکل تعامل را پشتیبانی کند.
سؤال کلیدی دیگر: چه کسی رابط کاربری را میسازد؟ افرادی که سرویسها را نگه میدارند از نحوهٔ surface شدن سرویسها به کاربران دورند — مثلاً اگر تیم دیگری UI میسازد، ممکن است به روزهای بد معماری لایهای برگردیم جایی حتی تغییرات کوچک به درخواست تغییر به چند تیم نیاز دارد.
این ارتباط هم میتواند fairly chatty باشد. باز کردن فراخوانیهای زیاد مستقیم به سرویسها برای دستگاه موبایل intensive است و استفادهٔ بسیار ناکارآمد از طرح موبایل مشتری! API gateway میتواند کمک کند چون میتوانید فراخوانیهایی expose کنید که چند فراخوانی زیرین را aggregate میکنند، اگرچه خودش معایبی دارد که بهزودی بررسی میکنیم.
ترکیب Fragment رابط کاربری
بهجای اینکه UI فراخوانی API کند و همهچیز را به کنترل UI map کند، میتوانیم سرویسها مستقیماً بخشهایی از UI را بدهند و این fragmentها را برای ساخت UI بکشیم، همانطور که در شکل ۲-۸ است. تصور کنید سرویس recommendation ویجت recommendation میدهد که با کنترلها یا fragmentهای UI دیگر ترکیب میشود تا UI کلی ساخته شود. شاید بهصورت جعبه روی صفحهٔ وب همراه محتوای دیگر render شود.
شکل ۲-۸. سرویسها مستقیماً componentهای UI برای مونتاژ سرو میدهند
تغییری از این رویکرد که خوب کار میکند، مونتاژ سریای از بخشهای coarse-grainedتر UI است. بهجای ویجتهای کوچک، paneهای کامل thick client یا مجموعهای از صفحات وب را مونتاژ میکنید.
این fragmentهای coarse-grained از اپلیکیشنهای سمت سرور سرو میشوند که خود فراخوانیهای API مناسب را انجام میدهند. این مدل وقتی بهترین کار را میکند که fragmentها با مالکیت تیم همراستا باشند. مثلاً شاید تیمی که order management در فروشگاه موسیقی را نگه میدارد همهٔ صفحات مرتبط با order management را سرو کند.
هنوز نوعی لایهٔ مونتاژ لازم است این بخشها را با هم بکشد. میتواند بهسادگی templating سمت سرور باشد، یا اگر هر مجموعه صفحه از اپ متفاوت میآید، شاید URI routing هوشمند لازم باشد.
یکی از مزایای کلیدی این است که همان تیمی که سرویسها را تغییر میدهد میتواند مسئول تغییر آن بخشهای UI هم باشد. تغییرات را سریعتر بیرون میدهیم. اما مشکلاتی دارد. اول، اطمینان از consistency تجربهٔ کاربری چیزی است که باید برطرف کنیم. کاربران تجربهٔ seamless میخواهند، نه اینکه بخشهای مختلف interface متفاوت کار کنند یا زبان طراحی متفاوت ارائه دهند. تکنیکهایی برای اجتناب از این مشکل هست، مثل living style guide، جایی assetهایی مثل componentهای HTML، CSS و تصویر برای consistency به اشتراک گذاشته میشوند.
مشکل دیگر سختتر است. با اپلیکیشن native یا thick client چه میشود؟ نمیتوانیم component UI سرو کنیم. میتوانیم رویکرد hybrid استفاده کنیم و اپ native برای سرو componentهای HTML استفاده کنیم، اما این رویکرد بارها معایب نشان داده. پس اگر تجربهٔ native لازم است، باید به رویکردی برگردیم که اپ frontend فراخوانی API کند و خود UI را handle کند. اما حتی اگر فقط UI وب را در نظر بگیریم، شاید treatmentهای بسیار متفاوت برای انواع دستگاه بخواهیم. ساخت componentهای responsive کمک میکند.
یک مشکل کلیدی با این رویکرد هست که مطمئن نیستم قابل حل باشد. گاهی قابلیتهایی که سرویس ارائه میدهد بهخوبی در ویجت یا صفحه نمینشیند. شاید بخواهم recommendation را در جعبه روی صفحه نشان دهم، اما اگر بخواهم recommendationهای پویا را جای دیگر ببافم چه؟ وقتی جستجو میکنم میخواهم type ahead بهطور خودکار recommendation تازه trigger کند. هرچه شکل تعامل cross-cuttingتر باشد، کمتر این مدل مینشیند و احتمال بیشتر برگشت به فراخوانی API.
Backends for Frontends
راهحل رایج برای مشکل interfaceهای chatty با سرویسهای backend، یا نیاز به vary کردن محتوا برای انواع دستگاه، داشتن endpoint تجمیع سمت سرور یا API gateway است. میتواند چند فراخوانی backend را marshal کند، در صورت نیاز محتوا را برای دستگاههای مختلف vary و aggregate کند و سرو کند، همانطور که در شکل ۲-۹ میبینیم.
شکل ۲-۹. استفاده از gateway monolithic واحد برای handle کردن فراخوانیها به/از UIها
دیدهام این رویکرد به فاجعه منجر شود وقتی این endpointهای سمت سرور به لایههای ضخیم با رفتار زیاد تبدیل شوند. توسط تیمهای جدا مدیریت میشوند و جایی دیگر است که منطق باید هر بار قابلیتی تغییر کند.
مشکلی که میتواند رخ دهد این است که معمولاً یک لایهٔ غولپیکر برای همهٔ سرویسها داریم. این به همهچیز پرتاب شدن با هم منجر میشود و ناگهان isolation رابطهای کاربری مختلف را از دست میدهیم و توانایی release مستقل آنها را محدود میکنیم. مدلی که ترجیح میدهم و دیدهام خوب کار میکند، محدود کردن استفاده از این backendها به یک رابط کاربری یا اپلیکیشن مشخص است، همانطور که در شکل ۲-۱۰ میبینیم.
شکل ۲-۱۰. استفاده از backendهای اختصاصی برای frontendها
این الگو گاهی backends for frontends (BFFs) نامیده میشود. به تیمی که روی هر UI تمرکز دارد اجازه میدهد componentهای سمت سرور خودش را هم handle کند. میتوانید این backendها را بخشهایی از رابط کاربری بدانید که در سرور embed شدهاند. برخی UIها footprint سمت سرور minimal میخواهند، برخی خیلی بیشتر. اگر لایهٔ API authentication و authorization لازم است، میتواند بین BFFها و UIها بنشیند. در (ارجاع آینده) بیشتر بررسی میکنیم.
خطر این رویکرد همان لایهٔ aggregating است؛ میتواند منطقی بگیرد که نباید. منطق کسبوکار قابلیتهایی که این backendها استفاده میکنند باید در خود سرویسها بماند. این BFFها فقط باید رفتار مخصوص ارائهٔ تجربهٔ کاربری خاص را داشته باشند.
رویکرد ترکیبی
بسیاری از گزینههای بالا نیازی به one-size-fits-all ندارند. میتوانم سازمانی ببینم که برای وب از مونتاژ مبتنی بر fragment استفاده کند، اما برای اپ موبایل backends-for-frontends. نکتهٔ کلیدی این است که cohesion قابلیتهای زیرین که به کاربران ارائه میدهیم را حفظ کنیم. باید اطمینان حاصل کنیم منطق مرتبط با سفارش موسیقی یا تغییر جزئیات مشتری درون سرویسهایی که آن عملیاتها را handle میکنند بماند و در همهٔ سیستم پخش نشود. اجتناب از تلهٔ گذاشتن رفتار زیاد در لایههای میانی کار متعادلسازی دشواری است.
یکپارچهسازی با نرمافزار شخص ثالث
رویکردهای شکستن سیستمهای موجود تحت کنترل خودمان را دیدیم، اما وقتی نمیتوانیم چیزهایی که با آنها صحبت میکنیم را تغییر دهیم چه؟ به دلایل معتبر زیاد، سازمانهایی که برای کار میکنیم نرمافزار آمادهٔ سفارشی (COTS) میخرند یا از software as a service (SaaS) استفاده میکنند که کنترل کمی روی آن داریم. پس چگونه sensibly با آنها یکپارچه شویم؟
اگر این کتاب را میخوانید، احتمالاً در سازمانی کار میکنید که کد مینویسد. شاید نرمافزار برای اهداف داخلی یا client خارجی یا هر دو بنویسید. با این حال، حتی اگر سازمانی با توانایی ساخت نرمافزار سفارشی قابلتوجه هستید، هنوز محصولات نرمافزاری از طرفهای خارجی استفاده میکنید، تجاری یا open source. چرا؟
اول، سازمان شما تقریباً قطعاً تقاضای نرمافزار بیشتری دارد از آنچه داخلاً برآورده میشود. به همهٔ محصولاتی که استفاده میکنید فکر کنید، از ابزار productivity دفتر مثل Excel تا سیستمعامل تا سیستمهای حقوق و دستمزد. ساخت همهٔ آنها برای استفادهٔ خودتان کار عظیمی است. دوم و مهمتر، cost effective نیست! هزینهٔ ساخت سیستم ایمیل خودتان احتمالاً از استفاده از ترکیب mail server و client موجود، حتی گزینههای تجاری، بسیار بیشتر است.
clientهای من اغلب با سؤال «بسازم یا بخرم؟» درگیرند. بهطور کلی، توصیهای که با سازمان enterprise متوسط میدهیم این است: «اگر منحصر به کاری شماست و strategic asset محسوب میشود بسازید؛ اگر استفاده از ابزار آنقدر خاص نیست بخرید.»
مثلاً سازمان متوسط سیستم حقوق و دستمزد را strategic asset نمیداند. مردم در کل جهان به یک شکل حقوق میگیرند. همینطور بیشتر سازمانها CMS را off the shelf میخرند چون استفاده از چنین ابزاری کلیدی برای کسبوکارشان نیست. از طرف دیگر، در بازسازی اولیهٔ وبسایت Guardian تصمیم گرفته شد CMS سفارشی بسازند چون هستهٔ کسبوکار روزنامه بود.
پس اینکه گاهی با نرمافزار تجاری شخص ثالث روبهرو میشویم منطقی و خوشآمد است. با این حال، بسیاری از ما در نهایت برخی از این سیستمها را لعنت میکنیم. چرا؟
کمبود کنترل
یکی از چالشهای یکپارچهسازی با و گسترش قابلیتهای محصولات COTS مثل CMS یا ابزار SaaS این است که معمولاً بسیاری از تصمیمات فنی برایتان گرفته شده. چگونه با ابزار یکپارچه شوید؟ تصمیم vendor است. با چه زبانی میتوانید ابزار را extend کنید؟ به vendor بستگی دارد. آیا میتوانید configuration ابزار را در version control ذخیره و از صفر rebuild کنید تا continuous integration سفارشیسازیها را فعال کنید؟ به انتخابهای vendor بستگی دارد.
اگر خوششانس باشید، آسان یا سخت بودن کار با ابزار از نظر توسعه در فرآیند انتخاب ابزار در نظر گرفته شده. اما حتی آنگاه، effectively بخشی از کنترل را به طرف خارجی واگذار میکنید. ترفند این است که کار یکپارچهسازی و سفارشیسازی را به شرایط خودتان برگردانید.
سفارشیسازی
بسیاری از ابزارهایی که سازمانهای enterprise میخرند خود را بر قابلیت سفارشیسازی سنگین برای شما میفروشند. مراقب باشید! اغلب بهخاطر ماهیت toolchain در دسترس، هزینهٔ سفارشیسازی از ساخت چیزی bespoke از صفر گرانتر است! اگر محصول را خریدهاید اما قابلیتهای خاص آن برایتان آنقدر خاص نیست، شاید منطقیتر باشد نحوهٔ کار سازمان را تغییر دهید تا سفارشیسازی پیچیده را آغاز نکنید.
سیستمهای مدیریت محتوا مثال عالی این خطرند. با چند CMS کار کردهام که بهطراحی continuous integration را پشتیبانی نمیکنند، APIهای وحشتناک دارند و حتی upgrade جزئی در ابزار زیرین میتواند سفارشیسازیهای شما را بشکند.
Salesforce بهویژه در این زمینه دردسرساز است. سالها Force.com platform را push کرده که استفاده از زبان برنامهنویسی Apex را میخواهد که فقط در ecosystem Force.com وجود دارد!
اسپاگتی یکپارچهسازی
چالش دیگر نحوهٔ یکپارچهسازی با ابزار است. همانطور که قبلاً بحث کردیم، فکر کردن دقیق دربارهٔ نحوهٔ یکپارچهسازی بین سرویسها مهم است و ایدهآل استاندارد کردن روی تعداد کمی نوع یکپارچهسازی باشد. اما اگر یک محصول پروتکل باینری اختصاصی استفاده کند، دیگری نوعی SOAP و دیگری XML-RPC، چه میماند؟ بدتر ابزارهایی که اجازه میدهند مستقیماً به data store زیرین دست ببرید، با همان مشکلات coupling که قبلاً بحث کردیم.
به شرایط خودتان
محصولات COTS و SaaS قطعاً جای خود را دارند و برای بیشتر ما ساخت همهچیز از صفر feasible یا sensible نیست. پس چگونه این چالشها را حل کنیم؟ کلید این است که چیزها را به شرایط خودتان برگردانید.
ایدهٔ هسته این است که سفارشیسازیها را روی platformی که کنترل میکنید انجام دهید و تعداد consumerهای مختلف ابزار را محدود کنید. برای بررسی دقیق، چند مثال ببینیم.
مثال: CMS بهعنوان سرویس
در تجربهٔ من، CMS یکی از رایجترین محصولاتی است که باید سفارشی یا یکپارچه شود. دلیلش این است که مگر سایت static ساده بخواهید، سازمان enterprise متوسط میخواهد عملکرد وبسایت را با محتوای پویا مثل رکوردهای مشتری یا آخرین پیشنهادات محصول غنی کند. منبع این محتوای پویا معمولاً سرویسهای دیگر درون سازمان است که شاید خودتان ساختهاید.
وسوسه — و اغلب نقطهٔ فروش CMS — این است که CMS را سفارشی کنید تا همهٔ این محتوای خاص را بکشد و به دنیای بیرون نشان دهد. با این حال، محیط توسعهٔ CMS متوسط وحشتناک است.
بیایید ببینیم CMS متوسط در چه تخصص دارد و احتمالاً برای چه خریده شده: ایجاد محتوا و مدیریت محتوا. بیشتر CMSها حتی در page layout بد هستند و معمولاً ابزار drag-and-drop میدهند که کافی نیست. حتی آنگاه کسی که HTML و CSS میفهمد برای fine-tune کردن templateهای CMS لازم است. platform بدی برای ساخت کد سفارشی هستند.
پاسخ؟ CMS را با سرویس خودتان front کنید که وبسایت را به دنیای بیرون میدهد، همانطور که در شکل ۲-۱۱ نشان داده شده.
شکل ۲-۱۱. پنهان کردن CMS با سرویس خودتان
نقش CMS را سرویسی بدانید که ایجاد و بازیابی محتوا را اجازه میدهد. در سرویس خودتان کد را مینویسید و با سرویسها همانطور که میخواهید یکپارچه میکنید. کنترل scale کردن وبسایت را دارید (بسیاری CMSهای تجاری add-on اختصاصی برای load دارند) و سیستم templating منطقی را انتخاب میکنید.
بیشتر CMSها API برای ایجاد محتوا هم میدهند، پس میتوانید آن را هم با façade سرویس خودتان front کنید. در برخی موقعیتها حتی از چنین façade برای abstract کردن APIهای بازیابی محتوا استفاده کردهایم.
این الگو را چند بار در ThoughtWorks در چند سال اخیر و بیش از یک بار خودم استفاده کردهایم. یک مثال قابلتوجه clientای بود که میخواست وبسایت جدید برای محصولاتش push کند. ابتدا میخواست کل راهحل را روی CMS بسازد اما هنوز یکی انتخاب نکرده بود. بهجای آن این رویکرد را پیشنهاد دادیم و توسعهٔ وبسایت fronting را شروع کردیم. در انتظار انتخاب CMS، با web serviceای که فقط محتوای static سطح میداد جعل کردیم. با استفاده از fake content service در production برای سطح دادن محتوا به سایت live، خیلی قبل از انتخاب CMS live شدیم. بعداً توانستیم ابزار انتخابشده را بدون تغییر در اپلیکیشن fronting drop in کنیم.
با این رویکرد scope کاری CMS را به حداقل نگه میداریم و سفارشیسازیها را به stack فناوری خودمان منتقل میکنیم.
مثال: سیستم CRM چندنقشی
ابزار CRM — یا Customer Relationship Management — موجودی است که اغلب در قلب حتی سختترین معمار ترس ایجاد میکند. این بخش، با vendorهایی مثل Salesforce یا SAP، پر از مثال ابزارهایی است که سعی میکنند همهچیز را برایتان انجام دهند. این میتواند به single point of failure شدن ابزار و گرهای از وابستگیها منجر شود. بسیاری از پیادهسازیهای CRM که دیدهام از بهترین مثالهای سرویسهای چسبنده (در مقابل cohesive) هستند.
scope چنین ابزاری معمولاً کوچک شروع میشود، اما با گذشت زمان بخش فزایندهای از نحوهٔ کار سازمان میشود. مشکل این است که جهت و انتخابهای اطراف این سیستم حیاتی اکنون اغلب توسط خود vendor ابزار گرفته میشود، نه شما.
اخیراً در تمرینی برای بازپس گرفتن کنترل شرکت کردم. سازمان فهمید اگرچه از CRM برای کارهای زیاد استفاده میکند، ارزش هزینههای فزایندهٔ platform را نمیگیرد. همزمان چند سیستم داخلی از APIهای کمتر از ایدهآل CRM برای یکپارچهسازی استفاده میکردند. میخواستیم معماری سیستم را به جایی ببریم که سرویسهایی داشته باشیم domain کسبوکار را مدل میکنند و زمینهٔ migration احتمالی را هم بگذاریم.
اول مفاهیم هستهٔ domain که CRM فعلاً مالک بود را شناسایی کردیم. یکی مفهوم project — یعنی چیزی که عضو staff به آن assign میشود. چند سیستم دیگر به اطلاعات project نیاز داشتند. project service ایجاد کردیم. این سرویس projectها را بهعنوان resourceهای RESTful expose میکرد و سیستمهای خارجی میتوانستند نقطهٔ یکپارچهسازی را به سرویس جدید و آسانتر منتقل کنند. دروناً، project service فقط façade بود و جزئیات یکپارچهسازی زیرین را پنهان میکرد. این را در شکل ۲-۱۲ میبینید.
شکل ۲-۱۲. استفاده از سرویسهای façade برای پنهان کردن CRM زیرین
کار، که هنگام نوشتن هنوز در جریان بود، شناسایی مفاهیم domain دیگری بود که CRM handle میکرد و ساخت façade بیشتر برای آنها. وقتی زمان migration از CRM زیرین برسد، میتوانیم هر façade را جداگانه ببینیم تا تصمیم بگیریم راهحل نرمافزاری داخلی یا چیزی off the shelf مناسب است.
الگوی Strangler
وقتی به platformهای legacy یا حتی COTS که کاملاً تحت کنترل ما نیستند میرسیم، باید با این هم روبهرو شویم که وقتی میخواهیم آنها را حذف یا حداقل از آنها دور شویم چه میشود. الگوی مفید Strangler Application Pattern است. شبیه مثال front کردن CMS با کد خودمان، با strangler فراخوانیها به سیستم قدیم را capture و intercept میکنید. این به شما اجازه میدهد تصمیم بگیرید این فراخوانیها به کد legacy موجود route شوند یا به کد جدیدی که نوشتهاید. این اجازه میدهد قابلیت را در طول زمان جایگزین کنید بدون نیاز به rewrite بزرگ یکباره.
وقتی به میکروسرویسها میرسیم، بهجای یک اپلیکیشن monolithic واحد که همهٔ فراخوانیها به سیستم legacy موجود را intercept میکند، شاید از سریای میکروسرویس برای این interception استفاده کنید. capture و redirect فراخوانیهای اصلی در این موقعیت پیچیدهتر میشود و شاید به proxy نیاز داشته باشید.
جمعبندی
گزینههای مختلفی دربارهٔ یکپارچهسازی دیدیم و افکارم را دربارهٔ انتخابهایی که احتمالاً بیشترین اطمینان را میدهند میکروسرویسها تا حد امکان از همکاران دیگر decoupled بمانند به اشتراک گذاشتم:
- از یکپارچهسازی پایگاه داده تا حد ممکن اجتناب کنید.
- trade-offهای بین REST و RPC را بفهمید، اما REST را بهعنوان نقطهٔ شروع خوب برای یکپارچهسازی request/response قویاً در نظر بگیرید.
- choreography را بر orchestration ترجیح دهید.
- با درک قانون Postel و استفاده از tolerant readerها از تغییرات شکننده و نیاز به versioning اجتناب کنید.
- رابطهای کاربری را لایههای compositional در نظر بگیرید.
موضوع زیادی پوشش دادیم و نتوانستیم همه را عمیق برویم. با این حال، این باید پایهٔ خوبی برای شروع و هدایت درست برای یادگیری بیشتر باشد.
همچنین زمانی صرف کردیم برای کار با سیستمهایی که کاملاً تحت کنترل ما نیستند بهصورت محصولات COTS. معلوم میشود این توصیف بههمانراحتی برای نرمافزاری که خودمان نوشتهایم هم صدق میکند!
برخی رویکردهای اینجا بهخوبی برای نرمافزار legacy هم کار میکنند، اما اگر بخواهیم کار اغلب عظیم آوردن این سیستمهای قدیمیتر به راه و تجزیهٔ آنها به بخشهای قابلاستفادهتر را به عهده بگیریم چه؟ آن را در فصل بعد بهتفصیل بحث میکنیم.