mmag

ハマったことメモなど

Protocolの@deriveについて調べた

Elixir 1.9がそろそろ出そうなところに1.8の話をするんですけど、1.8からInspectプロトコルで文字列化される構造体のメンバをキーで指定できるようになりました。

defmodule User do
  @derive {Inspect, only: [:id, :name, :age]}
  defstruct [:id, :name, :age, :email, :encrypted_password]
end

こうしておくと、意図せずログにメールアドレスとか個人情報やらが出力されることを防げたり、人間が見てもあんま意味ないものを出さないようにできるというわけですね。

で本題は、こういうものを自分で実装するにはどうしたらいいんでしょうか、ということを思ったので書いてみました、というものです。

defstructのドキュメントProtocol.derive/3のドキュメントに全て書いてあるんですけど、Jason.Encoderのコードも読んで参考にしました。 Elixirのドキュメント、こっちが知りたいことは大抵書いてあるんですよね。どういうことなんですかね。

練習課題

構造体メンバの値を"*****"に置き換えるMaskプロトコルを実装します。:onlyオプションを付けた場合は、指定したものだけ"*****"に置き換えます。使われ方の例はこちら。

defmodule User do
  @derive {Mask, only: [:password]}
  defstruct [:name, :age, :password]
end

Mask.mask %User{name: "John", age: 29, password: "secret123"}
#=> %User{name: "John", age: 29, password: "*****"}

実装

defprotocol Mask do
  def mask(struct)
end

defimpl Mask, for: Any do
  defmacro __deriving__(module, struct, opts) do
    all_fields = struct |> Map.drop([:__struct__]) |> Map.keys()
    mask_target = Keyword.get(opts, :only, all_fields)

    quote do
      defimpl Mask, for: unquote(module) do
        def mask(struct) do
          Enum.reduce(unquote(mask_target), struct, fn (field, acc) ->
            Map.put(acc, field, "*****")
          end)
        end
      end
    end
  end

  def mask(_any) do
    raise Protocol.UndefinedError
  end
end

説明

@deriveはモジュール属性を設定するだけなので、特にこれが何をやってるとかではないです。何かやってるのはdefstructの方で、@derive属性が設定されている場合に、この辺りから諸々呼ばれてMask.__deriving__/3マクロが展開されます。このマクロの中でdefimpl Mask, for: Userしておいてあげると、Mask.mask(%User{})できるようになるという寸法です。__deriving__/3の第3引数には@deriveに書いたキーワードリスト(上の例だと[only: [:password]]が渡ってくるので、よしなに利用してあげればマスクするべきキーが判定できます。今回はdefimplしてないもの以外はraiseするようにしたので、例えばnilなんか投げるとこうなります。

Mask.mask(nil)
# ** (Protocol.UndefinedError) protocol Mask not implemented for nil. This protocol is implemented for: User
#     lib/deriver/protocol.ex:1: Mask.impl_for!/1
#     lib/deriver/protocol.ex:2: Mask.mask/1