Take-A-Number Deluxe

Take-A-Number Deluxe

تمرين تعلّمي

مقدمة

GenServer

‏GenServer (الخادم العام) هو سلوك يوفّر تجريدًا للتفاعلات الشائعة بين العميل والخادم بين عمليات Elixir.

أتذكر حلقة الاستقبال من حين تعلّمنا عن العمليات؟ يوفّر سلوك GenServer تجريدات لتنفيذ حلقات كهذه، ولتبادل الرسائل مع عملية تشغّل حلقة كهذه. وهو يسهّل الحفاظ على الحالة وتنفيذ كود لاتزامني.

Note

احترس، فاسم GenServer محمّل بمعانٍ. فهو يُستخدم أيضًا لوصف وحدة تستخدم سلوك GenServer، وكذلك لوصف عملية بدأت من وحدة تستخدم سلوك GenServer.

يعرّف سلوك GenServer دالة رد نداء واحدة مطلوبة، هي init/1، وعددًا قليلًا من دوال رد النداء الاختيارية المثيرة للاهتمام: handle_call/3 وhandle_cast/2 وhandle_info/3. وليس من المفترض أن تستدعي العملاء التي تستخدم GenServer دوال رد النداء تلك مباشرة. وبدلًا من ذلك، توفّر وحدة GenServer دوال يمكن للعملاء استخدامها للتواصل مع عملية GenServer.

غالبًا ما تعرّف وحدة واحدة كلا الأمرين: واجهة 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

لتتذكر ما تفعله دالة رد النداء هذه من اسمها، تذكّر أن كلمة «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. استخدم القيمة الافتراضية :infinity إذا لم يُعطَ :auto_shutdown_timeout.

عدّل إعادة ضبط حالة الآلة لتمرّر أيضًا 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

مستعد لبدء Take-A-Number Deluxe؟

سجّل في Exercism لتتعلّم وتتقن Elixir عبر 58 مفهومًا168 تمرينًا، وإرشاد بشري حقيقي، وكل ذلك مجانًا.