Youxing Z

Youxing Z

记一次 Ecto 的时区问题

起因

最初使用 Ecto + Postgres 来做持久层的时候,发现时区一直都是 UTC+0 时区,而我们的业务在国内(UTC+8),所以在存储/查询数据库内容的时候修改默认的时区,以更方便地将时间格式为字符串转给前端是有必要的。

我们初步排查了 Postgres 的时区设置,重置为当前所在时区,但问题依旧。于是开始排查是不是 elixir 本身对 DateTime NaiveDateTime 的设计没考虑时区问题,但很快我们通过几台不同的服务器进行测试,很容易便排除了 elixir 的问题,开始把目光聚焦在 ecto 这个库里。

发现

我们在初步翻看关于 ecto 的文档后并没有找到这个问题的解释,于是开始阅读 ecto 的源码,因为我们测试的数据都有 :inserted_at :updated_at 字段,而这两个字段是 ecto 在数据变更后自动赋值的,所以我们把目标定位到这两个字段的定义上,也就是 schema 下的 timestamps/1 这个函数。

官方文档上有对 timestamps/1 的具体解释,起初我们因为把目光一直聚集在时区问题上,一直想找到类似 timezone 这类的关键词,所以并未发现解决时区的办法,现在看来还是因为不熟悉 ecto 的设计思想而绕了弯路。

解决

我们直接翻开了 schema.ex 这个源文件,找到了这个处理时间相关的函数:

defmacro timestamps(opts \\ []) do
    quote bind_quoted: binding() do
      timestamps = Keyword.merge(@timestamps_opts, opts)

      type = Keyword.get(timestamps, :type, :naive_datetime)
      autogen = timestamps[:autogenerate] || {Ecto.Schema, :__timestamps__, [type]}

      inserted_at = Keyword.get(timestamps, :inserted_at, :inserted_at)
      updated_at = Keyword.get(timestamps, :updated_at, :updated_at)

      if inserted_at do
        opts = if source = timestamps[:inserted_at_source], do: [source: source], else: []
        Ecto.Schema.field(inserted_at, type, opts)
      end

      if updated_at do
        opts = if source = timestamps[:updated_at_source], do: [source: source], else: []
        Ecto.Schema.field(updated_at, type, opts)
        Module.put_attribute(__MODULE__, :ecto_autoupdate, {[updated_at], autogen})
      end

      with [_ | _] = fields <- Enum.filter([inserted_at, updated_at], & &1) do
        Module.put_attribute(__MODULE__, :ecto_autogenerate, {fields, autogen})
      end

      :ok
    end
  end

很明显,ecto 针对 inserted_atupdated_at 两个字段进行了特别的处理,在 autogen 那行:

autogen = timestamps[:autogenerate] || {Ecto.Schema, :__timestamps__, [type]}

我们发现此处使用了 Ecto.Schema 模块下的 __timestamps__ 函数进行时间的自动生成,该函数定义如下:

  def __timestamps__(:naive_datetime) do
    %{NaiveDateTime.utc_now() | microsecond: {0, 0}}
  end

  def __timestamps__(:naive_datetime_usec) do
    NaiveDateTime.utc_now()
  end

  def __timestamps__(:utc_datetime) do
    %{DateTime.utc_now() | microsecond: {0, 0}}
  end

  def __timestamps__(:utc_datetime_usec) do
    DateTime.utc_now()
  end

  def __timestamps__(type) do
    type.from_unix!(System.os_time(:microsecond), :microsecond)
  end

原来时间的生成是在这里处理的,并且使用了 .utc_now() 这个函数作为生成函数。所以!我们只需要把 .utc_now() 改成 .local_now() 就可以完美解决这个默认时区的问题了!

转折

于是很快提了一个 pull request 到 ecto 的仓库,但,仅 10 分钟,那个 24 小时在线的男人出现了,并无情地关闭了这个合并请求...

img

按 josevalim 所说 UTC+0 是有一定道理的,统一使用 UTC+0 这样反而会保证时区不会混乱,各个服务按自己要求再进行时间的转换就好了。

但,我们的服务跑这么久了,要重新去代码里更新数据库是不太现实的,这个问题之前未被发现也都是因为前端拿到时间字段后使用 moment.js 这样的库自动处理了。但现在因为有需求要迫使对时间做手动更改,所以不得不设置一个默认的时区。

终章

于是,根据 schema.ex 源码,我们使用宏统一修改了 @timestamps_opts 这个参数,以更换 autogenerate 的规则:

defmodule App.Schema do
  defmacro __using__(_env) do
    quote do
      use Ecto.Schema
      @timestamps_opts [type: :naive_datetime, autogenerate: {NaiveDateTime, :local_now, []}]
      import Ecto.Changeset
      import Ecto.Query
    end
  end
end

这样凡是使用到 App.Schema 模块的地方都会使用当前时区来生成时间。

上述这种方法不好,大家不要学,推荐的做法是把 Postgres 的时区设置为 UTC+0ecto 不做任何更改,给前端/客户端返回的时间需带时区信息,前端使用 API 上传数据的时候也都要带上时区信息或者使用时间戳。这样不仅仅支持多时区的服务,让大家可以统一时间问题,而且客户端在不同时区运行也都能正常显示当地时间。因为在国内时区都是采用北京时间,所以这个问题不容易暴露出来,但一旦涉及到时区的问题,就需要尽早将时区的转换下放到客户端而非服务端/数据库来处理。