由于Node.js把JavaScript引入了服务器端,因此,原来必须使用PHP/Java/C#/Python/Ruby等其他语言来开发服务器端程序,现在可以使用Node.js开发了!用Node.js开发Web服务器端,有几个显著的优势:
在Node.js诞生后的短短几年里,出现了无数种Web框架、ORM框架、模版引擎、测试框架、自动化构建工具,数量之多,即使是JavaScript老司机,也不免眼花缭乱。
常见的Web框架包括:Express,Sails.js,koa,Meteor,DerbyJS,Total.js,restify……
ORM框架比Web框架要少一些:Sequelize,ORM2,Bookshelf.js,Objection.js……
模版引擎PK:Jade,EJS,Swig,Nunjucks,doT.js……
测试框架包括:Mocha,Expresso,Unit.js,Karma……
构建工具有:Grunt,Gulp,Webpack……
Node.js内置的
fs
模块就是文件系统模块,负责读写文件。和所有其它JavaScript模块不同的是,fs
模块同时提供了异步和同步的方法。
回顾一下什么是异步方法。因为JavaScript的单线程模型,执行IO操作时,JavaScript代码无需等待,而是传入回调函数后,继续执行后续JavaScript代码。比如jQuery提供的getJSON()
操作:
$.getJSON('http://example.com/ajax', function (data) {
console.log('IO结果返回后执行...');
});
console.log('不等待IO结果直接执行后续代码...');
而同步的IO操作则需要等待函数返回:
// 根据网络耗时,函数将执行几十毫秒~几秒不等:
var data = getJSONSync('http://example.com/ajax');
同步操作的好处是代码简单,缺点是程序将等待IO操作,在等待时间内,无法响应其它任何事件。而异步读取不用等待IO操作,但代码较麻烦。
'use strict';
var fs = require('fs');
fs.readFile('sample.txt', 'utf-8', function (err, data) {
if (err) {
console.log(err);
} else {
console.log(data);
}
});
请注意,sample.txt
文件必须在当前目录下,且文件编码为utf-8
。
异步读取时,传入的回调函数接收两个参数,当正常读取时,err
参数为null
,data
参数为读取到的String。当读取发生错误时,err
参数代表一个错误对象,data
为undefined
。这也是Node.js标准的回调函数:第一个参数代表错误信息,第二个参数代表结果。后面我们还会经常编写这种回调函数。
由于err
是否为null
就是判断是否出错的标志,所以通常的判断逻辑总是:
if (err) {
// 出错了
} else {
// 正常
}
如果我们要读取的文件不是文本文件,而是二进制文件,怎么办?
下面的例子演示了如何读取一个图片文件:
'use strict';
var fs = require('fs');
fs.readFile('sample.png', function (err, data) {
if (err) {
console.log(err);
} else {
console.log(data);
console.log(data.length + ' bytes');
}
});
当读取二进制文件时,不传入文件编码时,回调函数的data
参数将返回一个Buffer
对象。在Node.js中,Buffer
对象就是一个包含零个或任意个字节的数组(注意和Array不同)。
Buffer
对象可以和String作转换,例如,把一个Buffer
对象转换成String:
// Buffer -> String
var text = data.toString('utf-8');
console.log(text);
或者把一个String转换成Buffer
:
// String -> Buffer
var buf = Buffer.from(text, 'utf-8');
console.log(buf);
除了标准的异步读取模式外,fs
也提供相应的同步读取函数。同步读取的函数和异步函数相比,多了一个Sync
后缀,并且不接收回调函数,函数直接返回结果。
用fs
模块同步读取一个文本文件的代码如下:
'use strict';
var fs = require('fs');
var data = fs.readFileSync('sample.txt', 'utf-8');
console.log(data);
可见,原异步调用的回调函数的data
被函数直接返回,函数名需要改为readFileSync
,其它参数不变。
如果同步读取文件发生错误,则需要用try...catch
捕获该错误:
try {
var data = fs.readFileSync('sample.txt', 'utf-8');
console.log(data);
} catch (err) {
// 出错了
}
将数据写入文件是通过fs.writeFile()
实现的:
'use strict';
var fs = require('fs');
var data = 'Hello, Node.js';
fs.writeFile('output.txt', data, function (err) {
if (err) {
console.log(err);
} else {
console.log('ok.');
}
});
writeFile()
的参数依次为文件名、数据和回调函数。如果传入的数据是String,默认按UTF-8编码写入文本文件,如果传入的参数是Buffer
,则写入的是二进制文件。回调函数由于只关心成功与否,因此只需要一个err
参数。
和readFile()
类似,writeFile()
也有一个同步方法,叫writeFileSync()
:
'use strict';
var fs = require('fs');
var data = 'Hello, Node.js';
fs.writeFileSync('output.txt', data);
由于Node环境执行的JavaScript代码是服务器端代码,所以,绝大部分需要在服务器运行期反复执行业务逻辑的代码,必须使用异步代码,否则,同步代码在执行时期,服务器将停止响应,因为JavaScript只有一个执行线程。
服务器启动时如果需要读取配置文件,或者结束时需要写入到状态文件时,可以使用同步代码,因为这些代码只在启动和结束时执行一次,不影响服务器正常运行时的异步执行。
在函数式编程里很常见,简单的说,就是使内部函数可以访问定义在外部函数中的变量。深入理解:http://coolshell.cn/articles/6731.html
闭包的一个坑
for (var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
}, 5);
}
上面这个代码块会打印五个 5
出来,而我们预想的结果是打印 0 1 2 3 4。
之所以会这样,是因为 setTimeout 中的 i 是对外层 i 的引用。当 setTimeout 的代码被解释的时候,运行时只是记录了 i 的引用,而不是值。而当 setTimeout 被触发时,五个 setTimeout 中的 i 同时被取值,由于它们都指向了外层的同一个 i,而那个 i 的值在迭代完成时为 5,所以打印了五次 5
。
为了得到我们预想的结果,我们可以把 i 赋值成一个局部的变量,从而摆脱外层迭代的影响。
for (var i = 0; i < 5; i++) {
(function (idx) {
setTimeout(function () {
console.log(idx);
}, 5);
})(i);
}
在函数执行时,this 总是指向调用该函数的对象。要判断 this 的指向,其实就是判断 this 所在的函数属于谁。
在《javaScript语言精粹》这本书中,把 this 出现的场景分为四类,简单的说就是:
1)函数有所属对象时:指向所属对象
函数有所属对象时,通常通过 .
表达式调用,这时 this
自然指向所属对象。比如下面的例子:
var myObject = {value: 100};
myObject.getValue = function () {
console.log(this.value); // 输出 100
// 输出 { value: 100, getValue: [Function] },
// 其实就是 myObject 对象本身
console.log(this);
return this.value;
};
console.log(myObject.getValue()); // => 100
getValue()
属于对象 myObject
,并由 myOjbect
进行 .
调用,因此 this
指向对象 myObject
。
2).函数没有所属对象:指向全局对象
var myObject = {value: 100};
myObject.getValue = function () {
var foo = function () {
console.log(this.value) // => undefined
console.log(this);// 输出全局对象 global
};
foo();
return this.value;
};
console.log(myObject.getValue()); // => 100
在上述代码块中,foo
函数虽然定义在 getValue
的函数体内,但实际上它既不属于 getValue
也不属于 myObject
。foo
并没有被绑定在任何对象上,所以当调用时,它的 this
指针指向了全局对象 global
。
据说这是个设计错误。
3)构造器中的 this:指向新对象
js 中,我们通过 new
关键词来调用构造函数,此时 this 会绑定在该新对象上。
var SomeClass = function(){
this.value = 100;
}
var myCreate = new SomeClass();
console.log(myCreate.value); // 输出100
顺便说一句,在 js 中,构造函数、普通函数、对象方法、闭包,这四者没有明确界线。界线都在人的心中。
4).apply 和 call 调用以及 bind 绑定:指向绑定的对象
apply() 方法接受两个参数第一个是函数运行的作用域,另外一个是一个参数数组(arguments)。
call() 方法第一个参数的意义与 apply() 方法相同,只是其他的参数需要一个个列举出来。
简单来说,call 的方式更接近我们平时调用函数,而 apply 需要我们传递 Array 形式的数组给它。它们是可以互相转换的。
var myObject = {value: 100};
var foo = function(){
console.log(this);
};
foo(); // 全局变量 global
foo.apply(myObject); // { value: 100 }
foo.call(myObject); // { value: 100 }
var newFoo = foo.bind(myObject);
newFoo(); // { value: 100 }
crypto模块的目的是为了提供通用的加密和哈希算法。用纯JavaScript代码实现这些功能不是不可能,但速度会非常慢。Nodejs用C/C++实现这些算法后,通过cypto这个模块暴露为JavaScript接口,这样用起来方便,运行速度也快。
MD5是一种常用的哈希算法,用于给任意数据一个“签名”。这个签名通常用一个十六进制的字符串表示:
const crypto = require('crypto');
const hash = crypto.createHash('md5');
// 可任意多次调用update():
hash.update('Hello, world!');
hash.update('Hello, nodejs!');
console.log(hash.digest('hex')); // 7e1977739c748beac0c0fd14fd26a544
update()
方法默认字符串编码为UTF-8
,也可以传入Buffer。
如果要计算SHA1,只需要把'md5'
改成'sha1'
,就可以得到SHA1的结果1f32b9c9932c02227819a4151feed43e131aca40
。
还可以使用更安全的sha256
和sha512
。
Hmac算法也是一种哈希算法,它可以利用MD5或SHA1等哈希算法。不同的是,Hmac还需要一个密钥:
const crypto = require('crypto');
const hmac = crypto.createHmac('sha256', 'secret-key');
hmac.update('Hello, world!');
hmac.update('Hello, nodejs!');
console.log(hmac.digest('hex')); // 80f7e22570...
只要密钥发生了变化,那么同样的输入数据也会得到不同的签名,因此,可以把Hmac理解为用随机数“增强”的哈希算法。
AES是一种常用的对称加密算法,加解密都用同一个密钥。crypto模块提供了AES支持,但是需要自己封装好函数,便于使用:
const crypto = require('crypto');
function aesEncrypt(data, key) {
const cipher = crypto.createCipher('aes192', key);
var crypted = cipher.update(data, 'utf8', 'hex');
crypted += cipher.final('hex');
return crypted;
}
function aesDecrypt(encrypted, key) {
const decipher = crypto.createDecipher('aes192', key);
var decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
var data = 'Hello, this is a secret message!';
var key = 'Password!';
var encrypted = aesEncrypt(data, key);
var decrypted = aesDecrypt(encrypted, key);
console.log('Plain text: ' + data);
console.log('Encrypted text: ' + encrypted);
console.log('Decrypted text: ' + decrypted);
运行结果如下:
Plain text: Hello, this is a secret message!
Encrypted text: 8a944d97bdabc157a5b7a40cb180e7...
Decrypted text: Hello, this is a secret message!
可以看出,加密后的字符串通过解密又得到了原始内容。
注意到AES有很多不同的算法,如aes192
,aes-128-ecb
,aes-256-cbc
等,AES除了密钥外还可以指定IV(Initial Vector),不同的系统只要IV不同,用相同的密钥加密相同的数据得到的加密结果也是不同的。加密结果通常有两种表示方法:hex和base64,这些功能Nodejs全部都支持,但是在应用中要注意,如果加解密双方一方用Nodejs,另一方用Java、PHP等其它语言,需要仔细测试。如果无法正确解密,要确认双方是否遵循同样的AES算法,字符串密钥和IV是否相同,加密后的数据是否统一为hex或base64格式。
DH算法是一种密钥交换协议,它可以让双方在不泄漏密钥的情况下协商出一个密钥来。DH算法基于数学原理,比如小明和小红想要协商一个密钥,可以这么做:
小明先选一个素数和一个底数,例如,素数p=23
,底数g=5
(底数可以任选),再选择一个秘密整数a=6
,计算A=g^a mod p=8
,然后大声告诉小红:p=23,g=5,A=8
;
小红收到小明发来的p
,g
,A
后,也选一个秘密整数b=15
,然后计算B=g^b mod p=19
,并大声告诉小明:B=19
;
小明自己计算出s=B^a mod p=2
,小红也自己计算出s=A^b mod p=2
,因此,最终协商的密钥s
为2
。
在这个过程中,密钥2
并不是小明告诉小红的,也不是小红告诉小明的,而是双方协商计算出来的。第三方只能知道p=23
,g=5
,A=8
,B=19
,由于不知道双方选的秘密整数a=6
和b=15
,因此无法计算出密钥2
。
用crypto模块实现DH算法如下:
const crypto = require('crypto');
// xiaoming's keys:
var ming = crypto.createDiffieHellman(512);
var ming_keys = ming.generateKeys();
var prime = ming.getPrime();
var generator = ming.getGenerator();
console.log('Prime: ' + prime.toString('hex'));
console.log('Generator: ' + generator.toString('hex'));
// xiaohong's keys:
var hong = crypto.createDiffieHellman(prime, generator);
var hong_keys = hong.generateKeys();
// exchange and generate secret:
var ming_secret = ming.computeSecret(hong_keys);
var hong_secret = hong.computeSecret(ming_keys);
// print secret:
console.log('Secret of Xiao Ming: ' + ming_secret.toString('hex'));
console.log('Secret of Xiao Hong: ' + hong_secret.toString('hex'));
运行后,可以得到如下输出:
$ node dh.js
Prime: a8224c...deead3
Generator: 02
Secret of Xiao Ming: 695308...d519be
Secret of Xiao Hong: 695308...d519be
注意每次输出都不一样,因为素数的选择是随机的。
RSA算法是一种非对称加密算法,即由一个私钥和一个公钥构成的密钥对,通过私钥加密,公钥解密,或者通过公钥加密,私钥解密。其中,公钥可以公开,私钥必须保密。
RSA算法是1977年由Ron Rivest、Adi Shamir和Leonard Adleman共同提出的,所以以他们三人的姓氏的头字母命名。
当小明给小红发送信息时,可以用小明自己的私钥加密,小红用小明的公钥解密,也可以用小红的公钥加密,小红用她自己的私钥解密,这就是非对称加密。相比对称加密,非对称加密只需要每个人各自持有自己的私钥,同时公开自己的公钥,不需要像AES那样由两个人共享同一个密钥。
在使用Node进行RSA加密前,我们先要准备好私钥和公钥。
首先,在命令行执行以下命令以生成一个RSA密钥对:
openssl genrsa -aes256 -out rsa-key.pem 2048
根据提示输入密码,这个密码是用来加密RSA密钥的,加密方式指定为AES256,生成的RSA的密钥长度是2048位。执行成功后,我们获得了加密的rsa-key.pem
文件。
第二步,通过上面的rsa-key.pem
加密文件,我们可以导出原始的私钥,命令如下:
openssl rsa -in rsa-key.pem -outform PEM -out rsa-prv.pem
输入第一步的密码,我们获得了解密后的私钥。
类似的,我们用下面的命令导出原始的公钥:
openssl rsa -in rsa-key.pem -outform PEM -pubout -out rsa-pub.pem
这样,我们就准备好了原始私钥文件rsa-prv.pem
和原始公钥文件rsa-pub.pem
,编码格式均为PEM。
下面,使用crypto
模块提供的方法,即可实现非对称加解密。
首先,我们用私钥加密,公钥解密:
const
fs = require('fs'),
crypto = require('crypto');
// 从文件加载key:
function loadKey(file) {
// key实际上就是PEM编码的字符串:
return fs.readFileSync(file, 'utf8');
}
let
prvKey = loadKey('./rsa-prv.pem'),
pubKey = loadKey('./rsa-pub.pem'),
message = 'Hello, world!';
// 使用私钥加密:
let enc_by_prv = crypto.privateEncrypt(prvKey, Buffer.from(message, 'utf8'));
console.log('encrypted by private key: ' + enc_by_prv.toString('hex'));
let dec_by_pub = crypto.publicDecrypt(pubKey, enc_by_prv);
console.log('decrypted by public key: ' + dec_by_pub.toString('utf8'));
执行后,可以得到解密后的消息,与原始消息相同。
接下来我们使用公钥加密,私钥解密:
// 使用公钥加密:
let enc_by_pub = crypto.publicEncrypt(pubKey, Buffer.from(message, 'utf8'));
console.log('encrypted by public key: ' + enc_by_pub.toString('hex'));
// 使用私钥解密:
let dec_by_prv = crypto.privateDecrypt(prvKey, enc_by_pub);
console.log('decrypted by private key: ' + dec_by_prv.toString('utf8'));
执行得到的解密后的消息仍与原始消息相同。
如果我们把message
字符串的长度增加到很长,例如1M,这时,执行RSA加密会得到一个类似这样的错误:data too large for key size
,这是因为RSA加密的原始信息必须小于Key的长度。那如何用RSA加密一个很长的消息呢?实际上,RSA并不适合加密大数据,而是先生成一个随机的AES密码,用AES加密原始信息,然后用RSA加密AES口令,这样,实际使用RSA时,给对方传的密文分两部分,一部分是AES加密的密文,另一部分是RSA加密的AES口令。对方用RSA先解密出AES口令,再用AES解密密文,即可获得明文。
crypto模块也可以处理数字证书。数字证书通常用在SSL连接,也就是Web的https连接。一般情况下,https连接只需要处理服务器端的单向认证,如无特殊需求(例如自己作为Root给客户发认证证书),建议用反向代理服务器如Nginx等Web服务器去处理证书。