使用 Node.js 实现 Webhook 的后端处理

关于 Webhook 是什么,我已经在《腾讯云 CDN 强制刷新 API 的使用》一文的最后解释过了。现在,我们就来使用 Node.js 实现一个最简单的 Webhook 服务器。这里记录了从环境搭建开始的每一步流程,直接看最终的代码请点击这里

0. 为什么是 Node.js

实话实说,在做本文所做的事情之前,我并没有实际接触过 Node.js(甚至 Javascript 语言都很少碰),选择它自然是因为搜到的 Webhook 实现教程基本都是用的它。现在想来,这也体现了 Node.js 作为一种可以将前后端一起实现的编程语言的优势。

据我自己目前浅薄的理解,简而言之:想要直接处理 Webhook 所发送的 HTTP 协议请求(request)——一般是 Get 或者 Post 方式(method),就得具备控制服务器底层逻辑的能力。一般服务器常用的 Nginx 或者 Apache 再加 PHP 的架构决定了它的请求响应方式是前后端分离的,想要实现“if (收到 Get 请求) then (执行一段脚本)”这样的逻辑总是不那么容易的。而 Node.js,人家本来就是用来写服务器的。也就是说,你可以不需要什么 LEMP/LAMP 之类的东西,直接从头写一个自己的服务器结构;而这,需要仅仅是一个 Node.js 的开发环境和一个 js 脚本文件而已!

1. Linux 开发环境配置

Ubuntu 系统下,直接使用 apt-get:

1
2
sudo apt-get install nodejs
sudo apt-get install npm

就可以了。

CentOS 中似乎没有那么简单的方法,不过也不复杂。我们直接下载编译好的二进制文件:

1
2
3
4
5
cd /root                    # 放在 root 用户根目录下好了
wget https://nodejs.org/dist/v12.16.1/node-v12.16.1-linux-x64.tar.xz
tar xf  node-v12.16.1-linux-x64.tar.xz  # 解压
cd node-v12.16.1-linux-x64/              # 进入解压目录
./bin/node -v               # 执行node命令 查看版本

有点像 Windows 的“绿色软件”,直接就可以运行了。当然,为了能在任意目录下运行 node 命令,我们还需要设置一下软链接:

1
2
3
mv /root/node-v12.16.1-linux-x64 /root/nodejs    # 改个短点的名字
ln -s /root/nodejs/bin/npm   /usr/local/bin/
ln -s /root/nodejs/bin/node   /usr/local/bin/

这样就把 nodejs 和配套的包管理软件 npm 都装好了。在国内的 VPS 上,为了让以后 npm 的下载不至于龟速,我们可以把软件源切换到国内的淘宝源:

1
2
3
npm config set registry https://registry.npm.taobao.org
npm config get registry # 看下是否成功
# 还原的话:npm config set registry https://registry.npmjs.org/

2. 用得到的 Node.js 基础知识

Node.js 是一种 javascript 的运行环境,能够使得 javascript 脱离浏览器运行(因为平常意义下的 js 一般是加载于 HTML 中,用来改变网页内容的)。或者,用官方比较精炼但晦涩的语言讲:

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。Node.js 使用了一个事件驱动非阻塞式 I/O 的模型,使其轻量又高效。

好吧,对于初学者来说,这话是不太可能看懂的。没关系,留意上面加粗的两个特点就好。

2.1 知其所以然所需的几个概念

(仅仅要快速知其然可以跳过这一段)

  1. 函数参数

    JS 中函数的定义如下:

    1
    2
    3
     function execute(someFunction, value) {
         //do sth.
     }
    

    值得注意的是:

    在 JavaScript 中,一个函数可以作为另一个函数的参数。我们可以先定义一个函数,然后传递,也可以在传递参数的地方直接定义函数。

    这里传递的不是函数的返回值,而是函数本身1

    上面的例子中,execute 函数的第一个参数就是另一个函数。也就是说,我们可以直接在这个函数体中用形参函数名的形式调用 someFunction 函数:someFunction(value);

    至于 someFunction 这个形参所代表的实际的函数又是什么,则是在调用 execute 函数时传入的。

    完整的过程:

    1
    2
    3
    4
    5
    6
    7
    8
    9
     function say(word) {
         console.log(word);
     }
    
     function execute(someFunction, value) {
         someFunction(value);
     }
    
     execute(say, "Hello");
    
  2. 匿名函数

    我们可以把一个函数作为变量传递。但是我们不一定要绕这个 “先定义,再传递” 的圈子,我们可以直接在另一个函数的括号中定义和传递这个函数。1

    1
    2
    3
    4
    5
    6
    7
     function execute(someFunction, value) {
         someFunction(value);
     }
    
     execute(function(word){
         console.log(word)
     }, "Hello");
    

    这个例子与 1 是完全等价的。注意execute 函数的调用过程:我们直接在传入函数形参的地方定义了另一个函数,甚至不需要给它起一个诸如 say 的名字,此之谓“匿名函数”。

    像这样的调用格式在 Node.js 写的服务器代码中是十分常见的。

  3. 回调函数

    Node.js 异步编程的直接体现就是回调。异步编程依托于回调来实现。

    回调函数在完成任务后就会被调用,Node 使用了大量的回调函数,Node 所有 API 都支持回调函数。

    例如,我们可以一边读取文件,一边执行其他命令,在文件读取完成后,我们将文件内容作为回调函数的参数返回。这样在执行代码时就没有阻塞或等待文件 I/O 操作。这就大大提高了 Node.js 的性能,可以处理大量的并发请求。

    回调函数一般作为函数的最后一个参数出现。2

    如果之前没有接触过类似的概念,还是会觉得有点抽象的。想要搞懂这个东西,建议先完整阅读这里的代码和说明。

    总结起来:回调函数作为函数的一个参数,是在该函数执行结束之后被调用的。同时,主体函数将自身执行结束后的一些参数传递给回调函数(参数的个数和含义由主体函数定义,如何使用这些参数则由回调函数定义)。许多时候,执行一个函数的函数体代码是由另一个线程完成的,主线程将继续向下执行其他代码。这就能够实现异步的过程:下方的代码已经执行,而刚才调用的函数才终于(并行地)执行完毕,之后才去执行上方的回调函数。

    Node.js 中所谓的异步、回调、单线程多任务等等概念涉及到 JS 的底层运行机制和模型,想要深入理解请参考阮一峰的《JavaScript 运行机制详解:再谈 Event Loop》一文。

3. 代码编写

3.1 一个最简服务器的实现代码

快速上手一门语言讲究“依葫芦画瓢”。我们先来看 Node.js 所实现的一个最简单的服务器代码:

1
2
3
4
5
6
7
var http = require("http");

http.createServer(function(request, response) {
  response.writeHead(200, {"Content-Type": "text/plain"});
  response.write("Hello World");
  response.end();
}).listen(8888);

第一行代码中,require 函数有点类似 import,把 http 模块引入,然后赋值给了我们声明的一个对象 http

接下来调用 http 对象的成员函数 createServer。这个函数的唯一一个参数就是一个回调函数,会在收到 http 请求时调用它,同时传给它两个对象作为参数,即 requestresponse(形参的名字当然是任意的,为了简便,许多地方会写成 reqres)。

这里我们用匿名函数作为回调,其函数体中,调用了 response 的几个成员函数来发送返回给客户端(浏览器)的响应。这几句代码的效果是直接在网页上打出了 Hello World 这几个字。

createServer 的返回值又是另一个对象,这个对象有一个成员函数 listen,用于指定 http 服务器监听的端口。这里我们直接调用它,设置监听 8888 端口。

整段代码有点类似一个 while(true) 循环,程序(服务器)一直监听端口的请求,一旦有一次请求就分一个线程去执行一次回调函数,同时继续监听的循环。

大致讲解完毕。现在可以保存这段代码为 hello.js 文件,在终端中执行命令 node hello.js,然后打开浏览器,输入 http://<ip>:8080 就能看到效果。

3.2 Webhook 的实现

讲到这里大概已经能做出一个最基本的 Webhook 了——只需要把上述代码中回调函数的函数体部分改成一段执行外部脚本的代码就行了。

比如,我们想要做到:一旦访问 url 就立刻触发刷新腾讯 CDN 的缓存,把调用 api 的代码保存为 cdn.sh

在 Node.js 中调用外部脚本很简单:

1
2
3
4
5
6
7
8
9
10
11
var callfile = require('child_process');
callfile.execFile('./cdn.sh', null, null, function (err, stdout, stderr) {
    if (stderr) {
        console.log(stderr);
    }
    console.log(stdout);
    // 响应
    response.writeHead(200, { 'Content-Type': 'text/html' });
    response.write("success!");
    response.end();
});

这里 execFile 函数的最后一个参数也是回调函数,我们在其中编写了遇到错误时讲日志转发给终端的功能,还给了客户端一个 success 的纯文本响应信息。

到此为止,一个简易的 Webhook 已经被我们完整地实现了。

3.3 优化:仅响应 POST 请求

这里先解释一下:http 的请求分为很多种,比较常用的是 GET 和 POST。浏览器访问网页所使用的请求方式是 GET

3.2 中编写的代码是不关心请求方法的,无论 GET 还是 POST,只要有 http 请求,这个服务器就会执行回调函数。

我们用到的端口肯定是对外网开放的,如果 ip 泄露,有人用工具反复扫描端口,或者我们自己访问这个 url 时多刷新了几次,岂不是会在短时间内多次执行脚本,反复访问 api?应当对此做出限制。

这里我们从请求方式上入手。GitHub Webhooks 的请求是 POST 方式的,有其固定的格式,可以用条件判断来做到仅仅响应来自 GitHub 服务器的请求。这里为了简便起见,只判断了是否为 POST 请求(这也是为了实现之后的功能做铺垫)。

POST 请求的内容全部的都在请求体中,http.ServerRequest 并没有一个属性内容为请求体,原因是等待请求体传输可能是一件耗时的工作,比如上传文件。而很多时候我们可能并不需要理会请求体的内容,恶意的 POST 请求会大大消耗服务器的资源,所以 node.js 默认是不会解析请求体的,当你需要的时候,需要手动来做。3

主体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
http.createServer(function (request, response) {
    // 定义了一个 post 变量,用于暂存请求体的信息
    var POST = '';

    // 通过 request 的 data 事件监听函数,每当接受到请求体的数据,就累加到 post 变量中
    request.on('data', function (chunk) {
    POST += chunk;
    });
    request.on('end', function () {
        if (POST !== '') {
            console.log('post receive!');
            // 调用脚本及响应
        }
        else {
            // else
        }
    });
}).listen(8080);

这段代码做了一个简单的条件判断。注意,如果 POST 的请求体为空,一样视作非 POST 请求。

至此,服务器只会由 POST 请求触发脚本执行任务了。

3.4 代码及前端优化

实现了这些之后,我还想要实现一个手动触发执行脚本的功能,但不是一访问 url 就触发,最好有个按钮什么的。我们之前设定了只响应 POST 请求,实际上在一般的网页中,用到 POST 最多的地方就是表单提交了。所有由用户填写的表单数据都是通过 POST 提交给服务器的。

于是我们可以设想,在网页前端设计一个表单,提单提交也就发送了 POST 请求,从而触发了 Webhook。如何使用 Node.js 进行网页前端的设计呢?

从之前的代码可以看出,Node.js 所编写的服务器可以自由地定义响应内容。一般来讲,我们访问网站所看到了静态网页,都是服务器响应给浏览器了一端 html 代码。使用 Node.js 自然也可以实现这种效果,只需要提前指定 response.writeHead(200, {'Content-Type': 'text/html'}); ,然后使用 response.write("some html code"); 就可以了(比如打一个换行:response.write("<br>");)。

用 HTML 写一个输入框(表单)和相应的提交按钮很简单,这里不再赘述。我们可以把输入框设置为不可见(注意,<input> 标签仍必须要有 name,否则请求体将为空),仅仅保留提交按钮,这样就实现了按按钮发送 POST 请求。

4. 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
var http = require('http');
var postHTML =
  '<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title> GitHub Webhook Server </title></head>' +
  '<body>' +
  'Please use POST method!<br>' +
  '<form method="post">' +
  '<input name="sth" type="hidden"><br>' +
  '<input type="submit" value="Refresh">' +
  '</form>' +
  '</body></html>';

http.createServer(function (request, response) {
  //response.writeHead(200, {'Content-Type': 'text/plain'});
  //response.end('node Sever is OK!\n');
  //console.log('hello');
  var POST = '';
  request.on('data', function (chunk) {
    POST += chunk;
  });
  request.on('end', function () {
    if (POST !== '') {
      console.log('post receive!');
      //开始准备调用脚本
      var callfile = require('child_process');
      callfile.execFile('./cdn.sh', null, null, function (err, stdout, stderr) {
        if (stderr) {
          console.log(stderr);
        }
        console.log(stdout);
        response.writeHead(200, { 'Content-Type': 'text/html' });
        response.write("success!");
        response.end();
      });
    }
    else {
      //response.end('Please use POST method!\n');
      response.write(postHTML);
      response.end();
    }
  });
}).listen(7474);

console.log('Server running at http://0.0.0.0:7474/');

效果:

webpage

一旦点击按钮,就会到一个新的页面,显示 success!,同时后端触发刷新 CDN 的脚本。

需要注意的是,监听的端口号最好不要选择很奇葩的,我之前选择的端口使用 chrome 访问一直报错:ERR_UNSAFE_PORT。查了一下,原来是有一些端口有约定的用处,最好不要作为外部网站的端口。完整的浏览器非安全端口列表可以参考这篇文章

彩蛋:我选择 7474 端口是因为,EE 专业的应该都有印象,7474 芯片乃是带置位复位正触发双 D 触发器,嗯,触发器。

5. 后台持续运行

在终端使用 node 命令执行脚本便于调试,但是远程终端一旦关闭脚本也就随之停止。要想持续运行 js 脚本,可以使用 forever 模块4。安装:

1
npm install forever -g

试着执行一下 forever 命令,如果仍然提示 bash: forever: command not found,可以参考这里

要启动脚本并保持后台运行时,终端执行 forever start my_script.js 命令即可。如果想要停止,只需要执行 forever stop my_script.js

还可以把 forever 命令写入 /etc/rc.local 以实现开机启动,保证重启服务器也不会导致服务失效。

参考链接

-------------本文结束    感谢您的阅读-------------
0%