Closed12

Elixir 1.11 + ライブラリ更新

ピン留めされたアイテム
Takanori IshikawaTakanori Ishikawa

Elixir v1.11

Elixir v1.11 released - The Elixir programming language

  • Elixir 1.10 のリリース後、チームはコンパイラの改善に取り組んできた。

    • 今よりも多くのミスをコンパイル時に判定できるように
    • 高速化
  • 他の改善

    • Erlang との連携強化
    • さらに多くのガード式
    • 組み込みの日時フォーマット
      • カレンダー周りの強化
  • Logger

    • レベルが増えて Erlang/OTP 21 の新しい logger や Syslog 標準に合うようになった
    • 構造化データ (map, keyword list) のロギングをサポート
    • モジュールごとにログレベルを設定可能に
  • IEx で Erlang モジュールのドキュメントを表示できるようになった (Erlang/OTP 23+)

  • アプリケーション境界のチェック

    • Elixir v1.11 は v1.10 で追加された Compiler Tracer を使ってアプリケーション境界を追跡している。

    • これまでも Compile callbacks によってモジュールがコンパイルされるときに処理を追加することは可能だったが、Tracer によってコンパイラのイベントを処理するモジュールを実装できる。

    • あるアプリケーション内で、明示的に依存していないアプリケーションの関数を呼び出したときに警告を出すようになった。

      • Elixir, Erlang/OTP のモジュールは常に利用可能なので、明示的に依存リストに加えていなくても使えてしまう
      • Umbrella プロジェクトでも同様。同じ VM 上でコンパイルされるため、依存に加えていなくてもコンパイルできてしまう。
    • たとえば、scrivener_htm で以下のような警告が出た😅

    • warning: Plug.Conn.Query.encode/1 defined in application :plug is used by the current application but the current application does not depend on :plug. To fix this, you must do one of:
      
        1. If :plug is part of Erlang/Elixir, you must include it under :extra_applications inside "def application" in your mix.exs
      
        2. If :plug is a dependency, make sure it is listed under "def deps" in your mix.exs
      
        3. In case you don't want to add a requirement to :plug, you may optionally skip this warning by adding [xref: [exclude: [Plug.Conn.Query]]] to your "def project" in mix.exs
      
        lib/scrivener/html.ex:60: Scrivener.HTML.Default.path/3
      
    • Umbrella プロジェクト下の各アプリケーションで循環参照が生じていないかも検出できる。

Struct の存在しないフィールドのチェックでより多くの警告を出すようになった。

user1.ex
defmodule User do
  defstruct name: nil, address: nil
  def drive?(%User{age: age}), do: age >= 18
end

これをコンパイルすると Elixir 1.10 でも次のようなエラーが出る。

$ elixirc -v      
Erlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Elixir 1.10.4 (compiled with Erlang/OTP 23)
$ elixirc user1.ex

== Compilation error in file user1.ex ==
** (CompileError) user1.ex:3: unknown key :age for struct User
    user1.ex:3: (module)

Elixir 1.11 では次のパターンでも警告が出るようになった。

user2.ex
defmodule User do
  defstruct name: nil, address: nil
  def drive?(%User{} = user), do: user.age >= 18
end

これをコンパイルすると、Elixir 1.10 では何も問題なく実行できるが、

$ elixirc -v
Erlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Elixir 1.10.4 (compiled with Erlang/OTP 23)
$ elixirc user2.ex

Elixir 1.11 では次のようなエラーが出る。

$ elixirc -v
Erlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Elixir 1.11.3 (compiled with Erlang/OTP 23)
$ elixirc user2.ex 
warning: redefining module User (current version loaded from Elixir.User.beam)
  user2.ex:1

warning: undefined field "age" in expression:

    # user2.ex:5
    user.age

expected one of the following fields: __struct__, address, name

where "user" was given the type %User{} in:

    # user2.ex:5
    %User{} = user

Conflict found at
  user2.ex:5: User.drive?/1

<<>> 演算子でのタイプチェック強化

Bitstring を構築する <<>> 演算子では、各セグメントのタイプチェックが強化された。たとえば、以下の run_length/1 関数にはバグがある。

bitstring.ex
defmodule Bits do
  def run_length(bianry) when is_binary(bianry) do
    <<byte_size(bianry)::32, bianry>>
  end
end

<<>> 演算子で指定する各セグメントのタイプのデフォルトは integer であり、バイナリ binary を展開したい場合は明示的にタイプ ::binary を指定する必要がある。

これも Elixir 1.10 では特に警告は出ないが、実行時に ArgumentError になる。

$ elixirc -v            
Erlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Elixir 1.10.4 (compiled with Erlang/OTP 23)
$ elixirc bitstring.ex
$ iex
Erlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Interactive Elixir (1.10.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Bits.run_length("hi")
** (ArgumentError) argument error
    bitstring.ex:3: Bits.run_length/1

Elixir 1.11 ではコンパイル時にチェックされるようになった。

$ elixirc -v            
Erlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Elixir 1.11.3 (compiled with Erlang/OTP 23)
$ elixirc bitstring.ex  
warning: incompatible types:

    binary() !~ integer()

in expression:

    # bitstring.ex:3
    <<..., bianry>>

where "bianry" was given the type binary() in:

    # bitstring.ex:2
    is_binary(bianry)

where "bianry" was given the type integer() in:

    # bitstring.ex:3
    <<..., bianry>>

HINT: all expressions given to binaries are assumed to be of type integer() unless said otherwise. For example, <<expr>> assumes "expr" is an integer. Pass a modifier, such as <<expr::float>> or <<expr::binary>>, to change the default behaviour.

Conflict found at
  bitstring.ex:3: Bits.run_length/1

コンパイル時間の改善

再コンパイルが必要になるような依存関係の追跡について、Elixir 1.11 では多くの改善を施している。これまでのバージョンでは、以下の 3 種類の依存関係を追跡していた。

  • コンパイル時の依存関係 (compile time dependencies) - ファイル A がファイル B にコンパイル時に依存している(AB で定義されているマクロを使用しているなど)。B を変更した場合、A が再コンパイルされる。
  • struct の依存関係 (struct dependencies) - ファイル A がファイル B に定義されている struct を使用している。B 内の struct の定義を変更した場合、A が再コンパイルされる。
  • 実行時の依存関係 (runtime dependencies) - ファイル A がファイル B に実行時に依存している場合、B を変更しても A は再コンパイルされない。

Elixir 1.11 では、上記の struct dependenciesexport dependencies に拡張した。Exports dependencies については mix xref のページの Dependencies types に詳しく説明されている。

Exports dependencies are compile time dependencies on the module API, namely structs and its public definitions. For example, if you import a module but only use its functions, it is an export dependency. If you use a struct, it is an export dependency too. Export dependencies are only recompiled if the module API changes. Note, however, that compile time dependencies have higher precedence than exports. Therefore if you import a module and use its macros, it is a compile time dependency.

これによって、importrequirecompile time dependencies から export dependencies にすることができた。具体例を見た方が分かりやすいと思うので、以下のふたつのモジュールを考える。

moduleA.ex
defmodule ElixirV11.ModuleA do
  alias ElixirV11.ModuleB

  import ModuleB, only: [fetch_name: 1]

  def hello(%ModuleB{} = m) do
    case fetch_name(m) do
      {:ok, name} ->
        IO.puts("Hello, #{name}!")

      :error ->
        IO.puts("Who?")
    end
  end
end
moduleB.ex
defmodule ElixirV11.ModuleB do
  defstruct name: nil

  def new(name), do: %__MODULE__{name: name}

  def fetch_name(%__MODULE__{name: nil}), do: :error
  def fetch_name(%__MODULE__{name: name}), do: {:ok, name}
end

moduleA.eximport ModuleB, only: [fetch_name: 1] は、Elixir 1.10 では compile time dependencies だったが、Elixir 1.11 では export dependencies になっている。

そのため、moduleB.ex を変更したときに、moduleA.ex の再コンパイルが不要である。

# Elixir 1.10
$ touch lib/moduleB.ex    
$ mix compile --verbose
Compiling 1 file (.ex)
Compiled lib/moduleB.ex
Compiled lib/moduleA.ex

# Elixir 1.11
$ touch lib/moduleB.ex 
$ mix compile --verbose
Compiling 1 file (.ex)
Compiled lib/moduleB.ex

touch だけでは効果が分かりづらいので、公開インターフェースを変えない範囲で ModuleB の実装を変えてみよう。

moduleB.ex
defmodule ElixirV11.ModuleB do
  defstruct name: 1

  def new(name), do: %__MODULE__{name: check_name!(name)}

  def fetch_name(%__MODULE__{name: nil}), do: :error
  def fetch_name(%__MODULE__{name: name}), do: {:ok, name}

  defp check_name!(%__MODULE__{name: name}) when is_binary(name), do: name
end

公開インターフェースに変更がないので、これも再コンパイルは不要である。

# Elixir 1.10
$ mix compile --verbose
Compiling 1 file (.ex)
Compiled lib/moduleB.ex
Compiled lib/moduleA.ex

# Elixir 1.11
$ mix compile --verbose
Compiling 1 file (.ex)
Compiled lib/moduleB.ex

他の変更

  • 重複した @doc で警告が出るようになった。調査したときのスクラップ

TBD

Takanori IshikawaTakanori Ishikawa

kiex で Elixir 1.11 をインストール

$ kiex install 1.11.3
$ kiex use 1.11.3
$ kiex default 1.11.3

ビルド結果など全て削除

$ rm -rf _build deps

deps を全てアップデート

$ mix deps.update --all

改めてコンパイル

$ mix deps.get
$ mix compile
Takanori IshikawaTakanori Ishikawa

Apple M1 + Big Sur のせいなのか以下のエラーが出る。

Compiling 22 files (.ex)
could not compile dependency :ex_doc, "mix compile" failed. You can recompile this dependency with "mix deps.compile ex_doc", update it with "mix deps.update ex_doc" or clean it with "mix deps.clean ex_doc"
** (File.Error) could not write to file "/Users/takanori.ishikawa/Developer/Workspace/myapp/_build/dev/lib/ex_doc/ebin/ex_doc.app": no such file or directory
    (elixir 1.10.3) lib/file.ex:1050: File.write!/3
    (mix 1.10.3) lib/mix/tasks/compile.app.ex:165: Mix.Tasks.Compile.App.run/1
    (mix 1.10.3) lib/mix/task.ex:330: Mix.Task.run_task/3
    (mix 1.10.3) lib/mix/tasks/compile.all.ex:76: Mix.Tasks.Compile.All.run_compiler/2
    (mix 1.10.3) lib/mix/tasks/compile.all.ex:56: Mix.Tasks.Compile.All.do_compile/4
    (mix 1.10.3) lib/mix/tasks/compile.all.ex:27: anonymous fn/2 in Mix.Tasks.Compile.All.run/1
    (mix 1.10.3) lib/mix/tasks/compile.all.ex:43: Mix.Tasks.Compile.All.with_logger_app/2
    (mix 1.10.3) lib/mix/task.ex:330: Mix.Task.run_task/3
** (exit) 1
    (mix 1.10.3) lib/mix/tasks/cmd.ex:45: Mix.Tasks.Cmd.run/1
    (mix 1.10.3) lib/mix/task.ex:330: Mix.Task.run_task/3
    (mix 1.10.3) lib/mix/project.ex:352: Mix.Project.in_project/4
    (elixir 1.10.3) lib/file.ex:1544: File.cd!/2
    (mix 1.10.3) lib/mix/task.ex:430: anonymous fn/4 in Mix.Task.recur/1
    (elixir 1.10.3) lib/enum.ex:2111: Enum."-reduce/3-lists^foldl/2-0-"/3
    (mix 1.10.3) lib/mix/task.ex:429: Mix.Task.recur/1
    (mix 1.10.3) lib/mix/project_stack.ex:181: Mix.ProjectStack.recur/1

出る箇所はランダムなので、File API が壊れてる?

Takanori IshikawaTakanori Ishikawa

コンパイルエラー

warning: undefined module attribute @action_fallback, please remove access to @action_fallback or explicitly set it before access
  lib/myapp/controllers/async_api/result_store_controller.ex:40: MyApp.AsyncAPI.ResultStoreController (module)


== Compilation error in file lib/myapp/controllers/async_api/result_store_controller.ex ==
** (CompileError) lib/myapp/controllers/async_api/result_store_controller.ex:1: undefined function nil/2
    (elixir 1.10.3) src/elixir_locals.erl:114: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
    (stdlib 3.14) erl_eval.erl:680: :erl_eval.do_apply/6
    (elixir 1.10.3) lib/kernel/parallel_compiler.ex:304: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7

undefined module attribute 警告は次のようなコード

@action_fallback MyApp.FallbackController

action_fallback @action_fallback

action_fallback/1 内で Macro.expand/2 するようになっている。[1]

Macro.expand/2 は module attributes も展開するはずだが... [2]

The following contents are expanded:

該当箇所で inspect してみる。

warning: undefined module attribute @action_fallback, please remove access to @action_fallback or explicitly set it before access
  lib/abeja_dap_gateway_web/controllers/async_api/result_store_controller.ex:40: AbejaDapGatewayWeb.AsyncAPI.ResultStoreController (module)

#Macro.Env<
  aliases: [
    {Cached, AbejaDapBase.Cached},
    {AsyncAPI, AbejaDapBase.AsyncAPI},
    {ApplicationInstance, AbejaDapBase.ApplicationInstance},
    {BodyParser, AbejaDapGatewayWeb.Plug.BodyParser},
    {PlanActivationRequired, AbejaDapGatewayWeb.Plug.PlanActivationRequired}
  ],
  context: nil,
  context_modules: [AbejaDapGatewayWeb.AsyncAPI.ResultStoreController],
  file: "/Users/ishikawasonkyou/Developer/Workspace/arms/apps/abeja_dap_gateway/lib/abeja_dap_gateway_web/controllers/async_api/result_store_controller.ex",
  function: nil,
  functions: [
    {AbejaDapBase.ControllerHelpers,
     [parse_integer_param: 2, parse_string_param: 2]},
    {AbejaDapBase.Plug.Conn, [fetch_organization_id: 1]},
    {AbejaDapGatewayWeb.Router.Helpers, [async_api_result_store_path: 5, ...]},
    {Plug.Conn, [...]},
    {Phoenix.Controller.Pipeline, ...},
    {...},
    ...
  ],
  lexical_tracker: #PID<0.426.0>,
  line: 40,
  macro_aliases: [],
  macros: [{Phoenix.Controller.Pipeline, ...}, {...}, ...],
  module: AbejaDapGatewayWeb.AsyncAPI.ResultStoreController,
  requires: [...],
  ...
>
nil

このへんの話か。

How to access module attributes in a macro outside quote? - Questions / Help - Elixir Programming Language Forum

That’s a useful distinction, but I don’t understand why code outside the quote is evaluated at compile-time while code inside it is evaluated at runtime.

Because code inside a quote is not run, the AST of the code is returned instead:
...
So when your module returns the quote’s return, it is just returning AST, which is then just put in place in the module at the location of the module call, it is not executed until runtime as it is just code at this point in AST form. :slight_smile:

脚注
  1. Don't create compile-time dependencies for action_fallback by michalmuskala · Pull Request #3979 · phoenixframework/phoenix ↩︎

  2. elixir - Pass Module attribute as an argument to Macros - Stack Overflow ↩︎

Takanori IshikawaTakanori Ishikawa

やはり、コンパイルの挙動が不安定なので Erlang を Homebrew ではなくソースコードからインストールし直す。

brew uninstall elixir erlang      
Uninstalling /usr/local/Cellar/elixir/1.11.3... (433 files, 6.1MB)
Uninstalling /usr/local/Cellar/erlang/23.2.1... (7,978 files, 460.7MB)

Github から clone してコンパイル

$ git clone https://github.com/erlang/otp.git
$ cd otp
$ git checkout OTP-23.2.1
$ ./otp_build autoconf
$ ./configure --with-ssl=/usr/local/opt/openssl
$ make
$ sudo make install
Takanori IshikawaTakanori Ishikawa
==> eex_html
Compiling 3 files (.ex)

== Compilation error in file lib/eex_html.ex ==
** (FunctionClauseError) no function clause matching in EExHTML.sigil_E/2    
    expanding macro: EExHTML.sigil_E/2
    lib/eex_html.ex:255: EExHTML.javascript_variables/1
    (elixir 1.11.3) lib/kernel/parallel_compiler.ex:314: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7
could not compile dependency :eex_html, "mix compile" failed. You can recompile this dependency with "mix deps.compile eex_html", update it with "mix deps.update eex_html" or clean it with "mix deps.clean eex_html"
** (exit) 1
    (mix 1.11.3) lib/mix/tasks/cmd.ex:64: Mix.Tasks.Cmd.run/1
    (mix 1.11.3) lib/mix/task.ex:394: Mix.Task.run_task/3
    (mix 1.11.3) lib/mix/project.ex:352: Mix.Project.in_project/4
    (elixir 1.11.3) lib/file.ex:1553: File.cd!/2
    (mix 1.11.3) lib/mix/task.ex:511: anonymous fn/4 in Mix.Task.recur/1
    (elixir 1.11.3) lib/enum.ex:2193: Enum."-reduce/3-lists^foldl/2-0-"/3
    (mix 1.11.3) lib/mix/task.ex:510: Mix.Task.recur/1
    (mix 1.11.3) lib/mix/project_stack.ex:181: Mix.ProjectStack.recur/1

ううむ、これは sigil_E が重複してるのかな...

$ ag sigil_E deps  
deps/scrivener_html/README.md
141:# Using Phoenix.HTML's sigil_E for EEx

deps/phoenix_html/lib/phoenix_html.ex
93:  defmacro sigil_E(expr, opts) do

deps/eex_html/lib/eex_html.ex
139:  defmacro sigil_E({:<<>>, [line: line], [template]}, []) do

依存してるのは Raxx か...。

Takanori IshikawaTakanori Ishikawa

アップデートしてテストを実行したところ、debug ログが出力されているように見える。

mix.exs を見直してみたら、こうなっていた。--no-start が原因だ...。

  defp aliases do
    [
      ...
      test: ["ecto.create --quiet", "ecto.migrate", "test --no-start"]
    ]
  end

この設定はどこから持ってきたのだろう...。

以下のようなコードで確認

IO.puts("Logger.level() = #{Logger.level()}, config = #{Application.get_all_env(:logger)[:level]}")

Elixir 1.11.3 + --no-start

Logger.level() = debug, config = warn

Elixir 1.11.3

Logger.level() = warning, config = warn

config が warn でも warning に変換される

Elixir 1.10.3 w/ or w/o --no-start

Logger.level() = warn, config = warn

Logger.level():warn

Elixir 1.10 と Elixir 1.11 での比較
Elixir version config Logger.level()
Elixir 1.10.4 :warn :warn
Elixir 1.10.4 (--no-start) :warn :warn
Elixir 1.11.0 :warn :warning
Elixir 1.11.0 (--no-start) :warn :debug

1.11 の CHANGELOG には "Soft-deprecations (no warnings emitted)" として warn ログレベルの非推奨が記載されている。

[Logger] warn log level is deprecated in favor of warning

Takanori IshikawaTakanori Ishikawa
warning: redefining @doc attribute previously set at line 161.

Please remove the duplicate docs. If instead you want to override a previously defined @doc, attach the @doc attribute to a function head:

    @doc """
    new docs
    """
    def lookup_trial(...)

@doc が重複していたら警告が出るようになった。[1] 上書きしたい場合は、関数の本体を書かない function head で書く。

脚注
  1. Warn when @doc attribute is replaced across clauses · elixir-lang/elixir@593b308 ↩︎

Takanori IshikawaTakanori Ishikawa

Ecto.Migration のテスト

Ecto 3.17 から 3.2.2 にアップデート: Migration のテスト - Qiita でも書いた通り、

もっとも、Ecto の実装が変わるたびに書き換えが必要になるのはメンテナンス性が悪い(書き換えるのも二度目)。ecto_sql の integration test のように書いた方がいいかもしれない。

3.5 にアップデートするときも辛かったので、覚悟を決めて書き直す。

  • 最新バージョンでもテスト方法は変わっていない
  • 公開モジュールである Ecto.Migrator を使ってテストを書く
    • Migration モジュールを個別で up/down できる
  • SQL Sandbox を auto にしておく(トランザクションは使用しない)
    • Ecto.Adapters.SQL.Sandbox.mode(MasterRepo, :auto)
  • 作成されたテーブルに対して SQL を実行して調べる

information_schema にクエリを発行するときは prefix が必要

MasterRepo.all(
  from(i in "columns",
    select: [i.column_name, i.data_type],
    where: i.table_name == "create_table_migration"
  ),
  prefix: "information_schema"
)

このまま実行すると以下のような警告が出る。

[warn] You are running migration 1000419 but an older migration with version 20200721175245 has already run.

This can be an issue if you have already ran 20200721175245 in production because a new deployment may migrate 1000419 but a rollback command would revert 20200721175245 instead of 1000419.

If this can be an issue, we recommend to rollback 1000419 and change it to a version later than 20200721175245.

すでにアプリケーション本体の Migration で 20200721175245 などの version で実行しているためで、この警告に対処するためには、

  • そのまま無視する
  • @moduletag :capture_log でログを出さないようにする (Ecto.Integration.MigrationTest はこれ)
  • Ecto.Migrator.up/4 に渡す version をアプリケーション本体の Migration よりも新しいものにする
このスクラップは2021/07/19にクローズされました