7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Sync Solvedを実現するElectricSQLをPhoenixに組み込んでモバイルアプリのSyncがSolvedした話

Posted at

はじめに

本記事はPostgreSQLの同期エンジンであるElectricSQLをPhoenixに組み込んで、
APIを構築することなくモバイルアプリからリアルタイムにデータを取得する方法を解説します

ElectricSQLとは?

ElectricSQLは、リアルタイムで同期可能なローカルファーストデータベースを提供するプロダクトです

以下のような特徴があります:

✅ 主な機能と特徴

機能 内容
📖 リアルタイム読み取り同期 PostgreSQL → クライアントへの リアルタイムデータ同期に対応(書き込み同期は未対応)
📦 PostgreSQL ネイティブ対応 既存の Postgres スキーマ・データ・SQL をそのまま利用可能
🧩 部分レプリケーション(Shapes / DDLX) クエリルール(shape)や DDL 拡張で必要な行だけを選んで同期
🌍 マルチクライアント同期 複数の端末に対して即時に読み取りデータを共有
🌐 PGLite(WASM Postgres)対応 ブラウザ / Node.js 上で動作する Postgres と同期できる軽量クライアント
☁️ Electric Cloud(β) 30秒でセットアップ可能なマネージドクラウドサービス(現在β公開中)
🛠️ 幅広いクライアント SDK TypeScript、React、React Native、Expo、Phoenix などをサポート(書き込み非対応)
📡 CDNベースの高速配信 スケーラブルなアーキテクチャで数百万ユーザーにリアルタイム配信

⚠️ 現時点での制限事項

  • 書き込み(Insert / Update / Delete)の同期には対応していません
  • 書き込みは通常の API 呼び出し等で別途対応する必要があります

🧭 今後の展望

  • 双方向同期(read/write)のサポートがロードマップに含まれています
  • CRDT による競合解決の仕組みも検討されています

詳細: https://electric-sql.com

by chatgpt

更に要約すると、モバイル用途に特化した同期をリアルタイムに行うエンジンで、Elixirで記述されています
まだ実装はReadだけですが、APIを実装せずにEctoクエリを書くだけで従来のコードをほとんど変更することなくリアルタイム同期を行えるやばいやつです

どっちかというとReactNative等TSなどのフロントエンド環境のバックエンドとして使用することを想定しているようです
(Elixirのモバイルはニッチなのでしょうがない)

Phoenix.Sync

Phoenix.SyncとはElecticSQLを簡単にPhoenixに組み込むライブラリでクライアント、サーバー両方で使います

使い方は既存のControllerやPlug、LiveViewで専用の関数を使うだけでほとんど既存の書き方を変えることなく同期を行えます

今回はLiveViewを例を紹介します

LiveView streamの同期

Phoenix.LiveView.stream/3Phoenix.Sync.LiveView.sync_stream/4に入れ替えると、LiveViewが自動的にPostgresデータベースの最新の状態に保たれます:

Postgresの更新が入るとhandle_infoで受け取り、sync_stream_updateでデータが同期されます

defmodule MyWeb.MyLive do
  use Phoenix.LiveView
  import Phoenix.Sync.LiveView

  def mount(_params, _session, socket) do
    {:ok, sync_stream(socket, :todos, Todos.Todo)}
  end

  def handle_info({:sync, event}, socket) do
    {:noreply, sync_stream_update(socket, event)}
  end
end

この例だと全件取得されるので、絞り込みをするときは通常のEcto.Queryを使います

未完了のTodoを取得する場合は以下のような関数を作って、第3引数にわたすと未完了のTodo一覧が同期されます

def incomplete() do
 from(todo in Todo, where: todo.status == :incomplete)
end

サンプルの構築

ElectricSQLはまだクライアントからの書き込みを同期する機能はないので、Readオンリーなアプリとして
ニュース配信アプリを作ろうかと思います

Postgresqlの設定

Electricですが論理レプリケーションを使うためにwal_levelをreplicaからlogicalに変更する必要があります

postgresql.conf
203 # - Settings -
204 
205 wal_level = logical                     # minimal, replica, or logical

構成

配信サーバーとしてPhoenix、クライアントアプリとしてElixirDesktopを構築します
配信サーバーにはElecrticをいれ、サーバー、クライアント双方にPhoenix.Syncを組み込み同期を行います

スクリーンショット 2025-06-11 11.00.03.png

構築は基本こちらに沿って行います

配信サーバーの構築

通常のPhoenixアプリを作成します

mix phx.new newstand
cd newstand
mix ecto.create

次に必要なライブラリを追加します

mix.exs
  defp deps do
    [
      ...
      {:electric, "~> 1.0"},
      {:phoenix_sync, "~> 0.4"}
    ]
  end
mix deps.get

Phoenix.Syncの設定

httpサーバーと同じにするとうまく動かないので別のポートで起動します

config.ex
config :phoenix_sync,
  env: config_env(),
  mode: :http,
  http: [
    port: 3000
  ],
  repo: Newstand.Repo,
  url: "http://localhost:3000"

CRUD画面

記事作成画面を作ります

mix phx.gen.live Articles Article articles title:string content:string category:string
mix ecto.migrate

routerに以下のように追加します

lib/newstand_web/router.ex
  scope "/", NewstandWeb do
    pipe_through :browser

    get "/", PageController, :home

+   live "/articles", ArticleLive.Index, :index
+   live "/articles/new", ArticleLive.Form, :new
+   live "/articles/:id", ArticleLive.Show, :show
+   live "/articles/:id/edit", ArticleLive.Form, :edit
  end

配信サーバーは以上で終わりです

クライアントアプリの作成

通常のPhoenixアプリを作成し、destkop_setupでElixirDesktopのアプリ化します
DBは必要ないのですがphx.gen.liveが実行できなくなるので sqliteを指定しています

mix phx.new newstand_mobile --database sqlite3
cd newstand_mobile
mix ecto.create

ライブラリの追加とデスクトップアプリ化

mix.exs
  defp deps do
    [
      ...
      {:desktop_setup, github: "thehaigo/desktop_setup", only: :dev},
      {:phoenix_sync, "~> 0.4"}
    ]
  end
mix deps.get
mix desktop.install

Phoenix.Syncの設定

config.exsに以下を追加します

config.exs
config :phoenix_sync,
  env: config_env(),
  mode: :http,
  url: "http://localhost:3000"

CRUDの作成

以下のコマンドを実行します
実行後はマイグレーションは不要なのでpriv/repo/migration/xxx_create_articles.exsは削除します

mix phx.gen.live Articles Article articles title:string content:string category:string

routerに以下のリンクを追加します

lib/newstand_mobile_web/router.ex
  scope "/", NewstandMobileWeb do
    pipe_through :browser

    get "/", PageController, :home
+   live "/articles", ArticleLive.Index, :index
+   live "/articles/new", ArticleLive.Form, :new
+   live "/articles/:id", ArticleLive.Show, :show
+   live "/articles/:id/edit", ArticleLive.Form, :edit
  end

起動後に開くページを変更します

lib/newstand_mobile.ex
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: NewstandMobileWindow,
          title: "newstand_mobile",
          size: {400, 800},
-         url: "http://localhost:#{port}"
+         url: "http://localhost:#{port}/articles"
        ]
      })

sync_streamに置き換え

stream/4をsync_stream/4に差し替えます

lib/newstand_mobile_web/live/article_live/index.ex
defmodule NewstandMobileWeb.ArticleLive.Index do
  use NewstandMobileWeb, :live_view

  alias NewstandMobile.Articles
+ import Phoenix.Sync.LiveView
  
  @impl true
  def render(assigns) do
    ~H"""
     ...
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Listing Articles")
-    |> stream(:articles, Articles.list_artciles())}     
+    |> sync_stream(:articles, Articles.Article)}
  end

+ @impl true
+ def handle_info({:sync, event}, socket) do
+   {:noreply, sync_stream_update(socket, event)}
+ end
  
  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    article = Articles.get_article!(id)
    {:ok, _} = Articles.delete_article(article)

    {:noreply, stream_delete(socket, :articles, article)}
  end
end

動作確認

クライアント側はDBに繋いでないのでReadのみ配信サーバーのデータの同期になります

2d8365233b5524d614ce3fcdfe04d4b8.gif

配信サーバー側の変更が即時同期されるのを確認できました

クエリの例

条件をつけて取得、一件のみ取得の場合は以下のようになります

sync_streamに直でもいいですが、Articles.list_articles()など既存を書き換える方が従来と変わりないがないので良いかと思います

# articles.ex

  def list_articles(category) do
   from(article in Article, where: article.category == ^category)
  end

  def get_article(id) do
    from(article in Article, where: article.id == ^id, limit: 1)
  end

# index.ex

  def handle_event(%{"category" => category }, socket) do
   {:noreply, sync_stream(socket,:articles, Articles.list_article(category)}
  end 

# show.ex

  def mount(%{"id" => id}, session, socket) do
    {:ok, sync_stream(socket,:article, Articles.get_article(id))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
     <%= {_id, article} <- @stream.article do %>
      <.header>
        Article {article.id}
        <:subtitle>This is a article record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/articles"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/articles/#{article}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit article
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{article.title}</:item>
        <:item title="Content">{article.content}</:item>
        <:item title="Category">{article.category}</:item>
      </.list>
      <% end %>
    </Layouts.app>
    """
  end

運用に関して

今回はPhoenixにPhoenix.Syncを組み込んで同期サーバーとしましたが
Electric Cloudという同期サービスのみのホスティングがあるのでそちらを使うとより簡単に運用できるかと思います

最後に

クライアントからのWriteはまだAPI経由でしかできませんが、Readに関しては素晴らしい開発体験ができるかと思います

また従来のWebアプリケーションのLiveViewでもリアルタイム更新は従来はPubSubで行う必要があり別の複数のサーバーの場合はRedisを使用する必要がありましたが、これを使うことで簡単に実装ができると思われます

今後の発展が非常に楽しみなライブラリなため引き続き情報を追っていきたいと思います

本記事は以上になりますありがとうございました

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?