Electric 与 Phoenix LiveView 实战:把 Postgres 实时同步进 LiveView Stream,无需手写查询与变更处理
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
本篇技术文章基于 Electric 仓库中的 Phoenix LiveView 示例应用(examples/phoenix-liveview)与官方 Phoenix 集成文档,完整拆解"读路径同步"(read-path sync)在 Phoenix 生态的落地方式:通过Phoenix.Sync库把 Postgres 数据实时同步进 LiveView 的 Phoenix Stream,写入则沿用标准 Ecto API,从而在不重跑查询、不手写任何缓存失效或变更广播逻辑的前提下,让多个浏览器窗口与数据库始终保持一致。读完后,你可以复制该示例的目录结构、Mix 任务、配置文件与 LiveView 回调骨架,搭建一个端到端实时的 Elixir 应用。
核心思路:读路径走 Electric,写路径走标准 Ecto
示例应用的设计在 examples/phoenix-liveview/README.md 中有明确阐述:它使用Electric.Phoenix(现名Phoenix.Sync)集成库,借助electric_stream/4(当前代码中对应Phoenix.Sync.LiveView.sync_stream/4)将 Postgres 数据同步进 LiveView,并基于 Phoenix Streams 渲染列表;写入则使用标准 Phoenix/Ecto API。
这里的关键分工值得强调:
- 读路径(Read Path):由 Electric 负责。LiveView 不执行任何"拉取查询"来刷新数据,而是订阅一个由 Electric 驱动的同步流——Postgres 中发生的任何变更(无论来自本窗口、另一个窗口,还是其他服务)都会被流式推送到 LiveView 的 stream 中。
- 写路径(Write Path):由应用自身的 Context 模块负责,就是最普通的
Repo.insert/2、Repo.update/2、Repo.delete/1调用。示例源码中的注释直接点明了这一点(见 todo_live/index.ex):
# Deleting is enough -- Electric will stream the update directly from the # database into the views也就是说,"写操作本身就足以让所有视图更新"——因为 Electric 直接监听数据库变更并推送,你不需要在handle_event里手动 push 任何消息。
整体架构与数据流
从源码结构看,整个数据链路如下:
- Postgres(
electric数据库,todos表)是唯一事实来源; - Electric作为独立服务运行,监听数据库的变更(WAL/复制层),并通过 HTTP Shape 流对外提供数据与增量事件;
- Phoenix.Sync(Elixir 依赖
phoenix_sync)以:http模式连接 Electric,把 Shape 事件转换成 Phoenix.LiveView 的 stream 更新消息; - LiveView通过 Phoenix Streams(
phx-update="stream")把增量变更渲染到 DOM,浏览器端零 JavaScript 业务代码。
这条链路的官方讲解见 Phoenix 集成文档,其中给出了Phoenix.Sync的四个核心 API:
Phoenix.Sync.Client.stream/2:低层级使用,把Ecto.Query转换成 ElixirStream;Phoenix.Sync.LiveView.sync_stream/4:把 Ecto schema/查询同步进 LiveView stream(本示例使用);Phoenix.Sync.Router.sync/2:在 Router 中以宏形式暴露静态 shape,供任意 HTTP 客户端订阅;Phoenix.Sync.Controller.sync_render/3:在 Controller 中动态构造 shape 并输出。
Phoenix.Sync支持:embedded(Electric 作为应用依赖内嵌)与:http(消费外部 Electric 服务的 HTTP API)两种运行模式;本示例属于文档中"Local HTTP services"(本地 HTTP 服务)的形态——Electric 独立部署,Phoenix.Sync 走:http模式消费它。
示例应用结构
示例位于 examples/phoenix-liveview,是一个标准 Phoenix 1.7 + LiveView 应用,核心文件:
examples/phoenix-liveview/ ├── mix.exs # 依赖与 mix 别名(electric.start 等) ├── config/ │ ├── config.exs # Endpoint 通用配置 │ └── dev.exs # Repo 连接 + phoenix_sync 配置 ├── lib/ │ ├── electric_phoenix_example/ │ │ ├── application.ex # 启动树,Endpoint 挂 phoenix_sync plug │ │ ├── todos.ex # 标准 Context(写路径) │ │ ├── todos/todo.ex # Ecto Schema │ │ └── repo.ex │ └── electric_phoenix_example_web/ │ ├── router.ex # live "/" → TodoLive.Index │ └── live/todo_live/ │ ├── index.ex # 同步核心:mount + sync 事件 │ ├── index.html.heex # phx-update="stream" 列表 │ └── form_component.ex # 新建 Todo 的写路径组件 ├── priv/repo/migrations/...create_todos.exs # 建表迁移 ├── db/migrations/001-create_todos.sql # Electric 侧的 SQL 迁移 └── e2e/e2e.test.tsx # Playwright 双浏览器同步验证路由只注册了一条 LiveView 路由(router.ex):
scope "/", Electric.PhoenixExampleWeb do pipe_through :browser live "/", TodoLive.Index, :index end快速启动
按照 examples/phoenix-liveview/README.md 的步骤:
# 1. 启动 Electric 实例及其关联的 Postgres mix electric.start # 2. 安装依赖并初始化(deps.get + ecto 建库迁移 + 前端资源) mix setup # 3. 启动 Phoenix Endpoint mix phx.server # 或进入 IEx:iex -S mix phx.server启动后访问localhost:4000。README 中的验收标准很直观:打开两个独立浏览器窗口,在任一窗口做的变更会同时出现在两个窗口中——这正是 Electric 读路径同步的直接效果。
mix electric.start背后是一个 Mix 别名(mix.exs):
"electric.start": [ "cmd docker compose -f ../../.support/docker-compose.yml up -d" ]它拉起的是仓库共享的 .support/docker-compose.yml,包含两个服务:
- postgres:
postgres:16-alpine,库名electric,宿主端口映射为54321:5432(注意示例应用连接的是 54321 而非默认 5432); - backend:
electricsql/electric:canary,环境变量DATABASE_URL=postgresql://postgres:password@postgres:5432/electric?sslmode=disable,并设置ELECTRIC_INSECURE: true,对宿主暴露 3000 端口。
ELECTRIC_INSECURE在 compose 文件里被明确注释为"仅用于开发"("Not suitable for production. Only use insecure mode in development..."),生产环境需要按 Electric 安全文档另行配置鉴权。
核心实现逐段解析
数据模型与写路径 Context
todos表非常简洁(迁移文件):text(非空字符串)、completed(布尔,默认 false)加 UTC 时间戳。对应的 Ecto Schema(todo.ex):
schema "todos" do field :text, :string field :completed, :boolean, default: false timestamps(type: :utc_datetime) end写路径全部收敛在标准 Context 模块 todos.ex 中,包括create_todo/1、update_todo/2、delete_todo/1,以及示例特有的toggle_complete/1:
def toggle_complete(%Todo{} = todo) do todo |> update_todo(%{completed: !todo.completed}) endLiveView 挂载:一行 sync_stream 替代手动查询
同步的核心在 todo_live/index.ex:
@impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:electric_live, false) |> assign(:animate_insert, false) |> Phoenix.Sync.LiveView.sync_stream(:todos, Todos.Todo)} endsync_stream(:todos, Todos.Todo)把整个Todoschema 映射为一个 Electric shape,并把结果绑定到名为:todos的 LiveView stream。与 Phoenix 集成文档 中的最小用法一致——它等价于把原生Phoenix.LiveView.stream/3换成sync_stream/4。两者的职责边界文档描述得很清楚:
LiveView 负责自动把前端保持与已赋值 stream 同步;而 Phoenix.Sync 负责自动把stream 本身保持与数据库状态一致。
同步事件回调::loaded、:live与"必需的"转发
Electric 客户端在同步生命周期中会发出进度事件,示例用前两个handle_info分别处理(index.ex L21-L33):
@impl true # Progress events from the electric client. # - `:loaded` is sent when the initial fetch has completed # - `:live` is sent when the client is in `live` mode and waiting for the # latest updates from the server def handle_info({:sync, {:todos, :loaded}}, socket) do {:noreply, socket} end # here we use the `:live` state to turn on animations for new Todos def handle_info({:sync, {:todos, :live}}, socket) do {:noreply, socket |> assign(:electric_live, true) |> assign(:animate_insert, true)} end源码注释解释了这两个状态::loaded表示初始全量拉取完成;:live表示客户端已进入"live"模式、开始等待来自服务端的增量更新。示例利用:live状态切换 UI:初始加载时显示 "Loading…" 遮罩(@electric_live为 false 时 stream 内容opacity-0),进入 live 模式后移除遮罩并开启新条目的插入动画(@animate_insert配合模板中的JS.transition)。
真正驱动数据更新的是最后一条集成所必需的兜底回调(index.ex L35-L39):
# Forward all events from the Electric sync stream to the component. # This is **required** for the integration. def handle_info({:sync, event}, socket) do {:noreply, Phoenix.Sync.LiveView.sync_stream_update(socket, event, at: 0)} end所有 shape 事件(行插入/更新/删除)都由sync_stream_update/3转译成stream_insert/stream_update/stream_delete等对 stream 的操作;at: 0指定新行插入到 stream 顶部。这条回调缺失时,数据库变更将不会反映到 stream 上——这是集成中最容易遗漏的一步。
写操作不需要任何"手动推送"
handle_event中的两个交互处理器(index.ex L41-L62)值得特别注意:
@impl true def handle_event("delete", %{"id" => id}, socket) do {:ok, _todo} = id |> Todos.get_todo!() |> Todos.delete_todo() # Deleting is enough -- Electric will stream the update directly from the # database into the views {:noreply, socket} end def handle_event("toggle-completed", %{"id" => id}, socket) do {:ok, _todo} = id |> Todos.get_todo!() |> Todos.toggle_complete() {:noreply, socket} end两个处理器都只做了"落库"一件事,返回的 socket 没有任何stream_*调用。对比传统 LiveView 模式(写库后必须手动stream_insert/stream_delete并广播到其他 socket),这里的更新广播完全由 Electric 在数据库侧完成,天然覆盖"其他客户端、其他进程乃至其他服务直接写库"的场景。
模板侧:Phoenix Streams 渲染
index.html.heex 中列表容器声明phx-update="stream",内部用:for遍历@streams.todos:
<div id="todos" phx-update="stream" class="flex flex-col relative divide-y divide-zinc-100 ..." > <div :for={{id, todo} <- @streams.todos} id={id} class="... todo-item" phx-mounted={ if @animate_insert, do: JS.transition({"ease-out duration-500", "bg-violet-200", "bg-transparent"}, time: 600) } > ...phx-mounted处的JS.transition正是与后端@animate_insert状态配合的插入动画:只有当同步进入 live 模式后才对新条目播放"紫色高亮渐隐"过渡。每行绑定phx-click={JS.push("toggle-completed", value: %{id: todo.id})}与删除按钮事件,形成完整闭环。
新建条目的写路径由 form_component.ex 这个 LiveComponent 承担:phx-submit="save"触发Todos.create_todo/1,成功后重置表单并闪 "Todo created successfully"——新行随后由 Electric 流自动推入列表,组件本身不做任何 stream 操作。
配置要点
phoenix_sync 配置(:http 模式)
config/dev.exs 是本示例接入 Phoenix.Sync 的关键配置:
config :phoenix_sync, url: System.get_env("ELECTRIC_URL", "http://localhost:3000/"), mode: :http, credentials: [ secret: System.get_env("ELECTRIC_SECRET") || "my-dev-secret", ]url指向本地 Electric 服务(docker compose 映射的 3000 端口),可用环境变量ELECTRIC_URL覆盖;mode: :http表示 Phoenix.Sync 通过 Electric 的 HTTP Shape API 消费数据,而非内嵌 Electric 进程;credentials.secret是 shape 访问令牌,开发默认值my-dev-secret与ELECTRIC_INSECURE模式配套,生产必须通过ELECTRIC_SECRET注入。
这与 Phoenix 集成文档 中"Local HTTP services"一节的模板形态一致(mode: :http+ 本地port/url)。若改为:embedded模式,则 Electric 作为 Mix 依赖随应用启动,Phoenix.Sync 内部直接以 Elixir 函数 API 消费,无需 HTTP 层;:http模式也可指向外部服务(如 Electric Cloud),此时url与credentials(含source_id)指向远端。
依赖与启动树
mix.exs 中的 Electric 相关依赖:
{:phoenix_sync, "~> 0.4.1"}, {:electric_client, ">= 0.5.0-beta-1", override: true}注意示例没有把:electric本身列为依赖——:http模式下 Electric 是外部进程(由 docker compose 提供),应用只需要 Phoenix.Sync 客户端。application.ex 中唯一的集成点是 Endpoint 的启动参数:
children = [ Electric.PhoenixExampleWeb.Telemetry, Electric.PhoenixExample.Repo, # ... {Phoenix.PubSub, name: Electric.PhoenixExample.PubSub}, {Electric.PhoenixExampleWeb.Endpoint, phoenix_sync: Phoenix.Sync.plug_opts()} ]phoenix_sync: Phoenix.Sync.plug_opts()负责在 Endpoint 启动时初始化 Phoenix.Sync(包括 shape 连接管理),这是文档中各模式配置的共同要求。
数据库连接侧(dev.exs)与 compose 的端口映射对应:
config :electric_phoenix_example, Electric.PhoenixExample.Repo, username: "postgres", password: "password", hostname: "localhost", database: "electric", port: 54321, # ...版本说明
值得提醒的一点:示例仓库当前代码基于phoenix_sync ~> 0.4.1,调用的是Phoenix.Sync.LiveView.sync_stream/4;而 演示页文档 与示例 README 中仍引用早期库名Electric.Phoenix及其electric_stream/4API。两者是同一集成的新旧命名(库已演进为 Phoenix.Sync,API 更名为sync_stream/4),阅读旧资料时应对应理解。此外示例锁定了phoenix_live_view ~> 1.0.0-rc.1(override: true,注释标注正式版本后可提升),phoenix ~> 1.7.14,Elixir~> 1.14。
同步效果的自动化验证
示例自带 Playwright 端到端测试 e2e/e2e.test.tsx,其验证逻辑正是 README 承诺的"双窗口实时同步":
- 创建两个独立浏览器 context 打开同一页面,等待初始同步后断言两侧
.todo-item数量一致; - 在页面 1 输入新 Todo 并回车,等待后断言两个页面的条目数都 +1,且新条目文本在两侧均可见;
- 反向再在页面 2 添加,验证双向传播。
该测试把"读路径同步"从口头承诺变成了可回归的断言,也说明该方案的同步路径不依赖任何 LiveView 间的手工广播。
小结:从 Postgres 到 DOM 的零胶水链路
回顾这个示例,整条数据通路没有任何一处手写"变更 → UI"的代码:
| 环节 | 承担者 | 证据 |
|---|---|---|
| 数据库变更捕获与流式分发 | Electric 服务 | .support/docker-compose.yml |
| shape 事件 → LiveView stream 更新 | Phoenix.Sync | config/dev.exs、application.ex |
| stream 更新 → 最小化 DOM 重绘 | Phoenix Streams | index.html.heex |
| 业务写入 | 标准 Ecto Context | todos.ex |
要在自己的 Phoenix 应用中复刻这一模式,最小步骤是:引入phoenix_sync依赖、在配置中声明mode: :http与 Electric 服务地址(或选择:embedded内嵌)、在 Endpoint 启动参数中挂Phoenix.Sync.plug_opts()、在 LiveViewmount中用sync_stream替换手动stream初始化、并保留"必需"的handle_info({:sync, event})转发回调。完整可运行版本见 examples/phoenix-liveview,更深入的 API 说明(Router/Controller 形态、多环境模式切换等)见 website/docs/sync/integrations/phoenix.md。
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考