エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

C++のクラスをNIFでElixirにバインドしてみた

C++ のライブラリを Elixir から利用したくなりました。

NIF を使った単純な関数の呼び出しは実装したことがあるのですが、オブジェクトを利用するようなケースは試したことがなかったので、今回調べてみました。

今回のコードではエラーハンドリングなどは省略していますのでご注意ください。 不備がありましたらご指摘いただけるとありがたいです。

またここで実装したファイルは Gist にもアップしていますので合わせてご参照ください。

NIF環境の用意

elixir_make

mix compile コマンドで C++ のコードがいっしょにコンパイルされるように、elixir_make を導入します。

hex.pm

まず依存パッケージとして mix.exselixir_make を追加します。 このパッケージはコンパイル時のみ利用するので [runtime: false] のオプションを指定します。

またコンパイラのリストに :elixir_make を追加して、 mix compile 実行時に合わせて実行されるようにします。

defmodule Counter.MixProject do
  use Mix.Project

  def project do
    [
      app: :counter,
      version: "0.1.0",
      elixir: "~> 1.11",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      compilers: [:elixir_make | Mix.compilers()] # 追加
    ]
  end

  def application do
    [
      extra_applications: [:logger]
    ]
  end

  defp deps do
    [
      {:elixir_make, "~> 0.6", runtime: false} # 追加
    ]
  end
end

counter_nif

NIF のコードを C++ で記述します。 ここではまず動作確認のために引数の値をそのまま戻り値として返す identity/1 を実装してみます。

// c_src/counter_nif.cpp

#include <erl_nif.h>

static ERL_NIF_TERM identity(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    return argv[0];
}

static ErlNifFunc nif_funcs[] = {
    {"identity", 1, identity}
};

ERL_NIF_INIT(Elixir.Counter, nif_funcs, nullptr, nullptr, nullptr, nullptr);

Makefile

Makefile を用意します。

変数の意味の詳細については elixir_makeのドキュメント を参照してください。

PREFIX = $(MIX_APP_PATH)/priv
BUILD = $(MIX_APP_PATH)/obj

NIF = $(PREFIX)/counter_nif.so

CFLAGS = -std=c++11 -fpic
LDFLAGS = -lpthread -dynamiclib -undefined dynamic_lookup

ERL_CFLAGS = -I$(ERL_EI_INCLUDE_DIR)
ERL_LDFLAGS = -L$(ERL_EI_LIBDIR) -lei

SRC = $(wildcard c_src/*.cpp)
HEADERS = $(wildcard c_src/*.h)
OBJ = $(SRC:c_src/%.cpp=$(BUILD)/%.o)

all: install

install: $(PREFIX) $(BUILD) $(NIF)

$(OBJ): $(HEADERS) Makefile

$(BUILD)/%.o: c_src/%.cpp
  $(CC) -c $(CFLAGS) $(ERL_CFLAGS) -o $@ $<

$(NIF): $(OBJ)
  $(CC) $(LDFLAGS) $(ERL_LDFLAGS) -o $@ $^

$(PREFIX) $(BUILD):
  mkdir -p $@

clean:
  $(RM) $(NIF) $(OBJ)

.PHONY: all clean install

Counter module

NIF をバインドする Elixir のモジュールを記述します。

defmodule Counter do
  @on_load {:load_nif, 0}
  @compile {:autoload, false}

  def load_nif do
    Application.app_dir(:counter, "priv/counter_nif")
    |> to_charlist()
    |> :erlang.load_nif(0)
  end

  def identity(_term), do: :erlang.nif_error(:nif_not_loaded)
end

動作確認

ここまでの編集でファイル構成はこのようになっています。

├── Makefile
├── README.md
├── c_src/
│   └── counter_nif.cpp
├── lib/
│   └── counter.ex
├── mix.exs
└── test/
    ├── counter_test.exs
    └── test_helper.exs

mix deps.get でパッケージを取得し、 iex -S mix で IEx を起動します。

問題がなければ make が実行され、Makefile に記述した内容で NIF が作成されます。 Counter.identiy/1 を呼び出して NIF が機能していることを確認します。

$ mix deps.get
$ iex -S mix
iex(1)> Counter.identity(123)
123
iex(2)> Counter.identity("abc")
"abc"
iex(3)> Counter.identity(:abc) 
:abc

C++の Counter クラス

C++ のクラスを追加します。

ここでは簡単なカウンタクラスを例として用います。

// c_src/counter.h

#ifndef COUNTER_H_
#define COUNTER_H_

class Counter {
public:
    Counter(int count = 0) : count_(count) {}
    ~Counter() {}

    int up() { return ++count_; }
    int down() { return --count_; }
    void set(int count) { count_ = count; }
    int get() const { return count_; }

private:
    int count_;
};

#endif//COUNTER_H_

リソースの確保と解放

リソースの確保では次の関数を利用します。

また enif_open_resource_type は NIF がロードされたときに呼び出される必要があるので、ロード時に呼び出されるコールバック関数を用意します。

リソースの解放は garbage collection にまかせます。 リソースが解放されるときに呼び出される関数を用意し、 enif_open_resource_type でその関数を設定します。

counter_nif

c_src/counter_nif.cpp を次のように変更します。 ここではリソースを確保されたときと解放されたときの様子がわかるように enif_fprintf を挿入しています。

#include <cstdio>
#include <erl_nif.h>

// 先に定義した Counter クラスの定義ファイル
#include "counter.h"

// リソースタイプを保存する変数
ErlNifResourceType* CounterType;

// リソースが破棄されるときに呼び出される関数
void destruct_counter(ErlNifEnv* caller_env, void* obj) {
    // リソースが解放される前に、必要な破棄の操作をここに記述する
    enif_fprintf(stdout, "destruct %p\n", obj);
}

// NIF がロードされたときに呼び出される関数
int load(ErlNifEnv* caller_env, void** priv_data, ERL_NIF_TERM load_info) {
    // リソースタイプを作成する
    // 第4引数にリソースが破棄されるときに呼び出される関数を指定する
    CounterType = enif_open_resource_type(caller_env, "Counter", "Counter", destruct_counter, ERL_NIF_RT_CREATE, nullptr);

    return 0;
}

// リソースを確保する関数。Elixir から呼び出される
ERL_NIF_TERM create(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    // リソースを確保する
    void* resource = enif_alloc_resource(CounterType, sizeof(Counter));

    // 確保されたリソースに対して、必要な構築の操作をここに記述する
    enif_fprintf(stdout, "construct %p\n", resource);

    // Erlang がリソースを管理するためのハンドルを作成する
    // これで garbage collection の対象になる
    ERL_NIF_TERM handle = enif_make_resource(env, resource);

    // リソースへの参照を手放す
    enif_release_resource(resource);

    // ハンドルを返却する
    return enif_make_tuple2(
        env,
        enif_make_atom(env, "ok"),
        handle
    );
}

static ErlNifFunc nif_funcs[] = {
    {"create", 1, create}
};

// 第3引数にロード時に呼び出される関数を指定する
ERL_NIF_INIT(Elixir.Counter, nif_funcs, load, nullptr, nullptr, nullptr);

Counter module

Counter を次のように変更します。

defmodule Counter do
  @on_load {:load_nif, 0}
  @compile {:autoload, false}

  def load_nif do
    Application.app_dir(:counter, "priv/counter_nif")
    |> to_charlist()
    |> :erlang.load_nif(0)
  end

  def create(_init_count), do: :erlang.nif_error(:nif_not_loaded)
end

動作確認

IEx から Counter.create/1 を呼び出します。

$ iex -S mix
iex(1)> Counter.create(0)     
construct 0x00007ff929604a78
{:ok, #Reference<0.3631837740.152436741.82065>}

NIF 内に記述したコードによってリソースのポインタが表示されることが確認できます。 またリソースのハンドルは Elixir の環境からは reference として参照されることがわかります。

このリソースは garbage collection によって解放されますが、上記のように実行すると IEx のシェルの環境から参照されているため解放を確認できません。

mix run コマンドで実行して解放を確認します。

$ mix run -e 'Counter.create(0)'
construct 0x00007fb214e033e8
destruct 0x00007fb214e033e8

リソース解放時に呼び出される関数として登録した destruct_counter が実際に呼び出されていることがわかります。

オブジェクトの構築と破棄

メモリの獲得と解放/分離とオブジェクトの構築/破棄を分離して行うための構文、 placement new と placement delete を利用します。

c_src/counter_nif.cpp を次のように変更します。

#include <new>
#include <erl_nif.h>

#include "counter.h"

ErlNifResourceType* CounterType;

void destruct_counter(ErlNifEnv* caller_env, void* obj) {
    Counter* counter = static_cast<Counter*>(obj);

    // placement delete でオブジェクトを破棄する
    counter->~Counter();
}

int load(ErlNifEnv* caller_env, void** priv_data, ERL_NIF_TERM load_info) {
    CounterType = enif_open_resource_type(caller_env, "Counter", "Counter", destruct_counter, ERL_NIF_RT_CREATE, nullptr);

    return 0;
}

ERL_NIF_TERM create(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    int count;
    enif_get_int(env, argv[0], &count);

    void* resource = enif_alloc_resource(CounterType, sizeof(Counter));

    // placement new でオブジェクトを構築する
    new(resource) Counter(count);

    ERL_NIF_TERM handle = enif_make_resource(env, resource);
    enif_release_resource(resource);

    return enif_make_tuple2(
        env,
        enif_make_atom(env, "ok"),
        handle
    );
}

static ErlNifFunc nif_funcs[] = {
    {"create", 1, create}
};

ERL_NIF_INIT(Elixir.Counter, nif_funcs, load, nullptr, nullptr, nullptr);

メンバ関数の呼び出し

あとは構築したオブジェクトに対して操作を行う関数を追加するだけです。

Elixir のコードに渡された reference = リソースのハンドルを再び NIF に返すと、enif_get_resource でリソースのポインタとして受け取ることができます。

    void* resource;
    enif_get_resource(env, argv[0], CounterType, &resource);
    Counter* counter = static_cast<Counter*>(resource);

もしくは

    Counter* counter;
    enif_get_resource(env, argv[0], CounterType, reinterpret_cast<void**>(&counter));

counter_nif

Counterメンバ関数 up, down, set, get に対応する関数を実装します。

c_src/counter_nif.cpp を次のように変更します。

#include <new>
#include <erl_nif.h>

#include "counter.h"

ErlNifResourceType* CounterType;

void destruct_counter(ErlNifEnv* caller_env, void* obj) {
    Counter* counter = static_cast<Counter*>(obj);
    counter->~Counter();
}

int load(ErlNifEnv* caller_env, void** priv_data, ERL_NIF_TERM load_info) {
    CounterType = enif_open_resource_type(caller_env, "Counter", "Counter", destruct_counter, ERL_NIF_RT_CREATE, nullptr);

    return 0;
}

ERL_NIF_TERM create(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    int count;
    enif_get_int(env, argv[0], &count);

    void* resource = enif_alloc_resource(CounterType, sizeof(Counter));
    new(resource) Counter(count);

    ERL_NIF_TERM handle = enif_make_resource(env, resource);
    enif_release_resource(resource);

    return enif_make_tuple2(
        env,
        enif_make_atom(env, "ok"),
        handle
    );
}

ERL_NIF_TERM up(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    void* resource;
    enif_get_resource(env, argv[0], CounterType, &resource);
    Counter* counter = static_cast<Counter*>(resource);

    int result = counter->up();

    return enif_make_tuple2(
        env,
        enif_make_atom(env, "ok"),
        enif_make_int(env, result)
    );
}

ERL_NIF_TERM down(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    void* resource;
    enif_get_resource(env, argv[0], CounterType, &resource);
    Counter* counter = static_cast<Counter*>(resource);

    int result = counter->down();

    return enif_make_tuple2(
        env,
        enif_make_atom(env, "ok"),
        enif_make_int(env, result)
    );
}

ERL_NIF_TERM set(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    void* resource;
    enif_get_resource(env, argv[0], CounterType, &resource);
    Counter* counter = static_cast<Counter*>(resource);

    int count;
    enif_get_int(env, argv[1], &count);

    counter->set(count);

    return enif_make_atom(env, "ok");
}

ERL_NIF_TERM get(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    void* resource;
    enif_get_resource(env, argv[0], CounterType, &resource);
    Counter* counter = static_cast<Counter*>(resource);

    int result = counter->get();

    return enif_make_tuple2(
        env,
        enif_make_atom(env, "ok"),
        enif_make_int(env, result)
    );
}

static ErlNifFunc nif_funcs[] = {
    {"create", 1, create},
    {"up", 1, up},
    {"down", 1, down},
    {"set", 2, set},
    {"get", 1, get}
};

ERL_NIF_INIT(Elixir.Counter, nif_funcs, load, nullptr, nullptr, nullptr);

Counter module

Counter にも対応する関数を定義します。

defmodule Counter do
  @on_load {:load_nif, 0}
  @compile {:autoload, false}

  def load_nif do
    Application.app_dir(:counter, "priv/counter_nif")
    |> to_charlist()
    |> :erlang.load_nif(0)
  end

  def create(_init_count), do: :erlang.nif_error(:nif_not_loaded)
  def up(_ref), do: :erlang.nif_error(:nif_not_loaded)
  def down(_ref), do: :erlang.nif_error(:nif_not_loaded)
  def set(_ref, _count), do: :erlang.nif_error(:nif_not_loaded)
  def get(_ref), do: :erlang.nif_error(:nif_not_loaded)
end

動作確認

counter $ iex -S mix
iex(1)> {:ok, ref} = Counter.create(123)
{:ok, #Reference<0.2550255970.1501691911.208805>}
iex(2)> Counter.get(ref)
{:ok, 123}
iex(3)> Counter.up(ref); Counter.up(ref); Counter.up(ref)
{:ok, 126}
iex(4)> Counter.get(ref)                                 
{:ok, 126}
iex(5)> Counter.down(ref)                                
{:ok, 125}
iex(6)> Counter.get(ref) 
{:ok, 125}
iex(7)> Counter.set(ref, 321) 
:ok
iex(8)> Counter.get(ref)     
{:ok, 321}

いつか読むはずっと読まない:本を読む

電車での移動中によく本を読むのですが、最近は外出する機会がめっきり減ってしまい、それにともなって本を読む時間も減ってしまいまいました。 外出しないだけ自宅での時間が取れるのだから、その分だけ本を読めるはずなのですが、なかなか思うようにいきません。

Discord Webhook で Elixir からファイルを POST する

人と直接会う機会がめっきり減り、代わってテキスト、音声、動画を問わずチャットを利用する日々が続いています。

そんなチャット環境を少しでも便利にしようと、Discord の Webhook を Elixir から利用する方法を調べていました。

Discord の Webhook の情報

Webhook の情報自体はポータルにまとまっています。

discord.com

メッセージを投下するだけであれば、Webhook の URL を取得して HTTP の POST メソッドをリクエストすればよいので簡単です。

テキストを POST する

テキストのみの場合、JSON エンコードした body を POST するだけです。 本文は content 、ユーザ名は usernameJSON のキーにします。 content は必須です。 そのほかのフィールドは Discord のドキュメントを参照してください。

ここでは HTTP クライアントに HTTPoison を、JSONエンコードには Jason を利用します。

hex.pm

hex.pm

また Webhook の URL は次のように環境変数 WEBHOOK_URL に設定してあることにします。

$ export WEBHOOK_URL=https://discord.com/api/webhooks/****/*******

あとは Webhook の URL 、JSON エンコードした body 、ヘッダにコンテントタイプを指定して POST すれば OK です。

  def post_text do
    HTTPoison.post(
      System.get_env("WEBHOOK_URL"),
      Jason.encode!(%{content: "Hello"}),
      "Content-Type": "application/json"
    )
  end

ファイルを POST する

Discord のドキュメントに次のように注意書きがされています。

This endpoint supports both JSON and form data bodies. It does require multipart/form-data requests instead of the normal JSON request type when uploading files. Make sure you set your Content-Type to multipart/form-data if you're doing that.

ファイルをアップロードするときは JSON でなくて multipart/form-data で POST する必要があるとのこと。

multipart については HTTPoison のドキュメント で触れられているので、それを参考に引数を変更します。

  def post_file do
    HTTPoison.post(
      System.get_env("WEBHOOK_URL"),
      {
        :multipart,
        [
          {"content", "Hello"}, # フィールド content の値
          {
            :file,
            "image.jpg", # 読み込むファイルのファイル名
            {
              "form-data",
              [{"filename", "image.jpg"}], # 送信するファイル名
            },
            [{"Content-Type", "image/jpg"}] # 送信するファイルのコンテントタイプ
          }
        ]
      },
      "Content-Type": "multipart/form-data"
    )
  end

バイナリを POST する

上の例では既存のファイルを POST しましたが、プログラム内で生成したファイルデータを POST したいときなどは次のように変更します。

multipart に :file を指定した場合に自動的にファイルからデータを取得するようになっているようで、こちらの方がより一般的な設定のようです。

  def post_binary do
    HTTPoison.post(
      System.get_env("WEBHOOK_URL"),
      {
        :multipart,
        [
          {"content", "Hello"}, # フィールド content の値
          {
            "file", # フィールド file
            File.read!("image.jpg"), # 送信するバイナリそのもの
            {
              "form-data",
              [{"filename", "image.jpg"}],
            },
            [{"Content-Type", "image/jpg"}]
          }
        ]
      },
      "Content-Type": "multipart/form-data"
    )
  end

multipart については、HTTPoison が利用しているパッケージ hackney のドキュメントに利用できる形式が説明されています。 が、ちょっとわかりにくいかも。

hex.pm

いつか読むはずっと読まない:Stormbringer! Stormbringer!

Mournblade! Mournblade!

Stormbringer! Stormbringer!

Pow で認証したユーザを LiveView で参照するときの覚書

先日書いた phx_gen_auth とおなじく認証のしくみを Phoenix に組みこむためパッケージ。

phx_gen_auth が認証のしくみを実現するコードを生成するライブラリなのに対し(なので phx_gen_auth 自体はアプリケーション内で利用されない)、Pow はアプリケーションの一部として実行時に認証のしくみを提供するのが大きな違い。

hex.pm

結論

独自の authorization plug を実装し、トークンをセッションに登録する。 LiveView でそのトークンを受け取りユーザを取得する。

実例

アプリケーションを用意する

$ mix phx.new my_app --live
$ cd my_app

Pow を追加する

mix.exsdepspow を追加、パッケージを取得しコンパイルする。

defmodule MyApp.MixProject do
  use Mix.Project

  # ...

  def deps do
    # ...
    {:pow, "~> 1.0"}
  end
end
$ mix do deps.get, deps.compile

認証のしくみを用意する

mix pow.install タスクを利用して、認証のしくみに必要なファイルを生成する。 あわせて既存のファイルに対する変更が表示されるので、それにしたがっってファイルを編集する。

なお、Endpoint には後述するようにカスタマイズした独自のモジュールを指定するので、 Endpoint の編集はそこで説明する。

$ mix pow.install
* creating priv/repo/migrations
* creating priv/repo/migrations/20200915121951_create_users.exs
* creating lib/my_app/users
* creating lib/my_app/users/user.ex
Pow has been installed in your phoenix app!

There are three files you'll need to configure first before you can use Pow.

First, append this to `config/config.exs`:

config :my_app, :pow,
  user: MyApp.Users.User,
  repo: MyApp.Repo

Next, add `Pow.Plug.Session` plug to `lib/my_app_web/endpoint.ex` after `plug Plug.Session`:

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  # ...

  plug Plug.Session, @session_options
  plug Pow.Plug.Session, otp_app: :my_app
  plug MyAppWeb.Router
end

Last, update `lib/my_app_web/router.ex` with the Pow routes:

defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use Pow.Phoenix.Router

  # ... pipelines

  scope "/" do
    pipe_through :browser

    pow_routes()
  end

  # ... routes
end

Config

config/config.exs に Pow の設定を追加する。

config :my_app, :pow,
  user: MyApp.Users.User,
  repo: MyApp.Repo

Router

lib/my_app_web/router.ex で定義されるモジュール MyAppWeb.RouterPow.Phoenix.Router を use する。

defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use Pow.Phoenix.Router # 追加

  # ...
end

scope を追加し pow_routes/0 を追加する。 これによって認証に必要な登録やログインのルーティングが追加される。

defmodule MyAppWeb.Router do
  # ...

  scope "/" do
    pipe_through :browser

    pow_routes()
  end

  # ...
end

pipeline :protected を追加する。

defmodule MyAppWeb.Router do
  # ...

  pipeline :protected do
    plug Pow.Plug.RequireAuthenticated,
      error_handler: Pow.Phoenix.PlugErrorHandler
  end

  # ...
end

LiveView を含む scope の pipe_through:protected を追加する。 これによって LiveView のリソースにアクセスするにはログインが必要になる。

defmodule MyAppWeb.Router do
  # ...

  scope "/", MyAppWeb do
    pipe_through [:browser, :protected] # :protected を追加


    live "/", PageLive, :index
    live "/user", UserLive # 追加
  end

  # ...
end

Authorization Plug

ドキュメントにある独自の authorization plug を定義する。

ファイルはどこに配置してもよいけれども、ここではモジュール名に合わせて lib/my_app_web/pow/plug.ex に配置する。

ログインのときに呼び出される create/3 で、セッションにユーザ ID にもとづくトークンを記録する。 LiveView ではこのトークンを利用してログインしているユーザを取得する。

defmodule MyAppWeb.Pow.Plug do
  use Pow.Plug.Base

  @session_key :pow_user_token
  @salt "user salt"
  @max_age 86400

  @impl true
  def fetch(conn, _config) do
    conn  = Plug.Conn.fetch_session(conn)
    token = Plug.Conn.get_session(conn, @session_key)

    MyAppWeb.Endpoint
    |> Phoenix.Token.verify(@salt, token, max_age: @max_age)
    |> maybe_load_user(conn)
  end

  defp maybe_load_user({:ok, user_id}, conn), do: {conn, MyApp.Repo.get(MyApp.Users.User, user_id)}
  defp maybe_load_user({:error, _any}, conn), do: {conn, nil}

  @impl true
  def create(conn, user, _config) do
    token = Phoenix.Token.sign(MyAppWeb.Endpoint, @salt, user.id)
    conn  =
      conn
      |> Plug.Conn.fetch_session()
      |> Plug.Conn.put_session(@session_key, token)

    {conn, user}
  end

  @impl true
  def delete(conn, _config) do
    conn
    |> Plug.Conn.fetch_session()
    |> Plug.Conn.delete_session(@session_key)
  end
end

Endpoint

lib/my_app_web/endpoint.ex に上で定義した plug を設定する。

Pow.PlugSession の結果に依存し、RouterPow.Plug の結果に依存するため、この位置に記述する。

defmodule MyAppWeb.Endpoint do
  # ...

  plug Plug.Session, @session_options
  plug MyAppWeb.Pow.Plug, otp_app: :my_app # Session の後、Router の前のこの位置に追加
  plug MyAppWeb.Router
end

LiveView

認証を必要とする LiveView を定義する。

mount/3 の第二引数のセッション情報から、先に記述した plug の create/3 で記録したトークンを取得する。 このトークンからユーザ ID を復元し、ユーザのデータを取得する。

defmodule MyAppWeb.UserLive do
  use MyAppWeb, :live_view

  @salt "user salt"
  @max_age 86400

  def mount(_params, %{"pow_user_token" => token}, socket) do
    {:ok, user_id} = Phoenix.Token.verify(MyAppWeb.Endpoint, @salt, token, max_age: @max_age)
    current_user = MyApp.Repo.get(MyApp.Users.User, user_id)

    {:ok, assign(socket, current_user: current_user)}
  end

  def render(assigns) do
    ~L"""
    <%= @current_user.email %>
    """
  end
end

サーバを起動し動作を確認する

サーバを起動し、追加した LiveView の URL http://localhost:4000/user にアクセスする。

$ iex -S mix phx.server 

認証されていないのでログインページにリダイレクトされる。

ユーザ登録のためにログインページに表示されているリンク Register をクリックする。

表示されるユーザ登録ページでメールアドレスとパスワードと確認用のパスワードを入力する。

ユーザ登録が完了すると、LiveView のページが表示され、登録したメールアドレスが表示される。

いつか読むはずっと読まない: Peytoia nathorsti

石化した古生物を扱う学問であっても、それもまたダイナミックな人間の営みであることをいつも感じます。

アノマロカリス解体新書

アノマロカリス解体新書

  • 作者:土屋 健
  • 発売日: 2020/02/12
  • メディア: 単行本(ソフトカバー)

phx_gen_auth で認証したユーザを LiveView で参照するときの覚書

hex.pm

結論

LiveView モジュールの mount/3 関数の第二引数に "user_token" が設定されているので、phx_gen_auth が生成するモジュールに含まれる関数 get_user_by_session_token/1 を利用してユーザデータを取得する。

実例

アプリケーションを用意する

$ mix phx.new my_app --live
$ cd my_app

phx_gen_auth を追加する

mix.exsdepsphx_gen_auth を追加する。

このパッケージは開発時のみ利用し、また実行時には利用しないので only: :dev および runtime: false を指定する。

{:phx_gen_auth, "~> 0.4", only: :dev, runtime: false}

パッケージを取得しコンパイルする。

$ mix do deps.get, deps.compile

認証の仕組みを用意する

mix task の mix phx.gen.auth が有効になるので、これを利用して認証のリソースとコードを生成する。

$ mix phx.gen.auth Account User users
$ mix do deps.get, ecto.create, ecto.migrate

認証が必要な LiveView を追加する

lib/my_app_web/router.ex を編集して :require_authenticated_user が指定されているスコープに LiveView のルーティングを追加する。

  scope "/", MyAppWeb do
    pipe_through [:browser, :require_authenticated_user]

    live "/user", UserLive

    # ...
  end

lib/my_app_web/live/user_live.ex を作成して LiveView のモジュール MyAppWeb.UserLiveView を記述する。

このとき、mount/3 の第二引数のマップに、ログインしたユーザを識別するトーク"user_token" が設定されているので、mix phx.gen.auth で生成された MyApp.Account.get_user_by_session_token/1 を使ってそのユーザを取得する。

defmodule MyAppWeb.UserLive do
  use MyAppWeb, :live_view

  def mount(_params, session, socket) do
    current_user = MyApp.Account.get_user_by_session_token(session["user_token"])
    {:ok, assign(socket, current_user: current_user)}
  end

  def render(assigns) do
    ~L"""
    <h1><%= @current_user.email %></h1>
    """
  end
end

サーバを起動し動作を確認する

サーバを起動し、追加した LiveView の URL http://localhost:4000/user にアクセスする。

$ iex -S mix phx.server 

認証されていないのでログインページに移動する。

Register リンクをクリックし登録ページに移動する。

ユーザ登録が完了すると、LiveView のページが表示され、登録したメールアドレスが表示される。

いつか読むはずっと読まない:銃・病原菌・鉄

数年前に購入したままになっていた本。 たまたま先日見た番組に著者が出演していて、存在を思い出し読み始めたところ。

今、この時に、読むのが、よい気がしている。

Action Cable でサーバと通信し、Alpine.js で表示を更新する

Alpine.js です。 軽量で、宣言的で、リアクティブなふるまいを記述できるということで注目されている、ようです。

github.com

既存のフレームワークの Vue.js や React と比べてシンプルで軽量というところが注目点のようなのですが、わたしとしては HTML のタグにディレクティブを追加するだけというところに惹かれました。

Alpine.js であれば 既存の Ruby on Rails アプリケーションに簡単にリアクティブなフロントエンドを構築できるのではないか

と、いうわけで。 実際に Rails と Alpine.js をつないでみることにしました。

ここから先は、 INCDEC の二つのボタンを持ち、数字をカウントアップ/カウントダウンできる簡単な例を使って話を進めてゆきます。

また Rails のバージョンは 6.0 以降、Webpacker を利用している環境を想定しています。

f:id:E_Mattsan:20200714221753p:plain

Alpine.js で書く

count というデータを持ち、ボタンが押されたら +1 または -1 します。count の値は span タグの innerText として表示されます。 実質 HTML を書いただけでこのようなふるまいを実現できるのは、けっこう感動的です。

<!DOCTYPE html>
<html>
  <head>
    <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js" defer></script>
  </head>
  <body>
    <div x-data="{count: 0}">
      <button @click="count -= 1">DEC</button>
      <button @click="count += 1">INC</button>
      <span x-text="count"></span>
    </div>
  </body>
</html>

Action Cable で書く

おなじ操作を Action Cable を使って実現します。 この例に Action Cable を使うのは大仰すぎますが、おのおののアプリケーションで実際に Action Cable を利用する状況を想像しながら読んでみてください。

まずボタンを配置したページを用意します。

次に Action Cable のチャネルを追加します。 チャネルには fetch, inc, dec のイベントを用意します。 fetch はクライアントとサーバの接続が確立したときに、クライアントがサーバ側にある初期値を取得するために利用します。

ページを追加する

ルーティングを追加します。

config/routes.rb

Rails.application.routes.draw do
  root to: 'home#index'
end

コントローラを追加します。

app/controllers/home_controller.rb

class HomeController < ApplicationController
end

ヴューを追加します。 チャネルのクライアントが操作するためにボタンと span タグに ID を割り当てておきます。

app/views/home/index.html.erb

<button id="dec">DEC</button>
<button id="inc">INC</button>
<span id="count"></span>

チャネルを追加する

Rails のコマンドを利用して新しいチャネルを生成します。 先述の通り三つのイベントを指定します。

$ bin/rails g channel Counter fetch inc dec

サーバ側です。

接続が確立したときにインスタンス変数 @count を初期化します。 今回は動作の確認ができればじゅうぶんという姿勢で stream に指定する文字列にはここでは適当な値を指定しています。

fetch を受信したら、現在の @count の値をブロードキャストします。

inc を受信したら、@count に 1 を加えてその値をブロードキャストします。

dec を受信したら、@count から 1 を引いてその値をブロードキャストします。

それぞれがやっていることがわかるように、あえて冗長に書いてみました。

app/channels/counter_channel.rb

class CounterChannel < ApplicationCable::Channel
  def subscribed
    @count = 0
    stream_from 'some_channel'
  end

  def unsubscribed
  end

  def fetch
    ActionCable.server.broadcast('some_channel', {count: @count})
  end

  def inc
    @count += 1
    ActionCable.server.broadcast('some_channel', {count: @count})
  end

  def dec
    @count -= 1
    ActionCable.server.broadcast('some_channel', {count: @count})
  end
end

クライアント側です。

生成されたコードのうち connectedreceived に手を加えます。

connected には、ボタンのイベントハンドラの設定と初期値の取得 (fetch) するコードを記述します。

received には、サーバから受け取った値を span タグの innerText に設定するコードを記述します。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    document.getElementById('inc').addEventListener('click', () => this.inc())
    document.getElementById('dec').addEventListener('click', () => this.dec())

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    document.getElementById('count').innerText = data.count
  },

  fetch: function() {
    return this.perform('fetch');
  },

  inc: function() {
    return this.perform('inc');
  },

  dec: function() {
    return this.perform('dec');
  }
});

Rails アプリケーションを起動しページを開くと、Action Cable を介して初期値が取得されページに表示されます。 ボタンを押すたびに inc または dec のイベントがサーバに送られ、サーバ側でカウントアップ/カウントダウンし、その結果がクライアントに送られます。

Action Cable + Alpine.js で書く

では本題の Action Cable + Alpine.js を連携するコードを書いていきます。

…が、その前に一つ。

Alpine.js が登場してからまもないということもあって、ベストプラクティスの蓄積はまだまだこれからだと思われます。 ここからはわたし自身が情報を集めて模索したもののを集めたものなります。

では。あらためて。

まず Rails アプリケーションで Alpine.js を利用するために、yarn コマンドでパッケージを追加します。

$ yarn add alpinejs

追加した Alpine.js は必要となる js ファイルで import して利用します。

import "alpinejs"

これ以降はサーバ側のコードは変更がないので、クライアント側のコードのみ掲載することにします。

Alpine.js → Action Cable

Alpine.js の操作を Action Cable に伝える方法を考えます。

ボタンのクリックやテキストの入力といったページ上の操作を Action Cable そしてサーバに伝達するためのしくみです。

カスタムイベントを送る

Alpine.js でカスタムイベントを生成し Action Cable のチャネルで受け取ります。

developer.mozilla.org

Alpine.js には $dispatch というカスタムイベントを生成する マジックプロパティ があり、簡単にイベントを生成することができます。

イベントハンドラを割り当てる DOM を取得するために id="counter" を追加しておきます。

app/views/home/index.html.erb

<div id="counter" x-data="{}">
  <button @click="$dispatch('dec')">DEC</button>
  <button @click="$dispatch('inc')">INC</button>
  <span id="count"></span>
</div>

id="counter" を指定した DOM にイベントハンドラを割り当てます。 ここは一般的な id を使った DOM の取得とハンドラの割り当てです。 少し違う点は、任意に定義したカスタムイベントという点です。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    const counter = document.getElementById('counter')

    counter.addEventListener('inc', () => this.inc())
    counter.addEventListener('dec', () => this.dec())

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    document.getElementById('count').innerText = data.count
  },

  fetch: function() {
    return this.perform('fetch');
  },

  inc: function() {
    return this.perform('inc');
  },

  dec: function() {
    return this.perform('dec');
  }
});

関数を呼び出す

Alpine.js の @click には任意の js のコードを記述できるので、Action Cable のチャネルの関数が見えていれば直接呼び出すことができます。

ただし Alpine.js のコンポーネントから見えるようにするにはグローバルスコープにチャネルの関数かオブジェクトを公開する必要があります。

For bundler users, note that Alpine.js accesses functions that are in the global scope (window), you'll need to explicitly assign your functions to window in order to use them with x-data for example window.dropdown = function () {} (this is because with Webpack, Rollup, Parcel etc. function's you define will default to the module's scope not window).

https://github.com/alpinejs/alpine#x-data

ここでは connected のタイミングでオブジェクトを公開します。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    window.counterChannel = this

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    document.getElementById('count').innerText = data.count
  },

  fetch: function() {
    return this.perform('fetch');
  },

  inc: function() {
    return this.perform('inc');
  },

  dec: function() {
    return this.perform('dec');
  }
});

グローバルスコープに公開されたオブジェクトは自由にアクセスできるので、定義されている関数を直接呼び出すことができます。

app/views/home/index.html.erb

<div id="counter" x-data="{}">
  <button @click="counterChannel.dec()">DEC</button>
  <button @click="counterChannel.inc()">INC</button>
  <span id="count"></span>
</div>

Alpine.js ← Action Cable

次に Action Cable から Alpine.js への伝達を考えます。

サーバのデータの変化を Action Cable を介してページを更新するためのしくみです。

カスタムイベントを送る

Alpine.js は任意のイベントを受信することができるので、カスタムイベントを送ることで Alpine.js に情報を伝えることができます。

received でサーバから受信したデータを、updated と名付けた CustomEvent のオブジェクトに載せて dispatchEvent で送ります。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    const counter = document.getElementById('counter')

    counter.addEventListener('inc', () => this.inc())
    counter.addEventListener('dec', () => this.dec())

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    const counter = document.getElementById('counter')
    const event = new CustomEvent('updated', {detail: {count: data.count}})
    counter.dispatchEvent(event)
  },

  fetch: function() {
    return this.perform('fetch');
  },

  inc: function() {
    return this.perform('inc');
  },

  dec: function() {
    return this.perform('dec');
  }
});

Alpine.js では用意したカスタムイベントのハンドラ @updated でイベントを受け取ります。 イベントの値はマジックプロパティ $event から取り出すことができます。

app/views/home/index.html.erb

<div id="counter" x-data="{count: 0}" @updated="count = $event.detail.count">
  <button @click="$dispatch('dec')">DEC</button>
  <button @click="$dispatch('inc')">INC</button>
  <span x-text="count"></span>
</div>

Alpine.js コンポーネントを操作する

Alpine.js コンポーネントのデータには DOM を介してアクセスすることができます。

取得した DOM には .__x というフィールドがあり、.__x.$datax-data で定義したデータの proxy になっています。

> document.getElementById('counter').__x.$data
Proxy {}

ですので、ここに直接値を書き込むと Alpine.js コンポーネントのデータを更新することができます。

> document.getElementById('counter').__x.$data.count = 10

ただ、これに関しては公式なドキュメントを見つけられませんでした。 ですので、いまのところは参考にとどめておいてください。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    window.counterChannel = this

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    const counter = document.getElementById('counter')
    counter.__x.$data.count = data.count
  },

  fetch: function() {
    return this.perform('fetch');
  },

  inc: function() {
    return this.perform('inc');
  },

  dec: function() {
    return this.perform('dec');
  }
});

データを直接更新しているので、Alpine.js 側ではデータを更新するコードを記述する必要がなくなります。

app/views/home/index.html.erb

<div id="counter" x-data="{count: 0}">
  <button @click="counterChannel.dec()">DEC</button>
  <button @click="counterChannel.inc()">INC</button>
  <span x-text="count"></span>
</div>

Spruce

Alpine.js の状態管理のために Spruce というパッケージが開発されています。 開発者は Alpine.js の contributor のお一人です。

Alpine.js は x-data の代わりに Spruce に格納されたデータを利用します。 Spruce の変化は Alpine.js コンポーネントx-subscribe を記述することで受け取ることができるようになります。

データへのアクセスは Spruce が定義する $store を介しておこないます。

冒頭の Alpine.js のコードを Spruce を使って書き換えてみます。

まずパッケージの読み込みですが、Spruce が Alpine.js よりも先に読み込まれている必要があります。 記述順に注意してください。 詳細はドキュメントを参照してください。

次に Spruce.store でデータを定義します。

Alpine.js コンポーネントx-data の値は利用しないと書きましたが、コンポーネントになるには x-data を持つ必要があるので空のオブジェクトを渡します。 またおなじタグに x-subscribe を追加します。

データは $store を利用することで Spruce.store で定義した値を参照と更新ができます。 更新すると x-data で定義したデータとおなじようにコンポーネントの他の部分にその変化が伝搬します。

<!DOCTYPE html>
<html>
  <head>
    <script src="https://cdn.jsdelivr.net/gh/ryangjchandler/spruce@0.x.x/dist/spruce.umd.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js"></script>

    <script>
      Spruce.store(
        'counter',
        {
          count: 0
        }
      )
    </script>
  </head>
  <body>
    <div x-data="{}" x-subscribe>
      <button @click="$store.counter.count -= 1">DEC</button>
      <button @click="$store.counter.count += 1">INC</button>
      <span x-text="$store.counter.count"></span>
    </div>
  </body>
</html>

Spruce を使うことの利点の一つは、Alpine.js コンポーネントの外の js コードからも Spruce.store で定義した値の参照と更新ができるという点です。 これを利用して Action Cable のチャネルのコードから Alpine.js コンポーネントを更新します。

Spruce を介する

Spruce を Action Cable で利用するために、これも yarn コマンドでインストールできます。 npm で Spruce を検索するといくつかヒットしますので、他のパッケージをインストールしないように注意してください。

$ yarn add @ryangjchandler/spruce

Action Cable のチャネルのコードで Spruce を import します。 この時も Alpine.js よりも先に import する必要があるので注意が必要です。

Alpine.js コンポーネント外から Spruce のデータにアクセスするには Spruce.store('counter').count という形で記述します。 コンポーネント内の $store.counter.count という記述に相当します。

app/javascript/channels/counter_channel.js

import consumer from "./consumer"
import Spruce from "@ryangjchandler/spruce"

Spruce.store("counter", {
  count: 0
})

import "alpinejs"

consumer.subscriptions.create("CounterChannel", {
  connected() {
    window.counterChannel = this

    this.fetch()
  },

  disconnected() {
  },

  received(data) {
    Spruce.store('counter').count = data.count
  },

  fetch: function() {
    return this.perform('fetch');
  },

  inc: function() {
    return this.perform('inc');
  },

  dec: function() {
    return this.perform('dec');
  }
});

Spruce のデータの更新が反映されるので Alpine.js コンポーネントのコードではデータの更新を記述する必要がなくなります。

app/views/home/index.html.erb

<div x-data="{}" x-subscribe>
  <button @click="counterChannel.dec()">DEC</button>
  <button @click="counterChannel.inc()">INC</button>
  <span x-text="$store.counter.count"></span>
</div>

まとめ

Action Cable と Alpine.js を繋ぐこころみは以上です。

最後に現在の理解のまとめです。

Alpine.js → Action Cable

  • カスタムイベントを使う
    • Apline.js と Action Cable のコードを分離できる
    • ハンドラの記述を間違えてイベントを受け取れなくてもエラーにならないので注意が必要かもしれない
  • 関数を呼び出す
    • 直接的、直感的
    • Action Cable の関数やオブジェクトをグローバルに公開してしまう(どこからも参照できる状態になる)

Alpine.js ← Action Cable

  • カスタムイベントを使う
    • Apline.js と Action Cable のコードを分離できる
    • Apline.js にイベントを受け取ったときの挙動を記述する必要がある
  • Alpine.js コンポーネントを操作する
    • 直接的、直感的、Alpine.js に更新時の処理を記述する必要がない
    • 公式の資料が見つからない
  • Spruce を介する
    • 直接的、直感的、Alpine.js に更新時の処理を記述する必要がない
    • パッケージを読み込む順序に注意する必要がある

いつか読むはずっと読まない:見えないうらがわのようすを知る

へんなものみっけ! (1) (ビッグコミックス)

へんなものみっけ! (1) (ビッグコミックス)

  • 作者:早良 朋
  • 発売日: 2017/07/12
  • メディア: コミック

Phoenix LiveView で Markdwon Preview

Phoenix LiveView を使って、textarea に入力した Markdown のテキストを逐次プレビューするサンプルです。

f:id:E_Mattsan:20200626201515p:plain

Phoenix 1.5 になって簡単に LiveView を利用できるようになり、動的なページを作るのが本当に簡単になりました。

もちろん万能ではないですし弱点も少なくありません。 それでも従来の web ページを構築する要領で動的なページを構築できるのは大きな利点だと思います。

Earmark - a pure-Elixir Markdown converter

Markdown から HTML への変換にここでは Earmark を利用します。

Earmark は ex_doc がドキュメントを生成するときにも利用されているパッケージです。

as_html!/2で簡単に変換することができます。

iex(1)> Earmark.as_html!("""
...(1)> # 第一部            
...(1)> ## 第一章           
...(1)> ### 第一節          
...(1)> 
...(1)> | a | b |
...(1)> |---|---|
...(1)> | 1 | 2 |
...(1)> 
...(1)> - a
...(1)> - b
...(1)> """) |> IO.puts()
<h1>
  第一部
</h1>
<h2>
  第一章
</h2>
<h3>
  第一節
</h3>
<table>
  <thead>
    <tr>
      <th style="text-align: left;">
        a
      </th>
      <th style="text-align: left;">
        b
      </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left;">
        1
      </td>
      <td style="text-align: left;">
        2
      </td>
    </tr>
  </tbody>
</table>
<ul>
  <li>
    a
  </li>
  <li>
    b
  </li>
</ul>

MdPreviw - Markdownプレビュー app

アプリケーションを作成していきます。

Phoenix プロジェクトを用意する

LIveView を利用するプロジェクトを新たに作成します。 ここでは作業を簡単にするためデータベースを利用せず --no-ecto を指定します。

$ mix phx.new md_preview --live --no-ecto
$ cd md_preview

パッケージを追加する

mix.exs を編集して、依存パッケージに Earmark を追加します。

  defp deps do
    [
      {:phoenix, "~> 1.5.3"},
      {:phoenix_live_view, "~> 0.13.0"},
      {:floki, ">= 0.0.0", only: :test},
      {:phoenix_html, "~> 2.11"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_dashboard, "~> 0.2.0"},
      {:telemetry_metrics, "~> 0.4"},
      {:telemetry_poller, "~> 0.4"},
      {:gettext, "~> 0.11"},
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:earmark, "~> 1.4"} # 追加
    ]
  end

パッケージを取得します。

$ mix deps.get

ルーティングを追加する

live/md_preview_web/router.ex を編集して LiveView のルーティングを追加します。

  scope "/", MdPreviewWeb do
    pipe_through :browser


    live "/", PageLive, :index
    live "/markdown", MarkdownLive # 追加
  end

LiveView を書く

LiveView を書いていきます。

…とは言っても LiveView のドキュメントにある実装例ぐらい簡単なコードです。

textarea の内容の更新に逐一反応しないように phx-debounce="1000" を指定しています。 更新が止まってから 1,000 ミリ秒 = 1 秒経過してからイベントをサーバに送ります。

またレンダリング済みの HTML をエスケープせずに挿入するために Phoenix.HTML.raw/1 を利用しています。

defmodule MdPreviewWeb.MarkdownLive do
  use MdPreviewWeb, :live_view

  @impl true
  def mount(_, _, socket) do
    {:ok, assign(socket, body: "")}
  end

  @impl true
  def render(assigns) do
    ~L"""
      <div>
        <form phx-change="update">
        <textarea name="markdown[source]" class="source" phx-debounce="1000"></textarea>
        </form>
      </div>
      <div class="preview">
        <%= raw @body %>
      </div>
    """
  end

  @impl true
  def handle_event("update", %{"markdown" => %{"source" => source}}, socket) do
    body = Earmark.as_html!(source)
    {:noreply, assign(socket, body: body)}
  end
end

assets/css/app.scss にスタイルを追加して見た目を少し整えました。

.source {
  height: 20vh;
  resize: none;
  font-family: monospace;
}

.preview {
  border: solid thin #e0e0e0;
  padding: 10px;
}

app を動かす

サーバを起動し http://localhost:4000/markdown にアクセスすると、記事の先頭の画像のように textarea に入力した Markdown のテキストがすぐに変換されて表示されます。

$ iex -S mix phx.server 

レンダリングを非同期にする

イベント送信の負荷を減らすために、また編集途中の半端な状態でレンダリングされないように、入力が止まってから 1 秒ごにテキストをサーバに送信するようにしています。 つまりテキストの入力とレンダリング結果の表示のタイミングは同期していません。

このような関係にあるばあい、ブラウザからのテキストの送信とサーバからの結果の送信は分離してしまっても問題ありません。

MdPreviewWeb.MarkdownLiveイベントハンドラを次のように編集します。

"update" を受けたらすぐに自分に対して :render メッセージを送り、LiveView のイベントハンドラから抜けます。

その後 :render メッセージを受けとたら Markdown から HTML に変換して表示内容を更新します。

  @impl true
  def handle_event("update", %{"markdown" => %{"source" => source}}, socket) do
    send(self(), {:render, source})

    {:noreply, socket}
  end

  @impl true
  def handle_info({:render, source}, socket) do
    {:noreply, assign(socket, body: Earmark.as_html!(source))}
  end

このテクニックは The Pragmatic Studio の Phoenix LiveView コースで解説されています。 2020年6月27日現在 $0 で提供されています。 興味のある方はぜひ受講してみてください。

online.pragmaticstudio.com

いつか読むはずっと読まない:Real-Time Phoenix

The Pragmatic Bookshelf の「Real-Time Phoenix」。今年の3月3日に正式に出版され「Hands-On with Phoenix LiveView」で LiveView に触れられています。

pragprog.com

Phoenix.PubSub を Phoenix 以外で利用するための素振り

檄を飛ばす

檄を方々に急いで出し,決起を促す。

スーパー大辞林」より

Phoenx.PubSub は Phoenix名前空間にありますが Phoenix のプロジェクト以外でも利用できます。

バージョンが 2 になってシンプルに扱いやすくなったということで素振りをしてみました。

やること

  • Phoenix.PubSub を使って publisher から subscribers にメッセージを送る
  • アプリケーション起動時に subscribers のプロセスを起動するようにしてみる

準備

プロジェクトを作る

$ mix new geki --sub
$ cd geki

依存するパッケージに Phoenix.PubSub を追加する

Phoenix.PubSub のバージョンを確認。

$ mix hex.info phoenix_pubsub
Distributed PubSub and Presence platform

Config: {:phoenix_pubsub, "~> 2.0"}
...

mix.exs に追加。

defmodule Geki.MixProject do
  use Mix.Project

  # ...

  defp deps do
    [
      {:phoenix_pubsub, "~> 2.0"}
    ]
  end
end

Phoenix.PubSub を子プロセスに追加する

lib/geki/application.ex を編集してアプリケーションの子プロセスに Phoenix.PubSub を追加します。 ここではプロセスに Geki.PubSub という名前をつけています。

defmodule Geki.Application do
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      {Phoenix.PubSub, name: Geki.PubSub}
    ]

    opts = [strategy: :one_for_one, name: Geki.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Subscriber モジュールを用意する

Subscriber を記述するファイル lib/geki/subscriber.ex を追加します。 ここではGenServer で実装します。

任意のメッセージを受信したら、それを単純にログに出力します。

defmodule Geki.Subscriber do
  use GenServer

  require Logger

  alias Phoenix.PubSub

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts)
  end

  def init(opts) do
    name = opts[:name]
    pubsub = opts[:pubsub]
    topic = opts[:topic]

    PubSub.subscribe(pubsub, topic)

    {:ok, %{name: name}}
  end

  def handle_info(event, state) do
    Logger.info("Subscriber '#{state.name}' received #{inspect(event)}")

    {:noreply, state}
  end
end

プロセスを起動しメッセージを送る

iex 上でふるまいを確認します。

$ iex -S mix

Subscriber のプロセスを起動する

Geki.Subscriber のプロセスを起動します。 ここでは foo, bar, baz と名前をつけた 3 つのプロセスを起動しています。

iex> Geki.Subscriber.start_link(name: :foo, pubsub: Geki.PubSub, topic: "geki")
{:ok, #PID<0.204.0>}
iex> Geki.Subscriber.start_link(name: :bar, pubsub: Geki.PubSub, topic: "geki")
{:ok, #PID<0.206.0>}
iex> Geki.Subscriber.start_link(name: :baz, pubsub: Geki.PubSub, topic: "geki")
{:ok, #PID<0.208.0>}

メッセージを送る

Phoenix.PubSub.broadcast/4 を使ってメッセージをブロードキャストします。

iex> Phoenix.PubSub.broadcast(Geki.PubSub, "geki", "Hello")
:ok

12:12:23.140 [info]  Subscriber 'bar' received "Hello"

12:12:23.140 [info]  Subscriber 'foo' received "Hello"

12:12:23.140 [info]  Subscriber 'baz' received "Hello"

すこし使いやすく

Publisher モジュールを追加する

特定の topic 向けの Publisher を用意してみます。 状態を記憶するために GenServer で実装します。

Publisher を記述するファイル lib/geki/publisher.ex を追加します。

defmodule Geki.Publisher do
  use GenServer

  def start_link(opts) do
    name = opts[:name]

    GenServer.start_link(__MODULE__, opts, name: name)
  end

  def publish(name, event) do
    GenServer.cast(name, {:publish, event})
  end

  def init(opts) do
    pubsub = opts[:pubsub]
    topic = opts[:topic]

    {:ok, %{pubsub: pubsub, topic: topic}}
  end

  def handle_cast({:publish, event}, state) do
    Phoenix.PubSub.broadcast(state.pubsub, state.topic, event)

    {:noreply, state}
  end
end

Publisher と Subscriber を子プロセスに追加する

アプリケーションの起動時に Publisher と Subscriber のプロセスも起動するようにします。

lib/geki/application.ex を編集してアプリケーションの子プロセスに Publisher と Subscriber を追加します。

今回の Subscriber の実装では GenServer.start/3 でプロセスの名前を指定していないので、複数の Subscriber の起動を指定するとプロセスの識別ができないため起動のときにエラーになってしまいます。

そのため Supervisor.child_spec/2 でプロセスに ID をしています。

defmodule Geki.Application do
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      {Phoenix.PubSub, name: Geki.PubSub},
      {Geki.Publisher, name: :geki, pubsub: Geki.PubSub, topic: "geki"},
      Supervisor.child_spec({Geki.Subscriber, name: :foo, pubsub: Geki.PubSub, topic: "geki"}, id: :foo),
      Supervisor.child_spec({Geki.Subscriber, name: :bar, pubsub: Geki.PubSub, topic: "geki"}, id: :bar),
      Supervisor.child_spec({Geki.Subscriber, name: :baz, pubsub: Geki.PubSub, topic: "geki"}, id: :baz),
    ]

    opts = [strategy: :one_for_one, name: Geki.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

起動してブロードキャストする

$ iex -S mix
iex> Geki.Publisher.publish(:geki, "Hello")                
:ok

12:40:33.437 [info]  Subscriber 'foo' received "Hello"
 
12:40:33.437 [info]  Subscriber 'baz' received "Hello"

12:40:33.437 [info]  Subscriber 'bar' received "Hello"

いつか読むはずっと読まない:FUTURES

この三週間ばかり部屋の大掃除をしているのですが。 なぜかこの画集が二冊見つかりました。

FUTURE

FUTURE

  • 作者:鶴田謙二
  • 発売日: 2011/11/30
  • メディア: 大型本