فهم تطبيقات OTP في Elixir، الجزء 4: استبدال الوحدات أثناء التشغيل
أستعرض في هذه السلسلة القصيرة المبادئ الأساسية للغتي Erlang وElixir واحدًا تلو الآخر. وفي هذا المقال أناقش قدرة Elixir على استبدال الكود أثناء التشغيل (hot-swap) في بيئات الإنتاج

ناقشت في المقال السابق كيف تستحق Elixir وصف لغة الوقت الفعلي المرن بفضل إدارتها الموثوقة للعمليات. والآن سنلقي نظرة على كيف تعيد Elixir تحميل الكود أثناء التشغيل (hot code reload) على مستوى الوحدات المنفردة.
ما الاستبدال أثناء التشغيل؟
الاستبدال أثناء التشغيل (hot-swapping) ميزة تتيح تغيير الكود من دون إيقاف النظام. وهذا يسمح للنظام بمواصلة خدمة الطلبات بينما نحدّث الكود. وهو مفيد إذا كنا نحتاج إلى أنظمة عالية التوفر. تتيح لنا هذه الآلية إصلاح الأخطاء البرمجية فورًا، بل وإضافة وظائف جديدة، من دون الخروج عن الخدمة.
كيف يعمل الاستبدال أثناء التشغيل؟
صُمّمت Erlang بطريقة تسمح بتشغيل عدة إصدارات من الوحدة نفسها في الوقت ذاته. وهذا يتيح لنا تحميل إصدارات جديدة من الوحدات إلى النظام بينما لا تزال الإصدارات القديمة تعمل. يمكن للآلة الافتراضية تشغيل إصدارين من الوحدة في آن واحد، وهذا يمنح مرونة كبيرة. وحين تُحمَّل إصدارات جديدة من وحدة ما، تواصل العمليات الحالية عملها، إلا إذا كان هناك إصداران من الوحدة قيد الاستخدام بالفعل، وفي هذه الحالة تُنهى العمليات التي تشغّل الإصدار الأقدم عند بلوغ الحد. أما العمليات المُنشأة حديثًا فتحمّل الإصدار الجديد من الكود. حين يُحمَّل إصدار جديد من وحدة ما، تُوجَّه جميع الاستدعاءات البعيدة (remote calls) إلى الإصدار الجديد. لكن الاستدعاءات المحلية (local calls) في العمليات الجارية تواصل استخدام الدوال القديمة. يمكننا رؤية ذلك عند تشغيل وحدة في iex وإعادة تحميلها:
defmodule HelloWorld do
def hello(name) do
IO.puts("Hi, #{name}")
:timer.sleep(1000)
hello(name)
end
end
<pre>iex(1)> spawn(HelloWorld, :hello, ["Joe"])
#PID<0.119.0>
Hi, Joe
Hi, Joe
Hi, Joe
والآن إذا حدّثنا الوحدة، سنرى أن العملية الحالية تواصل استخدام الكود القديم:
defmodule HelloWorld do
def hello(name) do
IO.puts("Hello, #{name}")
:timer.sleep(1000)
hello(name)
end
end
يمكننا استخدام r/1 لإعادة تحميل الوحدة في iEX.
Hi, Joe
Hi, Joe
Hi, Joe
iex(2)> r(HelloWorld)
iex(3)> r HelloWorld
warning: redefining module HelloWorld (current version loaded from _build/dev/lib/elixir_playground/ebin/Elixir.HelloWorld.beam)
lib/hello_world.ex:1
Hi, Joe
iex(3)> spawn(HelloWorld, :hello, ["Joe"])
#PID<0.125.0>
Hello, Joe
Hi, Joe
Hello, Joe
كما ترون، تعمل العمليتان كلتاهما، لكن كل واحدة تستخدم إصدارًا مختلفًا من الوحدة. فماذا يحدث إذا أضفنا إصدارًا ثالثًا بينما لا يزال الإصدار القديم يعمل؟
defmodule HelloWorld do
def hello(name) do
IO.puts("Goodbye, #{name}")
:timer.sleep(1000)
hello(name)
end
end
iex(5)> r HelloWorld
warning: redefining module HelloWorld (current version defined in memory)
lib/hello_world.ex:1
Hello, Joe
Hello, Joe
Hello, Joe
Hello, Joe
Hello, Joe
Hello, Joe
Hello, Joe
فماذا حدث؟ لماذا لم تعد لدينا سوى عملية واحدة تعمل؟ صُمّمت آلة Erlang الافتراضية لتشغّل إصدارين من الكود كحد أقصى في الوقت نفسه، لذا أُزيل الإصدار الأقدم وانهارت العملية.
لنلقِ نظرة على استبدال وحدة GenServer أثناء التشغيل
بسبب طبيعة GenServer، من المهم ملاحظة أن إعادة التحميل أثناء التشغيل تتم بطريقة مختلفة قليلًا. فكما ذكرنا سابقًا، تُستبدل جميع الاستدعاءات البعيدة لوحدة ما بمجرد تحميل إصدار جديد منها في الآلة الافتراضية. والوحدات التي تستخدم GenServer تخزّن الحالة وتحدّثها فعليًا في عمليات منفصلة. ينشئ GenServer حلقة تخزّن الحالة ثم تستدعي دوال الاستدعاء الراجع (callbacks) في وحدتنا. وهذا يعني أن جميع الاستدعاءات لوحدتنا استدعاءات بعيدة. وكما تعلّمنا سابقًا، تُستبدل الاستدعاءات البعيدة بمجرد إعادة تحميل الوحدات. وتشمل وظائف GenServer ميزات متعددة مثل إدارة الحالة ومعالجة الرسائل غير المتزامنة. لنرَ ذلك عمليًا. defmodule HelloWorld do use GenServer
defmodule HelloWorld do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, "Joe")
end
def init(state) do
Process.send_after(self(), :hello, 1000)
{:ok, state}
end
def handle_info(:hello, name) do
IO.puts("Hi, #{name}")
Process.send_after(self(), :hello, 1000)
{:noreply, name}
end
end
نستخدم هنا مطابقة الأنماط (pattern matching) في دالة handle_info لالتقاط استدعاءات Process.send_after/3. يتصرف هذا الكود بالطريقة نفسها التي تتصرف بها وحدة HelloWorld الأصلية. والآن إذا استدعينا HelloWorld.start_link/0 ثم حدّثنا HelloWorld وأعدنا تحميلها، سنرى كيف يتصرف GenServer:
iex(1)> HelloWorld.start_link
{:ok, #PID<0.154.0>}
Hi, Joe
Hi, Joe
Hi, Joe
defmodule HelloWorld do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, "Joe")
end
def init(state) do
Process.send_after(self(), :hello, 1000)
{:ok, state}
end
def handle_info(:hello, name) do
IO.puts("Hello, #{name}")
Process.send_after(self(), :hello, 1000)
{:noreply, name}
end
end
iex(2)> r HelloWorld
warning: redefining module HelloWorld (current version loaded from _build/dev/lib/elixir_playground/ebin/Elixir.HelloWorld.beam)
lib/hello_world.ex:1
{:reloaded, HelloWorld, [HelloWorld]}
Hello, Joe
Hello, Joe
كما نرى، أُعيد تحميل الوحدة وتغيّر الناتج فورًا. ويعود ذلك إلى أن GenServer يستدعي handle_info على الوحدة، فيصبح الاستدعاء استدعاءً بعيدًا.
التعامل مع تغيّرات الحالة
في مثالنا لا ندير أي حالة فعليًا. نمرر اسمي، لكنه ليس حالة حقيقية، فقد استخدمناه لأغراض التوضيح فقط. لنتخيل أننا نحوّل خريطة (map) معقدة مليئة بالبيانات المتداخلة. في كل مرة تعيدون فيها تحميل الكود، عليكم التأكد من أن الحالة تُدار بشكل صحيح عند الانتقال من الإصدار القديم إلى الجديد. يوفر GenServer دالة استدعاء راجع يمكننا تطبيقها لتعديل الحالة عند تغيّر الكود. تستقبل code_change/3 ثلاثة وسائط: الإصدار القديم من الوحدة، والحالة الحالية، ومعلومات إضافية لازمة لإجراء التغيير. وهذه الدالة مفيدة حين تحتاجون إلى ترحيل الحالة لتتوافق مع الوحدة الجديدة، مثلًا:
defmodule EnvState do
change_code(_old_vsn, state, _extra) do
status = state[:stored_status]
errors = state[:repo][:errors]
{:ok, %{status: status, errors: errors}
end
end
فوائد الاستبدال أثناء التشغيل في Elixir
يوفر الاستبدال أثناء التشغيل في Elixir مزايا كبيرة، خاصة للأنظمة التي تتطلب توفرًا عاليًا وأقل قدر ممكن من التوقف. فحين تتمكنون من تحديث الكود من دون إيقاف النظام، تضمنون استمرار تقديم الخدمة. وهذا مفيد بشكل خاص للتطبيقات التي يجب أن تكون متاحة 24/7، مثل الأنظمة المالية والاتصالات ومنصات التجارة الإلكترونية.
وبفضل آلة Erlang الافتراضية التي تدعم تشغيل عدة إصدارات من الوحدة في آن واحد، تستطيع Elixir الانتقال بسلاسة بين الكود القديم والجديد. هذه المرونة تقلل خطر التوقف وانقطاع الخدمة، وتسهّل نشر إصلاحات الأخطاء والميزات الجديدة فورًا. كما يعزز الاستبدال أثناء التشغيل مرونة النظام وصموده، لأنه يتيح للمطورين معالجة المشكلات بسرعة من دون انتظار نوافذ الصيانة المجدولة.
ودمج الاستبدال أثناء التشغيل في تطبيقات Elixir لا يحسّن العمليات التشغيلية فحسب
الخلاصة ألقينا نظرة سريعة على كيف تتعامل Elixir مع الاستبدال أثناء التشغيل. هناك تفاصيل أخرى كثيرة تستحق البحث، لكنها خارج نطاق هذه السلسلة. إن كنتم مهتمين باستبدال الكود أثناء التشغيل في بيئات الإنتاج لديكم، فأنصحكم بالاطلاع على الترقيات أثناء التشغيل (hot upgrades) باستخدام distillery. تبسّط distillery عملية إنشاء الإصدارات (releases) وتساعد على استبدال الوحدات القديمة.
سنستعرض في المقال القادم ملخصًا لما سبق، ونرى كيف تعمل هذه الأجزاء كلها معًا لتقديم أنظمة عالية التوفر.


