[elixir! #0036]关于 elixir 应用的配置

[elixir! #0036]关于 elixir 应用的配置

缘起

最近完成了一个 elixir 项目, 打包发布之后, 却遇到了一些问题. config 文件中的配置信息, 都在编译的时候固定了, 打包之后就无法更改配置了. 为了解决这个问题, 实现运行时的配置, 我查询了一些资料, 整理成本文.

Application.get_env

Application, 即应用, 是 elixir/erlang 世界里实现某些特定功能的单位. 比如在新建 Phoenix 项目的时候, 就会创建一个 lib/MyApp.ex 文件, 包含了这个应用的 start 等函数. elixir 提供了一个 Application.get_env(app, key, default \\ nil) 函数, 我们可以很方便地获取到某个应用的某个环境变量的值. 我在想, 这些变量是存储在哪里的, 是查询速度很快的ets里吗? 于是我查看了一下这个函数的实现:

@spec get_env(app, key, value) :: value
  def get_env(app, key, default \\ nil) do
    :application.get_env(app, key, default)
  end
-spec get_env(Application, Par, Def) -> Val when
      Application :: atom(),
      Par :: atom(),
      Def :: term(),
      Val :: term().

get_env(Application, Key, Def) ->
    case get_env(Application, Key) of
    {ok, Val} ->
        Val;
    undefined ->
        Def
    end.

到这里, 还只是在实现默认值的功能, 继续看看:

-spec get_env(Application, Par) -> 'undefined' | {'ok', Val} when
      Application :: atom(),
      Par :: atom(),
      Val :: term().

get_env(Application, Key) -> 
    application_controller:get_env(Application, Key).

突然冒出了一个 application_controller 模块, 它大概是核心人物了吧, 继续看看:

get_env(AppName, Key) ->
    case ets:lookup(ac_tab, {env, AppName, Key}) of
    [{_, Val}] -> {ok, Val};
    _ -> undefined
    end.

果然和我们预想的一样, 所有应用的环境变量都存储在一个名为 ac_tab 的 ets 表中, 以环境名, 应用名和变量的Key组成的三元组来进行查询. 这就意味着, 在运行时修改这些环境变量的值是有可能的.

Mix.Config

那么, elixir 是在什么时候往这个表里写入数据的呢. 在 Phoenix 项目中, 我们一般把配置文件写在 config 目录下, 每个配置文件都需要用到 use Mix.Config. Mix 是elixir 内置的编译工具, 也是 elixir 世界里的管家, 项目的新建, 编译, 测试等它都要插一手. 让我来看看它都干了什么好事:

defmacro __using__(_) do
    quote do
      import Mix.Config, only: [config: 2, config: 3, import_config: 1]
      {:ok, agent} = Mix.Config.Agent.start_link
      var!(config_agent, Mix.Config) = agent
    end
  end

这里普及一点元编程的内容, var!(var, context) 函数的作用就是在宏内使用调用者上下文的变量. 在调用 use Mix.Config 之后, 首先导入了 Mix.Config 模块中的三个函数, 然后启动了一个 Agent, 并将它的 pid 绑定到变量 config_agent 上. 这个Agent是干嘛用的呢? 猜测一下, 应该是用来临时存储配置的, 最后再写入到 ets.

接下来就要看下 config(app, opts)config(app, key, opts) 函数了. 看到这里, 我想它们的作用应该就是将配置信息写入到 config_agent 中:

defmacro config(app, opts) do
    quote do
      Mix.Config.Agent.merge var!(config_agent, Mix.Config), [{unquote(app), unquote(opts)}]
    end
  end

  defmacro config(app, key, opts) do
    quote do
      Mix.Config.Agent.merge var!(config_agent, Mix.Config),
        [{unquote(app), [{unquote(key), unquote(opts)}]}]
    end
  end

看一下, Mix.Config.Agent.merge(agent, new_config) 函数:

@spec merge(pid, config) :: config
  def merge(agent, new_config) do
    Agent.update(agent, &Mix.Config.merge(&1, new_config))
  end

在 elixir 中, 如果一个进程的作用只是用来做简单的数据存取, 那么可以使用 Agent. 虽然很多人更偏向于只使用 GenServer~. 这里就不看 Mix.Config.merge/2 函数了, 它的作用就是将相同 app 的配置合并到一个列表.

那么, 调用完 config 函数, 配置信息都写入了 agent, 那么什么时候写入 ets 呢? 是在编译时吗, 还是运行时? 首先, 编译时肯定是有写入的, 因为我们经常会像这样 @something Application.get_env(:my_app, :something) 将环境变量获取到模块属性中, 而模块属性的值是在编译时确定的.

一点魔法

有很重要的一点我们还没有确定, 那就是 myapp/config 目录下的这些.exs 文件到底是什么时候执行的, elixir又是怎么获得 config_agent 的pid 的. 我在 Mix.Config 模块中发现了这个函数:

def read!(file, loaded_paths \\ []) do
    try do
      if file in loaded_paths do
        raise ArgumentError, message: "recursive load of #{file} detected"
      end

      {config, binding} = Code.eval_string File.read!(file), [{{:loaded_paths, Mix.Config}, [file | loaded_paths]}], [file: file, line: 1]

      config = case List.keyfind(binding, {:config_agent, Mix.Config}, 0) do
        {_, agent} -> get_config_and_stop_agent(agent)
        nil        -> config
      end

      validate!(config)
      config
    rescue
      e in [LoadError] -> reraise(e, System.stacktrace)
      e -> reraise(LoadError, [file: file, error: e], System.stacktrace)
    end
  end

  defp get_config_and_stop_agent(agent) do
    config = Mix.Config.Agent.get(agent)
    Mix.Config.Agent.stop(agent)
    config
  end

我们看到, 这里使用 Code.eval_string 函数, 执行 .exs配置文件, 并且粗暴地提取了其中的 config_agent 变量, 也就是用于存放环境变量列表的 Agent 的pid. 然后从该 Agent 里获取 config , 并将其终结.

Mix.Tasks.Loadconfig 模块里, 我们看到这个函数:

defp load(file) do
    apps = Mix.Config.persist Mix.Config.read!(file)
    Mix.ProjectStack.configured_applications(apps)
    :ok
  end

其它的代码这里就不展示了, 简而言之, 每次运行 mix ... 命令时, 都会先执行 mix loadconfig 来完成配置文件的载入工作.

[elixir! #0036]关于 elixir 应用的配置

sys.config

现在, 我们已经搞清楚了 elixir 是如何读取和保存环境变量的. 问题在于, 当我们使用 distillery 等发布工具将项目打包之后, 就无法使用 Mix 了, 那要如何修改配置呢.

事实上, 打包后的 my_app.tar.gz 文件解压缩后, 会附带一个 var/sys.config 文件, 里面有我们在 config.exs 中的所有配置. 修改它, 并将其保存到上层目录, 再运行项目, 就大功告成啦.

至于erlang是如何读取 sys.config 文件, 以及如何以更简单的方式修改该文件, 我们下回分解.

相关推荐