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

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

Phoenix LiveView で temporary_assigns を設定したときにどのように DOM を削除するか

動作確認に利用したコードは GitHub に push しました。 この記事で解説しているコードは このコミット にまとまっています。

temporary_assigns を設定したときの削除問題

LiveView では、一部が更新されたときに全体を再描画するのではなく、更新された部分だけを再描画するように設定することができます。

hexdocs.pm

ただし可能なのは、新規の DOM の追加と既存の DOM の更新だけで、削除はできません。 削除するには、該当する DOM を削除した全体をサーバからクライアントに送り、再描画する必要があります。 要素の数が少なければそのような方法も使えますが、数が多くなると送信のコストも再描画のコストも無視できなくなります。

これは Elixir Forum でも質問にあがっていた内容です。

elixirforum.com

解決策としては、Forum のこの質問にも回答されているように、JavaScript のフックを使って削除されたことの情報を持っている DOM を削除するというのが現実的なようです。

hexdocs.pm

と、いうわけで。 具体的に実装してみました。

実装のアイディア

アイディアはいたって単純です。

削除したい要素の DOM を更新して、削除の情報を設定します。 DOM の更新後に呼び出されるフック関数で、削除の情報が設定された DOM を削除します。

前提として。

ここでは Ecto ででデータベースを利用していることを想定しています。 ですが、対応するデータが削除されたことが DOM に反映されるようになっていれば、Ecto でなくてもデータベースのデータでなくても、同じように処理できます。

Ecto.Schema の状態を調べる

Ecto.Schema には :__meta__ という名前でメタデータが格納され、そこには :state という名前で状態が格納されています。

hexdocs.pm hexdocs.pm

メタデータの値を取得する Ecto.get_meta/2 が用意されているので、これで状態を確認できます。

Hoge というプロジェクトの Foos というコンテクストに Bar というスキーマが設定され bars というテーブルがあるような状況で、状態を確認してみます。

iex(1)> {:ok, bar} = Hoge.Foos.create_bar(%{name: "hoge"})
{:ok,
 %Hoge.Foos.Bar{
   __meta__: #Ecto.Schema.Metadata<:loaded, "bars">,
   id: 1,
   inserted_at: ~N[2020-12-30 23:23:13],
   name: "hoge",
   updated_at: ~N[2020-12-30 23:23:13]
 }}

iex(2)> Ecto.get_meta(bar, :state)
:loaded
iex(3)> {:ok, bar} = Hoge.Foos.get_bar!(1) |> Hoge.Foos.delete_bar()
{:ok,
 %Hoge.Foos.Bar{
   __meta__: #Ecto.Schema.Metadata<:deleted, "bars">,
   id: 1,
   inserted_at: ~N[2020-12-30 23:23:13],
   name: "hoge",
   updated_at: ~N[2020-12-30 23:23:13]
 }}

iex(4)> Ecto.get_meta(bar, :state)                                  
:deleted

state の値は次の 3 種類です。

状態
:built データはメモリ上に構築されているがデータベースに保存されていない状態
:loaded データはデータベースに保存されている状態
:deleted データはデータベースから削除されている状態

DOM にスキーマの状態を持たせる

これを利用して。 例えば data-state という属性を追加して、DOM にスキーマの状態を追加します。 また phx-hook でフックを設定します。

<div id="bars" phx-update="append">
  <%= for bar <- @bars do %>
    <div id="bar-<%= bar.id %>" data-state="<%= Ecto.get_meta(bar, :state) %>" phx-hook="Bar">
      <span><%= bar.id %></span>: <span><%= bar.name %></span>
    </div>
  <% end %>
</div>

JavaScript のフックで削除された状態の DOM を削除する

フックでは、DOM が更新された後に呼び出される関数 updated の中で data-state の内容を調べて、"deleted" になっていれば、状態が更新された要素を削除します。

let Hooks = {}

Hooks.Bar = {
  updated() {
    if (this.el.dataset.state == "deleted") {
      this.el.remove()
    }
  }
}

let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})

Hotwire のことを少し

先日 Hotwire が公表されました。 サーバで DOM を構築して送り出すことでページを更新するという、LiveView とも似ている部分があります。

さっそくさわってみました。

気がついたことはいくつかあるのですが、最初に目がいったのは部分的な DOM の削除の箇所。 予想どおりですが、削除のアクションが用意されていて、メソッド呼び出しひとつで指定した DOM を削除できるようになっていました。

www.rubydoc.info

実は LiveView での部分的な DOM の削除は、以前から気になっていた箇所です。 気になりつつも先送りしていたのですが、今回 Hotwire でそれができると知り、がぜん解決したくなったというのが今回の記事を書く動機になりました。

仕事では Ruby on Rails を主戦場にしていることもあり、ゆくゆくは Hotwire を使う機会があるかもしれません。 JavaScript 嫌いマン、JavaScript 書きたくないマン であるわたしにとっては LiveView や Hotwire のようなフレームワークが普及することを願って止みません。

Hotwire については。もう少し使ってみてから記事にしようと思います。

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

日々仕事でデータベースを扱っていて。 SQL以外にもデータをうまくあつかえる手段を手に入れたい、という思いが高まり。 一度は R に手を出したものの挫折。

構文が肌に合わなかったのかもしれないと、Julia に手を出し。 現在挫折中。

構文云々ではなく、まずなにより「データをあつかう」という領域のメンタルモデルが必要なのかもしれない、と感じる今日このごろ。

1から始める Juliaプログラミング

1から始める Juliaプログラミング

そんなわけで。 みなさん、よいお年を。

覚書 join.h

#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 を導入します。

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
  • メディア: コミック