Levelwise
فارسی
Actor Model

Akka.NET

کتابخانه Akka.NET مدل Actor کلاسیک را به .NET می‌آورد. خودت Actor ها را می‌سازی، در یک درخت والد و فرزند. هر والد ناظر فرزندانش است و وقتی یکی خطا داد، تصمیم می‌گیرد با آن چه کند.

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

نویسنده: bezzad

مشکل: انبار در روز حراج

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

  1. همزمانی. دو درخواست نباید آخرین کالا را با هم رزرو کنند.
  2. خطا. اگر کد یک محصول خطا بدهد (مثلاً داده خراب)، نباید کل انبار از کار بیفتد.

در درس مدل Actor دیدیم که یک Actor برای هر محصول مشکل اول را حل می‌کند. Akka.NET مشکل دوم را هم با یک ایده ساده حل می‌کند: هر Actor یک والد دارد که مراقب آن است.

ایده اصلی: درخت Actor ها

در Akka.NET هر Actor را یک Actor دیگر می‌سازد. پس همه Actor ها یک درخت می‌سازند.

/userمدیر انبار/user/stockاکتور هدفون/user/stock/p-7اکتور گوشی/user/stock/p-2اکتور کتاب/user/stock/p-9والد، ناظر فرزندان است
مدیر انبار برای هر محصول یک فرزند می‌سازد. هر Actor یک آدرس مثل مسیر فایل دارد.
  1. سیستم Actor (ActorSystem) ریشه درخت است. معمولاً در هر برنامه یکی داریم.
  2. هر Actor فرزندان خودش را می‌سازد. مدیر انبار، Actor هر محصول را می‌سازد.
  3. هر Actor یک آدرس دارد. کد دیگر فقط یک ارجاع (IActorRef) دارد، نه خود شیء. پس نمی‌تواند به وضعیت آن دست بزند.
  4. هر والد ناظر فرزندانش است. این مهم‌ترین ایده Akka است.

کد: یک Actor ساده

پیام‌ها record های تغییرناپذیر هستند:

public sealed record Reserve(string OrderId, string ProductId, int Quantity);
public sealed record Reserved(string OrderId);
public sealed record OutOfStock(string OrderId);

اکتور هر محصول. با Receive می‌گوییم برای هر نوع پیام چه کنیم:

using Akka.Actor;

public sealed class ProductStockActor : ReceiveActor
{
    private int _available;

    public ProductStockActor(string productId, IStockRepository stock)
    {
        _available = stock.GetAvailable(productId); // runs again after a restart
        Receive<Reserve>(msg =>
        {
            if (msg.Quantity > _available)
            {
                Sender.Tell(new OutOfStock(msg.OrderId));
                return;
            }

            _available -= msg.Quantity;
            Sender.Tell(new Reserved(msg.OrderId));
            if (_available == 0) Become(SoldOut); // change behavior for next messages
        });
    }

    private void SoldOut() =>
        Receive<Reserve>(msg => Sender.Tell(new OutOfStock(msg.OrderId)));
}

دو نکته در این کد:

  • ویژگی Sender همان Actor ای است که پیام فعلی را فرستاده است. جواب را به آن می‌فرستیم.
  • متد Become رفتار Actor را برای پیام‌های بعدی عوض می‌کند. بعد از تمام شدن موجودی، Actor دیگر حتی عدد را چک نمی‌کند.

مدیر انبار، فرزند هر محصول را اولین بار می‌سازد و پیام را به آن می‌دهد:

public sealed class StockManagerActor : ReceiveActor
{
    public StockManagerActor(IStockRepository stock)
    {
        Receive<Reserve>(msg =>
        {
            var child = Context.Child(msg.ProductId);
            if (child.Equals(ActorRefs.Nobody))
                child = Context.ActorOf(
                    Props.Create(() => new ProductStockActor(msg.ProductId, stock)),
                    msg.ProductId);

            child.Forward(msg); // keep the original sender, so the child replies to it
        });
    }
}

و استفاده از بیرون، مثلاً در یک API:

var system = ActorSystem.Create("shop");
var manager = system.ActorOf(Props.Create(() => new StockManagerActor(repository)), "stock");

// Tell: fire and forget.
manager.Tell(new Reserve("o-1", "p-7", 1));

// Ask: wait for one reply, with a timeout.
var reply = await manager.Ask<object>(new Reserve("o-2", "p-7", 1), TimeSpan.FromSeconds(3));
روش Tell یا Ask؟ با Tell پیام می‌فرستی و منتظر نمی‌مانی. این روش اصلی Akka است. روش Ask برای مرز بین کد معمولی و Actor ها است، مثلاً داخل یک API. بین دو Actor تا جای ممکن از Tell استفاده کن.

نظارت (Supervision): بگذار خراب شود

ایده Akka از Erlang آمده است: به جای try/catch در همه جا، بگذار Actor خراب شود و والدش تصمیم بگیرد.

اکتور هدفونخطا دادخبر خطامدیر انبارتصمیم می‌گیردResumeادامه بدهوضعیت می‌ماندRestartاز نو بسازوضعیت حافظه پاک می‌شودStopمتوقف کنبرای همیشهEscalateبه والد خودم بسپارمن نمی‌دانم چه کنمپیش‌فرض برای بیشتر خطاها، ساختن دوباره است
فرزند خودش خطا را درست نمی‌کند. فقط خبر می‌دهد و والد تصمیم می‌گیرد.
  1. فرزند خطا می‌دهد. پیام فعلی‌اش نیمه‌کاره می‌ماند.
  2. صندوق پیامش متوقف می‌شود و والد خبر خطا را می‌گیرد.
  3. والد یکی از چهار تصمیم را می‌گیرد. ادامه، ساختن دوباره، توقف، یا سپردن به والد بالاتر.
  4. بقیه انبار کار می‌کند. خطای Actor هدفون روی Actor گوشی اثری ندارد.
public sealed class StockManagerActor : ReceiveActor
{
    // ... the same constructor as before ...

    protected override SupervisorStrategy SupervisorStrategy() =>
        new OneForOneStrategy(
            maxNrOfRetries: 3,
            withinTimeRange: TimeSpan.FromMinutes(1),
            localOnlyDecider: ex => ex switch
            {
                InvalidDataException => Directive.Restart,  // reload clean state
                TimeoutException => Directive.Resume,        // state is still fine
                _ => Directive.Escalate
            });
}

استراتژی OneForOne یعنی تصمیم فقط روی همان فرزندی اجرا می‌شود که خطا داده است. استراتژی دیگری به نام AllForOne تصمیم را روی همه فرزندان اجرا می‌کند.

ساختن دوباره، وضعیت حافظه را پاک می‌کند. بعد از Restart، سازنده Actor دوباره اجرا می‌شود و وضعیت داخل حافظه از دست می‌رود. پس وضعیت مهم باید جایی ذخیره شود. در مثال ما، سازنده موجودی را از دیتابیس می‌خواند. پس بعد از Restart عدد درست برمی‌گردد. برای ذخیره رویدادهای Actor، بسته Akka.Persistence هم هست.

قانون‌های مهم

  1. ترتیب پیام فقط بین دو Actor مشخص تضمین می‌شود. اگر الف دو پیام به ب بفرستد، ب آن‌ها را به همان ترتیب می‌گیرد. ولی بین پیام‌های الف و ج ترتیبی نیست.
  2. تحویل پیام به طور پیش‌فرض «حداکثر یک بار» است. اگر پیام بین دو سرور گم شود، Akka خودش دوباره نمی‌فرستد. برای کار مهم، جواب بخواه و Timeout بگذار.
  3. داخل Actor منتظر کار طولانی نمان. اگر یک await طولانی داشته باشی، صندوق پیام پشت آن می‌ماند. کار را شروع کن و نتیجه را با PipeTo به صورت یک پیام جدید به خود Actor برگردان.
  4. وضعیت را بیرون نده. فقط پیام تغییرناپذیر بفرست.

Akka.NET یا Orleans؟

هر دو مدل Actor هستند، ولی فلسفه متفاوتی دارند:

Akka.NETبسازپیام بفرستمتوقف کنچرخه عمر دست توستوالد، ناظر و آدرس دقیق داردOrleansپیام بفرستساختن و از بین بردنخودکار استفقط شناسه، بدون والد
در Orleans کمتر فکر می‌کنی. در Akka.NET کنترل بیشتری داری.
Akka.NET Orleans
ساختن Actor خودت می‌سازی و متوقف می‌کنی. خودکار، با اولین پیام.
ساختار درخت والد و فرزند. بدون درخت. فقط شناسه.
مدیریت خطا والد با استراتژی نظارت. خطا به فراخواننده برمی‌گردد، مثل یک متد معمولی.
ارتباط پیام با Tell، و Ask در صورت نیاز. فراخوانی متد async روی رابط.
پخش روی چند سرور بسته‌های Cluster و Cluster Sharding. داخلی و پیش‌فرض.
منحنی یادگیری تندتر، مفهوم‌های بیشتر. ملایم‌تر برای برنامه‌نویس .NET.
مناسب برای جریان‌های پیچیده پیام، کنترل دقیق روی چرخه عمر و خطا. تعداد زیادی موجودیت مستقل، مثل سبد یا بازیکن.
قانون ساده: اگر بیشتر به «هر موجودیت یک شیء همیشه در دسترس» فکر می‌کنی، Orleans ساده‌تر است. اگر به «یک شبکه از Actor ها که با پیام همکاری می‌کنند و خطاها را مدیریت می‌کنند» فکر می‌کنی، Akka.NET مناسب‌تر است.

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

اشتباه نتیجه راه درست
استفاده از Ask بین همه Actor ها Actor ها منتظر هم می‌مانند و Timeout زیاد می‌شود. ارتباط با Tell، و جواب به صورت پیام.
try/catch برای همه خطاها داخل Actor خطای واقعی پنهان می‌شود و وضعیت خراب می‌ماند. خطا را به والد بسپار و استراتژی نظارت بنویس.
فکر کنی وضعیت بعد از Restart می‌ماند موجودی صفر یا غلط می‌شود. وضعیت مهم را ذخیره کن و دوباره بخوان.
فرستادن شیء قابل تغییر در پیام دو Actor به یک داده دست می‌زنند. فقط record تغییرناپذیر.
یک Actor برای همه محصولات گلوگاه می‌شود. یک Actor برای هر محصول.

خلاصه در شش خط

  1. در Akka.NET خودت Actor ها را می‌سازی و آن‌ها یک درخت می‌سازند.
  2. هر Actor با Receive برای هر نوع پیام رفتار تعریف می‌کند و با Become رفتارش را عوض می‌کند.
  3. ارتباط اصلی با Tell است. روش Ask برای مرز با کد معمولی است.
  4. هر والد ناظر فرزندانش است: ادامه، ساختن دوباره، توقف یا سپردن به بالاتر.
  5. بعد از Restart وضعیت حافظه پاک می‌شود. وضعیت مهم را ذخیره کن.
  6. کتابخانه Orleans ساده‌تر و خودکارتر است. کتابخانه Akka.NET کنترل بیشتری می‌دهد.