本篇記錄關於 Elixir 使用 Gen Server 做 Code Hot Reloading 的作法。
Hot Reloading 在本章要表達的意義 ?
對於本篇文章想表達的,就是在執行程式中途做程式碼變換,直接抽換 Function,這會是一個 Hot Patch 的行為,而且抽換時不會改變值或狀態、或執行緒本身。
這個概念在許久以前 Erlang 就已經是這樣的特性了,可以看這個 Erlang Movie
https://www.youtube.com/watch?v=xrIjfIjssLE
影片中是製作一個電話的伺服器,而且可以在電話通話中、撥號中去做熱更新,而且不會讓人家斷線,基本上就達到了 Zero Downtime。
Gen Server / OTP Server
OTP 本身是具有 Gen Server 的行為模組,而 Gen Server 本身是管控 Process, Thread 的一個大型模組,Phoenix 和各種 Elixir 程式可能都會選擇使用 Gen Server 來維持應用程式常駐 Deamon。
甚至可以透過 Supervisor 來幫你維持 Gen Server Process 的生命週期,比方說死掉時幫你復活,但這個行為本身不會是同一個 PID 或原來的 Process 狀態。
完成 Gen Server 實作
要使用 Gen Server,必須要實作一些函數,使得你的模組 (defmodule) 滿足 Gen Server 必要行為。
這個操作可以額外地 (optional),在模組內宣告 @behaviour GenServer 來表示你會完成 GenServer 必要實作,這個就會很像是 implements Interface, 或是繼承 Interface, Abstract 等說法。
本文章要實作的功能,是一個撥號伺服器,僅做撥號功能,而且是內線分機,假設我們的分機只會有三碼,所以在傳遞資料本身就用 a, b, c 的 map 元素表達。
defmodule InterphoneService do # 表示本模組保證會實作 Gen Server 所有要素 @behaviour GenServer # start_link 本身會讓 Supervisor 連結到這個模組 def start_link, do: GenServer.start_link(__MODULE__, []) # 這個是表示初始狀態是一個 %{} 的空 MAP # %{} is `state` init state def init(_opts), do: {:ok, %{}} # 表示被 GenServer 呼叫,且 Pattern Matching 到 {:dial, %{ :a=>x1, :b=>x2, :c=>x3 }} 而且是 map 的時候會執行 # input dial number or call out def handle_call({:dial, digit_map}, __sender, state) when is_map(digit_map) do # 這裡有三個回傳的內容 # 1. gen server 狀態, 2. 回傳的值, 3. state 要被更新的內容 (會直接更新 state) {:reply, :dailing, digit_map} end # 如果上面那個 handle_call 沒有被 match 到,就回傳錯誤的號碼 # input dial number or call out def handle_call({:dial, digit}, __sender, state) do {:reply, :invalid_digits, %{}} end # 被呼叫 :cancel 的時候,就重設整個狀態 # reset dial call def handle_call(:cancel, from, state) do {:reply, %{}, %{}} end # 被 :reset 的時候,不需要回傳任何狀態,但會設定執行緒狀態為 %{} def handle_cast(:reset, _state) do {:noreply, %{}} end # 被停止的時候,直接讓整個 state 清空 def handle_cast(:stop, _state) do {:stop, :normal, nil} end end
{:ok, pid} = InterphoneService.start_link() GenServer.call(pid,{:dial, %{ :a=>1, :b=>2, :c=>3}})
- 告知是哪個 pid
- 告訴要用哪個 pattern matching 參數傳到 handle_call
- %{ :a => 1 }
- %{ a: 1 }
- %{ "a": 1} *warning, 使用 : 是轉成 atom 的做法,盡量不要用字串
- % { "a" => 1 }
開始實作熱抽換 Hot Reloading #不暫停
我們想竄改一下剛才的 Hot Reloading ,讓被呼叫撥號時,可以顯示一些資訊出來,但目前撥號 Process 正在進行,有辦法做到嗎?
現在,可以直接在同一個 iex 貼上同一個程式碼,就可以立即抽換 Function 了。
抽換後,直接呼叫 call 同一個 pid,pid 也不會變,而且現在 pid 被呼叫的方法,變數 (state) 依然會存在。
由於現在的目的是要顯示收到了什麼使用者傳來的值,所以直接在 handle_call 回傳前,多一個 IO.inspect 顯示值:
defmodule InterphoneService do # 表示本模組保證會實作 Gen Server 所有要素 @behaviour GenServer # start_link 本身會讓 Supervisor 連結到這個模組 def start_link, do: GenServer.start_link(__MODULE__, []) # 這個是表示初始狀態是一個 %{} 的空 MAP # %{} is `state` init state def init(_opts), do: {:ok, %{}} # 表示被 GenServer 呼叫,且 Pattern Matching 到 {:dial, %{ :a=>x1, :b=>x2, :c=>x3 }} 而且是 map 的時候會執行 # input dial number or call out def handle_call({:dial, digit_map}, __sender, state) when is_map(digit_map) do IO.inspect("----------------------------") IO.inspect(state) IO.inspect("----------------------------") # 這裡有三個回傳的內容 # 1. gen server 狀態, 2. 回傳的值, 3. state 要被更新的內容 (會直接更新 state) {:reply, :dailing, digit_map} end # 如果上面那個 handle_call 沒有被 match 到,就回傳錯誤的號碼 # input dial number or call out def handle_call({:dial, digit}, __sender, state) do {:reply, :invalid_digits, %{}} end # 被呼叫 :cancel 的時候,就重設整個狀態 # reset dial call def handle_call(:cancel, from, state) do {:reply, %{}, %{}} end # 被 :reset 的時候,不需要回傳任何狀態,但會設定執行緒狀態為 %{} def handle_cast(:reset, _state) do {:noreply, %{}} end # 被停止的時候,直接讓整個 state 清空 def handle_cast(:stop, _state) do {:stop, :normal, nil} end end
GenServer.call(pid,{:dial, %{ :a=>1, :b=>2, :c=>3}})
開始實作熱抽換 Hot Reloading #暫停
很明顯的剛才這個撥號程式太廢了,用 map 存有敘的號碼也太反資料結構,而且也很反人類,此時此刻,你的 pid 上的 state 就算是被 dialing 後,還是存著 %{ a: 1, b: 2, c:3 } 這個詭異的結構在記憶體血脈中。
如果想要將它直接熱抽換成使用陣列加減作法,勢必會直接出錯,因為 state 是 map,沒辦法直接適應 array。
此時,Erlang 對熱抽換有 migration 的作法,可以讓你的 state 經過變遷,而你的 state 值會是用你轉型的結構處理。
這個方法是在 defmodule 裡面多實作一個 code_change,所以,以下直接實作全部使用陣列、也有 code_change 的程式:
defmodule InterphoneService do @behaviour GenServer def start_link, do: GenServer.start_link(__MODULE__, []) # [] is `state` init state def init(_opts), do: {:ok, []} # input dial number or call out def handle_call({:dial, digit}, __sender, state) when is_integer(digit) do IO.inspect(state) digits = state ++ [digit] if length(digits) != 3 do {:reply, digits, digits} else {:reply, :dailing, []} end end # input dial number or call out def handle_call({:dial, digit}, __sender, state) do {:reply, :invalid_digits, []} end # reset dial call def handle_call(:cancel, from, state) do {:reply, [], []} end def handle_cast(:reset, _state) do {:noreply, []} end def handle_cast(:stop, _state) do {:stop, :normal, nil} end # https://medium.com/blackode/how-to-perform-hot-code-swapping-in-elixir-afc824860012 # migrate from %{:a=>1, :b=>2, :c=>3} from :[] keyword-list to array # %{ :a=>1, :b=>2, :c=>3} will be same as %{ a: 1, b: 2, c: 3} def code_change(_old_vsn, %{ a: a, b: b, c: c} = old_state, _extra) do IO.inspect("===========================") {:ok, [a,b,c]} end end
從這裡,可以看到每一個 %{} 都換成空 [],而且 handle_call 已經採用連續呼叫制,每次呼叫就傳一個要撥號內線的分機號碼順序。
還有一個 code_change,它是一個 pattern_matching,而且它的參數是:
- old_vsn <- 舊的版本號,如果有指定,那就會讓你配對到指定要的舊版本號字串;沒指定基本上就是對所有人都替換
- old_state <- 該舊版本的狀態傳入,這也是一個 matching 指定樣板
- extra <- 看是否有需要在轉換時多加一些參考值,讓開發者自行實作
我們可能會認為 code_change 預設觸發時機,就是抽換當下,但其實不是這樣的,當你貼上 iex 時這段 code_change 也不會被執行,除非你呼叫 :sys 底下的功能幫你對特定 pid 做這件事。
而且,這個 pid 必須要被暫停,暫停不等於終結,狀態還是會存在,暫停的目的是避免繼續被傳入任何參數,導致之後 code_change 的 migrated 也跟有做沒做都一樣。
:sys.suspend pid
:sys.change_code pid, InterphoneService, "version-4", nil
- 指定的 pid
- 你剛才抽換的模組名稱
- 你想給這個版本叫做什麼 (字串) ,不管的話就隨便給
- extra 參考用資訊
:sys.resume pid
:sys.get_state pid
GenServer.call(pid,{:dial, 2}) GenServer.call(pid,{:dial, 1}) GenServer.call(pid,{:dial, 3})
且第三個就會得到撥號的狀態了。
References:
https://elixirschool.com/en/lessons/advanced/otp-concurrency/
https://erlang.org/doc/man/gen_server.html
http://erlang.org/pipermail/erlang-questions/2008-June/036243.html
https://blog.appsignal.com/2021/07/13/building-aggregates-in-elixir-and-postgresql.html
https://medium.com/blackode/how-to-perform-hot-code-swapping-in-elixir-afc824860012
沒有留言:
張貼留言