NodeJs in Action (一)

杨旭 bio photo By 杨旭

前言

  • Node.js是专为编写网络和Web应用 而生的全新平台
  • Node.js基于v8引擎带来了优越的性能
  • Node.js的主要理论和创新就是它完全构 建在事件驱动、非阻塞的编程模型之上。
  • 大量的三方模块给他的开发效率带来极大的提高
  • Node.js采用单进程运行,占用的资源大大减少
  • Node.js适合与频繁的短时请求场景,对于密集计算长耗时的应用并不适合

    书籍源码:https://github.com/marcwan/LearningNodeJS


第一部分 基础


入门

Node Shell - Node REPL(Read-Eval-Print-Loop)

安装Node.js之后,在控制台输入node命令进入Shell

输入命令并执行,控制台可以看到执行结果

最后一行的输出往往是指令的返回结果,下图中的console.log并没有返回值,显示为undefined

如果想要退出REPL,只需要按下Ctrl+D

如果控制台中出现三个点(…),表示需要进一步输入,以完成表达式,便于多行输入。

编辑并运行JS文件

创建js文件,通过 node file.js来执行:

第一个web服务器

新建文件first-web-server.js

const http = require('http') // 引入http模块

const processRequest = (req, res) => {
    const body = `Thank you for calling!`
    const bodyLength = body.length
    res.writeHead(200, { // 写入响应头
        'Content-Length': bodyLength,
        'Content-Type': 'text/plain'
    })
    res.end(body) // 写入响应内容
}

const server = http.createServer(processRequest) // 创建web服务和处理函数
server.listen(2000) // 启动并监听2000端口

执行node first-web-server.js启动web服务,并在页面访问

或者通过curl查看请求的头信息

过程

  • 用户访问2000端口时,进入到processRequest函数
  • 参数中带有请求信息req(ServerRequest实例)和响应对象res(ServerReponse实例)
  • 从res中获取参数并通过res返回给最终用户

    调试Node.js应用

执行node debug first-web-server.js启动调试模式

添加断点

使用setBreakPoint(n)设置断点

通过repl查看变量值

使用vscode调试

调试面板中加在nodejs配置,修改文件路径

在程序中添加断点后,在调试面板运行配置

使用WebStorm调试

添加断点后,点击调试

Node.js中的特殊对象

  • global - node中的全局对象,类似于web端的window对象
  • console - 控制台对象
  • process - 包含当前执行进程的信息和控制方法

    异步编程

在nodejs中,文件的读写是异步执行

执行如下代码:

const fs = require('fs') // 加载文件模块

let fileHandler

const buffer = new Buffer(10000) // 创建Buffer缓存

fs.open('some.text', 'r', (handler) => { // 使用只读方式打开文件
    fileHandler = handler
})

fs.read(fileHandler, buffer, 0, 10000, null, () => { // 读取文件
    console.log(buffer.toString())
    fs.close(fileHandler, () => {})
})

原因是:文件的打开操作open是异步执行的,当调用read的时候,尚未完成。 修复代码如下:

const fs = require('fs') // 加载文件模块


const buffer = new Buffer(10000) // 创建Buffer缓存

fs.open('some.text', 'r', (err, handler) => { // 使用只读方式打开文件

    fs.read(handler, buffer, 0, 10000, null, (err, length) => { // 读取文件
        console.log(buffer.toString('utf8', 0, length)) 
        fs.close(handler, () => {})
    })
})

nodejs使用单线程队列机制,等待用户请求,将操作放入队列等待执行;当执行完成后,通过回调函数响应用户请求;同时清空队列,挂起等待新请求

错误处理与异步函数

考虑如下代码:

try {
    setTimeout(() => {
        throw new Error('throw an error')
    }, 2000)
} catch(e) {
    console.log('i have caught an error')
}

我们希望捕获一个异常,但是实际情况是错误被直接抛出。

实际情况是,setTimeout在try-cache上下文中执行,但是异步函数(throw语句)被加入到执行队列,2秒后在一个全新到上下文中执行。

在Node中经常使用其他的核心模式来处理错误

回调函数和错误处理

例如:

fs.open('some.text', 'r', (err, handler) => { // 使用只读方式打开文件
    //...
})

err对象用来包含错误信息:

  • 正常情况下err的值为null
  • 错误情况下,err对象是Error实例,通常包含code和message字段以及其他的详细错误信息

    异步函数中的this

虽然定义在对象中,但是在运行过程中指向调用者。

  • 使用var self = this; 保存this的引用
  • 使用ES6中的箭头函数

    保持优雅——放弃控制权

    Node采用单线程执行,在执行密集计算任务的时候,会出现程序假死:

const len = 1e10

console.time()
for (let i = 0; i < len; i++) {
    Math.pow(2, 53)
}

console.timeEnd()

通常情况下,类似的任务适合在其他平台实现,由nodejs进行调用。

或者利用全局对象process中的nextTick方法,通知系统在空闲时才来执行我的任务。

const len = 1e10
const size = 1e4
let index = 0

console.time()

const calculate = () => {
    for (let i = index; i < index + size; i++) {
        Math.pow(2, 53)
    }

    if (i >= length) {
        console.log('finished')
    } else {
        index += size
        process.nextTick(calculate)
    }
}

console.timeEnd()

同步函数调用

Node有一些核心API的同步版本,尤其是在操作文件的API中:

const fs = require('fs') // 加载文件模块


const buffer = new Buffer(10000) // 创建Buffer缓存

const handler  = fs.openSync('some.text', 'r') // 同步打开,返回文件描述符
const length = fs.readSync(handler, buffer, 0, 10000, null) // 同步读取,返回读取长度

console.log(buffer.toString('utf8', 0, length))

fs.closeSync(handler)

第二部分 提高


编写简单应用

本章中,我们将会编写一个JSON服务器,用以提供相册列表以及每个相册对应的照片列表等服务,最后,还会添加为相册重命名的功能。 我们会 理解JSON服务器运行的基本知识,这其中包含了服务器与HTTP进行交互的基础知识,例如GET和POST参数、头信息、请求和响应。

创建web服务

const http = require('http')

const processRequest = (req, res) => {
    console.log(`Incoming request: ${req.method} ${req.url}`)
    res.writeHead(200, {'Content-Type': 'application/json'})

    res.end(JSON.stringify({error: null}) + '\n')
}

const server = http.createServer(processRequest)
server.listen(8080)
  • 发起请求时,控制台打印请求信息

  • 页面得到响应

创建相册目录

首先要列出albums目录下的所有相册目录

const loadAlbumList = (callback) => {
    fs.readdir('albums/', (err, files) => { // 遍历目录
        if (err) {
            callback(err)
            return
        }
        callback(null, files)
    })
}

loadAlbumList((err, files) => {
    console.log(files)
})

异步调用中的callback就是Node应用编程的核心技术:告诉Node去做某件事情, 并在Node完成时告知Node将结果传递给谁

完整代码如下:

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

const loadAlbumList = (callback) => {
    fs.readdir('albums/', (err, files) => { // 遍历目录
        if (err) {
            callback(err)
            return
        }
        callback(null, files)
    })
}

const processRequest = (req, res) => {
    console.log(`Incoming request: ${req.method} ${req.url}`)

    loadAlbumList((err, albums) => {
        if (err) {
            res.writeHead(503, {'Content-Type': 'application/json'})
            res.end(JSON.stringify(err) + '\n')
            return
        }

        res.writeHead(200, {'Content-Type': 'application/json'})
        res.end(JSON.stringify({error: null, albums}) + '\n')
    })
}

const server = http.createServer(processRequest)
server.listen(8080)

异步与循环

当相簿中还存在其他文本文件时:

我们的程序会返回:

需要使用fs.stat对文件类型进行判断:


const loadAlbumList = (callback) => {
    fs.readdir('albums/', (err, files) => { // 遍历目录
        if (err) {
            callback(err)
            return
        }

        const dirs = [];

        for (let i = 0; i < files.length; i++) {
            const file = files[i];
            
            fs.stat(`albums/${file}`, (err, stat) => {
                if (stat.isDirectory()) {
                    dirs.push(file)
                }
            })
        }

        callback(null, dirs)
    })
}

得到的结果却是:

原因在于:在异步编程模型下,大部分循环都不能与异步回调不能兼容

  • 当程序执行循环中的异步函数时,主线程不会等待,已经将空数组返回给回调
  • 当fs.stat执行完毕,并将结果push到数组时,数组的结果已经没有人关心了

此时只能使用递归来解决


const loadAlbumList = (callback) => {
    fs.readdir('albums/', (err, files) => { // 遍历目录
        if (err) {
            callback(err)
            return
        }

        const dirs = [];

        (function iterator(i) {
            if (i < files.length) {

                const file = files[i]

                fs.stat(`albums/${file}`, (err, stat) => {

                    if (err) {
                        callback(err)
                        return
                    }

                    if (stat.isDirectory()) {
                        dirs.push(file)
                    }

                    iterator(i + 1)
                })
            } else {
                callback(null, dirs)
            }
        })(0)
    })
}

如果使用ES6的promise进行重构:


const fileState = (file) => {
    return new Promise((resolve, reject) => {
        fs.stat(`albums/${file}`, (err, stat) => {
            console.log(stat)
            if (err) {
                reject(err)
            } else {
                resolve({
                    file,
                    isDir: stat.isDirectory()
                })
            }
        })
    })
}

const filterDirectories = (files, callback) => {
    const promiseList = files.map(file => fileState(file))

    Promise.all(promiseList).then(fileStats => {
        callback(null, fileStats.filter(f => f.isDir).map(f => f.file))
    }).catch(err => {
        callback(err)
    })
}

const loadAlbumList = (callback) => {
    fs.readdir('albums/', (err, files) => { // 遍历目录
        if (err) {
            callback(err)
            return
        }

        filterDirectories(files, callback)
    })
}

添加更多功能(代码回归到书主的示例,避免后续出现歧义)

https://github.com/marcwan/LearningNodeJS/blob/master/Chapter04/05_multiple_requests.js

  • 可用相册列表——调用/albums.json请求。
  • 相册中的照片列表——调用/albums/album_name.json请求。
  • 为请求添加.json后缀,强调当前编写的JSON服务器只用于这类请求。

更多请求的详情

  • writeHead和end。对于传入的每个请求都必须调用一次且只能调用一次end方法
  • HTTP状态码:http://en.wikipedia.org/wiki/List_of_HTTP_status_codes

提高灵活性:GET参数

当开始往相册中添加大量照片时,应用需要在一个“页面”中高 效地展示大量照片,所以我们需要为应用提供分页功能,而客户端需 要能够告诉我们需要多少张照片以及是哪一页的照片

http://localhost:8080/albums/china2009.json?page=1&page_size=2
  • 添加Node内置 的url模块后,可以使用url.parse函数来提取核心的URL路径名以及查询参数
  • url.parse函数可以更进一步给函数添加第二个参数——true,这个参数告诉url.parse函数解析查询字符串,并生成包含GET参数的对象
const url = require('url')

function handle_incoming_request(req, res) {

    console.log('INCOMING REQUEST: ' + req.method + ' ' + req.url)

    req.parsed_url = url.parse(req.url, true)
    const core_url = req.parsed_url.pathname

    if (core_url == '/albums.json') {
        handle_list_albums(req, res)
    } else if (core_url.substr(0, 7) == '/albums'
        && core_url.substr(req.url.length - 5) == '.json') {
        handle_get_album(req, res)
    } else {
        send_failure(res, 404, invalid_resource())
    }
}

解析后的结果如下:

在读取相簿内容时处理分页:

function handle_get_album(req, res) {
    // format of request is /albums/album_name.json

    const getp = req.parsed_url.query
    const core_url = req.parsed_url.pathname
    let page_num = getp.page || 0
    let page_size = getp.page_size || 1000

    if (isNaN(page_num)) page_num = 0
    if (isNaN(page_size)) page_size = 0

    var album_name = core_url.substr(7, core_url.length - 12)
    load_album(album_name, page_num, page_size, (err, album_contents) => {
        if (err && err.error == 'no_such_album') {
            send_failure(res, 404, err)
        } else if (err) {
            send_failure(res, 500, err)
        } else {
            send_success(res, {album_data: album_contents})
        }
    })
}
  • 通过core_url解析相簿名称
  • load_album增加page_num, page_size参数

修改内容:POST数据

为了能够使用curl客户端发送数 据,我们需要做点事情

  1. 设置HTTP方法参数为POST(或者PUT)。
  2. 为传入的数据设置Content-Type。
  3. 开始发送数据。

服务端处理post请求

在请求处理时,处理重命名操作:

处理post数据

程序为了获取POST数据,会使用一种叫做数据流的Node特 性。当使用Node的异步非阻塞特性时,数据流是传输大量数据的最 佳方式。


function handle_rename_album(req, res) {

    // 1. Get the album name from the URL
    var core_url = req.parsed_url.pathname;
    var parts = core_url.split('/');
    if (parts.length != 4) {
        send_failure(res, 404, invalid_resource());
        return;
    }

    var album_name = parts[2];

    // 2. get the POST data for the request. this will have the JSON
    // for the new name for the album.
    var json_body = '';
    req.on('readable', () => {
        var d = req.read();
        if (d) {
            if (typeof d == 'string') {
                json_body += d;
            } else if (typeof d == 'object' && d instanceof Buffer) {
                json_body += d.toString('utf8');
            }
        }
    });

    // 3. when we have all the post data, make sure we have valid
    //    data and then try to do the rename.
    req.on('end', () => {
        // did we get a valid body?
        if (json_body) {
            try {
                var album_data = JSON.parse(json_body);
                if (!album_data.album_name) {
                    send_failure(res, 404, missing_data('album_name'));
                    return;
                }
            } catch (e) {
                // got a body, but not valid json
                send_failure(res, 403, bad_json());
                return;
            }

            // we have a proposed new album name!
            do_rename(album_name, album_data.album_name, (err, results) => {
                if (err && err.code == "ENOENT") {
                    send_failure(res, 403, no_such_album());
                    return;
                } else if (err) {
                    send_failure(res, 500, file_error(err));
                    return;
                }
                send_success(res, null);
            });
        } else {
            send_failure(res, 403, bad_json());
            res.end();
        }
    });
}


function do_rename(old_name, new_name, callback) {
    // Rename the album folder.
    fs.rename("albums/" + old_name,
        "albums/" + new_name,
        callback);
}

对于传入请求的主体中的每一块(组块)数据,传入 on(‘readable’,…)的处理程序函数都会被调用。上面的代码中,首先通过read方法读取来自数据流的数据并将这些传入的数据添加到json_body变量的后面;然后,当监听到end事件,会得到这些结果字符串,并尝试去解析它。当给定的字符串不是一个合法的JSON 时,JSON.parse会抛出一个错误,所以必须用try/catch语句块封装 这些代码。

执行重命名

curl -s -X POST -H "Content-Type: application/json" -d '{"album_name": "japan2011"}' http://localhost:8080/albums/japan2010/rename.json

相册japan2010被重命名为japan2011

通过表单提交的数据

服务端收到的数据格式如下:

解析时,需要借助querystring模块:

const qs = require('querystring');
var POST_data = qs.parse(body);

将其转化为对象:

示例源码:https://github.com/marcwan/LearningNodeJS/blob/master/Chapter04/09_form_data.js