Levelwise
فارسی
پیام‌رسانی

تغییر ساختار پیام (Schema Evolution)

پیام یک قرارداد بین تیم‌ها است. فرستنده و گیرنده هیچ وقت همزمان به‌روز نمی‌شوند. پس هر تغییر باید طوری باشد که کد قدیم و کد جدید هر دو پیام را بفهمند.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۳ دقیقهمثال فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: یک تغییر کوچک، چهار سرویس خراب

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

{ "orderId": "o-1", "amount": 250000 }

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

{ "orderId": "o-1", "amount": { "value": 250000, "currency": "IRR" } }
سرویس سفارشکد جدید، امروز منتشر شد{"orderId":"o-1","amount":{"value":250000,"currency":"IRR"}}مقدار مبلغ حالا یک شیء است، نه یک عددسرویس گزارشکد جدید داردسرویس پیامکهنوز عدد می‌خواهدJsonExceptionسرویس انبارهنوز عدد می‌خواهدJsonExceptionسرویس حسابداریهنوز عدد می‌خواهدJsonException
فقط سرویسی که کد جدید را دارد پیام را می‌فهمد. بقیه با هر پیام خطا می‌دهند.

قدم به قدم چه اتفاقی می‌افتد؟

  1. سرویس پیامک هنوز کد قدیم را دارد. در کد آن، مبلغ یک عدد است.
  2. پیام جدید می‌رسد و مبلغ یک شیء است. تبدیل JSON با خطا شکست می‌خورد.
  3. مصرف‌کننده دوباره تلاش می‌کند و باز شکست می‌خورد. در کافکا ترتیب مهم است، پس پیام‌های بعدی هم پشت همین پیام گیر می‌کنند.
  4. مشکل برعکس هم هست. اگر سرویسی زودتر به کد جدید برود، پیام‌های قدیمی که هنوز در topic هستند را نمی‌فهمد.
  5. همه با هم منتشر کنیم؟ هماهنگ کردن انتشار چهار تیم در یک لحظه عملاً ممکن نیست. پیام‌های قدیمی هم در صف می‌مانند.

ایده اصلی: پیام یک قرارداد است

یک API عمومی را تصور کن. نمی‌توانی یک روز نوع یک فیلد را عوض کنی و انتظار داشته باشی همه کلاینت‌ها همان روز درست کار کنند. پیام هم دقیقاً همین است. فقط سخت‌تر، چون:

  • پیام می‌ماند. پیامی که امروز نوشته شده، ممکن است هفته بعد یا سال بعد دوباره خوانده شود.
  • فرستنده گیرنده‌ها را نمی‌شناسد. شاید سرویسی که خبر نداری هم این پیام را می‌خواند.

پس قانون اصلی این است: کد قدیم و کد جدید باید مدتی کنار هم کار کنند.

دو جهت سازگاری

سازگاری رو به عقبBACKWARDپیام قدیمیv1کد جدیدv2مصرف‌کننده‌ای که زودتر به‌روز شدپیام‌های قدیمی صف را می‌فهمدسازگاری رو به جلوFORWARDپیام جدیدv2کد قدیمیv1مصرف‌کننده‌ای که هنوز به‌روز نشدهپیام‌های جدید را می‌فهمدهر دو با هم: سازگاری کامل
در یک سیستم واقعی هر دو حالت پیش می‌آید. پس معمولاً هر دو لازم است.
  1. سازگاری رو به عقب (Backward). کد جدید، پیام قدیمی را می‌فهمد. لازم است چون پیام‌های قدیمی در صف یا topic هنوز هستند.
  2. سازگاری رو به جلو (Forward). کد قدیمی، پیام جدید را می‌فهمد. لازم است چون همه مصرف‌کننده‌ها همزمان به‌روز نمی‌شوند.
  3. سازگاری کامل (Full). هر دو با هم. امن‌ترین حالت برای رویدادهایی که چند تیم می‌خوانند.

کدام تغییر امن است؟

تغییر امن است؟ چرا
اضافه کردن فیلد اختیاری بله کد قدیم فیلد ناشناخته را نادیده می‌گیرد. کد جدید برای پیام قدیم مقدار پیش‌فرض می‌گذارد.
اضافه کردن فیلد اجباری نه پیام‌های قدیمی این فیلد را ندارند.
حذف فیلد نه مصرف‌کننده‌ای که هنوز آن را می‌خواند، مقدار خالی می‌گیرد.
تغییر نام فیلد نه برای کد قدیم، یعنی حذف یک فیلد و اضافه کردن یک فیلد دیگر.
تغییر نوع فیلد نه تبدیل JSON شکست می‌خورد، مثل مثال بالا.
تغییر معنای فیلد، با همان نام و نوع نه، و خطرناک‌ترین هیچ خطایی نمی‌دهد. فقط عددها غلط می‌شوند. مثلاً مبلغ از ریال به تومان.
اضافه کردن یک مقدار جدید به enum معمولاً نه کد قدیم مقدار ناشناخته را نمی‌شناسد.

راه درست: گسترش، مهاجرت، جمع کردن

تغییر بزرگ را به سه قدم کوچک تقسیم می‌کنیم. هر قدم به تنهایی امن است.

۱. گسترشفیلد جدید کنار فیلد قدیمamount: 250000money: {...}فرستنده هر دو را پر می‌کندهیچ کس خراب نمی‌شود۲. مهاجرتهر تیم هر وقت آماده بودگزارش: فیلد جدیدپیامک: فیلد جدیدانبار: هنوز فیلد قدیمچند هفته طول می‌کشد۳. جمع کردنحذف فیلد قدیمamountmoney: {...}وقتی همه رفتندو پیام قدیمی لازم نیست
در هیچ لحظه‌ای، هیچ مصرف‌کننده‌ای پیامی نمی‌بیند که نفهمد.
  1. گسترش. یک فیلد جدید با نام جدید اضافه کن. فیلد قدیم هم هنوز پر می‌شود.
  2. مهاجرت. هر تیم هر وقت آماده بود، کدش را به خواندن فیلد جدید می‌برد.
  3. جمع کردن. وقتی همه رفتند و پیام‌های قدیمی هم دیگر لازم نیستند، فیلد قدیم را حذف کن.

پیام در قدم اول:

{ "orderId": "o-1", "amount": 250000, "money": { "value": 250000, "currency": "IRR" } }

سمت فرستنده در C#:

public sealed record Money(decimal Value, string Currency);

public sealed record OrderCreated(
    string OrderId,
    [property: Obsolete("Use Money. Will be removed after all consumers move.")]
    decimal Amount,
    Money Money);

var message = new OrderCreated(order.Id, order.Total.Value, order.Total);
راه دیگر: نسخه جدید رویداد اگر تغییر خیلی بزرگ است، یک رویداد جدید بساز، مثلاً OrderCreatedV2. مدتی هر دو نسخه را منتشر کن. وقتی همه مصرف‌کننده‌ها رفتند، نسخه قدیم را متوقف کن. هزینه‌اش این است که مدتی دو پیام برای یک اتفاق داری.

سمت گیرنده: خواننده بخشنده (Tolerant Reader)

فرستنده باید مراقب باشد. ولی گیرنده هم باید بخشنده باشد:

  1. فقط فیلدهایی را بخوان که لازم داری. سرویس پیامک فقط شناسه سفارش و مبلغ را لازم دارد. فیلدهای دیگر را در کلاسش تعریف نکن.
  2. فیلد ناشناخته را نادیده بگیر. کتابخانه System.Text.Json این کار را به طور پیش‌فرض انجام می‌دهد. این رفتار را خاموش نکن.
  3. برای فیلد جدید، نبودن را قبول کن. پیام قدیمی فیلد جدید را ندارد. پس آن را nullable تعریف کن و یک راه جایگزین داشته باش.
// The SMS service reads only what it needs.
public sealed record OrderCreatedForSms(string OrderId, decimal? Amount, Money? Money)
{
    // Works with old messages (only Amount) and new ones (Money).
    public decimal Total => Money?.Value ?? Amount
        ?? throw new InvalidOperationException("Message has no amount.");
}
دو تله در System.Text.Json: اول، اگر یک property را با کلمه required تعریف کنی، پیامی که آن فیلد را ندارد با خطا رد می‌شود. پس فیلد جدید را در کلاس مصرف‌کننده required نکن. دوم، اگر enum را به صورت متن بفرستی و مقدار ناشناخته برسد، تبدیل JSON خطا می‌دهد. برای enum هایی که ممکن است بزرگ شوند، یک مقدار «ناشناخته» در نظر بگیر یا آن را به صورت متن ساده بخوان.

خواندن دوباره پیام‌های قدیمی (Replay)

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

  1. سرویس جدید همه شکل‌ها را بفهمد. برای چند نسخه ممکن است، ولی کد شلوغ می‌شود.
  2. یک لایه تبدیل (Upcaster). قبل از رسیدن به کد اصلی، هر نسخه قدیمی را به نسخه جدید تبدیل کن. کد اصلی فقط یک شکل را می‌بیند.

برای این کار، هر پیام باید نسخه‌اش را همراه داشته باشد. مثلاً در Header پیام، یا با شناسه schema.

public sealed record OrderCreatedV2(string OrderId, Money Money);

public static class OrderCreatedUpcaster
{
    public static OrderCreatedV2 Upcast(int version, JsonElement json) => version switch
    {
        1 => new OrderCreatedV2(
                json.GetProperty("orderId").GetString()!,
                new Money(json.GetProperty("amount").GetDecimal(), "IRR")), // v1 was always IRR
        2 => json.Deserialize<OrderCreatedV2>(JsonSerializerOptions.Web)!, // camelCase names
        _ => throw new NotSupportedException($"Unknown OrderCreated version {version}.")
    };
}
یادت باشد: مدت نگهداری پیام در کافکا (Retention) محدود است. اگر پیام‌های یک سال نگه داشته نشده باشند، Replay ممکن نیست.

جلوگیری قبل از Production با Schema Registry

قانون‌های بالا را آدم‌ها فراموش می‌کنند. یک ابزار می‌تواند آن‌ها را خودکار چک کند.

  1. شکل پیام با یک زبان دقیق تعریف می‌شود. معمولاً Avro یا Protobuf، و گاهی JSON Schema.
  2. هر نسخه جدید در Schema Registry ثبت می‌شود. فرستنده قبل از ارسال، schema را ثبت می‌کند.
  3. قانون سازگاری چک می‌شود. اگر نسخه جدید با قانون (مثلاً Backward) نخواند، ثبت رد می‌شود. پس خطا در CI یا هنگام انتشار دیده می‌شود، نه در Production.
  4. پیام فقط شناسه schema را همراه دارد. گیرنده با همین شناسه، شکل دقیق پیام را از Registry می‌گیرد.

دو نکته درباره قالب‌ها:

  • در Protobuf، هر فیلد یک شماره دارد. شماره فیلد حذف‌شده را هیچ وقت دوباره استفاده نکن. آن را reserved کن.
  • در Avro، فیلد جدید باید مقدار پیش‌فرض داشته باشد. وگرنه خواندن پیام قدیمی با schema جدید ممکن نیست.

اشتباه‌های رایج

اشتباه نتیجه راه درست
تغییر نوع یا نام فیلد در یک قدم مصرف‌کننده‌های قدیمی خطا می‌دهند و صف گیر می‌کند. گسترش، مهاجرت، جمع کردن.
«همه با هم منتشر می‌کنیم» یک تیم دیر می‌کند و پیام‌های قدیمی هم هنوز در صف هستند. کد قدیم و جدید مدتی کنار هم.
تغییر معنا یا واحد یک فیلد هیچ خطایی نیست، ولی گزارش‌ها غلط می‌شوند. فیلد جدید با نام روشن، مثل currency.
کلاس مصرف‌کننده با همه فیلدها و required هر فیلد کم یا اضافه، مصرف‌کننده را خراب می‌کند. فقط فیلدهای لازم، با تحمل نبودن.
پیام بدون شماره نسخه در Replay معلوم نیست هر پیام چه شکلی دارد. نسخه در Header یا شناسه schema.
حذف فیلد قدیم قبل از تمام شدن مهاجرت یکی از سرویس‌ها بی‌صدا مقدار خالی می‌گیرد. اول مطمئن شو هیچ کس آن را نمی‌خواند.

چقدر سخت‌گیری لازم است؟

سخت‌گیری کامل

  • رویداد را چند تیم یا چند شرکت می‌خوانند.
  • پیام‌ها مدت زیادی نگه داشته می‌شوند و Replay می‌شوند.
  • استفاده از Schema Registry و قانون Full ارزش هزینه‌اش را دارد.

ساده‌تر کافی است

  • فرستنده و گیرنده در یک سرویس و یک انتشار هستند.
  • صف فقط پیام‌های چند دقیقه را نگه می‌دارد.
  • همان قانون‌های ساده کافی است: فقط فیلد اختیاری اضافه کن و خواننده بخشنده باش.

خلاصه در شش خط

  1. پیام یک قرارداد است. فرستنده و گیرنده همزمان به‌روز نمی‌شوند.
  2. کد جدید باید پیام قدیم را بفهمد و کد قدیم پیام جدید را.
  3. اضافه کردن فیلد اختیاری امن است. حذف، تغییر نام و تغییر نوع امن نیست.
  4. تغییر بزرگ را در سه قدم انجام بده: گسترش، مهاجرت، جمع کردن.
  5. گیرنده فقط فیلدهای لازم را بخواند و فیلد ناشناخته را نادیده بگیرد.
  6. هر پیام نسخه‌اش را همراه داشته باشد. Schema Registry تغییر ناسازگار را زود می‌گیرد.