Webhooks 开发者指南
此页面是 极狐GitLab webhook 的开发者指南。
Webhook 会将极狐GitLab 中发生的事件或变更的 JSON 数据 POST 到 webhook 接收器。 使用 webhook,客户可以在特定变更发生时收到通知,而无需轮询 API。
Webhook 流程
以下是触发和执行 webhook 时发生过程的高层描述。
Rendering chart...
添加新 webhook
Webhook 是面向资源的。例如,每当表情被授予或撤销时,都会触发 "emoji" webhook。
要为资源添加 webhook 支持:
-
在 web_hooks 表中添加一个新列。新列必须:
- 布尔类型
- 不为空
- 的格式命名为 <resource>_events
- 默认值为 false。
迁移中 #change 方法的示例:
rubydef change add_column :web_hooks, :emoji_events, :boolean, null: false, default: false end -
将新 webhook 的支持添加到 TriggerableHooks.available_triggers。
-
将 triggerable_hooks 添加到 ProjectHook、GroupHook 或 SystemHook 的列表中,具体取决于该 webhook 是否应对项目、群组或极狐GitLab 实例进行配置。有关指导,请参见项目、群组和系统 webhook。
-
在 app/views/shared/web_hooks/_form.html.haml 中的 webhook 设置表单中为新增的复选框添加前端支持。
-
在 TestHooks::ProjectService 和 TestHooks::SystemService 中添加测试新 webhook 的支持。TestHooks::GroupService 无需更新,因为它仅执行 ProjectService。
-
定义 webhook 负载。
-
更新极狐GitLab 以触发 webhook。
-
添加webhook 文档。
-
添加 REST API 支持:
- 更新 API::ProjectHooks、API::GroupHooks 和 API::SystemHooks 以支持相应参数。
- 更新 API::Entities::ProjectHook 和 API::Entities::GroupHook 以支持新字段。(系统 hook 使用通用的 API::Entities::Hook)。
- 更新 项目 webhook、群组 webhook 和 系统 hook 的 API 文档。
决策:项目、群组和系统 webhook
使用以下内容帮助你决定 webhook 应该针对项目、群组还是极狐GitLab 实例进行配置。
- 与属于相应层级资源相关的 webhook 应在该层级进行配置。示例:议题 webhook 针对项目可配置,群组成员 webhook 针对群组可配置,用户登录失败 webhook 针对极狐GitLab 实例可配置。
- 可针对项目配置的 webhook 通常也应针对群组配置,因为群组 webhook 通常由群组所有者配置,用于接收该群组内项目中发生的任何事件,并且当项目 webhook 触发时,群组 webhook 会自动执行。
- 通常,仅当有明确的功能需求让实例管理员接收时,才应将可针对项目或群组(或两者)配置的 webhook 设为实例级可配置。许多当前的项目和群组 webhook 在实例级别不可配置。
EE 专属注意事项
群组 webhook 是一项专业版功能。所有与触发群组 webhook 或仅为群组可配置的 webhook 构建负载相关的代码,都必须位于 ee/ 目录。
触发 webhook
触发项目和群组 webhook
项目与群组 webhook 通过在项目或群组上调用 #execute_hooks 来触发。
传递给 #execute_hooks 方法的参数:
- 准备 POST 到 webhook 接收器的 webhook 负载。
- webhook 类型名称。
例如:
rubyproject.execute_hooks(payload, :emoji_hooks)
当在单个项目或群组上调用 #execute_hooks 时,触发会自动向上冒泡到祖先群组,它们也会执行。这使群组能够被配置为接收在其任何子群组或项目中发生的事件 webhook。
当该方法在以下对象上调用时:
- 项目,除了执行该项目为该类型配置的任何 webhook 外,为项目的群组和祖先群组配置的该类型 webhook 也会执行。任何为该类型配置的实例(系统)webhook 也会执行。
- 群组,除了执行该群组为该类型配置的任何 webhook 外,为该群组的祖先群组配置的 webhook 也会执行。
构建负载可能开销较大,因为它通常需要从数据库加载更多记录,因此在触发 webhook 之前请检查项目上的 #has_active_hooks?(对群组类似方法的支持在议题 #517890 中跟踪)。
该方法在以下任一情况下返回 true:
- 项目或群组,或其任何祖先群组,已为该类型配置了 webhook,因此至少应执行一个 webhook。
- 在项目上调用时,为该类型配置了系统 webhook。
示例:
ruby1def execute_emoji_hooks 2 return unless project.has_active_hooks?(:emoji_hooks) 3 4 payload = Gitlab::DataBuilder::Emoji.build(emoji) 5 project.execute_hooks(payload, :emoji_hooks) 6end
触发实例(系统)webhook
当触发项目 webhook 时,为该 webhook 类型配置的系统 webhook 会自动执行。
如果 webhook 不可针对项目进行配置,你也可以通过 SystemHooksService 触发系统 hook。
示例:
rubySystemHooksService.new.execute_hooks_for(user, :create)
你需要更新 SystemHooksService 使其为资源构建数据。
使用准确负载进行触发
Webhook 负载必须准确反映事件发生时的数据状态。应小心避免因竞态条件或并发进程改变数据状态而导致将不准确的负载发送到 webhook 接收器的问题。
一些实现此目标的技巧:
- 尽量避免在构建负载前重新加载对象,因为另一个进程可能已更改其状态。
- 在事件发生后立即构建负载,这样为负载加载的任何额外数据也能反映事件发生时的状态。
这两点都意味着负载通常必须在请求内构建,而不是通过 Sidekiq 异步构建。
例外情况是负载始终只包含不可变数据,但这通常不成立。
Webhook 负载
Webhook 负载是 POST 到 webhook 接收器的 JSON 数据。
请参阅 webhook 事件文档 中记录的现有 webhook 负载。
哪些内容不应包含在 webhook 负载中?
绝不应在 webhook 负载中包含敏感数据。这包括密钥和非公开用户电子邮件(私有用户电子邮件通过 User#hook_attrs 自动屏蔽)。
构建 webhook 负载必须具有极高性能,因此添加到 webhook 负载的每个新属性都必须权衡从数据库检索它的开销。
权衡一下,是让少数客户在收到较小的 webhook 后从 API 获取对象的某些数据更好,还是让所有客户都在 webhook 负载中收到这些数据更好。在这种情况下,从构建 webhook 到客户检索额外数据之间存在时间差。延迟可能导致 API 数据和 webhook 数据表示不同时间点的状态。
定义负载
对象应定义 #hook_attrs 方法来返回 webhook 负载的对象属性。
#hook_attrs 中的属性必须用静态键定义。该方法必须返回一组特定的属性,而不仅仅是 #attributes 或 #as_json 返回的属性。否则,模型的所有未来属性都将包含在 webhook 负载中(参见议题 #440384)。
Gitlab::DataBuilder:: 中的模块或类应组合完整的负载。通常,完整负载包括关联对象。
有关完整负载的结构,请参见负载模式。
例如:
ruby1# An object defines #hook_attrs: 2class Car < ApplicationRecord 3 def hook_attrs 4 { 5 make: make, 6 color: color 7 } 8 end 9end 10 11# A Gitlab::DataBuilder module or class composes the full webhook payload: 12module Gitlab 13 module DataBuilder 14 module Car 15 extend self 16 17 def build(car, action) 18 { 19 object_kind: 'car', 20 action: action, 21 object_attributes: car.hook_attrs, 22 driver: car.driver.hook_attrs # Calling #hook_attrs on associated data 23 } 24 end 25 end 26 end 27end 28 29# Building the payload: 30Gitlab::DataBuilder::Car.build(car, 'start')
负载模式
历史上,不同类型 webhook 的负载模式之间存在很大不一致。
展望未来,除非出于一致性原因(例如,新议题类型的 webhook),新的 webhook 类型负载应类似于现有负载,否则新 webhook 的模式必须遵循以下规则:
- 新 webhook 的模式必须包含以下必需属性:
- "object_kind",蛇形命名法的对象类型。示例:"merge_request"。
- "action",一个特定领域的动词,表示刚刚发生的事情,使用现在时态。示例:"create"、"assign"、"update" 或 "revoke"。这有助于接收器识别和处理在对象生命周期不同点触发 webhook 时发生的不同类型的变更。
- "object_attributes",包含事件发生后对象的属性。这些属性由 #hook_attrs 生成。
- 关联数据在负载中必须是顶层的,不能嵌套在 "object_attributes" 中。
- 如果负载包含变更属性值的记录,这些必须放在顶层的 "changes" 对象中。
描述上述内容的 JSON 模式:
json1{ 2 "$schema": "http://json-schema.org/draft-07/schema#", 3 "description": "Recommended GitLab webhook payload schema", 4 "type": "object", 5 "properties": { 6 "object_kind": { 7 "type": "string", 8 "description": "Kind of object in snake case. Example: merge_request", 9 "pattern": "^([a-zA-Z]+(_[a-zA-Z]+)*)$" 10 }, 11 "action": { 12 "type": "string", 13 "description": "A domain-specific verb of what just happened to the object, using present tense. Examples: create, revoke", 14 }, 15 "object_attributes": { 16 "type": "object", 17 "description": "Attributes of the object after the event" 18 }, 19 "changes": { 20 "type": "object", 21 "description": "Optional object attributes that were changed during the event", 22 "patternProperties": { 23 ".+" : { 24 "type" : "object", 25 "properties": { 26 "previous": { 27 "description": "Value of attribute before the event" 28 }, 29 "current": { 30 "description": "Value of attribute after the event" 31 } 32 }, 33 "required": ["previous", "current"] 34 } 35 } 36 } 37 }, 38 "required": ["object_kind", "action", "object_attributes"] 39}
一个遵循上述负载模式的虚构 Car 对象的 webhook 负载示例:
json1{ 2 "object_kind": "car", 3 "action": "start", 4 "object_attributes": { 5 "make": "Toyota", 6 "color": "grey" 7 }, 8 "driver": { 9 "name": "Kaya", 10 "age": 18 11 } 12}
包含变更对象
如果负载应包含对象属性变更列表,请将 ReportableChanges 模块添加到模型中。该模块收集从对象加载开始到所有后续保存过程中属性值的所有变更。这在给定请求上下文中对象存在多次保存操作且最终钩子需要访问累积增量(而不仅仅是最近一次保存)的情况下非常有用。
有关如何在负载中包含属性变更,请参见负载模式。
最小化数据库请求
某些类型的 webhook 在 JihuLab.com 上每天被触发数百万次。
为 webhook 负载加载额外数据必须高性能,因为我们需要在请求内构建负载而不是在 Sidekiq 上。在 JihuLab.com 上,这也意味着负载的额外数据是从 PostgreSQL 主库加载的,因为 webhook 是在数据库写入之后触发的。
为了在构建 webhook 负载时最小化数据请求:
你可能需要在已加载的记录上预加载数据。这种情况下,可以使用 ActiveRecord::Associations::Preloader。
如果关联数据仅用于构建 webhook 负载,则仅在 #has_active_hooks? 检查通过后才预加载这些关联数据。
我们代码库中一个很好的工作示例是 Gitlab::DataBuilder::Pipeline。
例如:
ruby1# Working with an issue that has been loaded 2issue = Issue.first 3 4# Imagine we have performed the #has_active_hooks? check and now are building the webhook payload. 5# Doing this will perform N+1 database queries: 6# issue.notes.map(&:author).map(&:name) 7# 8# Instead, first preload the associations to avoid the N+1 9ActiveRecord::Associations::Preloader.new(records: [issue], associations: { notes: :author }).call; 10issue.notes.map(&:author).map(&:name)
破坏性变更
我们不能对 webhook 负载进行破坏性变更。
如果 webhook 接收器可能因 webhook 负载的变更而遇到错误,则该变更是破坏性的。
只能进行添加性变更,即添加新的属性。
破坏性变更包括:
- 移除属性。
- 重命名属性。
- 更改 "object_kind" 属性的值。
- 更改 "action" 属性的值。
如果必须更改 "object_kind" 或 "action" 以外属性的值,例如由于功能移除,请将值设置为 null、{} 或 [],而不是移除该属性。
测试
在为 DataBuilder 类编写单元测试时,断言:
- 使用 QueryRecorder 断言数据库请求次数固定。你可以通过使用 QueryRecorder 测量查询次数,然后在 spec 中与该次数进行比较,以确保查询计数不会在我们没有意识的情况下发生变化。另请参见关联数据的预加载。
- 负载具有预期属性。
还要测试应该触发(或不触发)webhook 的场景,以断言它能正确触发。
质量保证变更
你可以将 webhook URL 配置为 https://webhook.site 提供的地址,以查看触发 webhook 时生成的完整 webhook 标头和负载。
使变更获得审核
除了 代码审核的常规审核者 外,webhook 的变更还应该由来自 Import & Integrate 的后端团队成员进行审核。