Skip to content

فصل ۴ — کدگذاری و تکامل

همه چیز تغییر می‌کند و هیچ‌چیز ثابت نمی‌ماند. — هراکلیتوس اِفسوس، نقل‌شده توسط افلاطون در Cratylus (۳۶۰ پیش از میلاد)

برنامه‌ها ناگزیر با گذشت زمان تغییر می‌کنند. ویژگی‌ها اضافه یا اصلاح می‌شوند؛ محصولات جدید عرضه می‌شوند؛ نیازهای کاربران بهتر درک می‌شوند؛ یا شرایط کسب‌وکار عوض می‌شود. در فصل ۱ مفهوم قابلیت تکامل (evolvability) را معرفی کردیم: باید تلاش کنیم سیستم‌هایی بسازیم که سازگاری با تغییر را آسان کنند (به «قابلیت تکامل: آسان کردن تغییر» در صفحه ۲۱ مراجعه کنید).

در بیشتر موارد، تغییر در ویژگی‌های یک برنامه به تغییر در داده‌ای که ذخیره می‌کند هم نیاز دارد: شاید لازم باشد فیلد یا نوع رکورد جدیدی ثبت شود، یا شاید داده‌های موجود باید به شکل جدیدی ارائه شوند.

مدل‌های داده‌ای که در فصل ۲ بررسی کردیم، برای مواجهه با چنین تغییری رویکردهای متفاوتی دارند. پایگاه‌های داده رابطه‌ای معمولاً فرض می‌کنند همه داده‌های پایگاه با یک اسکیما مطابقت دارند: اگرچه آن اسکیما قابل تغییر است (از طریق مهاجرت اسکیما؛ یعنی دستورات ALTER)، در هر لحظه دقیقاً یک اسکیما برقرار است. در مقابل، پایگاه‌های داده schema-on-read («بدون اسکیما») اسکیما را اعمال نمی‌کنند، بنابراین پایگاه می‌تواند ترکیبی از قالب‌های قدیمی‌تر و جدیدتر داده را داشته باشد که در زمان‌های مختلف نوشته شده‌اند (به «انعطاف اسکیما در مدل سندی» در صفحه ۳۹ مراجعه کنید).

وقتی قالب یا اسکیمای داده تغییر می‌کند، اغلب تغییر متناظری در کد برنامه هم لازم است (مثلاً فیلد جدیدی به رکورد اضافه می‌کنید و کد برنامه شروع به خواندن و نوشتن آن فیلد می‌کند). با این حال، در یک برنامه بزرگ، تغییرات کد معمولاً نمی‌توانند فوری انجام شوند:

  • در برنامه‌های سمت سرور ممکن است بخواهید به‌روزرسانی تدریجی (rolling upgrade) یا rollout مرحله‌ای انجام دهید: نسخه جدید را ابتدا روی چند گره مستقر کنید، بررسی کنید که نسخه جدید روان اجرا می‌شود، و به‌تدریج همه گره‌ها را پوشش دهید. این امکان استقرار نسخه‌های جدید بدون قطع سرویس را می‌دهد و در نتیجه انتشار مکررتر و قابلیت تکامل بهتر را تشویق می‌کند.
  • در برنامه‌های سمت کلاینت در اختیار کاربر هستید که ممکن است مدتی به‌روزرسانی را نصب نکند.

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

  • سازگاری پس‌رو (Backward compatibility): کد جدیدتر می‌تواند داده‌ای را بخواند که کد قدیمی‌تر نوشته است.
  • سازگاری پیش‌رو (Forward compatibility): کد قدیمی‌تر می‌تواند داده‌ای را بخواند که کد جدیدتر نوشته است.

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

در این فصل چند قالب برای کدگذاری داده را بررسی می‌کنیم، از جمله JSON، XML، Protocol Buffers، Thrift و Avro. به‌ویژه می‌بینیم چگونه با تغییرات اسکیما برخورد می‌کنند و چگونه سیستم‌هایی را پشتیبانی می‌کنند که داده و کد قدیمی و جدید باید هم‌زیستی داشته باشند. سپس بحث می‌کنیم که آن قالب‌ها چگونه برای ذخیره‌سازی داده و ارتباط استفاده می‌شوند: در سرویس‌های وب، Representational State Transfer (REST)، و فراخوانی رویه از راه دور (RPC)، و همچنین سیستم‌های پیام‌رسانی مانند actorها و صف‌های پیام.

قالب‌های کدگذاری داده

برنامه‌ها معمولاً با داده در (حداقل) دو نمایش متفاوت کار می‌کنند:

  1. در حافظه، داده در اشیاء، structها، لیست‌ها، آرایه‌ها، جدول‌های hash، درخت‌ها و غیره نگه‌داری می‌شود. این ساختارهای داده برای دسترسی و دستکاری کارآمد توسط CPU بهینه شده‌اند (معمولاً با استفاده از اشاره‌گر).
  2. وقتی می‌خواهید داده را در فایل بنویسید یا روی شبکه بفرستید، باید آن را به‌صورت دنباله‌ای خودکفا از بایت‌ها کدگذاری کنید (مثلاً یک سند JSON). چون اشاره‌گر برای هیچ فرآیند دیگری معنا ندارد، این نمایش دنباله‌بایت‌ها از ساختارهای داده‌ای که معمولاً در حافظه استفاده می‌شوند کاملاً متفاوت به نظر می‌رسد.

بنابراین به نوعی ترجمه بین این دو نمایش نیاز داریم. ترجمه از نمایش درون‌حافظه‌ای به دنباله بایت کدگذاری (encoding؛ که serialization یا marshalling هم نامیده می‌شود) نام دارد، و معکوس آن رمزگشایی (decoding؛ parsing، deserialization، unmarshalling) است.

تداخل اصطلاحات

متأسفانه serialization در زمینه تراکنش‌ها (فصل ۷) هم با معنایی کاملاً متفاوت به کار می‌رود. برای جلوگیری از بارگذاری بیش از حد این واژه، در این کتاب از encoding استفاده می‌کنیم، هرچند serialization شاید اصطلاح رایج‌تری باشد.

چون این مسئله بسیار رایج است، کتابخانه‌ها و قالب‌های کدگذاری بسیار متنوعی برای انتخاب وجود دارد. بیایید مرور کوتاهی داشته باشیم.

قالب‌های مختص زبان

بسیاری از زبان‌های برنامه‌نویسی پشتیبانی داخلی برای کدگذاری اشیاء درون‌حافظه‌ای به دنباله بایت دارند. مثلاً Java دارای java.io.Serializable [1]، Ruby دارای Marshal [2]، Python دارای pickle [3] و غیره است. کتابخانه‌های شخص ثالث زیادی هم وجود دارد، مثل Kryo برای Java [4].

این کتابخانه‌های کدگذاری بسیار راحت‌اند، چون با حداقل کد اضافی امکان ذخیره و بازیابی اشیاء درون‌حافظه‌ای را می‌دهند. با این حال، مشکلات عمیق متعددی هم دارند:

  • کدگذاری اغلب به زبان برنامه‌نویسی خاصی گره خورده و خواندن داده در زبان دیگر بسیار دشوار است. اگر داده را با چنین کدگذاری‌ای ذخیره یا منتقل کنید، خود را به زبان فعلی‌تان برای مدت طولانی متعهد کرده‌اید و ادغام سیستم‌هایتان با سازمان‌های دیگر (که ممکن است زبان‌های متفاوتی داشته باشند) را از پیش مسدود می‌کنید.
  • برای بازیابی داده در همان نوع اشیاء، فرآیند رمزگشایی باید بتواند کلاس‌های دلخواه را نمونه‌سازی کند. این اغلب منبع مشکلات امنیتی است [5]: اگر مهاجم بتواند برنامه شما را وادار به رمزگشایی یک دنباله بایت دلخواه کند، می‌تواند کلاس‌های دلخواه نمونه‌سازی کند که در نتیجه اغلب اجازه کارهای وحشتناکی مثل اجرای کد دلخواه از راه دور را می‌دهد [6, 7].
  • نسخه‌بندی داده در این کتابخانه‌ها اغلب پس از فکر است: چون برای کدگذاری سریع و آسان داده در نظر گرفته شده‌اند، اغلب مسائل ناخوشایند سازگاری پیش‌رو و پس‌رو را نادیده می‌گیرند.
  • کارایی (زمان CPU برای کدگذاری یا رمزگشایی، و اندازه ساختار کدگذاری‌شده) هم اغلب پس از فکر است. مثلاً serialization داخلی Java به خاطر کارایی ضعیف و کدگذاری حجیم بدنام است [8].

به همین دلایل، استفاده از کدگذاری داخلی زبان برای هر چیزی جز اهداف بسیار گذرا، به‌طور کلی ایده بدی است.

i. به‌جز چند مورد خاص، مثل برخی فایل‌های memory-mapped یا وقتی مستقیماً روی داده فشرده‌شده کار می‌کنید (همان‌طور که در «فشرده‌سازی ستونی» در صفحه ۹۷ توضیح داده شد).

ii. توجه کنید که کدگذاری ربطی به رمزنگاری ندارد. در این کتاب درباره رمزنگاری بحث نمی‌کنیم.

JSON، XML و گونه‌های دودویی

با حرکت به سمت کدگذاری‌های استاندارد که توسط بسیاری از زبان‌های برنامه‌نویسی قابل نوشتن و خواندن هستند، JSON و XML رقبای آشکارند. آن‌ها به‌طور گسترده شناخته شده‌اند، پشتیبانی گسترده دارند، و تقریباً به همان اندازه محبوب، مورد نفرت هم هستند. XML اغلب به خاطر پرحجم بودن و پیچیدگی غیرضروری مورد انتقاد قرار می‌گیرد [9]. محبوبیت JSON عمدتاً به پشتیبانی داخلی در مرورگرهای وب (به‌عنوان زیرمجموعه JavaScript) و سادگی نسبی نسبت به XML برمی‌گردد. CSV هم قالب محبوب دیگری است که مستقل از زبان است، هرچند قدرت کمتری دارد.

JSON، XML و CSV قالب‌های متنی هستند و بنابراین تا حدی برای انسان خوانا هستند (هرچند نحو آن‌ها موضوع بحث رایجی است). علاوه بر مسائل سطحی نحوی، مشکلات ظریفی هم دارند:

  • ابهام زیادی درباره کدگذاری اعداد وجود دارد. در XML و CSV نمی‌توانید بین عدد و رشته‌ای که فقط از رقم تشکیل شده تمایز قائل شوید (مگر با ارجاع به اسکیمای خارجی). JSON بین رشته و عدد تمایز می‌گذارد، اما بین عدد صحیح و اعشاری تمایز نمی‌گذارد و دقت را مشخص نمی‌کند. این برای اعداد بزرگ مشکل‌ساز است؛ مثلاً اعداد صحیح بزرگ‌تر از 2^53 را نمی‌توان دقیقاً در عدد اعشاری double-precision IEEE 754 نمایش داد، بنابراین چنین اعدادی وقتی در زبانی که از اعداد اعشاری استفاده می‌کند (مثل JavaScript) parse می‌شوند، نادرست می‌شوند. نمونه اعداد بزرگ‌تر از 2^53 در Twitter دیده می‌شود که از عدد ۶۴ بیتی برای شناسایی هر توییت استفاده می‌کند. JSON برگشتی از API توییتر شناسه توییت را دو بار شامل می‌شود: یک‌بار به‌صورت عدد JSON و یک‌بار به‌صورت رشته اعشاری، تا مشکل parse نشدن درست اعداد توسط برنامه‌های JavaScript دور زده شود [10].
  • JSON و XML پشتیبانی خوبی از رشته‌های کاراکتر Unicode (یعنی متن قابل خواندن برای انسان) دارند، اما از رشته‌های دودویی (دنباله بایت بدون کدگذاری کاراکتر) پشتیبانی نمی‌کنند. رشته‌های دودویی ویژگی مفیدی هستند، بنابراین مردم این محدودیت را با کدگذاری داده دودویی به‌صورت متن با Base64 دور می‌زنند. سپس اسکیما برای نشان دادن اینکه مقدار باید به‌عنوان Base64-encoded تفسیر شود به کار می‌رود. این کار می‌کند، اما تا حدی hack است و اندازه داده را ۳۳٪ افزایش می‌دهد.
  • برای XML [11] و JSON [12] پشتیبانی اختیاری اسکیما وجود دارد. این زبان‌های اسکیما نسبتاً قدرتمند و در نتیجه نسبتاً پیچیده برای یادگیری و پیاده‌سازی هستند. استفاده از اسکیماهای XML نسبتاً گسترده است، اما بسیاری از ابزارهای مبتنی بر JSON از اسکیما استفاده نمی‌کنند. چون تفسیر درست داده (مثل اعداد و رشته‌های دودویی) به اطلاعات اسکیما وابسته است، برنامه‌هایی که از اسکیماهای XML/JSON استفاده نمی‌کنند ممکن است مجبور شوند منطق کدگذاری/رمزگشایی مناسب را hardcode کنند.
  • CSV هیچ اسکیما ندارد، بنابراین تعریف معنای هر سطر و ستون بر عهده برنامه است. اگر تغییر برنامه سطر یا ستون جدیدی اضافه کند، باید آن تغییر را دستی مدیریت کنید. CSV هم قالب نسبتاً مبهمی است (اگر مقداری شامل کاما یا کاراکتر newline باشد چه می‌شود؟). اگرچه قواعد escape آن به‌طور رسمی مشخص شده [13]، همه parserها آن‌ها را درست پیاده‌سازی نمی‌کنند.

با وجود این نقص‌ها، JSON، XML و CSV برای بسیاری از اهداف کافی‌اند. احتمالاً محبوب می‌مانند، به‌ویژه به‌عنوان قالب‌های تبادل داده (یعنی برای ارسال داده از یک سازمان به سازمان دیگر). در چنین موقعیت‌هایی، تا وقتی مردم روی قالب توافق کنند، اغلب مهم نیست قالب چقدر زیبا یا کارآمد است. دشواری توافق سازمان‌های مختلف روی هر چیزی، بیشتر نگرانی‌های دیگر را پشت سر می‌گذارد.

کدگذاری دودویی

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

JSON از XML کم‌حجم‌تر است، اما هر دو در مقایسه با قالب‌های دودویی فضای زیادی مصرف می‌کنند. این مشاهده به توسعه انبوهی از کدگذاری‌های دودویی برای JSON (MessagePack، BSON، BJSON، UBJSON، BISON و Smile، برای نام بردن چند مورد) و برای XML (مثلاً WBXML و Fast Infoset) منجر شد. این قالب‌ها در حوزه‌های مختلف پذیرفته شده‌اند، اما هیچ‌کدام به اندازه نسخه‌های متنی JSON و XML پذیرش گسترده ندارند.

برخی از این قالب‌ها مجموعه datatypeها را گسترش می‌دهند (مثلاً تمایز بین اعداد صحیح و اعشاری، یا افزودن پشتیبانی از رشته‌های دودویی)، اما در غیر این صورت مدل داده JSON/XML را بدون تغییر نگه می‌دارند. به‌ویژه، چون اسکیما را تجویز نمی‌کنند، باید همه نام فیلدهای شیء را در داده کدگذاری‌شده بگنجانند. یعنی در کدگذاری دودویی سند JSON مثال ۴-۱، باید رشته‌های userName، favoriteNumber و interests را جایی بگنجانند.

مثال ۴-۱. رکورد نمونه‌ای که در این فصل در چند قالب دودویی کدگذاری می‌کنیم

json
{
      "userName": "Martin",
      "favoriteNumber": 1337,
      "interests": ["daydreaming", "hacking"]
}

بیایید MessagePack را به‌عنوان نمونه ببینیم، کدگذاری دودویی برای JSON. شکل ۴-۱ دنباله بایتی را نشان می‌دهد که با MessagePack [14] سند JSON مثال ۴-۱ را کدگذاری می‌کنید. چند بایت اول به این صورت است:

  1. بایت اول، 0x83، نشان می‌دهد آنچه بعد می‌آید یک object است (چهار بیت بالایی = 0x80) با سه فیلد (چهار بیت پایینی = 0x03). (اگر object بیش از ۱۵ فیلد داشته باشد و تعداد فیلدها در چهار بیت جا نشود، نشانگر نوع متفاوتی می‌گیرد و تعداد فیلدها در دو یا چهار بایت کدگذاری می‌شود.)
  2. بایت دوم، 0xa8، نشان می‌دهد آنچه بعد می‌آید یک string است (چهار بیت بالایی = 0xa0) به طول هشت بایت (چهار بیت پایینی = 0x08).
  3. هشت بایت بعدی نام فیلد userName به ASCII است. چون طول قبلاً مشخص شده، نیازی به نشانگری برای پایان رشته (یا escape) نیست.
  4. هفت بایت بعدی مقدار شش‌حرفی Martin را با پیشوند 0xa6 کدگذاری می‌کنند، و همین‌طور ادامه می‌یابد.

کدگذاری دودویی ۶۶ بایت طول دارد، که فقط کمی کمتر از ۸۱ بایت کدگذاری متنی JSON (با حذف whitespace) است. همه کدگذاری‌های دودویی JSON از این نظر مشابه‌اند. روشن نیست آیا چنین کاهش فضای کوچکی (و شاید سرعت بیشتر در parse) ارزش از دست دادن خوانایی برای انسان را دارد.

در بخش‌های بعد می‌بینیم چگونه می‌توان بهتر عمل کرد و همان رکورد را فقط در ۳۲ بایت کدگذاری کرد.

شکل ۴-۱. رکورد نمونه (مثال ۴-۱) کدگذاری‌شده با MessagePack.

Thrift و Protocol Buffers

Apache Thrift [15] و Protocol Buffers (protobuf) [16] کتابخانه‌های کدگذاری دودویی هستند که بر یک اصل مشترک استوارند. Protocol Buffers در ابتدا در Google توسعه یافت، Thrift در ابتدا در Facebook، و هر دو در ۲۰۰۷–۲۰۰۸ متن‌باز شدند [17].

هر دو Thrift و Protocol Buffers برای هر داده‌ای که کدگذاری می‌شود به اسکیما نیاز دارند. برای کدگذاری داده مثال ۴-۱ در Thrift، اسکیما را در زبان تعریف رابط Thrift (IDL) این‌طور توصیف می‌کنید:

struct Person {
  1: required string       userName,
  2: optional i64          favoriteNumber,
  3: optional list<string> interests
}

تعریف اسکیما معادل برای Protocol Buffers بسیار شبیه است:

message Person {
    required string user_name      = 1;
    optional int64 favorite_number = 2;
    repeated string interests      = 3;
}

Thrift و Protocol Buffers هر کدام ابزار تولید کد دارند که تعریف اسکیما مانند موارد بالا را می‌گیرد و کلاس‌هایی تولید می‌کند که اسکیما را در زبان‌های برنامه‌نویسی مختلف پیاده‌سازی می‌کنند [18]. کد برنامه شما می‌تواند این کد تولیدشده را برای کدگذاری یا رمزگشایی رکوردهای اسکیما فراخوانی کند.

داده کدگذاری‌شده با این اسکیما چه شکلی است؟ گیج‌کننده است که Thrift دو قالب کدگذاری دودویی متفاوت دارد، به نام‌های BinaryProtocol و CompactProtocol. ابتدا BinaryProtocol را ببینیم. کدگذاری مثال ۴-۱ در آن قالب ۵۹ بایت می‌گیرد، همان‌طور که در شکل ۴-۲ نشان داده شده [19].

شکل ۴-۲. رکورد نمونه کدگذاری‌شده با BinaryProtocol در Thrift.

iii. در واقع سه قالب دارد — BinaryProtocol، CompactProtocol و DenseProtocol — اگرچه DenseProtocol فقط توسط پیاده‌سازی C++ پشتیبانی می‌شود، پس cross-language محسوب نمی‌شود [18]. علاوه بر آن‌ها، دو قالب کدگذاری مبتنی بر JSON هم دارد [19]. چه سرگرمی!

مشابه شکل ۴-۱، هر فیلد حاشیه‌نویسی نوع دارد (برای نشان دادن string، integer، list و غیره) و در صورت لزوم نشانگر طول (طول string، تعداد آیتم در list). رشته‌های موجود در داده («Martin»، «daydreaming»، «hacking») هم به ASCII (یا دقیق‌تر UTF-8) کدگذاری شده‌اند، مشابه قبل.

تفاوت بزرگ نسبت به شکل ۴-۱ این است که نام فیلدها (userName، favoriteNumber، interests) وجود ندارند. در عوض، داده کدگذاری‌شده شامل tagهای فیلد است که اعداد هستند (۱، ۲ و ۳). این‌ها همان اعدادی‌اند که در تعریف اسکیما ظاهر می‌شوند. tagهای فیلد مانند نام مستعار فیلدها هستند — راه فشرده‌ای برای گفتن اینکه درباره کدام فیلد صحبت می‌کنیم، بدون نوشتن کامل نام فیلد.

کدگذاری Thrift CompactProtocol از نظر معنایی معادل BinaryProtocol است، اما همان‌طور که در شکل ۴-۳ می‌بینید، همان اطلاعات را فقط در ۳۴ بایت جا می‌دهد. این کار را با بسته‌بندی نوع فیلد و شماره tag در یک بایت، و استفاده از اعداد صحیح با طول متغیر انجام می‌دهد. به‌جای استفاده از هشت بایت کامل برای عدد 1337، در دو بایت کدگذاری می‌شود؛ بیت بالایی هر بایت برای نشان دادن اینکه بایت‌های بیشتری باقی مانده به کار می‌رود. یعنی اعداد بین −64 و 63 در یک بایت، اعداد بین −8192 و 8191 در دو بایت و غیره کدگذاری می‌شوند. اعداد بزرگ‌تر بایت بیشتری مصرف می‌کنند.

شکل ۴-۳. رکورد نمونه کدگذاری‌شده با CompactProtocol در Thrift.

در نهایت، Protocol Buffers (که فقط یک قالب کدگذاری دودویی دارد) همان داده را همان‌طور که در شکل ۴-۴ نشان داده شده کدگذاری می‌کند. بسته‌بندی بیت را کمی متفاوت انجام می‌دهد، اما در غیر این صورت بسیار شبیه CompactProtocol در Thrift است. Protocol Buffers همان رکورد را در ۳۳ بایت جا می‌دهد.

شکل ۴-۴. رکورد نمونه کدگذاری‌شده با Protocol Buffers.

یک جزئیات: در اسکیماهای نشان‌داده‌شده، هر فیلد یا required یا optional علامت‌گذاری شده بود، اما این تفاوتی در نحوه کدگذاری فیلد ایجاد نمی‌کند (چیزی در داده دودویی نشان نمی‌دهد فیلد required بوده). تفاوت فقط این است که required بررسی زمان اجرا را فعال می‌کند که اگر فیلد تنظیم نشده باشد خطا می‌دهد — که برای گرفتن باگ‌ها مفید است.

tagهای فیلد و تکامل اسکیما

قبلاً گفتیم اسکیماها ناگزیر با گذشت زمان باید تغییر کنند. به این تکامل اسکیما (schema evolution) می‌گوییم. Thrift و Protocol Buffers چگونه تغییرات اسکیما را مدیریت می‌کنند و در عین حال سازگاری پس‌رو و پیش‌رو را حفظ می‌کنند؟

همان‌طور که از مثال‌ها می‌بینید، رکورد کدگذاری‌شده فقط الحاق فیلدهای کدگذاری‌شده‌اش است. هر فیلد با شماره tag خود (اعداد ۱، ۲، ۳ در اسکیماهای نمونه) شناسایی و با datatype (مثلاً string یا integer) حاشیه‌نویسی می‌شود. اگر مقدار فیلد تنظیم نشده باشد، از رکورد کدگذاری‌شده حذف می‌شود. از این می‌بینید tagهای فیلد برای معنای داده کدگذاری‌شده حیاتی‌اند. می‌توانید نام فیلد را در اسکیما تغییر دهید، چون داده کدگذاری‌شده هرگز به نام فیلد ارجاع نمی‌دهد، اما نمی‌توانید tag فیلد را تغییر دهید، چون همه داده‌های کدگذاری‌شده موجود نامعتبر می‌شوند.

می‌توانید فیلدهای جدید به اسکیما اضافه کنید، به شرطی که به هر فیلد شماره tag جدید بدهید. اگر کد قدیمی (که tagهای جدید اضافه‌شده را نمی‌شناسد) بخواهد داده‌ای را بخواند که کد جدید نوشته و فیلد جدیدی با tag ناشناخته دارد، می‌تواند آن فیلد را نادیده بگیرد. حاشیه‌نویسی datatype به parser می‌گوید چند بایت باید رد کند. این سازگاری پیش‌رو را حفظ می‌کند: کد قدیمی می‌تواند رکوردهایی را بخواند که کد جدید نوشته.

سازگاری پس‌رو چطور؟ تا وقتی هر فیلد شماره tag یکتا دارد، کد جدید همیشه می‌تواند داده قدیمی را بخواند، چون شماره tagها همان معنا را دارند. تنها جزئیات این است که اگر فیلد جدید اضافه می‌کنید، نمی‌توانید آن را required کنید. اگر فیلدی اضافه کنید و required کنید، آن بررسی وقتی کد جدید داده‌ای را بخواند که کد قدیمی نوشته خطا می‌دهد، چون کد قدیمی فیلد جدیدی که اضافه کردید را ننوشته. بنابراین برای حفظ سازگاری پس‌رو، هر فیلدی که بعد از استقرار اولیه اسکیما اضافه می‌کنید باید optional باشد یا مقدار پیش‌فرض داشته باشد.

حذف فیلد مانند افزودن فیلد است، با نگرانی‌های سازگاری پس‌رو و پیش‌رو معکوس. یعنی فقط می‌توانید فیلدی را حذف کنید که optional است (فیلد required هرگز قابل حذف نیست)، و هرگز نمی‌توانید همان شماره tag را دوباره استفاده کنید (چون ممکن است هنوز جایی داده‌ای با tag قدیمی نوشته شده باشد و کد جدید باید آن فیلد را نادیده بگیرد).

datatypeها و تکامل اسکیما

تغییر datatype یک فیلد چطور؟ ممکن است شدنی باشد — جزئیات را در مستندات ببینید — اما خطر از دست رفتن دقت یا truncation وجود دارد. مثلاً فرض کنید integer ۳۲ بیتی را به integer ۶۴ بیتی تغییر می‌دهید. کد جدید به‌راحتی داده‌ای را که کد قدیمی نوشته می‌خواند، چون parser می‌تواند بیت‌های گم‌شده را با صفر پر کند. اما اگر کد قدیمی داده‌ای را بخواند که کد جدید نوشته، کد قدیمی هنوز از متغیر ۳۲ بیتی برای نگه‌داری مقدار استفاده می‌کند. اگر مقدار ۶۴ بیتی decodeشده در ۳۲ بیت جا نشود، truncate می‌شود.

جزئیات عجیب Protocol Buffers این است که datatype لیست یا آرایه ندارد، بلکه marker تکرار (repeated) برای فیلدها دارد (گزینه سوم کنار required و optional). همان‌طور که در شکل ۴-۴ می‌بینید، کدگذاری فیلد repeated دقیقاً همان چیزی است که از نامش پیداست: همان tag فیلد چند بار در رکورد ظاهر می‌شود. اثر خوب این است که تبدیل فیلد optional (تک‌مقداری) به repeated (چند‌مقداری) مشکلی ندارد. کد جدید که داده قدیمی می‌خواند لیستی با صفر یا یک عنصر می‌بیند (بسته به حضور فیلد)؛ کد قدیمی که داده جدید می‌خواند فقط آخرین عنصر لیست را می‌بیند.

Thrift datatype اختصاصی list دارد که با datatype عناصر list پارامetrize می‌شود. این همان تکامل از تک‌مقداری به چند‌مقداری را که Protocol Buffers دارد امکان‌پذیر نمی‌کند، اما مزیت پشتیبانی از لیست‌های تو در تو را دارد.

Avro

Apache Avro [20] قالب کدگذاری دودویی دیگری است که به‌شکل جالب‌تری با Protocol Buffers و Thrift تفاوت دارد. در ۲۰۰۹ به‌عنوان زیرپروژه Hadoop شروع شد، چون Thrift برای use caseهای Hadoop مناسب نبود [21].

Avro هم از اسکیما برای مشخص کردن ساختار داده‌ای که کدگذاری می‌شود استفاده می‌کند. دو زبان اسکیما دارد: یکی (Avro IDL) برای ویرایش انسانی، و یکی (مبتنی بر JSON) که برای ماشین خواناتر است.

اسکیمای نمونه ما، نوشته‌شده در Avro IDL، ممکن است این‌طور باشد:

record Person {
    string               userName;
    union { null, long } favoriteNumber = null;
    array<string>        interests;
}

نمایش JSON معادل آن اسکیما به این صورت است:

json
{
    "type": "record",
    "name": "Person",
    "fields": [
        {"name": "userName",       "type": "string"},
        {"name": "favoriteNumber", "type": ["null", "long"], "default": null},
        {"name": "interests",      "type": {"type": "array", "items": "string"}}
    ]
}

اول از همه توجه کنید که در اسکیما شماره tag وجود ندارد. اگر رکورد نمونه (مثال ۴-۱) را با این اسکیما کدگذاری کنیم، کدگذاری دودویی Avro فقط ۳۲ بایت طول دارد — فشرده‌ترین از همه کدگذاری‌هایی که دیدیم. تجزیه دنباله بایت کدگذاری‌شده در شکل ۴-۵ نشان داده شده.

اگر دنباله بایت را بررسی کنید، می‌بینید چیزی برای شناسایی فیلدها یا datatypeهایشان نیست. کدگذاری فقط از مقادیر الحاق‌شده تشکیل شده. یک string فقط پیشوند طول به‌علاوه بایت‌های UTF-8 است، اما در داده کدگذاری‌شده چیزی نمی‌گوید که string است. می‌تواند integer یا هر چیز دیگری باشد. integer با کدگذاری طول متغیر (همان CompactProtocol در Thrift) کدگذاری می‌شود.

شکل ۴-۵. رکورد نمونه کدگذاری‌شده با Avro.

برای parse داده دودویی، فیلدها را به ترتیبی که در اسکیما ظاهر می‌شوند طی می‌کنید و از اسکیما برای گفتن datatype هر فیلد استفاده می‌کنید. یعنی داده دودویی فقط وقتی درست decode می‌شود که کدی که داده را می‌خواند دقیقاً همان اسکیمایی را استفاده کند که کدی که داده را نوشته. هر ناهماهنگی اسکیما بین reader و writer به decode نادرست داده منجر می‌شود.

پس Avro چگونه تکامل اسکیما را پشتیبانی می‌کند؟

اسکیمای writer و اسکیمای reader

در Avro، وقتی برنامه‌ای می‌خواهد داده‌ای را کدگذاری کند (در فایل یا پایگاه داده بنویسد، روی شبکه بفرستد و غیره)، داده را با هر نسخه‌ای از اسکیما که می‌شناسد کدگذاری می‌کند — مثلاً آن اسکیما ممکن است در برنامه compile شده باشد. این اسکیمای writer (writer's schema) نامیده می‌شود.

وقتی برنامه‌ای می‌خواهد داده‌ای را decode کند (از فایل یا پایگاه داده بخواند، از شبکه دریافت کند و غیره)، انتظار دارد داده در اسکیمایی باشد که اسکیمای reader (reader's schema) نامیده می‌شود. یعنی اسکیمایی که کد برنامه به آن تکیه می‌کند — ممکن است کد از آن اسکیما در فرآیند build برنامه تولید شده باشد.

ایده کلیدی Avro این است که اسکیمای writer و reader لازم نیست یکسان باشند — فقط باید سازگار باشند. وقتی داده decode (خوانده) می‌شود، کتابخانه Avro با نگاه هم‌زمان به اسکیمای writer و reader تفاوت‌ها را حل می‌کند و داده را از اسکیمای writer به اسکیمای reader ترجمه می‌کند. مشخصات Avro [20] دقیقاً نحوه این resolution را تعریف می‌کند و در شکل ۴-۶ نشان داده شده.

مثلاً مشکلی نیست اگر اسکیمای writer و reader ترتیب فیلدهایشان متفاوت باشد، چون resolution اسکیما فیلدها را با نام فیلد تطبیق می‌دهد. اگر کد خواننده با فیلدی مواجه شود که در اسکیمای writer هست اما در reader نیست، نادیده گرفته می‌شود. اگر کد خواننده فیلدی را انتظار داشته باشد اما اسکیمای writer فیلدی با آن نام نداشته باشد، با مقدار پیش‌فرض اعلام‌شده در اسکیمای reader پر می‌شود.

شکل ۴-۶. reader در Avro تفاوت‌های بین اسکیمای writer و reader را حل می‌کند.

قواعد تکامل اسکیما

در Avro، سازگاری پیش‌رو یعنی می‌توانید نسخه جدید اسکیما را به‌عنوان writer و نسخه قدیمی را به‌عنوان reader داشته باشید. برعکس، سازگاری پس‌رو یعنی می‌توانید نسخه جدید اسکیما را به‌عنوان reader و نسخه قدیمی را به‌عنوان writer داشته باشید.

برای حفظ سازگاری، فقط می‌توانید فیلدی را اضافه یا حذف کنید که مقدار پیش‌فرض دارد. (فیلد favoriteNumber در اسکیمای Avro ما مقدار پیش‌فرض null دارد.)

مثلاً فرض کنید فیلدی با مقدار پیش‌فرض اضافه می‌کنید، بنابراین این فیلد جدید در اسکیمای جدید هست اما در قدیمی نیست. وقتی reader با اسکیمای جدید رکوردی را می‌خواند که با اسکیمای قدیمی نوشته شده، مقدار پیش‌فرض برای فیلد گم‌شده پر می‌شود.

اگر فیلدی بدون مقدار پیش‌فرض اضافه کنید، readerهای جدید نمی‌توانند داده‌ای را بخوانند که writerهای قدیمی نوشته‌اند، پس سازگاری پس‌رو را می‌شکنید. اگر فیلدی بدون مقدار پیش‌فرض حذف کنید، readerهای قدیمی نمی‌توانند داده‌ای را بخوانند که writerهای جدید نوشته‌اند، پس سازگاری پیش‌رو را می‌شکنید.

در برخی زبان‌های برنامه‌نویسی، null مقدار پیش‌فرض قابل قبولی برای هر متغیر است، اما در Avro این‌طور نیست: اگر می‌خواهید فیلدی null باشد، باید از union type استفاده کنید. مثلاً union { null, long, string } field; نشان می‌دهد field می‌تواند عدد، string یا null باشد. فقط وقتی می‌توانید null را به‌عنوان مقدار پیش‌فرض استفاده کنید که یکی از شاخه‌های union باشد. این کمی پرحجم‌تر از nullable بودن پیش‌فرض همه چیز است، اما با صریح بودن درباره آنچه می‌تواند null باشد از باگ جلوگیری می‌کند [22].

در نتیجه، Avro markerهای optional و required را مانند Protocol Buffers و Thrift ندارد (به‌جای آن union type و مقدار پیش‌فرض دارد). تغییر datatype فیلد ممکن است، به شرطی که Avro بتواند نوع را تبدیل کند. تغییر نام فیلد ممکن است اما کمی پیچیده است: اسکیمای reader می‌تواند alias برای نام فیلدها داشته باشد، تا نام فیلدهای اسکیمای writer قدیمی را با aliasها تطبیق دهد. یعنی تغییر نام فیلد سازگار با پس‌رو است اما با پیش‌رو نیست. به‌طور مشابه، افزودن شاخه به union type سازگار با پس‌رو است اما با پیش‌رو نیست.

iv. دقیق‌تر بگوییم، مقدار پیش‌فرض باید از نوع اولین شاخه union باشد، هرچند این محدودیت خاص Avro است نه ویژگی کلی union typeها.

اما اسکیمای writer چیست؟

سؤال مهمی که تا اینجا سطحی گذراندیم: reader چگونه می‌داند داده خاصی با کدام اسکیمای writer کدگذاری شده؟ نمی‌توانیم کل اسکیما را با هر رکورد بگنجانیم، چون اسکیما احتمالاً از داده کدگذاری‌شده بزرگ‌تر است و همه صرفه‌جویی فضای کدگذاری دودویی بی‌فایده می‌شود.

پاسخ به زمینه‌ای که Avro استفاده می‌شود بستگی دارد. چند مثال:

فایل بزرگ با رکوردهای زیاد : کاربرد رایج Avro — به‌ویژه در زمینه Hadoop — ذخیره فایل بزرگی با میلیون‌ها رکورد است که همه با یک اسکیما کدگذاری شده‌اند (این موقعیت را در فصل ۱۰ بحث می‌کنیم). در این حالت، writer فایل می‌تواند اسکیمای writer را یک‌بار در ابتدای فایل بگنجاند. Avro قالب فایل (object container files) را برای این کار مشخص می‌کند.

پایگاه داده با رکوردهای نوشته‌شده جداگانه : در پایگاه داده، رکوردهای مختلف ممکن است در زمان‌های مختلف با اسکیماهای writer متفاوت نوشته شده باشند — نمی‌توانید فرض کنید همه رکوردها اسکیمای یکسانی دارند. ساده‌ترین راه‌حل گنجاندن شماره نسخه در ابتدای هر رکورد کدگذاری‌شده و نگه‌داشتن فهرست نسخه‌های اسکیما در پایگاه داده است. reader می‌تواند رکورد را بگیرد، شماره نسخه را استخراج کند، و سپس اسکیمای writer آن شماره نسخه را از پایگاه داده بگیرد. با آن اسکیمای writer، بقیه رکورد را decode می‌کند. (Espresso [23] این‌طور کار می‌کند.)

ارسال رکوردها روی اتصال شبکه : وقتی دو فرآیند روی اتصال شبکه دوطرفه ارتباط برقرار می‌کنند، می‌توانند نسخه اسکیما را در زمان برقراری اتصال مذاکره کنند و سپس آن اسکیما را برای عمر اتصال استفاده کنند. پروتکل RPC در Avro (به «جریان داده از طریق سرویس‌ها: REST و RPC» در صفحه ۱۳۱) این‌طور کار می‌کند.

پایگاه داده نسخه‌های اسکیما در هر حال مفید است، چون به‌عنوان مستندات عمل می‌کند و فرصتی برای بررسی سازگاری اسکیما می‌دهد [24]. به‌عنوان شماره نسخه می‌توانید از integer ساده افزایشی استفاده کنید، یا hash اسکیما.

اسکیماهای تولیدشده پویا

یک مزیت رویکرد Avro نسبت به Protocol Buffers و Thrift این است که اسکیما شامل شماره tag نیست. اما چرا مهم است؟ مشکل نگه داشتن چند عدد در اسکیما چیست؟

تفاوت این است که Avro با اسکیماهای تولیدشده پویا سازگارتر است. مثلاً فرض کنید پایگاه داده رابطه‌ای دارید که می‌خواهید محتوایش را dump کنید و از قالب دودویی استفاده کنید تا مشکلات قالب‌های متنی (JSON، CSV، SQL) که گفتیم دور بماند. اگر Avro استفاده کنید، می‌توانید به‌راحتی اسکیمای Avro (در نمایش JSON که دیدیم) از اسکیمای رابطه‌ای تولید کنید و محتوای پایگاه را با آن اسکیما کدگذاری و همه را در فایل object container در Avro dump کنید [25]. برای هر جدول پایگاه داده یک record schema تولید می‌کنید و هر ستون فیلدی در آن record می‌شود. نام ستون در پایگاه به نام فیلد در Avro نگاشت می‌شود.

حالا اگر اسکیمای پایگاه داده تغییر کند (مثلاً یک ستون اضافه و یکی حذف شود)، می‌توانید اسکیمای Avro جدید از اسکیمای به‌روزشده پایگاه تولید کنید و داده را با اسکیمای Avro جدید export کنید. فرآیند export نیازی به توجه به تغییر اسکیما ندارد — می‌تواند هر بار که اجرا می‌شود تبدیل اسکیما را انجام دهد. هر کسی که فایل‌های داده جدید را بخواند می‌بیند فیلدهای رکورد تغییر کرده، اما چون فیلدها با نام شناسایی می‌شوند، اسکیمای writer به‌روزشده هنوز می‌تواند با اسکیمای reader قدیمی تطبیق داده شود.

در مقابل، اگر برای این منظور Thrift یا Protocol Buffers استفاده می‌کردید، tagهای فیلد احتمالاً باید دستی اختصاص داده شوند: هر بار اسکیمای پایگاه داده تغییر کند، مدیر باید نگاشت از نام ستون‌های پایگاه به tagهای فیلد را دستی به‌روز کند. (شاید بتوان این را خودکار کرد، اما generator اسکیما باید بسیار مراقب باشد tagهای قبلاً استفاده‌شده را دوباره اختصاص ندهد.) چنین اسکیمای تولیدشده پویایی اصلاً هدف طراحی Thrift یا Protocol Buffers نبود، اما برای Avro بود.

تولید کد و زبان‌های typed پویا

Thrift و Protocol Buffers به تولید کد متکی‌اند: بعد از تعریف اسکیما، می‌توانید کدی تولید کنید که این اسکیما را در زبان برنامه‌نویسی دلخواه پیاده‌سازی کند. این در زبان‌های typed ایستا مثل Java، C++ یا C# مفید است، چون ساختارهای درون‌حافظه کارآمد برای داده decodeشده امکان‌پذیر می‌شود و type checking و autocompletion در IDE هنگام نوشتن برنامه‌ای که به ساختارهای داده دسترسی دارد فراهم می‌شود.

در زبان‌های typed پویا مثل JavaScript، Ruby یا Python، تولید کد فایده چندانی ندارد، چون type checker زمان compile برای راضی کردن وجود ندارد. تولید کد در این زبان‌ها اغلب مورد انتقاد است، چون در غیر این صورت از مرحله compile صریح اجتناب می‌کنند. علاوه بر این، در مورد اسکیمای تولیدشده پویا (مثل اسکیمای Avro تولیدشده از جدول پایگاه داده)، تولید کد مانع غیرضروری برای رسیدن به داده است.

Avro تولید کد اختیاری برای زبان‌های typed ایستا فراهم می‌کند، اما می‌توان بدون تولید کد هم استفاده کرد. اگر object container file دارید (که اسکیمای writer را embed می‌کند)، می‌توانید با کتابخانه Avro آن را باز کنید و داده را همان‌طور که فایل JSON را می‌بینید ببینید. فایل self-describing است چون همه metadata لازم را شامل می‌شود.

این ویژگی به‌ویژه همراه با زبان‌های پردازش داده typed پویا مثل Apache Pig [26] مفید است. در Pig می‌توانید فایل‌های Avro را باز کنید، تحلیل را شروع کنید و datasetهای مشتق‌شده را در قالب Avro بنویسید بدون اینکه اصلاً به اسکیما فکر کنید.

شایستگی‌های اسکیما

همان‌طور که دیدیم، Protocol Buffers، Thrift و Avro همه از اسکیما برای توصیف قالب کدگذاری دودویی استفاده می‌کنند. زبان‌های اسکیمایشان بسیار ساده‌تر از XML Schema یا JSON Schema است که قواعد اعتبارسنجی بسیار دقیق‌تری پشتیبانی می‌کنند (مثلاً «مقدار string این فیلد باید با این regular expression مطابقت داشته باشد» یا «مقدار integer این فیلد باید بین ۰ و ۱۰۰ باشد»). چون Protocol Buffers، Thrift و Avro ساده‌تر برای پیاده‌سازی و استفاده هستند، طیف نسبتاً گسترده‌ای از زبان‌های برنامه‌نویسی را پشتیبانی کرده‌اند.

ایده‌هایی که این کدگذاری‌ها بر آن استوارند اصلاً جدید نیستند. مثلاً شباهت زیادی به ASN.1 دارند، زبان تعریف اسکیما که اولین بار در ۱۹۸۴ استاندارد شد [27]. برای تعریف پروتکل‌های شبکه مختلف استفاده شد و کدگذاری دودویی آن (DER) هنوز برای کدگذاری گواهی‌های SSL (X.509) به کار می‌رود [28]. ASN.1 تکامل اسکیما با شماره tag را پشتیبانی می‌کند، مشابه Protocol Buffers و Thrift [29]. با این حال، بسیار پیچیده و بد مستند است، پس ASN.1 احتمالاً برای برنامه‌های جدید انتخاب خوبی نیست.

بسیاری از سیستم‌های داده هم نوعی کدگذاری دودویی اختصاصی برای داده‌شان پیاده‌سازی می‌کنند. مثلاً بیشتر پایگاه‌های داده رابطه‌ای پروتکل شبکه‌ای دارند که با آن query به پایگاه می‌فرستید و پاسخ می‌گیرید. آن پروتکل‌ها معمولاً مختص پایگاه خاصی هستند و vendor پایگاه driver (مثلاً با APIهای ODBC یا JDBC) فراهم می‌کند که پاسخ‌های پروتکل شبکه پایگاه را به ساختارهای داده درون‌حافظه decode می‌کند.

پس می‌بینیم که اگرچه قالب‌های متنی داده مثل JSON، XML و CSV گسترده‌اند، کدگذاری‌های دودویی مبتنی بر اسکیما هم گزینه‌ای قابل اتکا هستند. ویژگی‌های خوبی دارند:

  • می‌توانند بسیار فشرده‌تر از گونه‌های مختلف «JSON دودویی» باشند، چون نام فیلدها را از داده کدگذاری‌شده حذف می‌کنند.
  • اسکیما شکل ارزشمندی از مستندات است، و چون برای decode اسکیما لازم است، مطمئن می‌شوید به‌روز است (در حالی که مستندات نگه‌داری‌شده دستی به‌راحتی از واقعیت جدا می‌شود).
  • نگه‌داشتن پایگاه داده اسکیماها امکان بررسی سازگاری پیش‌رو و پس‌رو تغییرات اسکیما را قبل از استقرار می‌دهد.
  • برای کاربران زبان‌های typed ایستا، توانایی تولید کد از اسکیما مفید است، چون type checking در زمان compile را فعال می‌کند.

خلاصه، تکامل اسکیما همان انعطافی را که پایگاه‌های JSON بدون اسکیما/schema-on-read فراهم می‌کنند (به «انعطاف اسکیما در مدل سندی» در صفحه ۳۹) می‌دهد، در عین حال تضمین‌های بهتری درباره داده و ابزار بهتری هم ارائه می‌کند.

حالت‌های جریان داده

در ابتدای این فصل گفتیم هر وقت بخواهید داده‌ای را به فرآیند دیگری بفرستید که حافظه مشترک ندارید — مثلاً هر وقت بخواهید داده را روی شبکه بفرستید یا در فایل بنویسید — باید آن را به‌صورت دنباله بایت کدگذاری کنید. سپس انواع مختلف کدگذاری برای این کار را بررسی کردیم.

درباره سازگاری پیش‌رو و پس‌رو صحبت کردیم که برای قابلیت تکامل (آسان کردن تغییر با اجازه به‌روزرسانی مستقل بخش‌های مختلف سیستم، بدون نیاز به تغییر همه چیز یک‌جا) مهم است. سازگاری رابطه‌ای بین فرآیندی که داده را کدگذاری می‌کند و فرآیندی است که آن را decode می‌کند.

این ایده نسبتاً انتزاعی است — راه‌های زیادی برای جریان داده از یک فرآیند به فرآیند دیگر وجود دارد. چه کسی داده را کدگذاری می‌کند و چه کسی decode می‌کند؟ در بقیه این فصل رایج‌ترین حالت‌های جریان داده بین فرآیندها را بررسی می‌کنیم:

  • از طریق پایگاه‌های داده (به «جریان داده از طریق پایگاه‌های داده» در صفحه ۱۲۹)
  • از طریق فراخوانی سرویس (به «جریان داده از طریق سرویس‌ها: REST و RPC» در صفحه ۱۳۱)
  • از طریق پیام‌رسانی ناهمگام (به «جریان داده پیام‌رسانی» در صفحه ۱۳۶)

جریان داده از طریق پایگاه‌های داده

در پایگاه داده، فرآیندی که به پایگاه می‌نویسد داده را کدگذاری می‌کند، و فرآیندی که از پایگاه می‌خواند decode می‌کند. ممکن است فقط یک فرآیند به پایگاه دسترسی داشته باشد، که در آن حالت reader فقط نسخه بعدی همان فرآیند است — می‌توانید ذخیره در پایگاه را مانند ارسال پیام به خود آینده‌تان بدانید.

سازگاری پس‌رو اینجا به‌وضوح لازم است؛ وگرنه خود آینده‌تان نمی‌تواند آنچه قبلاً نوشته‌اید decode کند.

به‌طور کلی، چند فرآیند مختلف همزمان به پایگاه دسترسی دارند رایج است. آن فرآیندها ممکن است چند برنامه یا سرویس مختلف باشند، یا فقط چند instance از همان سرویس (برای مقیاس‌پذیری یا تحمل خطا به‌صورت موازی). در هر حال، در محیطی که برنامه در حال تغییر است، احتمالاً برخی فرآیندهای دسترسی‌کننده به پایگاه کد جدیدتر و برخی کد قدیمی‌تر اجرا می‌کنند — مثلاً چون نسخه جدید در rolling upgrade مستقر می‌شود و برخی instanceها به‌روز شده‌اند و برخی هنوز نه.

این یعنی مقداری در پایگاه ممکن است توسط نسخه جدیدتر کد نوشته شود و بعد توسط نسخه قدیمی‌تر کد که هنوز در حال اجراست خوانده شود. بنابراین سازگاری پیش‌رو هم اغلب برای پایگاه‌های داده لازم است.

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

قالب‌های کدگذاری که قبلاً بحث کردیم چنین حفظ فیلدهای ناشناخته را پشتیبانی می‌کنند، اما گاهی باید در سطح برنامه مراقب باشید، همان‌طور که در شکل ۴-۷ نشان داده شده. مثلاً اگر مقدار پایگاه را به model object در برنامه decode کنید و بعد آن model objectها را دوباره encode کنید، فیلد ناشناخته ممکن است در آن فرآیند ترجمه از دست برود. حل این مسئله سخت نیست؛ فقط باید از آن آگاه باشید.

شکل ۴-۷. وقتی نسخه قدیمی‌تر برنامه داده‌ای را که قبلاً نسخه جدیدتر نوشته به‌روزرسانی می‌کند، اگر مراقب نباشید ممکن است داده از دست برود.

مقادیر مختلف نوشته‌شده در زمان‌های مختلف

پایگاه داده معمولاً اجازه می‌دهد هر مقداری در هر زمان به‌روزرسانی شود. یعنی در یک پایگاه ممکن است برخی مقادیر پنج میلی‌ثانیه پیش و برخی پنج سال پیش نوشته شده باشند.

وقتی نسخه جدید برنامه (حداقل برنامه سمت سرور) را مستقر می‌کنید، ممکن است در چند دقیقه نسخه قدیمی را کاملاً با نسخه جدید جایگزین کنید. برای محتوای پایگاه داده این‌طور نیست: داده پنج‌ساله هنوز آنجاست، با کدگذاری اصلی، مگر اینکه صریحاً از آن زمان بازنویسی کرده باشید. این مشاهده گاهی این‌طور خلاصه می‌شود: داده از کد بیشتر عمر می‌کند (data outlives code).

بازنویسی (مهاجرت) داده به اسکیمای جدید قطعاً ممکن است، اما روی dataset بزرگ کار پرهزینه‌ای است، پس بیشتر پایگاه‌ها در صورت امکان از آن اجتناب می‌کنند. بیشتر پایگاه‌های رابطه‌ای تغییرات ساده اسکیما مثل افزودن ستون جدید با مقدار پیش‌فرض null را بدون بازنویسی داده موجود اجازه می‌دهند. وقتی سطر قدیمی خوانده می‌شود، پایگاه برای ستون‌های گم‌شده از داده کدگذاری‌شده روی دیسک null پر می‌کند. Espresso، پایگاه داده سندی LinkedIn، از Avro برای ذخیره‌سازی استفاده می‌کند و قواعد تکامل اسکیمای Avro را به کار می‌گیرد [23].

v. به‌جز MySQL که اغلب کل جدول را بازنویسی می‌کند حتی وقتی از نظر فنی لازم نیست، همان‌طور که در «انعطاف اسکیما در مدل سندی» در صفحه ۳۹ ذکر شد.

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

ذخیره‌سازی آرشیوی

شاید گاهی snapshot از پایگاه داده بگیرید، مثلاً برای پشتیبان یا بارگذاری در data warehouse (به «انبار داده» در صفحه ۹۱). در این حالت، dump داده معمولاً با آخرین اسکیما کدگذاری می‌شود، حتی اگر کدگذاری اصلی در پایگاه منبع ترکیبی از نسخه‌های اسکیما از دوره‌های مختلف بوده باشد. چون در هر حال داده را کپی می‌کنید، می‌توانید کپی داده را به‌صورت یکنواخت encode کنید.

چون dump داده یک‌جا نوشته می‌شود و بعد immutable است، قالب‌هایی مثل فایل‌های object container در Avro مناسب‌اند. این هم فرصت خوبی برای encode داده در قالب ستونی مناسب analytics مثل Parquet است (به «فشرده‌سازی ستونی» در صفحه ۹۷).

در فصل ۱۰ بیشتر درباره استفاده از داده در ذخیره‌سازی آرشیوی صحبت می‌کنیم.

جریان داده از طریق سرویس‌ها: REST و RPC

وقتی فرآیندهایی باید روی شبکه ارتباط برقرار کنند، چند روش مختلف برای چیدمان آن ارتباط وجود دارد. چیدمان رایج داشتن دو نقش است: client و server. serverها API را روی شبکه expose می‌کنند و clientها می‌توانند به serverها متصل شوند و درخواست به آن API بدهند. API که server expose می‌کند سرویس نامیده می‌شود.

وب این‌طور کار می‌کند: clientها (مرورگرهای وب) به web server درخواست می‌دهند، با GET برای دانلود HTML، CSS، JavaScript، تصاویر و غیره، و با POST برای ارسال داده به server. API شامل مجموعه استاندارد پروتکل‌ها و قالب‌های داده (HTTP، URL، SSL/TLS، HTML و غیره) است. چون مرورگرهای وب، web serverها و نویسندگان وب‌سایت عمدتاً روی این استانداردها توافق دارند، می‌توانید با هر مرورگری به هر وب‌سایتی دسترسی داشته باشید (حداقل در تئوری!).

مرورگر وب تنها نوع client نیست. مثلاً برنامه native روی موبایل یا دسکتاپ هم می‌تواند درخواست شبکه به server بدهد، و برنامه JavaScript سمت client داخل مرورگر می‌تواند با XMLHttpRequest client HTTP شود (این تکنیک Ajax [30] نامیده می‌شود). در این حالت، پاسخ server معمولاً HTML برای نمایش به انسان نیست، بلکه داده‌ای در کدگذاری مناسب برای پردازش بیشتر توسط کد برنامه سمت client (مثل JSON) است. اگرچه HTTP ممکن است پروتکل transport باشد، API پیاده‌سازی‌شده روی آن مختص برنامه است و client و server باید روی جزئیات آن API توافق کنند.

علاوه بر این، یک server خودش می‌تواند client سرویس دیگری باشد (مثلاً web app server معمولی client پایگاه داده است). این رویکرد اغلب برای تجزیه برنامه بزرگ به سرویس‌های کوچک‌تر بر اساس حوزه عملکرد به کار می‌رود، طوری که یک سرویس وقتی به عملکرد یا داده‌ای از سرویس دیگر نیاز دارد به آن درخواست می‌دهد. این روش ساخت برنامه‌ها به‌سنت معماری service-oriented (SOA) نامیده می‌شد و اخیراً اصلاح و بازنام‌گذاری شده به معماری microservices [31, 32].

از برخی جهات، سرویس‌ها شبیه پایگاه‌های داده‌اند: معمولاً به client اجازه ارسال و query داده می‌دهند. اما در حالی که پایگاه‌های داده query دلخواه با زبان‌های query فصل ۲ را اجازه می‌دهند، سرویس‌ها API مختص برنامه expose می‌کنند که فقط ورودی و خروجی از پیش تعیین‌شده توسط منطق کسب‌وکار (کد برنامه) سرویس را اجازه می‌دهد [33]. این محدودیت درجه‌ای از encapsulation فراهم می‌کند: سرویس‌ها می‌توانند محدودیت‌های ریزدانه روی آنچه clientها می‌توانند و نمی‌توانند انجام دهند اعمال کنند.

هدف طراحی کلیدی معماری service-oriented/microservices این است که برنامه را با مستقل deployable و evolvable کردن سرویس‌ها آسان‌تر برای تغییر و نگه‌داری کند. مثلاً هر سرویس باید متعلق به یک تیم باشد و آن تیم بتواند نسخه‌های جدید سرویس را مکرراً منتشر کند بدون هماهنگی با تیم‌های دیگر. یعنی باید انتظار داشته باشیم نسخه‌های قدیمی و جدید server و client همزمان اجرا شوند، پس کدگذاری داده‌ای که server و client استفاده می‌کنند باید در نسخه‌های مختلف API سرویس سازگار باشد — دقیقاً همان چیزی که در این فصل درباره‌اش صحبت کردیم.

سرویس‌های وب

وقتی HTTP به‌عنوان پروتکل زیرین برای صحبت با سرویس استفاده می‌شود، سرویس وب (web service) نامیده می‌شود. شاید کمی نامگذاری نادرست باشد، چون سرویس‌های وب فقط در وب استفاده نمی‌شوند بلکه در زمینه‌های مختلف. مثلاً:

  1. برنامه client روی دستگاه کاربر (مثلاً برنامه native موبایل، یا web app JavaScript با Ajax) درخواست به سرویس روی HTTP. این درخواست‌ها معمولاً از اینترنت عمومی می‌گذرند.
  2. یک سرویس درخواست به سرویس دیگری که همان سازمان مالک آن است، اغلب در همان datacenter، به‌عنوان بخشی از معماری service-oriented/microservices. (نرم‌افزاری که این use case را پشتیبانی می‌کند گاهی middleware نامیده می‌شود.)
  3. یک سرویس درخواست به سرویس متعلق به سازمان دیگر، معمولاً از طریق اینترنت. برای تبادل داده بین backend سیستم‌های سازمان‌های مختلف به کار می‌رود. این دسته شامل APIهای عمومی سرویس‌های آنلاین مثل سیستم‌های پردازش کارت اعتباری، یا OAuth برای دسترسی مشترک به داده کاربر است.

دو رویکرد محبوب برای سرویس‌های وب REST و SOAP هستند. از نظر فلسفه تقریباً کاملاً متضادند و اغلب موضوع بحث داغ بین proponents هر کدام.

REST پروتکل نیست، بلکه فلسفه طراحی است که بر اصول HTTP استوار است [34, 35]. بر قالب‌های داده ساده، استفاده از URL برای شناسایی resourceها، و استفاده از ویژگی‌های HTTP برای cache control، authentication و content type negotiation تأکید دارد. REST نسبت به SOAP محبوبیت بیشتری گرفته، حداقل در زمینه ادغام سرویس بین‌سازمانی [36]، و اغلب با microservices [31] مرتبط است. API طراحی‌شده طبق اصول REST RESTful نامیده می‌شود.

در مقابل، SOAP پروتکل مبتنی بر XML برای درخواست API شبکه است. اگرچه بیشتر روی HTTP استفاده می‌شود، هدفش مستقل بودن از HTTP است و از بیشتر ویژگی‌های HTTP اجتناب می‌کند. در عوض، با مجموعه پیچیده و گسترده استانداردهای مرتبط (چارچوب سرویس وب، WS-*) که ویژگی‌های مختلف اضافه می‌کنند همراه است [37].

API سرویس وب SOAP با زبان XML به نام Web Services Description Language یا WSDL توصیف می‌شود. WSDL تولید کد را فعال می‌کند تا client بتواند با کلاس‌ها و فراخوانی method محلی به سرویس remote دسترسی داشته باشد (که به پیام‌های XML encode و توسط framework دوباره decode می‌شوند). این در زبان‌های typed ایستا مفید است، اما در typed پویا کمتر (به «تولید کد و زبان‌های typed پویا» در صفحه ۱۲۷).

چون WSDL برای خواندن انسان طراحی نشده و پیام‌های SOAP اغلب برای ساخت دستی خیلی پیچیده‌اند، کاربران SOAP شدیداً به پشتیبانی ابزار، تولید کد و IDE متکی‌اند [38]. برای کاربران زبان‌هایی که vendorهای SOAP پشتیبانی نمی‌کنند، ادغام با سرویس‌های SOAP دشوار است.

اگرچه SOAP و extensionهایش ظاهراً استاندارد شده‌اند، تعامل‌پذیری بین پیاده‌سازی‌های فروشندگان مختلف اغلب مشکل ایجاد می‌کند [39]. به همین دلایل، اگرچه SOAP هنوز در بسیاری از enterpriseهای بزرگ استفاده می‌شود، در بیشتر شرکت‌های کوچک‌تر از محبوبیت افتاده.

APIهای RESTful معمولاً رویکردهای ساده‌تر را ترجیح می‌دهند، معمولاً با تولید کد و ابزار خودکار کمتر. قالب تعریفی مثل OpenAPI، که Swagger [40] هم نامیده می‌شود، می‌تواند برای توصیف APIهای RESTful و تولید مستندات به کار رود.

vi. حتی در هر camp بحث‌های زیادی هست. مثلاً HATEOAS (hypermedia as the engine of application state) اغلب بحث برمی‌انگیزد [35].

vii. با وجود شباهت acronymها، SOAP الزام SOA نیست. SOAP فناوری خاصی است، در حالی که SOA رویکرد کلی برای ساخت سیستم‌هاست.

مشکلات فراخوانی رویه از راه دور (RPC)

سرویس‌های وب فقط آخرین تجسم فناوری‌های طولانی برای درخواست API روی شبکه هستند که بسیاری hype زیادی گرفتند اما مشکلات جدی دارند. Enterprise JavaBeans (EJB) و Remote Method Invocation (RMI) در Java محدود به Java هستند. Distributed Component Object Model (DCOM) به پلتفرم‌های Microsoft محدود است. Common Object Request Broker Architecture (CORBA) بیش از حد پیچیده است و سازگاری پس‌رو یا پیش‌رو فراهم نمی‌کند [41].

همه این‌ها بر ایده remote procedure call (RPC) استوارند که از دهه ۱۹۷۰ وجود دارد [42]. مدل RPC تلاش می‌کند درخواست به سرویس شبکه remote شبیه فراخوانی function یا method در زبان برنامه‌نویسی، درون همان فرآیند به نظر برسد (این abstraction location transparency نامیده می‌شود).

اگرچه RPC در ابتدا راحت به نظر می‌رسد، این رویکرد از پایه معیوب است [43, 44]. درخواست شبکه با فراخوانی function محلی بسیار متفاوت است:

  • فراخوانی function محلی قابل پیش‌بینی است و بسته به پارامترهای تحت کنترل شما موفق یا ناموفق می‌شود. درخواست شبکه غیرقابل پیش‌بینی است: درخواست یا پاسخ ممکن است به‌خاطر مشکل شبکه از دست برود، یا ماشین remote کند یا در دسترس نباشد، و چنین مشکلاتی کاملاً خارج از کنترل شماست. مشکلات شبکه رایج‌اند، پس باید برای آن‌ها آماده باشید، مثلاً با retry درخواست ناموفق.
  • فراخوانی function محلی یا نتیجه برمی‌گرداند، یا exception می‌اندازد، یا هرگز برنمی‌گردد (چون در حلقه بی‌نهایت می‌رود یا فرآیند crash می‌کند). درخواست شبکه نتیجه دیگری هم دارد: ممکن است بدون نتیجه برگردد به‌خاطر timeout. در آن حالت اصلاً نمی‌دانید چه اتفاقی افتاده: اگر از سرویس remote پاسخی نگیرید، راهی ندارید بفهمید درخواست رسیده یا نه. (این مسئله را در فصل ۸ بیشتر بحث می‌کنیم.)
  • اگر درخواست شبکه ناموفق را retry کنید، ممکن است درخواست‌ها واقعاً رسیده باشند و فقط پاسخ‌ها از دست رفته باشند. در آن حالت retry باعث اجرای چندباره action می‌شود، مگر مکانیزم deduplication (idempotence) در پروتکل بسازید. فراخوانی function محلی این مشکل را ندارد. (idempotence را در فصل ۱۱ بیشتر بحث می‌کنیم.)
  • هر بار function محلی را فراخوانی می‌کنید، معمولاً تقریباً همان زمان برای اجرا طول می‌کشد. درخواست شبکه بسیار کندتر از فراخوانی function است و latency آن هم شدیداً متغیر است: در زمان‌های خوب شاید کمتر از یک میلی‌ثانیه، اما وقتی شبکه شلوغ یا سرویس remote overload است ممکن است چند ثانیه برای همان کار طول بکشد.
  • وقتی function محلی را فراخوانی می‌کنید، می‌توانید reference (اشاره‌گر) به objectهای درون‌حافظه محلی را کارآمد به آن بدهید. وقتی درخواست شبکه می‌دهید، همه آن پارامترها باید به دنباله بایتی encode شوند که روی شبکه فرستاده شود. برای primitive مثل عدد یا string مشکلی نیست، اما برای objectهای بزرگ‌تر سریع مشکل‌ساز می‌شود.
  • client و سرویس ممکن است در زبان‌های برنامه‌نویسی مختلف پیاده‌سازی شده باشند، پس framework RPC باید datatypeها را از یک زبان به زبان دیگر ترجمه کند. این می‌تواند زشت شود، چون همه زبان‌ها type یکسان ندارند — مشکلات JavaScript با اعداد بزرگ‌تر از 2^53 را به یاد بیاورید (به «JSON، XML و گونه‌های دودویی» در صفحه ۱۱۴). این مشکل در فرآیند تک‌زبانه نوشته‌شده با یک زبان وجود ندارد.

همه این عوامل یعنی تلاش برای شبیه کردن بیش از حد سرویس remote به object محلی در زبان برنامه‌نویسی بی‌فایده است، چون از پایه چیز متفاوتی است. بخشی از جذابیت REST این است که تلاش نمی‌کند پنهان کند پروتکل شبکه است (هرچند به نظر نمی‌رسد مردم را از ساخت کتابخانه RPC روی REST باز دارد).

جهت‌های فعلی RPC

با وجود همه این مشکلات، RPC از بین نمی‌رود. frameworkهای RPC مختلف روی همه کدگذاری‌های این فصل ساخته شده‌اند: مثلاً Thrift و Avro پشتیبانی RPC دارند، gRPC پیاده‌سازی RPC با Protocol Buffers است، Finagle هم از Thrift استفاده می‌کند، و Rest.li JSON روی HTTP.

نسل جدید frameworkهای RPC صریح‌تر است که درخواست remote با فراخوانی function محلی متفاوت است. مثلاً Finagle و Rest.li از future (promise) برای encapsulate actionهای ناهمگام که ممکن است fail شوند استفاده می‌کنند. future هم موقعیت‌هایی را ساده می‌کند که باید به چند سرویس به‌صورت موازی درخواست بدهید و نتایج را ترکیب کنید [45]. gRPC stream پشتیبانی می‌کند، جایی که یک call فقط یک request و یک response نیست، بلکه سری request و response در طول زمان است [46].

برخی از این frameworkها service discovery هم فراهم می‌کنند — یعنی به client اجازه می‌دهند بفهمد سرویس خاصی را در کدام IP و port پیدا کند. در «مسیریابی درخواست» در صفحه ۲۱۴ به این موضوع برمی‌گردیم.

پروتکل‌های RPC سفارشی با قالب کدگذاری دودویی می‌توانند کارایی بهتری از چیزی generic مثل JSON روی REST داشته باشند. با این حال، API RESTful مزیت‌های مهم دیگری دارد: برای آزمایش و debug خوب است (می‌توانید با مرورگر وب یا curl درخواست بدهید، بدون تولید کد یا نصب نرم‌افزار)، توسط همه زبان‌ها و پلتفرم‌های mainstream پشتیبانی می‌شود، و ecosystem عظیمی از ابزار (server، cache، load balancer، proxy، firewall، monitoring، debug، test و غیره) دارد.

به همین دلایل، REST به نظر سبک غالب APIهای عمومی است. تمرکز اصلی frameworkهای RPC روی درخواست بین سرویس‌های متعلق به همان سازمان است، معمولاً در همان datacenter.

کدگذاری داده و تکامل برای RPC

برای evolvability مهم است client و server RPC بتوانند مستقل تغییر و deploy شوند. در مقایسه با جریان داده از طریق پایگاه‌های داده (بخش قبل)، در مورد جریان داده از طریق سرویس‌ها می‌توانیم فرض ساده‌کننده‌ای بگیریم: منطقی است فرض کنیم همه serverها اول به‌روز می‌شوند و همه clientها دوم. بنابراین فقط سازگاری پس‌رو روی request و سازگاری پیش‌رو روی response لازم است.

ویژگی‌های سازگاری پس‌رو و پیش‌رو scheme RPC از کدگذاری‌ای که استفاده می‌کند به ارث می‌رسد:

  • Thrift، gRPC (Protocol Buffers) و Avro RPC می‌توانند طبق قواعد سازگاری قالب کدگذاری مربوطه evolve شوند.
  • در SOAP، request و response با اسکیماهای XML مشخص می‌شوند. این‌ها قابل evolve هستند، اما pitfallهای ظریفی دارند [47].
  • APIهای RESTful بیشتر از JSON (بدون اسکیمای رسمی مشخص‌شده) برای response و JSON یا پارامتر request URI-encoded/form-encoded برای request استفاده می‌کنند. افزودن پارامتر request اختیاری و فیلدهای جدید به objectهای response معمولاً تغییراتی محسوب می‌شوند که سازگاری را حفظ می‌کنند.

سازگاری سرویس سخت‌تر می‌شود چون RPC اغلب برای ارتباط بین مرزهای سازمانی به کار می‌رود، پس ارائه‌دهنده سرویس معمولاً کنترل clientها را ندارد و نمی‌تواند آن‌ها را مجبور به upgrade کند. بنابراین سازگاری باید مدت طولانی، شاید نامحدود حفظ شود. اگر تغییر شکستن سازگاری لازم باشد، ارائه‌دهنده سرویس اغلب مجبور است چند نسخه API سرویس را هم‌زمان نگه دارد.

توافقی روی نحوه versioning API وجود ندارد (یعنی client چگونه نشان دهد کدام نسخه API را می‌خواهد [48]). برای APIهای RESTful، رویکردهای رایج استفاده از شماره نسخه در URL یا در header HTTP Accept است. برای سرویس‌هایی که از API key برای شناسایی client خاص استفاده می‌کنند، گزینه دیگر ذخیره نسخه API درخواستی client روی server و اجازه به‌روزرسانی این انتخاب نسخه از طریق رابط administrative جداگانه است [49].

جریان داده پیام‌رسانی

تا اینجا راه‌های مختلف جریان داده کدگذاری‌شده از یک فرآیند به فرآیند دیگر را بررسی کردیم. REST و RPC (جایی که یک فرآیند درخواست روی شبکه به فرآیند دیگر می‌فرستد و در اسرع وقت پاسخ انتظار دارد) و پایگاه‌های داده (جایی که یک فرآیند داده کدگذاری‌شده می‌نویسد و فرآیند دیگر بعداً می‌خواند) را دیدیم.

در این بخش پایانی، به‌اختصار سیستم‌های پیام‌رسانی ناهمگام را بررسی می‌کنیم که جایی بین RPC و پایگاه‌های داده قرار دارند. شبیه RPC هستند چون درخواست client (معمولاً پیام نامیده می‌شود) با latency کم به فرآیند دیگر تحویل می‌شود. شبیه پایگاه‌های داده هستند چون پیام از طریق اتصال شبکه مستقیم فرستاده نمی‌شود، بلکه از واسطه‌ای به نام message broker (که message queue یا message-oriented middleware هم نامیده می‌شود) می‌گذرد که پیام را موقتاً ذخیره می‌کند.

استفاده از message broker نسبت به RPC مستقیم چند مزیت دارد:

  • اگر گیرنده در دسترس نباشد یا overload باشد، می‌تواند buffer عمل کند و در نتیجه reliability سیستم را بهبود دهد.
  • می‌تواند پیام‌ها را به‌طور خودکار به فرآیندی که crash کرده دوباره تحویل دهد و از از دست رفتن پیام جلوگیری کند.
  • فرستنده نیازی ندارد IP و port گیرنده را بداند (که در deployment ابری که ماشین‌های مجازی مدام می‌آیند و می‌روند مفید است).
  • اجازه می‌دهد یک پیام به چند گیرنده فرستاده شود.
  • فرستنده را از نظر منطقی از گیرنده جدا می‌کند (فرستنده فقط پیام publish می‌کند و به اینکه چه کسی consume می‌کند اهمیت نمی‌دهد).

با این حال، تفاوت نسبت به RPC این است که ارتباط message-passing معمولاً یک‌طرفه است: فرستنده معمولاً انتظار پاسخ به پیام‌هایش را ندارد. فرآیند می‌تواند پاسخ بفرستد، اما معمولاً از کانال جداگانه‌ای انجام می‌شود. این الگوی ارتباط ناهمگام است: فرستنده منتظر تحویل پیام نمی‌ماند، فقط می‌فرستد و بعد فراموش می‌کند.

message brokerها

در گذشته، چشم‌انداز message brokerها توسط نرم‌افزار enterprise تجاری شرکت‌هایی مثل TIBCO، IBM WebSphere و webMethods تسلط داشت. اخیراً پیاده‌سازی‌های متن‌باز مثل RabbitMQ، ActiveMQ، HornetQ، NATS و Apache Kafka محبوب شده‌اند. در فصل ۱۱ جزئی‌تر مقایسه می‌کنیم.

معانی تحویل دقیق بسته به پیاده‌سازی و پیکربندی متفاوت است، اما به‌طور کلی message brokerها این‌طور استفاده می‌شوند: یک فرآیند پیامی به queue یا topic نام‌گذاری‌شده می‌فرستد و broker اطمینان می‌دهد پیام به یک یا چند consumer یا subscriber آن queue یا topic تحویل شود. می‌تواند چند producer و چند consumer روی همان topic باشد.

topic فقط جریان داده یک‌طرفه فراهم می‌کند. با این حال، consumer خودش می‌تواند پیام به topic دیگر publish کند (پس می‌توانید زنجیره کنید، همان‌طور که در فصل ۱۱ می‌بینیم)، یا به reply queue که توسط فرستنده پیام اصلی consume می‌شود (اجازه جریان داده request/response، مشابه RPC).

message brokerها معمولاً مدل داده خاصی enforce نمی‌کنند — پیام فقط دنباله بایت با metadata است، پس می‌توانید هر قالب کدگذاری استفاده کنید. اگر کدگذاری سازگار با پس‌رو و پیش‌رو باشد، بیشترین انعطاف را برای تغییر مستقل publisher و consumer و deploy به هر ترتیبی دارید.

اگر consumer پیام‌ها را به topic دیگر republish کند، ممکن است لازم باشد فیلدهای ناشناخته را حفظ کنید تا مسئله‌ای که قبلاً در زمینه پایگاه‌های داده (شکل ۴-۷) توضیح دادیم پیش نیاید.

frameworkهای actor توزیع‌شده

مدل actor مدل برنامه‌نویسی برای concurrency در یک فرآیند است. به‌جای برخورد مستقیم با thread (و مشکلات race condition، locking و deadlock)، منطق در actorها encapsulate می‌شود. هر actor معمولاً یک client یا entity را نمایندگی می‌کند، ممکن است state محلی داشته باشد (که با actor دیگری shared نیست)، و با actorهای دیگر با ارسال و دریافت پیام‌های ناهمگام ارتباط برقرار می‌کند. تحویل پیام تضمین نمی‌شود: در برخی سناریوهای خطا پیام از دست می‌رود. چون هر actor فقط یک پیام را در یک زمان پردازش می‌کند، نیازی به نگرانی درباره thread ندارد و framework هر actor را مستقل schedule می‌کند.

در frameworkهای actor توزیع‌شده، این مدل برنامه‌نویسی برای scale برنامه روی چند node استفاده می‌شود. همان مکانیزم message-passing به کار می‌رود، چه فرستنده و گیرنده روی همان node باشند چه nodeهای مختلف. اگر روی nodeهای مختلف باشند، پیام شفاف به دنباله بایت encode، روی شبکه فرستاده و در طرف دیگر decode می‌شود.

location transparency در مدل actor بهتر از RPC کار می‌کند، چون مدل actor از قبل فرض می‌کند پیام ممکن است از دست برود، حتی درون یک فرآیند. اگرچه latency روی شبکه احتمالاً از درون همان فرآیند بیشتر است، با مدل actor mismatch بنیادی کمتری بین ارتباط محلی و remote وجود دارد.

framework actor توزیع‌شده اساساً message broker و مدل برنامه‌نویسی actor را در یک framework ادغام می‌کند. با این حال، اگر rolling upgrade برنامه مبتنی بر actor انجام دهید، هنوز باید به سازگاری پیش‌رو و پس‌رو نگران باشید، چون پیام ممکن است از node با نسخه جدید به node با نسخه قدیمی فرستاده شود و برعکس.

سه framework actor توزیع‌شده محبوب کدگذاری پیام را این‌طور مدیریت می‌کنند:

  • Akka به‌طور پیش‌فرض از serialization داخلی Java استفاده می‌کند که سازگاری پیش‌رو یا پس‌رو فراهم نمی‌کند. با این حال می‌توانید آن را با چیزی مثل Protocol Buffers جایگزین کنید و rolling upgrade را ممکن کنید [50].
  • Orleans به‌طور پیش‌فرض از قالب کدگذاری داده سفارشی استفاده می‌کند که deployment rolling upgrade را پشتیبانی نمی‌کند؛ برای deploy نسخه جدید برنامه باید cluster جدید راه بیندازید، ترافیک را از cluster قدیمی به جدید منتقل کنید و قدیمی را خاموش کنید [51, 52]. مانند Akka، plug-inهای serialization سفارشی قابل استفاده‌اند.
  • در Erlang OTP تغییر schema رکوردها به‌طور شگفت‌آوری سخت است (با وجود ویژگی‌های زیاد سیستم برای دسترس‌پذیری بالا)؛ rolling upgrade ممکن است اما باید با دقت برنامه‌ریزی شود [53]. datatype آزمایشی maps (ساختار شبیه JSON، معرفی‌شده در Erlang R17 در ۲۰۱۴) شاید در آینده این را آسان‌تر کند [54].

خلاصه

در این فصل چند روش برای تبدیل ساختارهای داده به بایت روی شبکه یا بایت روی دیسک دیدیم. دیدیم جزئیات این کدگذاری‌ها نه فقط کارایی، بلکه مهم‌تر معماری برنامه‌ها و گزینه‌های deploy را تحت تأثیر قرار می‌دهد.

به‌ویژه، بسیاری از سرویس‌ها باید rolling upgrade را پشتیبانی کنند، جایی که نسخه جدید سرویس به‌تدریج روی چند node مستقر می‌شود، نه همه nodeها یک‌جا. rolling upgrade انتشار نسخه‌های جدید بدون downtime را ممکن می‌کند (و در نتیجه انتشار مکرر کوچک را به جای انتشار نادر بزرگ تشویق می‌کند) و deploy را کم‌ریسک‌تر می‌کند (اجازه تشخیص release معیوب و rollback قبل از تأثیر روی کاربران زیاد). این ویژگی‌ها برای evolvability، یعنی آسان بودن تغییر برنامه، بسیار ارزشمند است.

در rolling upgrade، یا به دلایل مختلف، باید فرض کنیم nodeهای مختلف نسخه‌های مختلف کد برنامه را اجرا می‌کنند. بنابراین مهم است همه داده‌ای که در سیستم جریان دارد به‌گونه‌ای کدگذاری شود که سازگاری پس‌رو (کد جدید داده قدیمی را بخواند) و سازگاری پیش‌رو (کد قدیمی داده جدید را بخواند) فراهم کند.

چند قالب کدگذاری داده و ویژگی‌های سازگاریشان را بحث کردیم:

  • کدگذاری‌های مختص زبان به یک زبان برنامه‌نویسی محدودند و اغلب سازگاری پیش‌رو و پس‌رو فراهم نمی‌کنند.
  • قالب‌های متنی مثل JSON، XML و CSV گسترده‌اند و سازگاریشان به نحوه استفاده بستگی دارد. زبان‌های اسکیمای اختیاری دارند که گاهی مفید و گاهی مانع است. درباره datatypeها تا حدی مبهم‌اند، پس باید درباره اعداد و رشته‌های دودویی مراقب باشید.
  • قالب‌های دودویی مبتنی بر اسکیما مثل Thrift، Protocol Buffers و Avro کدگذاری فشرده و کارآمد با semantics سازگاری پیش‌رو و پس‌رو به‌وضوح تعریف‌شده اجازه می‌دهند. اسکیماها برای مستندات و تولید کد در زبان‌های با نوع ایستا مفیدند. با این حال، داده باید decode شود تا برای انسان خوانا باشد.

چند حالت جریان داده را هم بررسی کردیم که سناریوهای مختلفی را نشان می‌دهند که کدگذاری داده مهم است:

  • پایگاه‌های داده، جایی که فرآیند نوشتن به پایگاه داده را کدگذاری می‌کند و فرآیند خواندن decode می‌کند
  • APIهای RPC و REST، جایی که client درخواست را encode می‌کند، server درخواست را decode و پاسخ را encode می‌کند، و client در نهایت پاسخ را decode می‌کند
  • پیام‌رسانی ناهمگام (با message broker یا actor)، جایی که nodeها با ارسال پیام به یکدیگر ارتباط برقرار می‌کنند که توسط فرستنده encode و توسط گیرنده decode می‌شود

می‌توانیم نتیجه بگیریم با کمی دقت، سازگاری پس‌رو/پیش‌رو و rolling upgrade کاملاً قابل دستیابی است. باشد تکامل برنامه‌تان سریع و deployهایتان مکرر.

منابع

[1] "Java Object Serialization Specification," docs.oracle.com, 2010.

[2] "Ruby 2.2.0 API Documentation," ruby-doc.org, Dec 2014.

[3] "The Python 3.4.3 Standard Library Reference Manual," docs.python.org, February 2015.

[4] "EsotericSoftware/kryo," github.com, October 2014.

[5] "CWE-502: Deserialization of Untrusted Data," Common Weakness Enumeration, cwe.mitre.org, July 30, 2014.

[6] Steve Breen: "What Do WebLogic, WebSphere, JBoss, Jenkins, OpenNMS, and Your Application Have in Common? This Vulnerability," foxglovesecurity.com, November 6, 2015.

[7] Patrick McKenzie: "What the Rails Security Issue Means for Your Startup," kalzumeus.com, January 31, 2013.

[8] Eishay Smith: "jvm-serializers wiki," github.com, November 2014.

[9] "XML Is a Poor Copy of S-Expressions," c2.com wiki.

[10] Matt Harris: "Snowflake: An Update and Some Very Important Information," email to Twitter Development Talk mailing list, October 19, 2010.

[11] Shudi (Sandy) Gao, C. M. Sperberg-McQueen, and Henry S. Thompson: "XML Schema 1.1," W3C Recommendation, May 2001.

[12] Francis Galiegue, Kris Zyp, and Gary Court: "JSON Schema," IETF Internet-Draft, February 2013.

[13] Yakov Shafranovich: "RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files," October 2005.

[14] "MessagePack Specification," msgpack.org.

[15] Mark Slee, Aditya Agarwal, and Marc Kwiatkowski: "Thrift: Scalable Cross-Language Services Implementation," Facebook technical report, April 2007.

[16] "Protocol Buffers Developer Guide," Google, Inc., developers.google.com.

[17] Igor Anishchenko: "Thrift vs Protocol Buffers vs Avro - Biased Comparison," slideshare.net, September 17, 2012.

[18] "A Matrix of the Features Each Individual Language Library Supports," wiki.apache.org.

[19] Martin Kleppmann: "Schema Evolution in Avro, Protocol Buffers and Thrift," martin.kleppmann.com, December 5, 2012.

[20] "Apache Avro 1.7.7 Documentation," avro.apache.org, July 2014.

[21] Doug Cutting, Chad Walters, Jim Kellerman, et al.: "[PROPOSAL] New Subproject: Avro," email thread on hadoop-general mailing list, mail-archives.apache.org, April 2009.

[22] Tony Hoare: "Null References: The Billion Dollar Mistake," at QCon London, March 2009.

[23] Aditya Auradkar and Tom Quiggle: "Introducing Espresso—LinkedIn's Hot New Distributed Document Store," engineering.linkedin.com, January 21, 2015.

[24] Jay Kreps: "Putting Apache Kafka to Use: A Practical Guide to Building a Stream Data Platform (Part 2)," blog.confluent.io, February 25, 2015.

[25] Gwen Shapira: "The Problem of Managing Schemas," radar.oreilly.com, November 4, 2014.

[26] "Apache Pig 0.14.0 Documentation," pig.apache.org, November 2014.

[27] John Larmouth: ASN.1 Complete. Morgan Kaufmann, 1999. ISBN: 978-0-122-33435-1

[28] Russell Housley, Warwick Ford, Tim Polk, and David Solo: "RFC 2459: Internet X.509 Public Key Infrastructure: Certificate and CRL Profile," IETF Network Working Group, Standards Track, January 1999.

[29] Lev Walkin: "Question: Extensibility and Dropping Fields," lionet.info, September 21, 2010.

[30] Jesse James Garrett: "Ajax: A New Approach to Web Applications," adaptivepath.com, February 18, 2005.

[31] Sam Newman: Building Microservices. O'Reilly Media, 2015. ISBN: 978-1-491-95035-7

[32] Chris Richardson: "Microservices: Decomposing Applications for Deployability and Scalability," infoq.com, May 25, 2014.

[33] Pat Helland: "Data on the Outside Versus Data on the Inside," at 2nd Biennial Conference on Innovative Data Systems Research (CIDR), January 2005.

[34] Roy Thomas Fielding: "Architectural Styles and the Design of Network-Based Software Architectures," PhD Thesis, University of California, Irvine, 2000.

[35] Roy Thomas Fielding: "REST APIs Must Be Hypertext-Driven," roy.gbiv.com, October 20 2008.

[36] "REST in Peace, SOAP," royal.pingdom.com, October 15, 2010.

[37] "Web Services Standards as of Q1 2007," innoq.com, February 2007.

[38] Pete Lacey: "The S Stands for Simple," harmful.cat-v.org, November 15, 2006.

[39] Stefan Tilkov: "Interview: Pete Lacey Criticizes Web Services," infoq.com, December 12, 2006.

[40] "OpenAPI Specification (fka Swagger RESTful API Documentation Specification) Version 2.0," swagger.io, September 8, 2014.

[41] Michi Henning: "The Rise and Fall of CORBA," ACM Queue, volume 4, number 5, pages 28–34, June 2006. doi:10.1145/1142031.1142044

[42] Andrew D. Birrell and Bruce Jay Nelson: "Implementing Remote Procedure Calls," ACM Transactions on Computer Systems (TOCS), volume 2, number 1, pages 39–59, February 1984. doi:10.1145/2080.357392

[43] Jim Waldo, Geoff Wyant, Ann Wollrath, and Sam Kendall: "A Note on Distributed Computing," Sun Microsystems Laboratories, Inc., Technical Report TR-94-29, November 1994.

[44] Steve Vinoski: "Convenience over Correctness," IEEE Internet Computing, volume 12, number 4, pages 89–92, July 2008. doi:10.1109/MIC.2008.75

[45] Marius Eriksen: "Your Server as a Function," at 7th Workshop on Programming Languages and Operating Systems (PLOS), November 2013. doi:10.1145/2525528.2525538

[46] "grpc-common Documentation," Google, Inc., github.com, February 2015.

[47] Aditya Narayan and Irina Singh: "Designing and Versioning Compatible Web Services," ibm.com, March 28, 2007.

[48] Troy Hunt: "Your API Versioning Is Wrong, Which Is Why I Decided to Do It 3 Different Wrong Ways," troyhunt.com, February 10, 2014.

[49] "API Upgrades," Stripe, Inc., April 2015.

[50] Jonas Bonér: "Upgrade in an Akka Cluster," email to akka-user mailing list, grokbase.com, August 28, 2013.

[51] Philip A. Bernstein, Sergey Bykov, Alan Geller, et al.: "Orleans: Distributed Virtual Actors for Programmability and Scalability," Microsoft Research Technical Report MSR-TR-2014-41, March 2014.

[52] "Microsoft Project Orleans Documentation," Microsoft Research, dotnet.github.io, 2015.

[53] David Mercer, Sean Hinde, Yinso Chen, and Richard A O'Keefe: "beginner: Updating Data Structures," email thread on erlang-questions mailing list, erlang.com, October 29, 2007.

[54] Fred Hebert: "Postscript: Maps," learnyousomeerlang.com, April 9, 2014.