Skip to content
0

WebHook

WebHook 用于服务端之间的通信(常用于微服务),也被称为反向 API

简单来说,WebHook 就是一个接受 HTTP 的 URL,一个实现了 WebHook 的 API 提供商会在事件发生的时候,会向这个配置好的 URL 发送一条信息(发送 HTTP 请求)

在传统方法中,客户端从服务器请求数据,然后服务器提供给客户端数据(客户端是在拉数据),在 Webhook 范式下,服务器更新所需提供的资源,然后自动将其作为更新发送到客户端(服务器是在推数据)

TIP

WebHook 常用于微服务之间,而不是浏览器和服务器(因为服务器无法直接推送数据到浏览器,而微服务中,服务器可以推送数据到服务器)

Github WebHook

我们以 Github Webhooks 举个例子,看看 WebHook 的常见的应用场景

需求:要在 Github 仓库主分支收到更新的时候,我们的服务器拉取仓库的最新代码并自动编译部署

考虑最笨的方法,就是我们的服务器不断请求 Github 的服务器,询问目标仓库是否有更新,从而来决定重新拉取编译部署等动作

但是这样的方式显然是非常不理想的,浪费服务器资源且更新也不够及时。所以我们需要 GitHub 仓库更新的时候,就向外通知消息,而且我们能及时拿到这个消息

解决方式

实际上 GitHub 已经实现了这个功能,仓库更新的时候,会向一个 URL 进行通知,这个 URL 就叫做 Webhook,默认是没有的,需要我们进行配置

我们在目标仓库的 Settings -> Webhooks -> Add webhooks 可以添加 WebHook:

Content type 是消息的类型,Secret 是自定义的密钥,用于确保这个请求的客户端来自 Github,而不是恶意请求的用户

添加我们的 Payload URL,也就是 webhook 的地址

我们使用 h3 举个例子,创建 NodeJs 服务端,并且具有鉴权功能:

import { createApp, createRouter, eventHandler, getHeaders, toNodeListener, readBody, RequestHeaders, sendNoContent } from "h3";
import { createServer } from "http";
import * as crypto from "crypto";

const app = createApp()

const router = createRouter()
  .post(
    '/',
    eventHandler(async (event) => {
      const headers = getHeaders(event)
      const body =  await readBody(event)
      if(!validate(headers, body)) {
        return sendNoContent(event, 401)
      }
      console.log('Headers', headers)
      console.log('Body', body)
      return 'Hello, WebHooks'
    })
  )

app.use(router)

createServer(toNodeListener(app)).listen(3000)

const WEBHOOK_SECRET = "It's a Secret to EveryBody"

function validate(headers: RequestHeaders, body: any) {
  if(!headers["x-hub-signature-256"]) return
  const signature = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(JSON.stringify(body))
    .digest("hex")
  
  const trusted = Buffer.from(`sha256=${signature}`, 'ascii')
  const untrusted =  Buffer.from(headers["x-hub-signature-256"], 'ascii')
  return crypto.timingSafeEqual(trusted, untrusted)
}

启动 NodeJs 服务

$ tsx index.ts

并且使用 untun 进行局域网穿透,将本地服务代理到外网,用于我们临时的测试:

$ untun tunnel http://localhost:5173
 Starting cloudflared tunnel to http://localhost:5173
 Waiting for tunnel URL...
 Tunnel ready at https://caroline-screen-peer-passes.trycloudflare.com

得到临时地址 https://sorry-expressions-fresh-documents.trycloudflare.com,填入我们的 Payload URl 中

并且填一个 Secret 字符串,也就是上面代码中的 WEBHOOK_SECRET 的值,用于 Github 服务端进行加密(确保 Secret 只有自己知道)

当我们用 Git 推送最新代码到 Github 仓库中的时候,我们的 NodeJs 服务就会输出如下内容,包括但不限于这次提交新增、更新、删除了哪些文件,以及目标仓库相关的各种信息

查看详情
Headers {
  host: 'sorry-expressions-fresh-documents.trycloudflare.com',
  'user-agent': 'GitHub-Hookshot/5ed4b81',
  'content-length': '7315',
  accept: '*/*',
  'accept-encoding': 'gzip',
  'cdn-loop': 'cloudflare; subreqs=1',
  'cf-connecting-ip': '140.82.115.109',
  'cf-ew-via': '15',
  'cf-ipcountry': 'US',
  'cf-ray': '811d31e262300735-IAD',
  'cf-visitor': '{"scheme":"https"}',
  'cf-warp-tag-id': '93586ddd-4af2-45e4-9b82-74d5ff3f062c',
  'cf-worker': 'trycloudflare.com',
  connection: 'keep-alive',
  'content-type': 'application/json',
  'x-forwarded-for': '140.82.115.109',
  'x-forwarded-proto': 'https',
  'x-github-delivery': '875722c8-6432-11ee-9b68-a23d1bf2a3fe',
  'x-github-event': 'push',
  'x-github-hook-id': '436870675',
  'x-github-hook-installation-target-id': '669854908',
  'x-github-hook-installation-target-type': 'repository',
  'x-hub-signature': 'sha1=ff19c9f9b6daaf8e99c61f76faf4775c517a165c',
  'x-hub-signature-256': 'sha256=cd5c97115aa7561a99598e9908564e0ae72226386c2c837b96913e205c4d4f13'
}
Body {
  ref: 'refs/heads/main',
  before: '7f93ad58f39a963358cc4a02a39de9865e6b5953',
  after: 'ca9991acca7b94986654d4044c08dc2d89144e26',
  repository: {
    id: 669854908,
    node_id: 'R_kgDOJ-0svA',
    name: 'fe-book',
    full_name: 'peterroe/fe-book',
    private: true,
    owner: {
      name: 'peterroe',
      email: '59404696+peterroe@users.noreply.github.com',
      login: 'peterroe',
      id: 59404696,
      node_id: 'MDQ6VXNlcjU5NDA0Njk2',
      avatar_url: 'https://avatars.githubusercontent.com/u/59404696?v=4',
      gravatar_id: '',
      url: 'https://api.github.com/users/peterroe',
      html_url: 'https://github.com/peterroe',
      followers_url: 'https://api.github.com/users/peterroe/followers',
      following_url: 'https://api.github.com/users/peterroe/following{/other_user}',
      gists_url: 'https://api.github.com/users/peterroe/gists{/gist_id}',
      starred_url: 'https://api.github.com/users/peterroe/starred{/owner}{/repo}',
      subscriptions_url: 'https://api.github.com/users/peterroe/subscriptions',
      organizations_url: 'https://api.github.com/users/peterroe/orgs',
      repos_url: 'https://api.github.com/users/peterroe/repos',
      events_url: 'https://api.github.com/users/peterroe/events{/privacy}',
      received_events_url: 'https://api.github.com/users/peterroe/received_events',
      type: 'User',
      site_admin: false
    },
    html_url: 'https://github.com/peterroe/fe-book',
    description: null,
    fork: false,
    url: 'https://github.com/peterroe/fe-book',
    forks_url: 'https://api.github.com/repos/peterroe/fe-book/forks',
    keys_url: 'https://api.github.com/repos/peterroe/fe-book/keys{/key_id}',
    collaborators_url: 'https://api.github.com/repos/peterroe/fe-book/collaborators{/collaborator}',
    teams_url: 'https://api.github.com/repos/peterroe/fe-book/teams',
    hooks_url: 'https://api.github.com/repos/peterroe/fe-book/hooks',
    issue_events_url: 'https://api.github.com/repos/peterroe/fe-book/issues/events{/number}',
    events_url: 'https://api.github.com/repos/peterroe/fe-book/events',
    assignees_url: 'https://api.github.com/repos/peterroe/fe-book/assignees{/user}',
    branches_url: 'https://api.github.com/repos/peterroe/fe-book/branches{/branch}',
    tags_url: 'https://api.github.com/repos/peterroe/fe-book/tags',
    blobs_url: 'https://api.github.com/repos/peterroe/fe-book/git/blobs{/sha}',
    git_tags_url: 'https://api.github.com/repos/peterroe/fe-book/git/tags{/sha}',
    git_refs_url: 'https://api.github.com/repos/peterroe/fe-book/git/refs{/sha}',
    trees_url: 'https://api.github.com/repos/peterroe/fe-book/git/trees{/sha}',
    statuses_url: 'https://api.github.com/repos/peterroe/fe-book/statuses/{sha}',
    languages_url: 'https://api.github.com/repos/peterroe/fe-book/languages',
    stargazers_url: 'https://api.github.com/repos/peterroe/fe-book/stargazers',
    contributors_url: 'https://api.github.com/repos/peterroe/fe-book/contributors',
    subscribers_url: 'https://api.github.com/repos/peterroe/fe-book/subscribers',
    subscription_url: 'https://api.github.com/repos/peterroe/fe-book/subscription',
    commits_url: 'https://api.github.com/repos/peterroe/fe-book/commits{/sha}',
    git_commits_url: 'https://api.github.com/repos/peterroe/fe-book/git/commits{/sha}',
    comments_url: 'https://api.github.com/repos/peterroe/fe-book/comments{/number}',
    issue_comment_url: 'https://api.github.com/repos/peterroe/fe-book/issues/comments{/number}',
    contents_url: 'https://api.github.com/repos/peterroe/fe-book/contents/{+path}',
    compare_url: 'https://api.github.com/repos/peterroe/fe-book/compare/{base}...{head}',
    merges_url: 'https://api.github.com/repos/peterroe/fe-book/merges',
    archive_url: 'https://api.github.com/repos/peterroe/fe-book/{archive_format}{/ref}',
    downloads_url: 'https://api.github.com/repos/peterroe/fe-book/downloads',
    issues_url: 'https://api.github.com/repos/peterroe/fe-book/issues{/number}',
    pulls_url: 'https://api.github.com/repos/peterroe/fe-book/pulls{/number}',
    milestones_url: 'https://api.github.com/repos/peterroe/fe-book/milestones{/number}',
    notifications_url: 'https://api.github.com/repos/peterroe/fe-book/notifications{?since,all,participating}',
    labels_url: 'https://api.github.com/repos/peterroe/fe-book/labels{/name}',
    releases_url: 'https://api.github.com/repos/peterroe/fe-book/releases{/id}',
    deployments_url: 'https://api.github.com/repos/peterroe/fe-book/deployments',
    created_at: 1690130452,
    updated_at: '2023-10-06T09:57:12Z',
    pushed_at: 1696587867,
    git_url: 'git://github.com/peterroe/fe-book.git',
    ssh_url: 'git@github.com:peterroe/fe-book.git',
    clone_url: 'https://github.com/peterroe/fe-book.git',
    svn_url: 'https://github.com/peterroe/fe-book',
    homepage: 'https://full-stack-liart.vercel.app',
    size: 6026,
    stargazers_count: 0,
    watchers_count: 0,
    language: 'TypeScript',
    has_issues: true,
    has_projects: true,
    has_downloads: true,
    has_wiki: false,
    has_pages: false,
    has_discussions: false,
    forks_count: 0,
    mirror_url: null,
    archived: false,
    disabled: false,
    open_issues_count: 0,
    license: {
      key: 'mit',
      name: 'MIT License',
      spdx_id: 'MIT',
      url: 'https://api.github.com/licenses/mit',
      node_id: 'MDc6TGljZW5zZTEz'
    },
    allow_forking: true,
    is_template: false,
    web_commit_signoff_required: false,
    topics: [],
    visibility: 'private',
    forks: 0,
    open_issues: 0,
    watchers: 0,
    default_branch: 'main',
    stargazers: 0,
    master_branch: 'main'
  },
  pusher: {
    name: 'peterroe',
    email: '59404696+peterroe@users.noreply.github.com'
  },
  sender: {
    login: 'peterroe',
    id: 59404696,
    node_id: 'MDQ6VXNlcjU5NDA0Njk2',
    avatar_url: 'https://avatars.githubusercontent.com/u/59404696?v=4',
    gravatar_id: '',
    url: 'https://api.github.com/users/peterroe',
    html_url: 'https://github.com/peterroe',
    followers_url: 'https://api.github.com/users/peterroe/followers',
    following_url: 'https://api.github.com/users/peterroe/following{/other_user}',
    gists_url: 'https://api.github.com/users/peterroe/gists{/gist_id}',
    starred_url: 'https://api.github.com/users/peterroe/starred{/owner}{/repo}',
    subscriptions_url: 'https://api.github.com/users/peterroe/subscriptions',
    organizations_url: 'https://api.github.com/users/peterroe/orgs',
    repos_url: 'https://api.github.com/users/peterroe/repos',
    events_url: 'https://api.github.com/users/peterroe/events{/privacy}',
    received_events_url: 'https://api.github.com/users/peterroe/received_events',
    type: 'User',
    site_admin: false
  },
  created: false,
  deleted: false,
  forced: false,
  base_ref: null,
  compare: 'https://github.com/peterroe/fe-book/compare/7f93ad58f39a...ca9991acca7b',
  commits: [
    {
      id: 'ca9991acca7b94986654d4044c08dc2d89144e26',
      tree_id: '5a84b1f14e6e979808e64855d5c946c2bf737967',
      distinct: true,
      message: 'wip: write',
      timestamp: '2023-10-06T18:24:22+08:00',
      url: 'https://github.com/peterroe/fe-book/commit/ca9991acca7b94986654d4044c08dc2d89144e26',
      author: [Object],
      committer: [Object],
      added: [],
      removed: [],
      modified: [Array]
    }
  ],
  head_commit: {
    id: 'ca9991acca7b94986654d4044c08dc2d89144e26',
    tree_id: '5a84b1f14e6e979808e64855d5c946c2bf737967',
    distinct: true,
    message: 'wip: write',
    timestamp: '2023-10-06T18:24:22+08:00',
    url: 'https://github.com/peterroe/fe-book/commit/ca9991acca7b94986654d4044c08dc2d89144e26',
    author: {
      name: 'peterroe',
      email: 'hi@peterroe.me',
      username: 'peterroe'
    },
    committer: {
      name: 'peterroe',
      email: 'hi@peterroe.me',
      username: 'peterroe'
    },
    added: [ '...' ],
    removed: [ '...' ],
    modified: ['...']
  }
}

设置了 Secret 之后,请求头中将会携带 x-hub-signaturex-hub-signature-256 ,用于我们鉴权

上面展示了 GitHub WebHook 的基本用法,结合 untun,进行了简单的实践,如果想要实际使用这个功能,却又没有云服务器,可以尝试使用 Netlify Functions 来提供 WebHook,进行例如邮件通知等任何 Serveless 能实现的功能

比较 Polling

相比于传统的 Polling 询问方式,WebHook 能够在准确的时机通知到目标服务

TIP

许多人说上面不就是普通的 REST API 吗?为什么引入 WebHooks 概念?

虽然 WebHooks 通常也是以 REST API 被调用。但 WebHooks 是允许被动态设置的,这就是为什么它名字中有个 hook。而传统的方式中,接口定义都是以硬编码方式写在代码中的

上面 Tell me when my data is ready 相当于我们设置 WebHook 的阶段,对于上面的 Github WebHook 示例而言就是我们在页面中手动填写的过程,这个过程也可以是动态的,我们可以通过 GitHub API ,使用 JS 来动态创建 WebHook

Here is your data 就是 WebHook 被调用的阶段

Released under the MIT License.