R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

NodeJS 的小科普 && 使用 NodeJS 实现 Webhook

注意:本文发布于 2805 天前,文章中的一些内容可能已经过时。
本文已授权 Coding博客 转载。

距离 NodeJS 这个东西出来已经过了好久了,感觉现在的前端如果不会点 NodeJS 就有点太落后于时代啦。我接触它是从去年暑假开始的,当时在为七牛写一个比较神奇的东西,就顺便接触了一下。虽然网传 npm 社区不是很好,但是我使用了这么久,觉得 NodeJS 还是个很好的工具。本文大概分两部分,前半部分用来向大家介绍 NodeJS,后半部分则是用 NodeJS 写的一个小项目,也就是纸飞机现在正在用的 WebHook。

虽然是科普向,但是还是需要先熟悉 JavaScript 语法以及它的异步思想,以及一些基本的数据库语句和命令行操作,此外后面的实例是用的 Coding 为例子,所以还需要了解 Coding 的基本操作。

所以,NodeJS 究竟是个啥?

如果你正在使用 Chrome 浏览器,你一定会觉得它比一般浏览器要快(黑一发:前提是电脑的内存足够大……),其原因之一是因为 Chrome 有个叫 V8 的东西,可以高效地解析 JavaScript。虽然浏览器上早就是 JavaScript 的天下了,但是还是有人觉得,能不能把 JavaScript 跑在别的地方,例如跟 Java 一样跑在本地?于是他们就把 V8 封装了一下,做了许多修改,最终诞生了 NodeJS。

所以 NodeJS 究竟是个啥?说白了,无非就是个 JavaScript 的解释器。其实不能说是“解释器”,因为 V8 会将其编译成原生机器码(IA-32,x86-64,ARM,MIPS 等),并且会使用内联缓存等方法来提高性能。据传说,在 V8 的帮助下,JavaScript 的运行效率直逼二进制程序。然而与 V8 相比,NodeJS 功能更多,例如直接访问文件系统、处理二进制数据等。

有好多同学一听到 NodeJS,就会联想到这是用来写服务器的。眼界放宽一点吧,刚才不是说了,可以直接访问文件系统、处理二进制数据么?这意味着可以用 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,就可以直接在本地开启一个小型服务器来预览我们写的效果。

NodeJS 的好帮手:NPM

其实 NodeJS 的程序员几乎不输入 node 命令,他们用的最多的命令是 npm。所以 NPM 又是个什么东西呢?这又不得不提到两个概念:包、依赖。

如果你用过 Linux,肯定对这两个概念很熟悉。例如我想装一个 Ruby,那么必须先装 libreadline 和 libruby,因为 Ruby 必须依赖他俩才能运行。为什么 Windows 没有依赖的概念呢?因为 Windows 的程序一般在安装的时候会自动帮你装上,当然也有例外,例如运行一个大游戏需要先安装 VC++ 运行库和 DirectX 运行库。

还记得刚才提到的“使用 grunt build 对图片进行压缩”嘛?其实压缩这一步不是 Grunt 做的,而是一个叫 imagemin 的工具做的。如果想安装它,可以从 GitHub 上面下载对应的代码。然而这家伙依赖 gulp-imagemin、node-atlas、cropshop 等 36 个工具,这 36 个工具还要再依赖其它的工具……这不是坑爹么?

还好我们有 NPM,只需要再 npm install -g imagemin,NPM 就会从官方源中读取 imagemin 的依赖,然后再读取这些依赖里面的依赖……通过拓扑排序生成一个安装序列,然后自动帮你装好所有需要的东西,甚至还可以全局安装,这样执行起来就跟原生的命令行工具一样自然。当然你也可以一条命令就将它们删掉。

一个工具就是一个包。NPM 的全称就是 Node Package Manager。

写一个 NodeJS 程序

很久之前纸飞机有一个用 PHP 写的 Webhook,但是有时候执行时间太长,会被强行断掉。当然其实可以这样:Web 端只负责接收 Webhook 请求然后存到数据库里,后端再写一个 daemon 不断轮询数据库,看有没有需要 pull / deploy 的项目。

然而 JavaScript 是基于单线程事件队列的,可以几乎不占资源地实时监听各种事件,因此我尝试着用 NodeJS 来写一个既带有 Web 功能又带有 daemon 功能的 Webhook 程序。

我的需求很简单:所有需要加入 Webhook 的项目的配置都存放在配置文件中,Webhook 的运行记录存放在数据库中,Web 端监听一个特定端口,只需要提供几个接口就可以了。

首先我们新建一个项目目录,然后用 npm init 新建一个项目,填写里面的各种信息,最终生成 package.json 文件。要注意的是,我们程序的运行方法是 node index.js,可以为它绑个命令:npm start。其实我们还可以为 npm 设定更多命令。

然后就可以在这个目录下写项目啦!配置文件很容易就写出来了:

var port = 9091;
var projects = {
    mall: {
        path: '/data/www',
        url: '[email protected]:Click_04/mall.git'
    },
    lib: {
        path: '/storage',
        url: '[email protected]:Click_04/lib.git'
    },
    // 更多的项目...
};
var db = {
    host: 'localhost',
    user: 'root',
    password: 'root',
    database: 'webhook'
};
module.exports = {
    projects: projects,
    port: port,
    db: db
};

其中 module 是 NodeJS 模块组织相关的东西,NodeJS 几乎遵守了 CommonJS 的标准,大家有兴趣可以了解一下。

于是我们怎么写一个可以监听端口的服务器出来呢?其实很简单,因为 NodeJS 自带了 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 变量中的。然后我们可以通过 res 提供的一些方法输出数据。

下一个问题就是如何连接数据库。NodeJS 并没有自带这玩意儿,所以我们必须要手动安装:

npm install --save mysql

然后就可以在项目中使用了:

var mysql = require('mysql');
var pool = mysql.createPool(config.db);
pool.getConnection(function (err, conn) {
    if (err) throw err;
    // 接下来可以通过 conn 来干一些事情了
});

最后一个需要解决的问题是如何执行命令行的 git 命令,这个 NodeJS 也自带了:

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 串的格式,可以获取到我需要的数据并插入到数据库中:

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();
            });
        });
    });
}

这样,只需要在 Coding 上面设置 Webhook 的地址是 http://ip:9091/ 或者通过 Nginx 等程序进行端口转发即可。

大部分代码还是很好理解的,就是那个 res.end 有点别扭。对于大部分语言来说,执行完了之后是会自动停止向 Response body 写入数据的,并且可以通知浏览器“我写完了你看着用吧”,然而 NodeJS 的 http 并不行,必须手动加上这句话才可以。如果不加,浏览器就会一直等待。

注意到 query -> exec -> query 已经有三层回调了,这是 JavaScript 的一个大坑,当然我们可以改成 Promise,但是其实本质没太大变化,只是让你写着舒服一点。但是 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 来守护 NodeJS 的进程,用 Nginx 做了端口转发,当然这些就不在本文的讨论范围内了。

看一下效果吧,在一个项目中 push 一下,或者手工执行一下 pull / clone,然后从服务器上看 log。为了方便,我将 log 集成到了纸飞机的电子办公系统中,以 AJAX 的形式请求,然后将数据以表格方式显示,并稍做了一点美化。上个截图:

1

全部的代码在 这里,欢迎吐槽。

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3078 天