Ecto(DB)とPubSub(リアルタイム通信)を組み合わせたフルスタックアプリケーションの作成例。

Commanded / CQRS を使う場合: 状態変更は <.dialup_action command={...}> で宣言し、 mix dialup.gen.aggregate Ordering Order orders add_item:sku:string confirm で 集約・コマンド・プロジェクタを生成します。ページの mount/2 は Read Model を読み、 成功した dispatch 後にフレームワークが自動 remount します。 詳細は Building agent-native applications を参照してください。

このガイドで作るもの

リアルタイム掲示板:

  • 投稿をDB(PostgreSQL)に保存
  • 複数のブラウザで同時閲覧時、新規投稿が即座に全員の画面に反映
  • 投稿の編集・削除もリアルタイム同期

前提条件

  • PostgreSQLがインストール済み
  • Elixir 1.19以上

ステップ1: プロジェクト作成

まだ mix dialup.new をインストールしていない場合は先にインストール:

mix archive.install hex dialup_new

mix dialup.new ジェネレータを使用する。root.html.heexlib/app/ 以下のファイル、mix.exs の設定が自動生成される:

mix dialup.new my_app
cd my_app

ステップ2: 依存の追加

mix.exsdeps にEctoとPubSubを追加:

defp deps do
  [
    {:dialup, "~> 0.1"},

    # Ecto(DB接続)
    {:ecto_sql, "~> 3.0"},
    {:postgrex, ">= 0.0.0"},

    # PubSub(リアルタイム通信)
    {:phoenix_pubsub, "~> 2.0"}
  ]
end
mix deps.get

ステップ3: データベース設定

3-1. DB設定ファイルの作成

config/config.exs を作成:

import Config

config :my_app, ecto_repos: [MyApp.Repo]

config/runtime.exs を作成(または編集):

import Config

# Ecto用のDB接続設定
config :my_app, MyApp.Repo,
  database: System.get_env("DB_NAME", "my_app_dev"),
  username: System.get_env("DB_USER", System.get_env("USER", "postgres")),
  password: System.get_env("DB_PASS", ""),
  hostname: System.get_env("DB_HOST", "localhost")

macOSの場合: HomebrewでインストールしたpostgresqlはデフォルトのロールがOSのユーザー名になります。 DB_USER 環境変数を設定するか、createuser -s postgres でpostgresロールを作成してください。

3-2. Repoモジュールの作成

lib/my_app/repo.ex手動で作成

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres
end

補足: RepoはEctoがDBにアクセスするためのモジュール。use Ecto.Repo で自動的に get, insert, update などの関数が使えるようになる。


ステップ4: テーブル作成(マイグレーション)

4-1. マイグレーションファイルの作成

マイグレーションは手動で作成します。ファイル名には日時を含めるのが一般的:

# ディレクトリ作成
mkdir -p priv/repo/migrations

# マイグレーションファイル作成
# ファイル名の先頭の数字は実行順序(日時推奨)
touch priv/repo/migrations/20240304000001_create_posts.exs

4-2. マイグレーション内容の記述

priv/repo/migrations/20240304000001_create_posts.exs を編集:

defmodule MyApp.Repo.Migrations.CreatePosts do
  use Ecto.Migration

  def change do
    # :posts テーブルを作成
    create table(:posts) do
      add :title, :string, null: false    # タイトル(必須)
      add :content, :text, null: false    # 内容(必須)
      timestamps()                        # inserted_at, updated_at自動追加
    end
  end
end

補足: mix ecto.gen.migration コマンドでも生成できるが、手動作成でも同じ。

4-3. DBとテーブルの作成

# DB作成(初回のみ)
mix ecto.create

# マイグレーション実行(テーブル作成)
mix ecto.migrate

実行後、PostgreSQL内に posts テーブルが作成される。


ステップ5: Schema(データ構造)の定義

lib/my_app/post.ex手動で作成

defmodule MyApp.Post do
  use Ecto.Schema
  import Ecto.Changeset

  # DBのpostsテーブルと対応
  schema "posts" do
    field :title, :string
    field :content, :string
    timestamps()  # inserted_at, updated_at
  end

  # バリデーション用関数
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content])
    |> validate_required([:title, :content])
  end
end

補足:

  • schema マクロでDBテーブルとElixir構造体のマッピングを定義
  • changeset はデータの検証・変換を行う(必須項目チェックなど)

ステップ6: Applicationの設定

mix dialup.new で生成された lib/my_app.ex を編集し、RepoとPubSubを children に追加:

defmodule MyApp do
  use Application
  use Dialup, app_dir: __DIR__ <> "/app"

  @impl Application
  def start(_type, _args) do
    children = [
      # DB接続プール
      MyApp.Repo,

      # PubSub(リアルタイム通信)
      {Phoenix.PubSub, name: MyApp.PubSub},

      # Dialupサーバー(生成時から存在)
      {Dialup, app: __MODULE__, port: 4000}
    ]

    Supervisor.start_link(children,
      strategy: :one_for_one,
      name: __MODULE__.Supervisor
    )
  end
end

補足: children の順序は重要。Repo → PubSub → Dialup の順で起動。


ステップ7: ページ実装

mix dialup.newlib/app/layout.exlib/app/error.exlib/app/page.ex は生成済み。 一覧ページと詳細ページを追加していく。

7-1. レイアウトを掲示板用に書き換え

生成済みの lib/app/layout.exrender/1 を以下に置き換える(mount/1 はそのまま残す):

  def render(assigns) do
    ~H"""
    <div style="max-width: 800px; margin: 0 auto; padding: 20px;">
      <h1>📝 Dialup Board</h1>
      {raw(@inner_content)}
    </div>
    """
  end

7-2. 一覧ページを書き換え(投稿表示・作成)

生成済みの lib/app/page.ex を以下の内容で置き換え

defmodule Dialup.App.Page do
  use Dialup.Page
  alias MyApp.{Repo, Post}

  # ページ表示時に呼ばれる
  def mount(_params, assigns) do
    # PubSubで "posts:all" トピックを購読
    # → 他のユーザーが投稿した時に通知を受け取る
    # subscribe/2 はページ離脱時に自動解除される
    subscribe(MyApp.PubSub, "posts:all")

    # DBから投稿一覧を取得(新しい順)
    posts = Repo.all(Post) |> Enum.reverse()

    {:ok, assigns |> set_default(%{posts: posts, errors: []})}
  end

  # フォーム送信時に呼ばれる
  def handle_event("create", %{"title" => title, "content" => content}, assigns) do
    # 1. DBに保存を試みる
    case %Post{} |> Post.changeset(%{title: title, content: content}) |> Repo.insert() do
      {:ok, post} ->
        # 2. 成功:他のセッションに通知
        Phoenix.PubSub.broadcast(MyApp.PubSub, "posts:all", {:post_created, post})
        
        # 3. 自分の画面も更新(エラー消去)
        {:update, assigns |> overwrite(%{errors: []})}
        
      {:error, changeset} ->
        # バリデーションエラーを表示
        errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
        {:update, assigns |> overwrite(%{errors: errors})}
    end
  end

  # 他のセッションからの通知を受信
  def handle_info({:post_created, new_post}, assigns) do
    # 新規投稿を一覧に追加
    posts = [new_post | assigns.posts]
    {:update, assigns |> overwrite(%{posts: posts})}
  end

  def render(assigns) do
    ~H"""
    <!-- 投稿フォーム -->
    <form ws-submit="create" style="margin-bottom: 30px; padding: 15px; background: #f5f5f5;">
      <h3>新規投稿</h3>
      <div style="margin-bottom: 10px;">
        <input name="title" placeholder="タイトル" style="width: 100%; padding: 8px; box-sizing: border-box;" />
      </div>
      <div style="margin-bottom: 10px;">
        <textarea name="content" placeholder="内容" style="width: 100%; height: 80px; padding: 8px; box-sizing: border-box;"></textarea>
      </div>
      <button type="submit">投稿する</button>
      
      <!-- エラー表示 -->
      <%= for {field, msgs} <- @errors do %>
        <p style="color: red; margin: 5px 0;">{field}: {Enum.join(msgs, ", ")}</p>
      <% end %>
    </form>
    
    <!-- 投稿一覧 -->
    <h2>投稿一覧</h2>
    <div id="posts">
      <%= for post <- @posts do %>
        <div style="border: 1px solid #ddd; padding: 15px; margin: 10px 0; border-radius: 4px;">
          <h3><a ws-href={"/posts/#{post.id}"}>{post.title}</a></h3>
          <p>{post.content}</p>
          <small style="color: #666;">投稿日時: {post.inserted_at}</small>
        </div>
      <% end %>
    </div>
    """
  end
end

ポイント解説:

  • subscribe/2: このページを開いている間、指定トピックの通知を受け取る。ページ離脱時に自動解除される
  • Phoenix.PubSub.broadcast/3: 同じトピックを購読している全員にメッセージ送信
  • handle_info: 他のセッションからの通知を処理

7-3. 詳細ページ(編集・削除)

新規ファイルを作成:

mkdir -p lib/app/posts/[id]

lib/app/posts/[id]/page.ex新規作成

defmodule Dialup.App.Posts.Id.Page do
  use Dialup.Page
  alias MyApp.{Repo, Post}

  def mount(%{"id" => id}, assigns) do
    # この投稿専用のトピックを購読(編集時のリアルタイム反映用)
    subscribe(MyApp.PubSub, "post:#{id}")

    # DBから特定の投稿を取得
    post = Repo.get(Post, id)

    {:ok, assigns |> overwrite(%{post: post, editing: false})}
  end

  # 削除ボタン押下時
  def handle_event("delete", _value, assigns) do
    # DBから削除
    Repo.delete!(assigns.post)
    
    # 一覧ページに通知(一覧の表示を更新するため)
    Phoenix.PubSub.broadcast(MyApp.PubSub, "posts:all", {:post_deleted, assigns.post.id})
    
    {:update, assigns |> overwrite(%{deleted: true})}
  end

  # 編集モード切り替え
  def handle_event("toggle_edit", _value, assigns) do
    {:update, assigns |> overwrite(%{editing: !assigns.editing})}
  end

  # 編集保存時
  def handle_event("update", %{"title" => title, "content" => content}, assigns) do
    case assigns.post |> Post.changeset(%{title: title, content: content}) |> Repo.update() do
      {:ok, updated_post} ->
        # この投稿を閲覧中の他ユーザーに通知
        Phoenix.PubSub.broadcast(MyApp.PubSub, "post:#{updated_post.id}", {:post_updated, updated_post})
        
        {:update, assigns |> overwrite(%{post: updated_post, editing: false})}
        
      {:error, _} ->
        {:noreply, assigns}
    end
  end

  # 他のユーザーによる編集を受信
  def handle_info({:post_updated, updated_post}, assigns) do
    {:update, assigns |> overwrite(%{post: updated_post})}
  end

  def render(assigns) do
    ~H"""
    <%= if assigns[:deleted] do %>
      <p>削除されました。<a ws-href="/">一覧へ戻る</a></p>
    <% else %>
      <a ws-href="/">← 一覧へ戻る</a>
      
      <%= if @editing do %>
        <!-- 編集フォーム -->
        <form ws-submit="update" style="margin-top: 20px;">
          <input name="title" value={@post.title} style="width: 100%; padding: 8px; font-size: 1.2em;" />
          <textarea name="content" style="width: 100%; height: 150px; padding: 8px; margin-top: 10px;">{@post.content}</textarea>
          <div style="margin-top: 10px;">
            <button type="submit">保存</button>
            <button type="button" ws-event="toggle_edit">キャンセル</button>
          </div>
        </form>
      <% else %>
        <!-- 表示モード -->
        <article style="margin-top: 20px; padding: 20px; border: 1px solid #ddd; border-radius: 4px;">
          <h1>{@post.title}</h1>
          <p style="line-height: 1.6;">{@post.content}</p>
          <small style="color: #666;">投稿日時: {@post.inserted_at}</small>
          
          <div style="margin-top: 20px;">
            <button ws-event="toggle_edit">✏️ 編集</button>
            <button ws-event="delete" style="color: red; margin-left: 10px;">🗑️ 削除</button>
          </div>
        </article>
      <% end %>
    <% end %>
    """
  end
end

ステップ8: 起動と動作確認

8-1. 起動

mix run --no-halt

8-2. 動作確認手順

  1. ブラウザ1http://localhost:4000 を開く
  2. ブラウザ2(シークレットウィンドウ等)でも同じURLを開く
  3. ブラウザ1で新規投稿
    • ブラウザ2に即座に表示されることを確認
  4. ブラウザ2で投稿をクリックして詳細ページへ
  5. ブラウザ1でも同じ投稿の詳細ページを開く
  6. ブラウザ1で編集→保存
    • ブラウザ2の内容が即座に更新されることを確認

トラブルシューティング

DB接続エラー

** (DBConnection.ConnectionError) connection not available

→ PostgreSQLが起動しているか確認

ロールが存在しないエラー(macOS)

FATAL 28000 (invalid_authorization_specification) role "postgres" does not exist

→ macOSのHomebrewインストールではデフォルトロールがOSユーザー名になる。以下のいずれかで対処:

# 方法1: postgresロールを作成
createuser -s postgres

# 方法2: 環境変数でユーザーを指定して起動
DB_USER=$(whoami) mix run --no-halt

マイグレーションエラー

** (Postgrex.Error) ERROR 42P01 (undefined_table) relation "posts" does not exist

mix ecto.migrate を実行し忘れている可能性

PubSubが動作しない

→ 複数ブラウザでテスト。同じセッション(同じクッキー)では動作しない。


まとめ

コンポーネント役割Dialupとの関係
EctoDBアクセスDialupは関与せず、普通に使う
Phoenix.PubSubプロセス間通信Dialupは関与せず、普通に使う
DialupWebSocket + ルーティングコア機能

重要なポイント: Dialupはこれらのライブラリを「隠蔽」しない。各ライブラリの標準的な使い方がそのまま活きる。

次のステップ: