بناء آلة الدولة في Elixir و Ecto

هناك العديد من أنماط التصميم المفيدة ومفهوم آلة الدولة هو أحد أنماط التصميم المفيدة.



تعتبر آلة الحالة رائعة عندما تقوم بنمذجة عملية تجارية معقدة تنتقل فيها الحالات من مجموعة محددة مسبقًا من الحالات ويجب أن يكون لكل ولاية سلوكها المحدد مسبقًا.



في هذا المنشور ، ستتعلم كيفية تنفيذ هذا النمط باستخدام Elixir و Ecto.



استخدم حالات



يمكن أن تكون آلة الحالة خيارًا رائعًا عند تصميم عملية تجارية معقدة ومتعددة الخطوات وحيث يتم فرض متطلبات محددة في كل خطوة.



أمثلة:



  • التسجيل في حسابك الشخصي. في هذه العملية ، يقوم المستخدم بالتسجيل أولاً ، ثم يضيف بعض المعلومات الإضافية ، ثم يؤكد بريده الإلكتروني ، ثم يقوم بتشغيل المصادقة الثنائية ، وبعد ذلك فقط يمكنه الوصول إلى النظام.
  • سلة التسوق. في البداية يكون فارغًا ، ثم يمكنك إضافة منتجات إليه وبعد ذلك يمكن للمستخدم متابعة الدفع والتسليم.
  • مجموعة من المهام في أنظمة إدارة المشاريع. على سبيل المثال: في البداية يكون للمهام حالة " تم إنشاؤها " ، ثم يمكن " تعيين " المهمة إلى المنفذ ، ثم ستتغير الحالة إلى " قيد التقدم " ، ثم إلى " مكتملة ".


مثال على آلة الدولة



فيما يلي دراسة حالة صغيرة لتوضيح كيفية عمل آلة الدولة: تشغيل الباب.



يمكن قفل الباب أو فتحه . يمكن أيضًا فتحه أو إغلاقه . إذا تم إلغاء قفله ، فيمكن فتحه.



يمكننا نمذجة هذا كآلة حالة:



صورة



آلة الحالة هذه لديها:



  • 3 حالات ممكنة: مغلق ، مفتوح ، مفتوح
  • 4 انتقالات محتملة للحالة: فتح ، فتح ، إغلاق ، قفل


من المخطط ، يمكننا أن نستنتج أنه من المستحيل الانتقال من مغلق إلى مفتوح. أو بكلمات بسيطة: تحتاج أولاً إلى فتح الباب ، وبعد ذلك فقط تفتحه. يصف هذا الرسم البياني السلوك ، ولكن كيف يتم تنفيذه؟



آلات الدولة مثل عمليات الإكسير



منذ OTP 19 ، توفر Erlang وحدة نمطية : gen_statem التي تسمح لك بتنفيذ عمليات تشبه gen_server تتصرف مثل آلات الحالة (حيث تؤثر الحالة الحالية على سلوكها الداخلي). دعونا نرى كيف سيبحث عن مثال بابنا:



defmodule Door do
  @behaviour :gen_statem
 #  
 def start_link do
   :gen_statem.start_link(__MODULE__, :ok, [])
 end
 
 #  ,   , locked - 
 @impl :gen_statem
 def init(_), do: {:ok, :locked, nil}
 
 @impl :gen_statem
 def callback_mode, do: :handle_event_function
 
 #   :   
 # next_state -   -  
 @impl :gen_statem
 def handle_event({:call, from}, :unlock, :locked, data) do
   {:next_state, :unlocked, data, [{:reply, from, {:ok, :unlocked}}]}
 end
 
 #   
 def handle_event({:call, from}, :lock, :unlocked, data) do
   {:next_state, :locked, data, [{:reply, from, {:ok, :locked}}]}
 end
 
 #   
 def handle_event({:call, from}, :open, :unlocked, data) do
   {:next_state, :opened, data, [{:reply, from, {:ok, :opened}}]}
 end
 
 #   
 def handle_event({:call, from}, :close, :opened, data) do
   {:next_state, :unlocked, data, [{:reply, from, {:ok, :unlocked}}]}
 end
 
 #     
 def handle_event({:call, from}, _event, _content, data) do
   {:keep_state, data, [{:reply, from, {:error, "invalid transition"}}]}
 end
end


تبدأ هذه العملية في الحالة : مغلق . من خلال إرسال الأحداث المناسبة ، يمكننا مطابقة الحالة الحالية مع الانتقال المطلوب وإجراء التحولات اللازمة. يتم حفظ وسيطة البيانات الإضافية لأي حالة إضافية أخرى ، لكننا لا نستخدمها في هذا المثال.



يمكننا أن نسميها مع انتقال الحالة الذي نريده. إذا كانت الحالة الحالية تسمح بهذا الانتقال ، فستعمل. خلاف ذلك ، سيتم إرجاع خطأ (بسبب قيام معالج الحدث الأخير بإمساك أي شيء لا يتطابق مع الأحداث الصالحة).



{:ok, pid} = Door.start_link()
:gen_statem.call(pid, :unlock)
# {:ok, :unlocked}
:gen_statem.call(pid, :open)
# {:ok, :opened}
:gen_statem.call(pid, :close)
# {:ok, :closed}
:gen_statem.call(pid, :lock)
# {:ok, :locked}
:gen_statem.call(pid, :open)
# {:error, "invalid transition"}


إذا كانت آلة الدولة لدينا مدفوعة بالبيانات أكثر من كونها مدفوعة بالعمليات ، فيمكننا عندئذٍ اتباع نهج مختلف.



آلات الحالة المحدودة كنماذج Ecto



هناك العديد من حزم الإكسير التي تحل هذه المشكلة. سأستخدم Fsmx في هذا المنشور ، لكن الحزم الأخرى مثل الماكينات توفر وظائف مماثلة أيضًا.



تسمح لنا هذه الحزمة بمحاكاة الحالات والانتقالات نفسها تمامًا ، ولكن في نموذج Ecto الحالي:



defmodule PersistedDoor do
 use Ecto.Schema
 
 schema "doors" do
   field(:state, :string, default: "locked")
   field(:terms_and_conditions, :boolean)
 end
 
 use Fsmx.Struct,
   transitions: %{
     "locked" => "unlocked",
     "unlocked" => ["locked", "opened"],
     "opened" => "unlocked"
   }
end


كما نرى ، يأخذ Fsmx.Struct جميع الفروع الممكنة كوسيطة. هذا يسمح له بالتحقق من التحولات غير المرغوب فيها ومنع حدوثها. يمكننا الآن تغيير الحالة باستخدام النهج التقليدي غير Ecto:



door = %PersistedDoor{state: "locked"}
 
Fsmx.transition(door, "unlocked")
# {:ok, %PersistedDoor{state: "unlocked", color: nil}}


ولكن يمكننا أيضًا أن نطلب نفس الشيء في شكل مجموعة تغييرات Ecto (المستخدمة في Elixir لـ "Changeset"):



door = PersistedDoor |> Repo.one()
Fsmx.transition_changeset(door, "unlocked")
|> Repo.update()


هذه التغييرات فقط تقوم بتحديث : حقل الحالة. لكن يمكننا توسيعه ليشمل حقولاً وعمليات تحقق إضافية. دعنا نقول لفتح الباب ، نحتاج إلى قبول شروطه:



defmodule PersistedDoor do
 # ...
 
 def transition(changeset, _from, "opened", params) do
   changeset
   |> cast(params, [:terms_and_conditions])
   |> validate_acceptance(:terms_and_conditions)
 end
end


يبحث Fsmx عن وظيفة transfer_changeset / 4 الاختيارية في مخططك ويستدعيها بالحالة السابقة والحالة التالية. يمكنك تصميمها لإضافة شروط محددة لكل انتقال.



التعامل مع الآثار الجانبية



يعد نقل آلة الحالة من حالة إلى أخرى مهمة شائعة لأجهزة الحالة. لكن ميزة أخرى كبيرة لآلات الحالة هي القدرة على التعامل مع الآثار الجانبية التي يمكن أن تحدث في كل دولة.

لنفترض أننا نريد أن يتم إعلامنا في كل مرة يفتح فيها شخص ما بابنا. قد نرغب في إرسال بريد إلكتروني عندما يحدث هذا. لكننا نريد أن تكون هاتان العمليتان عملية ذرية واحدة.



يعمل Ecto مع atomicity من خلال حزمة Ecto.Multi ، التي تجمع عمليات متعددة داخل معاملة قاعدة البيانات. يحتوي Ecto أيضًا على ميزة تسمى Ecto.Multi.run/3 تتيح تشغيل رمز عشوائي في نفس المعاملة.



Fsmxيتكامل بدوره مع Ecto.Multi ، مما يمنحك القدرة على إجراء انتقالات الحالة كجزء من Ecto.Multi ، ويوفر أيضًا رد اتصال إضافي يتم تنفيذه في هذه الحالة:



defmodule PersistedDoor do
 # ...
 
 def after_transaction_multi(changeset, _from, "unlocked", params) do
   Emails.door_unlocked()
   |> Mailer.deliver_later()
 end
end


الآن يمكنك إجراء الانتقال كما هو موضح:



door = PersistedDoor |> Repo.one()
 
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "unlocked")
|> Repo.transaction()


ستستخدم هذه المعاملة نفس transfer_changeset / 4 كما هو موضح أعلاه لحساب التغييرات المطلوبة في نموذج Ecto. وسيتضمن رد اتصال جديد كاستدعاء لـ Ecto.Multi.run . نتيجة لذلك ، يتم إرسال البريد الإلكتروني (بشكل غير متزامن ، باستخدام Bamboo لتجنب تشغيله داخل المعاملة نفسها).



إذا تم إبطال مجموعة التغييرات لأي سبب من الأسباب ، فلن يتم إرسال البريد الإلكتروني مطلقًا ، كنتيجة للتنفيذ الذري لكلتا العمليتين.



خاتمة



في المرة القادمة التي تقوم فيها بنمذجة بعض السلوك القائم على الحالة ، فكر في نهج يستخدم نمط آلة الحالة (آلة الحالة) ، يمكن أن يكون هذا النمط مساعدًا جيدًا لك. إنها بسيطة وفعالة. يسمح هذا القالب بالتعبير عن مخطط الانتقال للحالة النموذجية بسهولة في الكود ، مما سيسرع التطوير.



سأقوم بالحجز ، ربما يساهم نموذج الممثل في بساطة تنفيذ آلة الحالة في Elixir \ Erlang ، كل فاعل له حالته الخاصة وقائمة انتظار من الرسائل الواردة ، والتي تغير حالتها بالتتابع. في كتاب " تصميم أنظمة قابلة للتطوير في Erlang / OTP " حول آلات الحالة المحدودة مكتوب بشكل جيد للغاية ، في سياق نموذج الممثل.



إذا كان لديك أمثلة خاصة بك على تنفيذ آلات الحالة المحدودة في لغة البرمجة الخاصة بك ، فيرجى مشاركة الرابط ، وسيكون من المثير للاهتمام دراستها.



All Articles