Membangun mesin negara di Elixir dan Ecto

Ada banyak pola desain yang berguna dan konsep mesin negara adalah salah satu pola desain yang berguna.



Mesin keadaan sangat bagus ketika Anda memodelkan proses bisnis yang kompleks di mana keadaan transisi dari sekumpulan keadaan yang telah ditentukan dan setiap keadaan harus memiliki perilaku yang telah ditentukan sebelumnya.



Dalam posting ini, Anda akan belajar bagaimana menerapkan pola ini menggunakan Elixir dan Ecto.



Kasus penggunaan



Mesin status dapat menjadi pilihan tepat saat Anda memodelkan proses bisnis multi-langkah yang kompleks dan di mana persyaratan khusus diterapkan pada setiap langkah.



Contoh:



  • Pendaftaran di akun pribadi Anda. Dalam proses ini, pengguna pertama-tama mendaftar, lalu menambahkan beberapa informasi tambahan, lalu mengonfirmasi emailnya, lalu mengaktifkan 2FA, dan baru setelah itu mendapat akses ke sistem.
  • Keranjang belanja. Pertama, kosong, lalu Anda dapat menambahkan produk ke dalamnya, lalu pengguna dapat melanjutkan ke pembayaran dan pengiriman.
  • Sebuah pipa tugas dalam sistem manajemen proyek. Misalnya: awalnya tugas memiliki status " dibuat ", lalu tugas dapat " ditetapkan " ke pelaksana, kemudian status akan berubah menjadi " dalam proses ", lalu menjadi " selesai ".


Contoh mesin negara



Berikut adalah studi kasus kecil untuk menggambarkan cara kerja mesin negara: pengoperasian pintu.



Pintunya bisa dikunci atau dibuka kuncinya . Itu juga bisa dibuka atau ditutup . Jika tidak terkunci, maka itu bisa dibuka.



Kita dapat memodelkannya sebagai mesin negara:



gambar



Mesin negara ini memiliki:



  • 3 kemungkinan status: terkunci, tidak terkunci, terbuka
  • 4 kemungkinan transisi status: buka kunci, buka, tutup, kunci


Dari diagram, kita dapat menyimpulkan bahwa tidak mungkin beralih dari terkunci menjadi terbuka. Atau dengan kata sederhana: pertama Anda perlu membuka kunci pintu, dan baru kemudian membukanya. Diagram ini menjelaskan perilakunya, tetapi bagaimana Anda menerapkannya?



Nyatakan mesin sebagai proses Elixir



Sejak OTP 19, Erlang menyediakan modul : gen_statem yang memungkinkan Anda mengimplementasikan proses mirip gen_server yang berperilaku seperti mesin status (di mana status saat ini memengaruhi perilaku internalnya). Mari kita lihat bagaimana itu akan mencari contoh pintu kita:



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


Proses ini dimulai dalam status : terkunci . Dengan mengirimkan peristiwa yang sesuai, kita dapat mencocokkan keadaan saat ini dengan transisi yang diminta dan melakukan transformasi yang diperlukan. Argumen data tambahan disimpan untuk status ekstra lainnya, tetapi kami tidak menggunakannya dalam contoh ini.



Kita bisa menyebutnya dengan transisi keadaan yang kita inginkan. Jika keadaan saat ini memungkinkan transisi ini, maka itu akan berhasil. Jika tidak, kesalahan akan dikembalikan (karena penangan kejadian terakhir menangkap apa pun yang tidak cocok dengan kejadian yang valid).



{: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"}


Jika mesin negara kita lebih digerakkan oleh data daripada digerakkan oleh proses, maka kita bisa mengambil pendekatan yang berbeda.



Mesin Negara Hingga sebagai Model Ecto



Ada beberapa paket Elixir yang dapat mengatasi masalah ini. Saya akan menggunakan Fsmx dalam posting ini , tetapi paket lain seperti Mesin juga menyediakan fungsi serupa.



Paket ini memungkinkan kita untuk mensimulasikan keadaan dan transisi yang sama persis, tetapi dalam model Ecto yang ada:



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


Seperti yang bisa kita lihat, Fsmx.Struct menggunakan semua kemungkinan cabang sebagai argumen. Ini memungkinkannya untuk memeriksa transisi yang tidak diinginkan dan mencegahnya terjadi. Sekarang kita dapat mengubah status menggunakan pendekatan tradisional non-Ecto:



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


Tapi kita juga bisa meminta hal yang sama dalam bentuk Ecto changeset (digunakan di Elixir untuk "changeset"):



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


Perubahan ini hanya memperbarui bidang : negara. Namun kami dapat mengembangkannya untuk menyertakan bidang dan validasi tambahan. Katakanlah untuk membuka pintu, kita perlu menerima persyaratannya:



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


Fsmx mencari fungsi transisi_changeset / 4 opsional dalam skema Anda dan memanggilnya dengan status sebelumnya dan berikutnya. Anda dapat membuat pola untuk menambahkan kondisi spesifik untuk setiap transisi.



Menangani efek samping



Memindahkan mesin keadaan dari satu keadaan ke keadaan lain adalah tugas umum untuk mesin keadaan. Tetapi keuntungan besar lainnya dari mesin negara adalah kemampuannya untuk menangani efek samping yang dapat terjadi di setiap negara bagian.

Katakanlah kita ingin diberi tahu setiap kali seseorang membuka pintu kita. Kami mungkin ingin mengirim email jika ini terjadi. Tapi kami ingin dua operasi ini menjadi satu operasi atom.



Ecto bekerja dengan atomicity melalui paket Ecto.Multi , yang mengelompokkan beberapa operasi dalam transaksi database. Ecto juga memiliki fitur bernama Ecto.Multi.run/3 yang memungkinkan kode arbitrer dijalankan dalam transaksi yang sama.



Fsmxpada gilirannya terintegrasi dengan Ecto.Multi, memberi Anda kemampuan untuk melakukan transisi status sebagai bagian dari Ecto.Multi, dan juga menyediakan callback tambahan yang dijalankan dalam kasus ini:



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


Sekarang Anda dapat melakukan transisi seperti yang ditunjukkan:



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


Transaksi ini akan menggunakan transisi_changeset / 4 yang sama seperti yang dijelaskan di atas untuk menghitung perubahan yang diperlukan dalam model Ecto. Dan akan menyertakan panggilan balik baru sebagai panggilan ke Ecto.Multi.run . Hasilnya, email dikirim (secara asinkron, menggunakan Bamboo agar tidak terpicu dalam transaksi itu sendiri).



Jika set perubahan tidak valid karena alasan apa pun, email tidak akan pernah dikirim, sebagai akibat dari eksekusi kedua operasi tersebut.



Kesimpulan



Lain kali Anda memodelkan beberapa perilaku stateful, pertimbangkan pendekatan menggunakan pola mesin kondisi hingga (mesin kondisi terbatas), pola ini bisa menjadi penolong yang baik untuk Anda. Ini sederhana dan efektif. Template ini memungkinkan diagram transisi status yang dimodelkan dengan mudah diekspresikan dalam kode, yang akan mempercepat pengembangan.



Saya akan membuat reservasi, mungkin model aktor berkontribusi pada kesederhanaan implementasi mesin negara di Elixir \ Erlang, setiap aktor memiliki statusnya sendiri dan antrian pesan masuk, yang secara berurutan mengubah statusnya. Dalam buku " Designing scalable systems in Erlang / OTP " tentang mesin keadaan hingga ditulis dengan sangat baik, dalam konteks model aktor.



Jika Anda memiliki contoh sendiri tentang implementasi mesin negara hingga dalam bahasa pemrograman Anda, maka silakan bagikan tautannya, itu akan menarik untuk dipelajari.



All Articles