Akka.NET
کتابخانه Akka.NET مدل Actor کلاسیک را به .NET میآورد. خودت Actor ها را میسازی، در یک درخت والد و فرزند. هر والد ناظر فرزندانش است و وقتی یکی خطا داد، تصمیم میگیرد با آن چه کند.
نویسنده: bezzad
مشکل: انبار در روز حراج
در روز حراج، هزاران درخواست رزرو در یک ثانیه به انبار فروشگاه میرسد. هر محصول موجودی خودش را دارد. دو مشکل داریم:
- همزمانی. دو درخواست نباید آخرین کالا را با هم رزرو کنند.
- خطا. اگر کد یک محصول خطا بدهد (مثلاً داده خراب)، نباید کل انبار از کار بیفتد.
در درس مدل Actor دیدیم که یک Actor برای هر محصول مشکل اول را حل میکند. Akka.NET مشکل دوم را هم با یک ایده ساده حل میکند: هر Actor یک والد دارد که مراقب آن است.
ایده اصلی: درخت Actor ها
در Akka.NET هر Actor را یک Actor دیگر میسازد. پس همه Actor ها یک درخت میسازند.
- سیستم Actor (ActorSystem) ریشه درخت است. معمولاً در هر برنامه یکی داریم.
- هر Actor فرزندان خودش را میسازد. مدیر انبار، Actor هر محصول را میسازد.
- هر Actor یک آدرس دارد. کد دیگر فقط یک ارجاع (IActorRef) دارد، نه خود شیء. پس نمیتواند به وضعیت آن دست بزند.
- هر والد ناظر فرزندانش است. این مهمترین ایده 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));
نظارت (Supervision): بگذار خراب شود
ایده Akka از Erlang آمده است: به جای try/catch در همه جا، بگذار Actor خراب شود و والدش تصمیم بگیرد.
- فرزند خطا میدهد. پیام فعلیاش نیمهکاره میماند.
- صندوق پیامش متوقف میشود و والد خبر خطا را میگیرد.
- والد یکی از چهار تصمیم را میگیرد. ادامه، ساختن دوباره، توقف، یا سپردن به والد بالاتر.
- بقیه انبار کار میکند. خطای 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 تصمیم را روی همه فرزندان اجرا میکند.
قانونهای مهم
- ترتیب پیام فقط بین دو Actor مشخص تضمین میشود. اگر الف دو پیام به ب بفرستد، ب آنها را به همان ترتیب میگیرد. ولی بین پیامهای الف و ج ترتیبی نیست.
- تحویل پیام به طور پیشفرض «حداکثر یک بار» است. اگر پیام بین دو سرور گم شود، Akka خودش دوباره نمیفرستد. برای کار مهم، جواب بخواه و Timeout بگذار.
- داخل Actor منتظر کار طولانی نمان. اگر یک await طولانی داشته باشی، صندوق پیام پشت آن میماند. کار را شروع کن و نتیجه را با PipeTo به صورت یک پیام جدید به خود Actor برگردان.
- وضعیت را بیرون نده. فقط پیام تغییرناپذیر بفرست.
Akka.NET یا Orleans؟
هر دو مدل Actor هستند، ولی فلسفه متفاوتی دارند:
| Akka.NET | Orleans | |
|---|---|---|
| ساختن Actor | خودت میسازی و متوقف میکنی. | خودکار، با اولین پیام. |
| ساختار | درخت والد و فرزند. | بدون درخت. فقط شناسه. |
| مدیریت خطا | والد با استراتژی نظارت. | خطا به فراخواننده برمیگردد، مثل یک متد معمولی. |
| ارتباط | پیام با Tell، و Ask در صورت نیاز. | فراخوانی متد async روی رابط. |
| پخش روی چند سرور | بستههای Cluster و Cluster Sharding. | داخلی و پیشفرض. |
| منحنی یادگیری | تندتر، مفهومهای بیشتر. | ملایمتر برای برنامهنویس .NET. |
| مناسب برای | جریانهای پیچیده پیام، کنترل دقیق روی چرخه عمر و خطا. | تعداد زیادی موجودیت مستقل، مثل سبد یا بازیکن. |
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| استفاده از Ask بین همه Actor ها | Actor ها منتظر هم میمانند و Timeout زیاد میشود. | ارتباط با Tell، و جواب به صورت پیام. |
| try/catch برای همه خطاها داخل Actor | خطای واقعی پنهان میشود و وضعیت خراب میماند. | خطا را به والد بسپار و استراتژی نظارت بنویس. |
| فکر کنی وضعیت بعد از Restart میماند | موجودی صفر یا غلط میشود. | وضعیت مهم را ذخیره کن و دوباره بخوان. |
| فرستادن شیء قابل تغییر در پیام | دو Actor به یک داده دست میزنند. | فقط record تغییرناپذیر. |
| یک Actor برای همه محصولات | گلوگاه میشود. | یک Actor برای هر محصول. |
خلاصه در شش خط
- در Akka.NET خودت Actor ها را میسازی و آنها یک درخت میسازند.
- هر Actor با Receive برای هر نوع پیام رفتار تعریف میکند و با Become رفتارش را عوض میکند.
- ارتباط اصلی با Tell است. روش Ask برای مرز با کد معمولی است.
- هر والد ناظر فرزندانش است: ادامه، ساختن دوباره، توقف یا سپردن به بالاتر.
- بعد از Restart وضعیت حافظه پاک میشود. وضعیت مهم را ذخیره کن.
- کتابخانه Orleans سادهتر و خودکارتر است. کتابخانه Akka.NET کنترل بیشتری میدهد.