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

AtomでSvelte 3のSyntax Highlight

https://svelte.dev/docs#Component_format にもあるように*.svelteはだいたいHTMLなので、HTMLのハイライトを使うようにエディタを設定してやればよさそう。ブログに書くときもHTMLってことにすれば

<script>
  export let name
</script>

<style>
  .p {
    color: purple;
  }
</style>

<p>Hello {name}</p>

こんな感じ。

で、Atomの設定を眺めたけどそういう項目がなかったのでググり、config.csoncustomFileTypesを書くとよいという知見を得た。

"*":
  ...
  core:
    ...
    customFileTypes:
      "text.html.basic": [
        "svelte"
      ]

一応svelte-atomっていうパッケージはあったけど、v3には対応してなさそうだった。多分そのうちlanguage-svelteみたいなのが現れると思う。

Phoenixでresourcesをネストさせるときにaliasも設定できる

普通にやるならこうする。

scope "/v1", MyApp do
  pipe_through :api

  resources "/users", UserController, only: [:index] do
    resources "/articles", ArticleController, only: [:index]
  end
end

生成されるルーティングはこれ。

$ mix phx.routes
        user_path  GET  /v1/users                    MyApp.UserController :index
user_article_path  GET  /v1/users/:user_id/articles  MyApp.ArticleController :index

ただしこれだと/v1/articlesを入れたときに

scope "/v1", MyApp do
  pipe_through :api

  resources "/users", UserController, only: [:index] do
    resources "/articles", ArticleController, only: [:index]
  end
  resources "/articles", ArticleController, only: [:index]
end
$ mix phx.routes
        user_path  GET  /v1/users                    MyApp.UserController :index
user_article_path  GET  /v1/users/:user_id/articles  MyApp.ArticleController :index
     article_path  GET  /v1/articles                 MyApp.ArticleController :index

というルーティングになるので、/v1/users/:user_id/articlesと同じコントローラを使うことになる。それでいいならOKだけど、別にしたいときはscope/2でaliasを入れてあげる。

scope "/v1", MyApp do
  pipe_through :api

  resources "/users", UserController, only: [:index] do
    scope alias: User do
      resources "/articles", ArticleController, only: [:index]
    end
  end
  resources "/articles", ArticleController, only: [:index]
end
$ mix phx.routes
        user_path  GET  /v1/users                    MyApp.UserController :index
user_article_path  GET  /v1/users/:user_id/articles  MyApp.User.ArticleController :index
     article_path  GET  /v1/articles                 MyApp.ArticleController :index

MyApp.User.ArticleControllerMyApp.ArticleControllerに分けられる。ここまでは何も特別なことは言ってなくて、ドキュメントに書いてあるしわざわざブログ書くようなことじゃない。自分もこんな風にscope/2使うのが普通と思っていたんだけど、最近うろ覚えで「こうだっけ?」って↓のように書いたら同じ動きをした。

scope "/v1", MyApp do
  pipe_through :api

  resources "/users", UserController, only: [:index], alias: User do # ここに alias
    resources "/articles", ArticleController, only: [:index]
  end
  resources "/articles", ArticleController, only: [:index]
end
$ mix phx.routes
        user_path  GET  /v1/users                    MyApp.UserController :index
user_article_path  GET  /v1/users/:user_id/articles  MyApp.User.ArticleController :index
     article_path  GET  /v1/articles                 MyApp.ArticleController :index

ソースを追っていくとhttps://github.com/phoenixframework/phoenix/blob/v1.4.1/lib/phoenix/router/resource.ex#L32にたどり着いて、確かにresourcesaliasオプション受け入れてくれるんだなーって感じ。でもhttps://hexdocs.pm/phoenix/1.4.1/Phoenix.Router.html#resources/4には言及がなかったので、とりあえずプルリク投げといた。