GenServer (سرور عمومی) یک «رفتار» است که تعاملات رایج کلاینت-سرور میان فرایندهای Elixir را انتزاع میکند.
«حلقهی دریافت» را از زمانی که دربارهی فرایندها یاد گرفتیم به خاطر دارید؟ «رفتار» GenServer انتزاعهایی برای پیادهسازی چنین حلقههایی فراهم میکند و امکان تبادل پیام با فرایندی را میدهد که چنین حلقهای را اجرا میکند. این کار نگهداری وضعیت و اجرای کد ناهمگام را آسانتر میکند.
آگاه باشید که نام GenServer چندمعناست. از آن هم برای اشاره به ماژولی استفاده میشود که از «رفتار» GenServer استفاده میکند و هم برای اشاره به فرایندی که از ماژولی راهاندازی شده که از «رفتار» GenServer استفاده میکند.
«رفتار» GenServer یک کالبک الزامی، init/1 و چند کالبک اختیاری جالب تعریف میکند: handle_call/3، handle_cast/2 و handle_info/3. کلاینتهایی که از یک GenServer استفاده میکنند قرار نیست آن کالبکها را مستقیماً فراخوانی کنند. در عوض، ماژول GenServer توابعی فراهم میکند که کلاینتها میتوانند با آنها با یک فرایند GenServer ارتباط برقرار کنند.
اغلب یک ماژول واحد هم یک API کلاینت تعریف میکند و هم پیادهسازیهای کالبک سمت سرور. API کلاینت مجموعهای از توابع است که بخشهای دیگر برنامهی Elixir شما میتوانند برای ارتباط با این فرایند GenServer آنها را فراخوانی کنند. پیادهسازیهای کالبک سمت سرور هم منطق این GenServer را در خود دارند.
بیایید اول به یک مثال ساده از GenServer نگاه کنیم و بعد ببینیم هر کالبک چه معنایی دارد.
این یک سرور نمونه است که میتواند در یک سفر جادهای طولانی به پرسشهای مکرر مسافران آزاردهنده پاسخ دهد؛ دقیقتر بگوییم، به این پرسش: «رسیدیم؟». تعداد دفعاتی که این پرسش پرسیده شده را پیگیری میکند و پاسخهایی با آزردگی فزاینده برمیگرداند.
defmodule AnnoyingPassengerAutoresponder do
use GenServer
# Client API
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg)
end
def are_we_there_yet?(pid) do
GenServer.call(pid, :are_we_there_yet?)
end
# Server callbacks
@impl GenServer
def init(_init_arg) do
# the initial count of questions asked is always 0
state = 0
{:ok, state}
end
@impl GenServer
def handle_call(:are_we_there_yet?, _from, state) do
reply =
cond do
state <= 3 -> "No."
state <= 10 -> "I told you #{state} times already. No."
true -> "..."
end
# increase the count of questions asked
new_state = state + 1
# reply to the caller
{:reply, reply, new_state}
end
end
init/1یک سرور را میتوان با فراخوانی GenServer.start/3 یا GenServer.start_link/3 راهاندازی کرد. تفاوت این دو تابع را در مفهوم پیوندها یاد گرفتیم.
این دو تابع:
GenServer را پیادهسازی میکند به عنوان آرگومان اول میپذیرند.init_arg میپذیرند. همانطور که از اسمش پیداست، این آرگومان به کالبک init/1 فرستاده میشود.راهاندازی یک سرور با فراخوانی GenServer.start/3 یا GenServer.start_link/3، کالبک init/1 را بهصورت مسدودکننده فراخوانی میکند. مقدار بازگشتی init/1 تعیین میکند که آیا سرور با موفقیت راهاندازی میشود یا نه.
کالبک init/1 معمولاً یکی از این مقادیر را برمیگرداند:
{:ok, state}. سرور حلقهی دریافت خود را با state به عنوان وضعیت اولیهاش آغاز میکند. state میتواند از هر نوعی باشد.{:stop, reason}. reason میتواند از هر نوعی باشد. سرور حلقهی دریافت خود را آغاز نمیکند. فرایند با دلیل دادهشده خارج میشود.امکانات پیشرفتهی دیگری هم هست که فعلاً به آنها نمیپردازیم.
اگر حلقهی دریافت سرور آغاز شود، توابع GenServer.start/3 و GenServer.start_link/3 یک تاپل {:ok, pid} برمیگردانند. در غیر این صورت {:error, reason} را برمیگردانند.
handle_call/3پیامی را که به پاسخ نیاز دارد میتوان با GenServer.call/2 به یک فرایند سرور فرستاد. این تابع pid یک فرایند سرور در حال اجرا را به عنوان آرگومان اول و پیام را به عنوان آرگومان دوم انتظار دارد. پیام میتواند از هر نوعی باشد.
کالبک handle_call/3 مسئول رسیدگی به پیامهای همگام و پاسخ دادن به آنهاست. سه آرگومان دریافت میکند:
message: مقداری که به عنوان آرگومان دوم به GenServer.call/2 فرستاده شده است.from: pid فرایندی که GenServer.call/2 را فراخوانی کرده است. در بیشتر موارد میتوان این آرگومان را نادیده گرفت.state: وضعیت فعلی سرور. یادتان باشد که مقدار اولیهی آن در کالبک init/1 تعیین شده بود.کالبک handle_call/3 معمولاً یک تاپل ۳تایی به شکل {:reply, reply, state} برمیگرداند. یعنی عنصر دوم تاپل، یعنی reply که میتواند از هر نوعی باشد، به فراخواننده بازگردانده میشود. عنصر سوم تاپل، یعنی state، وضعیت جدید سرور پس از رسیدگی به این پیام است.
امکانات پیشرفتهی دیگری هم هست که فعلاً به آنها نمیپردازیم.
برای اینکه از روی اسمش به خاطر بسپارید که این کالبک چهکار میکند، آن را مانند «تماس گرفتن» با کسی از طریق تلفن در نظر بگیرید.
اگر آن شخص در دسترس باشد، بلافاصله (بهصورت همگام) پاسخ میگیرید.
handle_cast/2پیامی را که به پاسخ نیاز ندارد میتوان با GenServer.cast/2 به یک فرایند سرور فرستاد. آرگومانهایش با آرگومانهای GenServer.call/2 یکسان است.
کالبک handle_cast/2 مسئول رسیدگی به این پیامهاست. دو آرگومان دریافت میکند، message و state، که همان آرگومانهای کالبک handle_call/3 هستند (بهجز from).
کالبک handle_cast/2 معمولاً یک تاپل ۲تایی به شکل {:noreply, state} برمیگرداند.
امکانات پیشرفتهی دیگری هم هست که فعلاً به آنها نمیپردازیم.
برای اینکه از روی اسمش به خاطر بسپارید که این کالبک چهکار میکند، یادتان باشد که «to cast» معنای «پرتاب کردن» را هم میدهد.
اگر پیامی را در یک بطری به دریا بیندازید، انتظار ندارید که بلافاصله پاسخی بگیرید، یا شاید هرگز.
call استفاده کنم یا cast؟تقریباً همیشه از call استفاده کنید، حتی اگر کد کلاینت شما به پاسخ سرور نیازی نداشته باشد.
استفاده از call منتظر پاسخ میماند، که به عنوان مکانیزمی برای فشار برگشتی عمل میکند (تا مانع فرستادن پیامهای بیش از حد از سوی کلاینتها بهطور همزمان شود). دریافت پاسخ از سرور هم تنها راه اطمینان از این است که سرور پیام کلاینت را دریافت و پردازش کرده است.
handle_info/2پیامها میتوانند به روشهایی جز فراخوانی GenServer.call/2 یا GenServer.cast/2 هم به صندوق پیام سرور برسند، مثلاً با فراخوانی تابع سادهی send/2.
برای رسیدگی به چنین پیامهایی، از کالبک handle_info/2 استفاده کنید. این کالبک دقیقاً به همان روش handle_cast/2 کار میکند.
«رفتار» GenServer یک پیادهسازی جامع از handle_info/2 فراهم میکند که خطاهای مربوط به پیامهای غیرمنتظره را ثبت میکند. اگر آن پیادهسازی پیشفرض را بازنویسی میکنید، حتماً پیادهسازی جامع خودتان را هم بگنجانید. اگر فراموش کنید، سرور در صورت دریافت یک پیام غیرمنتظره از کار میافتد.
مقدار بازگشتی هر یک از چهار کالبک توصیفشده در بالا را میتوان با یک عنصر دیگر تاپل، یعنی یک «مهلت»، گسترش داد. برای مثال، بهجای برگرداندن {:ok, state} از init/1، {:ok, state, timeout} را برگردانید.
از مهلت میتوان برای تشخیص نبود پیام در صندوق پیام در یک بازهی زمانی مشخص استفاده کرد. اگر سرور از یکی از کالبکهایش یک مهلت برگرداند و تعداد میلیثانیهی مشخصشده بدون رسیدن هیچ پیامی بگذرد، handle_info/2 با :timeout به عنوان آرگومان اول فراخوانی میشود.
دستگاه سادهی نوبتدهی خیلی خوب فروش میرفت، اما بعضی کاربران از کمبود قابلیتهای پیشرفتهاش در مقایسه با مدلهای دیگری که در بازار موجود بود شکایت داشتند.
سازنده به بازخورد کاربران گوش داد و تصمیم گرفت مدل دلوکسی با قابلیتهای بیشتر منتشر کند و یک بار دیگر وظیفهی نوشتن نرمافزار این دستگاه به شما سپرده شد.
قابلیتهای جدیدی که به مدل دلوکس اضافه شدهاند عبارتاند از:
منطق کسبوکار دستگاه را پیشتر همکارتان پیادهسازی کرده و در ماژول TakeANumberDeluxe.State قابل مشاهده است. اکنون وظیفهی شما این است که آن را درون یک GenServer بگنجانید.
از رفتار GenServer در ماژول TakeANumberDeluxe استفاده کنید.
تابع start_link/1 و کالبک لازمِ GenServer را پیادهسازی کنید.
آرگومانی که به start_link/1 داده میشود یک لیست کلیدواژه است. این لیست شامل کلیدهای :min_number و :max_number است. مقادیر این کلیدها باید به تابع TakeANumberDeluxe.State.new/2 داده شوند.
اگر TakeANumberDeluxe.State.new/2 یک تاپل {:ok, state} برگرداند، دستگاه باید راهاندازی شود و حالت بازگشتی را بهعنوان حالت خود در نظر بگیرد. اما اگر در عوض یک تاپل {:error, error} برگرداند، دستگاه باید متوقف شود و خطای بازگشتی را دلیل توقف قرار دهد.
TakeANumberDeluxe.start_link(min_number: 1, max_number: 9)
# => {:ok, #PID<0.174.0>}
TakeANumberDeluxe.start_link(min_number: 9, max_number: 1)
# => {:error, :invalid_configuration}
شاید متوجه شده باشید که تابع TakeANumberDeluxe.State.new/2 یک آرگومان اختیاری سوم هم به اسم auto_shutdown_timeout میگیرد. ما در آخرین مرحلهی این تمرین از آن استفاده خواهیم کرد.
تابع report_state/1 و کالبک لازمِ GenServer را پیادهسازی کنید. دستگاه باید حالت فعلی خود را به فراخواننده پاسخ دهد.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.report_state(machine)
# => %TakeANumberDeluxe.State{
# max_number: 10,
# min_number: 1,
# queue: %TakeANumberDeluxe.Queue{in: [], out: []},
# auto_shutdown_timeout: :infinity,
# }
تابع queue_new_number/1 و کالبک لازمِ GenServer را پیادهسازی کنید.
باید تابع TakeANumberDeluxe.State.queue_new_number/1 را با حالت فعلی دستگاه فراخوانی کند.
اگر TakeANumberDeluxe.State.queue_new_number/1 یک تاپل {:ok, new_number, new_state} برگرداند، دستگاه باید با شمارهی جدید به فراخواننده پاسخ دهد و حالت جدید را بهعنوان حالت خود تنظیم کند. اما اگر در عوض یک تاپل {:error, error} برگرداند، دستگاه باید با خطا به فراخواننده پاسخ دهد و حالتش را تغییر ندهد.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 2)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 2}
TakeANumberDeluxe.queue_new_number(machine)
# => {:error, :all_possible_numbers_are_in_use}
تابع serve_next_queued_number/2 و کالبک لازمِ GenServer را پیادهسازی کنید.
باید تابع TakeANumberDeluxe.State.serve_next_queued_number/2 را با حالت فعلی دستگاه و آرگومان اختیاری دومش، priority_number، فراخوانی کند.
اگر TakeANumberDeluxe.State.serve_next_queued_number/2 یک تاپل {:ok, next_number, new_state} برگرداند، دستگاه باید با شمارهی بعدی به فراخواننده پاسخ دهد و حالت جدید را بهعنوان حالت خود تنظیم کند. اما اگر در عوض یک تاپل {:error, error} برگرداند، دستگاه باید با خطا به فراخواننده پاسخ دهد و حالتش را تغییر ندهد.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:error, :empty_queue}
تابع reset_state/1 و کالبک لازمِ GenServer را پیادهسازی کنید.
باید تابع TakeANumberDeluxe.State.new/2 را فراخوانی کند تا با استفاده از min_number و max_number حالت فعلی، حالت جدیدی بسازد. دستگاه باید حالت جدید را بهعنوان حالت خود تنظیم کند. نباید به فراخواننده پاسخی بدهد.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
راهاندازی دستگاه را تغییر دهید. باید مقدار کلید :auto_shutdown_timeout را در لیست کلیدواژهای که بهعنوان init_arg داده میشود بخواند و آن را بهعنوان آرگومان سوم به TakeANumberDeluxe.State.new/3 بدهد. اگر :auto_shutdown_timeout داده نشده باشد، از مقدار پیشفرض :infinity استفاده کنید.
بازنشانی حالت دستگاه را طوری تغییر دهید که auto_shutdown_timeout را هم به TakeANumberDeluxe.State.new/3 بدهد.
مقادیر بازگشتی همهی کالبکهای پیادهسازیشده (init/1 و همهی کالبکهای handle_*) را طوری تغییر دهید که یک مهلت زمانی تعیین کنند. از مقدار کلید :auto_shutdown_timeout در حالت فعلی دستگاه استفاده کنید. مهلت زمانی را به مقدار بازگشتی {:stop, reason} مربوط به init/1 اضافه نکنید؛ مهلتهای زمانی فقط پس از آن اعمال میشوند که سرور حلقهی دریافت خود را آغاز کرده باشد.
یک کالبک GenServer پیادهسازی کنید تا پیام :timeout را مدیریت کند؛ این پیام اگر دستگاه در بازهی مهلت دادهشده هیچ پیام دیگری دریافت نکند، برایش فرستاده میشود. باید فرایند را با دلیل :normal پایان دهد.
مطمئن شوید که پیامهای غیرمنتظره را هم با نادیده گرفتنشان مدیریت میکنید.
{:ok, machine} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 10,
auto_shutdown_timeout: :timer.hours(2)
)
# after 3 hours...
TakeANumberDeluxe.queue_new_number(machine)
# => ** (exit) exited in: GenServer.call(#PID<0.171.0>, :queue_new_number, 5000)
# ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
# (elixir 1.13.0) lib/gen_server.ex:1030: GenServer.call/3