状态传输的进化之路:从表单 POST、AJAX、REST、GraphQL 到 Electric 的 Local-first 终极形态
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
状态传输(state transfer)是联网应用的基本问题:Web 从一开始就围绕网络架构展开,前后端分离、AJAX、REST 都是对"数据如何在网络上移动"这一问题的不同回答。本篇技术文章以 Electric 官方博客《The evolution of state transfer》为骨架,梳理 Web 状态传输从命令式到声明式、从手动到自动的完整进化脉络,并结合当前开源仓库中的同步引擎源码与文档,深入讲解 local-first(本地优先)架构为何被视为这一进化的终局、其背后的混合架构分层思想,以及 Electric 如何用可复现的代码与配置把"状态传输抽象进数据库层"这一愿景落地。
[!WARNING] 本文所依据的博客文章发布于 2022 年 12 月,面向当时基于嵌入式 SQLite + WebSocket 协议的旧版 ElectricSQL 系统撰写,现仅作历史背景保留。仓库中当前活跃的是重写后的 Electric 同步引擎(Electric Next),其架构说明见仓库内 blog/posts/2024-07-17-electric-next.md。下文在介绍历史愿景时会同时标注与当前实现的对应关系。
从表单 POST 到 GraphQL:前半程的进化
Web 上的状态传输始于 HTTP POST。数据在服务端被渲染进页面,用户填写表单,点击提交后,表单数据以application/x-www-urlencoded的编码形式作为 HTTP POST 请求的 body 发送到服务器。
这就是状态最初在 Web 上被传输的方式:显式且命令式,绑定在整页刷新之上,直接由用户输入触发。它改变了世界,但开发者很快对"每次交互都要整页刷新"的模型感到不满。
AJAX 的发明
1998 年,Microsoft Outlook 团队发明了XMLHttpRequest,次年将其悄悄带入 Internet Explorer 5.0。随后所有浏览器跟进,在 Gmail、Google Maps 等应用的推动下,AJAX 时代到来。有了 AJAX,开发者可以自行决定何时、以何种方式向服务器 POST 数据,不再把状态传输与页面刷新耦合在一起。
这使 Web 成为一流的交互式应用平台。但随意地 POST、处理零散数据片段仍然复杂且武断,开发者寻求标准化,AJAX 最终收敛到 JSON 与 REST 之上。
标准化到 REST
JSON 与 REST 的组合为 API 设计和异步状态传输带来了通用模式。REST 有助于扩展性、简化后端开发,并且由于协议标准化,还催生了自动 API 生成的可能——例如 Swagger 可以从 REST API 规范自动生成文档和客户端库,Remix 等框架可以根据客户端代码自动创建 API。
标准化是好事,但 REST 在数据获取上并非最优模式:
- 渲染一个页面状态常常需要多次请求;
- 常常加载超出所需的数据——REST 端点通常返回整个资源的表示,哪怕你只用到其中一部分。
GraphQL 登场
2015 年,Facebook 发布 GraphQL 及配套的 Relay 框架。GraphQL 的两个核心设计目标正是修复 REST 在数据获取上的弱点:
- 最小化应用渲染页面所需的请求次数;
- 优化取回的数据形状,避免加载不必要的数据。
在 GraphQL 中,组件树上的每个组件都声明自己所需的确切数据形状("fragment",数据片段),Relay 将这些 fragment 聚合、归一化为一个顶层查询,从而同时优化网络请求数量与返回数据的形状。
例如,一个展示书籍信息的页面,其子组件需要展示作者简介。你可以为顶层页面组件定义查询,并把子组件需要的数据片段包含进来:
query BookQuery($bookID: ID!) { book(id: $bookID) { title author { ...AuthorDetails_author } } } fragment AuthorDetails_author on Author { name photo { url } }Relay 通过聚合 fragment 来填充查询形状,页面实际抓取到的数据为:
{ book { title author { name photo { url } } } }可以看到,GraphQL 是声明式的:作为开发者,你只声明组件需要的数据,系统负责 ① 在网络上传输数据;② 将传输的数据量最小化到应用实际使用的形状。这是朝向"抽象并优化状态传输"迈出的一大步。但协议仍然围绕网络设计——优化的是那个"前置的大规模 resolve"。开发者仍需用 fetch policies 显式控制数据如何、从哪里获取。
写入在默认情况下也仍然是命令式的。乐观写(optimistic writes)虽然更声明式,却引入了处理回滚的代码负担。从 Relay 核心commitMutationAPI 的签名可见一斑:
commitMutation( environment: Environment, config: { mutation: GraphQLTaggedNode, variables: {[name: string]: mixed}, onCompleted?: ?(response: ?Object, errors: ?Array<PayloadError>) => void, onError?: ?(error: Error) => void, optimisticResponse?: Object, optimisticUpdater?: ?(store: RecordSourceSelectorProxy) => void, updater?: ?(store: RecordSourceSelectorProxy, data: SelectorData) => void, configs?: Array<DeclarativeMutationConfig>, cacheConfig?: CacheConfig, }, );归根结底,在 GraphQL 下,状态传输层的关注点仍以错误处理器、命令式 mutation、fetch policy 语义的形式渗透回应用代码。相比之下,local-first 作为一种范式,有潜力把状态传输完全抽象掉,不让网络层的关注点泄漏进应用代码。
Local-first:把状态传输抽象进数据库层
在 local-first 架构中,开发者直接面向本地嵌入式数据库编码。读写是即时的。用户依然可以共享数据、协作,但状态传输发生在后台:读写先针对本地数据库执行,再在后台与服务器同步。
与 GraphQL 类似,你需要定义哪些数据可以被同步,并把数据声明式地绑定到组件上。但与 GraphQL 不同的是,你不需要等待网络来确认写入,也不需要配置 fetch policy 语义。作为 Web 开发者,你可以构建实时、多用户的应用程序,而不必思考或围绕网络编码。
例如在旧版 ElectricSQL 中,把 GraphQL 的useQuery换成useElectricQuery,结果就会自动保持同步——无论世界哪个角落的人编辑了items表。因为 SQL 是声明式语言,而你查询的是嵌入式 SQLite 数据库,因此不再需要图抽象,也不需要把关系表映射到 GraphQL schema 的额外 resolver 层:
const ExampleComponent = () => { const { results } = useElectricQuery('SELECT value FROM items', []) return ( <View> {results.map((item, index) => ( <Text key={ index } style={styles.item}> Item: { item.value } </Text> ))} ) }关键在于:系统保持同步的结果来自本地数据库。它不会在组件渲染时为你聚合和抓取数据,只是与嵌入式本地数据库对话。因此你可以确信:即使网络宕机、后端宕机,应用依然能工作,而且查询时间一致且极快(常常亚毫秒级)。
当 local-first 做对了,开发者无需担心网络及其各种失败模式,就能构建响应式、实时、多用户的应用。数据如何、何时传输完全由数据库复制系统决定。由此,local-first 把状态传输彻底带入了数据库的领域,连同围绕一致性与完整性的全部严谨性——而这些严谨性,当你在应用代码里手工处理数据时很容易丢失。
配合正确的系统与并发语义,你还可以用**终局性(finality)而非试探性(tentativity)**进行本地写入,即:写入一旦在本地被接受,就确定不会被拒绝1。于是你不再需要实现上文 GraphQLcommitMutationAPI 中的updater与optimisticUpdater两个回调,只需写入本地数据库——如果本地写入成功,就完成了。
当前仓库中的声明式数据绑定实现
这篇 2022 年的博客描述的useElectricQuery属于旧版系统;但"声明式地把数据绑定到组件"这一核心思想,在仓库当前的 React 集成中完整继承了下来。当前实现位于 packages/react-hooks/src/react-hooks.tsx,对外通过 packages/react-hooks/src/index.ts 导出,核心是useShapehook:它接收 Shape 配置(url、params等),通过useSyncExternalStoreWithSelector把 Shape 数据流绑定到组件状态,并用streamCache/shapeCache对 ShapeStream 与 Shape 做内存级复用。仓库的 React 集成文档 给出了可直接运行的示例:
import { useShape } from '@electric-sql/react' const MyComponent = () => { const { isLoading, data } = useShape<{ title: string }>({ url: `http://localhost:3000/v1/shape`, params: { table: 'items', }, }) if (isLoading) { return <div>Loading ...</div> } return ( <div> {data.map((item) => ( <div>{item.title}</div> ))} </div> ) }声明式理念也体现在 Shape 定义上。仓库文档 guides/shapes.md 指出:Shape 是控制同步的核心原语,由表名、可选的where过滤子句、可选的列选择子句等构成,客户端可以同步一个或多个 Shape,多个客户端可同步同一 Shape,Shape 之间可以重叠——这正是"声明哪些数据可以同步"这一愿景在当前实现中的直接映射。
数据的最优放置与移动:混合架构
"There are no solutions. There are only trade-offs."(没有解决方案,只有取舍。) —— Thomas Sowell
当然,local-first 也有约束。并发写入受合并(merge)语义支配,你必须接受"相对论宇宙"的现实(详见仓库博客 blog/posts/2022-05-20-relativity-causal-consistency.md)。
设备约束还意味着:你往往无法把整个数据库同步到设备上。因此仍然需要一种"混合"架构——云端作为 local-first 应用的地基,它不仅提供持久性与同步,还要在首次运行、以及设备所需数据形状变化时,把数据加载进本地应用。
这种混合架构从中心云到终端设备包含若干层:
- 中心云存储,如 AWS Aurora;
- 多区域地理分布式存储,如 PolyScale;
- 无服务器云边缘,如 Fauna 与 Neon;
- 设备端本地数据库,如 SQLite 与 DuckDB。
管理数据在这些层之间——"从云到边缘再到本地设备"——的放置与移动极其复杂,复杂到无法手工优化。它必须由系统来管理和优化:与其命令式地把数据放到云架构的某个位置,不如声明你的需求与优化参数,让云端处理其余部分。
"随着云编程走向成熟,它似乎不可避免地将脱离传统顺序编程。云是一台巨大的、横跨全球的分布式计算机。并行性在各个尺度上都无处不在。富有创造力的程序员正被遗留编程模型束缚。[需要的是]把分布式程序分离为程序语义、可用性、一致性与优化目标。" —— New Directions in Cloud Programming, Joe Hellerstein 等
这就是状态传输的终局:静态程序分析与 AI 的结合,不仅优化数据在点与点之间的传输,还优化数据的放置,以及这些"点"与"层"本身应该在哪里。
对 Electric 的启发:从愿景到当前实现
Web 开发一直在沿着"从手动、命令式的数据传输,走向自动、声明式系统"的路径进化,混合式 local-first 架构正是这条路径的自然终点。这也是 Electric 的构建目标:一个框架——你声明哪些数据可以同步、组件需要哪些数据,系统处理其余一切。
在这个愿景里,状态传输被完全抽象进数据库层,可以针对一致性、完整性、放置与延迟进行优化,而你永远不必再写回滚处理器,或检查 HTTP 状态码。
需要明确的是,博客撰写时的旧版 ElectricSQL 与仓库当前的 Electric Next 同步引擎在技术选型上有重大差异:
- 旧版系统:嵌入式 SQLite + WebSocket 复制协议 + DDLX 权限规则等垂直集成方案,即博客中所描述的 local-first 平台形态;
- 当前系统:从 blog/posts/2024-07-17-electric-next.md 可以看到,Electric 重写为"基于 Postgres 的分区复制同步引擎",采用 HTTP 复制协议、以 Shape 定义部分复制,通过 packages/react-hooks 等客户端库与 React、MobX、TanStack 等集成;同时拥抱试探性(tentativity)写入模式,逐步从只读路径向写入路径演进。
但无论选型如何变化,"把状态传输从应用域中抽象出去"这一进化脉络始终是 Electric 的思想内核,也是理解其 Shape、HTTP 同步 API 与客户端库设计的一把钥匙。仓库内 examples/linearlite 等示例应用完整演示了如何用这套当前实现构建实时多用户应用,可供进一步上手验证。
延续阅读
- 博客:Electric Next——构建 Electric 的新方法
- 文档:Shapes 同步原语指南
- 文档:React 集成与 useShape 使用说明
- 源码:react-hooks 包实现(useShape / useShapeStream / preloadShape)
- 文档:local-first 与分布式数据库相关文献
- 示例:linearlite——基于同步引擎的实时应用
相关理论基础见 Highly Available Transactions 与 Cure 两篇论文,均收录于仓库 docs/sync/reference/literature.md 的文献页。
↩
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考