Webhook 开发者指南
本文档是 GitLab webhooks 的开发者指南。
Webhook 会将 GitLab 中发生的事件或变更的 JSON 数据发送到 webhook 接收器。
使用 webhook 后,当特定变更发生时,系统会主动通知客户,无需轮询 API。
Webhook 流程
以下描述了 webhook 被触发和执行时的高层级流程:
sequenceDiagram
Web or API node->>+Database: 获取 payload 数据
Database-->>-Web or API node: 构建 payload
Note over Web or API node,Database: Webhook 已触发
Web or API node->>Sidekiq: 将 webhook 执行加入队列
Sidekiq->>+Remote webhook receiver: POST webhook payload
Remote webhook receiver-)-Database: 将响应保存到 WebHookLog
Note over Database,Remote webhook receiver: Webhook 已执行
添加新 Webhook
Webhook 采用资源导向设计。例如,“emoji” webhook 在表情符号被授予或撤销时触发。
为资源添加 webhook 支持的步骤:
-
在
web_hooks表中添加新列。新列必须满足:- 为布尔类型
- 不允许为空
- 命名格式为
<resource>_events - 默认值为
false
迁移文件中
#change方法的示例:def change add_column :web_hooks, :emoji_events, :boolean, null: false, default: false end -
在
TriggerableHooks.available_triggers中添加对新 webhook 的支持。 -
根据 webhook 应配置在项目、群组还是 GitLab 实例级别,将其添加到
ProjectHook、GroupHook或SystemHook的triggerable_hooks列表中。
参见 项目、群组和系统 webhook 获取指导。 -
在
app/views/shared/web_hooks/_form.html.haml中为 webhook 设置表单添加新的复选框前端支持。 -
在
TestHooks::ProjectService和/或TestHooks::SystemService中添加对新 webhook 的测试支持。
TestHooks::GroupService无需更新,因为它仅 执行ProjectService。 -
定义 webhook payload。
-
更新 GitLab 以 触发 webhook。
-
添加 webhook 文档。
-
添加 REST API 支持:
- 更新
API::ProjectHooks、API::GroupHooks和/或API::SystemHooks以支持该参数。 - 更新
API::Entities::ProjectHook和/或API::Entities::GroupHook以支持新字段(系统 hook 使用通用API::Entities::Hook)。 - 更新 项目 webhook、群组 webhook 和/或 系统 webhook 的 API 文档。
- 更新
决策:项目、群组和系统 Webhook
使用以下标准决定你的 webhook 应配置在项目、群组还是 GitLab 实例级别:
- 与特定层级资源相关的 webhook 应在该层级可配置。
例如:issue webhook 可在项目级别配置,群组成员关系 webhook 可在群组级别配置,用户登录失败 webhook 可在 GitLab 实例级别配置。 - 可在项目级别配置的 webhook 通常也应支持群组级别配置,因为群组所有者常配置群组 webhook 以接收该群组下所有项目的事件,且群组 webhook 在项目 webhook 触发时会 自动执行。
- 通常,仅项目或群组(或两者)可配置的 webhook,只有在实例管理员明确需要接收时才应支持实例级别配置。
许多现有的项目/群组 webhook 不支持实例级别配置。
仅 EE 版注意事项
群组 webhook 是高级版(Premium)功能。所有与触发群组 webhook 或构建仅群组可配置 webhook payload 相关的代码,必须位于 ee/ 目录。
触发 Webhook
触发项目和群组 Webhook
项目和群组 webhook 通过在项目或群组上调用 #execute_hooks 触发。
#execute_hooks 方法接收:
- 要 POST 到 webhook 接收器的 webhook payload。
- webhook 类型名称。
例如:
project.execute_hooks(payload, :emoji_hooks)当在单个项目或群组上调用 #execute_hooks 时,触发会自动向上冒泡到祖先群组并执行。这允许群组配置为接收其任何子群组或项目事件的 webhook。
当方法调用对象为:
- 项目:除执行该项目配置的同类型 webhook 外,项目群组和祖先群组配置的同类型 webhook 也会 执行。任何配置的实例(系统)同类型 webhook 也会 执行。
- 群组:除执行该群组配置的同类型 webhook 外,祖先群组配置的同类型 webhook 也会 执行。
构建 payload 可能很耗时,因为它通常需要从数据库加载更多记录,因此在触发 webhook 前检查项目的 #has_active_hooks?(群组的类似方法支持在 issue #517890 中跟踪)。
当以下任一条件满足时,方法返回 true:
- 项目或群组(或任何祖先群组)配置了指定类型的 webhook,因此至少应执行一个 webhook。
- 在项目上调用时,为该类型 配置了系统 webhook。
示例:
def execute_emoji_hooks
return unless project.has_active_hooks?(:emoji_hooks)
payload = Gitlab::DataBuilder::Emoji.build(emoji)
project.execute_hooks(payload, :emoji_hooks)
end触发实例(系统)Webhook
当项目 webhook 被触发时,配置了该类型的系统 webhook 会 自动执行。
如果 webhook 不支持项目级别配置,也可通过 SystemHooksService 触发系统 hook。
示例:
SystemHooksService.new.execute_hooks_for(user, :create)需要更新 SystemHooksService 以构建资源数据。
使用精确的 Payload 触发
Webhook payload 必须准确反映事件发生时的数据状态。
需注意避免因竞态条件或并发进程改变数据状态导致的问题,这会导致发送给接收器的 payload 不准确。
实现方法:
- 尽量避免在构建 payload 前重新加载对象,因为其他进程可能已更改其状态。
- 事件发生后立即构建 payload,这样为 payload 加载的任何额外数据也反映事件发生时的状态。
这两点意味着 payload 通常必须在请求内构建,而不能使用 Sidekiq 异步处理。
例外情况是 payload 仅包含不可变数据,但这种情况很少见。
Webhook Payload
Webhook payload 是 POST 到 webhook 接收器的 JSON 数据。
现有 webhook payload 参见 webhook 事件文档。
Payload 中不应包含什么?
敏感数据绝不能包含在 payload 中,包括密钥和非公开用户邮箱(私有邮箱通过 User#hook_attrs 自动脱敏)。
构建 webhook payload 必须 高性能,因此添加到 payload 的每个新属性都必须证明其从数据库检索的开销是合理的。
权衡考虑:让少数客户在收到较小的 webhook 后通过 API 获取对象的部分数据,是否比让所有客户在 payload 中接收该数据更好?
此场景下,构建 webhook 和客户检索额外数据之间存在时间差,延迟可能导致 API 数据和 webhook 数据代表不同时间点的状态。
定义 Payload
对象应定义 #hook_attrs 方法返回 webhook payload 的对象属性。
#hook_attrs 中的属性必须使用静态键定义。方法必须返回特定属性集,而非直接返回 #attributes 或 #as_json 的所有属性,否则模型所有未来属性都会包含在 payload 中(参见 issue 440384)。
Gitlab::DataBuilder:: 下的模块或类应组合完整 payload。完整 payload 通常包含关联对象。
完整 payload 结构参见 payload schema。
示例:
# 对象定义 #hook_attrs:
class Car < ApplicationRecord
def hook_attrs
{
make: make,
color: color
}
end
end
# Gitlab::DataBuilder 模块组合完整 webhook payload:
module Gitlab
module DataBuilder
module Car
extend self
def build(car, action)
{
object_kind: 'car',
action: action,
object_attributes: car.hook_attrs,
driver: car.driver.hook_attrs # 调用关联数据的 #hook_attrs
}
end
end
end
end
# 构建 payload:
Gitlab::DataBuilder::Car.build(car, 'start')Payload Schema
历史上不同类型 webhook 的 payload schema 存在大量不一致性。
未来,除非新类型 webhook 的 payload 因一致性需求(如新 issuable 的 webhook)应模仿现有 schema,否则新 webhook schema 必须遵循以下规则:
- 新 webhook schema 必须包含以下必需属性:
"object_kind":对象类型(小写下划线格式)。示例:"merge_request"。"action":描述刚发生的领域特定动词(现在时)。示例:"create"、"assign"、"update"或"revoke"。这有助于接收器识别和处理对象生命周期不同阶段触发 webhook 时的变更。"object_attributes":包含事件后对象的属性,这些属性由#hook_attrs生成。
- 关联数据必须是 payload 的顶级属性,不能嵌套在
"object_attributes"中。 - 如果 payload 包含 变更属性值记录,必须位于顶级
"changes"对象中。
上述结构的 JSON schema 描述:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "推荐 GitLab webhook payload schema",
"type": "object",
"properties": {
"object_kind": {
"type": "string",
"description": "对象类型(小写下划线格式)。示例:merge_request",
"pattern": "^([a-zA-Z]+(_[a-zA-Z]+)*)$"
},
"action": {
"type": "string",
"description": "对象刚发生的领域特定动词(现在时)。示例:create, revoke",
},
"object_attributes": {
"type": "object",
"description": "事件后对象的属性"
},
"changes": {
"type": "object",
"description": "事件期间变更的可选属性",
"patternProperties": {
".+" : {
"type" : "object",
"properties": {
"previous": {
"description": "事件前的属性值"
},
"current": {
"description": "事件后的属性值"
}
},
"required": ["previous", "current"]
}
}
}
},
"required": ["object_kind", "action", "object_attributes"]
}遵循上述 schema 的虚构 Car 对象的 webhook payload 示例:
{
"object_kind": "car",
"action": "start",
"object_attributes": {
"make": "Toyota",
"color": "grey"
},
"driver": {
"name": "Kaya",
"age": 18
}
}包含变更对象
如果 payload 需包含对象属性变更列表,将 ReportableChanges 模块添加到模型中。
该模块收集从对象加载到后续所有保存操作期间的属性值变更。这在给定请求上下文中对象有多次保存操作且最终 hook 需要访问累积变更(而非最近一次保存的变更)时很有用。
如何将属性变更包含在 payload 中,参见 payload schema。
最小化数据库请求
某些类型的 webhook 在 GitLab.com 上每天触发数百万次。
为 webhook payload 加载额外数据必须高性能,因为我们需要 在请求内构建 payload 而非通过 Sidekiq。在 GitLab.com 上,这也意味着 payload 的额外数据从 PostgreSQL 主库加载,因为 webhook 在数据库写入后触发。
构建 webhook payload 时最小化数据请求的方法:
- 权衡添加额外数据到 payload 的重要性。
- 预加载关联数据避免 N+1 问题。
- 在 测试中 断言构建 payload 的数据库调用次数,避免回归。
可能需要预加载已加载记录的关联数据。此时可使用 ActiveRecord::Associations::Preloader。
如果关联数据仅用于构建 payload,仅在 #has_active_hooks? 检查 通过后预加载该数据。
代码库中的良好实践示例是 Gitlab::DataBuilder::Pipeline。
示例:
# 处理已加载的 issue
issue = Issue.first
# 假设已通过 #has_active_hooks? 检查,现在构建 webhook payload。
# 以下操作会导致 N+1 数据库查询:
# issue.notes.map(&:author).map(&:name)
#
# 改为先预加载关联数据避免 N+1
ActiveRecord::Associations::Preloader.new(records: [issue], associations: { notes: :author }).call;
issue.notes.map(&:author).map(&:name)破坏性变更
我们不能对 webhook payload 进行破坏性变更。
如果 webhook 接收器可能因 payload 变更遇到错误,则该变更为破坏性变更。
只能进行增量变更(添加新属性)。
破坏性变更包括:
- 删除属性
- 重命名属性
"object_kind"属性值的变更"action"属性值的变更
如果 "object_kind" 或 "action" 以外的属性值必须变更(如因功能移除),将值设为 null、{} 或 [] 而非删除属性。
测试
为 DataBuilder 类 编写单元测试时,需断言:
- 使用
QueryRecorder断言数据库请求数量固定。
可通过 QueryRecorder 测量查询数,然后在测试中比较该数值,确保查询数不会在未明确同意的情况下变更。另见 数据预加载。 - payload 包含预期属性。
还需测试 webhook 应触发(或不触发)的场景,断言其正确触发。
变更 QA
可将 webhook URL 配置为 https://webhook.site 提供的地址,以查看 webhook 触发时生成的完整请求头和 payload。
变更审核
除 代码审核的常规审核者 外,webhook 变更还需由 Import & Integrate 组的后端团队成员审核。