node 基础学习(一) -- 基础知识

博客分类: 原创

node 基础学习(一) -- 基础知识

瞎鸡巴忙了一个多月之后,对node终于有一点点认识了,我对于一个新东西的学习一向主张先搞个东西出来。这个过程中我很可能完全不懂,什么都乱用,但是这个过程之后,再次进行系统的学习的时候,有很多东西,你就可以结合当时自己做的实例来分析。当然仅供个人扯淡。

如果对javascript有深入的了解,那node简直是分分钟入门。所以最好在学习node之前温补一下javascript的基础知识,又把javascript 高级程序设计看了一遍,尽管一半时间都在打呼呼,但是还是算翻了一遍吧,至少对其中的一些大概了解了一下。真希望那本书能越翻越薄,越读越快。前期还做了一些笔记,后来觉得很多知识需要融会贯通才行,看那上面的知识完全不够。扯这些,就是想在学习node之前能把基础学习扎实了。

阻塞与非阻塞IO

程序执行过程中会有很多I/O操作,如读写文件、输入输出、请求响应等,当I/O操作时整个线程都会暂停下来等待,当前I/O操作完成后再继续造成了浪费等待时间。非阻塞I/O起实质是利用CPU最小时间片来处理事务的,发起I/O操作之后,就利用闲置时的时间片来询问内核是否处理完成,直到得到完整的数据。

对于一组互不相关的任务需要完成,现在主流的方式有两种:单线程串行依次执行、多线程并行执行。

单线程串行执行:比较符合逻辑思维,很显然,单线程容易发生阻塞,性能有瓶颈且既然是单线程就会对CPU资源利用率不高。

多线程并行执行:如果多线程开销小于并行执行的开销,这是一种非常好的方式,缺点也很明显消耗非常大,消耗主要在于线程创建及线程上下文切换。而且一个独立任务非常大时,这个独立任务切给多个线程进行计算,这样会面临资源浪费,如果切给多线程,会面临死锁,状态同步等问题。

node本身是单线程的,单线程是很容易造成阻塞的。但是node也拥有异步I/O的机制,可以在单进程的情况下尽可能的高利用线程。这个机制的好处在于:避免了单线程的阻塞,也避免了多线程的死锁和状态同步的问题。而且如果有多核CPUnode还提供子进程来对多核的利用。但是也会有个问题,由于CPU执行任务是分时间片的。所以每次时间片,显然需要一个确认结果是否完成的机制。CPU会长期处于判断阶段,判断任务有没有执行完成,这个会造成资源的浪费。node肯定希望减少这个浪费,我们常见的方式就是轮询

node的高效性

我们所知道的是I/O操作是最消耗时间的,特别是阻塞I/O,更会使得造成等待I/O执行浪费时间,node的异步I/O机制使得js线程在执行I/O操作时不会去等待,所以尽管只有一个js 线程,执行效率依旧是相当高的。当然,如果我们的服务器是多核的,node也提供开启子进程的方式去多核利用,同时我们还可以利用多核来做负载均衡。node由于是基于异步I/O的实现,所以本身就有着单线程能处理高并发的能力,多进程的使用只是提高了对多核的利用率,并非是更高效的处理并发。

异步I/O的处理

事件的处理大致有四个重要点:事件循环、观察者、请求对象、执行回调。

事件循环:进程开启时node便会创建一个类似while(true)的循环。最小的一次执行,就是一次Tick(这个时间片和cpu不同,是把所有事件循环一次的时间),每个请求对象被推入线程池的时候会挂到时间循环的loop上的pending_reqs_tail属性上,每个 Tick 的过程就是查看自己的loop上的pending_reqs_tail属性上有没有待处理的事件,如果有就取出并和相应的观察者去匹配I/O操作结果(去匹配,成功则拿到请求对象,执行其回调)。如果不再有事件处理就退出。

观察者:判断这个事件是否需要处理是由一个叫观察者的事务来决定的,异步I/O,网络I/O不断的将自己在线程池中的执行结果告诉不同的观察者,而事件循环则是看自己有没有与其相匹配的事件,事件循环去去询问观察者,观察者对这些事件对应自己手中的I/O操作结果进行匹配。匹配完成交给事件循环。

请求对象:当js线程接受到I/O操作请求时,到最终内核执行完I/O操作时会产生一个中间产物叫请求对象。这个请求对象在线程池中没有得到结果之前,保存着当前所有的状态,这个请求对象在完成I/O操作之后存储着对于回调的处理。

整个处理流程就是js线程接到了I/O操作,将调用底层方法(libuv的实现,还要分平台,linuxwindows)创建出了一个中间产物,就是请求对象,上面有各种信息,将js线程将这个请求对象推入线程池中,js线程就释放了,就可以去做下一个I/O操作,事件循环也将这个请求对象放在了loop属性上,开始进行事件循环,线程池空闲时执行请求对象中的I/O操作,将执行完成结果放在请求对象中(这里是I/O执行的结果),通知观察者搞定了,事件循环就不断的循环自己loop上某个属性上的事件,拿着事件去询问观察者,观察者匹配完成之后事件循环从loop上拿出请求对象,执行请求对象上的回调函数,完成整个异步I/O的操作。

异步方法

异步处理的方法有四种:setTimeoutsetIntervalprocess.nextTicksetImmediate

setTimeout:延时处理事件。

setInterval:没隔一段时间执行。

process.nextTick:延时最小时间片执行。效率比setTimeout(function(){},0)要高。

setImmediate:与process.nextTick的功能相似,但是没有process.nextTick优先级高。因为事件循环对观察者的检查是有先后顺序的,process.nextTickidle观察者,setImmediatecheck的观察者,被通知的优先级是:idle观察者 -> I/O观察者 -> check观察者

setImmediate(function(){
    console.log('setImmediate');
})
process.nextTick(function(){
    console.log('nextTick');
})
console.log('正常执行')
// 结果
// 正常执行
// nextTick
// setImmediate

// 在 深入浅出 书中,下面这个例子的执行顺序是
// 正常执行 -> nextTick1 -> nextTick2 -> setImmediate1 -> nextTick3 -> setImmediate2
process.nextTick(function(){
    console.log('nextTick1');
})
process.nextTick(function(){
    console.log('nextTick2');
})
setImmediate(function(){
    console.log('setImmediate1');
    process.nextTick(function(){
        console.log('nextTick3');
    })
})
setImmediate(function(){
    console.log('setImmediate2');
})
console.log('正常执行')
// 我得到的结果却是
// 正常执行 -> nextTick1 -> nextTick2 -> setImmediate1 -> setImmediate2 -> nextTick3

模块系统

整个模块结构:内建模块(C/C++) -> 核心模块(javascript) -> 文件模块

node的核心模块系统分为两部分(前两部分)。内建模块(C/C++)核心模块(javascript)javascript开发速度快于静态语言,但是性能不如静态语言。node采用复合开发的方式,希望在这里寻求一个平衡点。

通常我们把用纯C/C++编写的部分统一称为内建模块,nodebufferfscrypto等是C/C++开发的。存在node_module_list数组中。取的时候也非常好取。在编译时会被编译到二进制文件中,一旦node被执行这些会被直接加载到内存中,直接可执行。

模块化对于前端肯定不陌生,为了避免过多使用全局变量,也为了能更好的组织自己的代码,将一个文件拆成多个模块,进行开发,由此有两个模块化模式产生,AMD和CMD。在node中,称这部分为文件模块

引入模块时又分为相对模块与绝对模块。node的模块化引入了requireJsAMD类型模块化。你可以将其他模块预先加载进来,然后直接对其中的API进行使用,如果是node_modules中的模块或者是系统模块,可以直用require不加任何后缀和路径进行引用称之为绝对模块,如:引入文件系统var fs = require('fs');自己写了一个文件要引入依赖的话需要加上相对的路径。如:引入一个同级目录的module.js文件var module = require('./module.js')

暴露:module.js需要暴露出一些方法供引入者使用的话,直接写exports.name = 'karyn';外界引入时var module = require('./module.js')module.name === 'karyn'了,exports`就是暴露的一个对象,可以将暴露的方法挂载到该对象上,外界引用的时候可以获取到。

整个过程是怎么进行的呢?分为三个过程路径分析文件定位编译执行

路径分析

分析路径肯定会有标识符,require()就是一个标志,拿到这样的标志之后我们就需要分析括号里面的东西了,里面就是路径,路径一般分为四种:

  • 核心模块,如:http、fs等
  • .或者..开始的相对路径文件模块
  • 以/开始的绝对路径文件模块
  • 非路径形式的文件模块,如 express。不是核心模块,但也不是使用路径方式引用的

查找的优先级是:缓存加载 -> 核心模块 -> 路径文件 -> 非路径形式的文件模块。

文件定位

前面三种文件的定位都比较容易找到其路径。显然前三种都可以是很快的,因为有准确的路径。但是最后一种怎么保证其效率呢。沿着当前目录往上寻找名为node_modules的文件夹,直至找到文件。文件目录越深,越不容易找到,这就是为什么会慢。

编译

可以不用些扩展名,如:require('index');node会按照.js、.node、.json的方式补足。但是显然写完整是更有效率的,也不会出错的。

我们在书写代码的过程中,并没有发现exportsrequire等参数,为什么还能引用到呢?如果放到浏览器中看的话,就会是全局变量,但node是在编译的时候增加了头尾。这样的好处是显而易见的,可以避免文件间的变量相互影响污染。

(function (exports, require, module, __filename, __dirname){
    // 业务代码
});

包装之后会用vm.runInThisContext方法执行。该方法类似于eval,使用如:vm.runInThisContext('console.log(1)')

多个模块同时引入一个文件不会被编译多次,node内部有缓存机制。二次引入会走缓存加载。就不会再去编译文件了。

不同文件的结尾会使用不同的编译方式:.js的文件编译就是上面所说的编译流程。增加头尾,执行代码;.node结尾的文件不需要编译,因为本身就是C/C++写的,只需要执行;.json文件的编译只需要读取文件,使用JSON.parse()方法转换成js对象就行了。

编写一个核心模块

尽管我也对C/C++也一点都不懂。当服务器出现性能瓶颈的时候,可以考虑这种方式来解决。简单的模块通过javascript来编写可以大大提高生产效率。 有这样的方法,我并没有去验证。

// 将一下代码保存为 node_hello.h,存放到 Node 的 src 目录下:
#ifndef NODE_HELLO_H_
#define NODE_HELLO_H_
#include <v8.h>

namespace node{
    v8::Handle<v8::Value> SayHello(const v8::Arguments& args);
}
#endif

// 编写 node_hello.cc,并存储到 src 目录下
#include <node.h>
#include <node_hello.h>
#include <v8.h>

namespace node{
    using namespace v8;
    Handle<Value> SayHello(const Arguments& args) {
        HandleScope scope;
        return scope.Close(String::New("Hello world!"));
    }
    void Init_Hello(Handle<Object> target){
        target->Set(String::NewSymbol("sayHello"), FunctionTemplate::New(SayHello)->GetFunction());
    }
}
NODE_MODULE(node_hello, node::Init_Hello)

事件

事件的机制贯穿整个nodeJs,事件是Node非阻塞设计的重要体现。Node通常不会直接返回数据,而是采用分发事件来传递数据的方式。非阻塞的体现在于,我监听了一个事件之后我就不管了,知道有事件通知我,然后我再做回馈。分发事件也是一样的,分发了事件之后我也不管了,所以两者之间也不会是谁等谁,事件的这种机制就决定了非阻塞。使用方式如下:

var EventEmitter = require('events').EventEmitter,
    thisEvents = new EventEmitter();

thisEvents.on('hello', function(){
    console.log('hello')
});

thisEvents.emit('hello')

前面写过一个对于javascript事件的理解,事件的基本运行机制和怎样实现一个自定义事件。

异步编程解决方案

node中大量使用异步编程来提升性能。我们大致使用三种方案使用在异步编程中:事件机制Promise流程控制库

事件机制:使用方法如上。原理实现事件EventProxy是对于单纯事件的一个扩展。

Promise:使用方法Promise,也是一种非常好的异步编程的方法。

流程控制库:主要是通过流程控制模块来解决。

掌握这种编程方式会有非常多的体验。至少你不用担心你的大型计算会因为阻塞而变得非常慢。

buffer

Buffer是一个像Array的对象,但它主要用于操作字节。是挂载在全局对象上的。并且是非V8分配的内存而是在nodeC++层面实现内存的申请,属于堆外内存。

var str = '我是宋奇',
    buf = new Buffer(str, 'utf-8');
console.log(buf)        // <Buffer e6 88 91 e6 98 af e5 ae 8b e5 a5 87>

// buffer 受数组影响很大,声明方式,长度,访问方式都很类似
var buf = new Buffer(100);
console.log(buf.length);    // 100
console.log(buf[10]);       // 0

// 赋值
buf[20] = -100;
console.log(buf[20]);       // 156 = 256 - 100
buf[21] = 300;
console.log(buf[21]);       // 44  = 300 - 256
buf[22] = 3.1415
console.log(buf[22]);       // 3  =  省略小数点后面的数

Buffer8KB为界限来区分Buffer是大对象还是小对象。

Buffer可以与字符串之间相互转换,目前转换的字符串类型有:ASCIIUTF-8UTF-16LE/UCS-2Base64Hex

new Buffer(str, [encoding])     // 编码类型,默认按 UTF-8 编码进行转码和存储
new Buffer('aaa', 'utf-8')

buf.toString([encoding], [start], [end])
var buf = new Buffer('my name is karyn', 'utf-8')
buf.toString('utf-8', 0, 2)     // 'my'

Buffer.isEncoding('GBK')

编码类型转换类库iconv-lite。无法转换就是乱码,会有降级处理。多字节是,单字节是?

var iconv = require('iconv-lite'),
    buf = iconv.encode('my name is karyn', 'utf-8'),
    str = iconv.decode(buf, 'GBK');

流式读文件中的编码应用。

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

fs.createReadStream('/home/q/log/data/output/20150923.log').on('data', function(chunk){
    data += chunk;
}).on('end', function(){
    console.log(data)
})

// 上面的操作 data += chunk 隐藏了一个操作
data = data.toString() + chunk.toString();      // 默认是 utf-8

// 如果将上面的文件方法改为下面这种就会出现很多 �
createReadStream('/home/q/log/data/output/20150923.log', {highWaterMark: 11})
// 原因在于 UTF-8 占3个字节,只能显示3个字符。构造了11这个限制随时可能被截断,就会转换出乱码了
// 解决方案是 rs.setEncoding('utf-8');
// 这样的流式读法就不会产生刚才的问题了。但是这只能处理少数字符集的数据

// 正确的使用方式
var iconv = require('iconv-lite'),
    fs = require('fs');

var data = '',
    rs = fs.createReadStream('/home/q/log/data/output/20150923.log'),
    chunks = [],
    size = 0;

rs.on('data', function(chunk){
    chunks.push(chunk);
    size += chunk.length;
}).on('end', function(){
    var buf = Buffer.concat(chunks, size),
        str = iconv.decode(buf, 'utf8');
    console.log(str.split('\n'))
})

Buffer.concat = function(list, length){
    if(!Array.isArray(list)) {
        throw new Error('Usage: Buffer.concat(list, [length])');
    }

    if(list.length === 0){
        return new Buffer(0);
    }else if(list.length === 1){
        return list[0];
    }

    if(typeof length !== 'number'){
        length = 0;
        for(var i=0; i<list.length; i++){
            var buf = list[i];
            length += buf.length;
        }
    }

    var buffer = new Buffer(length),
        pos = 0;
    for(var i=0; i<list.length; i++){
        var buf = list[i];
        buf.copy(buffer, pos);
        pos += buf.length;
    }
    return buffer;
}

在网络传输的时候选用Buffer作为数据传输媒介,要更快,本身就是二进制文件,可以避免在传输前进行转化被损耗。

在读取文件时设置适当的参数会加快读取的速度。如:highWaterMarkstartend。比如第一个参数就很有考究,如果将第一个数取值为8KB的大小是最有利于读取文件的。具体为什么请了解buffer的内存分配

// 以下是读取测试,文件大小为 6MB
var iconv = require('iconv-lite'),
    fs = require('fs');

var data = '',
    rs = fs.createReadStream('/home/q/log/data/output/20150923.log', {
            flags: 'r',
            encoding: null,
            fd: null,
            mode: 0666,
            highWaterMark: 1 * 1024
        }),
    chunks = [],
    size = 0;
var timer = +new Date()
rs.on('data', function(chunk){
    chunks.push(chunk);
    size += chunk.length;
}).on('end', function(){
    var buf = Buffer.concat(chunks, size),
        str = iconv.decode(buf, 'utf8');
    console.log(+new Date() - timer);
})

// highWaterMark: 1 * 1024  -> 145 ms
// highWaterMark: 4 * 1024  -> 87 ms
// highWaterMark: 8 * 1024  -> 75 ms
// highWaterMark: 64 * 1024  -> 67 ms
// highWaterMark: 1024 * 1024  -> 60 ms
// 实际上结论可以看出这个值取得越大是读取的速度越快。当然过大也会开始下降,内存的也是一个考量方向,所以可以综合可以来看