فهم تطبيقات OTP في Elixir، الجزء 1: التوزيع
أحب Elixir. بحكم قدومي من Ruby، أجد نفسي في أرض مألوفة من حيث الصياغة، وأستمتع في الوقت نفسه ببعض الأمور التي تنفّذها Elixir بطريقة مختلفة. أكتب سلسلة عن مبادئ Elixir وErlang وكيف تعمل من الداخل. يتناول هذا المقال الأنظمة الموزعة والعمليات.

بُنيت Elixir فوق Erlang. وErlang لغة طُوّرت في شركة الاتصالات Ericsson، التي عُرفت لاحقًا باسم Sony Ericsson. حين صمّم المهندسون الأوائل Open Telecom Protocol الخاص بـ Erlang، كانت لديهم تصورات عن الخصائص التي يحتاجها النظام ليتعامل مع بنية الاتصالات لديهم بموثوقية. صُمّمت OTP لتكون موزّعة، ومتحمّلة للأعطال، وتعمل في الوقت الفعلي، وعالية التوفر، وقابلة للاستبدال أثناء التشغيل (hot-swappable). هذا كثير على الاستيعاب، فماذا يعني كل ذلك؟ سأشرح في هذا المقال كيف يعمل الجانب الموزّع من هذه المنظومة. مفاهيم Erlang وElixir متبادلة إلى حد كبير. فكثير من الوحدات (modules) في Elixir هي تجريدات فوق تجريدات Erlang، لذا يمكنكم اعتبارها المفهوم نفسه. سأناقش في هذا المقال كيف تعمل Processes وGenServer في Elixir، وهما في صميم أنظمتها الموزعة.
التواصل مع العمليات الموزعة عبر صناديق البريد
صُمّمت OTP لتعمل على الشبكات الموزعة. وهي تحقق ذلك عبر نظام صناديق البريد (mailbox). يمكن لأي عملية (Process) في Elixir أن تُنشئ عمليات فرعية، وهذه العمليات لا تتشارك أي ذاكرة، لذا فهي معزولة تمامًا. وللتواصل فيما بينها، ترسل الرسائل إلى صندوق بريد العملية الذي يُعرَّف بمعرّف العملية (PID). يمكن لكل عملية أن ترسل رسائل إلى عمليات أخرى، وأن تضبط مستمعًا على صندوق بريدها لمعالجة الرسائل الواردة. لنبدأ بإنشاء عملية بسيطة تعيد رسالة إلى صندوق بريد العملية الأم:
parent = self()
spawn fn -> send(parent, {:ok, self()}) end
Process.info(parent, :messages)
=> {:messages, [ok: #PID<0.000.0>]}
أولًا نحصل على PID العملية الحالية لنستخدمه داخل الدالة المجهولة التي نمررها إلى spawn. وداخل الدالة المُنشأة، نستدعي send/3. الوسيط الأول هو العملية التي نرسل إليها الرسالة، والثاني هو الرسالة نفسها. في هذه الحالة نرسل tuple يحتوي على :ok وPID العملية المُنشأة. يمكن أن تكون الرسالة بأي صيغة تريدونها، ومن الشائع في Elixir استخدام tuples حتى نطابق العنصر الأول ونعرف كيف نعالج العنصر الثاني. يمكننا فحص الرسائل باستخدام Process.info/2، وسنرى أن رسالتنا قد عادت بالفعل. والآن لنعالج هذه الرسائل:
parent = self()
spawn fn -> send(parent, {:ok, self()}) end
receive do
{:ok, pid} -> "Got ok from #{inspect pid}"
_ -> "Unknown message type"
end
تتيح لنا الدالة receive/1 إنشاء مستمع ينتظر وصول رسالة إلى صندوق بريد العملية الحالية. وداخل كتلة receive يمكننا مطابقة نوع الرسالة التي استقبلناها (pattern matching). إن بدت لكم صياغة receive مربكة، فذلك لأنها macro من نوع SpecialForm. حسنًا، لننشئ الآن حلقة تكرار ونطلق عملية مرتبطة (linked)، حتى نضمن ألا تنتهي عمليتنا ببساطة. بهذا الإعداد البسيط يمكننا تخزين الحالة (state)، أو في هذا المثال البسيط مجرد إعادة رسالة تحاكي روبوت محادثة بدائيًا.
defmodule Bot do
def start_link do
Task.start_link(fn -> loop() end)
end
defp loop() do
receive do
{:help, caller} ->
send caller, "You can ask me what today's date is by passing :date_today"
loop()
{:date_today, caller} ->
send caller, "Today's date is #{Date.utc_today()}"
loop()
end
end
end
{:ok, pid} = Bot.start_link
send pid, {:help, self()}
flush()
=> "You can ask me what today's date is by passing :date_today"
=> :ok
send pid, {:date_today, self()}
=> "Today's date is 2020-11-20"
=> :ok
لاحظوا كيف تعيد المهمة المُنشأة تشغيل الحلقة في كل مرة تُعالَج فيها رسالة داخل receive. هذا هو الأساس الذي يقوم عليه GenServer.
فهم واجهة GenServer
حين بنى المطورون مكتبات OTP، أدركوا أن هذا النمط سيكون شائعًا، فأنشأوا تجريدًا يتولى عنا الحلقة ومعالجة الرسائل. يمكن للوحدة التي تطبّق GenServer أن تطبّق عددًا من دوال الاستدعاء الراجع (callbacks). سنستخدم init/1 وhandle_cast/2 وhandle_call/3. بعد ذلك يمكننا استخدام GenServer لبدء عملية تعمل في حلقة، وتنتظر cast/2 أو call/3. وحين تستقبل العملية cast/2 أو call/3، تستدعي الدالتين handle_cast/2 وhandle_call/3 على التوالي. لننشئ مخزنًا للمفاتيح والقيم (key-value store)، نسخة مبسطة من Redis أو Memcached. ولتسهيل الاستخدام وتحسين قابلية القراءة، سنضيف الدوال start/1 وset/3 وget/2 وdelete/2 لتغليف استدعاءات GenServer.cast/2 وGenServer.call/3
defmodule KeyValueStore do
use GenServer
def start(initial_state \\ %{}) do
GenServer.start(__MODULE__, initial_state)
end
def set(server, key, value) do
GenServer.cast(server, {:set, key, value})
end
def get(server, key) do
GenServer.call(server, {:get, key})
end
def delete(server, key) do
GenServer.cast(server, {:delete, key})
end
def init(state) do
{:ok, state}
end
def handle_cast({:set, key, value}, state) do
{:noreply, Map.put(state, key, value)}
end
def handle_cast({:delete, key}, state) do
{:noreply, Map.delete(state, key)}
end
def handle_call({:get, key}, _from, state) do
value = case Map.fetch(state, key) do
{:ok, v} -> v
:error -> nil
end
{:reply, value, state}
end
end
الآن يمكننا تشغيل KeyValueStore، وتمرير أزواج المفاتيح والقيم إليه، واسترجاعها لاحقًا.
{:ok, pid} = KeyValueStore.start
Process.register(pid, :primary_store)
:primary_store
:primary_key_value_store |> KeyValueStore.set(:foo, :bar)
=> :ok
:primary_store
|> KeyValueStore.get(:foo)
=> :bar
:primary_store
|> KeyValueStore.delete(:foo)
=> :ok
:primary_store
|> KeyValueStore.get(:foo)
=> nil
ما التالي؟
سأشرح في المقال القادم كيف تتعامل Elixir مع تحمّل الأعطال عبر المشرفين (supervisors).
هل تحتاجون إلى مساعدة؟
هل لديكم مشروع Elixir تحتاجون فيه إلى مساعدة؟ تواصلوا معنا لنرى كيف يمكننا مساعدتكم.


