Ecto(DB)とPubSub(リアルタイム通信)を組み合わせたフルスタックアプリケーションの作成例。
このガイドで作るもの
リアルタイム掲示板:
- 投稿をDB(PostgreSQL)に保存
- 複数のブラウザで同時閲覧時、新規投稿が即座に全員の画面に反映
- 投稿の編集・削除もリアルタイム同期
前提条件
- PostgreSQLがインストール済み
- Elixir 1.19以上
ステップ1: プロジェクト作成
まだ mix dialup.new をインストールしていない場合は先にインストール:
mix archive.install hex dialup_new
mix dialup.new ジェネレータを使用する。root.html.heex、lib/app/ 以下のファイル、mix.exs の設定が自動生成される:
mix dialup.new my_app
cd my_app
ステップ2: 依存の追加
mix.exs の deps にEctoとPubSubを追加:
defp deps do
[
{:dialup, "~> 0.1"},
# Ecto(DB接続)
{:ecto_sql, "~> 3.0"},
{:postgrex, ">= 0.0.0"},
# PubSub(リアルタイム通信)
{:phoenix_pubsub, "~> 2.0"}
]
endmix 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.new で lib/app/layout.ex、lib/app/error.ex、lib/app/page.ex は生成済み。
一覧ページと詳細ページを追加していく。
7-1. レイアウトを掲示板用に書き換え
生成済みの lib/app/layout.ex の render/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>
"""
end7-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で
http://localhost:4000を開く - ブラウザ2(シークレットウィンドウ等)でも同じURLを開く
- ブラウザ1で新規投稿
- ブラウザ2に即座に表示されることを確認
- ブラウザ2で投稿をクリックして詳細ページへ
- ブラウザ1でも同じ投稿の詳細ページを開く
- ブラウザ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との関係 |
|---|---|---|
| Ecto | DBアクセス | Dialupは関与せず、普通に使う |
| Phoenix.PubSub | プロセス間通信 | Dialupは関与せず、普通に使う |
| Dialup | WebSocket + ルーティング | コア機能 |
重要なポイント: Dialupはこれらのライブラリを「隠蔽」しない。各ライブラリの標準的な使い方がそのまま活きる。
次のステップ:
- Deployment - 本番環境へのデプロイ方法