Riak and Erlang/OTP
معمارية تطبيقات المصادر المفتوحة (المجلد الأول)Riak وErlang/OTP
معمارية تطبيقات المصادر المفتوحة (المجلد الأول) Riak وErlang/OTP
Francesco Cesarini وAndy Gross وJustin Sheehy
إذا استمتعت بهذه الكتب، فقد يعجبك أيضًا التصميم البرمجي بالأمثلة في Python والتصميم البرمجي بالأمثلة في JavaScript.
Riak قاعدة بيانات موزّعة، متسامحة مع الأعطال، مفتوحة المصدر، توضّح كيفية بناء أنظمة واسعة النطاق باستخدام Erlang/OTP. وإلى حدٍّ كبير بفضل دعم Erlang للأنظمة الموزّعة الشديدة قابلية للتوسّع، تقدّم Riak ميزاتٍ نادرة في قواعد البيانات، مثل التوافرية العالية وقابلية التوسّع الخطية في السعة وفي الإنتاجية (throughput) على السواء.
توفّر Erlang/OTP منصّة مثالية لتطوير أنظمة مثل Riak، لأنها توفّر الاتصال بين العقد، وطوابير الرسائل، وكواشف الأعطال، وتجريدات العميل-الخادم جاهزةً للاستخدام. وإلى جانب ذلك، فإنّ أكثر أنماط Erlang استخدامًا قد نُفِّذت في وحدات مكتبية تُعرف عادةً بـ«سلوكيات OTP» (OTP behaviors). وهي تحوي إطار الشيفرة العامّ للتزامن ومعالجة الأخطاء، فتُبسِّط البرمجة المتزامنة وتحمي المطوّر من كثير من المزالق الشائعة. وتُراقَب السلوكيات بواسطة المشرفين (supervisors) الذين هم بدورهم سلوكٌ بحدّ ذاته، وتُجمَّع معًا في أشجار إشراف (supervision trees). وتُحزَّم شجرة الإشراف داخل تطبيق، ما ينشئ لبنة بناء (building block) لبرنامج Erlang.
نظام Erlang كامل مثل Riak هو مجموعة تطبيقات مترابطة ربطًا ضعيفًا تتفاعل فيما بينها. فبعض هذه التطبيقات كتبه المطوّر، وبعضها جزء من توزيع Erlang/OTP القياسي، وبعضها الآخر قد يكون مكوّنات مفتوحة المصدر أخرى. وتُحمَّل هذه التطبيقات وتُشغَّل بالتسلسل بواسطة سكربت إقلاع (boot script) مُولَّد من قائمة من التطبيقات وإصداراتها.
ما يختلف بين الأنظمة هو التطبيقات التي يتضمّنها الإصدار الذي يُشغَّل. ففي توزيع Erlang القياسي، تشغّل ملفات الإقلاع تطبيقي Kernel وStdLib (المكتبة القياسية). وفي بعض التثبيتات، يُشغَّل أيضًا تطبيق SASL (Systems Architecture Support Library). ويحتوي SASL على أدوات الإصدار وترقية البرمجيات إضافةً إلى إمكانات التسجيل. ولا يختلف Riak عن ذلك، إلا بأنّه يشغّل تطبيقات Riak إضافةً إلى تبعياتها وقت التشغيل، ومنها Kernel وStdLib وSASL. والبناء الكامل الجاهز للتشغيل من Riak يضمّ فعليًا هذه العناصر القياسية من توزيع Erlang/OTP ويشغّلها جميعًا في الوقت نفسه عند استدعاء riak start من سطر الأوامر. وتتألّف Riak من تطبيقات معقّدة كثيرة، لذا لا ينبغي تفسير هذا الفصل بوصفه دليلًا كاملًا، بل ينبغي رؤيته مقدّمةً إلى OTP تستعين بأمثلة من شيفرة مصدر Riak. وقد اختُصرت الأشكال والأمثلة وقُصِّرت لأغراض العرض.
15.1. مقدّمة مختصرة إلى Erlang
Erlang لغة برمجة وظيفية متزامنة تُصرَّف (compile) إلى شيفرة بايتية (byte code) وتعمل داخل آلة افتراضية (virtual machine). وتتكوّن البرامج من دوال تستدعي بعضها بعضًا، ممّا ينتج غالبًا آثارًا جانبية (side effects) مثل تمرير الرسائل بين العمليات وعمليات الإدخال والإخراج (I/O) وعمليات قواعد البيانات. ومتغيّرات Erlang أحادية الإسناد (single assignment)، أي أنّها إذا أُعطيت قيمًا فإنّها لا يمكن تحديثها بعدها. وتستخدم اللغة على نطاقٍ واسع مطابقة الأنماط (pattern matching)، كما يوضّح مثال عامل الضرب (factorial) أدناه:
-module(factorial).
-export([fac/1]).
fac(0) -> 1;
fac(N) when N>0 ->
Prev = fac(N-1),
N*Prev.
هنا يعطي البند (clause) الأول عامل الضرب للصفر، ويعطي الثاني عامل الضرب للأعداد الموجبة. وجسم كل بند هو سلسلة من التعبيرات، والتعبير الأخير في الجسم هو نتيجة ذلك البند. واستدعاء الدالة بعدد سالب سيؤدي إلى خطأ وقت التشغيل (run time error)، لأنّ أياّ من البنود لن تطابق. وعدم معالجة هذه الحالة مثال على البرمجة غير الدفاعية (non-defensive programming)، وهي ممارسة مُشجَّعة في Erlang.
داخل الوحدة (module)، تُستدعى الدوال بالطريقة المعتادة، وخارجها يُسبق اسم الوحدة، كما في factorial:fac(3). ومن الممكن تعريف دوال بالاسم نفسه وبأعداد وسائط مختلفة—ويُسمّى هذا العدد arity الخاص بها. وفي تعليمة التصدير (export directive) في الوحدة factorial، تُدلَّل الدالة fac ذات الـ arity واحد بالقيمة fac/1.
تدعم Erlang الثنائيات (tuples) (وتُسمّى أيضًا أنواع المنتجات) والقوائم. وتُحاط الثنائيات بأقواس معقوفة، كما في {ok,37}. وفي الثنائيات، نصل إلى العناصر بالموضع. والسجلات (records) نوع بيانات آخر؛ فهي تتيح لنا تخزين عدد ثابت من العناصر التي يُشار إليها بعد ذلك ويُتعامل معها بالاسم. ونعرّف سجلًا بالتعبير -record(state, {id, msg_list=[]}). ولإنشاء نسخة منه، نستخدم التعبير Var = #state{id=1}، ونفحص محتوياته باستخدام Var#state.id. ولمتغيّر عدد من العناصر، نستخدم القوائم المعرَّفة بأقواس مربّعة مثل {[}23,34{]}. وتطابق الصيغة {[}X|Xs{]} قائمة غير فارغة رأسُها X وذيلُها Xs. وتشير المعرّفات التي تبدأ بحرف لاتيني صغير إلى الذرّات (atoms)، التي لا تقف في مكانها إلّا هي نفسها؛ وok في الثنائي {ok,37} مثال على ذرّة. وتُستعمل الذرّات بهذه الطريقة غالبًا للتمييز بين أنواع مختلفة لنتائج الدوال: فإلى جانب نتائج ok، قد تكون هناك نتائج من الشكل {error, "Error String"}.
العمليات في أنظمة Erlang تعمل بالتوازي في ذواكر منفصلة، وتتواصل فيما بينها بتمرير الرسائل. ويمكن استخدام العمليات في مجموعة واسعة من التطبيقات، منها البوابات (gateways) نحو قواعد البيانات، والمعالجات (handlers) لمكدّسات البروتوكولات، وإدارة تسجيل رسائل التتبّع (trace messages) الصادرة عن العمليات الأخرى. ورغم أنّ هذه العمليات تعالج طلبات مختلفة، فإنّ هناك أوجه تشابه في كيفية معالجة تلك الطلبات.
ولأنّ العمليات توجد داخل الآلة الافتراضية وحدها، يمكن لآلة افتراضية واحدة أن تشغّل في الوقت نفسه ملايين العمليات، وهي ميزة يستثمرها Riak استثمارًا واسعًا. فمثلًا، يُنمذَج كل طلب على قاعدة البيانات—قراءات وكتابات وحذوف—في عملية منفصلة، وهو نهج يستحيل تنفيذه مع معظم تطبيقات خيوط نظام التشغيل على مستوى النظام.
تُعرَّف العمليات بمعرّفات العمليات (process identifiers) المعروفة بـ PIDs، لكن يمكن أيضًا تسجيلها تحت اسم مستعار (alias)؛ ولا ينبغي استعمال ذلك إلا مع العمليات طويلة العمر «الثابتة». ويسمح تسجيل عملية باسمها المستعار للعمليات الأخرى أن ترسل إليها الرسائل دون معرفة PID الخاص بها. وتُنشأ العمليات باستخدام الدالة المدمجة (built-in function) spawn(Module, Function, Arguments). والدوال المدمجة (BIFs) هي دوال مدمجة في الآلة الافتراضية تُستعمل لتنفيذ ما يستحيل تنفيذه أو يكون بطيئًا في Erlang الخالص. وتأخذ الدالة المدمجة spawn/3 المعاملات Module وFunction وقائمة Arguments. وتُعيد الاستدعاء PID العملية المُنشَأة حديثًا، وكأثر جانبي تُنشئ عملية جديدة تبدأ بتنفيذ الدالة في الوحدة بالمعاملات المذكورة آنفًا.
تُرسَل الرسالة Msg إلى العملية ذات المعرّف Pid باستخدام Pid ! Msg. ويمكن للعملية أن تعرف PID الخاص بها باستدعاء الدالة المدمجة self، ثمّ يُرسَل هذا المعرّف إلى عمليات أخرى لتستخدمه في التواصل مع العملية الأصلية. ولنفترض أنّ عملية تنتظر استقبال رسائل من الشكل {ok, N} و{error, Reason}. ولمعالجتها تستخدم تعليمة استقبال (receive):
receive
{ok, N} ->
N+1;
{error, _} ->
0
end
وتكون نتيجة ذلك عددًا يحدّده البند المطابق للنمط. وحين لا تكون قيمة متغيّر ما مطلوبة في مطابقة النمط، يمكن استعمال الشرطة السفلية كحرف بديل (wild-card) كما في الأعلى.
مرور الرسائل بين العمليات غير متزامن (asynchronous)، وتُوضَع الرسائل التي تستقبلها العملية في صندوق بريدها (mailbox) بالترتيب الذي تصل به. ولنفترض الآن أنّ تعبير receive أعلاه هو المُنفَّذ: إذا كان العنصر الأول في صندوق البريد إمّا {ok, N} أو {error, Reason} فإنّ النتيجة المقابلة ستُعاد. وإذا لم تكن الرسالة الأولى في صندوق البريد من هذا الشكل، فإنّها تبقى في صندوق البريد وتُعالَج الثانية بطريقة مماثلة. وإذا لم تطابق أي رسالة، فسيتلقّى الاستقبال وينتظر وصول رسالة مطابقة.
تنتهي العمليات لسببين. فإن لم يَعُد هناك شيفرة لتنفيذها، فإنّها تقال إنّها تنتهي بسبب normal. وإذا صادفت العملية خطأ وقت التشغيل، فإنّها تقال إنّها تنتهي بسبب غير طبيعي. وإنّ انتهاء عملية لن يؤثّر في العمليات الأخرى إلا إن كانت مرتبطة بها. ويمكن للعملية أن ترتبط بأخرى عبر الدالة المدمجة link(Pid) أو عند استدعاء spawn_link(Module, Function, Arguments). وإذا انتهت عملية، فإنّها ترسل إشارة EXIT إلى العمليات الموجودة في مجموعة ارتباطها (link set). وإذا كان سبب الانتهاء غير طبيعي، فإنّ العملية تنهي نفسها، مُنتِلةً إشارة EXIT إلى ما بعدها. وباستدعاء الدالة المدمجة process_flag(trap_exit, true) يمكن للعمليات أن تتلقّى إشارات EXIT كرسائل Erlang في صناديق بريدها بدلًا من أن تنتهي.
تستخدم Riak إشارات EXIT لمراقبة سلامة عمليات المساعد التي تؤدّي عملًا غير حرج بدأته آلات الحالات المنتهية التي تقود الطلبات. وحين تنتهي عمليات المساعد هذه بشكل غير طبيعي، تتيح إشارة EXIT للأب إمّا تجاهل الخطأ أو إعادة تشغيل العملية.
15.2. هياكل العمليات
أَشرنا سابقًا إلى فكرة أنّ العمليات تتبع نمطًا مشتركًا بصرف النظر عن الغرض الخاص الذي أُنشئت العملية من أجله. وللبدء، يجب إنشاء العملية (spawn) ثمّ تسجيل اسمها المستعار، إن كان ذلك مطلوبًا. وأول إجراء تؤدّيه العملية المُنشَأة حديثًا هو تهيئة بيانات حلقة العملية. وغالبًا ما تكون بيانات الحلقة هي نتيجة المعاملات المُمرَّرة إلى الدالة المدمجة spawn عند تهيئة العملية. وتُخزَّن بيانات حلقة العملية في متغيّر نسمّيه حالة العملية (process state). وتُمرَّر الحالة، التي تُخزَّن غالبًا في سجل، إلى دالة استقبال-وتقييم (receive-evaluate) تُشغّل حلقة تستقبل رسالة، وتعالجها، وتحدّث الحالة، ثمّ تعيدها كمعامل لاستدعاء تعاودي على الذيل (tail-recursive call). وإذا كانت إحدى الرسائل التي يعالجها هي رسالة stop، فإنّ العملية المستلِمة ستنظّف خلفها ثمّ تنتهي.
وهذا موضوع متكرّر في العمليات يظهر بصرف النظر عن المهمة التي أُسندت إلى العملية لتنفيذها. ومع هذا في الأذهان، لننظر إلى الفروق بين العمليات التي تلتزم بهذا النمط:
- تختلف المعاملات المُمرَّرة إلى استدعاءات الدالة المدمجة
spawnمن عملية إلى أخرى. - عليك أن تقرّر ما إذا كان ينبغي تسجيل العملية تحت اسم مستعار، وإن كان الأمر كذلك، فما الاسم المستعار الذي ينبغي استعماله.
- في الدالة التي تهيّئ حالة العملية، تختلف الإجراءات المتَّخذة بحسب المهام التي ستؤديها العملية.
- تُمثَّل حالة النظام ببيانات الحلقة في كل الحالات، لكنّ محتويات بيانات الحلقة تختلف بين العمليات.
- وحين تكون في جسم حلقة الاستقبال-والتقييم، تستقبل العمليات رسائل مختلفة وتعالجها بطرق مختلفة.
- وأخيرًا، عند الانتهاء، يختلف التنظيف من عملية إلى أخرى.
وعليه، حتى لو وُجد هيكل من الإجراءات العامة، فإنّ هذه الإجراءات تُكمَّل بإجراءات خاصة ترتبط مباشرةً بالمهام المسنَدة إلى العملية. وباستخدام هذا الهيكل بوصفه قالبًا، يستطيع المبرمجون إنشاء عمليات Erlang تؤدّي دور الخوادم وآلات الحالات المنتهية ومعالجات الأحداث والمشرفين. لكن بدلًا من إعادة تنفيذ هذه الأنماط في كل مرة، فقد وُضعت في وحدات مكتبية تُسمّى السلوكيات (behaviors). وهي تأتي كجزء من الطبقة الوسيطة (middleware) الخاصة بـ OTP.
15.3. سلوكيات OTP
إنّ فريق المطوّرين الأساسي الذي يضيف المساهمات إلى Riak موزّع على نحو اثنتي عشرة موقعًا جغرافيًّا تقريبًا. ولو لم تكن هناك تنسيقات شديدة الإحكام وقوالب للعمل في ضوءها، لانتهى الأمر إلى مجموعة تطبيقات عميل/خادم مختلفة لا تعالج الحالات الحدّية الخاصة ولا أخطاء التزامن. ومن المرجَّح ألّا تتوفّر هناك طريقة موحّدة للتعامل مع انهيار العميل والخادم أو لضمان أنّ استجابةً لطلب هي بالفعل تلك الاستجابة، وليست مجرّد أي رسالة تطابق بروتوكول الرسائل الداخلي.
OTP مجموعة من مكتبات Erlang ومبادئ تصميم توفّر أدوات جاهزة يمكن بها تطوير أنظمة متينة. وكثير من هذه الأنماط والمكتبات يُقدَّم في شكل «سلوكيات».
تعالج سلوكيات OTP هذه المشكلات بتوفير وحدات مكتبية تطبّق أنماط التصميم المتزامنة الأكثر شيوعًا. وفي الخلفية، دون أن يكون على المطوّر وعيٌ بذلك، تضمن الوحدات المكتبية معالجة الأخطاء والحالات الخاصة معالجةً متّسقة. ونتيجةً لذلك، توفّر سلوكيات OTP مجموعة لبنات بناء معياريّة تُستعمل في تصميم الأنظمة وبنائها بمستوى الصناعي (industrial-grade).
15.3.1. مقدّمة
سلوكيات OTP تُقدَّم كوحدات مكتبية في تطبيق stdlib الذي يأتي جزءًا من توزيع Erlang/OTP. أمّا الشيفرة الخاصّة التي يكتبها المطوّر فتوضع في وحدة منفصلة وتُستدعى عبر مجموعة من دوال الاستدعاء الراجعة (callback) المعرَّفة مسبقًا والمعياريّة لكل سلوك. وسيحتوي وحدة الاستدعاء الراجعة هذه على كامل الشيفرة الخاصّة اللازمة لتقديم الوظيفة المطلوبة.
تتضمّن سلوكيات OTP عمليات العمّال التي تؤدّي المعالجة الفعلية، وكذلك المشرفين مهمّتهم مراقبة العمّال والمشرفين الآخرين. وتشمل سلوكيات العمّال، التي يُشار إليها في المخطّطات عادةً بالدوائر، الخوادم ومعالجات الأحداث وآلات الحالات المنتهية. أمّا المشرفون، فيشار إليهم في الرسوم التوضيحية بالمربّعات، فيراقبون أبنائهم من عمّال ومشرفين آخريين، بما ينشئ ما يُسمّى شجرة إشراف (supervision tree).

الشكل 15.1: شجرة إشراف Riak في OTP
تُحزَّم أشجار الإشراف داخل سلوك يُسمّى تطبيقًا (application). وتطبيقات OTP ليست لبنات بناء أنظمة Erlang فحسب، بل هي أيضًا وسيلة لتغليف مكوّنات قابلة لإعادة الاستعمال. وتتألّف الأنظمة بمستوى الصناعي مثل Riak من مجموعة تطبيقات مترابطة ربطًا ضعيفًا، وموزّعة على الأرجح. وبعض هذه التطبيقات جزء من توزيع Erlang القياسي، وبعضها الآخر القطع التي تشكّل الوظيفة الخاصّة بـ Riak.
ومن أمثلة تطبيقات OTP معرّف كائن Corba (Corba ORB) أو وكيل بروتوكول إدارة الشبكة البسيط (Simple Network Management Protocol, SNMP). وتطبيق OTP هو مكوّن قابل لإعادة الاستعمال يحزم وحدات مكتبية معًا مع عمليات مشرف وعمّال. ومن الآن فصاعدًا، حين نشير إلى تطبيق، فإنّنا نعني تطبيق OTP.
تحتوي وحدات السلوك على كامل الشيفرة العامّة لكل نوع سلوك معطى. ومع أنّه من الممكن تنفيذ وحدة سلوك خاصة بك، فإنّ ذلك نادر، لأنّ الوحدات التي تأتي مع توزيع Erlang/OTP تخدم معظم أنماط التصميم التي ستستعملها في شيفرتك. وتشمل الوظيفة العامّة التي توفّرها وحدة السلوك عمليات مثل:
- إنشاء العملية، وتسجيلها عند الحاجة؛
- إرسال رسائل العميل واستقبالها في صورة استدعاءات متزامنة أو غير متزامنة، بما في ذلك تعريف بروتوكول الرسائل الداخلي؛
- تخزين بيانات الحلقة وإدارة حلقة العملية؛ و
- إيقاف العملية.
بيانات الحلقة هي متغيّر يحتوي على البيانات التي يحتاج السلوك إلى تخزينها بين الاستدعاءات. وبعد الاستدعاء، تُعاد نسخة محدَّثة من بيانات الحلقة. وتُمرَّر بيانات الحلقة المحدَّثة هذه، التي يُشار إليها عادةً بأنّها بيانات الحلقة الجديدة، كمعامل في الاستدعاء التالي. ويُشار إلى بيانات الحلقة أيضًا عادةً بأنّها حالة السلوك.
وتشمل الوظيفة التي ينبغي تضمينها في وحدة الاستدعاء الراجعة لتطبيق الخادم العامّ كي يقدّم السلوك الخاصّ المطلوب ما يلي:
- تهيئة بيانات حلقة العملية، واسم العملية إن كانت العملية مسجَّلة.
- معالجة طلبات العميل الخاصّة، والردود المُرسَلة إلى العميل إن كان الاستدعاء متزامنًا.
- معالجة بيانات حلقة العملية وتحديثها بين طلبات العملية.
- تنظيف بيانات حلقة العملية عند الانتهاء.
15.3.2. الخوادم العامة
الخوادم العامة التي تطبّق سلوكيات العميل/الخادم معرَّفة في سلوك gen_server الذي يأتي كجزء من تطبيق المكتبة القياسية. وفي شرح الخوادم العامة، سنستعمل الوحدة riak_core_node_watcher.erl من تطبيق riak_core. وهي خادم يتتبّع الخدمات الفرعية والعُقد المتوافرة في عنقود Riak ويبلّغ عنها. وترويسات الوحدة وتعليماتها كالتالي:
-module(riak_core_node_watcher).
-behavior(gen_server).
%% API
-export([start_link/0,service_up/2,service_down/1,node_up/0,node_down/0,services/0,
services/1,nodes/1,avsn/0]).
%% gen_server callbacks
-export([init/1,handle_call/3,handle_cast/2,handle_info/2,terminate/2, code_change/3]).
-record(state, {status=up, services=[], peers=[], avsn=0, bcast_tref,
bcast_mod={gen_server, abcast}}).
ويمكن التعرّف بسهولة على الخوادم العامة من خلال تعليمة -behavior(gen_server). وتستعمل هذه التعليمة من قِبل المُصرِّف للتأكّد من تصدير جميع دوال الاستدعاء الراجعة تصديرًا سليمًا. أمّا حالة السجل (record state) فتُستعمل في بيانات حلقة الخادم.
15.3.3. تشغيل خادمك
مع سلوك gen_server، وبدلًا من استعمال الدالتين المدمجتين spawn وspawn_link، ستستعمل دالتي gen_server:start وgen_server:start_link. وأبرز فرق بين spawn وstart هو كون الاستدعاء متزامنًا. فاستعمال start بدلًا من spawn يجعل بدء عملية العامل أكثر حدّية (deterministic) ويمنع حالات التسابق غير المتوقّعة، إذ لا يُعيد الاستدعاء PID للعامل إلّا بعد تهيئته. وتُستدعى الدالتان بواحدة من الصيغتين:
gen_server:start_link(ServerName, CallbackModule, Arguments, Options)
gen_server:start_link(CallbackModule, Arguments, Options)
ServerName ثنائي (tuple) بصيغة {local, Name} أو {global, Name}، يدلّ على اسم محلي Name أو عامّ لاسم العملية المستعار إن كان ستُسجَّل العملية. وتتيح الأسماء العامّة الوصول إلى الخوادم بشفافية عبر عنقود من عُقد Erlang الموزّعة. وإن لم تكن ترغب في تسجيل العملية والإشارة إليها بدلًا من ذلك بواسطة PID، فإنّك تحذف هذا المعامل وتستعمل استدعاء الدالة start_link/3 أو start/3. أمّا CallbackModule فهو اسم الوحدة التي وُضعت فيها دوال الاستدعاء الراجعة الخاصّة، وArguments مصطلح Erlang (term) صالح يُمرَّر إلى دالة الاستدعاء الراجعة init/1، بينما Options قائمة تتيح لك ضبط رايات إدارة الذاكرة fullsweep_after وheapsize، إضافةً إلى رايات التتبّع ووسائل التتبّع الأخرى.
وفي مثالنا، نستدعي start_link/4، مسجِّلين العملية بالاسم نفسه الذي لوحدة الاستدعاء الراجعة، باستعمال استدعاء الصنف (macro) ?MODULE. ويوسّع هذا المعالج (preprocessor) هذا الصنف إلى اسم الوحدة التي عُرِّف فيها عند تصريف الشيفرة. ومن الممارسات الجيدة دائمًا تسمية سلوكك باسم مستعار مطابق لوحدة الاستدعاء الراجعة التي نُفِّذ فيها. ولا نمرّر أي معاملات، ونتيجةً لذلك نكتفي بإرسال القائمة الفارغة. وتُبقي قائمة الخيارات فارغة:
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
والفرق الواضح بين دالتي start_link وstart هو أنّ start_link ترتبط بأبيها، وهو في أغلب الأحوال مشرف، بينما start لا تفعل. وهذا يستدعي ذكرًا خاصًّا، إذ إنّ ربط السلوك بنفسه بالمشرف هو مسؤولية سلوك OTP. وكثيرًا ما تُستعمل دوال start عند اختبار السلوكيات من الصدفة (shell)، لأنّ خطأً في الكتابة يُسقط عملية الصدفة لن يؤثّر في السلوك. وتُعيد جميع صيغ دالتي start وstart_link القيمة {ok, Pid}.
وتُنشئ دالتي start وstart_link عملية جديدة تستدعي دالة الاستدعاء الراجعة init(Arguments) في CallbackModule بالمعاملات Arguments المُمرَّرة. ويجب أن تهيّئ الدالة init المتغيّر LoopData الخاص بالخادم، وأن تُعيد ثنائيًا بصيغة {ok, LoopData}. ويحتوي LoopData على النسخة الأولى من بيانات الحلقة التي ستُمرَّر بين دوال الاستدعاء الراجعة. وإن أردت تخزين بعض المعاملات التي مرّرتها إلى الدالة init، فإنّك تفعل ذلك في المتغيّر LoopData. أمّا LoopData في خادم مراقب العُقد في Riak فهو نتيجة الاستدعاء schedule_broadcast/1 مع سجل من نوع state تكون فيه الحقول مضبوطة على القيم الافتراضية:
init([]) ->
%% Watch for node up/down events
net_kernel:monitor_nodes(true),
%% Setup ETS table to track node status
ets:new(?MODULE, [protected, named_table]),
{ok, schedule_broadcast(#state{})}.
ورغم أنّ عملية المشرف قد تستدعي الدالة start_link/4، فإنّ عملية أخرى هي التي تستدعي دالة الاستدعاء الراجعة init/1، وهي العملية التي أُنشئت للتوّ. وبما أنّ الغرض من هذا الخادم هو ملاحظة توافر الخدمات الفرعية داخل Riak وتسجيله وبثّه، فإنّ التهيئة تطلب من بيئة تشغيل Erlang أن تُشعِره بهذه الأحداث، وتُنشئ جدولًا لتخزين هذه المعلومات فيه. ويجب أن يتمّ هذا أثناء التهيئة، لأنّ أي استدعاءات للخادم ستفشل لو لم تكن تلك البنية موجودة بعد. ولا تفعل إلّا ما هو ضروري، وقلِّل العمليات داخل دالتك init إلى أدنى حدّ، لأنّ الاستدعاء إلى init استدعاء متزامن يمنع جميع العمليات الأخرى المتسلسلة من البدء حتى يعود.
15.3.4. تمرير الرسائل
إن أردت إرسال رسالة متزامنة إلى خادمك، تستعمل الدالة gen_server:call/2. أمّا الاستدعاءات غير المتزامنة فتُجرى بالدالة gen_server:cast/2. ولنبدأ بأخذ دالتين من واجهة خدمات Riak؛ وسنقدّم بقية الشيفرة لاحقًا. وهما تُستدعيان من عملية العميل وتؤدّيان إلى إرسال رسالة متزامنة إلى عملية الخادم المسجَّلة بالاسم نفسه الذي لوحدة الاستدعاء الراجعة. ولاحظ أنّ التحقّق من البيانات المُرسَلة إلى الخادم ينبغي أن يجري في جانب العميل. فإذا أرسل العميل معلومات خاطئة، فينبغي للخادم أن ينتهي.
service_up(Id, Pid) ->
gen_server:call(?MODULE, {service_up, Id, Pid}).
service_down(Id) ->
gen_server:call(?MODULE, {service_down, Id}).
وحين تستقبل الرسائل، تستدعي عملية gen_server دالة الاستدعاء الراجعة handle_call/3 التي تعالج الرسائل بالترتيب نفسه الذي أُرسلت به:
handle_call({service_up, Id, Pid}, _From, State) ->
%% Update the set of active services locally
Services = ordsets:add_element(Id, State#state.services),
S2 = State#state { services = Services },
%% Remove any existing mrefs for this service
delete_service_mref(Id),
%% Setup a monitor for the Pid representing this service
Mref = erlang:monitor(process, Pid),
erlang:put(Mref, Id),
erlang:put(Id, Mref),
%% Update our local ETS table and broadcast
S3 = local_update(S2),
{reply, ok, update_avsn(S3)};
handle_call({service_down, Id}, _From, State) ->
%% Update the set of active services locally
Services = ordsets:del_element(Id, State#state.services),
S2 = State#state { services = Services },
%% Remove any existing mrefs for this service
delete_service_mref(Id),
%% Update local ETS table and broadcast
S3 = local_update(S2),
{reply, ok, update_avsn(S3)};
لاحظ قيمة إعادة دالة الاستدعاء الراجعة. فالثنائي يحتوي ذرّة التحكّم reply، التي تخبر الشيفرة العامّة في gen_server بأنّ العنصر الثاني من الثنائي (وهو في الحالتين ذرّة ok) هو الردّ المُرسَل إلى العميل. أمّا العنصر الثالث من الثنائي فهو State الجديدة، التي في دورة جديدة من دورات الخادم تُمرَّر كمعامل ثالث إلى الدالة handle_call/3؛ وفي الحالتين هنا تكون محدَّثة لتعبّر عن مجموعة الخدمات المتوافرة الجديدة. أمّا المعامل _From فهو ثنائي يحتوي مرجع رسالة فريدًا ومعرّف عملية العميل. ويُستعمل الثنائي ككلّ في دوال مكتبية لن نتناولها في هذا الفصل. وفي أغلب الحالات، لن تحتاج إليه.
وتحتوي وحدة المكتبة gen_server على عدد من الآليات وحواجز الأمان المدمجة التي تعمل في الخلفية. فإذا أرسل عميلك رسالة متزامنة إلى خادمك ولم تحصل على استجابة خلال خمس ثوانٍ، فإنّ العملية التي تنفّذ الدالة call/2 تُنهى. ويمكنك تجاوز ذلك باستعمال gen_server:call(Name, Message, Timeout) حيث Timeout قيمة بالمللي ثانية أو الذرّة infinity.
وُضعت آلية المهلة في الأصل لغرض منع التعارض (deadlock)، بما يضمن إنهاء الخوادم التي تستدعي بعضها بعضًا بالخطأ بعد انقضاء المهلة الافتراضية. وسجِّل تقرير الانهيار، ويُرجى أن يؤدّي ذلك إلى تنقيح الخطأ وإصلاحه. وستعمل معظم التطبيقات على نحو سليم مع مهلة خمس ثوانٍ، لكنّ تحت الأحمال شديدة الثقل قد تضطر إلى ضبط القيمة بدقة، بل وقد تضطر حتى إلى استعمال infinity؛ وهذا الخيار يعتمد على التطبيق. وجميع الشيفرة الحرجة في Erlang/OTP تستعمل infinity. وتستعمل مواضع مختلفة في Riak قيمًا مختلفة للمهلة: فـ infinity شائعة بين القطع المترابطة من البنية الداخلية، بينما تُضبط Timeout بناءً على معامل يمرّره المستخدم في الحالات التي يحدّد فيها شيفرة العميل المتحدّثة مع Riak أنّه يُسمح للعملية بأن تنتهي بمهلة.
ومن سائر حواجز الأمان عند استعمال الدالة gen_server:call/2 حالة إرسال رسالة إلى خادم غير موجود، وحالة انهيار الخادم قبل إرسال ردّه. وفي الحالتين، تنتهي العملية المستدعية. وفي Erlang الخام، فإنّ إرسال رسالة لا يطابقها أي نمط في بند استقبال هو خطأ قد يسبّب تسريبًا في الذاكرة. ويُستعمل في Riak استراتيجيتان مختلفتان للتقليل من ذلك، كلتاهما تضمّ بنود مطابقة «شاملة» (catchall). وفي المواضع التي قد تأتي فيها الرسالة من المستخدم، قد تُطرح الرسالة غير المطابقة بصمت. أمّا في المواضع التي لا يمكن أن تأتي فيها هذه الرسالة إلّا من البنية الداخلية لـ Riak، فإنّها تمثّل خطأً، ولذلك تُستعمل لتوليد تقرير انهيار داخلي يُنبّه إلى الخطأ، مع إعادة تشغيل عملية العامل التي استلمتها.
ويجري إرسال الرسائل غير المتزامنة بطريقة مماثلة. فإنّ الرسائل تُرسَل بصورة غير متزامنة إلى الخادم العامّ وتُعالَج في دالة الاستدعاء الراجعة handle_cast/2. ويجب أن تُعيد الدالة ثنائيًا بصيغة {reply, NewState}. وتُستعمل الاستدعاءات غير المتزامنة حين لا نكون مهتمّين باستجابة الخادم ولست قلقين من إنتاج رسائل أكثر مما يستطيع الخادم استهلاكه. وفي الحالات التي لا نكون فيها مهتمّين باستجابة لكنّنا نرغب في الانتظار حتى تُعالَج الرسالة قبل إرسال الطلب التالي، نستعمل gen_server:call/2 التي تُعيد الذرّة ok في الردّ. وتخيّل عملية تُنتج مُدخلات قاعدة بيانات بمعدّل أسرع مما يستطيع Riak استهلاكه. فباستعمال الاستدعاءات غير المتزامنة نخاطر بملء صندوق بريد العملية وجعل العقدة تفتقر إلى الذاكرة. وتستعمل Riak خصائص تسلسل الرسائل في الاستدعاءات المتزامنة لـ gen_server لتنظيم الحمل، فتُعالج الطلب التالي فقط بعد أن يُعالَج سابقه. ويُلغي هذا النهج الحاجة إلى شيفرة اختناق أعقد: فإلى جانب تمكين التزامن، يمكن أيضًا استعمال عمليات gen_server لإدخال نقاط تسلسل.
15.3.5. إيقاف الخادم
كيف توقف الخادم؟ في دالتَي الاستدعاء الراجع handle_call/3 وhandle_cast/2، بدلًا من إعادة {reply, Reply, NewState} أو {noreply, NewState}، يمكنك إعادة {stop, Reason, Reply, NewState} أو {stop, Reason, NewState} على التوالي. ويجب أن يُطلق هذا قيمة الإعادة، وغالبًا ما تكون رسالة إيقاف مُرسَلة إلى الخادم. وحين يستقبل ثنائي الإيقاف الذي يحتوي Reason وState، تنفّذ الشيفرة العامّة دالة الاستدعاء الراجعة terminate(Reason, State).
وتُعدّ الدالة terminate المكان الطبيعي لإدراج الشيفرة اللازمة لتنظيف State الخاص بالخادم وأي بيانات دائمة أخرى يستعملها النظام. وفي مثالنا، نُرسِل رسالة أخيرة واحدة إلى أقراننا كي يعرفوا أنّ مراقب العُقد هذا لم يعد يعمل ويراقب. وفي هذا المثال، يحتوي المتغيّر State على سجل بالحقلين status وpeers:
terminate(_Reason, State) ->
%% Let our peers know that we are shutting down
broadcast(State#state.peers, State#state { status = down }).
إنّ استعمال دوال الاستدعاء الراجعة للسلوك بوصفها دوالًا مكتبية واستدعاؤها من أجزاء أخرى من برنامجك ممارسة سيّئة للغاية. فمثلًا، ينبغي ألّا تستدعي riak_core_node_watcher:init(Args) من وحدة أخرى مطلقًا للحصول على بيانات الحلقة الأولية. وينبغي أن يتمّ هذا الاسترجاع عبر استدعاء متزامن للخادم. ولا ينبغي أن تنطلق الاستدعاءات إلى دوال الاستدعاء الراجعة للسلوك إلّا من وحدات مكتبة السلوك نتيجةً لحدوث حدث في النظام، وأبدًا مباشرةً من المستخدم.
15.4. سلوكيات عمّال أخرى
يمكن تنفيذ عدد كبير من سلوكيات العمّال الأخرى، وقد نُفِّذت بالفعل، بالاعتماد على هذه الأفكار نفسها.
15.4.1. آلات الحالات المنتهية
آلات الحالات المنتهية (FSMs)، المنفَّذة في وحدة سلوك gen_fsm، مكوّن جوهري عند تنفيذ مكدّسات البروتوكولات في أنظمة الاتصالات (وهو مجال المشكلات الذي اخترع Erlang لأجله في الأصل). وتُعرَّف الحالات بوصفها دوال استدعاء راجعة مسمّاة باسم الحالة، تُعيد ثنائيًا يحتوي State التالية وبيانات الحلقة المحدَّثة. ويمكنك إرسال الأحداث إلى هذه الحالات بصورة متزامنة وغير متزامنة. وينبغي لوحدة الاستدعاء الراجعة لآلة الحالات المنتهية أن تصدّر أيضًا دوال الاستدعاء الراجعة القياسية مثل init وterminate وhandle_info.
وبالطبع، فإنّ آلات الحالات المنتهية ليست خاصّة بالاتصالات. ففي Riak تُستعمل في معالجات الطلبات. فحين يصدر عميل طلبًا مثل get أو put أو delete، تنشئ العملية التي تستمع إلى ذلك الطلب عملية تطبّق سلوك gen_fsm المقابل. فمثلًا، تكون riak_kv_get_fsm مسؤولة عن معالجة طلب get، واسترجاع البيانات وإرسالها إلى عملية العميل. وسيمرّ عملية الآلة خلال حالات مختلفة وهي تحدّد أي العقد تسأل عن البيانات، وترسل الرسائل إلى تلك العقد، وتستقبل البيانات أو الأخطاء أو حالات انتهاء المهلة في المقابل.
15.4.2. معالجات الأحداث
معالجات الأحداث ومديراتها سلوكٌ آخر منفَّذ في وحدة المكتبة gen_event. وتتمثّل الفكرة في إنشاء نقطة مركزية تستقبل أحداثًا من نوع معيّن. ويمكن إرسال الأحداث بصورة متزامنة وغير متزامنة، مع تطبيق مجموعة محدّدة مسبقًا من الإجراءات عند استلامها. وتشمل الاستجابات الممكنة للأحداث تسجيلها في ملف، أو إرسال إنذار في صورة رسالة SMS، أو جمع إحصاءات. ويُعرَّف كل إجراء من هذه الإجراءات في وحدة استدعاء راجعة منفصلة لها بيانات حلقة خاصة بها تبقى محفوظة بين الاستدعاءات. ويمكن إضافة المعالجات أو إزالتها أو تحديثها لكل مدير أحداث محدّد. وعليه، عمليًّا، قد يكون لكل مدير أحداث وحدات استدعاء راجعة كثيرة، وقد توجد نسخ مختلفة من وحدات الاستدعاء الراجعة هذه في مديرين مختلفين. وتشمل معالجات الأحداث عمليات تستقبل الإنذارات، وبيانات التتبّع الحيّ، والأحداث المرتبطة بالمعدّات أو السجلّات البسيطة.
ومن استعمالات سلوك gen_event في Riak إدارة الاشتراكات في «أحداث الحلقة» (ring events)، أي التغييرات في عضوية عنقود Riak أو في إسناد التقسيمات إليه. فعمليات ما على عقدة Riak يمكنها تسجيل دالة في نسخة من riak_core_ring_events، وهي نسخة تطبّق سلوك gen_event. وفي كلّما غيّرت العملية المركزية التي تدير الحلقة لتلك العقدة سجلّ العضوية الخاصّ بالعنقود ككلّ، فإنّها تُطلق حدثًا يدفع كل واحدة من وحدات الاستدعاء الراجعة تلك إلى استدعاء الدالة المسجَّلة. وبهذه الطريقة، يصعب على أجزاء مختلفة من Riak أن تستجيب للتغييرات في إحدى أكثر بنى Riak مركزيةً دون الحاجة إلى إضافة تعقيد إلى الإدارة المركزية لتلك البنية.
وتُعالَج أنماط التزامن والاتصال الأكثر شيوعًا بسلوكيات OTP الثلاث الرئيسة التي ناقشناها للتوّ: gen_server وgen_fsm وgen_event. لكن في الأنظمة الكبيرة، تظهر مع الوقت أنماط خاصّة بكل تطبيق تستوجب إنشاء سلوكيات جديدة. وتتضمّن Riak أحد هذه السلوكيات، وهو riak_core_vnode، الذي ينظّف كيفية تنفيذ العُقد الافتراضية. والعُقد الافتراضية هي تجريد التخزين الرئيس في Riak، إذ تكشف واجهةً موحّدة لتخزين المفتاح-القيمة لآلات الحالات التي تقود الطلبات. وتُحدَّد واجهة وحدات الاستدعاء الراجعة باستعمال الدالة behavior_info/1، على النحو التالي:
behavior_info(callbacks) ->
[{init,1},
{handle_command,3},
{handoff_starting,2},
{handoff_cancelled,1},
{handoff_finished,2},
{handle_handoff_command,3},
{handle_handoff_data,2},
{encode_handoff_item,2},
{is_empty,1},
{terminate,2},
{delete,1}];
ويبيّن المثال أعلاه الدالة behavior_info/1 من riak_core_vnode. وقائمة ثنائيات {CallbackFunction, Arity} تعرّف العقد (contract) الذي يجب أن تتبعه وحدات الاستدعاء الراجعة. ويجب أن تصدّر تنفيذات العُقد الافتراضية الملموسة هذه الدوال، وإلا أصدر المُصرِّف تحذيرًا. وتنفيذ سلوكيات OTP الخاصة بك مباشر إلى حدٍّ ما. وإلى جانب تعريف دوال الاستدعاء الراجعة، وباستعمال وحدتي proc_lib وsys، عليك تشغيلها بدوال معيّنة، ومعالجة رسائل النظام، ومراقبة الأب تحسّبًا لانتهائه.
15.5. المشرفون
مهمّة سلوك المشرف (supervisor behavior) هي مراقبة أبنائه واتخاذ إجراء عند انتهائهم بناءً على قواعد معدّة مسبقًا. ويتألّف الأبناء من مشرفين وعمليات عمّال معًا. ويتيح ذلك لقاعدة شيفرة Riak أن تركّز على الحالة الأساسية، وهو ما يتيح للمشرف معالجة أخطاء البرمجيات أو البيانات التالفة أو أخطاء النظام معالجةً متّسقة عبر النظام كلّه. وفي عالم Erlang، يُشار كثيرًا إلى هذا النهج في البرمجة غير الدفاعية بأنّه استراتيجية «اتركه ينهار» (let it crash). ويمكن أن يتضمّن الأبناء الذين يشكّلون شجرة الإشراف مشرفين وعمليات عمّال معًا. أمّا عمليات العمّال فهي سلوكيات OTP من بينها gen_fsm وgen_server وgen_event. وفريق Riak، إذ لم يعد مضطرًا إلى معالجة حالات الأخطاء الحدّية، يعمل مع قاعدة شيفرة أصغر. وقاعدة الشيفرة هذه، بسبب استعمالها للسلوكيات، أصغر في أصلها، إذ لا تتعامل إلّا مع الشيفرة الخاصّة. ويملك Riak مشرفًا على المستوى الأعلى كما تملك معظم تطبيقات Erlang، كما يملك أيضًا مشرفين فرعيين لمجموعات من العمليات ذات المسؤوليات المترابطة. ومن الأمثلة العُقد الافتراضية في Riak، ومستمعي مقابس TCP، ومديري الاستعلام-والردّ.
15.5.1. دوال الاستدعاء الراجعة للمشرف
لعرض كيفية تنفيذ سلوك المشرف، سنستعمل الوحدة riak_core_sup.erl. ومشرف Riak الأساسي هو المشرف على المستوى الأعلى لتطبيق Riak core. وهو يشغّل مجموعة من العمّال والمشرفين الساكنين، مع عدد ديناميكي من العمّال الذين يتعاملون مع ارتباطات HTTP وHTTPS لواجهة REST الخاصة بالعقدة، والمعرَّفة في ملفات إعدادات خاصّة بالتطبيق. وعلى نحو مشابه لحالة gen_servers، يجب أن تتضمّن جميع وحدات الاستدعاء الراجعة للمشرف تعليمة -behavior(supervisor). وتُشغَّل بالدالتين start أو start_link اللتين تأخذان ServerName الاختياري وCallBackModule وArgument الذي يُمرَّر إلى دالة الاستدعاء الراجعة init/1.
وإذا نظرنا إلى الأسطر الأولى القليلة من الشيفرة في الوحدة riak_core_sup.erl، إلى جانب تعليمة السلوك وصنف (macro) سنصفه لاحقًا، نلاحظ الدالة start_link/3:
-module(riak_core_sup).
-behavior(supervisor).
%% API
-export([start_link/0]).
%% Supervisor callbacks
-export([init/1]).
-define(CHILD(I, Type), {I, {I, start_link, []}, permanent, 5000, Type, [I]}).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
ويؤدي تشغيل المشرف إلى إنشاء عملية جديدة، وإلى استدعاء دالة الاستدعاء الراجعة init/1 في وحدة الاستدعاء الراجعة riak_core_sup.erl. وServerName ثنائي بصيغة {local, Name} أو {global, Name}، حيث Name هو الاسم المسجَّل للمشرف. وفي مثالنا، الاسم المسجَّل ووحدة الاستدعاء الراجعة كلاهما الذرّة riak_core_sup، وينبعثان من صنف ?MODULE. ونمرّر القائمة الفارغة كمعامل إلى init/1، نتعامل معها بوصفها قيمة فارغة. ودالة init هي دالة الاستدعاء الراجعة الوحيدة للمشرف. ويجب أن تُعيد ثنائيًا بالصيغة:
{ok, {SupervisorSpecification, ChildSpecificationList}}
حيث SupervisorSpecification ثنائي من ثلاثة عناصر {RestartStrategy, AllowedRestarts, MaxSeconds} يحتوي معلومات عن كيفية التعامل مع انهيار العمليات وإعادة تشغيلها. وRestartStrategy إحدى ثلاث معاملات إعداد تحدّد كيف تتأثّر العمليات الشقيقة للسلوك عند الانتهاء غير الطبيعي:
one_for_one: العمليات الأخرى في شجرة الإشراف لا تتأثّر.rest_for_one: العمليات التي أُنشئت بعد العملية المنتهية تُنهى وتُعاد تشغيلها.one_for_all: جميع العمليات تُنهى وتُعاد تشغيلها.
تُحدّد AllowedRestarts كم مرّة يمكن لأي من أبناء المشرف أن ينتهي خلال MaxSeconds قبل أن يُنهي المشرف نفسه (وأبناءه). وحين تنتهي عملية، فإنّها ترسل إشارة EXIT إلى مشرفها، الذي يتعامل مع الانتهاء وفقًا لاستراتيجية إعادة تشغيله. وإنهاء المشرف بعد بلوغ الحدّ الأقصى المسموح به من عمليات إعادة التشغيل يضمن تصعيد أعطال إعادة التشغيل الدورية وغيرها من المشكلات التي لا يمكن حلّها على هذا المستوى. ومن المرجَّح أن تكون المشكلة في عملية تقع في شجرة فرعية مختلفة، مما يتيح للمشرف الذي يتلقّى التصعيد أن يُنهي الشجرة الفرعية المتأثّرة ويعيد تشغيلها.
وإذا فحصنا السطر الأخير من دالة الاستدعاء الراجعة init/1 في الوحدة riak_core_sup.erl، نلاحظ أنّ هذا المشرف بعينه له استراتيجية «واحد لواحد» (one-for-one)، ما يعني أنّ العمليات مستقلّة عن بعضها. وسيسمح المشرف بعشر عمليات إعادة تشغيل كحدّ أقصى قبل أن يعيد تشغيل نفسه.
وتحدّد ChildSpecificationList أي أبناء يجب على المشرف تشغيلهم ومراقبتهم، مع معلومات عن كيفية إنهائهم وإعادة تشغيلهم. وتتألّف من قائمة ثنائيات بالصيغة التالية:
{Id, {Module, Function, Arguments}, Restart, Shutdown, Type, ModuleList}
Id معرّف فريد لذلك المشرف بعينه. وModule وFunction وArguments هي دالة مصدَّرة تؤدّي إلى استدعاء دالة start_link الخاصة بالسلوك، وتُعيد ثنائيًا بصيغة {ok, Pid}. وتحدّد استراتيجية Restart ما يحدث بحسب نوع انتهاء العملية، الذي يمكن أن يكون:
- عمليات
transient، التي لا يُعاد تشغيلها مطلقًا؛ - عمليات
temporary، التي لا يُعاد تشغيلها إلّا إذا انتهت بشكل غير طبيعي؛ و - عمليات
permanent، التي يُعاد تشغيلها دائمًا، سواء كان الانتهاء طبيعيًّا أو غير طبيعي.
Shutdown قيمة بالمللي ثانية تشير إلى الوقت المسموح به للسلوك كي ينفّذ في الدالة terminate عند الانتهاء نتيجةً لإعادة تشغيل أو إيقاف. ويمكن أيضًا استعمال الذرّة infinity، لكنّه غير مستحسن بشدّة للسلوكيات الأخرى غير المشرفين. وType إمّا الذرّة worker، وهي تشير إلى الخوادم العامة ومعالجات الأحداث وآلات الحالات المنتهية، أو الذرّة supervisor. ومع ModuleList، وهي قائمة بالوحدات المنفِّذة للسلوك، تُستعملان للتحكّم في العمليات وتعليقها أثناء إجراءات ترقية البرمجيات وقت التشغيل. ولا يمكن إلّا للسلوكيات الموجودة أو التي نفّذها المستخدم أن تكون جزءًا من قائمة مواصفات الأبناء، وبالتالي أن تكون مشمولة في شجرة إشراف.
ومع توفّر هذه المعرفة في أيدينا، ينبغي أن نتمكّن الآن من صياغة استراتيجية إعادة تشغيل تعرّف الاعتماديات بين العمليات وعتبات تحمّل الأخطاء وإجراءات التصعيد بناءً على معمارية مشتركة. وينبغي أيضًا أن نتمكّن من فهم ما يجري في مثال init/1 في الوحدة riak_core_sup.erl. أوّلًا وقبل كلّ شيء، ادرس صنف CHILD. فهو ينشئ مواصفة الابن لابن واحد، مستعملًا اسم وحدة الاستدعاء الراجعة بوصفه Id، ويجعله دائمًا (permanent) ويمنحه مهلة إيقاف قدرها 5 ثوانٍ. ويمكن أن تكون أنواع الأبناء عمّالًا أو مشرفين. انظر إلى المثال، وشاهد ما تستطيع استخراجه منه:
-define(CHILD(I, Type), {I, {I, start_link, []}, permanent, 5000, Type, [I]}).
init([]) ->
RiakWebs = case lists:flatten(riak_core_web:bindings(http),
riak_core_web:bindings(https)) of
[] ->
%% check for old settings, in case app.config
%% was not updated
riak_core_web:old_binding();
Binding ->
Binding
end,
Children =
[?CHILD(riak_core_vnode_sup, supervisor),
?CHILD(riak_core_handoff_manager, worker),
?CHILD(riak_core_handoff_listener, worker),
?CHILD(riak_core_ring_events, worker),
?CHILD(riak_core_ring_manager, worker),
?CHILD(riak_core_node_watcher_events, worker),
?CHILD(riak_core_node_watcher, worker),
?CHILD(riak_core_gossip, worker) |
RiakWebs
],
{ok, {{one_for_one, 10, 10}, Children}}.
معظم Children التي يشغّلها هذا المشرف عمّال معرَّفون بشكل ساكن (أو مشرف في حالة vnode_sup). والاستثناء هو جزء RiakWebs، الذي يُعرَّف ديناميكيًّا بحسب الجزء الخاص بـ HTTP في ملف إعدادات Riak.
وباستثناء تطبيقات المكتبة، يمتلك كلّ تطبيق OTP، بما في ذلك تطبيقات Riak، شجرة إشراف خاصة به. وفي Riak، تعمل تطبيقات متنوّعة على المستوى الأعلى داخل عقدة Erlang، مثل riak_core لخوارزميات الأنظمة الموزّعة، وriak_kv لدلالات تخزين المفتاح-القيمة، وwebmachine لـ HTTP، وغيرها. وقد أظهرنا الشجرة الموسّعة تحت riak_core لتوضيح الإشراف متعدّد المستويات القائم. ومن كثير فوائد هذه البنية أنّ نظامًا فرعيًا بعينه يمكن أن ينهار (بسبب خطأ برمجي أو مشكلة بيئية أو إجراء متعمَّد) وأن تُنهى في المقام الأول تلك الشجرة الفرعية وحدها.
وسيعيد المشرف تشغيل العمليات اللازمة ولن يتأثّر النظام ككلّ. وقد رأينا عمليًّا أنّ هذا يعمل جيدًا مع Riak. فقد يكتشف المستخدم كيف يُسقط عقدة افتراضية، لكنّها ستُعاد تشغيلها ببساطة بواسطة riak_core_vnode_sup. وإذا تمكّن من إسقاط ذلك أيضًا، فإنّ مشرف riak_core سيعيد تشغيله، ناقلًا الانتهاء إلى المشرف على المستوى الأعلى. وتتيح آلية عزل الأعطال والتعافي هذه للمطوّرين (في Riak وErlang) بناء أنظمة مرنة بصورة مباشرة.
وقد ظهرت قيمة النموذج الإشرافي حين أنشأ أحد المستخدمين الصناعيين الكبار بيئة شديدة الإيذاء بغية معرفة أين ستنهار كل واحدة من عدّة أنظمة قواعد بيانات. وقد أنتجت هذه البيئة انفجارات عشوائية ضخمة في كلٍّ من حركة البيانات وحالات الفشل. والقد ارتبكوا حين رفض Riak ببساطة أن يتوقّف عن العمل، حتى في أسوأ ترتيبات من هذا النوع. وعلى أيّ حال، فقد تمكّنوا في الخلفية من إسقاط عمليات أو أنظمة فرعية منفردة بطرق متعدّدة—لكنّ المشرفين كانوا ينظّفون ويعيدون تشغيل الأشياء ليعيدوا النظام كلّه إلى حالة العمل في كل مرة.
15.5.2. التطبيقات
يُستعمل سلوك application الذي قدّمناه سابقًا لتغليف وحدات Erlang ومواردها في مكوّنات قابلة لإعادة الاستعمال. وفي OTP، هناك نوعان من التطبيقات. أمّا الشكل الأكثر شيوعًا، ويُسمّى التطبيقات العادية (normal applications)، فيُشغّل شجرة إشراف وجميع العمّال الساكنين ذوي الصلة. أمّا تطبيقات المكتبة مثل المكتبة القياسية، التي تأتي كجزء من توزيع Erlang، فتحتوي وحدات مكتبية لكنّها لا تشغّل شجرة إشراف. وهذا لا يعني أنّ الشيفرة قد لا تحتوي على عمليات أو أشجار إشراف، بل يعني فقط أنّها تُشغَّل كجزء من شجرة إشراف تابعة لتطبيق آخر.
وسيتألّف نظام Erlang من مجموعة تطبيقات مترابطة ربطًا ضعيفًا. فبعضها مكتوب من قِبل المطوّرين، وبعضه متاح كمصدر مفتوح، وبعضها الآخر جزء من توزيع Erlang/OTP. ويتعامل نظام وقت تشغيل Erlang وأدواته مع جميع التطبيقات بالمثل، بغضّ النظر عمّا إذا كانت جزءًا من توزيع Erlang أم لا.
15.6. التكرار والاتصال في Riak
صُمِّم Riak من أجل موثوقية وتوافرية شديدتين عند نطاق هائل، واستُلهِم من نظام تخزين Dynamo لدى Amazon DHJ+07. وتجمّع معماريتا Dynamo وRiak جوانب من كلٍّ من جداول التجزئة الموزّعة (Distributed Hash Tables, DHTs) وقواعد البيانات التقليدية. أمّا التقنيتان الرئيسيتان اللتان تستعملهما Riak وDynamo معًا فهما التجزئة المتّسقة (consistent hashing) لوضع النُسخ، وبروتوكول الثرثرة (gossip protocol) لمشاركة الحالة المشتركة.
وتتطلّب التجزئة المتّسقة أن تعرف كل عقدة في النظام العقدَ الأخرى، وأن تعرف أي التقسيمات تملكها كل عقدة. ويمكن الاحتفاظ ببيانات هذا الإسناد في ملف إعدادات مُدار مركزيًّا، لكنّ هذا يصبح صعبًا جدًّا في التكوينات الكبيرة. والخيار البديل الآخر هو استعمال خادم إعدادات مركزي، لكنّ هذا يُدخل نقطة فشل وحيدة في النظام. وبدلًا من ذلك، تستعمل Riak بروتوكول ثرثرة لنشر بيانات عضوية العنقود وبيانات ملكية التقسيمات في أرجاء النظام.
وتعمل بروتوكولات الثرثرة، وتُسمّى أيضًا بروتوكولات الأوبئة (epidemic protocols)، تمامًا كما يوحي اسمها. فحين ترغب عقدة في النظام في تغيير جزء من البيانات المشتركة، فإنّها تُجري التغيير على نسختها المحلية من البيانات وتنشر البيانات المحدَّثة إلى نظير عشوائي. وحين تستقبل تحديثًا، تدمج العقدة التغييرات المستلَمة مع حالتها المحلية وتُنشر مرّة أخرى إلى نظير عشوائي آخر.
وحين يُشغَّل عنقود Riak، يجب أن تكون جميع العُقد مُهيّأة بعدد التقسيمات نفسه. ثمّ تُقسَّم حلقة التجزئة المتّسقة بعدد التقسيمات ويُخزَّن كلّ مدى محليًّا كزوج {HashRange, Owner}. وتُطالب العقدة الأولى في العنقود ببساطة بجميع التقسيمات. وحين تنضمّ عقدة جديدة إلى العنقود، فإنّها تتّصل بعقدة قائمة للحصول على قائمة أزواج {HashRange, Owner} الخاصة بها. ثمّ تطالب بـ (عدد التقسيمات)/(عدد العُقد) من الأزواج، محدِّثةً حالتها المحلية لتعكس ملكيتها الجديدة. ثمّ تُنشر معلومات الملكية المحدَّثة إلى نظير. وبعد ذلك تنتشر هذه الحالة المحدَّثة في أرجاء العنقود كلّه باستعمال الخوارزمية أعلاه.
وباستعمال بروتوكول الثرثرة، تتجنّب Riak إدخال نقطة فشل وحيدة في صورة خادم إعدادات مركزي، وتُعفي مشغّلي النظام من الحاجة إلى صيانة بيانات إعدادات العنقود الحرجة. وبعد ذلك يمكن لأي عقدة أن تستعمل بيانات إسناد التقسيمات المنشورة في النظام لتوجيه الطلبات. وعند استعمالهما معًا، يتيح بروتوكول الثرثرة والتجزئة المتّسقة لـ Riak العمل بوصفه نظامًا لامركزيًّا حقًّا، وهو ما له نتائج مهمّة على نشر الأنظمة واسعة النطاق وتشغيلها.
15.7. الخلاصات والدروس المستفادة
يعتقد معظم المبرمجين أنّ قواعد الشيفرة الأصغر والأبسط أسهل في الصيانة فحسب، وأنّها غالبًا ما تحتوي أخطاءً أقلّ. وباستعمال أساسيات التوزيع في Erlang للاتصال داخل العنقود، تمكّنت Riak من الانطلاق بطبقة مراسلة غير متزامنة سليمة جوهريًّا، وأن تبني بروتوكولاتها الخاصّة دون الحاجة إلى الانشغال بالتنفيذية الأساسية. ومع نمو Riak ليصبح نظامًا ناضجًا، ابتعدت بعض جوانب اتصالها الشبكي عن استعمال التوزيع المدمج في Erlang (وباتجاه التلاعب المباشر بمقابس TCP)، بينما بقيت جوانب أخرى ملائمةً جيّدًا للأساسيات المضمّنة. وبالانطلاق من مراسلة Erlang الأصلية في كل شيء، تمكّن فريق Riak من بناء النظام كلّه بسرعة بالغة. وهذه الأساسيات نظيفة وواضحة بما يكفي بحيث كان من السهل لاحقًا استبدال المواضع القليلة التي تبيّن أنها ليست الخيار الأفضل في الإنتاج.
وكذلك، فإنّ طبيعة مراسلة Erlang وخفّة قلب الآلة الافتراضية فيها يتيحان للمستخدم تشغيل 12 عقدة على جهاز واحد بسهولة تامّة، أو 12 عقدة على 12 جهازًا. وهذا يجعل التطوير والاختبار أسهل بكثير مقارنةً بآليات المراسلة والتكتيل الأثقل وزنًا. وقد كانت هذه الميزة ذات قيمة خاصّة بسبب طبيعة Riak الموزّعة جوهريًّا. فتاريخيًّا، يصعب تشغيل معظم الأنظمة الموزّعة جدًّا في «وضع تطوير» على حاسوب مطوّر واحد. ونتيجةً لذلك، ينتهي المطوّرون غالبًا إلى اختبار شيفرتهم في بيئة تمثّل مجموعة جزئية من نظامهم الكامل، ذات سلوك مختلف تمامًا. وبما أنّ عنقود Riak كثير العُقد يمكن تشغيله بسهولة تامّة على حاسوب محمول واحد دون استهلاك موارد مفرط أو إعدادات معقّدة، تصبح عملية التطوير أكثر قدرة على إنتاج شيفرة جاهزة للنشر الإنتاجي.
ويجعل استعمال مشرفي Erlang/OTP من Riak أكثر مرونة بكثير في مواجهة انهيار المكوّنات الفرعية. ويأخذ Riak هذا أبعد من ذلك؛ فمستوحى من هذه السلوكيات، يتمكّن عنقود Riak أيضًا من الاستمرار في العمل بسهولة حتى حين تنهار عقد كاملة وتختفي من النظام. وقد يؤدّي هذا إلى مستوى من المرونة يكون مدهشًا أحيانًا. ومن الأمثلة على ذلك حين كانت شركة كبيرة تجرّب إجهادًا لقواعد بيانات متنوّدة وتُسقطها عمدًا لرصد حالاتها الحدّية. وحين وصلوا إلى Riak ارتبكوا. فكلّما وجدوا طريقة (عبر التلاعب على مستوى نظام التشغيل، أو IPC معطوب، إلخ) لإسقاط نظام فرعي من Riak، كانوا يرون انخفاضًا شديدًا القصر في الأداء ثمّ يعود النظام إلى سلوكه الطبيعي. وهذه نتيجة مباشرة لمقاربة متأنّية قائمة على «اتركه ينهار». فقد كان Riak يعيد تشغيل كل واحد من هذه الأنظمة الفرعية عند الطلب بسلاسة، وأما النظام ككلّ فاستمرّ في العمل ببساطة. وتُظهر تلك التجربة بالضبط نوع المرونة التي يتيحها منهج Erlang/OTP في بناء البرامج.
15.7.1. شكر وتقدير
يعتمد هذا الفصل على ملاحظات محاضرة لعام 2009 لكلٍّ من Francesco Cesarini وSimon Thompson من مدرسة البرمجة الوظيفية الأوروبية المركزية (central European Functional Programming School) المعقودة في بودابست وKomárno. وقدّمت مساهمات كبرى Simon Thompson من جامعة كنت في كانتربري، المملكة المتحدة. وتتوجّه بالشكر الخاصّ إلى جميع المراجعين، الذين قدّموا تغذية راجعة قيّمة في مراحل مختلفة من كتابة هذا الفصل.