📚

Livebook モジュールを共有する一案

に公開

Livebookで作業していると「共通モジュールを定義して使いまわしたい」ことがあります。(standaloneなLivebookの話です)

以前、下記に残したような方法を試しました。こちらは関数を実行するのがアプリ側になる、という面があります。
https://zenn.dev/ta_to/articles/540bdd0c46a086

// これはこれで良い面もありますが、Kinoライブラリを作った際にうまくいかない事象がありました。

今回は、ノート側で定義する形を考えてみました。

ざっくりした仕組み

  • Livebookノード側のetsに、定義したモジュールを登録・保持する(register)。
  • Livebookノード側のetsから、使いたいときにノート側で取り込んで(load)、使う。
  • Appを使ってLivebook起動時に自動で利用可能な状態にする。

アプリのフォルダ構成

Livebookは、起動時にLIVEBOOK_APPS_PATHに指定したフォルダにあるファイルをアプリとして立ち上げる機能があります。本来はそのノートをアプリ化するものですが、ここでは自動起動のために使います。

LIVEBOOK_APPS_PATH/lib/
- 000_livebook_lib_store.livemd #<= モジュールのストアを立ち上げる
- 001_lib_adder.livemd #<= 共有したいモジュールを定義して、ストアに蓄積する
- ...

ファイル命名で大事なことは、一番最初にモジュールをストアするためのファイルを置いて準備させることです。ここではわかりやすく採番にしています。

実装 000_livebook_lib_store.livemd

やるべきことは、Livebookノード側にets(と保持するプロセス)を用意することです。またこのノートを作った後は、設定からslugを入力してアプリにしておきます。

ここではLibStoreとしています。以降は、定義したモジュールをLibStore.register(Node.self(), module)で登録し、LibStore.load(Node.self(), module)で使いたいノートで取り込みます。

000_livebook_lib_store.livemd
# 000_livebook_lib_store

## モジュール共有用Store定義

```elixir
defmodule LibStore do
  use GenServer

  def start(opts \\ []) do
    GenServer.start(__MODULE__, :ok, Keyword.put(opts, :name, __MODULE__))
  end

  def register(from_node, module) do
    GenServer.call(__MODULE__, {:register, from_node, module})
  end

  def load(to_node, module) do
    GenServer.call(__MODULE__, {:load, to_node, module})
  end

  @impl true
  def init(:ok) do
    :ets.new(:lib_store, [:named_table, :public, :set])
    {:ok, %{}}
  end

  @impl true
  def handle_call({:register, from_node, module}, _from, state) do
    {_module, bytecode, _which} = :rpc.call(from_node, :code, :get_object_code, [module])
    :ets.insert(:lib_store, {module, bytecode})
    {:reply, :ok, state}
  end

  @impl true
  def handle_call({:load, to_node, module}, _from, state) do
    [{^module, bytecode}] = :ets.lookup(:lib_store, module)
    result = :rpc.call(to_node, :code, :load_binary, [module, ~c"nofile", bytecode])
    {:reply, result, state}
  end
end

# Livebookに注入
livebook = Node.list(:connected) |> hd()
{_, bytecode, _} = :code.get_object_code(LibStore)
:rpc.call(livebook, :code, :load_binary, [LibStore, ~c"nofile", bytecode])

:rpc.call(livebook, LibStore, :start, [])
```

実装 001_lib_adder.livemd

やるべきことは、共通利用したいモジュールの定義と、定義したモジュールを先ほど作ったetsに登録(register)することです。保存する際にLibebook側のノードがいるので一手間必要です。

こちらもノートを書いた後に、設定からslugを決めてアプリにしておく必要があります。

内容はてきとうです。

001_lib_adder.livemd
# 001_lib_sample

## Adder

```elixir
defmodule Adder do
  def add(a, b) do
    a + b
  end
end
```

```elixir
livebook = Node.list(:connected) |> hd()
:rpc.call(livebook, LibStore, :register, [Node.self(), Adder])
```

ライブラリを使う

モジュールを使用するノートでは、loadをすれば使用可能になります。

livebook = Node.list(:connected) |> hd()
:rpc.call(livebook, LibStore, :load, [Node.self(), Adder])
# and
Adder.add(1, 2)

注意点

  • あくまで実行するのがロードしているノート側なので、モジュール側で依存しているパッケージがあればMix.install()が必要です。
  • Kinoライブラリのように実行時にassets(ファイル)が作られるようなモジュールは、アプリとなるノートの方で一度実行が必要。つまりKino.MyKino.newを書いておく必要がありそうです(検証不十分)

コードブロックの入れ子を使うときは、外側に~~~を使うと良いと知った(zenn記法のはなし)

Discussion