Open97

2025-09 開発スクラップ 主にLivebookのコード読解

tatotato

はじめに

  • どこかに書いておけば何かの役に立つだろう(?)なメモや、ただのトラブルの供養です。
  • 話題に上げているLivebookバージョンは @version "0.17.0-dev"
  • コード生成AI環境は Claude Opus/Sonnet 4
tatotato

Livebook よりプラクティスの切り出し ↓

  • data-p-*をHookで使う共通の属性としている
    • それを処理する共通関数を作っている
  • assets/js/lib/に共通ライブラリ群
  • if Mix.env() == :test do からのdefp setup_testsがある at lib/livebook/application.ex。アプリ起動前に実行したいからコード注入やむなしだったのかな。
  • Livebook.Configがモジュールとして独立していて、設定周りはそこに関数を集約している様子
  • サイドバーの実装は面白い。表示と処理をうまく分離させていて、処理をhooksとして用意しsubscribeやイベントハンドラもattach_hookなどでアサインしている。
  • {Task.Supervisor, name: Livebook.TaskSupervisor} でTaskを切り出している。LiveViewプロセスは画面切り替えで落ちるからだろうと思われる。
  • lib/livebook/utils/unique_tasks.exみたいい複数defmoduleがあるファイルもある。NotFoundErrorを中に書いているところもある。
  • 全画面共通系は、attach_hookを活用するなどで切り出せる。
  • HTML上で操作のためにつけている属性はdata-el-*
  • 画面の一部LiveComponentは*_live/*_component.ex、共通利用のLiveComponentは<*context_name>/*_component.ex かな
  • renderに関わる関数がファイル上部に全てあるのかな? // これは少し意外。トップのrenderだけ上で細かい(LiveComponent内のコンポーネント単位 defp相当)はファイル下部が好きなので...
  • lib/livebook/session.exlib/livebook_web/helpers/session.exがあるが、一度ヘルパーを通すときはwebで扱う情報(socketとか)に情報を入れるユースということだろうか。
  • ノートを表示するLiveView(SessionLive)は.html.heexファイルを使わずに、renderを別モジュールにdelegateしている。確かに結局defpが欲しくなるし、ファイル分けるにしてもテンプレートファイルじゃなくていいかもしれない。
  • defguarddefguardpを使っているところがある(初めてみた)。
  • 単にデータを変換するような関数は、get_とかconv_とかなしでデータ名_fromが多い、かもしれない。

その他

  • Keyword.validate!系がわりとある
  • def handle_params(%{}, _url, socket) when socket.assigns.live_action == :public_new_notebook do 長いパターンマッチは使わずに whenで処理している様子
tatotato

Livebook 前回みたときから時間が空いたので手元で試す。

tatotato

ERROR!!! [Livebook] You must set the environment variable ELIXIR_ERL_OPTIONS="-epmd_module Elixir.Livebook.EPMD" before the command (and exclusively before the command)

tatotato

とりあえず指示通りに指定すれば動く。Livebook側で :erl_epmdをカスタマイズしている理由は:
TODO

tatotato
Erlang/OTP 27 [erts-15.0.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1]

[notice]     :alarm_handler: {:set, {:system_memory_high_watermark, []}}
[info] Reading storage from  <>/tmp/livebook_data/dev/livebook_config.v1.ets
[info] Running LivebookWeb.IframeEndpoint with Bandit 1.6.8 at 127.0.0.1:4001 (http)
[Livebook] Application running at http://localhost:4000/
[info] Running LivebookWeb.Endpoint with Bandit 1.6.8 at 127.0.0.1:4000 (http)
Interactive Elixir (1.18.4) - press Ctrl+C to exit (type h() ENTER for help)
tatotato

livebook_data/dev/livebook_config.v1.ets を使う様子。ETSの活躍場面は設定保存面、ETSといっても永続化している。

tatotato

tidewave のパッケージを入れようとしたら最初から入っていた。
そのため設定だけを済ます。

.mcp.json
{
  "mcpServers": {
    "tidewave": {
      "type": "http",
      "url": "http://localhost:4000/tidewave/mcp"
    }
  }
}
tatotato

Livebook 環境変数と意味、あるいはコード読解のきっかけとして。

tatotato

Livebook.config_runtime()なる関数がある。runtime.exsから呼び出している。

...どうやらLivebookそのものをアプリケーションにそのまま組み込んだときのために、関数でruntime.exsには書かずに呼び出す形を取っている模様。attachedとかもせずに、PJにパッケージとして入れ込むときのもの(?)。serverlessという設定があるのでそっちも付随して設定が必要そう。

tatotato

LIVEBOOK_ALLOW_URI_SCHEMES=ftp,others:

Livebookで https://github.com/rehypejs/rehype-sanitize を使っている。そのhref属性として使用可能なURIスキーマを加えることができる。

phx-hook="MarkdownRenderer"に渡されている。タグのdata-p-*属性を各Hookで使うオプション的なものとしている様子。

...手厚い。でも[hoge](others://example.com) の反映は確認できたけど、others://example.com直書きだとリンクにしてくれない。自動でリンク化するのはremark-gfmの仕事でそちらはカスタマイズできなさそうではある。

tatotato

LIVEBOOK_APP_SERVICE_NAME=:
LIVEBOOK_APP_SERVICE_URL=:

System settingに表示+リンク、詳しい用途不明。触ることはなさそう。

tatotato

LIVEBOOK_APPS_PATH=pwd/notes/apps/:
LIVEBOOK_APPS_PATH_PASSWORD=:

指定したディレクトリの.livemdをアプリケーションとして実行時にデプロイする。WebアプリのプラットフォームとしてのLivebook

(直接関係ないけど、起動時にライブラリ群をロードしてくれそうだからMix.installしたいものを忍ばせた.livemdなファイルを置いておくといいかもしれない? 書いた

tatotato

LIVEBOOK_APPS_PATH_WARMUP:

例えばmanualにしておいて、Dockerイメージ作成時Dockerfileで実行しておけば、インスタンス起動ごとの実行を省略できるので起動が早くなる。
=> Mix.install()はパッケージによっては時間を食うので、ありがたい。

tatotato

LIVEBOOK_BASE_URL_PATH:
LIVEBOOK_PUBLIC_BASE_URL_PATH:

リバースプロキシを使うなどで、URLが見かけと違うときに見かけのURLを設定する。

tatotato

LIVEBOOK_CACERTFILE:

認証局証明書へのパス。あまり使わないだろうけど社内認証局を使う外部リソースとかへのアクセスに。

tatotato

LIVEBOOK_CLUSTER:
LIVEBOOK_COOKIE:

dns_clusterのqueryとして扱われるものと、クッキー設定。
https://hexdocs.pm/dns_cluster/DNSCluster.html

コードをよむと dns:~で設定しないといけない様子。

指定するとクラスタに参加する。// 明示的に実施したことがないが、活用するプロジェクトはどこか面白そう。

tatotato

LIVEBOOK_DATA_PATH:

内部的なデータの置き場所。指定なしだと、:filename.basedir/2を使っている => :filename.basedir(:user_data, "livebook")

test実行時に削除するようなコードがあるから指定するときは注意したほうがよさそう。指定しないときは環境ごとのconfigの設定を使う。

tatotato

LIVEBOOK_DEFAULT_RUNTIME:

ノートをstandaloneで動かすか、他のNodeにattachさせるか、入れ子で使っているかの設定。Projectの補助用途ならattachしておく。遊ぶだけならstandalone

tatotato

LIVEBOOK_FORCE_SSL_HOST:

localhost時には機能しない。 // こんなのもあるのか。

tatotato

LIVEBOOK_HOME:

画面のファイルブラウザの初期ディレクトリになるので割と大事。デフォルトはユーザーホームだが、プロジェクトホームとか livemdを置くルートディレクトリにすべき。

tatotato

LIVEBOOK_IDENTITY_PROVIDER:

ベーシック認証からクラウド活用まで。

tatotato

LIVEBOOK_IFRAME_PORT:
LIVEBOOK_IFRAME_URL:

Livebook上のJSは、開いているノートとぶつからないように(あるいはその他理由のため)用意されたiframe中で実行される。そのiframeに内容をJSで書き足して、その結果をノートでは見せている。

httpsでは特に指定がないとlivebookusercontent.comを拝借している。// なるほど

https://livebookusercontent.com/iframe/v5.html をベースに自分で用意すれば、共通のカスタマイズができるということになる。

tatotato

LIVEBOOK_IP:
LIVEBOOK_PORT:

そこまで使うことがなさそうだが、Dockerで閉じるときに0.0.0.0 にするなど。ポートは同じマシン中で複数動かしたり、ポートを変更する際に。あわせてLIVEBOOK_IFRAME_PORTもおそらく変更が必要。

tatotato

LIVEBOOK_LOG_LEVEL:
LIVEBOOK_LOG_METADATA:
LIVEBOOK_LOG_FORMAT:

Loggerに渡される設定

tatotato

LIVEBOOK_NODE:

Node名を指定。Node.start(node, :longnames) しているのでFQDNなどを使う必要あり。

tatotato

LIVEBOOK_PASSWORD:

パスワードを設定可能。

prod環境で設定していない場合が起動時にトークンが発行されるのでそれを使う。
dev環境ではauthentication自体が:disabledなので未設定ならば求められない。

tatotato

LIVEBOOK_PROXY_HEADERS:

リバースプロキシのときのあるある設定(なるべく使わないでいたい)

tatotato

LIVEBOOK_SHUTDOWN_ENABLED:

サイドバーから終了操作ができるかどうか。終了操作したあとにどうやって再開するんだろう(Webアプリ)

tatotato

LIVEBOOK_TOKEN_ENABLED:

falseにするとprodでも無条件にアクセスできるのだろう(とても危険)

tatotato

LIVEBOOK_UPDATE_INSTRUCTIONS_URL:割愛

提供サイドの設定項目

tatotato

LIVEBOOK_WITHIN_IFRAME:

デフォルトはfalse。Livebookそのものを外部サイトからiframeで入れ込めるようにするときにtrueにする模様。

活用すると、Livebook Appsを埋め込みまくった1つのページが作れるのかもしれない。

tatotato

その他

  • MIX_INSTALL_DIR: 確かMix.install()の保存先なので、重たいライブラリがあるなら(docker運用下)指定して、永続化させておくのが良かった記憶
tatotato

Livebook 起動されるサーバと役割 Livebook.Applicationより

tatotato

LivebookWeb.Telemetry:

自動生成される項目よりも少なめになっている。特に特徴はなし。
// そういえば自動作成された後に真面目に吟味したことがなかった。

tatotato

{Phoenix.PubSub, name: Livebook.PubSub}:

活用しているところが多いので略。後述

tatotato

{Task.Supervisor, name: Livebook.TaskSupervisor}:

Taskを扱うSupervisorを起動している。
// こういうのあったか。例えばLiveViewプロセスが親で単にTaskを起動するとlive_navigationとかされると子もなくなる(はず)けど、任せてしまっておけば問題なくTaskは続けられる。例えば画面アクセス時起点でDBに何か記録するみたいなもの(仮)は、専用プロセスにぶん投げたほうが良い。

Livebookでは例えばファイル保存処理で使っている様子。確かにページ切り替えられても保存処理が完了したいから適例。

tatotato

Livebook.Utils.UniqueTask:

Livebookが複数セッションを前提にしている中で、一度で良くて重複が困る処理を担当している。

...わりとLivebookな強みに関わる模様。というのは、リモートで実行したセルの結果を受け取るときに使っている様子で、そのときの重複取得を防いでいる大事な役割。

📝 ref = Process.monitor(pid) して重複タスクも完了を待っている。

tatotato

Livebook.Storage:

{{namespace, entity_id}, attribute, value, timestamp}
secretの保存など。ETSだがfileに保存しているためアプリを落としても保存されている。

tatotato

{Livebook.Utils.SupervisionStep, {:migration, &Livebook.Migration.run/0}}:

その場で(非同期で)実行するためのLivebook.Utils.SupervisionStepを用意して、Migrationを走らせている。
// あとでTask.Supervisorに積むのではいけないのだろうか。

migrationはEctoのそれとは無関係で、アプリケーションのバージョンアップに伴う処理をいれる想定に見える。

tatotato

Livebook.UpdateCheck:

バージョンアップのチェック。チェックした結果はstateとして保持。いまのバージョンは設定にてMix.Project.config()[:version]している。

tatotato

Livebook.SystemResources:

その通り、システムリソースを定期的に計測してetsに保存している。これは↑のStorageとは別で単なるets活用 // state でもたない理由があるだろうか。

tatotato

Livebook.NotebookManager:

ホームに表示される最近使ったノートブック、まわりの管理。

実装面では {:noreply, state, {:continue, :dump_state}}からのhandle_continueでStorageへの保存を行っている点が面白い。

tatotato

{Livebook.Tracker, pubsub_server: Livebook.PubSub}:

Phoenix.Trackerを使っている。diffで検知したいのが背景。開いているセッション(=ノート)と、稼働しているアプリをTrackしている様子。

  • pubsub_serverがオプション指定なのは不明
  • セッションに参加しているユーザーはProcess.monitorでpid監視なのでまた別
tatotato

Livebook.EPMD.NodePool:

Nodeごとに毎回アトムを発行するとメモリや枯渇を招くため、一度作成したものを(解放済みなら)再利用するための仕組み。なるほど。

LivebookはノートごとにNodeを起動しているので、atomの大量消費を抑えているもの。

tatotato

{DynamicSupervisor, name: Livebook.RuntimeSupervisor, strategy: :one_for_one}:
{DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one}:
{DynamicSupervisor, name: Livebook.HubsSupervisor, strategy: :one_for_one}:

それぞれのプロセスの管理用途。ものが違うので別途準備している。
Trackerとはまた別で、Trackerは情報保持の観点であり、こちらはプロセス管理用途。

tatotato

{Registry, keys: :unique, name: Livebook.HubsRegistry}:

HubのためのRegistry。あるHubの情報を引くためにレジストリしておくイメージ。// SessionはTrackerがあるし、Runtimeは内部で使うプロセスなのでレジストリなどはない。

tatotato

{Livebook.Utils.SupervisionStep, {:boot, boot(create_teams_hub)}}:

Hubの初期化(非同期で)。Hubは簡単には1つの設定群。手元かstagingかprodかといった単位で切り替えができるので、それぞれの設定(DB向き先とか)を反映して、ノートを実行できる。

tatotato

Livebook.Apps.DeploymentSupervisor:

Apps独自のsupervisorを起動している。// 深い

  • Apps.ManagerWatcher: Managerの起動と落ちたときの各処理。Managerが:globalなので、各クラスタがwatchしているということだろうか。
tatotato

Livebook PubSub活用シーン
(js側にも簡易的にあるがここではサーバ側~Phoenix.PubSub)

broadcast, local_broadcast, direct_broadcast はそれぞれ、全ノード、同一ノード、特定ノードへの送信で、使い分けできる。

// web系でのsubscribeは大抵リスト更新とかなので調べない。

tatotato

"tracker_apps"

appsについてTrackerからの通知受け。Trackerがノード情報を持っておりdirectが適当(// localでも可?)

tatotato

"apps_manager":

appsの管理サーバ(:global起動)からの通知受け。だと思うけど local_broadcastを使っているのがよくわからない。各クラスタに通知するなら普通のbroadcastな気がした(深くは追わないけど)。

tatotato

"hubs:#{topic}":

各クラスタの情報を得るために、NotebookManagerなどが欲しいtopicで購読する。

tatotato

"notebook_manager:recent_notebooks":
"notebook_manager:starred_notebooks":

ノートブック状況の通知。こちらはbroadcastなので組んでいたら反映される(未確認)。

tatotato

"sessions:#{session_id}":

あるノートブック(セッション単位)を複数のクライアント(〜LiveViewプロセス)が使うため、セッションは基本的にoperationによる更新(通知)で同期を計っている。

tatotato

defp runtime_messages_topic(session_id, topic, subtopic):

runtimeとsessionプロセス間でのtopic

tatotato

"tracker_sessions":

sessionsについてTrackerからの通知受け。Trackerがノード情報を持っておりdirectが適当(// localでも可?)

tatotato

"system_resources":

システムのリソース状況更新次の通知。ローカルなマシンの話なのでlocal_broadcastのみ。

tatotato

"teams:#{topic}":

sessionと同様、複数クライアントを想定して基本的にはtopic経由の通知更新を行う。

tatotato

📝 異なるクラスタ(Livebook)から同じファイルを開こうとするとエラーになるらしい。あくまで同じsession(URL)に参加する形をとれば、共同編集ができる。

tatotato

📝 Livebook Apps内で定義したモジュールも再利用できると嬉しい、と試したものの(最初からruntimeがあるといっても)やはりステップが多い。.livemdからelixirを抽出するライブラリを作ってMix.install()する方が幸せかもしれない。

tatotato

Livebook Plug/Hooks ホーム画面他共通

tatotato

/sessions/:id/assets/:hash/*file_partsのfile_parts部分は、images/icon.pngといったときに、%{"file_parts" => ["images", "icon_png"] となる。 // 使ったことなかった。

tatotato

LivebookWeb.Confirmの作り方はプラクティスとして面白い。厄介というか手間というかな確認画面を見事(だと思うけど)に切り出している。

tatotato

live_session :app, session: ~ の形でいれるsessionは、liveview_sessionになるから重たいデータも入れられる。

tatotato

レイアウトデザイン

tatotato

use Phoenix.LiveView, layout: {LivebookWeb.Layouts, :live}

  • 全体mainはあるある <main role="main" class="grow flex flex-col h-screen">
  • flash_groupcore_componentのままなんだ。特にlive_componentで出しているわけではなさそう。
tatotato

Homeでは実際にはLayoutComponents.layout()を使っている。

tatotato

live-region スクリーンリーダー用の表示更新もある様子

tatotato

サイドバー(on Home):

    <nav
      class="hidden md:flex w-[17rem] h-full py-2 md:py-5 bg-gray-900"
      aria-label="sidebar"
      data-el-sidebar
    >
  • 操作のための属性はdata-el-sidebar
  • md以上では常に出していて隠す機能はなし(ホームでのサイドバー)。小さいときのみ表示可否変更ができる
  • イベントなどの処理はhooksで切り出している(調査済み省略)

カレント(今開いているか)でのデザイン分けは下記。各リンクはnavigateだったので、おそらくpatchで移動可能な粒度のサイドメニューはない様子。

  defp sidebar_link_text_color(to, current) when to == current, do: "text-white"
  defp sidebar_link_text_color(_to, _current), do: "text-gray-400"
tatotato

ホーム画面で使うlive_componentの切り出しの一例

          <.live_component
            module={LivebookWeb.HomeLive.SessionListComponent}
            id="session-list"
            sessions={@sessions}
            starred_notebooks={@starred_notebooks}
            memory={@memory}
          />
  • 命名規則的には、ある画面専用なら livebook_web/home_live/hoge_component.ex で、共通で使うものは livebook_web/<name>/hoge_component.ex みたいな感じかな。画面付属なら *_liveフォルダ以下ということ。 // おそらく一般的
tatotato

ホーム画面のimportなど一部使っていない(だろう)機能が残っている様子。

tatotato

ノートブック画面まわり。基本のURLは /sessions/:id

  • session情報そのものは永続化対象ではなくdefstructとして定義されている。// ホーム画面などに表示するときに使うような情報粒度というところだろうか。
  • stateがサーバ状況としての保持情報、ほか、Session.Data(state.data)という全クライアントで共有するデータがある。
tatotato

session_idboot_id, node_id からなる

    boot_id = Livebook.Config.random_boot_id()
    node_part = node_hash(node())
    random_part = :crypto.strong_rand_bytes(11)
    binary = <<boot_id::binary, node_part::binary, random_part::binary>>
    # 3B + 16B + 11B = 30B is suitable for base32 encoding without padding
    Base.encode32(binary, case: :lower)

node_idはローカルではないクライアントがノード接続(既に開かれているファイルならそのsessionをもつサーバへ接続,PubSub購読)に必要。boot_idは提供元Livebookが再起動していると確実にsession_idがすでに存在していない判定でチェックしている(?)

tatotato

Livebook.Session.Worker ...?

これをちらっと見た感じでは、ただ中継しているだけである。sessionそのものもサーバだが、workerサーバとして置く必要性がいまひとつわからない。責務としてプロセスを分けたかったんだろうか?でもワークしていることがない...

tatotato

セルの実行まわり

tatotato
  • エディタまわりの仕組み
  • 編集中の同期の仕組み
  • 保存時の同期の仕組み
  • 実行時の結果の受け取りと同期の仕組み
  • ...
tatotato

セルを作成するメニューの出し方:

表示する処理自体は、各セクション(見出しや各セルのくくり)の下にひっついている。あとはそれらが何もないときに出している。

メニュー表示の出し入れをどうしているのかと思ったらhover:で透過率のクラスを変えているだけだった。なるほど、この場合は表示されないときは、ノートのほど良い余白、になるからそれで十分か。

snippet_definitions:
Block > CODEのところにあるメニューに出すものだが、カスタマイズはできなさそう、固定。

smart_cell_definitions:
Smartのところにあるメニューに出すもので、自作のパッケージから追加できる。
技術的には、ライブラリから規定のregister関数で追加するmoduleを登録すれば、それが反映されるというもの。追加スマートセルもいくつかのプロトコルに従って実装が必要。

tatotato

既存のセルの表示まで:

render側ではHook(と置き場所になるdom)だけ置いて、push_event()によって描画(およびJSエディタの初期化)を行っている。画面が表示されているときに出ているのはLiveEditorで描画しているsourceとなる。

LiveEditorのマウント前に、置き場所となっているdomのところにsourceを飾らずに表示している処理がある(マウント時に消している)。これはどうやらスクロール系の都合の様子。

tatotato

セルの作成から表示まで:

  • メニューをクリックして選択
  • Sessionモジュールで、sessionプロセスに実行させるためにcast(非同期)したり、セルの作成をつないでいる各クライアントにbroadcastしている。
  • Data.apply_operationが特徴的で、各LiveViewプロセス側で実行しているし、sessionプロセス自身でも実行している。LiveViewプロセスが同じNode内だと重複実行なんだろうけど、違うNodeがあることを前提に、透過的に処理をしている(のだろうか)。
  • ↑で重複実行と書いたが、実際にData.apply_operationで駆動されるアクションは、プロセスごとに動作が違って、sessionプロセスはセルのruntimeを実行させるし、LiveViewプロセスは単に画面を更新するためのpush_eventをしている様子。
  • runtimeは実行完了後にsessionプロセスに通知、そこからbroadcastして購読しているLiveViewプロセスがそれぞれ画面更新して、結果が反映される。

// ロジカルしてて面白いが、難しいな、分散システム

tatotato

スマートセル SmartCell まわりの確認

tatotato

考えてみればruntimeが独立しているからそれはそうなんだけど、KinoはLivebook自体には含まれていない。Livebookの本体でしていることは、UIにメニューとして出してあげることや、メニュー押下したときにデータを渡してあげるなどのフロー。

Livebookでは、主要なKinoを使うメニューはハードコードされていて押すとKinoのインストールが求められる(standalone)。自前でMix.installで追加したものは、動的に追加されて(ライブラリ内でKino.SmartCell.registerしている必要)、メニューに反映される。

tatotato

ということで、自作スマートセルを作ったらKino.SmartCell.register(MyCell)で登録が必要。// おそらく多くのライブラリではapplication.exで行っていると思われる。

tatotato

メニューが選択されたときには、セルが作られる。Kino.SmartCellでもって必要なものをMyCellから取り出す。

選択されたときにMyCell.init()がコールされる。みたところではattrsは空%{}だった。実装としては、使いたい変数をctxにassign(初期化)しておく。

MyCell
  @impl true
  def init(attrs, ctx) do
    {:ok, ctx}
  end

attrsがJSON互換のmap形式、ctx%Kino.JS.Live.Contextで状態を保持する自由なストアという感じか。attrsは、ctxから、コード実行・ノートの保存復元(init)できる形にしたもの。

tatotato

クライアント接続時にはMyCell.handle_connect()がコールされる。この返り値には、JS側で使用するためのpayloadを返す。たぶんto_payload関数を作ってしまった方が意味がわかりやすい。

JSで使用するもの=画面のUIとして提供するもの

tatotato

to_sourceはセルで実行するコードであり、画面上でソースに切り替えたときに表示されるコードになる。

つまりcx |> to_attrs() |> to_source() の関係がある。

描画するための情報の流れ:
Kino.VegaLite.render() => Kino.render() => Kino.Render.to_livebook()

Livebookの利用者が見ているのは、to_livebookで返された情報で実行されたiframe内のHTML

独自の描画をしたいときは、Kino.JSに従った実装をしたモジュールを作って、それをto_source内から呼び出す。