MMO伺服器從零開始(1) - Beacon Server

dyzdyz010發表於2022-06-11

今天來實現伺服器的第一個部件 - beacon_server

功能解析

為了建立Elixir叢集,需要所有 Beam 節點在啟動之時就已經知道一個固定的節點用來連線,之後 Beam 會自動完成節點之間的連結,即預設的全連線模式,所有節點兩兩之間均有連線。關於這一點我還沒有深入思考過有沒有必要進行調整,之後看情況再說?

因此,為了讓伺服器叢集內的所有節點在啟動時都能夠連線一個固定節點從而組成叢集,這個固定節點就是beacon_server

beacon_server需要有什麼功能呢?在經過一番簡單思考後,至少需要具備以下幾個功能:

  1. 接受其他節點的連線
  2. 接受其他節點的註冊資訊
  3. 相應其他節點的需求,返回需求節點的資訊

這裡有兩個重要概念:資源(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"
    }
  ]
}

我用一個字典儲存所有資訊,分為 nodesrequirements以及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

為了讓其他節點連線,namecookie 一定好設定好。

我寫了點測試程式碼呼叫一下試試:

Beacon Server Output

最後我們看一下 Beacon 模組的 state 長什麼樣:

Beacon State

就先這樣,後面我們會在此基礎上繼續實現別的伺服器。

相關文章