#ifndef __JOIN_H__ #define __JOIN_H__ #include <sstream> #include <iterator> template<class Iterator> std::string join(Iterator begin, Iterator end) { std::ostringstream io; for(Iterator i = begin; i != end; ++i) { io << *i; } return io.str(); } template<class Iterator, class Sep> std::string join(Iterator i, Iterator end, const Sep& sep) { std::ostringstream io; io << *i; while(++i != end) { io << sep << *i; } return io.str(); } #endif// __JOIN_H__
C++のクラスをNIFでElixirにバインドしてみた
C++ のライブラリを Elixir から利用したくなりました。
NIF を使った単純な関数の呼び出しは実装したことがあるのですが、オブジェクトを利用するようなケースは試したことがなかったので、今回調べてみました。
今回のコードではエラーハンドリングなどは省略していますのでご注意ください。 不備がありましたらご指摘いただけるとありがたいです。
またここで実装したファイルは Gist にもアップしていますので合わせてご参照ください。
NIF環境の用意
elixir_make
mix compile
コマンドで C++ のコードがいっしょにコンパイルされるように、elixir_make を導入します。
まず依存パッケージとして mix.exs
に elixir_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
でリソースタイプを登録するenif_alloc_resource
でリソースを確保するenif_make_resource
でハンドルを作成するenif_release_resource
でリソースへの参照を手放す
また 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}
いつか読むはずっと読まない:本を読む
電車での移動中によく本を読むのですが、最近は外出する機会がめっきり減ってしまい、それにともなって本を読む時間も減ってしまいまいました。 外出しないだけ自宅での時間が取れるのだから、その分だけ本を読めるはずなのですが、なかなか思うようにいきません。
- 作者:J・モーティマー・アドラー,V・チャールズ・ドーレン
- 発売日: 1997/10/09
- メディア: 文庫
Discord Webhook で Elixir からファイルを POST する
人と直接会う機会がめっきり減り、代わってテキスト、音声、動画を問わずチャットを利用する日々が続いています。
そんなチャット環境を少しでも便利にしようと、Discord の Webhook を Elixir から利用する方法を調べていました。
Discord の Webhook の情報
Webhook の情報自体はポータルにまとまっています。
メッセージを投下するだけであれば、Webhook の URL を取得して HTTP の POST メソッドをリクエストすればよいので簡単です。
テキストを POST する
テキストのみの場合、JSON エンコードした body を POST するだけです。
本文は content
、ユーザ名は username
を JSON のキーにします。
content
は必須です。
そのほかのフィールドは Discord のドキュメントを参照してください。
ここでは HTTP クライアントに HTTPoison を、JSON のエンコードには Jason を利用します。
また 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 のドキュメントに利用できる形式が説明されています。 が、ちょっとわかりにくいかも。
いつか読むはずっと読まない:Stormbringer! Stormbringer!
Mournblade! Mournblade!
Stormbringer! Stormbringer!
- 作者:マイケル・ムアコック,ジュリアン・ブロンデル,ジャン・リュック・カノ,ディディエ・ポリ,ロビン・レクト,ジャン・バスティッド
- 発売日: 2020/08/28
- メディア: コミック
Pow で認証したユーザを LiveView で参照するときの覚書
先日書いた phx_gen_auth とおなじく認証のしくみを Phoenix に組みこむためパッケージ。
phx_gen_auth が認証のしくみを実現するコードを生成するライブラリなのに対し(なので phx_gen_auth 自体はアプリケーション内で利用されない)、Pow はアプリケーションの一部として実行時に認証のしくみを提供するのが大きな違い。
結論
独自の authorization plug を実装し、トークンをセッションに登録する。 LiveView でそのトークンを受け取りユーザを取得する。
実例
アプリケーションを用意する
$ mix phx.new my_app --live $ cd my_app
Pow を追加する
mix.exs
の deps
に pow
を追加、パッケージを取得しコンパイルする。
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.Router
で Pow.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.Plug
は Session
の結果に依存し、Router
は Pow.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 で参照するときの覚書
結論
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.exs
の deps
に phx_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 のページが表示され、登録したメールアドレスが表示される。
いつか読むはずっと読まない:銃・病原菌・鉄
数年前に購入したままになっていた本。 たまたま先日見た番組に著者が出演していて、存在を思い出し読み始めたところ。
今、この時に、読むのが、よい気がしている。
文庫 銃・病原菌・鉄 (上) 1万3000年にわたる人類史の謎 (草思社文庫)
- 作者:ジャレド・ダイアモンド
- 発売日: 2012/02/02
- メディア: 文庫
文庫 銃・病原菌・鉄 (下) 1万3000年にわたる人類史の謎 (草思社文庫)
- 作者:ジャレド・ダイアモンド
- 発売日: 2012/02/02
- メディア: 文庫
Action Cable でサーバと通信し、Alpine.js で表示を更新する
Alpine.js です。 軽量で、宣言的で、リアクティブなふるまいを記述できるということで注目されている、ようです。
既存のフレームワークの Vue.js や React と比べてシンプルで軽量というところが注目点のようなのですが、わたしとしては HTML のタグにディレクティブを追加するだけというところに惹かれました。
Alpine.js であれば 既存の Ruby on Rails アプリケーションに簡単にリアクティブなフロントエンドを構築できるのではないか。
と、いうわけで。 実際に Rails と Alpine.js をつないでみることにしました。
ここから先は、 INC
と DEC
の二つのボタンを持ち、数字をカウントアップ/カウントダウンできる簡単な例を使って話を進めてゆきます。
また Rails のバージョンは 6.0 以降、Webpacker を利用している環境を想定しています。
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
クライアント側です。
生成されたコードのうち connected
と received
に手を加えます。
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 のチャネルで受け取ります。
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 towindow
in order to use them withx-data
for examplewindow.dropdown = function () {}
(this is because with Webpack, Rollup, Parcel etc.function
's you define will default to the module's scope notwindow
).
ここでは 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.$data
は x-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 に更新時の処理を記述する必要がない
- パッケージを読み込む順序に注意する必要がある
いつか読むはずっと読まない:見えないうらがわのようすを知る
- 作者:リチャード・フォーティ
- 発売日: 2011/04/22
- メディア: 単行本
- 作者:早良 朋
- 発売日: 2017/07/12
- メディア: コミック
へんなものみっけ!5巻(7月30日発売です)、表紙はこんな感じになりました!そして裏側には作中に出てくる南極猫のたけしと、火を使う伝説の鳥(を、アボリジナルアート風にしてみたもの)の絵が入ってます。見てくださった方が、少しでも楽しい気分になってもらえたら嬉しいです。😊 pic.twitter.com/0WsC2bDMBd
— 早良 朋(へんなものみっけ!) (@michitomo1) 2020年7月11日
Phoenix LiveView で Markdwon Preview
Phoenix LiveView を使って、textarea に入力した Markdown のテキストを逐次プレビューするサンプルです。
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 で提供されています。 興味のある方はぜひ受講してみてください。
いつか読むはずっと読まない:Real-Time Phoenix
The Pragmatic Bookshelf の「Real-Time Phoenix」。今年の3月3日に正式に出版され「Hands-On with Phoenix LiveView」で LiveView に触れられています。