缓存时间:
2026/05/14 16:33
# Goblins 上的 ActivityPub — Spritely 研究所 来源:https://spritely.institute/news/mandy-activitypub-on-goblins.html Jessica Tallon — 2026 年 1 月 6 日 Mandy 角色艺术作品 ActivityPub (https://www.w3.org/TR/activitypub/) 是驱动联邦宇宙(Fediverse)的协议。它不仅允许同一应用的不同实例之间进行联邦,还允许不同应用之间进行联邦。例如,视频托管应用上的一个帖子可以联邦到微博应用。ActivityPub 在这方面做得很好,相比于之前的技术 (https://en.wikipedia.org/wiki/OStatus) 是一个飞跃。对于那些不熟悉的人来说,ActivityPub 是一个去中心化的社交网络协议,由 W3C 标准化。Spritely 的执行董事 Christine Lemmer-Webber 和我本人(Jessica Tallon)都参与了 ActivityPub 的标准化工作。ActivityPub 规范在身份认证、分布式存储等方面留下了空白。此后,Spritely 延续了这项工作,研究和开发下一代社交网络基础设施。但 ActivityPub 的未来呢?Spritely 是否将其视为一块垫脚石而弃之不顾?不!我们长期以来在路线图中有一个项目(代号 Mandy),旨在 Goblins 之上实现 ActivityPub。如果你打开 ActivityPub 规范,实际上会看到 (https://www.w3.org/TR/activitypub/#actors) 对 actor 的提及。该协议本身在设计时考虑到了 actor 模型。由于 Goblins 是 actor 模型的一种实现,它们应该是自然的契合。这篇博客文章所基于的原型源代码可以在 这里 (https://codeberg.org/spritely/mandy) 找到。
## 通过 HTTP 的 Goblins actors
ActivityPub 是一个基于 HTTP 的协议,但 Goblins 不使用 HTTP。因此,第一步是让 Goblins actors 可通过 HTTP 访问。幸运的是,连接起来相当容易。有很多不同的方法可以做到这一点,但对于这个原型,我采用了一种相当简单的方案。Guile 有一个 内置的 Web 服务器 (https://www.gnu.org/software/guile/manual/html_node/Web-Server.html)。不仅如此,fibers (https://codeberg.org/guile/fibers)(Goblins 使用的并发系统)也为此提供了一个后端。这意味着我们可以很快开始处理请求。Web 服务器可以使用 `run-server` 过程启动。它接受一个符号参数,指定实现(`'http` 是内置的 HTTP 服务器,`'fibers` 是由 Fibers 提供的):
```
(run-server handler 'fibers)
```
handler 是一个过程,接受一个 request(请求)和一个 body(主体),并期望返回 HTTP 响应。在 Fibers 中编写典型的 HTTP 服务器时,我们会挂起 fiber 直到响应准备就绪。然而,Goblins 代码是围绕发送消息和等待 promise 解析而构建的。为了桥接这两种不同理念的 API,我们使用了一个通道(channel)。通道允许两个 fiber 相互发送消息。读取或写入通道会导致 fiber 挂起,直到有消息可用。然后,我们可以向我们的某个 actor 发送一条消息,并使用 `on` 来监听响应,一旦获得响应,我们就可以将响应写入我们的通道。
Goblins vat 是在 fiber 上运行的事件循环。这些事件循环管理一个消息队列,这些消息被发送到在该 vat 内生成的 actors,并按顺序处理。如果我们只是写入通道,就会挂起 vat 的底层 fiber。当 vat 的 fiber 挂起时,它会停止处理队列中的其他消息。为了确保我们不会因挂起 vat 而阻塞它,我们将使用辅助过程 `syscaller-free-fiber`,它在 vat 外部提供一个新的 fiber,该 fiber 可以安全地挂起。
```
(define vat (spawn-vat))
(define (^web-server bcom router)
(define (handler . args)
(define response-ch (make-channel))
(with-vat vat
(on (apply <- router args)
(lambda (response)
(syscaller-free-fiber
(lambda ()
(put-message response-ch (vector 'ok response))))
*unspecified*)
#:catch (lambda (err)
(syscaller-free-fiber
(lambda ()
(put-message response-ch (vector 'err err))))
*unspecified*)))
(match (get-message response-ch)
[#(ok (content-type response))
(values `((content-type . (,content-type))) response)]
[#(ok (content-type response) headers)
(values `((content-type . ,content-type) ,@headers) response)]
[#(ok response)
(values '((content-type . (text/plain))) response)]
[#(err err) (error "Oh no!")]))
(syscaller-free-fiber
(lambda ()
(run-server handler 'fibers `(#:addr ,(inet-pton AF_INET "0.0.0.0")))))
(lambda () 'running))
(define web-server
(with-vat vat (spawn ^web-server (spawn ^router))))
```
这比原型中使用的版本稍微简化了一些,但它展示了我们如何让可以返回 promise 的异步 actors 对 HTTP 请求可访问。从上面的代码中,我们已经桥接到了我们的 Goblins actors 中。这是一个非常灵活的桥接,因为 `^router` actor 只是接收一个请求并提供响应,我们可以以多种方式分发这个请求。对于我们的原型,我们采用了以下方法:
```
(define-values (registry locator)
(call-with-vat vat spawn-nonce-registry-and-locator))
(define (^router bcom)
(lambda (request body)
(define request-url (request-uri request))
(match (string-split (uri-path (request-uri request)) #\/)
[("" "static" filename)
(static-file (string-append "mandy/web/static/" filename))]
[("" "object" id)
(let ((object (<- registry 'fetch (base32-decode id))))
(<- object 'request))])))
```
大多数 Web 框架使用一种专门的、基于字符串的路由语言,这是一种表达力不足的 反模式 (https://wiki.c2.com/?StringlyTyped)。幸运的是,在 Scheme 中,我们有一个强大的 模式匹配器 (https://www.gnu.org/software/guile/manual/html_node/Pattern-Matching.html) 可以使用。在这种情况下,我们正在匹配静态文件的文件名,同时也在 `/object/` 下提供一些对象。我们可以在上面看到,路由器正在生成一个称为 nonce 注册表 (https://files.spritely.institute/docs/guile-goblins/0.17.0/Nonce-Registry.html) 的东西。这是一个 actor,它提供了一种机制,可以针对某个标识符注册任意数量的 actors 并查找它们。该 actor 处理 ID 的加盐和哈希,甚至包括持久化。这对于注册 ActivityPub 对象非常适用。每个对象都获得一个唯一的 ID,以后可用于查找。这些 ID 是字节向量,所以我们用 base32 编码将它们转换成文本形式,以便包含在 URI 中。然后,我们只需要在我们的 `match` 子句中添加一个路由来查找它们即可。你可能注意到我们使用了 `<-` 来发送消息,这意味着我们会得到一个 promise 作为返回。不过这对我们的 Web 服务器来说不是问题,因为 `on` 处理程序会等待它解析完成。
原型中的路由比上面显示的代码段更多,处理的情况也稍多一些,但引入的原则是相同的。
## ActivityPub 是如何工作的?
让我们退后一步,看看 ActivityPub 本身,这既是为了方便理解本文的其余部分,也是为了看看我们是否能在规范中看到 actor 模型。ActivityPub 其实并不太复杂。它拥有收件箱(inbox)和发件箱(outbox)等概念,您可能从电子邮件中就已经熟悉了。它还有活动(activities),描述用户正在“做”的事情。活动是协议的构建块。最后,它还有对象(objects),比如笔记(Note)、图片(Image)、视频(Video)等。
ActivityPub 实际上是两个协议合二为一。有客户端到服务器(client-to-server)协议和联邦服务器到服务器(server-to-server)协议。这些协议实际上非常相似,并且在大多数情况下相互对应,但不幸的是,客户端到服务器协议很少受到 ActivityPub 实现者的喜爱。即便如此,让我们看看我如何向我的好朋友 Christine 发布一条 `Note`(可以认为是 toot/tweet/微博文本):
```
POST /outbox/ HTTP/1.1
Host: tsyesika.se
Authorization: Bearer XXXXXXXXXXX
Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"to": ["https://dustycloud.org/"],
"object": {
"type": "Note",
"content": "Ohai Christine!"
}
}
```
上面的 JSON 对象代表一个 `Create` 活动 (https://www.w3.org/TR/activitypub/#create-activity-outbox),基本上就是发布某些东西。其他活动可能有 `Like`、`Share`、`Delete` 等。大多数活动是及物的(活动有一个对象),我们的 `Create` 活动也不例外。里面的对象是一个包含一些内容的 `Note`。该活动以 HTTP `POST` 的形式发布到我的发件箱。服务器将为两个对象分配 ID,并将我(Jessica)指定为笔记的作者。
如果你对发件箱执行 `GET` 操作,你会看到类似这样的内容:
```
GET /outbox/ HTTP/1.1
Host: tsyesika.se
Authorization: Bearer XXXXXXXXXXX
Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
{
"@context" : "https://www.w3.org/ns/activitystreams",
"id" : "https://tsyesika.se/outbox",
"type" : "OrderedCollection",
"name": "Jessica's Outbox"
"totalItems" : 1,
"items": {
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://tsyesika.se/objects/79402654-a9e5-4356-a50d-5109fedbaacc"
"actor": "https://tsyesika.se"
"to": ["https://dustycloud.org/"],
"object": {
"type": "Note",
"attributedTo": "https://tsyesika.se"
"id": "https://tsyesika.se/objects/2f614e93-1fe7-4a8a-ba39-f9e4468ed77f"
"content": "Ohai Christine!"
}
}
}
}
```
从用户的发件箱读取内容,会得到他们发布的所有内容(通常分页,但原型中未实现)。可以看到我们发布的笔记是唯一的项目,并且服务器已经为活动、笔记和作者分配了 ID。服务器还应该通过查找 Christine 的收件箱并向其发送 HTTP `POST` 请求来将活动和笔记递送给她。我不打算进一步深入联邦部分,但它与客户端到服务器 API 非常相似。
## ActivityPub 与 Goblins 的结合
如果你使用过 Goblins 或其他 actor 框架,你可能在想“这些活动看起来非常像不同对象之间的消息”,你是对的。我们上面发布的那个 `Create` 活动可以写成这样:
```
(define note (spawn ^note #:content "Ohai Christine!"))
(<- tsyesika-outbox ; 发件箱
'create ; 一个名为 create 的方法
#:object note ; 创建一个对象
#:to (list christine)) ; 将其发送给 Christine
```
然后发件箱可以实现其 `Create` 活动所需的任何功能(分配 ID)、向外联邦帖子等。就像任何其他 Goblins actor 实现方法一样。我构建的原型对解组后的 JSON 数据进行了相当简单的转换。JSON 数据被接收并解析为关联列表。然后有一个解组步骤,其中任何嵌套对象都被转换为相应的 Goblins actors。结果是一个看起来像这样的活动 actor:
```
(define-actor (^as2:activity bcom parent #:key actor object target result origin instrument)
(extend-methods parent
((actor) actor)
((object) object)
((target) target)
((origin) origin)
((to-method) (string->symbol (string-downcase (assoc-ref ($ parent 'to-data) "type"))))
((to-data) (append (filter-data `(("actor" . ,actor)
("object" . ,object)
("target" . ,target)
("result" . ,result)
("origin" . ,origin)
("instrument" . ,instrument)))
($ parent 'to-data)))))
```
ActivityPub 基于 Activity Streams 2.0,后者具有层次结构。`Activity` 类型从基类型 `Object` 扩展而来。这在 Mandy 原型中表示为 `parent`。然后,我们可以使用这个非常简单的过程将一个活动转换为消息:
```
(define* (activity->message activity #:key send-to)
(define method-name ($ activity 'to-method))
(define object ($ activity 'object))
(define to (if send-to send-to ($ activity 'object)))
(list to method-name
#:object ($ activity 'object)
#:actor ($ activity 'actor)
#:target ($ activity 'target)
#:self activity))
```
这将生成我们的消息,包含方法名和一系列关键字参数。然后可以将这些方法定义为普通的 Goblins 方法。如果并非所有关键字参数都是实现该方法行为所必需的,它可以包含 `#:allow-other-keys` 以便忽略其余内容。作为示例,让我们看看 `Collection` 类型,它基本上是一个对象列表。以下是原型中的实现:
```
(define* (^as2:collection bcom parent #:optional [items (make-gset)])
(extend-methods parent
((add #:key object #:allow-other-keys)
(bcom (^as2:collection bcom parent (gset-add items object))))
((remove #:key object #:allow-other-keys)
(bcom (^as2:collection bcom parent (gset-remove items object))))
((move #:key object target #:allow-other-keys)
(define new-items (gset-remove items object))
($ target 'add #:object object)
(bcom (^as2:collection bcom parent new-items)))))
```
我们可以看到它支持 `add`、`remove` 和 `move` 方法。规范 定义 (https://www.w3.org/TR/activitystreams-vocabulary/#dfn-add) 了 add 的行为如下:
> 表示 `actor` 已将 `object` 添加到 `target`。如果未明确指定 `target` 属性,则目标需要由上下文隐式确定。origin 可用于标识 `object` 来源的上下文。
在这种情况下,此方法可能有两个重要的东西,第一个是 `#:object` 关键字,第二个是 `#:target`。由于集合被发送了 `add` 消息,在上面的代码中假设发送者已经确定了集合。那么 `add` 方法只需要关心对象,在这种情况下,它指定 object 为唯一的关键字,并忽略其他所有内容。最后,行为很简单:它将对象添加到集合中。
希望上面的内容展示了如何将这些 ActivityPub 活动转换为 Goblins 消息。这在我们实现 ActivityPub 对象的同时,提供了我们想要的 Goblins 人体工程学设计。
## 更进一步
我实现的原型是一个演示,试图探索 Goblins actor 的 HTTP 接口以及如何在像 Goblins 这样的 actor 框架中实现 ActivityPub。作为共同编写 ActivityPub 并随后开发 Goblins 的人,我曾想象过这种实现可能的样子,但看到它们在实际中运行良好,真是令人非常兴奋。这个演示探索了带有 Goblins actor 的 HTTP 接口以及 ActivityPub。我认为每个方面都有很大的未来工作潜力,我很希望看到 Goblins 被应用于构建网站。Goblins 既可用于构建网站的后端,通过自身处理 HTTP 请求,也可以在浏览器中通过 使用 Hoot (https://spritely.institute/hoot/) 来实现。ActivityPub 实现的许多方面还有待探索,例如,Goblins 的持久化系统非常适合作为我们的数据库。我们可以探索添加联邦功能(Goblins 作为一个分布式框架非常适合实现)。希望将来我们能够在此基础上进行更多实验。
如果你觉得这篇博文有趣,我和 Christine Lemmer-Webber 将在 FOSDEM 2026 上就此发表 演讲 (https://fosdem.org/2026/schedule/event/HVJRNV-how_to_level_up_the_fediverse/)。如果你参会,我们很希望在那里见到你;如果无法参会,视频将在活动结束后不久发布。
## 感谢我们的支持者
您的支持使我们的工作成为可能!如果您喜欢我们所做的事情,请考虑 今天成为 Spritely 的支持者 (https://spritely.institute/donate)
### 钻石级
- Aeva Palecek
- David Anderson
- Holmes Wilson
- Lassi Kiuru
### 黄金级
- Alex Sassmannsh