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

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

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
  • メディア: 大型本

file_system パッケージを使ってファイルの更新を監視する Phoenix app についての覚書

いまさらなのですが。

Phoenix app の開発時に、コードを更新したときに自動的に再読み込みをおこなうしくみを file_system というパッケージが担っているということを知りました。

ということなので。

file_system を使ってファイルの更新を監視し Phoenix LiveView を使ってリアルタイムにブラウザの表示に反映するというのをやります。 これはその覚書。


作成

アプリケーションを用意します。

Phoenix 1.5 以降であれば LiveView のためのオプション --live が追加されているので、それを指定します。 1.5 よりも前のバージョンを利用するばあいはドキュメントのインストール手順に従ってインストールしてください。

$ mix phx.new my_app --live

lib/my_app_web/live/page_live.ex

--live オプションで生成される LiveView のモジュールを次のように書き換えます。 手作業でインストールしたばあいは新たにファイルを作成します。

ここではモジュール FileSystem のプロセスにディレクト/tmp を監視させます。

また通知を受け取るためのハンドラ handle_info/2 を記述します。 受け取った通知は file_events という名前のリストに追加しています。

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket) do
      {:ok, watcher_pid} = FileSystem.start_link(dirs: ["/tmp"])
      FileSystem.subscribe(watcher_pid)
    end

    {:ok, assign(socket, :file_events, [])}
  end

  def handle_info({:file_event, watcher_pid, {path, events}}, socket) do
    {:noreply, update(socket, :file_events, &[{path, events} | &1])}
  end
end

lib/my_app_web/live/page_live.html.leex

LiveView のテンプレートを書き換え(あるいは作成)します。

<ul>
  <%= for {path, events} <- @file_events do %>
    <li>
      <span><%= path %></span>
      <span><%= inspect(events) %></span>
    </li>
  <% end %>
</ul>

lib/my_app_web/router.ex

LiveView を手作業でインストールしモジュールを新規に追加したばあいは、LiveView を利用するようにルーティングも変更します。

  scope "/", MyAppWeb do
    pipe_through :browser

    live "/", PageLive, :index
  end

実行

Phoenix app を起動します。

$ iex -S mix phx.server

FileSystem のプロセスが監視するディレクトリを操作します。

$ echo hi > /tmp/hi.txt
$ echo hello > /tmp/hello.txt

操作と同時にブラウザ上の表示が更新されることが確認できます。

f:id:E_Mattsan:20200503100015p:plain

Ruby の Enumerable モジュールの使い方の覚書

Enumerable モジュールを使って Fibonacci number を実装する例です。

# fibonacci.rb

class Fibonacci
  include Enumerable

  def each
    if block_given?
      n1, n2 = 1, 1
      loop do
        yield n1
        n1, n2 = n2, n1 + n2
      end
    else
      to_enum
    end
  end
end

先頭から 10 個の数を取得する。

$ ruby -r./fibonacci -e 'puts Fibonacci.new.take(10)'
1
1
2
3
5
8
13
21
34
55

先頭から 200 未満の数を取得する。

$ ruby -r./fibonacci -e 'puts Fibonacci.new.take_while {|f| f < 200 }'
1
1
2
3
5
8
13
21
34
55
89
144

各数の平方を先頭から 10 個取得する(だめな例)。

$ ruby -r './fibonacci' -e 'puts Fibonacci.new.map {|f| f * f }.take(10)'

#take が評価されるのは #mapすべての数 を処理してからなので、永遠にそのときはやってきません。 こんなときのための遅延評価です。

各数の平方を先頭から 10 個取得する。

$ ruby -r./fibonacci -e 'puts Fibonacci.new.lazy.map {|f| f * f }.take(10).to_a'
1
1
4
9
25
64
169
441
1156
3025

だいたいこんな感じ。