مسیرها
/
Elixir
Elixir
/
تمرین‌ها
/
شماره‌ی نوبت دلوکس
شماره‌ی نوبت دلوکس

شماره‌ی نوبت دلوکس

تمرین یادگیری

مقدمه

GenServer

GenServer (سرور عمومی) یک «رفتار» است که تعاملات رایج کلاینت-سرور میان فرایندهای Elixir را انتزاع می‌کند.

«حلقه‌ی دریافت» را از زمانی که درباره‌ی فرایندها یاد گرفتیم به خاطر دارید؟ «رفتار» GenServer انتزاع‌هایی برای پیاده‌سازی چنین حلقه‌هایی فراهم می‌کند و امکان تبادل پیام با فرایندی را می‌دهد که چنین حلقه‌ای را اجرا می‌کند. این کار نگه‌داری وضعیت و اجرای کد ناهمگام را آسان‌تر می‌کند.

Note

آگاه باشید که نام 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 مسئول رسیدگی به پیام‌های همگام و پاسخ دادن به آن‌هاست. سه آرگومان دریافت می‌کند:

  1. message: مقداری که به عنوان آرگومان دوم به GenServer.call/2 فرستاده شده است.
  2. from: pid فرایندی که GenServer.call/2 را فراخوانی کرده است. در بیشتر موارد می‌توان این آرگومان را نادیده گرفت.
  3. state: وضعیت فعلی سرور. یادتان باشد که مقدار اولیه‌ی آن در کال‌بک init/1 تعیین شده بود.

کال‌بک handle_call/3 معمولاً یک تاپل ۳تایی به شکل {:reply, reply, state} برمی‌گرداند. یعنی عنصر دوم تاپل، یعنی reply که می‌تواند از هر نوعی باشد، به فراخواننده بازگردانده می‌شود. عنصر سوم تاپل، یعنی state، وضعیت جدید سرور پس از رسیدگی به این پیام است.

امکانات پیشرفته‌ی دیگری هم هست که فعلاً به آن‌ها نمی‌پردازیم.

Note

برای این‌که از روی اسمش به خاطر بسپارید که این کال‌بک چه‌کار می‌کند، آن را مانند «تماس گرفتن» با کسی از طریق تلفن در نظر بگیرید.

اگر آن شخص در دسترس باشد، بلافاصله (به‌صورت همگام) پاسخ می‌گیرید.

handle_cast/2

پیامی را که به پاسخ نیاز ندارد می‌توان با GenServer.cast/2 به یک فرایند سرور فرستاد. آرگومان‌هایش با آرگومان‌های GenServer.call/2 یکسان است.

کال‌بک handle_cast/2 مسئول رسیدگی به این پیام‌هاست. دو آرگومان دریافت می‌کند، message و state، که همان آرگومان‌های کال‌بک handle_call/3 هستند (به‌جز from).

کال‌بک handle_cast/2 معمولاً یک تاپل ۲تایی به شکل {:noreply, state} برمی‌گرداند.

امکانات پیشرفته‌ی دیگری هم هست که فعلاً به آن‌ها نمی‌پردازیم.

Note

برای این‌که از روی اسمش به خاطر بسپارید که این کال‌بک چه‌کار می‌کند، یادتان باشد که «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 بگنجانید.

1. راه‌اندازی دستگاه

از رفتار 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 می‌گیرد. ما در آخرین مرحله‌ی این تمرین از آن استفاده خواهیم کرد.

2. گزارش حالت دستگاه

تابع 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,
#    }

3. قرار دادن شماره‌های جدید در صف

تابع 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}

4. ارائه‌ی شماره‌ی بعدی صف

تابع 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}

5. بازنشانی حالت

تابع 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

6. پیاده‌سازی خاموش‌شدن خودکار

راه‌اندازی دستگاه را تغییر دهید. باید مقدار کلید :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
ویرایش از طریق GitHub این لینک در پنجره یا زبانه‌ی جدیدی باز می‌شود
Elixir Exercism

آماده‌اید شماره‌ی نوبت دلوکس را شروع کنید؟

در Exercism ثبت‌نام کنید تا Elixir را همراه با 58 مفهوم168 تمرین و مربی‌گری انسانی واقعی یاد بگیرید و در آن استاد شوید، همه‌ی این‌ها رایگان.