在未来,我们会怎样构建 Web 应用程序呢?如果行业正常发展下去的话,那么今天我们认为很难、做起来很有价值的事情在明天都会变得很轻松普遍。我想我们会发现很多新的抽象,让Google Docs写起来也能像今天的普通 Web 应用一样简单。
这就引出来一个问题——这些抽象会是什么样子?我们今天能发现它们吗?想要找出答案,一种方法是审视我们在构建 Web 应用程序时必须经历的所有问题,然后看看我们能做些什么。
亲爱的读者,这篇文章就是我对上述方法的一次实践尝试。我们会走过一段旅程,看看今天我们是如何构建 Web 应用程序的:我们将回顾行业面临的各种问题,评估Firebase、Supabase、Hasura 等解决方案,看看还有什么需要做的事情。我想到了旅途的最后,你一定会同意我的观点,那就是浏览器中的数据库看起来应该是最有用的抽象之一。不过,这里说的有点太远了,我们先从头开始。
这段旅程始于浏览器中的Javascript。
我们的第一步工作是获取信息并将其显示在各个位置。例如,我们可能会显示一个好友列表、好友数量、特定好友组的一个模态等。
我们面临的问题是,所有组件看到的信息都需要是一致的。如果一个组件看到的好友数据和别的不一样,你就可能显示出错误的“计数”,或者一个视图与另一个视图中的昵称不一样。
为解决这个问题,我们需要有一个核心的事实来源。于是每当我们获取什么东西时,我们都会对其标准化并把它放在一个地方(通常是一个存储)。然后,每个组件(使用一个选择器)读取并转换所需的数据。下面这样的代码是很常见的:
// normalise [posts] -> {[id]: post}
fetchRelevantPostsFor(user).then(posts=> {
posts.forEach(post=> {
store.addPost(post);
})
})
// see all posts by author:
store.posts.values().reduce((res, post)=> {
res[post.authorId]=res[post.authorId] || [];
res[post.authorId].push(post);
return res;
}, {})
复制代码
这里的问题是,为什么我们需要做这些工作呢?我们得编写自制代码来处理这些数据,可是数据库早就解决这个问题了。我们应该能够“查询”数据才是,比如说:
SELECT posts WHERE post.author_id=?;
复制代码
这样查询我们浏览器内部的信息不是很方便吗?
下一个问题是让数据保持最新状态。假设我们删除了一个好友,会发生什么呢?
我们发送一个 API 请求,等待它完成,然后编写一些逻辑来“删除”关于这个好友的所有信息。比如这样的代码:
deleteFriend(user, friend.id).then(res=> {
userStore.remove(friend.id);
postStore.removeUserPosts(friend.id);
})
复制代码
但这种机制很快就会变得很麻烦:我们必须记住存储中可能受这一更改影响的所有位置才行,就好像我们要在大脑里搞一个垃圾收集器,可我们的大脑不擅长这种活儿。为了避开它,人们想出的一种办法是跳过问题并重新获取整个世界:
deleteFriend(user, id).then(res=> {
fetchFriends(user);
fetchPostsRelevantToTheUser(post);
})
复制代码
这两种解决方案都不是很好。在这两种情况下都存在我们需要留意的隐式不变量(基于这一更改,我们还需要注意其他哪些更改?),并且我们在应用程序中引入了延迟。
问题是,当我们对数据库做任何更改时,它用不着我们这么小心就可以完成工作。为什么浏览器不能自动搞定这种事情呢?
DELETE FROM friendships WHERE friend_one_id=? AND friend_two_id=?
-- Browser magically updates with all the friend and post information removed
复制代码
你可能已经注意到 B.的问题是,我们必须等待好友被移除才能更新浏览器状态。
在大多数情况下,我们可以通过一个乐观更新来加快速度——毕竟,我们知道调用很可能会成功。为此,我们执行以下操作:
friendPosts=userStore.getFriendPosts(friend);
userStore.remove(friend.id);
postStore.removeUserPosts(friend.id);
deleteFriend(user, id).catch(e=> {
// undo
userStore.addFriend(friend);
postStore.addPosts(friendPosts);
})
复制代码
这更烦人了。现在我们需要手动更新成功操作和失败操作才行。
这是为什么?在后端,数据库本来就能做乐观更新啊——为什么我们不能在浏览器中这样做?
DELETE friendship WHERE friend_one_id=? AND friend_two_id=?
-- local store optimistically updated, if operation fails we undo
复制代码
数据不仅会因我们自己的行为而改变。有时我们需要连接到其他用户所做的更改。例如,有人可以取消我们的好友关系,或者有人可以向我们发送消息。
为了完成这项工作,我们需要做的事情与在 API 端点中所做的是一样的,但这次是在我们的 websocket 连接上:
ws.listen(${user.id}/friends-removed
, friend=> {
userStore.remove(friend.id);
postStore.removeUserPosts(friend.id);
}
复制代码
但这又引入两个问题。首先,我们又得玩垃圾收集器那套了,需要记住可能受事件影响的每一个位置。
其次,如果我们要做乐观更新,我们就会遇到争用情况。想象一下,你运行一个乐观更新,将一个形状的颜色设置为blue,同时一个陈旧(stale)更新跑来了,说它是red。
Optimistic Update: Blue
Stale reactive update: Red
Successful Update, comes in through socket: Blue
复制代码
现在你会看到闪烁的图像。乐观更新把形状改成蓝色,响应更新又会把它改成红色,但是一旦乐观更新成功,新的响应更新又会把它变回蓝色。
解决这样的问题涉及一致性的主题,于是你会去搜索关于……数据库的资料。
其实,用不着这么麻烦。如果每个查询都是响应式的呢?
SELECT friends FROM users JOIN friendships on friendship.user_one_id=?
复制代码
现在,好友关系的任何变化都会自动更新订阅这个查询的视图。你不必操心哪些内容出现了更改,并且你的本地数据库可以找出“最新更新”的内容,于是消除了大部分复杂性。
在服务器上,问题只会更复杂。
许多后端开发工作到头来成为了数据库和前端之间的一种粘合剂。
// db.js
function getRelevantPostsFor(userId) {
db.exec("SELECT * FROM users WHERE ...")
}
// api.js
app.get("relevantPosts", (req, res)=> {
res.status(200).send(getRelevantPosts(req.userId));
})
复制代码
这里面也太多重复了,以至于我们最后要创建脚本来生成这些文件。但是为什么我们需要这样做呢?不管怎样,它们通常是与客户端非常紧密地耦合的。为什么我们不能直接将数据库暴露给客户端呢?
好吧,我们不这样做的原因是我们需要确保权限正确设置。例如,你应该只能看到你好友的帖子。为此,我们向 API 端点添加中间件:
app.put("user", auth, (req, res)=> {
...
}
复制代码
但这会变得越来越混乱。Websocket 呢?新的代码更改有时会引入一些你意想不到的方法来更新数据库对象。突然之间,你就遇到了麻烦。
这里要问的问题是,为什么要在 API 级别进行身份验证?理想情况下,我们应该有一些非常接近数据库的东西,确保任何数据访问都通过权限检查。像 Postgres 这样的数据库有行级安全性,但这很快就会变得很麻烦。但如果你能“描述”数据库附近的实体呢?
User {
view: [
IAllowIfAdmin(),
IAllowIfFriend(),
IAllowIfSameUser(),
]
write: [
IAllowIfAdmin(),
IAllowIfSameUser(),
]
}
复制代码
在这里,我们编写一些身份验证规则,并确保不管你尝试用哪种方式来编写和更新用户实体,你都可以被许可。于是乎,现在只有少数代码更改(而不是大多数更改)会影响权限了。
并且在某些时候,我们要完成的需求会增加复杂性。
例如,假设我们需要支持“撤消/重做”,用于好友操作。一个用户删除了一个好友,然后他们按下了“撤消”——我们怎么来支持这一过程呢?
我们不能直接删除好友关系,因为如果我这样做的话,就没法不知道这个人原本“已经是好友”,还是现在刚请求成为好友。在后一种情况下,我们可能需要发送好友请求才行。
为了解决这个问题,我们改进了数据模型。我们将用“好友事实”来代替单一的好友关系。
[
{status: "friends", friend_one_id: 1, friend_two_id: 2, at: 1000},
{status: "disconnected", friend_one_id: 1, friend_two_id: 2, at: 10001},
]
复制代码
那么“最新事实”会代表俩人之间是否存在好友关系。
这种办法是可行的,但大多数数据库并不是为它设计的:查询不像我们预期的那样工作,优化起来也比我们预期的更难。我们最后不得不非常小心地处理更新机制,以免意外删除记录。
突然之间,我们变成了“某种数据库工程师”,跑去大量查阅有关查询优化的资料。
这种要求看似独特,但在实践中越来越常见。如果你处理的是金融交易,你需?要这样的机制来做审计。撤消/重做是许多应用中的必需品。
也许突然发生了一个错误,于是我们不小心删除了数据。在事实统治的世界中不会有这样的事情——反正你可以撤销删除操作。但这并不是我们大多数人生活的世界。
有一些模式将事实视为一等公民(Datomic,后文具体讨论),但现在它们还是很罕见的,很少有工程师能做到。如果这种模式没那么罕见呢?
令人头疼的例子还有很多。比如说离线模式——许多应用程序都是长期运行的,可以在没有互联网连接的情况下继续运行一段时间。我们如何支持这一特性呢?
我们只能再次进化我们的数据模型,但这一次真正将所有内容都作为“事实”,并准备一个客户端数据库,该数据库基于这些事实来演进自己的内部状态。恢复连接后,我们应该能够协调更改。
这很难做到。从本质上讲,能做到这一步的程序员都变成了数据库工程师。但是,如果我们在浏览器中有一个数据库,让它扮演分布式数据库中的一个“节点”,上面的任务不就可以自动完成了吗?
事实证明,基于事实的系统实际上更容易做到这一点。许多人认为我们需要求助于操作转换来做这样的事情,但正如 figma 展示的那样,只要我们允许单一的领导者,并且可以接受最后写入者获胜这样的语义,我们就可以彻底简化这个机制,只要事实就足够了。当你需要更严肃的解决方案时,你可以打开 OT 兔子洞。
想象一下......立即启用离线模式。这样一来,大多数应用程序会变成什么样?
前面,我们讨论了来自客户端的响应性。在服务器上的响应性也是个问题。我们必须确保在数据更改时更新所有相关客户端。例如,如果添加了一个“帖子”,我们需要通知与这个帖子相关的所有可能订阅。
function addPost(post) {
db.addPost(post);
getAllFriends(post).forEach(notifyNewPost);
}
复制代码
这会变得相当混乱。我们很难知晓所有可能相关的主题。错过一些主题也是很容易的:如果使用addPost之外的查询更新数据库,我们永远不会知道是不是有主题被错过了。这项工作需要开发人员来完成。它开始做起来很容易,但会变得越来越复杂。
然而,数据库也可以知晓所有这些订阅,并且可以只处理更新相关的查询。RethinkDB 是在这方面做得很好的一个例子。如果你选择的查询语言可以做到这一点,是不是会很方便?
最终,我们需要将数据放在多个位置:缓存(Redis)、搜索索引(ElasticSearch)或分析引擎(Hive)。这个步骤会变得非常麻烦。你可能需要引入某种队列(Kafka),确保所有这些衍生源都保持最新状态。这里面的工作涉及配置机器、引入服务发现和整个 shebang 等操作。
可为什么要这么复杂呢?在一个常规数据库中,你可以执行以下操作:
CREATE INDEX ...
复制代码
对于其他服务,我们为什么不能这样做?Martin Kleppman 在他的《数据密集型应用程序》中提出了这样一种语言:
db |> ElasticSearch
db |> Analytics
db.user |> Redis
// Bam, we've connected elastic search, analytics, and redis to our db
复制代码
我们都列举到了 J。但这些只是你开始构建应用程序后才开始面临的问题。那么在开始构建之前呢?
也许今天对开发人员来说最难办的问题是上手。如果你想存储用户信息并显示一个页面,你会怎么做?
以前,你只需要一个index.html和 FTP 就行了。现在,你需要 webpack、typescript、大量的构建过程,经常还需要多个服务。活动的部件太多了,迈出第一步都绝非易事。
这似乎是一个菜鸟才需要面对的问题,似乎有经验的程序员上手起来会快很多。我认为情况更复杂一些。大多数项目都处于边缘场景——它们不是你日常应对的那种类型。这意味着原型制作阶段哪怕只多了几分钟,也可能会让我们淘汰很多项目。
简化这一步骤将大大增加我们可以使用的应用程序数量。如果这一阶段能比index.html和 FTP 更容易完成呢?
这问题可是真够多的。情况看起来很糟糕,但如果你回过头看看区区几年前的样子,就会发现我们已经有了这么大的进步。不管怎样,我们不再需要自己应付那些机架了。如同文艺复兴时代一样,很多杰出的人才正在努力开发这些问题的解决方案。这些方案有哪些代表呢?
我认为 Firebase 在推动 Web 应用程序开发方面做了一些最具创新性的工作。他们做的最重要的一件事情就是浏览器上的数据库。
有了 firebase,你可以像在服务器上一样查询数据。通过这种抽象,他们解决了上面列出的 A-E 问题。Firebase 可以处理乐观更新,默认就是响应式的。它提供了对权限的支持,从而消除了对端点的需求。
K 问题也可以从中大大获益:我认为它的原型制作速度表现还是市面上最出色的。你只需从index.html开始就行了!
但它也有两个问题:
第一,查询能力。Firebase 选择的文档模型简化了抽象管理,但会破坏你的查询能力。很多时候,你必须对数据做反正则化,或者查询变得很难处理。例如,要记录像好友这样的多对多关系,你需要执行以下操作:
userA:
friends:
userBId: true
userB:
friends:
userAId: true
复制代码
你通过两个不同的路径(userA/friends/userBId)和(userB/friends/userAId)对好友关系进行反正则化。要获取完整数据,你需要手动复制一个联接(join):
1. get userA/friends
2. for each id, get /${id}
复制代码
这种关系在你的应用程序中很快就会出现。如果能有解决方案帮助你处理它就太好了。
第二,权限。Firebase 要求你使用一种受限的语言来编写权限。在实践中,这些规则很快就会变得非常混乱——于是人们开始自己编写一些高级语言并编译成 Firebase 规则。
我们在 Facebook 对此进行了大量实验,得出的结论是,你需要一种真正的语言来表达权限。如果 Firebase 有这样的语言就会更加强大。
至于剩下的项目(审计、撤消/重做、写入的离线模式、衍生数据)——Firebase 还没有解决它们。
Supabase 正在尝试做 Firebase 为 Mongo 所做的事情,但 Supabase 是为 Postgres 做的。如果他们成功了,这将是一个非常有吸引力的选择,因为它将解决 Firebase 面临的最大问题:查询能力。
到目前为止,Supabase 取得了一些重大进展。他们的身份验证抽象非常棒,这让它成为少数几个像 firebase 一样容易上手的平台之一。
他们的实时选项允许你订阅行级更新。例如,如果我们想知道一个好友是何时被创建、更新或更改的,我们可以这样写:
const friendsChange=supabase
.from('friendships:friend_one_id=eq.200')
.on('*', handleFriendshipChange)
.subscribe()
复制代码
在实践中这可以让你走得更远。不过它可能会变得很麻烦。例如,如果我们创建了一个好友,我们可能没有用户信息,所以必须获取它。
function handleFriendshipChange(friendship) {
if (!userStore.get(friendship.friend_two_id)) {
fetchUser(...)
}
}
复制代码
这里指出了 Supabase 的主要弱点:它还没有“浏览器上的数据库”这种抽象。虽然你可以做查询,但你要自己负责正则化并处理数据。这意味着它不能自动进行乐观更新,不能做响应式查询等。他们的权限模型也很像 Firebase,因为它遵循了 Postgres 的行级安全性。一开始这是很好用的,但就像 Firebase,它很快就会变得很麻烦。这些规则往往会拖慢查询优化器的速度,并且 SQL 本身会变得越来越难推理。
GraphQL 是一种很好的方法来声明性地定义你想要从客户端获取的数据。像 Hasura 这样的服务可以使用像 Postgres 这样的数据库,并做一些聪明的事情,比如给你一个 GraphQL API。
Hasura 很适合读取数据。他们在处理联接方面做得很聪明,并且可以给你一个很好的数据视图。你可以用一个 flip 将任何查询转换为订阅。当我第一次尝试将查询转换为订阅时,确实感觉这很神奇。
今天 GraphQL 工具的一大问题是它们的二手原型制作速度。你往往需要多个不同的库和构建步骤。他们在数据写入方面做得也没那么好。乐观更新不会自动发生——你必须自己处理它。
我们已经研究了三个最有前途的解决方案。现在,Firebase 可以立刻解决大多数问题。Supabase 以牺牲更多客户端支持为代价为你提供了更好的查询能力。Hasura 以牺牲原型制作速度为代价,为你提供了更强大的订阅和更强大的本地状态。据我所知,还没有方案能在客户端解决冲突,提供撤消/重做和强大的响应式查询。
现在的问题是:这些工具会演变成什么样子?
在某些层面,未来已经到来了。例如,我认为 Figma 就是一款来自未来的应用:它可以出色地处理离线模式、撤消/重做和多人关系。如果我们想制作这样的应用,理想的数据抽象应该是什么样的?
从浏览器来看,这种抽象必须像 firebase 一样,但要有强大的查询语言。
你应该能够查询本地数据,并且它应该与 SQL 一样强大。你的查询应该是响应式的,如果有更改会自动更新。它也应该为你处理乐观更新。
user=useQuery("SELECT * FROM users WHERE id=?", 10);
复制代码
接下来,我们需要一种可组合的权限语言。FB 的 EntFramework 也是我经常使用的例子,因为它非常强大。我们应该能够定义实体的规则,并且应该保证我们不会意外看到不允许我们看到的东西。
User {
view: [
IAllowIfAdmin(),
IAllowIfFriend(),
IAllowIfSameUser(),
]
write: [
IAllowIfAdmin(),
IAllowIfFriend(),
]
}
复制代码
最后,这个抽象应该让我们更容易实现离线模式,或者撤消重做。如果发生本地写入,并且服务器上存在写入冲突,则应该有一个协调器在大多数情况下做出正确的决定。如果有问题,我们应该能够朝着正确的方向推动它前进。
无论我们选择什么抽象,它都应该让我们能够在离线时运行写入操作。
最后,我们应该能够表达数据依赖关系,而无需启动任何东西。一个简单的命令:
db.user |> Redis
复制代码
对用户的所有查询都应该神奇地被 Redis 缓存。
好吧,这些需求听起来很神奇。那么今天满足它们的实现会是什么样子?
在 Clojure 世界中,人们长期以来一直是 Datomic 的粉丝。Datomic 是一个基于事实的数据库,可以让你“看到时间线上的每一个更改”。Nikita Tonsky 还实现了 datascript,这是一个与 Datomic 语义相同的客户端数据库和查询引擎!
它们已被用于构建支持离线的应用程序(如 Roam)或协作应用程序(如 Precursor)。如果我们在后端打包一个类似 Datomic 的数据库,在前端打包一个类似 datascript 的数据库,它就可以成为“具有强大查询语言的客户端数据库”!
Datomic 让你可以轻松地将新提交的事实订阅到数据库。如果我们在顶层创建一个服务,让它保留查询并听取这些事实,是不是会很棒?出现一个更改后,我们将更新相关查询。突然之间,我们的数据库变成实时的了!
我们的服务器可以接受一些代码片段,并在获取数据时运行它们。这些片段将负责处理权限,为我们提供强大的权限语言!
最后,我们可以编写一些 DSL,让你可以根据用户的喜好将数据通过管道传输到 Elastic Search、Redis 等。
有了它,我们就有了一个优秀的方案。
那么,为什么这种方案还不存在呢?那是因为……
如果我们使用 Datomic 这样的数据库,我们就不会再使用 SQL。Datomic 使用一种基于逻辑的查询语言,称为 Datalog。现在它与 SQL 一样强大,甚至更为强大。唯一的问题是,对于外行来说,它看起来非常难上手的样子:
[:find [(pull ?c [:conversation/user :conversation/message]) ...]
:where [?e :session/thread ?thread-id]
[?c :conversation/thread ?thread-id]]
复制代码
这个查询将查找当前“会话”中活动线程的所有消息以及用户信息。不错!一旦你学会了它,就会意识到它是一种优雅而出色的语言。但我认为这还不够。原型制作速度需要非常快才行,我们可能没时间去学这种语言了。
有一些有趣的实验可以简化这一过程。例如,Dennis Heihoff尝试使用自然语言。这给我们启发了一种有趣的解决方案:我们能否编写一种稍微冗长但更加自然的查询语言,把它编译为 Datalog?我认同这种想法。
另一个问题是数据建模也与人们习惯的做法不一样。Firebase 是黄金标准,你可以在不指定任何 schema 的情况下编写你的第一个更改。
虽然做起来很难,但我认为我们的目标应该是尽可能接近“简单易用”。Datascript 只要求你指明引用和多值属性。Datomic 需要一个 schema,但也许如果我们使用开源的、基于 datalog 的数据库,我们可以增强它来做类似的事情。要么尽可能少用 schema,要么是“神奇的可检测 schema”。
SQL 和 Datalog 都存在的一个大问题是,它们很难基于一些新的更改来确定哪些查询需要更新。
我不认为这是不可能解决的障碍。Hasura 可以做轮询,而且可扩展。我们也可以尝试使用特定的订阅语言,类似于 Supabase。如果我们可以证明某些查询只能通过事实的某些子集来更改,我们可以将它们从轮询中移出。
这是一个棘手的问题,但我认为它还是可以解决的。
让权限检查成为一种成熟的语言的话,一个问题是我们容易过度获取数据。
我认为这个问题是值得考虑的,但如果使用像 Datomic 这样的数据库,我们就可以解决它。数据读取很容易扩展和缓存。因为一切都是事实,我们可以创建一个界面来引导人们只获取他们需要的值。
Facebook 就做到了这一点。这可能会很难,但终究是可行的。
框架通常无法通用化。例如,如果我们想共享鼠标位置怎么办?这是短暂的状态,不适合数据库,但我们确实需要让它实时化——我们应该把它保存在哪里?如果你构建这样的抽象,将会出现很多这样的事情,并且你很可能会搞错。
我认为这确实是一个问题。如果有人要解决这个问题,最好的办法是采用 Rails 方法:使用它构建一个生产应用,并将内部组件提取为产品。我认为他们很有可能找到正确的抽象。
这类产品的共同问题是,人们只会将它们用于业余爱好项目,而且里面不会有很多商机。我认为 Heroku 和 Firebase 在这里指明了正确的出路。
大企业都是从业余项目开始起家的。老一辈工程师可能将 Firebase 视为玩具,但现在许多成功的初创公司都在使用 Firebase。它不仅仅是一个数据库,也许它还会成为一个全新的平台——甚至是 AWS 的继任者。
市场竞争非常激烈,用户变化无常。Slava 的《为什么RethinkDB会失败》描绘了在开发工具市场中获胜的难度有多大。我不认为他是错的。这样做需要对如何构建护城河并扩展成下一个 AWS 给出令人信服的回答。
好吧,我们涵盖了痛点,讨论了竞争对手,介绍了理想的解决方案,并考虑了诸多问题。谢谢你陪我走过这段旅程!