📈

LivebookでElixirアプリケーションにWeb UIを追加する

2021/08/25に公開
2

Webアプリではないけれども、アプリケーションの現在の状況がどうなっているかを可視化するために、ちょっとしたUIをつけたいということがあったりすると思います。たとえば、記録され続ける時系列データをちょっとグラフ化して見てみたいとか。しかし、それだけのためにPhoenixを使ってWebアプリを作るのも面倒です。

そこで、Livebookを使うことでアプリケーションに動的Web UIを簡単に追加してみます。

アプリケーション

こんなアプリケーションを作っているとしましょう。1秒ごとに0〜100までのランダムな数字を生成して、時系列データとして保持しておくというものです。これ自体は特に意味のあるものではないですが、たとえば、デバイスから送られてきたセンサーデータを受け取って記録するアプリケーションだと思ってください。

ここで、記録された時系列データをグラフとして可視化したいと思った時にどうするか?というのが本記事でやりたいことになります。

lib/livebook_sidecar/worker.ex
defmodule LivebookSidecar.Worker do
  use GenServer

  @impl GenServer
  def init(_init_arg) do
    {:ok, %{data: []}, {:continue, :init_worker}}
  end

  def start_link(init_arg) do
    GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)
  end

  @impl GenServer
  def handle_continue(:init_worker, state) do
    Process.send_after(self(), :work, 0)
    {:noreply, state}
  end

  @impl GenServer
  def handle_info(:work, state) do
    data =
      state.data
      |> Enum.concat([
        %{
          datetime: Timex.now("Asia/Tokyo"),
          count: Enum.random(0..100)
        }
      ])

    Process.send_after(self(), :work, 1_000)
    {:noreply, %{state | data: data}}
  end

  @impl GenServer
  def handle_call(:retrieve_data, _from, state) do
    {:reply, state.data, state}
  end
end

上記のプロセスにアクセスするためのヘルパーAPIを用意しておきます。

lib/livebook_sidecar.ex
defmodule LivebookSidecar do
  def retrieve_data() do
    GenServer.call(LivebookSidecar.Worker, :retrieve_data)
  end
end

Livebookの設定

Livebookとは、ElixirのためのJupyterLabのようなものです。

まず、上記のアプリケーションにLivebookを組み込んでいく必要があります。mix.exsに依存ライブラリを追加します。

mix.ex
{:livebook, "~> 0.2.3"},
{:vega_lite, "~> 0.1.0"},
{:kino, "~> 0.3.0"},

そして、config/config.exsにLivebookの設定を追加します。以下では、最低限のLivebookのWebアプリケーションへの接続に関する設定などを記述しています(セキュリティ的な保護は全くしていないので、誰でもアクセス可能な場所に置くのには向いていない設定です)。

config/config.exs
mport Config

config :livebook, LivebookWeb.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 8080],
  server: true,
  pubsub_server: Livebook.PubSub,
  secret_key_base: Base.encode64(:crypto.strong_rand_bytes(48)),
  live_view: [signing_salt: "livebook"]

config :livebook,
  default_runtime: {Livebook.Runtime.Embedded, []},
  authentication_mode: :disabled,
  cookie: :livebook_sidecar,
  root_path: "./livebooks"

config :phoenix, :json_library, Jason

ここで、LivebookのランタイムをLivebook.Runtime.Embeddedに設定しています。Livebookのランタイムには、以下の4つの種類があります。

  • Eilxir Standalone: Livebook用に独立したElixirランタイム(ノード)を起動してコードを実行する
  • Mix Standalone: 上記と同じだが、mixプロジェクトを指定して依存ライブラリを読み込んだコンテキストでコードを実行する
  • Attached Node: すでに起動している他のノードに接続し、そのコンテキストでコードを実行する
  • Embedded: 組込みデバイスなどノードを別途起動できない環境のために、Livebookが組み込まれたアプリケーションのコンテキストでコードを実行する(グローバルな環境を共有する)

今回の目的を達成するためには、ターゲットとなるアプリケーションを実行しているノードに接続するAttached Nodeを用いても良かったのですが、Nervesデバイスなどでも同じように使えるEmbeddedランタイムを使ってみることにしました。

時系列データのグラフ化

上記の設定を施した状態で、iexを起動します。

$ iex -S mix

すると、localhost:8080でLivebookが起動します。適当にノートブックを作って、以下のようなコードを書き込んで実行してみましょう。

alias VegaLite, as: Vl

data =
  LivebookSidecar.retrieve_data()
  |> Enum.map(fn row ->
    {:ok, datetime} = Timex.format(row.datetime, "{ISO:Extended}")
    Map.put(row, :datetime, datetime)
  end)

Vl.new(width: 800, height: 400)
|> Vl.data_from_values(data)
|> Vl.mark(:line)
|> Vl.encode_field(:x, "datetime", type: :temporal, title: "time")
|> Vl.encode_field(:y, "count", type: :quantitative, title: "count")

Embeddedランタイムを用いているので、ターゲットとなるアプリケーションとグローバルな状態を共有しているため、LivebookSidecarモジュールにアクセスできます。そのモジュールを通じて、記録されている時系列データを取り出して、VegaLiteライブラリを用いてグラフを作っています。

上記を実行すると、こんな感じでグラフが表示されます。

こうして、時系列データを記録するアプリケーションに対して、簡単にWeb UIを用意してグラフ化することができました。Livebookを用いると、こういう時に簡単に動的なWeb UIが作れるのも便利ですね。

本記事で用いたコードは、以下に置いてあります。適宜ご参照ください。

kentaro/livebook_sidecar

Discussion

tatotato

Livebookのバージョンが上がったことで少し設定項目がかわったようです。とりあえず手元で組み込めて動いた程度のものですが、コメントさせてください。

Livebook version: 0.6.1

config/config.exs 等
# 設定まわり
config :livebook,
  authentication_mode: :disabled,
  cookie: :livebook_sidecar,
  runtime_modules: [],
  app_service_name: nil,
  app_service_url: nil,
  iframe_port: 8081,
  plugs: [],
  shutdown_enabled: false,
  storage: Livebook.Storage.Ets
config/runtime.exs
# default_runtime はこちらに移ったようです
# その他 `Livebook.config_runtime()`相当の処理がこちら
config :livebook, default_runtime: Livebook.Runtime.Embedded.new()