Nodejs

Published: by Creative Commons Licence

  • Tags:

模块

模块可能是一个文件,也可能是一个目录。如果模块是一个目录,Node通常会在这个目录下找一个叫index.js的文件作为模块的入口。

典型的模块是一个包含exports对象属性定义的文件,这些属性可以是任意数据。

下面建立一个currency.js文件作为示范:

var canadianDollar = 0.91;
function roundTwoDecimals(amount)
{
    return Math.round(amount * 100) / 100;
}
exports.canadianToUS = function(canadian){
    return roundTwoDecimals(canadian * canadianDollar);
}
exports.USToCanadian = function(us){
    return roundTwoDecimals(us / canadianDollar);
}

这里我们导出了两个函数canadianToUSUSToCanadian,但是roundTwoDecimalscanadianDollar依旧是私有的。

要引入模块,需要用到Node的require函数,这个函数以模块路径为参数,Node以同步的方式定位并加载文件。

require是Node中少数的几个同步I/O操作之一。但是同步调用会阻塞Node,比如在服务器接受请求时用上require,就会遇到性能问题,所以通常都只在程序加载时才使用require和其它同步操作。

之后在相同目录创建一个test-currency.js文件,输入以下内容:

var currency = require('./currency');
console.log('100C = ' + currency.canadianToUS(100) + "U");
console.log('100U = ' + currency.USToCanadian(100) + "C");

如果你仅希望导出一个变量,由于不能重写exports,因此你需要使用module.exports。如果exportsmodule.exports同时在一个模块文件中出现,那么仅module.exports生效。实际上exportsmodule.exports的一个全局引用,而最终导出的实际上是module.exports

node_modules

前面引入模块使用的是./currency,使用的是相对路径。如果只使用currency,Node会遵照几个规则搜索这个模块:

  1. 如果模块是核心模块,那么直接返回
  2. 从当前目录下的node_modules目录开始,不断向上搜索,直到找到目录下有该模块,就返回。
  3. 如果模块在环境变量NODE_PATH指定的目录下,就返回。
  4. 抛出异常。

目录模块

如果模块是目录,加载规则如下:

  1. 如果目录下的package.json文件,且文件中包含main元素,那么main元素指定了模块的入口。
  2. 在目录下查找index.js作为模块的入口。
  3. 抛出异常。

模块缓存

Node会把模块作为对象缓存起来。如果程序中两个文件引入了相同的模块,第一次加载会把模块对应的数据存在内存中,这样第二次加载就不会去重新去读取文化。

异步回调

创建一个echo.js文件,输入下面内容:

var net = require('net');
var server = net.createServer(function(socket){
    socket.on('data', function(data){
        socket.write(data);
    });
});
server.listen(3000);

上面的程序会不断地重述用户的输入。你也可以修改为仅响应第一次:

var net = require('net');
var server = net.createServer(function(socket){
    socket.once('data', function(data){
        socket.write(data);
    });
});
server.listen(3000);

事件发射器

var EventEmitter = require('events').EventEmitter;
var channel = new EventEmitter();
channel.on('join', function(){
    console.log('Welcome');
});
channel.emit('join');

事件只是关键字,可以是任何字符串,但是事件error是特殊的。如果你向error信道发送一个异常,且没有对应的该事件的监听器,那么会导致该异常被抛出。

要修改一个发射器上可以绑定的监听器的数量,可以通过setMaxListeners方法。

Web开发

HTTP服务器

Node中的http模块提供了HTTP服务器和客户端接口。创建服务器要用到http.createServer()这个函数,它接受一个回调函数作为参数,每次请求到达的时候会调用回调函数,回调函数的参数为请求和响应对象。

var http = require('http');
var server = http.createServer(function(req, res){
    //处理请求
});

Node会自动解析HTTP头,但是不会解析请求体。Node允许你流式解析请求体。

Node也不会自动向客户端写内容,在处理完请求后,你需要调用res.end()结束这次请求。

var http = require('http');
var server = http.createServer(function(req, res){
    res.write('Hello world');
    res.end();
});
server.listen(3000);

下面演示如何流式解析HTTP体:

var http = require('http');
var server = http.createServer(function(req, res){
    req.setEncoding('utf8');
    req.on('data', function(chunk){
        console.log('parsed', chunk);
    });
    req.on('end', function(){
        console.log('done');
        res.end();
    });
});
server.listen(3000);

默认情况下,data事件提供的参数时Buffer对象,这是Node的字节数组。而很多时候你可能不需要二进制数据,所以需要设置流编码:

var http = require('http');
var server = http.createServer(function(req, res){
    req.setEncoding('utf8');
    req.on('data', function(chunk){
        console.log('parsed', chunk);
    });
    req.on('end', function(){
        console.log('done');
        res.end();
    });
});
server.listen(3000);

字符集

如果你输出时使用了其它字符集,那么你的响应很可能会是乱码。

在设置Content-Length头时,不同字符集下一个字符对应的长度不同,可以使用Buffer.byteLength方法计算实际上这个字符串占用的字节数。

在HTTP头中,可以用Content-Type指定具体使用的字符集,比如Content-Type: application/json;charset=utf8

静态服务器

__dirname表示该文件所在目录的路径,对于不同的文件的脚本,其值不同。

默认情况下,根目录为__dirname指定的路径。

var http = require('http');
var parse = require('url').parse;
var join = require('path').join;
var fs = require('fs');

var root = __dirname;

var server = http.createServer(function(req, res){
    var url = parse(req.url);
    var path = join(root, url.pathname);
    console.log(path);
});

server.listen(3000);

下面我们传输文件的内容,这可以通过文件系统流读取fs.ReadStream完成,它是Node中Stream类之一。这个类从文件系统读取文件的过程中会发射data事件。

var http = require('http');
var parse = require('url').parse;
var join = require('path').join;
var fs = require('fs');

var root = __dirname;

var server = http.createServer(function(req, res){
    var url = parse(req.url);
    var path = join(root, url.pathname);
    var stream = fs.createReadStream(path);
    stream.on('data', function(data){
        res.write(data);
    });
    stream.on('end', function(){
        res.end();
    });
});

server.listen(3000);

尽管fs.ReadStream非常灵活,但是Node中还提供了更加高级的实现机制:Stream.pipe()。这个方法可以将输入输出流连接起来。

var http = require('http');
var parse = require('url').parse;
var join = require('path').join;
var fs = require('fs');

var root = __dirname;

var server = http.createServer(function(req, res){
    var url = parse(req.url);
    var path = join(root, url.pathname);
    var stream = fs.createReadStream(path);
    stream.pipe(res);
});

server.listen(3000);

由于上面代码没有处理异常的逻辑,因此一旦用户请求一个不存在的文件,整个程序都会退出。我们可以监控stream的error信道来捕获异常从而实现容错。

var http = require('http');
var parse = require('url').parse;
var join = require('path').join;
var fs = require('fs');

var root = __dirname;

var server = http.createServer(function(req, res){
    var url = parse(req.url);
    var path = join(root, url.pathname);
    var stream = fs.createReadStream(path);
    stream.pipe(res);
    stream.on('error', function(error){
        res.statusCode = 500;
        res.end("Internal Server Error");
    });
});

server.listen(3000);

可以利用fs.stat()接口得到文件的元数据。如果文件不存在,fs.stat()会在err.code中放入ENOENT作为响应,你可以根据此返回404错误。

var http = require('http');
var parse = require('url').parse;
var join = require('path').join;
var fs = require('fs');

var root = __dirname;

var server = http.createServer(function (req, res) {
    var url = parse(req.url);
    var path = join(root, url.pathname);

    fs.stat(path, function (err, stat) {
        if (err) {
            if ('ENOENT' === err.code) {
                res.statusCode = 404;
                res.end('Not Found');

            }
            else {
                res.statusCode = 500;
                res.end("Internal Server Error");
            }
            return;
        }
        res.setHeader('Content-Length', stat.size);
        var stream = fs.createReadStream(path);
        stream.pipe(res);
        stream.on('error', function (error) {
            res.statusCode = 500;
            res.end("Internal Server Error");
        });
    });

});

server.listen(3000);

从表单提取参数

要解析表单参数,我们可以使用querystring.parse方法。这个方法从形如a=1&b=2的请求参数中解析成对象。

对于文件上传,我们可以使用formidable提供的流式解析进行处理。我们使用formidable.IncomingForm解析表单,在解析表单元素时,formidable会发出很多事件,比如收到文件时发出file事件,收到字段时会发出field事件。

var http = require('http');
var parse = require('url').parse;
var join = require('path').join;
var fs = require('fs');
var formidable = require('formidable');

var root = __dirname;

var server = http.createServer(function (req, res) {
    switch (req.method) {
        case 'GET':
            show(req, res);
            break;
        case 'POST':
            upload(req, res);
            break;
    }
});

function show(req, res){
    var html = ''
    + '<form method="post" action="/" enctype="multipart/form-data">'
    + '<p><input type="text" name="name" /></p>'
    + '<p><input type="file" name="file" /></p>'
    + '<p><input type="submit" value="Upload" /></p>'
    + '</form>';

    res.setHeader('Content-Type', 'text/html');
    res.setHeader('Content-Length', Buffer.byteLength(html));
    res.end(html);
}

function isFormData(req){
    var type = req.headers['content-type'] || '';
    return 0 == type.indexOf('multipart/form-data');
}

function upload(req, res){
    if(!isFormData(req)){
        res.statusCode=400;
        return;
    }
    var form = new formidable.IncomingForm();
    form.parse(req);
    form.on('field', function(field, value){
        console.log(field + "=" + value);
    });
    form.on('file', function(name, file){
        console.log(name + "=" + file);
    });
    form.on('end', function(){
        res.end("Upload complete!");
    });
};

server.listen(3000);

你也可以使用更加简单的API来处理整个表单解析后的结果。

    form.parse(req, function(err, fields, files){
        console.log(fields);
        console.log(files);
        res.end("Upload complete!");
    });

formidable的progress事件能给出收到的字节数,以及期望收到的字节数、我们可以借此做出一个进度条。

    form.on('progress', function(recv, total){
        var percentage = Math.floor(recv / total * 100);
        if(lastPercentage == percentage){
            return;
        }
        lastPercentage = percentage;
        console.log(percentage + "%");
    });

证书

在创建HTTP服务器的时候,我们可以提供证书,这样就能使用https协议。

var https = require('https');
var fs = require('fs');

var options = {
    key: fs.readFileSync(__dirname + '/../resources/server.raw.key'),
    cert: fs.readFileSync(__dirname + '/../resources/server.crt')
};

https.createServer(options, function(req, res){
    res.end("Hello, https");
}).listen(3000);

connect框架

connect核心

connect是一个第三方模块,其实现一个请求和响应的中间件。

一个最简单的connect程序如下:

var connect = require('connect');
var app = connect();
app.listen(3000);

connect的中间件是一个函数,可以接受三个参数,第一个是请求,第二个是响应,第三个则是下一个中间件,命名为next。

我们向其中增加一个中间件,用于打印日志:

var connect = require('connect');
function logger(req, res, next){
    console.log('%s %s', req.method, req.url);
    next();
}
var app = connect();
app.use(logger);
app.listen(3000);

之后我们增加一个响应请求的中间件,用于向客户端返回hello world。

var connect = require('connect');

function logger(req, res, next){
    console.log('%s %s', req.method, req.url);
    next();
}

function greet(req, res, next){
    res.end('Hello world');
}

var app = connect();
app.use(logger);
app.use(greet);
app.listen(3000);

如果我们交换logger和greet的use顺序,会发现logger永远不会被调用。这说明了中间件始终是加在上一个加入的中间件的尾部。

在使用use的时候可以额外提供一个字符串,如果字符串不是url的前缀,会跳过该中间件,前往下一个中间件。

app.use('/hello', greet);

下面我们看一下错误情况下connect是怎么处理的吧。

app.use(function hell(){
    foo();
    res.end('Hello world');
});

这里我们使用了一个不存在的函数foo,因此会抛出异常。connect最终以500状态码响应请求并输出栈信息。如果你希望能手动处理异常,可以实现一个错误处理中间件。错误处理中间件接受四个参数:err、req、res和next。

app.use(function(err, req, res, next){
    var env = process.env.NODE_ENV || 'development';
    res.statusCode = 500;
    switch(env){
        case 'development':
            res.end(JSON.stringify(err));
            break;
        default:
            res.end('Server error');
    }
});

在异常发生的情况下,只有异常发生的中间件后面的错误处理中间件会被调用,而普通中间件会直接被跳过。

解析cookie

Connect的cookie解析器支持常规cookie,签名cookie和特殊的JSON cookie。req.cookies默认是常规cookie,如果你想使用签名cookie,在调用cookieParser时需要传递秘钥。要设置cookie,可以通过HTTP请求头Set-Cookie字段。

为了避免Cookie被篡改,需要使用签名cookie。

var connect = require('connect');
var cookieParser = require('cookie-parser');

var app = connect();
app.use(cookieParser('tobi is a cool ferret'));
app.use(function(req, res, next){
    console.log(req.cookies);
    console.log((req.signedCookies));
    res.end('hello');
});

app.listen(3000);

只有通过签名校验的cookie才会出现在signedCookies中。签名cookie类似于abc.efg,点号左边的代表的是cookie实际值,右边代表这段cookie的数字签名。

bodyParser

bodyParser用于解析JSON、x-www-form-urlencoded和multipart/form-data请求,并将结果作为req.body对象。如果还包含文件上传,那么还同时会建立req.files对象。

bodyParser会将解析的结果放在内存中。容易发现如果攻击者提供了一个特别大的请求,那么会导致服务器内存溢出。可以用limits限制允许提交的最大请求体:

app.use(limits({
    enable: true,
    file_uploads: true,
    post_max_size: 10000000
}));

如果你想要为不同的文件类型增加不同的大小上限:

var limits = require('limits');

function createLimit(type, size){
    var limit = limits({
        enable: true,
        file_uploads: true,
        post_max_size: size
    });
    return function(req, res, next){
        var contentType = req.headers['content-type'] || '';
        if(contentType.indexOf(type) != 0)
        {
            next();
            return;
        }
        limit(req, res, next);
    };
}

var app = connect();
app.use(bodyParser())
.use(createLimit('multipart/form-data', 1000000000))
.use(createLimit('application/json', 1000000))
.use(createLimit('application/x-www-form-urlencoded', 1000000));

koa框架

koa框架是由Express幕后原班人马打造的全新web框架。通过利用async函数,koa帮助你丢弃回调函数,并有力地增强错误处理。

创建一个koa应用。

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
    ctx.body = 'Hello World';
});

app.listen(3000);

增加额外中间件:

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
    await next();
    const rt = ctx.response.get('X-Response-Time');
    console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

app.use(async (ctx, next) => {
    const start = Date.now();
    await next();
    const ms = Date.now() - start;
    ctx.set('X-Response-Time', `${ms}ms`);
});

app.use(async ctx => {
    ctx.body = 'Hello World';
});

app.listen(3000);

app.listen方法仅是一个语法糖,我们可以用下面的方式在多个HTTP服务器上绑定同一个app。

const http = require('http');
const https = require('https');
const Koa = require('koa');
const app = new Koa();
http.createServer(app.callback()).listen(3000);
https.createServer(app.callback()).listen(3001);

错误处理

默认情况下,所有错误都输出到stderr,除非app.siltent为true。要手动处理异常,可以如下:

多线程

Nodejs默认是单线程的,所有的js代码都在主线程中执行。而Nodejs的异步IO任务则是交付给异步线程处理,但是异步任务的回调函数还是作为任务投递到了主线程的任务队列中。

也正是因为这个原因,Nodejs拥有很高的IO性能,但是不擅于处理计算密集型的任务(阻塞了主线程,就没法及时响应其它请求了)。

work_threads模块允许我们创建工作线程来并行执行JS代码。使用工作线程,允许主线程将计算密集型任务转给异步线程来处理,从而减轻主线程压力。但是工作线程不会优化IO性能,因为Nodejs内置的异步IO操作比工作线程的效率更高。

工作线程和主线程之间不能共享内存,它们之间通过传输ArrayBuffer或SharedArrayBuffer实例来通信。

const {
    Worker, isMainThread, parentPort, workerData
} = require('worker_threads');

if(isMainThread){
    var values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    const worker1 = new Worker(__filename, {workerData: values.slice(0, 5)});
    const worker2 = new Worker(__filename, {workerData: values.slice(5, 10)});
    var sum = 0;
    var sumFunction = function(value){
        sum += value;
        console.log(sum);
    }
    worker1.on('message', sumFunction);
    worker2.on('message', sumFunction);
}else{
    var sum = 0;
    console.log(workerData);
    for(var index in workerData){
        sum += workerData[index];
    }
    parentPort.postMessage(sum);
}

isMainThread判断当前线程是主线程还是工作线程,主线程可以通过调用工作线程对象的postMessage方法向工作线程发送消息,反之,工作线程也可以通过调用parentPort的postMessage方法向主线程发送消息。消息可以通过监听message事件捕获。

参考资料