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

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

PureScript と Erlang、と Elixir、の覚書

PureScript というプログラミング言語があります。

Haskell のような構文で記述でき JavaScript を出力できる、ということを半年ほど前に知ったのですが。

www.purescript.org

最近になって、バックエンドを切り替えれば Erlangソースコードを出力できるということを知りました。

元々は Phoenix のフロントエンドのプログラミングで PureScript を使うための方法を調べようとしていたのですが、あまりに面白そうだったので先にこちらに手を出した次第。

なお、ここから先は PureScript の開発環境は別途準備できている前提で話をしてゆきます。

Alternate backends

PureScript に利用できるバックエンドは、ドキュメントにまとめられています。

github.com

Erlang をターゲットにした purerl は今も開発が続けられ、現時点では一つ前のバージョン PureScript 0.15.14 まで対応されています。

github.com

Installation

purerl のインストールは、利用する環境のバイナリを GitHub からダウンロードするのが今のところ一番簡単な方法のようです。

github.com

ダウンロードできたら、 purerl コマンドを実行できるようにパスを設定するかリンクを作成するなどします。

purerl を利用した PureScript プロジェクトのサンプル

PureScript のパッケージマネジャの spago を使って、新しい PureScript プロジェクトを作成します。

$ mkdir example
$ cd example
$ spago init

purerl を設定する

spago.dhall を編集してバックエンドの指定を追加します。

{ name = "my-project"
, packend = "purerl" -- この行を追加する
, dependencies = [ "console", "effect", "prelude" ]
, packages = ./packages.dhall
, sources = [ "src/**/*.purs", "test/**/*.purs" ]
}

packages.dhall を編集して利用するパッケージの指定を purerl のものに変更します。

let upstream =
      https://github.com/purerl/package-sets/releases/download/erl-0.15.3-20220629/packages.dhall
        sha256:48ee9f3558c00e234eae6b8f23b4b8b66eb9715c7f2154864e1e425042a0723b

spago run コマンドを実行すると、パッケージのインストールやビルドが実行され、 src/Main.purs に書かれたコードの実行結果が出力されることを確認できると思います。

$ spago run
... パッケージのインストールやビルドのログ ...
🍝

生成された Erlang のコードは output/ に出力されています。

$ ls output/Main/ 
corefn.json     externs.cbor        main.hrl        main@foreign.hrl    main@ps.erl

また .beam ファイルは ebin/ に出力されます。 src/Main.pursコンパイル結果は main@ps.beam に出力されています。

$ ls ebin/main*       
ebin/main@ps.beam

インストールされたパッケージのソースコードやバイナリも output/ebin/ に格納されていることが確認できると思います。

Erlang から利用する

erl を起動します。 このとき、検索対象のパスに .beam ファイルが格納されたディレクトリを指定します。

$ erl -pa ebin

.beam ファイルのファイル名から、モジュール名は main@ps とわかるので、main@ps:main を実行してみます。

1> main@ps:main().
#Fun<effect_console@foreign.0.108104793>

main の型は Effect Unit と定義されていますが、 Effect モナドの値は Erlang からは関数に見えるようです。

戻り値の関数を実行してみます。

2> (main@ps:main())().
🍝
ok

Elixir から利用する

iex からも利用しています。 やり方は erl と同じです。

$ iex -pa ebin     
iex(1)> :main@ps.main()
#Function<0.108104793/0 in :effect_console@foreign.log/1>
iex(2)> :main@ps.main().()
🍝
:ok

elixir コマンドで直接実行することもできます。

$ elixir -pa ebin -e ':main@ps.main().()'
🍝

purerl を利用した Elixir プロジェクトのサンプル

Hex を検索すると、Elixir から purerl を利用するためのパッケージ purerlex を登録してくださっている方がいます。

hex.pm

これを利用させてもらうことにしました。

まず Elixir のプロジェクトを作成します。

$ mix new my_app
$ cd my_app

続いて同じディレクトリで PureScript のプロジェクトを作成します。

$ spago init

先の purerl のサンプルと同様に spago.dhallpackages.dhall を編集してバックエンドとパッケージの取得先を指定します。

purerlex を利用する

mix.exs を次のように編集します。

defmodule MyApp.MixProject do
  use Mix.Project

  def project do
    [
      app: :my_app,
      version: "0.1.0",
      elixir: "~> 1.16",
      start_permanent: Mix.env() == :prod,
      erlc_paths: ["output"],                  # 追加: Erlang のソースコードのパスとして output を指定
      compilers: [:purerl] ++ Mix.compilers(), # 追加: Elixir のコンパイル時に purerl の実行を指定
      deps: deps()
    ]
  end

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

  defp deps do
    [
      {:purerlex, "~> 0.12.2"}                 # 追加
    ]
  end
end

ちなみに。 ここでコンパイラに指定している purerl は、 purerl コマンドではなく、purerlex が定義しているタスクです。

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

$ mix deps.get

iex を起動します。

$ iex -S mix                                                                                  

-S mix を指定して iex を起動すると自動的にコンパイルが実行されますが、このとき PureScript のパッケージの取得やビルドも実行されていることが確認できると思います。

iex(1)> :main@ps.main()
#Function<0.108104793/0 in :effect_console@foreign.log/1>
iex(2)> :main@ps.main().()
🍝
:ok

Elixir から利用する

Elixir のプロジェクトを作成したときに作成される MyApp.hello/0 を PureScript で書いたものに置き換えてみます。

MyApp.hello/0 はアトムを返すので、アトムを利用できるように PureScript のパッケージを追加します。 このパッケージは packages.dhall で指定した package-sets を元に検索されます。

$ spago install purescript-erl-atom

新しく src/MyApp.purs を作成します。

module MyApp where

import Erl.Atom (atom)

hello = atom "world"

コンパイル

$ mix compile

コンパイルの結果出力される .beam ファイルが格納される _build/dev/lib/my_app/ebin/ を確認すると myApp@ps.beam という名前で出力されていることがわかります。

実行してみます。

$ iex -S mix
iex(1)> :myApp@ps.hello()
:world

期待する値が得られることが確認できました。

MyApp の呼び出しを委譲してみます。

defmodule MyApp do
  defdelegate hello, to: :myApp@ps
end

当然ですが期待する結果がえられますしテストもパスします。

$ iex -S mix
iex(1)> MyApp.hello()
:world
$ mix test
1 test, 0 failures

Elixir のプロジェクトで PureScript を利用できることが確認できました。

フォントデータを ETS で保存する

前回の続きです。

前回は BDF ファイルをパースして読み込む話をしました。

そして、BDF ファイルは単純なテキストファイルだし、Nerves アプリケーションへそのまま持っていっても大丈夫だろう、と高を括っていたのですが。

Raspberry Pi ZERO W の非力さを甘くみていました。

起動時に BDF ファイルを読み込ませるようにしたら、電源を入れてもなかなか入力に反応しない。 何かを壊したかとあせりもしたのですが、結局テキストのパースに時間がかかっている様子でした。

一旦読み込み終えてしまえば、あとはメモリ上のアクセスのみになるので、その後の動作には影響しません。 しかし電源を入れてから使えるようになるまで時間がかかるのは問題です。 そのため、読み込んだデータを別の形式で保存しておきすぐに読み出せるようにできる方法を模索しました。

せっかくなら文字コードをキーにアクセスできるように、key-value ストレージのようなものでなにか扱いやすいもの。

Hex も検索してみたのですが。 よく考えてみればあるではないですか、標準装備のストレージが。

ETS です。

www.erlang.org elixirschool.com

どのように利用するとデータが扱いやすくなるのか、いくつか格納方法を変えて試してみました。

今回もフォントデータには 16 ドットの東雲フォントを利用しています。 ファイルサイズは約 1.1 M バイト。

-rw-r--r--@ 1 matsumotoeiji  staff  1135668  9 15  2004 shnmk16.bdf

構造体で保存する

Erlang Term Storage の名の通り、Erlang Term であればなんでも格納できるとあって、まずは構造体をそのまま格納。

BDF モジュールは前回の記事で登場したモジュールです。

{:ok, fonts} = BDF.load("shinonome-0.9.11/bdf/shnmk16.bdf")

table = :ets.new(:shnmk16, [:set])

Enum.each(fonts, fn font ->
  :ets.insert(table, {font.encoding, font})
end)

:ets.tab2file(table, ~c"priv/fonts/shnmk16.ets")

できたファイルのサイズは約 2 M バイト。

$ mix run bdf2ets-1.exs
$ ls -l priv/fonts/shnmk16.ets
-rw-r--r--  1 matsumotoeiji  staff  2080992  2 27 19:44 priv/fonts/shnmk16.ets

tab2file/2 で書き出したデータは、file2tab/1 で簡単に復元でき、lookup/2 で検索できます。

iex(1)> {:ok, table} = :ets.file2tab(~c"priv/fonts/shnmk16.ets")
{:ok, #Reference<0.513635613.2690514945.38635>}
iex(2)> :ets.lookup(table, 0x4E6E)
[
  {20078,
   %BDF.Font{
  encoding: 0x4E6E,
  dwidth: %BDF.Font.DWIDTH{dwx0: 16, dwy0: 0},
  bbx: %BDF.Font.BBX{bbw: 16, bbh: 16, bbxoff0x: 0, bbyoff0y: -2},
  bitmap: [0x0000, 0x3FFC, 0x0100, 0x7FFE, 0x4102, 0x7D7A, 0x4F3E, 0x0000, 0x1FF8, 0x0000, 0x7FFE, 0x0248, 0x1248, 0x0A50, 0x7FFE, 0x0000]
}
}
]

ただしこれは BDF モジュールが定義されている環境のばあい。

モジュールが定義されていない環境で読み込んでみると。

iex(1)> {:ok, table} = :ets.file2tab(~c"priv/fonts/shnmk16.ets")
{:ok, #Reference<0.217574499.542769155.19734>}
iex(2)> :ets.lookup(table, 0x4E6E)
[
  {20078,
   %{
     encoding: 20078,
     __struct__: BDF.Font,
     dwidth: %{__struct__: BDF.Font.DWIDTH, dwx0: 16, dwy0: 0},
     bbx: %{
       __struct__: BDF.Font.BBX,
       bbh: 16,
       bbw: 16,
       bbxoff0x: 0,
       bbyoff0y: -2
     },
     bitmap: [0, 16380, 256, 32766, 16642, 32122, 20286, 0, 8184, 0, 32766, 584,
      4680, 2640, 32766, 0]
   }}
]

読み込めないわけではないものの、構造体が定義されていないので、内部構造が丸見えになったマップとして扱われています。

タプルで保存する

一度構造体に格納したものを、ただのタプルにしてしまうのもどうかと思いましたが。 内容の単純さや利用シーンを考えるとタプルでもさほど不便はないかと思い直すなど。

{:ok, fonts} = BDF.load("shinonome-0.9.11/bdf/shnmk16.bdf")

table = :ets.new(:shnmk16, [:set])

Enum.each(fonts, fn font ->
  :ets.insert(
    table,
    {
      font.encoding,
      font.dwidth.dwx0,
      font.dwidth.dwy0,
      font.bbx.bbw,
      font.bbx.bbh,
      font.bbx.bbxoff0x,
      font.bbx.bbyoff0y,
      font.bitmap
    }
  )
end)

:ets.tab2file(table, ~c"priv/fonts/shnmk16.ets")
$ mix run bdf2ets-2.exs
$ ls -al priv/fonts/shnmk16.ets
-rw-r--r--  1 matsumotoeiji  staff  767103  2 27 19:52 priv/fonts/shnmk16.ets

約 767 k バイト。構造体で保存した時の 3 分の 1 程度。

構造から名前が失われ、読みだせばたただの数字の羅列。

iex(1)> {:ok, table} = :ets.file2tab(~c"priv/fonts/shnmk16.ets")
{:ok, #Reference<0.2786847046.275644418.110991>}
iex(2)> :ets.lookup(table, 0x4E6E)
[
  {20078, 16, 0, 16, 16, 0, -2,
   [0, 16380, 256, 32766, 16642, 32122, 20286, 0, 8184, 0, 32766, 584, 4680,
    2640, 32766, 0]}
]

しかしタプルの中の位置がわかっていればよいので、これでも構わない気もしてくる。

バイナリで保存する

フォントデータはビット操作で加工したりするわけだし、いっそのこと全体をバイナリにしてしまっても構わないのでは? と思ってやってみました。

ETS に格納できるのは先頭の要素をキーとしたタプルのみなので、文字コードだけをそのままに、残りのデータを一つのバイナリに詰め込み。

{:ok, fonts} = BDF.load("shinonome-0.9.11/bdf/shnmk16.bdf")

table = :ets.new(:shnmk16, [:set])

Enum.each(fonts, fn font ->
  bitmap = for line <- font.bitmap, into: <<>>, do: <<line::size(font.bbx.bbw)>>

  :ets.insert(
    table,
    {
      font.encoding,
      <<
        font.dwidth.dwx0::8,
        font.dwidth.dwy0::8,
        font.bbx.bbw::8,
        font.bbx.bbh::8,
        font.bbx.bbxoff0x::8,
        font.bbx.bbyoff0y::8,
        bitmap::binary
      >>
    }
  )
end)

:ets.tab2file(table, ~c"priv/fonts/shnmk16.ets")
$ mix run bdf2ets-3.exs
$ ls -l priv/fonts/shnmk16.ets
-rw-r--r--  1 matsumotoeiji  staff  406276  2 27 20:01 priv/fonts/shnmk16.ets

約 406 k バイト。タプルで保存したときの半分強くらい。構造体で保存したときの 5 分の 1 くらい。 コンパクトにはなりました。

読みだせば、値と値の区切りも喪失した、タプルで格納したときよりもさらに面妖な状態。

iex(1)> {:ok, table} = :ets.file2tab(~c"priv/fonts/shnmk16.ets")
{:ok, #Reference<0.3552940925.8519682.187654>}
iex(2)> :ets.lookup(table, 0x4E6E)
[
  {20078,
   <<16, 0, 16, 16, 0, 254, 0, 0, 63, 252, 1, 0, 127, 254, 65, 2, 125, 122, 79,
     62, 0, 0, 31, 248, 0, 0, 127, 254, 2, 72, 18, 72, 10, 80, 127, 254, 0, 0>>}
]

ベンチマークは未実施

ライブラリの関数を使ってデータを一括で読み込むので時間もかからず、格納のしかたによってファイルサイズを小さくできることもわかりました。 が。 利用時の効率はまだ測れていません。

Elixir がバイナリ操作を得意としているとはいえ、構造を持つタプルの操作と比べると、バイナリの操作には余分に時間がかかるのではと想像します。 その差は微々たるものなのでしょうが、Raspberry Pi でテキストファイルを読み込ませたら存外時間が取られたという前科があるので、油断はできません。 あるいは、表示デバイスとの IO の方の影響の方がずっと大きくて、バイナリの操作にかかる時間は気にするほどのことでないのかもしれません。

こればかりはベンチマークするしかないので、もう少し調べてみたいと思います。

Glyph Bitmap Distribution Format (BDF) を Elixir で読み込む

Glyph Bitmap Distribution Format (BDF) というフォントフォーマットがあります。

en.wikipedia.org

記事の最後の方に書いたような理由があって、 BDF を読み込むパッケージを書いています。

道半ばなのですが、お試しで使えるくらいにはまとまったので、一旦出力しておこうと思った次第。

github.com

今回書いた BDF をパースする方法は、仕様上は安全でない可能性があるのですが、それに関しては記事を改めて考察することにしたいと思います。

BDF をコンソールに表示してみる

これを使って実際に文字を表示してみます。 まずは iex でお手軽に試します。

パッケージをインストールする

まだ hex.pm に公開していないので、GitHubリポジトリを指定してインストールしてください。

mix.exs でインストールする場合:

  defp deps do
    [
      {:bdf, github: "mattsan/bdf"}
    ]
  end

iex やスクリプトファイルでインストールする場合:

Mix.install([{:bdf, github: "mattsan/bdf"}])

フォントファイルを入手する

BDF ファイルを用意して読み込みます。

今回はパブリックドメインで公開されている東雲フォントを利用させていただきました。

openlab.ring.gr.jp

表示したいフォントデータを取得する

東雲フォントは文字コードに JIS を利用しています。 必要に応じて JIS X 0213のコード対応表 などを利用して表示したい文字の文字コードを確認し、フォントデータを取得します。

今回はサンプルということで、特に効率などを考慮せず Enum.find/2 で線形検索しています。

{:ok, fonts} = BDF.load("path/to/shnmk16.bdf") # 入手した BDF ファイルのパスを指定します

font = Enum.find(fonts, & &1.encoding == 0x4E6E)

表示する

取得したフォントデータは、点の一つ一つが 1 ビットで表現されているので、それらのビットを見える形に展開して表示します。

Enum.each(font.bitmap, fn row ->
  for <<dot::1 <- <<row::size(font.bbx.bbw)>> >> do
    case dot do
      0 -> " ."
      1 -> "@@"
    end
  end
  |> Enum.join()
  |> IO.puts()
end)

結果。

 . . . . . . . . . . . . . . . .
 . .@@@@@@@@@@@@@@@@@@@@@@@@ . .
 . . . . . . .@@ . . . . . . . .
 .@@@@@@@@@@@@@@@@@@@@@@@@@@@@ .
 .@@ . . . . .@@ . . . . . .@@ .
 .@@@@@@@@@@ .@@ .@@@@@@@@ .@@ .
 .@@ . .@@@@@@@@ . .@@@@@@@@@@ .
 . . . . . . . . . . . . . . . .
 . . .@@@@@@@@@@@@@@@@@@@@ . . .
 . . . . . . . . . . . . . . . .
 .@@@@@@@@@@@@@@@@@@@@@@@@@@@@ .
 . . . . . .@@ . .@@ . .@@ . . .
 . . .@@ . .@@ . .@@ . .@@ . . .
 . . . .@@ .@@ . .@@ .@@ . . . .
 .@@@@@@@@@@@@@@@@@@@@@@@@@@@@ .
 . . . . . . . . . . . . . . . .

BDF を Livebook で表示してみる

Livebook を使えば簡単に画像で確認することもができるので、これもやってみます。

パッケージをインストールする

mattsan/bdf の他に、画像を表示すために kino と、PNG データを生成するために拙作の pngex も合わせてインストールします。

Mix.install([
  {:kino, "~> 0.12.3"},
  {:pngex, "~> 0.1.2"},
  {:bdf, github: "mattsan/bdf"}
])

BDF ファイルを読み込む

読み込みは iex で試したときと変わりません。 ダウンロードした BDF ファイルを読み込みます。

{:ok, fonts} = BDF.load("path/to/shnmk16.bdf")

フォントデータを画像に変換する

pngex を使って画像に変換します。 ここではパレットカラーを使い、 1 ビット / ドットの画像を生成しています。

palette = [
  {255, 255, 255}, # 背景: 白
  {0, 0, 0}        # 文字: 黒
]

font1 = Enum.find(fonts, & &1.encoding == 0x4E6E)
font2 = Enum.find(fonts, & &1.encoding == 0x4C74)

bitmap =
  for row <- font1.bitmap ++ font2.bitmap, into: <<>> do
    <<row::size(font1.bbx.bbw)>>
  end

Pngex.new()
|> Pngex.set_type(:indexed)
|> Pngex.set_depth(:depth1)
|> Pngex.set_size(font1.bbx.bbw, font1.bbx.bbh + font2.bbx.bbh)
|> Pngex.set_palette(palette)
|> Pngex.generate(bitmap)
|> IO.iodata_to_binary()

Livebook での表示の様子。

表示が小さいので縦横 4 倍のサイズにしてみます。

palette = [
  {255, 255, 255}, # 背景: 白
  {0, 0, 0}        # 文字: 黒
]

font1 = Enum.find(fonts, & &1.encoding == 0x4E6E)
font2 = Enum.find(fonts, & &1.encoding == 0x4C74)

bitmap =
  for row <- font1.bitmap ++ font2.bitmap, into: <<>> do
    line =
      for <<dot::1 <- <<row::size(font1.bbx.bbw)>> >>, into: <<>> do
        case dot do
          0 -> <<0::4>>
          1 -> <<0xF::4>>
        end
      end
    String.duplicate(line, 4)
  end

Pngex.new()
|> Pngex.set_type(:indexed)
|> Pngex.set_depth(:depth1)
|> Pngex.set_size(font1.bbx.bbw * 4, (font1.bbx.bbh + font2.bbx.bbh) * 4)
|> Pngex.set_palette(palette)
|> Pngex.generate(bitmap)
|> IO.iodata_to_binary()

解像度の低いディスプレイに似合いそうな表示が得られました。

そんなわけで、Nerves 再起動

開発が進んでいる様子のコメントがされつつも、なかなか公開に至っていなかった Circuits.GPIO ですが、満を持してバージョン 2.0 が今月公開されました。

hex.pm

それに刺激を受けて、5 年ほど放置していた Nerves プログラミングを再開。

nerves-project.org

(デバイスRPI-ZERO-WH Raspberry Pi Zero WH【ピンヘッダ実装済】 + WaveShare 13891 1.44インチ 128×128 LCDディスプレイHAT for RaspberryPi

そのうち、何かおもしろいものを出力できるといいな。 そのうち。

いつか読むはずっと読まない:神経網計画

nextpublishing.jp pragprog.com pragprog.com

40年のソフトウェア的愛情〜または私は如何にして心配するのを止めてプログラマであったか

以前、プログラミングを始めて 30 年が経ちましたという記事を書きました。

blog.emattsan.org

それから 10 年が過ぎました。 気づくと 40 年。

職業プログラマに転向して 10 年。 今もプログラマを続けています。

好きでプログラミングを続けているとはいえ、仕事をしていると疲労もストレスも感じます。 そんなときは好きなプログラミングで疲れを癒す。

そんな日々を送っています。 たぶんこれからもそんな日々を続けるのだろう、と思いをはせる年の瀬。

41 年目もよろしくお願いします。

Phoenix LiveView の assign_async と async_result

Phoenix LiveView で値を socket に assign するとき、その値が例えば Web API などで取得しなければならないとき、一旦待ち状態を設定して、それから Task.async などを利用して、結果が得られたら取得できた値を設定し直すという非同期の処理を行うわけですが。

そういった使い方が多かったためなのか、 LiveView 0.20.0 では非同期で assign をおこなう Phoenix.LiveView.assign_async/3 と、その状態を表示する Phoenix.Component.async_result/1 が追加されていました。 9 月末に追加されていたのですが、すっかり見落としていました。

関数の追加に合わせて、ドキュメントにも一節が追加されています。

今後、繰り返し利用することになりそうなので、これらの使い方を確認してみました。

assign_async & async_result

基本的な使い方はそれほど難しくありません。

まず、値の設定には assign/3 の代わりに assign_async/3 を利用します。

第 2 引数は assign/3 と同じようにキーを指定しますが、第 3 引数には非同期で実行する関数を指定します。 その関数は、戻り値として {:ok, result}{:error, reason} の形の値を返す必要があります。 また result の値は assign_async/3 の第 2 引数に渡したキーを持つマップでなければなりません。

assign_async(socket, :foo, fn ->
  # 非同期で実行したい処理

  {:ok, %{foo: 42}} # 処理に成功したばあい、第 2 引数に渡したキーと同じキーを持つマップを返す
end)

assign_async/3 の第 3 引数に渡した関数の結果は async_result/1 で受けることができます。

async_result/1assign で指定したキーを指定し、:let で値を受け取ります。 内部のブロックは関数が成功して値を返するまで表示されません。

また async_result/1:loading:failed の 2 つのスロットを持っています。

:loading は関数が完了するまでのあいだ表示さるものです。

:failed は関数がエラーを返したときに表示されます。 エラーの内容は :let で受けることができます。 内容は関数の戻り値そのままになっています。

これらのスロットは省略可能です。

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <.async_result :let={result} assign={@result}>
      result = <%= result %>
      <:loading>waiting...</:loading>
      <:failed :let={ {:error, reason} }><%= reason %></:failed>
    </.async_result>
    """
  end

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign_async(:result, fn ->
        Process.sleep(2_000)     # 時間のかかる処理の代わり
        case :rand.uniform(2) do # 2 分の 1 で成功/失敗を返す
          1 -> {:ok, %{result: 42}}
          2 -> {:error, "no result"}
        end
      end)

    {:ok, socket}
  end
end

ちなみに。 関数の戻り値をマップにしなければならない理由は、一回の結果で複数の値を返せるようにするためのようです。 次の例ように assign_async/3 の第 2 引数はリストで複数のキーを指定することができ、そのキーごとに async_result/1 を記述することができます。

  def render(assigns) do
    ~H"""
    <.async_result :let={foo} assign={@foo}><%= foo %></.async_result>
    <.async_result :let={bar} assign={@bar}><%= bar %></.async_result>
    <.async_result :let={baz} assign={@baz}><%= baz %></.async_result>
    """
  end

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign_async([:foo, :bar, :baz], fn ->
        Process.sleep(2_000)
        {:ok, %{foo: "Foo", bar: "Bar", baz: "Baz"}}
      end)
    {:ok, socket}
  end

ページを表示したあとに、ページ上のイベントで処理を実行したいばあいも assign_async/3 が利用できます。

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <.async_result :let={result} assign={@result}>
      result = <%= result %>
      <:loading>waiting...</:loading>
      <:failed :let={{:error, reason}}><%= reason %></:failed>
    </.async_result>

    <div>
      <.button phx-click="rerun">再実行</.button>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign_async(:result, &run/0)

    {:ok, socket}
  end

  # 再実行イベントのハンドラ
  def handle_event("rerun", _params, socket) do
    socket =
      socket
      |> assign_async(:result, &run/0)

    {:noreply, socket}
  end

  # 再実行イベントでも利用するので、独立した関数に分離した
  defp run do
    Process.sleep(2_000)

    case :rand.uniform(2) do
      1 -> {:ok, %{result: 42}}
      2 -> {:error, "no result"}
    end
  end
end

Phoenix.LiveView.AsyncResult

ちなみに assign_async/3 で割り当てられた値はどのようになっているかというと。

rener/1 の中に <%= inspect(@result) %> を挿入するなどしてその値を覗いてみます。

  • 実行中
%Phoenix.LiveView.AsyncResult{ok?: false, loading: [:result], failed: nil, result: nil}
  • 成功
%Phoenix.LiveView.AsyncResult{ok?: true, loading: nil, failed: nil, result: 42}
  • 失敗
%Phoenix.LiveView.AsyncResult{ok?: false, loading: nil, failed: {:error, "no result"}, result: nil}

構造体 Phoenix.LiveView.AsyncResult に値が格納されていることがわかります。

このことを理解しておくことが、もう一つの関数を利用するときに重要になります。

start_async & handle_async

今回のバージョンアップで、 assign_async/3async_result/1 に加えてもう一つ、関数 start_async/3 が追加されています。

start_async/3 を使うと assign_async/3 と比べべてより細かな制御ができるようになっています。

その代わり、構造体 Phoenix.LiveView.AsyncResult のキーへの割り当てと、結果を反映するためにハンドラ handle_async/3 は自分で記述しなければなりません。

start_async/3

まず、 Phoenix.LiveView.AsyncResult.loading/0-1 を使ってキーに割り当てる値を作成します。 ここで引数に任意の値を渡すことができます。

assign(socket, :result, Phoenix.LiveView.AsyncResult.loading())

# もしくは、引数に任意の値を指定する
assign(socket, :result, Phoenix.LiveView.AsyncResult.loading("実行中"))

引数で渡した値は、スロット :loading:let を使って受け取ることができます。

<:loading :let={loading}><%= loading %></:loading>

キーへの割り当てができたら、 start_async/3 で非同期処理を実行します。

    socket =
      socket
      |> assign(:result, Phoenix.LiveView.AsyncResult.loading("実行中"))
      |> start_async(:result, &run/0)

なお、今回は使用しませんが、構造体の値を更新する loading/2 も用意されています。 非同期処理を多段階で実行するときに活用できそうです。

socket
|> assign(:result, Phoenix.LiveView.AsyncResult.loading(1))

...

socket
|> update(:result, &Phoenix.LiveView.AsyncResult.loading(&1, &1.loading + 1))
<:loading :let={step}>ステップ <%= step %> を実行中</:loading>

handle_async/3

結果は、ハンドラ handle_async/3 で受け取ります。 第 1 引数には assign したキーを指定します。

タスク成功時

注意が必要なのは第 2 引数で、ここには非同期処理を実行したタスクの結果が渡されてきます。 {:ok, task_result} の形で受け取る値はタスクの実行結果であり、処理の実行結果ではありません。 処理の実行結果は task_result の内容を調べる必要があります。

結果を表示に反映するには、最初に割り当てた値を ok/2 または failed/2 を使って更新します。

ハンドラの全体は次のようになります。 成功時、失敗時、それぞれ扱う値がやや込み入っているので注意が必要です。

  def handle_async(:result, {:ok, task_result}, socket) do
    socket =
      case task_result do
        {:ok, %{result: result}} ->
          # 関数の成功時の処理
          socket
          |> update(:result, &Phoenix.LiveView.AsyncResult.ok(&1, result))

        {:error, _reason} = error ->
          # 関数の失敗時の処理
          socket
          |> update(:result, &Phoenix.LiveView.AsyncResult.failed(&1, error))
      end

    {:noreply, socket}
  end

タスク失敗時

handle_async/3 の第 2 引数はタスクの実行結果なので、処理が完了したときの結果は :ok のタプルで渡されてきますが、例外などで処理が完了しなかったばあいには :exit のタプルが渡されてきます。

そのときの第 2 引数は {:exit, reason} という形になり、原因が例外だったばあいには result{exception, stacktrace} の形になります。

ハンドリングした例外の内容は failed/2 で設定して表示することができます。

  def handle_async(:result, {:exit, {exception, _stacktrace}}, socket) do
    socket =
      socket
      |> update(:result, &Phoenix.LiveView.AsyncResult.failed(&1, {:error, Exception.message(exception)}))

    {:noreply, socket}
  end

まとめ

コードの全体はこのようになりました。

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <.async_result :let={result} assign={@result}>
      result = <%= result %>
      <:loading :let={loading}><%= loading %></:loading>
      <:failed :let={{:error, reason}}><%= reason %></:failed>
    </.async_result>

    <div>
      <.button phx-click="rerun">再実行</.button>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:result, Phoenix.LiveView.AsyncResult.loading("実行中"))
      |> start_async(:result, &run/0)

    {:ok, socket}
  end

  # 再実行イベントのハンドラ
  def handle_event("rerun", _params, socket) do
    socket =
      socket
      |> assign(:result, Phoenix.LiveView.AsyncResult.loading("再実行中"))
      |> start_async(:result, &run/0)

    {:noreply, socket}
  end

  # 関数完了イベントのハンドラ
  def handle_async(:result, {:ok, task_result}, socket) do
    socket =
      case task_result do
        {:ok, %{result: result}} ->
          socket
          |> update(:result, &Phoenix.LiveView.AsyncResult.ok(&1, result))

        {:error, _reason} = error ->
          socket
          |> update(:result, &Phoenix.LiveView.AsyncResult.failed(&1, error))
      end

    {:noreply, socket}
  end

  # 例外発生時のハンドラ
  def handle_async(:result, {:exit, {exception, _stacktrace}}, socket) do
    socket =
      socket
      |> update(:result, &Phoenix.LiveView.AsyncResult.failed(&1, {:error, Exception.message(exception)}))

    {:noreply, socket}
  end

  defp run do
    Process.sleep(2_000)

    case :rand.uniform(3) do # 3 分の 1 で成功/失敗/例外を返す
      1 -> {:ok, %{result: 42}}
      2 -> {:error, "no result"}
      3 -> raise "Boom"
    end
  end
end

いつか読むはずっと読まない:恐竜前、恐竜後

恐竜前の単弓類の時代と、恐竜後の単弓類の時代。

ElixirでCompositeパタン風な構造を扱う時の覚書

en.wikipedia.org

Compositeパタンは、再起的な構造を表現するときにしばしば顔を出すパタンで、node と leaf に同じイタンフェースを与えることで、それらを同一視して再起的に扱えるようにするしくみです。

Elixir は型付けが動的なので、特別な工夫をしなくても再起的な構造を作れますが、同一視して扱えるようにするには少し工夫必要です。

例えば。 一つの値をもつ leaf と、leaf もしくは node を二つ持つ node からtree を構築し、traverse ですべての値を渡り歩きたいばあい。

leaf1 = Tree.new_leaf(1)
leaf2 = Tree.new_leaf(2)
leaf3 = Tree.new_leaf(3)
leaf4 = Tree.new_leaf(4)
leaf5 = Tree.new_leaf(5)
leaf6 = Tree.new_leaf(6)

tree =
  Tree.new_node(
    Tree.new_node(
      Tree.new_node(leaf1, leaf2),
      leaf3
    ),
    Tree.new_node(
      leaf4,
      Tree.new_node(leaf5, leaf6)
    )
  )

Tree.Component.traverse(tree, fn a -> IO.inspect(a) end)
$ mix run sample.exs 
1
2
3
4
5
6

このとき Node が持つ二つの要素が Leaf なのか Node なのかを区別せずに関数を適用したいわけですが、その関数が LeafNode をパタンマッチで識別するようでは実装が硬直してしまいます。

このようなばあい、traverse/2プロトコルで定義し、それぞれの構造体で定義することで実現します。

具体的には、次のような実装が考えられます。

defmodule Tree do
  alias Tree.{Leaf, Node}

  def new_leaf(value) do
    Leaf.new(value)
  end

  def new_node(left, right) do
    Node.new(left, right)
  end
end
defprotocol Tree.Component do
  def traverse(component, fun)
end
defmodule Tree.Leaf do
  defstruct [:value]

  def new(value) do
    %__MODULE__{value: value}
  end

  defimpl Tree.Component do
    def traverse(%Tree.Leaf{value: value}, fun) do
      fun.(value)
    end
  end
end
defmodule Tree.Node do
  defstruct [:left, :right]

  def new(left, right) do
    %__MODULE__{left: left, right: right}
  end

  defimpl Tree.Component do
    def traverse(%Tree.Node{left: left, right: right}, fun) do
      Tree.Component.traverse(left, fun)
      Tree.Component.traverse(right, fun)
    end
  end
end

ここで問題になるのが、プロトコルを実装していない値が含まれてしまったばあいです。

Node 向けの traverse/2 の実装は、leftrighttraverse/2 を定義したプロトコルを実装した構造体の値であることを前提にしています。 ですが、Elixir は型付けが動的なゆえに、このままでは任意の値を格納した Node の値を作れてしまいます。

そこで Tree.new_node/2Node の値を作るときに、引数に渡せる値を制限することにします。

まず、ガードの is_struct/1 を使うことで、引数に与えることができる値を構造体の値に制限します。

is_struct/2 を使えば、構造体の種類も限定できますが、今回は Leaf の値も Node の値も受け付けたいので都合がよくありません。 is_struct(left, Tree.Leaf) or is_struct(left, Tree.Node) と書けなくはないですが、プロトコルを利用するときの利点であったコードの柔軟性が失われてしまいます。

プロトコルの実装状況をガードで検証したいところですが、残念ながらそのようなガードは用意されていないようです。 ただ、関数は用意されているのでこれを利用することにします。

Protocol.assert_impl!/2 は、第 1 引数にプロトコルを、第 2 引数に構造体の型になるモジュールを取ります。 また Elixir の構造体の値は、__struct__ を参照することでその値の方になるモジュールがわかるようになっています。

leaf = Tree.new_leaf(123)
#=> %Tree.Leaf{value: 123}

leaf.__struct__
#=> Tree.Leaf

node = Tree.new_node(Tree.new_leaf(123), Tree.new_leaf(456))
#=> %Tree.Node{left: %Tree.Leaf{value: 123}, right: %Tree.Leaf{value: 456}}

node.__struct__
#=> Tree.Node

これらを組み合わせてプロトコルを実装した型で引数を制限することにします。 ここでは Tree.new_node/2 の引数を検証することで、期待しない値が tree に組み込まれるのを防ぐことにします。

これで少なくとも traverse/2 を適用する時点でなく、値を構築する時点で異常を検出できるようになりました。

defmodule Tree do
  alias Tree.{Leaf, Node}

  def new_leaf(value) do
    Leaf.new(value)
  end

  def new_node(left, right) when is_struct(left) and is_struct(right) do
    Protocol.assert_impl!(Tree.Component, left.__struct__)
    Protocol.assert_impl!(Tree.Component, right.__struct__)

    Node.new(left, right)
  end
end

なお、補足として。 Tree.Node の内部構造や Tree.Node.new/2 はあくまで非公開というのが前提になります。

ElixirのGenServerでTaskを使うための補遺

前回、GenServer の渋滞を解消するために Task を利用する方法を紹介しました。

blog.emattsan.org

これは Task のプロセスが異常終了しないことを前提にしていて、異常終了が予想されるばあいには、その対策を施しておく必要があります。

結論から言うと Task.Supervisor.async_nolink/3 を利用するとよいようです。

詳細は TaskTask.Supervisor に記載されていますので、そちらを参照してみてください。

以下は、GenServer の中で Task プロセスが異常終了したときのふるまいを検証した記録です。

対策しない

まず、対策しなかったときのふるまいを確認します。

コード

do_something/0 を呼ぶと、handle_call/3 の中で Task のプロセスを起動します。

Task のプロセスは起動するとすぐに例外を送出します。

defmodule MyApp.Worker1 do
  use GenServer

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def do_something do
    GenServer.call(__MODULE__, :do_something)
  end

  @impl true
  def init(_opts) do
    {:ok, %{}}
  end

  @impl true
  def handle_call(:do_something, _from, state) do
    Task.async(fn ->
      raise "Boom!"
    end)
    {:noreply, state}
  end

  @impl true
  def handle_info(msg, state) do
    dbg msg
    {:noreply, state}
  end
end

実行

iex -S mix で起動し、実行します。

iex(1)> {:ok, pid} = MyApp.Worker1.start_link()
{:ok, #PID<0.167.0>}

iex(2)> Process.info(pid)
[
  registered_name: MyApp.Worker1,
  ...略...
]

iex(3)> MyApp.Worker1.do_something()

11:32:48.607 [error] Task #PID<0.168.0> started from MyApp.Worker1 terminating
** (RuntimeError) Boom!
    (my_app 0.1.0) lib/my_app/worker_1.ex:20: anonymous fn/0 in MyApp.Worker1.handle_call/3
    (elixir 1.15.6) lib/task/supervised.ex:101: Task.Supervised.invoke_mfa/2
    (elixir 1.15.6) lib/task/supervised.ex:36: Task.Supervised.reply/4
Function: #Function<0.95600963/0 in MyApp.Worker1.handle_call/3>
    Args: []
** (EXIT from #PID<0.166.0>) shell process exited with reason: an exception was raised:
    ** (RuntimeError) Boom!
        (my_app 0.1.0) lib/my_app/worker_1.ex:20: anonymous fn/0 in MyApp.Worker1.handle_call/3
        (elixir 1.15.6) lib/task/supervised.ex:101: Task.Supervised.invoke_mfa/2
        (elixir 1.15.6) lib/task/supervised.ex:36: Task.Supervised.reply/4

Interactive Elixir (1.15.6) - press Ctrl+C to exit (type h() ENTER for help)

タスクとリンクしている MyApp.Worker1 のプロセスが終了し、そのプロセスにリンクしている iex のプロセスも終了して iex が再起動していることがわかります。

EXIT をトラップする

trap_exit フラグを true にして EXIT をトラップする方法があります。

しかし一律でトラップしてしまうため、予想できない影響が出ることも考えられます。 Task.async/1 のドキュメントにも注意を促す但書がついています。

  • Setting :trap_exit to true - trapping exits should be used only in special circumstances as it would make your process immune to not only exits from the task but from any other processes.

コード

init/1Process.flag(:trap_exit, true) を実行し EXIT のトラップを指定しています。

defmodule MyApp.Worker2 do
  use GenServer

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def do_something do
    GenServer.call(__MODULE__, :do_something)
  end

  @impl true
  def init(_opts) do
    Process.flag(:trap_exit, true)
    {:ok, %{}}
  end

  @impl true
  def handle_call(:do_something, _from, state) do
    Task.async(fn ->
      raise "Boom!"
    end)
    {:noreply, state}
  end

  @impl true
  def handle_info(msg, state) do
    dbg msg
    {:noreply, state}
  end
end

実行

タスクのプロセスで例外が送出されたあと、GenServer のプロセスが :DOWN 以外に :EXIT のメッセージを受け取っているのがわかります。

iex(1)> {:ok, pid} = MyApp.Worker2.start_link()
{:ok, #PID<0.143.0>}

iex(2)> Process.info(pid)
[
  registered_name: MyApp.Worker2,
  ...略...
]

iex(3)> MyApp.Worker2.do_something()

11:34:22.007 [error] Task #PID<0.144.0> started from MyApp.Worker2 terminating
** (RuntimeError) Boom!
    (my_app 0.1.0) lib/my_app/worker_2.ex:21: anonymous fn/0 in MyApp.Worker2.handle_call/3
    (elixir 1.15.6) lib/task/supervised.ex:101: Task.Supervised.invoke_mfa/2
    (elixir 1.15.6) lib/task/supervised.ex:36: Task.Supervised.reply/4
Function: #Function<0.111817085/0 in MyApp.Worker2.handle_call/3>
    Args: []
[lib/my_app/worker_2.ex:28: MyApp.Worker2.handle_info/2]
msg #=> {:EXIT, #PID<0.144.0>,
 {%RuntimeError{message: "Boom!"},
  [
    {MyApp.Worker2, :"-handle_call/3-fun-0-", 0,
     [
       file: ~c"lib/my_app/worker_2.ex",
       line: 21,
       error_info: %{module: Exception}
     ]},
    {Task.Supervised, :invoke_mfa, 2,
     [file: ~c"lib/task/supervised.ex", line: 101]},
    {Task.Supervised, :reply, 4, [file: ~c"lib/task/supervised.ex", line: 36]}
  ]}}

[lib/my_app/worker_2.ex:28: MyApp.Worker2.handle_info/2]
msg #=> {:DOWN, #Reference<0.0.18307.2349639668.3769958402.28027>, :process,
 #PID<0.144.0>,
 {%RuntimeError{message: "Boom!"},
  [
    {MyApp.Worker2, :"-handle_call/3-fun-0-", 0,
     [
       file: ~c"lib/my_app/worker_2.ex",
       line: 21,
       error_info: %{module: Exception}
     ]},
    {Task.Supervised, :invoke_mfa, 2,
     [file: ~c"lib/task/supervised.ex", line: 101]},
    {Task.Supervised, :reply, 4, [file: ~c"lib/task/supervised.ex", line: 36]}
  ]}}

なおこのコードでは call/3 に対して reply を返していないために 5 秒ごにタイムアウトの例外が発生しますが、その点は割愛します。 対処として、タスクのプロセス終了時に GenServer.reply/2 を使って応答を返す方法を前回のブログで説明していますので、そちらも参照してみてください。

リンクしない

EXIT とトラップしないばあいに、タスクのプロセスの異常終了につられて GenServer のプロセスも異常終了するのはリンクしているためです。

Task.Supervisor.async_nolink/3 を利用すると、タスクのプロセスの管理は Task.Supervisor にまかせ、プロセスをリンクせずにタスクを利用することが可能になります。

コード

defmodule MyApp.Worker3 do
  use GenServer

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def do_something do
    GenServer.call(__MODULE__, :do_something)
  end

  @impl true
  def init(_opts) do
    {:ok, %{}}
  end

  @impl true
  def handle_call(:do_something, _from, state) do
    Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
      raise "Boom!"
    end)
    {:noreply, state}
  end

  @impl true
  def handle_info(msg, state) do
    dbg msg
    {:noreply, state}
  end
end

先に Task.Supervisor のプロセスを起動しておく必要があるので、MyApp.Application を追加しプロセスを起動する設定を記述しておきます。

defmodule MyApp.Application do
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      {Task.Supervisor, name: MyApp.TaskSupervisor}
    ]

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

--sup オプションなしでプロジェクトを作成したばあいは、mix.exsapplication/0mod: {MyApp.Application, []} を追加することを忘れないでください。

defmodule MyApp.MixProject do
  use Mix.Project

  ......

  def application do
    [
      extra_applications: [:logger],
      mod: {MyApp.Application, []}
    ]
  end

  ......
end

実行

タスクのプロセスで例外が送出されたあと、:DOWN のみ受け取っていることがわかります。

iex(1)> {:ok, pid} = MyApp.Worker3.start_link()
{:ok, #PID<0.143.0>}

iex(2)> Process.info(pid)
[
  registered_name: MyApp.Worker3,
  ...略...
]

iex(3)> MyApp.Worker3.do_something()

11:36:07.211 [error] Task #PID<0.144.0> started from MyApp.Worker3 terminating
** (RuntimeError) Boom!
    (my_app 0.1.0) lib/my_app/worker_3.ex:20: anonymous fn/0 in MyApp.Worker3.handle_call/3
    (elixir 1.15.6) lib/task/supervised.ex:101: Task.Supervised.invoke_mfa/2
    (elixir 1.15.6) lib/task/supervised.ex:36: Task.Supervised.reply/4
Function: #Function<0.16324509/0 in MyApp.Worker3.handle_call/3>
    Args: []
[lib/my_app/worker_3.ex:27: MyApp.Worker3.handle_info/2]
msg #=> {:DOWN, #Reference<0.0.18307.1968646231.2159083530.717>, :process,
 #PID<0.144.0>,
 {%RuntimeError{message: "Boom!"},
  [
    {MyApp.Worker3, :"-handle_call/3-fun-0-", 0,
     [
       file: ~c"lib/my_app/worker_3.ex",
       line: 20,
       error_info: %{module: Exception}
     ]},
    {Task.Supervised, :invoke_mfa, 2,
     [file: ~c"lib/task/supervised.ex", line: 101]},
    {Task.Supervised, :reply, 4, [file: ~c"lib/task/supervised.ex", line: 36]}
  ]}}

Scalability and partitioning

なおドキュメントにあるように、Task.Supervisor はシングルプロセスのため、それがボトルネックになる可能性があるとのこと。

それを対処するために PartitionSupervisor を利用する例が記載されています。 実装してみます。

MyApp.Application で PartitionSupervisor のプロセスを起動するように変更します。

defmodule MyApp.Application do
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      {PartitionSupervisor, child_spec: Task.Supervisor, name: MyApp.TaskSupervisors}
    ]

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

handle_call/3 でタスクのプロセスを起動しているコードも、PartitionSupervisor を指定して起動するように変更します。

  def handle_call(:do_something, _from, state) do
    Task.Supervisor.async_nolink({:via, PartitionSupervisor, {MyApp.TaskSupervisors, self()}}, fn ->
      raise "Boom!"
    end)
    {:noreply, state}
  end

書くのも読むのも大変になってきたので、モジュールを追加してコードを移動します。

MyApp.TaskSupervisor を追加して、タスクのプロセスを起動する関数を用意します。

defmodule MyApp.TaskSupervisor do
  def async_nolink(fun, options \\ []) do
    Task.Supervisor.async_nolink(
      {:via, PartitionSupervisor, {MyApp.TaskSupervisors, self()}},
      fun,
      options
    )
  end
end

この関数を使って handle_call/3 を書き換えます。

  def handle_call(:do_something, _from, state) do
    MyApp.TaskSupervisor.async_nolink(fn ->
      raise "Boom!"
    end)
    {:noreply, state}
  end

これで見やすくなりました。