Webhook 服务的设计与实现
背景
最近负责的项目有这样一个需求:现在有一个教学管理平台和一个编程平台,需要提供一种接入机制,让编程平台接入教学管理平台,使得编程平台可以利用教学管理平台的能力。
在理想的情况下,教学管理平台的接入机制应该是开放的,对接入的系统并无类型上的要求。
Webhook 可以解决这个问题。Webhook 是一种 Web 开发技术,它能让 Web 应用通过自定义回调的方式进行交互。与通过 API 轮询的方式获取数据的传统方式不同,基于 Webhook 的系统让接收方仅在某些事件发生时才接收到数据,因此 Webhook 也被称为「反向 API」。在基于 Webhook 的系统中,接收方需要提供静态的 URL,也就是自定义的回调,用于接收被触发事件的相关数据。
后端架构
与 Webhook 相关的后端服务如下:
其中,Classroom Service
负责教学管理的业务;Webhook Service
负责 Webhook 的管理;Auth Service
负责用户的认证;Event Service
会存储事件信息,并将事件信息推送到消息队列中;Webhook Worker
会从消息队列中获取事件信息,对 Webhook 相关的事件进行处理。
Webhook Service 的设计
事件类型的表示
在本系统中,事件是由某些资源被执行了某些操作后产生的,比如课程的创建、作业的更新和教师评分的删除。为了区分不同的事件类型,本系统采用 namespace.noun.verb
的命名方法来标识事件,也就是说,事件的类型由三部分组成:命名空间、名词和动词。
部分事件类型如下:
Webhook 的存储
数据库中存储的 Webhook 信息如下。为了保证 Webhook 服务的安全性,Webhook 的属性还包含了密钥,用于生成和验证数字签名。
接口
管理 Webhook 信息的 HTTP 接口:
查询 Webhook 信息的 RPC 接口:
回调 URL 的验证
鉴于用户在创建 Webhook 时需要提供回调 URL,Webhook Service
会对回调 URL 进行检查,以确保收到的回调 URL 确实有监听事件的意向,并在一定程度上防止欺骗攻击。
Webhook Service
在收到创建 Webhook 的请求后,不会马上创建 Webhook,而是先执行检查过程。检查的方法是对回调 URL 执行 HTTP 的 GET 请求,并在请求头中填写 X-Hook-Challenge
字段,内容格式为 creator_id.topic
(用 .
符号连接创建者 ID 和监听的事件,比如 tom.classroom.submission.deleted
)。若 GET 请求能够得到响应,且响应中有 X-Hook-Challenge
字段并与发送的 X-Hook-Challenge
字段一致,则算作检查成功;否则,算作检查失败。只有检查成功,Webhook Service
才会进行 Webhook 的创建。
与此类似,Webhook Service
在收到更新 Webhook 的请求后,也会进行同样的检查过程,以确认 URL 监听事件的意向。
Event Service 的设计
Event Service
主要负责事件信息的存储和推送。其中,事件信息使用 MongoDB 进行存储,而事件信息的推送通过 RabbitMQ 进行。
除此之外,Event Service
还提供了一些查询事件信息的接口。
Webhook Worker 的设计
Event Service
会将事件信息推送到 RabbitMQ 中,而 Webhook Worker
则是负责处理这些的事件信息的服务。在接收到事件信息后,Webhook Worker
会通过 Webhook Service
查询关联的Webhook,并通过 Webhook 的回调 URL 推送事件信息至外部系统。
Webhook Worker
在正常情况下的执行流程如下表所示。Webhook Worker
会在 RabbitMQ 的消息队列中监听消息。当有消息到达后,Webhook Worker
会解析消息的内容,通过 Webhook Service
的 RPC 接口查找与该事件信息相关的 Webhook 列表。通过 Webhook 列表,Webhook Worker
可以解析出回调 URL 列表,并通过 HTTP POST 方法将事件信息推送到外部系统。如果推送成功,则流程结束。
在对回调 URL 发起 HTTP POST 请求时,HTTP 请求体(JSON 格式)中包含了下图中的属性。除了共通属性外,data
字段中还包含了与具体事件类型相关的属性。比如,对于 classroom.submission.created
(学生提交作业)事件类型,data
字段中包含了作业信息和学生信息等属性。
Webhook 服务的重试机制
在理想的情况下,对回调 URL 发起 HTTP POST 请求后,Webhook Worker
会收到外部系统的确认信息,并结束事件信息的推送流程;而在现实情况中,由于网络故障等问题,外部系统可能会处于不可用的状态。如果本系统没有提供重试机制,外部系统就可能会丢失重要的事件信息。
在本系统中,判断是否需要重试的标准有两个。首先是判断接收方是否在规定的时间(5秒)内返回响应,若超时,则进行重试。如果 Webhook Worker
能在规定的时间内收到响应,则对 HTTP 响应的状态码进行检查。如果 HTTP 状态码为 2xx
或 410
,则不进行重试,其余情况都需要重试。
然而,重试操作不会一直持续。在本系统中,重试次数最多为78次。重试次数和时间的关系可见下表。在重试次数较少时,重试的时间间隔呈指数规律;从第7次重试开始,重试的时间间隔为60分钟。如果重试次数到达上限,则消息不会再推送到外部系统,因此整个重试阶段的时间上限约为72小时。如果重试阶段停止,外部系统仍可通过 Event Service
的 HTTP 接口查询事件信息。
在具体的实现上,RabbitMQ 中划分了两类队列:事件队列和重试队列。Webhook Worker
会监听这两类队列,并对消息进行处理。首先,Event Service
会将系统中的事件信息发送至事件队列。在对事件队列里的信息进行处理后,如果需要重试,则 Webhook Worker
会将重试信息发送至重试队列。通过 RabbitMQ 的延时消息机制,Webhook Worker
会在特定的时间后收到重试队列中的消息,并重试尝试处理事件信息。
就队列中的数据而言,事件队列存放的是事件相关的信息;而重试队列除了存放事件信息以外,还包含了回调 URL 和重试次数序号。借助 RabbitMQ 的消息队列,Event Service
和 Webhook Worker
两个服务得到了解耦;同时,事件信息和重试信息不会因为 Webhook Worker
停止运行而丢失,系统的健壮性得到了提升。
Webhook 服务的安全性
上文说到,本系统会对外部系统的回调 URL 进行验证,这属于发送方对接收方的一种验证。同样的,为保证系统的安全性,接收方也需要对发送方进行验证。在 Webhook Worker
对回调 URL 发起 HTTP POST 请求时,与 Webhook 相关的请求头字段信息下表所示。其中,X-Hook-Signature
字段就是用于接收方对发送方进行验证的。
X-Hook-Signature
字段由三个部分组成:
-
哈希函数的前缀,有以下三种可能:
-
sha1(对应 SHA-1 哈希函数)
-
sha256(对应 SHA-256 哈希函数)
-
sha512(对应 SHA-512 哈希函数)
-
-
=
(等号) -
数字签名(十六进制编码的哈希值),生成过程如下:
-
拼接以下三者,结果记为
Message
:-
发起请求时的时间戳(与
X-Hook-Timestamp
字段一致) -
.
(英文句号) -
HTTP 请求体
-
-
基于哈希函数前缀选择对应的哈希函数,以 Webhook 信息中的 Secret 属性(见上文)为密钥,对
Message
执行 HMAC 算法,记结果为MAC
。 -
将
MAC
进行十六进制编码,得到最终的数字签名。
-
接收方在收到 POST 请求后,可对 HTTP 请求头中的 X-Hook-Signature
字段进行解析。首先根据 X-Hook-Signature
字段的第一个部分选择哈希函数,然后依据 HTTP 头中的 X-Hook-Timestamp
字段和 HTTP 请求体,运用同样的方法生成数字签名。最后,将生成的数字签名与 X-Hook-Signature
字段的第三部分进行比对,若两者不一致,则说明接收到的请求不可信任,需要忽略。
由于数字签名的生成过程包含了时间戳,所以接收方可以在一定程度上抵抗重放攻击。如果攻击者截获了接收方收到的 POST 请求并进行重发,那么攻击者就不能修改请求头中的时间戳(X-Hook-Timestamp
字段),因为这样会使数字签名失效。因此,在确认数字签名无误后,接收方可通过时间戳进一步对比请求发出的时间和当前时间的间隔。如果间隔时间过长,超过了可容忍的范围,接收方可考虑拒绝收到的请求。
外部系统的接入
Webhook 机制的存在,使得教学管理平台的开放性有了很大的提升。首先,外部系统可以建立接受事件信息的 API,并向教学管理平台提供回调 URL。在监听的事件被触发时,外部系统可以得知相关资源的状态变化,以完成进一步的操作。另外,外部系统可以借助教学管理平台的开放接口,对相关资源的状态进行查询或修改。