三天node入门(day3)

前言

被催更的day3今天终于发了,
day1
day2
还是那句话,有疑问或是文章中有错误的地方,请在评论区指出来,如果私信的话别人就看不到了,加油一起进步~~(゜▽゜*)♪

目录

  1. 异步编程
  2. 一个大示例

1.异步编程

异步编程是node最大的特点,虽然也受到众多开发者的诟病,但不会异步编程就不算完全学会node。

回调

异步编程的体现为回调,异步编程依托于回调来实现,但并不是回调之后就异步了。。

写一个运行时间较长的函数

function heavyCompute(n,cb){
    let count=0,i,j

    for(i=n;i>0;--i){
        for(j=n;j>0;--j){
            count+=1
        }
    }

    cb(count)
}

heavyCompute(10000,count=>{
    console.log(count)
})

console.log('hello')

------------
100000000
hello

从上面的输出结果可以看出,代码中的回调函数仍然先于后续代码执行。没有执行完回调函数是不会运行其他的代码,所以这并不是异步。但是在做运行某个函数的时候,创建一个新的进程或是线程,并与js主线程并行的做一些事情,在事情做完后,直接通知主线程,这情况就又不一样了。

setTimeout(function() {
    console.log('world')
}, 2000)

console.log('hello')
--------
hello
world

写前端的都知道上面的执行顺序。这次回调函数后于后续代码的执行了。如上,js本身为单线程的,无法异步执行,因此我们可以认为setTimeout这类js规范之外由运行环境提供的特殊函数做的事情时创建一个平行线程后立即返回,让js主进程可以接着执行后续代码,并在收到平行进程的通知后再执行回调函数。除了setTimeoutsertInerval这些原生的,还包括node中提供了诸如fs.readFile这样的异步api。

即便js是单线程的,这决定了js在执行完一段代码之前无法执行包括回调函数在内的别的代码。也就是说,通知js主线程执行回调了,回调函数也要等到js主线程空闲时才能开始执行。


setTimeout(function() {
    console.log('world')
}, 2000)

function heavyCompute(n) {
    var count = 0,
        i, j;

    for (i = n; i > 0; --i) {
        for (j = n; j > 0; --j) {
            count += 1;
        }
    }
}

var t = new Date();

setTimeout(function () {
    console.log(new Date() - t);
}, 1000);

heavyCompute(50000);

运行下试试看,本应该在1s后被调用的回调函数因为js主线程忙于其他代码,实际执行时间被大幅延迟。

遍历数组

在遍历数组时,使用某个函数依次对数据成员做一些处理也是常见的需求。当函数为异步执行的,数组必须一个一个串行处理,异步函数在执行一次并返回执行结果后才传入下一个数组成员并开始下一轮执行,直到所有数组成员处理完毕后,通过回调方式触发后续代码的执行。如果数组成员可以并行处理,但后续代码仍然需要所有数组处理完毕后才能执行的话,异步代码会调整为一下形式:

(function (i, len, count, callback) {
    for (; i < len; ++i) {
        (function (i) {
            async(arr[i], function (value) {
                arr[i] = value;
                if (++count === len) {
                    callback();
                }
            });
        }(i));
    }
}(0, arr.length, 0, function () {
    // All array items have processed.
}));

异常处理

注意try...catch 只能用于同步执行的代码。异常会沿着代码执行路径一直冒泡,知道遇到第一个try语句时被捕获,但由于异步函数会打断代码执行路径,异步函数执行过程中以及执行后产生的异常冒泡到执行路径被打断的位置时,如果一直没有遇到try语句,则作为一个全局异常抛出。

function async(fn,cb){
    setTimeout(function() {
        console.log('async')
        cb(fn())
    }, 1000)
}

try{
    async(null,()=>{
        console.log('try')
    })
}catch(err){
    console.log(`err:${err.message}`)
}

----------------
C:\Users\mytac\Desktop\test\node_test.js:8
                cb(fn())
                   ^

TypeError: fn is not a function
    at Timeout._onTimeout (C:\Users\mytac\Desktop\test\node_test.js:8:6)
    at ontimeout (timers.js:469:11)
    at tryOnTimeout (timers.js:304:5)
    at Timer.listOnTimeout (timers.js:264:5)

因为代码执行路径被打断了,我们就需要在异常冒泡到断点之前用try语句把异常捕获住,并通过回调函数传递被捕获的异常。于是我们可以像下边这样改造上边的例子。

function async(fn, cb) {
    setTimeout(function() {
        try {
            cb(null,fn())
        } catch (err) {
            cb(err)
        }
    }, 1000)
}

async(null,(err,data)=>{
    if(err){
        console.log(`Error:${err.message}`)
    }else{
        console.log('success')
    }
})

-------------
Error:fn is not a function

可以看到,异常再次被捕获。node中几乎所有的异步api都像上述方式设计,回调函数第一个参数都是err,因此我们在编写自己设计的异步函数时,也要按照这种方法处理异常,与node设计风格保持一致。

有了异常处理方式后,我们可以接着想一想一般我们怎么写代码。通常我们都是写一个函数做一些事情,然后再调用一些函数,如此循环。如果我们写的是同步代码,只需要在代码入口写一个try语句就可以捕获所以冒泡上来的异常,示例如下:

function main() {
    // Do something.
    syncA();
    // Do something.
    syncB();
    // Do something.
    syncC();
}

try {
    main();
} catch (err) {
    // Deal with exception.
}

如果写的是异步代码,由于每次异步调用都会打断代码执行路径,只能通过回调函数来传递异常,于是我们需要在每个回调函数里判断是否有异常发生

function main(callback) {
    // Do something.
    asyncA(function (err, data) {
        if (err) {
            callback(err);
        } else {
            // Do something
            asyncB(function (err, data) {
                if (err) {
                    callback(err);
                } else {
                    // Do something
                    asyncC(function (err, data) {
                        if (err) {
                            callback(err);
                        } else {
                            // Do something
                            callback(null);
                        }
                    });
                }
            });
        }
    });
}

main(function (err) {
    if (err) {
        // Deal with exception.
    }
});

我们会看到以上代码嵌套很深,回调函数让代码看起来更加复杂了,而异步方式下对异常的处理加剧了代码的复杂度。

node提供domain模块,可以简化异步代码的异常处理。域是一个js的运行环境,如果有一个异常没有被捕获,将作为一个全局异常被抛出。node通过process对象提供了捕获全局异常的方法,示例:

process.on('uncaughtException',err=>{
    console.log(err.message)
})

setTimeout(function(fn) {
    fn()
},1000)

全局有个地方可以捕获,对于大多数异常,我们更希望尽早捕获,并根据结果决定代码执行路径。写一个HTTP服务器代码:

function async(req,cb){
    asyncA(req,(err,data)=>{
        if(err){
            cb(err)
        }else{
            asyncB(req,(err,data)=>{
                if(err){
                    cb(err)
                }else{
                    cb(null,data)
                }
            })
        }
    })
}

http.createServer((req,res)=>{
    async(req,(err,data)=>{
        if(err){
            res.writeHead(500)
            res.end()
        }else{
            res.writeHead(200)
            response.end(data)
        }
    })
})

以上代码将请求对象交给异步函数处理后,根据处理结果返回响应。这里使用上文提到的用回调处理异常的方法,因此async内部如果在多几个异步函数的话,代码会嵌套更多。为了让代码变得好看,我们在处理每个请求时,使用domain模块创建一个子域。在子域内运行的代码可以随意抛出异常,这些异常可以通过子域对象的error事件统一捕获,改造后的代码如下:

const domain=require('domain')

function async(req,cb){
    asyncA(req,(err,data)=>{
            asyncB(req,(err,data)=>{
                    cb(null,data)
            })
    })
}

http.createServer((req,res)=>{
    const d=domain.create()
    d.on('error',()=>{
        res.writeHead(500)
        res.end()
    })

    d.run(()=>{
        async(req,data=>{
            res.writeHead(200)
            response.end(data)
        })
    })
})

使用.create方法创建一个子域对象,并通过.run进入需要在子域中运行的代码的入口点。而位于子域中的异步函数回调函数由于不在需要捕获异常,代码变得整洁了很多。

陷阱

无论是通过processuncaughtException事件捕获到全局异常,还是通过子域对象error事件捕获到了子域异常,在node官方文档里都强烈建议处理完异常后立即重启程序,而不是让程序运行。按照官方的说法时,发生异常后的程序处于一个不确定的状态,不立即退出,会发生内存泄漏,或是其他奇怪的表现。

throw..try..catch异常处理机制并不会导致内存泄漏,也不会让程序的执行结果出乎意料,但node中大量api是用c/c++ 实现的,因此在node运行时,代码执行于js内部和外部,而js抛出异常可能会打断正常的执行流程,导致内存泄漏。因此,使用uncaughtExceptiondomain捕获异常,代码执行路径里涉及到了内部c/c++代码时,如果不能确定是否会造成内存泄漏的问题,最好在处理完异常后重启程序更好。而使用try语句捕获异常时一般捕获到的都是js本身的异常,不用担心上述问题。

一个大示例

需求说明

我们现在开发的是简单的静态文件合并服务器,支持js和css文件合并请求,请求格式如下:

http://assets.example.com/foo/??bar.js,baz.js

在以上的url中,??代表一个分隔符,之前是需要合并的多个文件的url公共部分,之后是使用,分隔的差异部分。服务器再处理这个url时,返回的是以下两个文件按顺序合并后的内容。

/foo/bar.js
/foo/baz.js

另外,服务器也需要能支持以下格式的普通js或css文件请求。

http://assets.example.com/foo/bar.js

以上就是整个需求。

第一次迭代

我们在第一次迭代中,先实现服务器的基本功能。

设计

简单的分析需求之后,我们会得到以下的设计方案

request -->|  parse  |-->|  combine  |-->|  output  |--> response

服务器首先会先分析URL,得到请求的文件的路径和MIME类型,然后服务器读取请求的文件,按顺序合并文件内容。最后,服务器返回响应,完成对一次请求的处理。另外,服务器再读取文件时需要个根目录,并且服务器监听的HTTP端口最好不要写死在代码里,做到服务器是可配置的。

const fs=require('fs'),
      path=require('path'),
      http=require('http')

const MIME={
    ".css":'text/css',
    ".js":'application/javascript'
}

// 先分析url,解析出对应的mime类型和完整的路径集合
function parseURL(root,url){
    let base,pathname,parts

    if(url.indexOf('??') === -1){
        url=url.replace('/','/??')
    }

    parts=url.split('??')
    base=parts[0]
    pathnames=parts[1].split(',').map(value=>path.join(root,base,value))
    return {
        mime:MIME[path.extname(pathnames[0])]||'text/plain',
        pathnames:pathnames
    }
}

// 根据解析后的pathnames合并对应的文件
function combineFiles(pathnames,cb){
    let output=[];

    (function next(i,len){
        if(i<len){
            fs.readFile(pathnames[i],(err,data)=>{
                if(err){
                    cb(err)
                }else {
                        output.push(data)
                        next(i+1,len)
                    }
                })
            }else{
                cb(null,Buffer.concat(output))
            }
        }(0,pathnames.length));
}

// 启动服务
function main(argv){
    const config=JSON.parse(fs.readFileSync(argv[0],'utf-8')),
         root=config.root||'.',
         port=config.port||80

    http.createServer((req,res)=>{
        const {mime,pathnames}=parseURL(root,req.url)
        console.log(`the server is running on ${port}`)

        combineFiles(pathnames,(err,data)=>{
            if(err){
                res.writeHead(404)
                res.end(err.message)
            }else{
                res.writeHead(200,{
                    'Content-Type':mime
                })
                res.end(data)
            }
        })
    }).listen(port)
}

main(process.argv.slice(2))

以上,我们就实现了需求中所提到的功能

  1. 使用命令行参数传递JSON配置文件路径,入口函数负责读取配置并创建服务器
  2. 入口函数完整描述了程序的运行逻辑,其中解析URL和合并文件的具体实现封装在其他两个函数里
  3. 解析url时先将普通url转换为了文件合并URL,使得两种URL的处理方式可以一致
  4. 合并文件时使用异步API读取文件,避免服务器因等待磁盘IO而发生阻塞。

第二次迭代

在上一个方法中,我们有了一个可以工作的版本,满足了需求。

 发送请求       等待服务端响应         接收响应
---------+----------------------+------------->
         --                                        解析请求
           ------                                  读取a.js
                 ------                            读取b.js
                       ------                      读取c.js
                             --                    合并数据
                               --                  输出响应

但我们请求合并输出多个文件时,串行读取文件会比较耗时,服务端响应时间会很长。由于每次响应输出的数据都先缓存再内存中,当服务器请求并发数较大时,会有较大的内存开销。

我们会很容易想到把读取文件的方式从串行改为并行。但是对于机械磁盘而言,因为只有一个磁头,尝试并读取文件只会造成磁盘频繁抖动,反而降低IO效率。对于固态硬盘来说,虽然的确存在多个并行IO通道,但是对于服务器并行处理的多个请求而言,硬盘已经在做IO了,对单个请求采用并行IO无异于拆东墙补西墙。因此,正确的方法不是改用并行IO,而是一边读取文件一边输出响应,把响应输出时机提前至第一个文件的时刻。

发送请求 等待服务端响应 接收响应
---------+----+------------------------------->
         --                                        解析请求
           --                                      检查文件是否存在
             --                                    输出响应头
               ------                              读取和输出a.js
                     ------                        读取和输出b.js
                           ------                  读取和输出c.js

在第一版的基础上,更改了相关代码如下:

// 判断路径是否为一个文件,并进行错误处理
function validateFiles(pathnames,cb){
    (function next(i,len){
        if(i<len){
            fs.stat(pathnames[i],(err,stat)=>{
                if(err){
                    cb(err)
                }else if(stat.isFile()){
                    next(i+1,len)
                }else{
                    cb(new Error())
                }
            })
        }else{
            cb(null,pathnames)
        }
    }(0,pathnames.length))
}

// 输出文件
function outputFiles(pathnames,writer){
    (function next(i,len){
        if(i<len){
            const reader=fs.createReadStream(pathnames[i]);
            reader.pipe(writer,{end:false}) //end:false->在 reader 结束时结束 writer  默认为true
            reader.on('end',()=>{
                next(i+1,len)
            })
        }else{
            writer.end()
        }
    }(0,pathnames.length))
}

// 启动服务
function main(argv){
    const config=JSON.parse(fs.readFileSync(argv[0],'utf-8')),
         root=config.root||'.',
         port=config.port||80

    http.createServer((req,res)=>{
        const {mime,pathnames}=parseURL(root,req.url)


        validateFiles(pathnames,(err,data)=>{
            if(err){
                res.writeHead(404)
                res.end(err.message)
            }else{
                res.writeHead(200,{
                    'Content-Type':mime
                })
                outputFiles(pathnames,res)
            }
        })
    }).listen(port)
}

第三次迭代

经过第二次迭代后,服务器的性能和功能已经得到了基本满足。

但整个服务器也有可能因为外部因素而挂掉。一般在生产环境下都配有守护进程,在服务挂掉的时候立即重启服务。在这个程序中,我们把守护进程作为父进程,服务器程序作为子进程,并让父进程监控子进程的状态,在异常时退出重启子进程。

我们新建一个叫做deamon.js的文件:

const cp=require('child_process')

let worker;

function spawn(server,config){
    worker=cp.spawn('node',[server,config])
    worker.on('exit',code=>{
        if(code!==0){
            spawn(server,config)
        }
    }
}

function main(argv){
    spawn('server.js',argv[0])
    process.on('SIGNTERM',()=>{
        worker.kill()
        process.exit(0)
    })

}

main(process.argv.slice(2))

同时,我们也要在server的入口,在收到SIGNTERM信号的时候将服务停掉,再正常退出。

// server.js
...
function main(argv){
// ....
    process.on('SIGNTERM',()=>{
        server.close(()=>{
            process.exit(0)
        })
    })
}

之后,命令行键入node deamon.js config.json启动服务,我们有了守护进程,这个服务就显得靠谱多了~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容