مقدمة إلى OTP في Elixir
في هذا الجزء من سلسلتنا التمهيدية عن Elixir، نحوّل خادم FizzBuzz الذي بنيناه في المرة السابقة إلى تطبيق OTP.

ما هو OTP؟
بحسب التوثيق الرسمي، فإن OTP، أي منصة الاتصالات المفتوحة (Open Telecom Platform)، هي "بيئة تطوير متكاملة للبرمجة المتزامنة". وتضم مترجمًا ومفسّرًا للغة Erlang، وخادم قاعدة بيانات (Mnesia)، وأداة تحليل (Dyalizer)، إضافة إلى عدد كبير من المكتبات. وهذه المكتبات تحديدًا هي ما يقصده الناس عادة عندما يتحدثون عن OTP.
السلوكيات (Behaviours)
من أهم مبادئ التصميم في Erlang/OTP أنماط التطبيقات، أو ما يُسمّى "behaviours" بلغة OTP. تحدد هذه الأنماط تطبيقات عامة للمهام الشائعة، مثل وحدة الخادم العام (gen_server). يضيف مطوّر التطبيق الكود الخاص به في وحدة callback تصدّر مجموعة محددة من الدوال. وعلى خلاف Erlang، توفر Elixir تطبيقات افتراضية لهذه الدوال، فلا تحتاجون إلا إلى إعادة تعريف ما يلزمكم منها.
خادم FizzBuzz باستخدام GenServer
يكفي حديثًا عن النظرية، لنكتب بعض الكود!
نستخدم mix لإنشاء مشروع جديد. ولأننا نريد أن يكون اسم الوحدة بصيغة CamelCase، علينا تمرير الخيار module (كما في الأمر أدناه)، وإلا فسيكون الاسم الافتراضي Fizzbuzz.
$ mix new fizzbuzz --module FizzBuzz
lang-bash
ينشئ هذا الأمر قالب تطبيق كاملًا، لكننا في هذا المقال سنعمل على الملف lib/fizzbuzz.ex فقط.
لنبدأ بإخبار التطبيق بأننا نطبّق سلوك GenServer من OTP:
use GenServer
require Logger
استدعينا أيضًا وحدة Logger لأننا نستخدمها لأغراض التتبع وتصحيح الأخطاء. بعد ذلك نعرّف واجهة API الخاصة بالعملاء.
def start_link do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
def get(n) do
GenServer.call(__MODULE__, {:print, n})
end
def print(n) do
GenServer.cast(__MODULE__, {:print, n})
end
الدالة FizzBuzz.start_link/0 مجرد غلاف حول GenServer.start_link/3 التي تشغّل الخادم كعملية مرتبطة (linked process)، وغالبًا ما يكون ذلك ضمن شجرة إشراف (supervision tree). لاحظوا أننا نشغّل الخادم مع الخيار name ونعطيه اسم الوحدة (MODULE). بهذه الطريقة لا تحتاج الدوال الأخرى إلى الإشارة إلى الخادم عبر PID.
الدالتان FizzBuzz.get/1 وFizzBuzz.print/1 هما الواجهة الرئيسية للخادم. تستقبل كل منهما رقمًا وتعيد قيمة FizzBuzz المقابلة له أو تطبعها. يدعم GenServer نوعين من الطلبات: calls وcasts. النوع الأول متزامن ويُفترض أن يعيد شيئًا إلى العميل (دالة get لدينا)، أما الثاني فغير متزامن ولا يعيد بالضرورة أي شيء إلى العميل (دالة print لدينا).
من الشائع تغليف واجهة GenServer بواجهات API خاصة بنا للعملاء، لذا يستحق هذا النمط أن تتذكروه.
بعد ذلك ننتقل إلى تطبيق دوال callback اللازمة في GenServer.
def init(:ok) do
Logger.debug "FizzBuzz server started"
{:ok, %{}}
end
def handle_call({:print, n}, _from, state) do
{:ok, fb, state} = fetch_or_calculate(n, state)
{:reply, fb, state}
end
def handle_cast({:print, n}, state) do
{:ok, fb, state} = fetch_or_calculate(n, state)
IO.puts fb
{:noreply, state}
end
تستدعي GenSever.start_link/3 الدالة init/1، وتعيد هذه الأخيرة tuple بالشكل {:ok, state}. وفي حالتنا هذه تكون الحالة (state) مجرد map بسيطة في Elixir (%{}).
الدالتان handle_call/2 وhandle_cast/2 هما قلب التطبيق. نستخدم pattern matching على الوسيط الأول لنحدد أننا نعالج الرسائل التي تأتي بالشكل {:print, n}. تسترجع الدالة الخاصة fetch_or_calculate/2 القيمة أو تحسبها، ثم نعيد الاستجابة المناسبة. في حالة call تكون الاستجابة بالشكل {:reply, response, state}، أما في حالة cast فتكون {:noreply, state}. هذه أعراف GenServer، ويسرد التوثيق جميع أنواع الردود الممكنة لكل نوع من الطلبات.
هكذا تبدو الدالة المساعدة الخاصة:
defp fetch_or_calculate(n, state) do
if Dict.has_key?(state, n) do
Logger.debug "Fetching #{n}"
{:ok, fb} = Dict.fetch(state, n)
else
Logger.debug "Calculating #{n}"
fb = fizzbuzz(n)
state = Dict.put(state, n, fb)
end
{:ok, fb , state}
end
تتحقق fetch_or_calculate/2 مما إذا كانت قيمة FizzBuzz للرقم n قد حُسبت من قبل. إن كانت كذلك، نجلبها من القاموس. وإن لم تكن، نحسبها ونحدّث الحالة بإضافة القيمة الجديدة إليها (Dict.put(state, n, fb)). وفي النهاية نعيد tuple بالشكل {:ok, value, state} نطابقه في دوال المعالجة.
هذا كل شيء، أصبح خادم FizzBuzz جاهزًا للعمل! وإليكم الكود كاملًا:
defmodule FizzBuzz do
use GenServer
require Logger
## Client API
def start_link do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
def get(n) do
GenServer.call(__MODULE__, {:print, n})
end
def print(n) do
GenServer.cast(__MODULE__, {:print, n})
end
## Server Callbacks
def init(:ok) do
Logger.debug "FizzBuzz server started"
{:ok, %{}}
end
def handle_call({:print, n}, _from, state) do
{:ok, fb, state} = fetch_or_calculate(n, state)
{:reply, fb, state}
end
def handle_cast({:print, n}, state) do
{:ok, fb, state} = fetch_or_calculate(n, state)
IO.puts fb
{:noreply, state}
end
defp fetch_or_calculate(n, state) do
if Dict.has_key?(state, n) do
Logger.debug "Fetching #{n}"
{:ok, fb} = Dict.fetch(state, n)
else
Logger.debug "Calculating #{n}"
fb = fizzbuzz(n)
state = Dict.put(state, n, fb)
end
{:ok, fb , state}
end
defp fizzbuzz(n) do
case {rem(n, 3), rem(n, 5)} do
{0, 0} -> :FizzBuzz
{0, _} -> :Fizz
{_, 0} -> :Buzz
_ -> n
end
end
end
حان وقت استخدام الكود. من داخل مجلد المشروع، شغّلوا جلسة iex في سياق التطبيق بالأمر التالي:
iex -S mix
الآن يمكننا تشغيل الخادم وحساب بعض القيم:
FizzBuzz.start_link
4..6 |> Enum.map(&FizzBuzz.print/1)
هذا هو الناتج الذي يولّده الأمر. وبسبب الطبيعة غير المتزامنة للطلبات، يتداخل الناتج ورسائل السجل والقيمة التي تعيدها الدالة:
4
10:54:58.026 [debug] Calculating 4
Buzz
[:ok, :ok, :ok]
10:54:58.028 [debug] Calculating 5
Fizz
لاحظوا أن ناتج IO.puts ظهر في جلسة iex رغم أن الخادم يعمل في عملية منفصلة، لأن هذه الجلسة هي قائد المجموعة (group leader) الحالي.
وإذا حاولنا الآن جلب قيمة سبق حسابها (FizzBuzz.get(5))، فسنرى أنها تُسترجع فعلًا من الـ cache:
10:58:56.774 [debug] Fetching 5
:Buzz
الخلاصة
استعرضنا في هذا المقال مدى سهولة بناء تطبيقات الخوادم بالاستفادة من OTP. أصبح خادم FizzBuzz الآن جاهزًا للنشر كجزء من تطبيق Elixir أكبر، مع ميزات مثل الإشراف (supervision) والاستبدال الحي للكود (hot code swapping).
مصادر


