社会化文件系统
本文信息来源:overreacted
还记得文件吗?
.doc.doc.doc.doc.jpg.jpg.svg
你写好一份文档,点击保存,文件就存放在你的电脑上。它是你的。你可以查看它,可以把它发给朋友,也可以用其他应用打开它。
文件源自个人计算这一范式。
然而,这篇文章并不是在讨论个人计算。我想谈的是社会化计算 ——像 Instagram、Reddit、Tumblr、GitHub 和 TikTok 这样的应用。
文件与社会化计算有什么关系?
从历史上看,并不多—— 直到最近。
aliceownsownsbob.doc.doc.doc.doc.doc.docpostpost.jpg.jpg.jpg.jpg.jpg.jpg.jpg.jpgfollowfollowvotevote
但首先,向文件致敬。
为什么文件如此出色
文件在最初被发明时,并不是为了存在于应用程序内部 。
既然文件代表的是你的创作,它们就应该存放在一个由你掌控的地方。应用会代表你创建和读取文件,但文件并不属于应用。
.doc.doc.doc.doc.jpg.jpg.svgC:\Users\alicealiceowns
文件属于你——使用这些应用的人。
应用(及其开发者)也许并不拥有你的文件,但他们确实需要能够对文件进行读取和写入 。要可靠地做到这一点,应用需要你的文件具备结构性。因此,作为开发应用的一部分,应用开发者可能会发明并不断演进文件格式 。
文件格式就像一种语言。一个应用可能会“说”多种格式,而一种格式也可以被许多应用理解。 应用与格式之间是多对多的关系。文件格式让不同的应用在彼此并不相互了解的情况下协同工作。
来看这个 .svg:
.jpg.jpg.svgC:\Users\alice
SVG 是一项开放规范。这意味着不同的开发者对如何读取和写入 SVG 达成了一致。我是在 Excalidraw 中创建了这个 SVG 文件,但也完全可以使用 Adobe Illustrator 或 Inkscape。你的浏览器本来就知道如何显示这个 SVG,它不需要调用任何 Excalidraw 的 API,也不需要向 Excalidraw 请求权限来显示这个 SVG。无论是哪个应用创建了这个 SVG,都没有关系。
文件格式本身就是 API。
当然,并非所有文件格式都是开放的或有文档说明的。
一些文件格式是特定于应用程序的,甚至是专有的,比如 .doc。然而,尽管 .doc 并未公开文档,这并没有阻止有动力的开发者对其进行逆向工程,并创建出更多能够读取和写入 .doc 的软件:
.doc.doc.doc.docC:\Users\alice
文件范式的又一次胜利。
“文件”这一范式抓住了人们对工具的直观理解:我们用工具制作出来的东西,并不属于工具本身。手稿不会留在打字机里,照片不会留在相机里,歌曲也不会留在麦克风中。
我们的记忆、思想与创作成果理应比我们用来创造它们的软件活得更久。与应用无关的存储方式(即文件系统)正是通过这种分离来加以保障。
一个文件可以拥有多种生命。
你可能在一个应用中创建文件,但别人可以用另一个应用来读取它。你可以更换所使用的应用,或同时使用多个应用。你可以把文件从一种格式转换为另一种格式。只要两个应用都能正确“说”同一种文件格式,它们就可以协同工作,即便各自的开发者彼此水火不容。
如果这个应用很糟糕呢?
任何人都可以围绕你已经拥有的文件,打造“下一个应用”:
.doc.doc.doc.doc.jpg.jpg.svgC:\Users\alice
应用可能会来来去去,但文件会留下来——至少,只要我们的应用仍然以文件为思考单位。
另见: 文件胜于应用
一切文件夹
当你想到社交应用——Instagram、Reddit、Tumblr、GitHub、TikTok——时,可能不会想到“文件”。文件只是用于个人计算的,对吗?
Tumblr 上的一条帖子并不是一个文件。
Instagram 上的一次关注并不是一个文件。
Hacker News 上的一次点赞并不是一个文件。
但如果它们在所有重要方面都像文件一样运作呢?设想你有一个文件夹,里面包含了你的线上身份曾经 POST 过的所有内容:
aliceowns.doc.doc.docpost.jpg.jpg.jpg.jpgfollowvote
它将涵盖你在不同社交应用中创建的一切内容——你的帖子、点赞、听歌记录、食谱等等。也许我们可以把它称为你的“万物文件夹”。
当然,像 Instagram 这样的封闭式应用并不是以这种方式构建的。但不妨设想如果它们是这样运作的。 在那个世界里,“Tumblr 帖子”或“Instagram 关注”都是一种社交文件格式:
- 你在 Tumblr 上发帖,会在你的文件夹中创建一个 “Tumblr 帖子” 文件。
- 你在 Instagram 上关注他人,会在你的文件夹中放入一个 “Instagram 关注” 文件。
- 你在 Hacker News 上点赞,会在你的文件夹中新增一个 “HN 点赞” 文件。
请注意,这个文件夹并不是某种归档,而是你的数据实际存放和存在的地方:
aliceowns.doc.doc.docpost.jpg.jpg.jpg.jpgfollowvote
文件才是唯一的事实来源——应用只是反映你文件夹中的内容。
对你的文件夹进行的任何写入都会同步到感兴趣的应用。例如,删除一个 “Instagram 关注” 文件,其效果与在应用内取消关注完全一致。向三个 Tumblr 社区同时发布内容,只需创建三个 “Tumblr 帖子” 文件即可。在底层,每个应用都会管理你文件夹中的文件。
在这种范式下,应用对文件是响应式的。每个应用的数据库在很大程度上都变成了派生数据——即对所有人文件夹的、应用特定的缓存化物化视图。
社会化文件系统
这听起来也许非常假想,但事实并非如此。到目前为止我所描述的,正是 AT 协议背后的设计前提。它已经在生产环境中以规模化方式运行。Bluesky、Leaflet、Tangled、Semble 和 Wisp,都是以这种方式构建的新型开放式社交应用。
使用这些应用时并没有什么不同的感觉 。但通过把用户数据从应用中抽离出来,我们强制实现了与个人计算时代相同的分离原则: 应用不会把你用它们创建的内容锁死。 任何人都可以为旧数据开发新的应用:
aliceowns.doc.doc.docpost.jpg.jpg.jpg.jpgfollowvote
和以前一样,应用开发者会不断演进他们的文件格式。但他们无法把持谁可以读取和写入这些格式的文件。使用哪些应用,完全由你决定。
综合来看,每个人的文件夹共同构成了一种分布式的社会化文件系统 :
aliceownsownsbob.doc.doc.doc.doc.doc.docpostpost.jpg.jpg.jpg.jpg.jpg.jpg.jpg.jpgfollowfollowvotevote
我之前在 Open Social 中写过关于 AT 协议的文章,从以 Web 为中心的视角来审视它的模型。但我认为,从文件系统的角度来看同样引人入胜,因此邀请你一同探索它是如何运作的。
个人文件系统始于一个文件。
那么,社交文件系统始于什么?
一个记录
以下是一条典型的社交媒体帖子:
wint@drilno6:25 PM · Sep 15, 200881956K125K
你会如何将它表示为一个文件?
将 JSON 视为一种格式是很自然的。毕竟,如果你在构建一个 API,返回的通常就是它。因此,让我们把这篇帖子完整地描述为一段 JSON:
{ author: { avatar: 'https://example.com/dril.jpg', displayName: 'wint', handle: 'dril' }, text: 'no', createdAt: '2008-09-15T17:25:00.000Z', replyCount: 819, repostCount: 56137, likeCount: 125381}
然而,如果我们想把这条帖子作为一个文件来存储,就不应该把作者信息嵌入其中。毕竟,如果作者后来更改了他们的显示名称或头像,我们并不希望逐一遍历他们的每一条帖子并在其中进行修改。
那么我们可以假设他们的头像和姓名存放在别处——也许是在另一个文件中。我们当然可以在 JSON 里保留 author: 'dril',但这同样没有必要。既然这个文件位于创作者的文件夹中——毕竟这是他们的帖子——我们总是可以根据当前正在查看的是谁的文件夹来确定作者。
让我们彻底移除 author 字段:
{ text: 'no', createdAt: '2008-09-15T17:25:00.000Z', replyCount: 819, repostCount: 56137, likeCount: 125381}
这似乎是描述这篇文章的一个好方法:
wint@drilno6:25 PM · Sep 15, 200881956K125K
但等等,不对,这仍然不对。
你看,replyCount、repostCount 和 likeCount 并不是真正由帖子的作者创建的。这些数值来源于其他人创建的数据—— 他们的回复、 他们的转发、 他们的点赞。展示这条帖子的应用必须以某种方式对这些数据进行统计和跟踪,但它们并不是这个用户的数据。
因此,实际上我们最终只剩下这一点:
{ text: 'no', createdAt: '2008-09-15T17:25:00.000Z'}
这就是我们以文件形式呈现的帖子!
wint@drilno6:25 PM · Sep 15, 200881956K125K{ text: 'no', createdAt: '2008-09-15T17:25:00.000Z'}
请注意,为了确定数据中真正属于这个文件的部分,我们需要进行一定程度的取舍。在使用 AT 协议创建应用时,这是你必须有意识去思考的问题。对此,我的心智模型是从 POST 请求来理解:当用户创建这个东西时, 他们发送了哪些数据? 这些数据很可能就接近我们需要存储的内容——也就是用户刚刚创建的那部分。
我们的社交文件系统将比传统文件系统具有更严格的结构。例如,它将仅由 JSON 文件组成。为了更明确地说明这一点,我们将开始引入新的术语。我们把这种文件称为记录 。
记录键
现在我们需要给记录命名。帖子并不存在天然的名称。我们可以使用顺序编号吗?我们的名称只需要在同一个文件夹内保持唯一即可:
posts/├── 1.json├── 2.json└── 3.json
一个缺点是,我们必须跟踪最新的那个,因此在同时从不同设备创建大量文件时,存在发生冲突的风险。
相反,我们可以使用时间戳,并混入一些基于各自时钟的随机性:
posts/├── 1221499500000000-c5.json├── 1221499500000000-k3.json # clock id helps avoid global collisions└── 1221499500000001-k3.json # artificial +1 avoids local collisions
这样更好,因为这些内容可以在本地生成,而且几乎永远不会发生冲突。
我们会在 URL 中使用这些名称,因此需要将它们编码得更紧凑一些。我们将谨慎选择编码方式 ,以确保按字母顺序排序时与时间顺序一致:
posts/├── 34qye3wows2c5.json├── 34qye3wows2k3.json└── 34qye3wows3k3.json
现在,ls -r 就能为我们提供一条按时间倒序排列的帖子时间线!这很酷。而且,由于我们坚持使用 JSON 作为通用语言,也就不需要文件扩展名了。
posts/├── 34qye3wows2c5├── 34qye3wows2k3└── 34qye3wows3k3
并非所有记录都会随着时间累积。例如,你可以发布很多帖子,但你的个人资料信息只有一份——你的头像和显示名称。对于这种“单例”记录,使用预定义的名称是合理的,例如 me 或 self:
posts/├── 34qye3wows2c5├── 34qye3wows2k3└── 34qye3wows3k3 profiles/└── self
顺便说一句,让我们把这个个人资料记录保存到 profiles/self:
{ avatar: 'https://example.com/dril.jpg", displayName: 'wint'}
请注意,综合来看,posts/34qye3wows2c5 和 profiles/self 让我们得以重建最初界面的更多部分,尽管仍有一些部分尚未缺失:
wint@drilno6:25 PM · Sep 15, 200881956K125Kposts/├── 34qye3wows2c5├── 34qye3wows2k3└── 34qye3wows3k3 profiles/└── self
不过,在填写它们之前,我们需要先让系统更加稳固。
词典
这是我们的帖子记录的结构:
{ text: 'no', createdAt: '2008-09-15T17:25:00.000Z'}
这是我们的个人资料记录的结构:
{ avatar: 'https://example.com/dril.jpg", displayName: 'wint'}
由于这些内容是以文件形式存储的,格式不发生漂移就显得尤为重要。
让我们编写一些类型定义:
type Post = { text: string, createdAt: string}; type Profile = { avatar?: string, displayName?: string};
TypeScript 看起来很方便,但它并不够用。例如,我们无法表达诸如“text 字符串最多应包含 300 个 Unicode 字素”,或“createdAt 字符串应采用日期时间格式”这样的约束。
我们需要一种更丰富的方式来定义社交文件格式。
我们或许可以四处寻找现有的方案(RDF?JSON Schema?),但如果没有任何一种完全契合,我们不妨干脆设计一套专门面向社交文件系统需求的自有模式语言。这就是我们的 Post 的样子:
{ // ... "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "required": ["text", "createdAt"], "properties": { "text": { "type": "string", "maxGraphemes": 300 }, "createdAt": { "type": "string", "format": "datetime" } } } } }}
我们将其称为 Post 词典 ,因为它就像是我们应用希望使用的一种语言。
我最初的反应也是“哎哟”,但从概念上这样理解会更有帮助:
type Post = { @maxGraphemes(300) text: string, createdAt: datetime};
我曾经渴望有一种更好的语法 ,但后来我也开始勉强学会欣赏 JSON。它解析起来易如反掌,使围绕它构建工具变得非常简单(这一点我会在文末进一步展开)。当然,我们还可以生成绑定,将这些内容转换为适用于任何编程语言的类型定义和校验代码。
集合
到目前为止,我们的社交文件系统看起来是这样的:
posts/├── 34qye3wows2c5├── 34qye3wows2k3└── 34qye3wows3k3 profiles/└── self
posts/ 文件夹中包含符合 Post 词汇表的记录,而 profiles/ 文件夹则包含符合 Profile 词汇表的记录(实际上只有一条记录)。
这在单一应用中可以很好地运作。但问题在于:如果还有另一个应用,对“posts”和“profiles”有自己的一套定义呢?
回想一下,每个用户都有一个包含来自所有应用数据的“everything folder”:
aliceowns.doc.doc.docpost.jpg.jpg.jpg.jpgfollowvote
不同的应用很可能在“post”的格式定义上存在分歧!例如,微博式帖子可能有 300 个字符的限制,而真正的博客文章则可能没有。
我们能让这些应用彼此达成一致吗?
我们可以试着把所有应用开发者关在同一个房间里,直到他们就一条帖子的完美词汇表达成一致。那一定会是对大家时间的一种“有趣”利用。
对于某些使用场景,比如跨站点内容分发 ,采用一种大致标准、由多方共同治理的词汇规范是合理的。而在其他情况下,你确实希望由应用来主导。事实上,不同产品对“什么是帖子”存在分歧是好事 !不同的产品,不同的气质。我们应该支持这种差异,而不是与之对抗。
其实,我们一直在问错问题。我们并不需要每个应用开发者就什么是 post 达成一致;我们只需要允许任何人“定义”他们自己的 post。
我们可以尝试按照应用名称对记录类型进行命名空间划分:
twitter/├── posts/│ ├── 34qye3wows2c5│ ├── 34qye3wows2k3│ └── 34qye3wows3k3└── profiles/ └── self tumblr/├── posts/│ ├── 34qye3wows4c5│ └── 34qye3wows5k3└── profiles/ └── self
不过,应用名称也可能发生冲突。幸运的是,我们已经有一种避免冲突的方法——域名。域名具有唯一性,并且意味着所有权。
为什么我们不从 Java 中汲取一些灵感呢?
com.twitter.post/├── 34qye3wows2c5├── 34qye3wows2k3└── 34qye3wows3k3 com.twitter.profile/└── self com.tumblr.post/├── 34qye3wows4c5└── 34qye3wows5k3 com.tumblr.profile/└── self
这样我们就有了集合 。
集合是一个文件夹,里面存放着某一种词典类型的记录。Twitter 的帖子词典可能与 Tumblr 的不同,这没有问题——它们位于不同的集合中。集合的命名始终采用 <whoever.designs.the.lexicon>.<name> 这样的形式。
例如,你可以设想以下这些集合名称:
com.instagram.follow用于 Instagram 关注fm.last.scrobble用于 Last.fm 播放记录io.letterboxd.review用于 Letterboxd 影评
你也可以想象一些稍微更古怪的集合名称:
com.ycombinator.news.vote(子域名也可以)co.wint.shitpost(个人域名同样适用)org.schema.recipe(也许有一天会成为共享标准?)fm.last.scrobble_v2(破坏性变更=新的词典,就像文件格式一样)
这就好比为每一种文件扩展名都配备一个专用文件夹。
想看看一些真实存在的词典名称,可以访问 UFOs 和 Lexicon Garden。
不存在所谓的“词典警察”
如果你是一名应用程序开发者,你可能会这样想:
谁来确保这些记录符合其词典?如果任何应用都可以(在获得用户明确同意的情况下)向其他应用的集合中写入数据,我们如何避免最终充斥着大量无效数据?如果某个其他应用把垃圾内容塞进“我的”集合里,又该怎么办?
答案是,记录确实可能是垃圾,但系统依然能够正常运作。
可以类比文件扩展名来理解。没有什么能阻止别人把 cat.jpg 重命名为 cat.pdf,只不过 PDF 阅读器会直接拒绝打开它。
词表验证的工作方式也是一样。com.tumblr.post 中的 com.tumblr 表明是谁设计了该词表,但这些记录本身可能由任何应用创建。这就是为什么应用始终将记录视为不可信输入 ,类似于 POST 请求体。当你根据词表生成类型定义时,也会得到一个用于执行验证的函数。如果某条记录通过了检查,那很好——你就会得到一个带类型的对象;如果没有,通过不了也没关系,忽略那条记录即可。
因此,像处理文件一样,在读取时进行验证。
在演进词表时需要格外小心。一旦某个词表开始在实际环境中使用,就不应再改变它对哪些记录视为有效的判定方式。例如,你可以新增可选字段,但不能改变某个字段是否为可选。这可以确保新代码仍然能够读取旧记录, 同时旧代码也能够读取任何新记录。这里有一个用于检查此类问题的 linter。(对于破坏性变更,应当创建一个新的词表,就像处理文件格式那样。)
虽然这并非必需,但你可以发布你的词汇表用于文档和分发。这有点像发布类型定义。它们没有单独的注册表;你只需将其放入某个账户的 com.atproto.lexicon.schema 集合中,然后证明该词汇表的域名归你所有。例如,如果我想发布一个 io.overreacted.comment 词汇表,我可以将它放在这里:
app.bsky.feed.post/├── 3mclfkzg4uc2k├── 3mcleqsh7cc2k└── 3mclejvlp5c2k com.atproto.lexicon.schema└── io.overreacted.comment
接下来我需要进行一些 DNS 设置 ,以证明 overreacted.io 归我所有。这样一来,我的词汇表就会出现在 pdsls、Lexicon Garden 以及其他工具中。
链接
让我们回到刚才那篇帖子。
wint@drilno6:25 PM · Sep 15, 200881956K125K
我们已经决定,个人资料应存放在 com.twitter.profile 集合中,而帖子本身应存放在 com.twitter.post 集合中:
wint@drilno6:25 PM · Sep 15, 200881956K125Kcom.twitter.post/├── 34qye3wows2c5├── 34qye3wows2k3└── 34qye3wows3k3 com.twitter.profile/└── self
但点赞呢?
事实上, 什么是点赞?
点赞是由用户创建的,因此将每一个点赞视为一条记录是合理的。点赞记录并不承载除“正在被点赞的是哪条帖子”之外的任何数据:
type Post = { text: string, createdAt: string}; // ... type Like = { subject: Post};
在 TypeScript 中,我们将其表示为对 Post 类型的引用。由于词典是具有全局唯一名称的 JSON 文件,下面是我们在词典中表达这一点的方式:
{ "lexicon": 1, "id": "com.twitter.like", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "required": ["subject"], "properties": { "subject": { "type": "ref", "ref": "com.twitter.post" } } } } }}
我们要表达的是:一个“点赞”是一个对象,带有一个指向某个帖子(Post)的 subject 字段。
然而,“指代”这个词在这里承担了很多含义。那么,一个 Like 记录实际上长什么样?你究竟是如何在一个 JSON 文件中真正引用另一个 JSON 文件的?
{ subject: "???"}
我们可以尝试通过它在“everything 文件夹”中的路径来指向该 Post 记录:
{ subject: "com.twitter.post/34qye3wows2c5"}
但这只能在单个用户的“everything 文件夹”内唯一标识它。请记住,每个用户都有自己完全隔离的文件夹,里面包含他们的所有内容:
aliceownsownsbob.doc.doc.doc.doc.doc.docpostpost.jpg.jpg.jpg.jpg.jpg.jpg.jpg.jpgfollowfollowvotevote
我们需要找到某种方式来指代用户本身:
{ subject: "???????????????????????????????/com.twitter.post/34qye3wows2c5"}
我们是如何做到的?
身份
这是一个棘手的问题。
到目前为止,我们一直在为社交应用构建一种类似文件系统的结构。但“社交”的关键在于需要在用户之间建立连接。我们需要一种可靠的方式来指代某个用户。挑战在于,我们正在构建的是一个分布式文件系统,不同用户的“所有内容文件夹”可能托管在不同的计算机上,由不同的公司、社区或组织运营,或者由用户自行托管。
此外,我们不希望任何人被锁定在其当前的托管服务中。用户应当能够在任何时候更换其“万物文件夹”的托管方,而不会破坏指向其文件的任何现有链接。 核心的张力在于:我们既希望保留用户更换托管方的能力,又不希望因此破坏任何链接。 此外,我们还希望确保,尽管系统是分布式的,但每一条数据都能够被确认未遭到篡改。
暂时你可以把记录、集合和文件夹这些概念都放到一边。我们将聚焦于一个单一的问题:链接。更具体地说,我们需要一种支持可更换托管的永久链接设计。如果这一点行不通,其他一切都会随之崩塌。
尝试一:以托管方作为身份
假设 dril 的内容托管在 some-cool-free-hosting.com 上。最直观的链接方式,就是使用一个指向其托管方的普通 HTTP 链接:
{ subject: "https://some-cool-free-hosting.com/com.twitter.post/34qye3wows2c5"}
这样确实能用,但如果 dril 想要更换托管服务,他就会破坏所有链接。所以这并不是解决方案——它恰恰是我们试图解决的问题 。我们希望链接指向的是“无论 dril 的内容将来在哪里”,而不是“dril 的内容此刻所在的位置”。
我们需要某种形式的间接层。
尝试二:作为身份处理
我们可以给 dril 分配一个类似 @dril 的持久标识符,并在链接中使用它:
{ subject: "@dril/com.twitter.post/34qye3wows2c5"}
然后我们就可以运行一个注册表,为每位用户存储这样一个 JSON 文档:
{ // ... "service": [{ // ... "serviceEndpoint": "https://some-cool-free-hosting.com" }]}
这个想法是,这份文档告诉我们如何找到 @dril 的实际托管位置。
我们还需要提供某种方式,让 dril 能够更新这份文档。
这种方案的某个版本或许可行,但在互联网上已经存在一个全球命名空间的情况下,再去发明我们自己的,似乎有些可惜。让我们对这个想法做一点变形。
尝试三:以域名作为身份
已经有一个任何人都可以参与的全球命名空间:DNS。如果 dril 拥有 wint.co,也许我们可以让他使用这个域名作为他持久的身份标识:
{ subject: "@wint.co/com.twitter.post/34qye3wows2c5"}
这并不意味着实际内容托管在 wint.co;它只是表示 wint.co 托管了一个 JSON 文档,用来说明内容当前所在的位置。举例来说,约定可能是将该文档以 /document.json 的形式提供。同样,这个文档会指向实际的托管位置。当然,dril 可以更新他的文档。
这种做法在概念上颇为优雅,但在实践中权衡并不理想。域名丢失相当常见,大多数人也不希望因为这种情况而导致自己的账号直接“变砖”。
尝试四:以哈希作为身份
最后两次尝试都有一个共同的缺陷:它们把你永远绑定在同一个账号名上。
无论是像 @dril 这样的账号名,还是像 @wint.co 这样的域名式账号名,我们都希望人们可以在任何时候更改自己的账号名,而不会导致链接失效。
听起来是不是很熟悉?我们对托管也有同样的诉求。因此,让我们保留“域名账号名”的概念,但将当前账号名与当前托管信息一起存储在 JSON 中:
{ // ... "alsoKnownAs": ["@wint.co"], // ... "service": [{ // ... "serviceEndpoint": "https://some-cool-free-hosting.com" }]}
这个 JSON 正逐渐变成一种代表你身份的名片:“叫我 @wint.co,我的内容在 https://some-cool-free-hosting.com 。”
现在我们需要一个地方来托管这份文档,以及一种让你可以编辑它的方式。
让我们重新审视方案 #2 中的“集中式注册表”。其中一个问题是把句柄作为永久标识符。此外,集中式本身也不好,但为什么不好?原因有很多,通常在于权力滥用的风险或单点故障。也许我们无法完全消除这些风险,但可以设法降低其中一部分。例如,如果能让注册表的输出具备自验证能力,那将是件好事。
看看我们是否可以借助数学来解决这个问题。
当你创建一个账户时,我们会生成一对私钥和公钥。然后,我们创建一段 JSON,包含你的初始句柄、托管信息以及公钥。我们用你的私钥对这次“创建账户”的操作进行签名,随后对已签名的操作进行哈希。这样就会得到一串看似乱码的字符串,比如 6wpkkitfdkgthatfvspcfmjo。
注册表会在该哈希下存储你的操作。 该哈希将成为你账户的永久标识符。 我们会在链接中使用它来指代你:
{ subject: "6wpkkitfdkgthatfvspcfmjo/com.twitter.post/34qye3wows2c5"}
要解析这样的链接,我们会向注册表请求属于 6wpkkitfdkgthatfvspcfmjo 的文档。它会返回你当前的托管位置、句柄和公钥。然后我们从你的托管位置获取 com.twitter.post/34qye3wows2c5 。
好的,但你如何在这个注册表中更新你的用户名或托管信息呢?
要进行更新,你需要创建一个新的操作,并将其 prev 字段设置为上一条操作的哈希。你对其进行签名并发送到注册表。注册表会验证签名,将该操作追加到你的日志中,并更新文档。
为了证明其未伪造所提供的文档,注册表会公开一个端点,用于列出某个标识符的历史操作。要验证一项操作,你需要检查其签名是否有效,以及其 prev 字段是否与前一项操作的哈希相匹配。这样你就可以一路验证整个更新链,直到第一条操作。第一条操作的哈希就是该标识符,因此这一点也可以被验证。至此,你可以确定每一次更改都是使用用户的密钥签名的。
(有关信任模型的更多内容,请参见 PLC 规范 。)
采用这种方式,注册表仍然是中心化的,但它无法在不被发现的情况下伪造任何人的文档。为了进一步降低对注册表的信任需求,我们将其全部操作日志设为可审计。注册表本身不持有任何私有数据,并将完全开源。理想情况下,它最终将被拆分出来 ,成立为一个独立的法律实体,从长远来看可以像 ICANN 一样运作。
由于大多数人并不愿意自行进行密钥管理,通常假定由托管方代表用户持有密钥。注册表中提供了一种机制,可注册一个可轮换的覆盖密钥,这在托管方本身失控时会非常有用。(我希望能有一种具备良好用户体验的设置方式;目前大多数人并没有启用这一功能。)
最后,由于句柄如今由注册表中保存的文档所决定,我们需要增加一种机制,让某个域名能够表明它同意成为某个标识符的句柄。这可以通过 DNS、HTTPS,或两者结合来实现。
呼!这并不完美 ,但已经出乎意料地让我们走得很远了。
尝试 5:以 DID 作为身份
从终端用户的角度来看,方案 #4(以哈希作为身份)是最友好的。它不使用域名作为身份(仅作为句柄),因此即便丢失域名也无妨。
然而,有些人认为,无论第三方注册表多么透明,依赖它都是不可接受的。因此,也有必要支持方案 #3(以域名作为身份)。
我们将使用一种灵活的标识符标准——DID(去中心化标识符),它本质上是一种为多种彼此无关的身份识别方式提供命名空间的方法:
did:web:wint.co等——基于域名(尝试 #3)did:plc:6wpkkitfdkgthatfvspcfmjo等——基于注册表(尝试 #4)- 这也为我们在未来添加其他方法留下了空间,比如
did:bla:...
这使我们的 Like 记录看起来如下:
{ subject: "at://did:plc:6wpkkitfdkgthatfvspcfmjo/com.twitter.post/34qye3wows2c5"}
这将是它的最终形态。我们在这里写 at:// 是为了提醒自己,这不是一个 HTTP 链接,你需要遵循解析流程(获取文档、获取托管位置,然后获取记录)才能真正得到结果。
现在你可以忘记我们刚刚讨论的一切,只记住四点:
- DID 是一个字符串标识符,用来代表一个账户。
- 账户的 DID 永远不会改变。
- 每个 DID 都指向一个文档,其中包含当前的托管位置、用户名句柄以及公钥。
- 句柄还需要从反向进行验证(域名必须同意)。
其心智模型是存在这样一个函数:
async function resolveDID(did) { // ... return { hosting, handle, publicKey };}
你向它提供一个 DID,它就会返回到哪里可以找到该用户的数据、经过双向验证的当前句柄,以及他们的公钥。你会希望在其上使用 “use cache”。
现在让我们完成我们的社会化文件系统。
at:// URI
有了 DID,我们终于可以构建一条路径,用来标识每一条具体的记录:
at://did:plc:6wpkkitfdkgthatfvspcfmjo/com.twitter.post/34qye3wows2c5 └─────────── who ──────────────┘ └─ collection ─┘ └── record ─┘
at:// URI 是指向一条记录的链接,即使托管方或账号标识发生变化也依然有效。
这里的心智模型是:你始终可以将它解析到某一条记录上。
async function fetchRecord(atURI) { const { did, collection, rkey } = parseATUri(atURI); const { hosting } = await resolveDID(did); const params = `repo=${did}&collection=${collection}&rkey=${rkey}`; return fetch(`${hosting}/xrpc/com.atproto.repo.getRecord?${params}`);}
如果托管服务宕机,它将暂时无法解析;但只要用户将其部署到任何地方并将其 DID 指向那里,就会再次开始解析。用户也可以删除该记录,从而将其从用户的“全部文件夹”中移除。
另一种理解 at:// URI 的方式是,它是我们文件系统中每一条记录的唯一标识符,因此可以作为数据库或缓存中的键。
JSON 超链接
通过链接,我们终于可以表示记录之间的关系。
让我们再看看 dril 的帖子:
wint@drilno6:25 PM · Sep 15, 200881956K125K
这 12.5 万个点赞从何而来?
它们只是分散在不同用户“所有内容文件夹”中的 12.5 万条 com.twitter.like 记录,每一条都链接到 dril 的 com.twitter.post 记录:
wint@drilno6:25 PM · Sep 15, 200881956K125Kdid:plc:fpruhuo22xkm5o7ttr2ktxdo/└── com.twitter.like/ ├── 3lbvlcpei2c25 └── ...did:plc:5c6cw3veuqruljoy5ahzerfx/└── com.twitter.like/ ├── 3lbwmdk7f3k2a └── ...did:plc:ad4m72ykh2evfdqen3qowxmg/└── com.twitter.like/ ├── 3lbxnep4g4m2b └── ...
这 5.6 万次转发从何而来?同样,这意味着在我们的社交文件系统中,有 5.6 万条 com.twitter.repost 记录指向这篇帖子:
wint@drilno6:25 PM · Sep 15, 200881956K125Kdid:plc:ewvi7nxzyoun6zhxrhs64oiz/└── com.twitter.repost/ ├── 3lc7vqm2k6p3d └── ...did:web:did12.whey.party/└── com.twitter.repost/ ├── 3lc8wrn3l7q4e └── ...did:plc:oisofpd7lj26yvgiivf3lxsi/└── com.twitter.repost/ ├── 3lc9xso4m8r5f └── ...
那回复呢?
回复只是一个带有父帖子(parent post)的帖子。在 TypeScript 中,我们会这样写:
type Post = { text: string, createdAt: string parent?: Post};
在 lexicon 中,我们会这样写:
// ... "text": { "type": "string", "maxGraphemes": 300 }, "createdAt": { "type": "string", "format": "datetime" }, "parent": { "type": "ref", "ref": "com.twitter.post" } // ...
这表示:parent 字段是对另一个 com.twitter.post 记录的引用。
所有对 dril 帖子的回复,都会将 dril 的帖子作为它们的 parent:
{ "text": "yes", "createdAt": "2008-09-15T18:02:00.000Z", "parent": "at://did:plc:6wpkkitfdkgthatfvspcfmjo/com.twitter.post/34qye3wows2c5"}
因此,要获取回复数量,我们只需统计每一条这样的帖子:
wint@drilno6:25 PM · Sep 15, 200881956K125Kdid:plc:ewvi7nxzyoun6zhxrhs64oiz/└── com.twitter.post/ ├── 3ld2kpm5n9t6g └── ...did:plc:ragtjsm2j2vknwkz3zp4oxrd/└── com.twitter.post/ ├── 3ld3lqn6o0u7h └── ...did:plc:oisofpd7lj26yvgiivf3lxsi/└── com.twitter.post/ ├── 3ld4mro7p1v8i └── ...
我们现在已经解释了原始 UI 的每一个组成部分是如何从文件中派生出来的:
- 显示名称和头像来自 dril 的
com.twitter.profile/self。 - 推文内容和日期来自 dril 的
com.twitter.post/34qye3wows2c5。 - 点赞数由所有人的
com.twitter.like汇总而成。 - 转发数由所有人的
com.twitter.repost汇总而成。 - 回复数由所有人的
com.twitter.post汇总而成。
最后一个收尾细节是用户名。遗憾的是,@dril 已经不能再作为用户名使用了,因为我们选择使用域名作为用户名。作为补偿,dril 如果愿意,将来可以在所有社交应用中统一使用 @wint.co。
dril@wint.cono6:25 PM · Sep 15, 200881956K125K
代码仓库
现在是时候给我们的“万物文件夹”起一个正式的名字了。我们称它为一个代码仓库 。一个代码仓库由一个 DID 标识。它包含多个集合,而集合中又包含记录:
did:plc:fpruhuo22xkm5o7ttr2ktxdo/├── com.twitter.like/│ └── ...├── com.twitter.post/│ └── ...├── fm.last.scrobble/│ ├── 3ld5nsp8q2w9j│ ├── 3ld5ntq9r3x0k│ └── ...└── com.ycombinator.news.vote/ ├── 3ld6our0s4y1l └── ...
每个代码仓库都是用户在社交文件系统中的一小块空间。代码仓库可以托管在任何地方——免费的提供方、付费服务,或你自己的服务器。你可以随意多次迁移自己的代码仓库,而不会导致链接失效。
在实践中构建社交文件系统的一大挑战是,应用需要在不增加额外开销的情况下计算派生数据(例如点赞数量)。显然,在为某条帖子提供 UI 时,若要在每个代码仓库中查找所有引用该帖子的 com.twitter.like 记录,是完全不切实际的。
正因如此,除了将代码仓库视为一个文件系统——可以进行列出和读取操作——你还可以将其视为一个数据流,通过 WebSocket 对其进行订阅 。这使得任何人都可以为特定应用构建一个本地缓存,仅保存该应用所需的派生数据。通过该数据流,你会以事件的形式接收每一次提交,以及对应的树增量。
例如,一个 Hacker News 后端可以监听所有已知代码仓库中 com.ycombinator.news.* 记录的创建、更新和删除,并将这些记录本地保存以便快速查询。它还可以跟踪诸如 vote_count 之类的派生数据。
订阅来自每个应用的所有已知代码仓库并不方便。使用称为 中继 的专用服务来转发所有事件要更理想一些。然而,这也带来了信任问题:你如何知道他人的中继是否在撒谎?
为了解决这一问题,我们可以让代码仓库数据具备自证性。我们可以将代码仓库构建为一棵哈希树 。每一次写入都是一个带签名的 提交 ,其中包含新的根哈希。这使得在记录传入时,可以依据其原作者的公钥对其进行验证。只要你订阅的中继会转发其证明,你就可以检查每一份证明,从而确认这些记录是真实的。
验证记录的真实性并不需要存储其内容,这意味着中继可以作为简单的转发器运行,并且运行成本可控 。
高空之上
打开 pdsls。
如果你想探索 Atmosphere(at://-mosphere,懂了吧?),pdsls 是最好的起点。给定一个 DID 或一个句柄,它会展示一系列集合及其记录。它真的很像老式的文件管理器,只不过处理的是社交内容。
如果你想找个随机的地方开始,可以访问 at://danabra.mov。你会注意到,你能理解那里大约 80% 的内容——集合、身份、记录等等。
可以随意扩展探索。记录会链接到其他记录。这里没有特定于应用的聚合,因此会让人感觉有点“无依无靠”(例如不像 Bluesky 那样有线程视图),但也有一些有趣的导航功能,比如反向链接。
看我在 Atmosphere 里随便走走:
(对啊,那是什么词汇表?!我没想到在录制时会碰到这个。)
总之,我最喜欢的演示是这个。
观看我通过 pdsls 创建一条 Bluesky 帖子,也就是创建一条记录:
这适用于任何 AT 应用,Bluesky 并没有什么特别之处。事实上,所有愿意监听 Bluesky Post 词汇表事件的 AT 应用都会知道这条帖子已经被创建。应用都位于所有人记录的下游。
一个月前,我做了一个名为 Sidetrail 的小应用( 它是开源的 ),用来练习全栈开发。它可以让你创建一步步的操作指南,并按照这些步骤进行“行走”。在这里你可以看到,我正在 pdsls 中删除一条 app.sidetrail.walk 记录,相应的行走内容也会从我的 Sidetrail“行走”标签页中消失:
我完全清楚它为什么能奏效,它本不该让我感到惊讶 ,可事实恰恰相反!我的仓库确实就是真相之源。我的数据存在于 Atmosphere 中,而应用会对这些数据作出“反应”。
太奇怪了!!!
以下是我的数据摄取器的代码:
export async function handleEvent(db: IngesterDb, evt: JetstreamEvent): Promise<void> { if (evt.kind === "account") { await handleAccountEvent(db, evt.account); return; } if (evt.kind === "identity") return; if (evt.kind !== "commit") return; const { commit } = evt; const { collection, rkey } = commit; if (!COLLECTIONS.includes(collection)) return; const [accountStatus] = await db .select({ active: accounts.active }) .from(accounts) .where(eq(accounts.did, evt.did)) .limit(1); if (accountStatus && !accountStatus.active) { return; } const uri = `at://${evt.did}/${collection}/${rkey}`; if (commit.operation === "delete") { switch (collection) { case "app.sidetrail.trail": await deleteTrail(db, uri); break; case "app.sidetrail.walk": await deleteWalk(db, uri); break; case "app.sidetrail.completion": await deleteCompletion(db, uri); break; } return; } const record = commit.record as Record<string, unknown>; await ensureAccount(db, evt.did); switch (collection) { case "app.sidetrail.trail": await upsertTrail( db, uri, commit.cid, evt.did, rkey, record, (record.createdAt as string) || new Date().toISOString(), ); break; case "app.sidetrail.walk": { const trailRef = record.trail as { uri: string } | undefined; const trailUri = trailRef?.uri || ""; await upsertWalk( db, uri, commit.cid, evt.did, rkey, trailUri, record, (record.createdAt as string) || new Date().toISOString(), ); break; } case "app.sidetrail.completion": { const trailRef = record.trail as { uri: string } | undefined; const trailUri = trailRef?.uri || ""; await upsertCompletion( db, uri, commit.cid, evt.did, rkey, trailUri, record, (record.createdAt as string) || new Date().toISOString(), ); break; } }}
这会把所有人的仓库变更同步到我的数据库中,这样我就能得到一个易于查询的快照。我确信我可以把这段话写得更清楚一些,但从概念上讲,这就像是我在重新渲染我的数据库 。这就好比我在互联网“之上”调用了一次 setState,于是新的 props 从文件向下流入各个应用,而我的数据库随之作出响应。
我可以在生产环境中删除这些表,然后用 Tap 从零开始回填我的数据库。我只是缓存了全局数据中的一部分切片。所有构建 AT 应用的人也都需要缓存一些数据切片,或许切片各不相同,但它们彼此重叠。因此, 汇聚资源就变得更加有价值,我们的更多工具也可以实现共享。
这里还有一个我非常喜欢的例子。
这是一个由 @chadmiller.com 制作的 teal.fm Relay 演示 。它展示了所有人最近播放的曲目列表,以及一些整体的播放统计数据:
现在,你可以看到屏幕顶部显示着“678,850 次 scrobble”。你可能会以为,人们已经有一段时间在向 teal.fm API 提交他们的播放记录了。
嗯,其实并不是。
teal.fm API 实际上并不存在。它不是个真实的东西。而且,teal.fm 这个产品本身也并不存在。我的意思是,我认为它正在开发中(这是一个业余项目!),但在撰写本文时,https://teal.fm/ 只是一个落地页。
但这并不重要!
你开始 scrobbling 所需要做的,只是将 fm.teal.alpha.feed.play 这一词典(lexicon)的记录放入你的仓库中。
看看现在是否有人正在这么做:
waiting to connect
该词汇表尚未以记录形式发布(至少目前还没有?),但在 GitHub 上很容易找到 。因此,任何人都可以构建一个 scrobbler 来写入这些数据。我正在使用其中一个 scrobbler。
这是我的播放记录显示出来:
(有点慢,但我觉得延迟在 Spotify/Scrobbler 的集成端 。)
需要明确的是,制作这个演示的人也并不在 teal.fm 工作。这不是任何形式的“官方”演示,也没有使用所谓的“teal.fm 数据库”或“teal.fm API”等类似资源。它只是对 fm.teal.alpha.feed.play 进行索引。
该演示的数据层使用了新的 lex-gql 包,这是 @chadtmiller.com 的另一项实验。你向它提供一些词典(lexicons),它就能让你在已回填的社会文件系统相关部分的快照上运行 GraphQL。
如果你拥有世界的 JSON,为什么不在产品之上运行 联接(joins)?
fragment TrackItem_play on FmTealAlphaFeedPlay { trackName playedTime artists { artistName } releaseName releaseMbId actorHandle musicServiceBaseDomain appBskyActorProfileByDid { displayName avatar { url(preset: "avatar") } }}
fm.teal.alpha.feed.playapp.bsky.actor.profile×
我还想分享最后一个例子。
几个月来,我一直在抱怨 Bluesky 的默认 Discover 信息流,坦白说,它对我来说并不太好用。后来我听到有人对 @spacecowboy17.bsky.social 的 “For You” 算法评价颇高。
我一直在试用它,真的很喜欢!
最后我干脆彻底切换过去了。它让我想起了 2017 年的 Twitter 算法——起伏有点大,但总能找到那些我不想错过的内容。而且它对“显示更少”的反馈也灵敏得多。它的 核心原则 看起来相当简单。
这种自定义信息流是如何运作的?其实,Bluesky 的信息流只是一个端点 ,它返回一组 at:// URI。这就是约定。你知道它是怎么回事。
[ { post: 'at://did:example:1234/app.bsky.feed.post/1' }, { post: 'at://did:example:1234/app.bsky.feed.post/2' }, { post: 'at://did:example:1234/app.bsky.feed.post/3' }]
除了帖子之外,还能有其他内容的订阅流吗?当然可以。
有意思的是,@spacecowboy17.bsky.social 过去曾在家用电脑上运行“For You”。他发布了很多有趣的内容,比如关于订阅流变更的 A/B 测试 。另外,这里还有一个针对我账户的 For You 调试器 。“切换视角”这个功能很酷。
几周前,有一条推文嘲讽 Bluesky 的算法糟糕到这种程度:用户必须安装第三方订阅流,才能获得良好的使用体验。
我同意 @dame.is 的看法,这说明了一件重要的事情:Bluesky 是一个可以发生这种情况的地方。为什么?在 Atmosphere 中,第三方就是第一方。我们都在基于同一份数据构建各自的呈现。有人能把它做得更好,本身就是一种特性 。
一个“全能应用”试图包办一切。
一个“全能生态”让一切自然完成。