别再用 REST 做状态同步了 (2024)
别再用 REST 做状态同步了
2024年9月22日
简而言之:大多数应用需要状态 同步,而不是状态 传输。我们应该在适当的时候用合适的状态同步协议来替代 REST 及类似方案。
除了在欧洲享受房车生活,并开发 Eqlog Datalog 引擎之外,我还花了一些休假时间构建各种WebApp。我使用的技术栈是前端使用 React + Typescript,后端使用 Rust 和 Axum 库实现的 REST 服务器。Rust 可能有点不常见,但我认为其他部分是非常典型的设置。
让我惊讶的是,这种编程模型是多么的繁琐、重复和脆弱,我认为这很大程度上是由于使用 REST 作为客户端和服务器之间的接口。REST 是一种状态 传输 协议,但我们通常希望在客户端和服务器之间 同步 一部分状态。这种不匹配意味着我们通常在 REST 之上实现临时的状态同步,但事实证明,这并非易事,而且实际上很难做好。
用一个在大多数WebApp中都存在的例子来说明我的意思可能最容易:一个允许用户编辑一段文本并将其保存到后端数据库的输入元素。 使用REST,我们可以将其建模为一个路径,例如 /api/foo
,该路径支持GET和POST方法来获取或替换给定值的文本(如果你想让事情变得更复杂,也可以使用DELETE和PUT)。 一个允许用户编辑此文本的 React 组件可能会显示一个文本输入元素,在组件创建时 GET 初始值,并在文本输入失去焦点时 POST 一个新值。 如果请求失败,我们会显示一条包含重试按钮的错误消息,并在请求进行中时显示一个spinner。 如下所示:
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-1>)function FooInput(): JSX.Element {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-2>) // The value of the input element. Should be != null after we've fetched the
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-3>) // initial value.
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-4>) const [value, setValue] = useState<string|null>(null);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-5>)
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-6>) const [showSpinner, setShowSpinner] = useState<boolean>(true);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-7>) const [showError, setShowError] = useState<boolean>(false);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-8>)
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-9>) useEffect(() => {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-10>) (async () => {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-11>) console.assert(showSpinner, 'showSpinner should be true initially');
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-12>) try {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-13>) const response = await fetch('/api/foo');
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-14>) if (!response.ok) {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-15>) throw new Error('Failed to fetch');
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-16>) }
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-17>) const data = await response.json();
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-18>) setValue(data);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-19>) } catch (err) {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-20>) setShowError(true);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-21>) } finally {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-22>) setShowSpinner(false);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-23>) }
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-24>) })();
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-25>) }, []);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-26>)
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-27>) async function postValue(): void {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-28>) setShowSpinner(true);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-29>) try {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-30>) const response = await fetch('/api/foo', {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-31>) method: 'POST',
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-32>) headers: {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-33>) 'Content-Type': 'application/json',
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-34>) },
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-35>) body: JSON.stringify(value),
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-36>) });
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-37>) if (!response.ok) {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-38>) throw new Error('Failed to save');
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-39>) }
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-40>) setShowError(false);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-41>) } catch (err) {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-42>) setShowError(true);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-43>) } finally {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-44>) setShowSpinner(false);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-45>) }
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-46>) }
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-47>)
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-48>) function handleChange(event: React.ChangeEvent<HTMLInputElement>): void {
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-49>) setValue(event.target.value);
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-50>) }
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-51>)
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-52>) return (
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-53>) <div>
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-54>) {value != null && <input type='text' value={value} onChange={handleChange} onBlur={postValue} />}
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-55>) {showSpinner && <div className='spinner'>Loading...</div>}
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-56>) {showError && (
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-57>) <div>
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-58>) An error occurred <button onClick={postValue}>Retry</button>
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-59>) </div>
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-60>) )}
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-61>) </div>
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-62>) );
[](https://www.mbid.me/posts/stop-using-rest-for-state-synchronization/<#cb1-63>)}
鉴于我们只想让用户编辑数据库中的一个字符串,这里有很多样板代码。 理想情况下,我们只需要指定如何显示用户界面以及在哪里可以找到数据库中的字符串,但是这里我们还必须处理来回发送状态、显示错误和显示spinner。 诚然,在一个真实的应用程序中,您会将多个控件放在一个表单中,并且您只需要为整个表单准备一套这样的样板代码,而不是为每个控件都准备一套。 但是我们将在几个地方编写类似但又不完全相同的代码,因为我们的应用程序可能包含更多表单,并且上面的代码很难抽象:其他控件可能必须 GET 和 POST 复杂的数据类型,而不仅仅是一个字符串。 或者我们可能需要几个不同的端点来加载和更新表单中的数据。 并且我们可能需要将spinner和错误消息放置在相对于控件的不同位置,具体取决于特定的组件,并确保在显示或隐藏spinner和错误消息时,UI中没有奇怪的跳动。
但更重要的是,当同时存在多个正在进行的请求时,上面的代码甚至是不正确的。 如果用户快速连续两次更改该值,例如先更改为 "A"
,然后更改为 "B"
,我们可能会遇到这种情况。 这将按此顺序触发两个 POST 请求,分别带有有效负载 "A"
和 "B"
。 不幸的是,HTTP 并不能保证请求也以这种顺序到达我们的服务器。 即使请求以发送的相同顺序到达,我们也会遇到麻烦:由于我们的服务器很可能同时处理多个请求,因此仍然可能发生第二个请求在第一个请求之前被处理的情况。 这意味着我们可能会首先将 "B"
保存到我们的数据库中,然后用旧值 "A"
覆盖它,即使用户希望最终值为 "B"
。 我们的 UI 甚至没有指示有什么不对劲,而是向用户显示“B”,所以这很糟糕。
解决此问题的一种方法是要求用户单击“提交”按钮,并在请求进行中时禁用该按钮,以便我们在收到对第一个请求的响应之前无法发送第二个请求。 但这可以说是糟糕的 UX,大多数高度优化的 UI (例如,浏览器的首选项页面) 都不需要您这样做就证明了这一点。 另一种方法是将请求排队,以便我们将第二个请求推迟到收到第一个请求之后,例如通过包装或 monkey-patching fetch
。 这里的缺点是我们现在减慢了与服务器的通信速度。 根据应用程序的重要程度,忽略此问题也可能是可以接受的,因为它通常不会经常发生。
但是,即使我们的后端以发送的相同顺序接收和处理这两个请求,我们用于显示spinner的逻辑也是错误的:请注意,我们在启动 POST 请求之前设置 showSpinner = true
,并在收到回复时设置 showSpinner = false
。 问题是,当我们在请求结束时设置 showSpinner = false
时,我们没有考虑到其他请求可能仍在进行中。 这导致我们在收到第一个响应时已经隐藏了spinner,即使第二个请求仍在进行中。
我们可以通过将 showSpinner
标志替换为 requestCount
整数来修复spinner逻辑,在请求之前递增该整数,并在请求之后递减该整数。 然后,只要计数大于 0,我们就显示spinner。
我想提到的最后一个问题是,大多数应用程序只是接受,如果用户两次打开该应用程序,则在一个实例中所做的更改不会自动传播到第二个实例。 我们可以通过将初始 GET 请求替换为服务器发送事件的订阅来解决此问题,服务器会通过该事件通知客户端值的更改。 但这将需要大量的服务器端工作。
正如我在开头所写的那样,我认为所有这些偶然的复杂性之所以出现,是因为我们使用了一种为状态 传输 而制作的工具来解决状态 同步 问题。 因此,仅仅用其他状态传输协议(例如 gRPC)替换 REST 并不能解决问题。
我无法准确地概述该领域,但是有一些举措正在推动实际的状态同步机制:Automerge,Yjs,Braid working group,Electric SQL 以及其他。 它们的大多数方法似乎都基于 CRDTs。 由于我没有认真测试过这些工具,所以我无法说明它们目前的成熟程度。
我看到的一些关于 CRDT 的工作让我担心的是,他们似乎正在优化客户端长时间断开连接的情况(“本地优先”)。 但是,即使对于非常常见的情况,即所涉及的各方只是单个客户端和服务器通过合理的互联网连接(即,一个普通的WebApp),因此状态分歧的持续时间仅为毫秒甚至秒的数量级,拥有适当的状态同步机制也将非常有用。
无论如何,我希望状态同步技术最终能够成熟到足够普遍的程度,以至于我不需要一遍又一遍地在 REST 之上构建充满错误的临时状态同步。