2021年7月17日 星期六

Elixir - Gen Server Hot Reloading

本篇記錄關於 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



直接放到 iex 執行它,你就有這個 defmodule 了,接著,還需要呼叫它出來看看:

{:ok, pid} = InterphoneService.start_link()
GenServer.call(pid,{:dial, %{ :a=>1, :b=>2, :c=>3}})


現在,它應該會回傳 :dailing 告訴你撥號了。

其中做法是先 start_link,然後讓 GenServer 用 call 這個方法,給出兩個參數:
  1. 告知是哪個 pid
  2. 告訴要用哪個 pattern matching 參數傳到 handle_call 

其中,與 state 有關的變數,就是這個 Gen Server 被開出 Process 時的狀態變數 (它會存在記憶體的一個區域當作狀態) , 上述 handle_call 的三個內容,最後一個就會控制這個狀態變數要被更新成什麼。


而模組 defmodule 本身會放在 Heap。



關於 Map Matching 小註解


在這裡的 %{ :a=>1 }, 使用 :a 是因為 map 的元素是 atom, 稍微在這裡做比較:

  1.  %{ :a => 1 }
  2.  %{ a: 1 }
  3.  %{ "a": 1} *warning, 使用   :  是轉成 atom 的做法,盡量不要用字串
  4.  % { "a" => 1 }
這裡有四個看起來很像的 map 作法,只有 4 完全不等價 (無法 matching) 到其他 1,2,3 的寫法。

這是因為 1~3 都是在指你的 map key 為 atom 的狀況,使用 2,3 的冒號寫法,他則會將你的 key 自動轉成 :a 的形式出現。

4 則是你的 key 為 string type,因此與 atom 不一樣。




開始實作熱抽換 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



以上是直接在 handle_call 加一個精美的 plot 顯示出東西,還會有 ------------ 夾在上下提示。

直接拿到同一個 iex 執行後,直接呼叫:

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,而且它的參數是:

  1. old_vsn <- 舊的版本號,如果有指定,那就會讓你配對到指定要的舊版本號字串;沒指定基本上就是對所有人都替換

  2. old_state <- 該舊版本的狀態傳入,這也是一個 matching 指定樣板

  3. extra <- 看是否有需要在轉換時多加一些參考值,讓開發者自行實作


我們可能會認為 code_change 預設觸發時機,就是抽換當下,但其實不是這樣的,當你貼上 iex 時這段 code_change 也不會被執行,除非你呼叫 :sys 底下的功能幫你對特定 pid 做這件事。


而且,這個 pid 必須要被暫停,暫停不等於終結,狀態還是會存在,暫停的目的是避免繼續被傳入任何參數,導致之後 code_change 的 migrated 也跟有做沒做都一樣。


:sys.suspend pid


現在,剛才這個 pid 被暫停了。

接著,可以把剛才的程式碼整段貼上 iex ,然後呼叫 code_change。


:sys.change_code pid, InterphoneService, "version-4", nil


注意,這個 change_code 帶有 4 個參數:
  1.  指定的 pid
  2.  你剛才抽換的模組名稱
  3.  你想給這個版本叫做什麼 (字串) ,不管的話就隨便給
  4.  extra 參考用資訊

一旦呼叫後,change_code 就會把 state 的 map 通通轉成 array [],然後,要把這個 pid 恢復才能用。


:sys.resume pid


恢復後,可以檢查一下 pid state:

:sys.get_state pid


此時就會看到剛才的狀態已經被轉換了。


此時,打電話的方法就跟第一個不一樣了,如果你要撥內線 213 ,你就需要:



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

沒有留言:

張貼留言

© Mac Taylor, 歡迎自由轉貼。
Background Email Pattern by Toby Elliott
Since 2014