今天來實現伺服器的第一個部件 - beacon_server。
功能解析
為了建立Elixir叢集,需要所有 Beam 節點在啟動之時就已經知道一個固定的節點用來連線,之後 Beam 會自動完成節點之間的連結,即預設的全連線
模式,所有節點兩兩之間均有連線。關於這一點我還沒有深入思考過有沒有必要進行調整,之後看情況再說?
因此,為了讓伺服器叢集內的所有節點在啟動時都能夠連線一個固定節點從而組成叢集,這個固定節點就是beacon_server
。
beacon_server
需要有什麼功能呢?在經過一番簡單思考後,至少需要具備以下幾個功能:
- 接受其他節點的連線
- 接受其他節點的註冊資訊
- 相應其他節點的需求,返回需求節點的資訊
這裡有兩個重要概念:資源(Resource)
和 需求(Requirement)
。資源
指某個節點自身的內容型別,也就是在叢集中所處的角色,比如閘道器伺服器的資源就是閘道器(gate_server);需求
指某個節點需要的其他節點,比如閘道器節點需要閘道器管理節點(gate_manager)來註冊自己,資料服務節點需要資料聯絡節點(data_contact)來把資料庫同步到自身。
當一個節點向beacon_server
節點註冊時,我們希望它能夠向beacon_server
提供自己的節點名稱、資源、需求等資料,方便beacon_server
在收到別的節點註冊時,能夠把已經註冊過的節點當做需求返回給別的節點。
資料結構
我用一個 GenServer
執行緒負責上面所說的所有工作,利用執行緒的 state
來儲存來往節點資訊。當前粗略想了想,姑且定義資訊儲存格式如下:
%{
nodes: %{
"node1@host": :online,
"node2@host": :offline
},
requirements: [
%{
module: Module.Interface,
name: [:requirement_name],
node: :"node@host"
}
],
resources: [
%{
module: Module.Interface,
name: :resoutce_name,
node: :"node@host"
}
]
}
我用一個字典儲存所有資訊,分為 nodes
、requirements
以及resources
三部分。
nodes
儲存所有已經連線的節點和他們的狀態,:online
表示線上正常連線,:offline
表示節點斷開連線;
requirements
儲存每個節點註冊時提供的需求資訊。使用列表儲存,列表中每個項代表一個節點。項使用字典,儲存模組(module)、名稱(name)、節點(node)資訊。其中名稱
欄位,因為有些節點可能會有不只一個需求
,因此使用列表儲存。模組
欄位是為了留著以備後用,目前沒什麼用……節點
欄位用於獲取的節點使用該欄位對目標節點傳送訊息,必不可少。
resources
儲存每個節點註冊時提供的資源資訊,欄位與requirements
完全相同,有一個不同的地方是名稱
欄位的資料型別不再是列表,而是原子,因為每個節點只可能屬於唯一的一種資源,不可能屬於兩種以上,因此用一個單一的原子就可以代表了。
簡要實現
建立專案
這是第一個實現,在實現之前,我們先建立一個umbrella
專案,用來存放之後的所有程式碼:
mix new cluster --umbrella
然後建立本節的beacon_server
專案:
cd apps/
mix new beacon_server --sup
--sup
用來生成監督樹。
有了專案之後,我們需要建立一個GenServer
,用來充當其他節點用來通訊的介面,我們就把他叫做Beacon
好了。
功能函式
根據前面的設想,我們需要下面這麼幾個函式:
- register(credentials, state) - 用於把註冊來的節點資訊記錄在
state
中,並將新的state
返回。 - get_requirements(node, requirements, resources) - 用於向已註冊的節點返回其需求。
下面貼上我粗略實現的程式碼,當然這不會是最終版本,未來還有優化的空間:
@spec register({node(), module(), atom(), [atom()]}, map()) :: {:ok, map()}
defp register(
{node, module, resource, requirement},
state = %{nodes: connected_nodes, resources: resources, requirements: requirements}
) do
Logger.debug("Register: #{node} | #{resource} | #{inspect(requirement)}")
{:ok,
%{
state
| nodes: add_node(node, connected_nodes),
resources: add_resource(node, module, resource, resources),
requirements:
if requirement != [] do
add_requirement(node, module, requirement, requirements)
else
requirements
end
}
}
end
@spec get_requirements(node(), list(map()), list(map())) :: list(map())
defp get_requirements(node, requirements, resources) do
req = find_requirements(node, requirements)
offer = find_resources(req, resources)
offer
end
上面程式碼中用到的其他私有函式我就不貼了,總之就是利用執行緒 state
中的資料返回新的資料。
除了這兩個必要的函式,我還想新增兩個能夠監控節點通斷的函式。這兩個函式通過 handle_info
實現。首先需要線上程初始化的時候開啟這項功能:
:net_kernel.monitor_nodes(true)
之後實現兩個 callback:
# ========== Node monitoring ==========
@impl true
def handle_info({:nodeup, node}, state) do
Logger.debug("Node connected: #{node}")
{:noreply, state}
end
@impl true
def handle_info({:nodedown, node}, state = %{nodes: node_list}) do
Logger.critical("Node disconnected: #{node}")
{:noreply, %{state | nodes: %{node_list | node => :offline}}}
end
不在 :nodeup
回撥中將節點狀態修改為 :online
是因為節點在註冊的時候,註冊函式已經將節點的狀態修改為 :online
了。
介面函式
有了功能之後,還需要提供對外介面,GenServer
已經提供了相關的回撥函式供我們實現,在這裡我使用 handle_call/3
,因為註冊流程需要是同步的,只有註冊完成之後對應節點才能開始正常執行。
同樣地,對外介面也是兩個,分別是 :register
和 :get_requirements
:
@impl true
# Register node with resource and requirement.
def handle_call(
{:register, credentials},
_from,
state
) do
Logger.info("New register from #{inspect(credentials, pretty: true)}.")
{:ok, new_state} = register(credentials, state)
Logger.info("Register #{inspect(credentials, pretty: true)} complete.", ansi_color: :green)
{:reply, :ok, new_state}
end
@impl true
# Reply to caller node with specified requirements
def handle_call(
{:get_requirements, node},
_from,
state = %{nodes: _, resources: resources, requirements: requirements}
) do
Logger.debug("Getting requirements for #{inspect(node)}")
offer = get_requirements(node, requirements, resources)
{:reply,
case length(offer) do
0 -> nil
_ ->
Logger.info("Requirements retrieved: #{inspect(offer, pretty: true)}", ansi_color: :green)
{:ok, offer}
end, state}
end
至此,Beacon
功能模組就基本完整了,最後我們需要把它加入到監督樹裡使其執行起來。在 application.ex
中:
def start(_type, _args) do
children = [
# Starts a worker by calling: BeaconServer.Worker.start_link(arg)
{BeaconServer.Beacon, name: BeaconServer.Beacon}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: BeaconServer.Supervisor]
Supervisor.start_link(children, opts)
end
像這樣把 Beacon
模組加入到監督者的子執行緒列表中,beacon_server
暫時就算完成了。
效果測試
執行一下試試:
iex --name beacon1@127.0.0.1 --cookie mmo -S mix
為了讓其他節點連線,name
和 cookie
一定好設定好。
我寫了點測試程式碼呼叫一下試試:
最後我們看一下 Beacon
模組的 state
長什麼樣:
就先這樣,後面我們會在此基礎上繼續實現別的伺服器。