实现ActivityPub为何困难,又为何不必如此
摘要
本文解释了实现ActivityPub协议的复杂性,例如处理多种HTTP签名标准和JSON-LD上下文,并介绍了Fedify,这是一个旨在简化构建ActivityPub应用的TypeScript框架。
<p><a href="https://lobste.rs/s/1g5bum/why_implementing_activitypub_is_hard_why">评论</a></p>
查看缓存全文
缓存时间: 2026/07/03 14:24
# 为什么实现 ActivityPub 很难,以及它其实不必如此
来源:https://hackers.pub/@fedify/2026/why-activitypub-is-hard
## 一次静默的失败
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#a-quiet-failure
想象一下,你的服务器向 Mastodon 发送了第一个 `Follow` 活动。你阅读了规范,构建了 JSON,签署了 HTTP 请求,并小心翼翼地用 `POST` 发送出去。返回的却只有一行:`401 Unauthorized`。没有响应体。没有任何解释。哪里出了问题?也许你的 `Date` 头部后面的时钟偏差了几分钟。也许 `Digest` 头部中的哈希值有误。也许你在构建签名串时,把 `(request-target)` 伪首部大写了,或者你以 PEM 格式发布了公钥,而对方期望的是 multibase 格式。远程服务器不会告诉你原因。
于是你开始阅读别人的服务器代码来调试自己的代码。我知道,因为我经历过。Fedify 最初是另一个项目的牺牲品。我着手构建一个单用户微博客服务器(也就是后来成为 Hollo 的那个),并从零开始实现 ActivityPub。在签名规范和 JSON-LD 之间,协议工作吞噬了产品开发,我最终放弃了整个项目。
后来我重新捡起的不是那个应用,而是本该属于那个应用的框架。Fedify 先诞生了;只有在那之后,Hollo 才能建立在它之上。(我在《与联邦宇宙的一年》(https://writings.hongminhee.org/2024/12/a-year-with-the-fediverse/)中更详细地讲述了这个故事。)
ActivityPub 开发在某些特定地方变得非常困难。在这篇文章中,我想逐一介绍五个这样的难点,然后展示在 Fedify 中每个难点是如何被处理的。如果你在联邦宇宙中待过一段时间,你可能会点头表示认同。如果没有,你可能会好奇为什么有人要手工完成所有这些工作。无论哪种情况,结论都是一样的:现在没人需要再这样做了。
## 五个场景
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#five-scenes
### 场景一:不止一个标准
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-1-there-is-more-than-one-standard
ActivityPub 服务器之间通过 HTTP 签名进行身份验证。然而签名规范不止一个。联邦宇宙的大多数运行在 `draft-cavage-http-signatures-12`(https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12)上,这是一个**过期且从未成为标准的草案**。真正的标准也存在:RFC 9421(https://www.rfc-editor.org/rfc/rfc9421),即 HTTP 消息签名。问题在于,你**只有尝试之后**才能知道某个服务器接受哪个规范。
因此,一个实际实现必须先用一个规范签名,看看是否被拒绝,再用另一个规范重新签名,并记住每个服务器使用哪个规范有效,以便下次跳过这个步骤。联邦宇宙中这被称为**双敲**(https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions)。是的,你需要自己实现它。
但这还没完。HTTP 签名只能证明谁发起了请求。在收件箱转发等场景中,你需要将收到的活动中继给第三方,这时需要文档本身的签名:链接数据签名(Linked Data Signatures)(https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/)和对象完整性证明(Object Integrity Proofs)(https://w3id.org/fep/8b32)。总共四种签名机制,以及两种需要管理的密钥:RSA 和 Ed25519。
### 场景二:一份文档,多种形态
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-2-one-document-many-shapes
ActivityPub 的线路格式是 JSON-LD(https://json-ld.org/),在 JSON-LD 中,同一份文档可以有多种形态。这用示例比用解释更容易说明。下面是一个服务器可能发送的 `Create` 活动:
```json
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"actor": "https://example.com/users/alice",
"to": "https://www.w3.org/ns/activitystreams#Public",
"object": {
"type": "Note",
"id": "https://example.com/notes/123",
"content": "Hello, fediverse!"
}
}
```
下面是来自另一个服务器的**语义上完全相同**的活动:
```json
{
"@context": ["https://www.w3.org/ns/activitystreams"],
"type": "Create",
"actor": {
"type": "Person",
"id": "https://example.com/users/alice",
"preferredUsername": "alice"
},
"to": ["as:Public"],
"object": "https://example.com/notes/123"
}
```
`actor` 从 URI 字符串变成了内联对象。`to` 从字符串变成了数组。`object` 则相反,从内联对象变成了 URI。甚至表示“公开”的地址也有三种有效拼写:`https://www.w3.org/ns/activitystreams#Public`、`as:Public` 和 `Public`。你的解析器必须接受所有组合,而具体出现哪种取决于发送方的实现。
符合规范的做法是使用 JSON-LD 处理器(扩展后压缩)对每个文档进行标准化。实际上,许多实现将其视为“纯 JSON”,并在遇到某些服务器发出的特定形态时悄悄出错。无论哪种方式,你最终会在整个代码库中散布防御性代码:这是字符串?数组?对象?还是需要我获取的 URI?
### 场景三:僵尸帖子
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-3-the-zombie-post
用户发布了一个帖子,发现拼写错误,立即删除了它。你的服务器发送了 `Create`,然后发送了 `Delete`。由于网络状况,某些接收服务器先收到 `Delete`,后收到 `Create`。它会忽略对一个尚不存在的帖子的删除操作,然后兢兢业业地处理一个已经被删除的帖子的创建操作。这个帖子现在在那台服务器上**永久存在**,而其作者却以为它已经被删除了。
然后是规模问题。如果有五千个关注者,一个帖子就意味着数千次 HTTP 投递。如果在请求处理程序中同步进行,你的发布按钮需要半分钟才能响应,或者服务器直接崩溃。好吧,使用队列。投递会失败,所以需要重试。按什么时间表?指数退避。重试多少次?`500 Internal Server Error` 和 `410 Gone` 是同一种失败吗?什么时候清理一个已不存在服务器上的三千个关注者?是否应该继续猛击一个已经宕机多日的主机?在某些时候,你会意识到这不再是协议实现,而是分布式系统工程。
### 场景四:不是规范,是生态系统
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-4-its-not-a-spec-its-an-ecosystem
即使完全符合规范,也不能保证互操作性。以下是实际中的一些例子:
- Mastodon 的安全模式要求 `GET` 请求也使用 HTTP 签名(即所谓的**授权获取**(https://swicg.github.io/activitypub-http-signature/#authorized-fetch))。现在假设两个服务器都运行在这种模式下。要获取对方的公钥,你必须签署你的请求;要验证你的签名,对方必须先获取**你的**密钥。死锁。社区的解决方法是使用代表服务器本身的“实例 actor”进行签名。你在规范中找不到这一点。
- Threads 无法解析 actor 以内联对象嵌入的活动。在发送给 Threads 时,actor 必须是 URI。
- Lemmy **静默地**拒绝缺少 Mastodon 从不要求的字段的 `Group` actor,例如通过 `attributedTo` 链接的版主集合和 `featured` 集合。
- Misskey 有自己的词汇扩展;仅引用帖子在不同实现中就有三个不同的属性名。
- 这个列表在持续增长。
这里的互操作性不是一次完成就可以不再关心的事情。它是永久的维护工作。
### 场景五:默认不安全
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-5-insecure-by-default
从零开始构建,你一开始就是完全敞开的。跳过传入活动的签名验证,任何人都可以注入伪造的 `Follow` 或 `Delete`。不对文档加载器加以限制,恶意活动可以将其指向 `http://169.254.169.254/` 或你的内部网络,把你的服务器变成一个 SSRF 代理。不对嵌入对象进行来源检查,任何服务器都可以发出一条文档,声称“这是 Mastodon 首席开发人员说的话”。
这些陷阱的共同点是:**掉进去时什么都不会发生**。一切看起来都正常。直到有人利用它们。
### Ghost 也遇到了这个问题
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#ghost-ran-into-this-too
如果你在想“我们团队肯定能处理好”,看看 Ghost:一个被数千名记者和创作者使用的领先开源发布平台,而且他们团队也开始构建自己的 ActivityPub 支持。
> 我们完全可以证明 Fedify 正在努力解决的问题,因为即使在早期原型设计的短短几周内,我们就立即遇到了上述问题。
摘自《好吧,让我们 Fedify》(https://activitypub.ghost.org/day-4/)
Ghost 最终将其 ActivityPub 层构建在 Fedify 之上。
## 所以我把所有这些都放进了框架
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#so-i-put-all-of-it-in-a-framework
Fedify(https://fedify.dev/)是一个 TypeScript 库,用于在 ActivityPub(https://www.w3.org/TR/activitypub/)及周边标准之上构建联邦服务器应用。它运行在 Deno、Node.js 和 Bun 上,并支持 Cloudflare Workers 等边缘运行时。设计目标从一开始就没有改变:将所有五个场景中的问题排除在应用代码之外。
以下是同样的五个场景,这次使用 Fedify。
### 场景一回顾:签名之战是框架的工作
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-1-revisited-the-signature-war-is-the-frameworks-job
以下是在联邦宇宙中放置一个 actor 所需的全部代码:
```typescript
import { createFederation, generateCryptoKeyPair, MemoryKvStore } from "@fedify/fedify";
import { Endpoints, Person } from "@fedify/vocab";
const federation = createFederation({
kv: new MemoryKvStore(), // 生产环境替换为 Redis、PostgreSQL 等
});
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
if (identifier !== "alice") return null;
const keyPairs = await ctx.getActorKeyPairs(identifier);
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
name: "Alice",
inbox: ctx.getInboxUri(identifier),
endpoints: new Endpoints({
sharedInbox: ctx.getInboxUri(),
}),
publicKey: keyPairs[0].cryptographicKey,
assertionMethods: keyPairs.map((keyPair) => keyPair.multikey),
});
})
.setKeyPairsDispatcher(async (ctx, identifier) => {
// 实际代码中你会将其持久化到数据库;这里展示要点
return [await generateCryptoKeyPair()];
});
```
这段代码一运行:
- 每个外发请求都会被签名。使用 RSA 密钥时,Fedify 会发射 HTTP 签名和链接数据签名;添加 Ed25519 密钥后,它还会附加对象完整性证明。所有四种机制在单个活动上共存,每个接收方会用它理解的最强机制进行验证。
- Fedify 会为你处理**双敲**(https://fedify.dev/manual/send#double-knocking-http-signatures):首次联络使用 RFC 9421,被拒绝后会触发 draft-cavage 重试,并缓存每台服务器适用的规范。如果拒绝响应带有 `Accept-Signature` 挑战(https://fedify.dev/manual/send#accept-signature-negotiation)(RFC 9421 §5),Fedify 会读取并精确使用服务器要求的组件重新签名。
- 传入签名在你的代码看到任何内容之前就已验证(https://fedify.dev/manual/inbox)。验证失败的活动永远不会到达你的监听器。
- 额外福利。因为你注册了 actor 分发器(https://fedify.dev/manual/actor),你现在就有了一个 WebFinger(RFC 7033(https://datatracker.ietf.org/doc/html/rfc7033))服务器,**完全免费**。在 Mastodon 的搜索框中输入 `@[email protected]`,你的 actor 就会出现。你一行 WebFinger 代码都没写。
### 场景二回顾:类型代替 JSON-LD
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-2-revisited-types-instead-of-json-ld
Fedify 提供了大约八十个类(https://fedify.dev/manual/vocab),覆盖了整个 Activity Vocabulary(https://www.w3.org/TR/activitystreams-vocabulary/)以及主要的供应商扩展。这些类是有类型且不可变的,它们的访问器吸收了 JSON-LD 允许的形态差异。
```typescript
const actor = await ctx.lookupObject("@[email protected]");
if (actor instanceof Person) {
console.log(actor.name); // 无论是字符串还是 langString 都安全
const followers = await actor.getFollowers(); // 获取一个 URI,解包一个对象
}
```
`lookupObject()`(https://fedify.dev/manual/vocab#looking-up-remote-objects)接受一个句柄并为你运行整个链,包括 WebFinger 发现。像 `getFollowers()` 这样的访问器(https://fedify.dev/manual/vocab#object-ids-and-remote-objects)无论值是 URI 引用还是内联对象,行为都一样,并且获取的值会被缓存。
供应商碎片在这里也被缝合起来。三个相互竞争的引用属性(`quoteUri`、`_misskey_quote`、`quoteUrl`)被统一为一个 API,旁边还有新兴的 FEP-044f(https://w3id.org/fep/044f)`quote`。Misskey 的 `isCat` 属性作为一个类型存在,因此你的服务器可以完全类型安全地判断猫属性。这听起来像玩笑话,但正是几十个这种细节才构成了真正的互操作性。
### 场景三回顾:僵尸帖子一行代码解决
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-3-revisited-the-zombie-post-dies-in-one-line
首先是投递基础设施。将一个消息队列插入 `createFederation()`,投递就会转移到后台,并在指数退避下自动重试(默认最多十次)。当一个帖子需要发送给数千名关注者时,**两阶段扇出**(https://fedify.dev/manual/send#optimizing-activity-delivery-for-large-audiences)就会启动:一条合并后的消息进入队列,后端工作线程将其拆分为按服务器分配的投递任务。发布按钮立即响应。
重试会带来一个问题:同一个活动可能到达两次。Fedify 维护一个 24 小时的**幂等性缓存**(https://fedify.dev/manual/inbox#activity-idempotency),用于记录已处理的活动,这样重复项在到达你的处理器之前就会被检测并跳过。
至于僵尸帖子,修复方法是一个选项:
```typescript
await ctx.sendActivity(
{ identifier: "alice" },
"followers", // 从你的关注者集合中收集接收者
deleteActivity,
{ orderingKey: post.id }, // 相同的 key = 每台服务器按序投递
);
```
共享 `orderingKey`(https://fedify.dev/manual/send#ensuring-ordered-delivery)的活动会按照发送顺序投递到每个接收服务器。`Delete` 不能再超过它的 `Create`。具有不同 key 的活动仍然并行发出,因此吞吐量不受影响。
Fedify 还处理死掉的服务器。在 `404 Not Found` 或 `410 Gone` 时,它停止重试并调用你注册的处理程序(https://fedify.dev/manual/send#permanent-delivery-failure-handling)。如果投递经过了共享收件箱,你还会获得其后关注者列表,以便当场清理消失的账号。
频繁失败的主机会触发每主机的**断路器**(https://fedify.dev/manual/circuit-breaker),它会暂停投递并定期探测,直到主机恢复。默认开启,无需配置。
### 场景四回顾:我们跟踪怪癖,你无需操心
https://hackers.pub/@fedify/2026/why-activitypub-is-hard#scene-4-revisited-we-track-the-quirks-so-you-dont
以下是 Fedify 如何解除场景四中的陷阱:
- 授权获取:将 `.authorize()`(https://fedify.dev/manual/access-control)链接到分发器上,请求者的已验证身份就会出现在你的回调中。黑名单、私有集合、
相似文章
在 ATProto 之上运行 ActivityPub
本文建议在 AT Protocol 的 PDS 之上运行 ActivityPub,认为结合两种架构可以在保持与现有联邦式社交媒体兼容性的同时,提供更好的用户自主权和可信退出机制。
ActivityPub实现E2EE前的长尾工作
本文详细介绍了为ActivityPub实现端到端加密(E2EE)所需进行的剩余技术工作,将其分解为基本的消息传递、安全和真实性目标,并解释了密钥透明度和MLS协议如何融入这项工作。
Mandy:Goblins上的ActivityPub
Spritely Institute发布了Mandy,一个原型,它在Goblins演员模型框架之上实现了ActivityPub,使得ActivityPub参与者可以通过HTTP访问。
ATProto中没有实例
解释为何来自Mastodon/ActivityPub的“实例”概念不适用于ATProto(Bluesky的协议),并阐明在ATProto架构中托管和聚合是分离的。
Agentic Publication Protocol: 科学出版现代化的尝试
本文介绍了Agentic Publication Protocol (APP),这是一种将科学论文与代码、数据以及智能体指令打包的格式,旨在提高可复现性,并允许AI智能体与已发表的研究进行交互并在此基础上进行构建。