Pages

Thursday, 1 September 2016

使用 Node.js 实现简单的 Webhook

使用 Node.js 实现简单的 Webhook
距离 Node.js 这个东西出来已经过了好久了,感觉现在的前端如果不会点 Node.js 就有点太落后于时代啦。我接触它是从去年暑假开始的,当时在写一个比较神奇的东西,就顺便接触了一下。虽然网传 npm 社区不是很好,但是我使用了这么久,觉得 Node.js 还是个很好的工具。本文大概分两部分,前半部分用来向大家介绍 Node.js,后半部分则是用 Node.js 写的一个小项目:一个简单的 WebHook。
虽然是科普向,但大家还是需要先熟悉 JavaScript 的基本语法、它的异步思想,以及一些数据库查询语句和命令行操作,此外后面的实例是用的 Coding 为例子,所以还需要了解 Coding 的基本操作。

Node.js 究竟是什么?

如果你正在使用 Chrome 浏览器,你一定会觉得它比其它浏览器要快,其原因之一是因为 Chrome 有个叫 V8 的东西,可以高效地解析 JavaScript。Node.js 的作者其实一开始打算用 Ruby 来写一个本地的运行平台,但是后来发现 Ruby 性能不够,于是他开始尝试用 V8 引擎,并做了许多修改,最终诞生了 Node.js。
所以 Node.js 究竟是个啥?说白了,无非就是个本地的 JavaScript 的解释器。其实不能说是“解释器”,因为 V8 会将其编译成原生机器码(IA-32,x86-64,ARM,MIPS 等),并且会使用内联缓存等方法来提高性能。据传说,在 V8 的帮助下,JavaScript 的运行效率直逼二进制程序。然而与 V8 相比,Node.js 功能更多,例如直接访问文件系统、处理二进制数据等。
有好多同学一听到 Node.js,就会联想到这是用来写服务器的。眼界放宽一点吧,刚才不是说了,可以直接访问文件系统、处理二进制数据么?这意味着可以用 JavaScript 的语法来写各种各样的本地工具。其中最著名那些就是前端自动化构建工具了:Webpack、Gulp、Grunt……那么就顺便插播一段前端的故事。
A long time ago in a galaxy far, far away...

前端的概念无非是 HTML、CSS、JavaScript,当时页面的样式和交互还没有现在那么复杂,所以只需要完成基本的样式显示和数据操作就好了。

As time went by...

各种复杂的页面相继出现,甚至出现了 Angular、React 这样的大工程。为了提高网页的加载速度,前端们不得不在发布前将所有的文件拼合在一起并混淆压缩以节省流量和请求数。
上面提到的三款工具,任意一款都可以满足这种需求。当配置好了之后,我们只要在命令行执行一句 grunt build,就可以将各种零散的代码文件拼接起来并混淆压缩,甚至还可以对图片进行压缩;执行一句 gulp serve,就可以直接在本地开启一个小型服务器来预览我们写的效果。

Node.js 的好帮手:NPM

其实 Node.js 的程序员几乎不输入 node 命令,他们用的最多的命令是 npm。所以 NPM 又是个什么东西呢?这又不得不提到两个概念:包、依赖。
如果你用过 Linux,肯定对这两个概念很熟悉。例如我想装一个 Ruby,那么必须先装 libreadline 和 libruby,因为 Ruby 必须依赖他俩才能运行。为什么 Windows 没有依赖的概念呢?因为 Windows 的程序一般在安装的时候会自动帮你装上,当然也有例外,例如运行一个大游戏需要先安装 VC++ 运行库和 DirectX 运行库。
还记得刚才提到的“使用 grunt build 对图片进行压缩”嘛?其实压缩这一步不是 Grunt 做的,而是一个叫 imagemin 的工具做的。如果想安装它,可以从 GitHub 上面下载对应的代码,然后再将这家伙依赖的 36 个项目的代码也下下来,它们是: gulp-imagemin、node-atlas、cropshop……然后再将这些项目的依赖也……
坑爹呢!
还好我们有 NPM,只需要再 npm install -g imagemin,NPM 就会从指定的源(默认是官方源)中读取 imagemin 的依赖,然后再读取这些依赖里面的依赖……通过拓扑排序生成一个安装序列,然后自动帮你装好所有需要的东西,如果你的指令中带了 -g,那就是全局安装,执行起来就跟原生的命令行工具一样自然。当然你也可以一条命令就将它们删掉。
一个工具就是一个包。NPM 的全称就是 Node Package Manager。

写一个 Node.js 程序

很久之前我的一个团队有一个用 PHP 写的 Webhook,但是有时候网速不好,执行时间太长,会被 PHP 强行断掉。当然其实可以这样:Web 端只负责接收 Webhook 请求然后存到数据库里,后端再写一个 daemon 不断轮询数据库,看有没有需要 pull / deploy 的项目。然而 JavaScript 是基于单线程事件队列的,可以几乎不占资源地实时监听各种事件,因此我尝试着用 Node.js 来写一个 Webhook 程序。
我的需求很简单:所有需要加入 Webhook 的项目的配置都存放在配置文件中,Webhook 的运行记录存放在数据库中,Web 端监听一个特定端口,只需要提供几个 API 就可以了。
首先我们新建一个项目目录,然后用 npm init 新建一个项目,填写里面的各种信息,最终生成 package.json 文件。要注意的是,我们程序的运行方法是 node index.js,可以为它绑个命令:npm start。其实我们还可以为 npm 设定更多命令。
然后就可以在这个目录下写项目啦!配置文件很容易就写出来了:
// 监听的端口
var port = 9091;
// 项目配置
var projects = {
    mall: {
        path: '/data/www',
        url: 'git@git.coding.net:Click_04/mall.git'
    },
    lib: {
        path: '/storage',
        url: 'git@git.coding.net:Click_04/lib.git'
    },
    // 更多的项目...
};
// 数据库配置
var db = {
    host: 'localhost',
    user: 'root',
    password: 'root',
    database: 'webhook'
};
module.exports = {
    projects: projects,
    port: port,
    db: db
};
其中 module 是 Node.js 模块组织相关的东西,Node.js 几乎遵守了 CommonJS 的标准,然而这个就不在本文的讨论范围之内了。
于是我们怎么写一个可以监听端口的服务器出来呢?其实很简单,因为 Node.js 自带了 http 模块,我们只需要这样:
var http = require('http');
var config = require('./config.js');
var server = http.createServer(function (req, res) {
    // 接收 POST 数据。如果请求方法不是 POST,那么这个变量最终是空字符串
    var POST = '';
    req.on('data', function (chunk) { POST += chunk;});
    req.on('end', function () {
        // 执行后端逻辑代码
    });
});
server.listen(config.port);
console.log("Server runing at port: " + config.port + ".");
其中 http.createServer 的回调函数就是创建完服务器之后需要做的事情,http 的机制是:始终只有一个线程,然后监听 req 的各种事件,例如 data 事件就是正在接收数据,end 事件就是当前请求的数据已经接收完毕了。当然这儿的数据指的是 POST 数据,像 header 这样的东西当然是直接存在 req 变量中的(可以试试 console.log(req),这样会将 req 变量输出到终端里)。然后我们可以通过 res 提供的一些方法输出数据。
下一个问题就是如何连接数据库。Node.js 并没有自带这玩意儿,所以我们必须要手动安装:
npm install --save mysql
选项 --save 表示将这个库添加到 package.json 中,方便后续拿到代码的人直接执行 npm install 安装全部依赖。mysql 这玩意儿是这样用的:
var mysql = require('mysql');
// config 就是上面那个 config
var pool = mysql.createPool(config.db);
pool.getConnection(function (err, conn) {
    if (err) throw err;
    // 接下来可以通过 conn 来干一些事情了
});
最后一个需要解决的问题是如何执行命令行的 git 命令,这个 Node.js 也自带了:
var exec = require('child_process').exec;
exec(cmd_str, function(err, stdout, stderr) {
    var status = err ? -1 : 1,
        cmd_result = err ? stderr : stdout;
    // 可以获取到错误信息、标准输出和标准错误输出,接下来继续处理吧
});
一切技术问题都扫清了,可以开始理思路了!
首先我分析了一下 Coding 的 Webhook 传过来的数据,首先肯定是 JSON 串,其次如果有 zen 属性的话那就是测试请求,如果有 commits 属性的话就是正常的请求。按照 JSON 串的格式,可以获取到我需要的数据并插入到数据库中:
data = (POST == '') ? {} : JSON.parse(POST);
if (data.commits) {
    // 获取到数据
    var project_name = data.repository.name,
        trigger_user = data.user.global_key,
        commit_user = data.commits[0].committer.name,
        commit_user_email = data.commits[0].committer.email,
        commit_message = data.commits[0].short_message;
    if (!config.projects[project_name]) {
        return;
    }
    // 数据库查询
    conn.query('INSERT INTO `log` (`project_name`, `trigger_user`, `commit_user`, `commit_user_email`, `commit_message`) VALUES (?, ?, ?, ?, ?)',
               [project_name, trigger_user, commit_user, commit_user_email, commit_message],
               function (err, results) {
        if (err) throw err;
        // 拼接 git 命令字符串
        var cmd_str = 'cd ' + config.projects[project_name].path + '/' + project_name + ' && git pull origin master',
            log_id = results.insertId;
        // 执行命令
        exec(cmd_str, function(err, stdout, stderr) {
            var status = err ? -1 : 1,
                cmd_result = err ? stderr : stdout;
            // 更新数据库
            conn.query('UPDATE `log` SET `status` = ?, cmd_result = ? WHERE `log_id` = ?', [status, cmd_result, log_id], function (err, results) {
                // 结束对返回数据的写操作
                res.end();
            });
        });
    });
}
JSON.parse 是 JavaScript 的一个方法,可以将 JSON 字符串转换为 JSON 对象。我们只需要在 Coding 上面设置 Webhook 的地址是 http://ip:9091/ 或者通过 Nginx 等程序进行端口转发,就可以看到 Webhook 的效果啦!
大部分代码还是很好理解的,就是那个 res.end 有点别扭。对于大部分语言来说,执行完了之后是会自动停止向 Response body 写入数据的,并且可以通知浏览器“我写完了,你不用再等了”,然而 Node.js 的 http 并不行,必须手动加上这句话才可以。如果不加,浏览器就会一直等待。其实 Node.js 的一些框架例如 Express,就可以让你专心处理后端逻辑,不必担心这些细枝末节。
注意到 query -> exec -> query 已经有三层回调了,这是 JavaScript 的一个大坑,当然我们可以改成 Promise,但是其实本质没太大变化,只是让你写着舒服一点。如何使用异步的思路来写程序也是一个比较好玩的问题,但同时也是比较头疼的问题。关于如何避免掉进回调函数的陷阱里,现在已经有了许多解决方案,但是本文的这个项目非常小,所以并不需要。
其实对于一个 Webhook 来说,这个功能已经足够了,但是我想干点别的:在网页上直接显示 log,或者显示当前已经加入 Webhook 的全部项目。我们可以接着上一段代码的 if 来写:
else {
    // 处理各种 GET 请求,或者 body 为空的 POST 请求
    res.writeHeader(200, {'Content-type': 'application/json'});
    // 尝试通过 URL 来判断请求类型
    var match = '';
    // 显示 log
    if (req.url == '/log') {
        conn.query('SELECT * FROM `log` ORDER BY `log_id` DESC LIMIT 30', [], function (err, results) {
            if (err) throw err;
            res.write(JSON.stringify(results));
            res.end();
        });
    }
    // 显示所有加到 Webhook 中的项目信息
    else if (req.url == '/projects') {
        res.write(JSON.stringify(config.projects));
        res.end();
    }
    // 手动 pull / clone 一个项目
    else if (match = req.url.match(/\/(pull|clone)\/(.+)/i)) {
        if (!config.projects[match[2]]) {
            res.end();
            return;
        }
        conn.query('INSERT INTO `log` (`project_name`) VALUES (?)', [match[2]], function (err, results) {
            if (err) throw err;
            var cmd_str = '';
            if (match[1] == 'clone') {
                cmd_str = 'cd ' + config.projects[match[2]].path + ' && git clone ' + config.projects[match[2]].url;
            }
            else if (match[1] == 'pull') {
                cmd_str = 'cd ' + config.projects[match[2]].path + '/' + match[2] + ' && git pull origin master';
            }
            var log_id = results.insertId;
            exec(cmd_str, function(err, stdout, stderr) {
                var status = err ? -1 : 1,
                    cmd_result = err ? stderr : stdout;
                conn.query('UPDATE `log` SET `status` = ?, cmd_result = ? WHERE `log_id` = ?', [status, cmd_result, log_id], function (err, results) {});
            });
        });
        res.end();
    }
}
通过 res.writeHeader 来输出 header,通过 res.write 来输出一段文本。JSON.stringify 是 JavaScript 自带的一个方法,可以将 JSON 对象转换为字符串。因为是手动触发(Manual),所以只能获取到项目名称,无法显示提交信息(虽说可以通过 git 命令来获取但是好麻烦),而前文的自动触发是 Coding 发过来的请求,里面附上了完整的信息。
最后我使用了 supervisor 来守护 Node.js 的进程,用 Nginx 做了端口转发,当然这些就不在本文的讨论范围内了。
看一下效果吧,在一个项目中 push 一下,或者手工执行一下 pull / clone,然后从服务器上看 log。为了方便,我写了一个页面,以 AJAX 的形式请求 log,然后将数据以表格方式显示。上个截图:
1
全部的代码在 这里,欢迎吐槽.
from https://blog.coding.net/blog/nodejs-webhook