Node.js 异步流程同步化
这些天,我在陆续地用 Node.js 重写以前的 Python 和 PHP 代码,是为了让技能树长得更高,而修剪掉次要的技能。
大三时发现学校某系统上有学生证件照,而且它们的地址是明文(学号)。于是写了个 Python 脚本,去抓取所有学生的照片。看看当时的代码,不得不说 Python 确实优雅,怪不得那么多人说 “人生苦短,我用 Python”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
from urllib import request, error import shutil, os, os.path baseurl = 'http://无可奉告/' chances = 10 for grade in range(11, 16): if not os.path.exists('%02.0f' % grade): os.mkdir('%02.0f' % grade + '/') for school in range(1, 30): path = '%02.0f' % grade + '/' + '%02.0f' % school if not os.path.exists(path): os.mkdir(path + '/') for t in range(1, 10): for d in range(10 if t == 1 else 1): for i in range(1, 1000): if chances: identity = '%02.0f' % grade + str(t) + '%02.0f' % school + str(d) + '%03.0f' % i print(identity) filename = path + '/' + identity + '.jpg' if not os.path.exists(filename): url = baseurl + identity + '.jpg' try: request.urlretrieve(url, filename) chances = 10 except error.HTTPError: chances -= 1 else: chances = 10 break |
相对而言,JavaScript(下文简称 JS)就不优雅了,部分由基因决定:Python 作为纯后端语言,可以使用缩进来定义代码块,既保持了良好的代码格式,又省去了冗长的大括号。而 JS 作为一门前端语言,为了节省带宽往往要做代码压缩,所以代码块必须用大括号来标明。另一部分原因是,Python 内置了许多实用函数,虽然 npm 社区有许多开源的包作为替代 ,但为了练手我先不用。事实上,本文基本围绕着 urllib.request.urlretrieve
这个函数展开。
这段程序大致分为两块:其一为迭代器,遍历所有应抓取的学号。其二为抓取文件并写入磁盘。现在开始翻译吧!首先,引入模块,补全实用函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
'use strict'; const http = require('http'); const fs = require('fs'); function pad(str, width) { return '0'.repeat(width - ('' + str).length) + str; } function* range(a, b) { let start = a, end = b; if (b === undefined) { start = 0; end = a; } for (let i = start; i < end; i++) { yield i; } } function exists(path) { try { fs.accessSync(path); return true; } catch (err) { return false; } } const baseurl = 'http://无可奉告/'; let chances = 10; let path; |
和 Python 不同的是,这个 range 函数仅仅返回一个可迭代对象而不是一个数组,以减少内存占用。什么叫可迭代?看下边的例子你就懂了:
1 2 3 4 5 6 7 8 9 |
for (let x of range(3)) { console.log(x) } // 0 // 1 // 2 [...range(2, 6)] // [2, 3, 4, 5] |
接下来,为了专注于主题,先把学号迭代器分离出来,同样使用生成器函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
const iter = (function* () { for (let grade of [15]) { grade = pad(grade, 2); if (!exists(grade)) { fs.mkdirSync(grade); } for (let school of range(1, 30)) { school = pad(school, 2); path = `${grade}/${school}`; if (!exists(path)) { fs.mkdirSync(path); } for (let t of range(1, 10)) { for (let d of range(t === 1 ? 10 : 1)) { for (let i of range(1, 1000)) { const identity = grade + t + school + d + pad(i, 3); const filename = `${path}/${identity}.jpg`; if (!chances) { chances = 10; break; } else if (!exists(filename)) { yield identity; } else { chances = 10; }}}}}}})(); |
把大括号叠起来,看起来更像 Python 了。这个 iter
会一次吐一个学号出来。它的行为会根据外部环境(文件是否已存在? 已连续失败多少次?)进行微调。
基于协程的同步流程
Node.js 自带的文件读写函数提供了同步和异步两个版本,为了写起来舒服,看起来也舒服,我这里尽量用了同步的。但是,http
库的 get
方法并未提供同步版本。有没有什么办法能造出一个同步版本的 get
,即下文的 getSync
呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
for (let identity of iter) { console.log(identity); const url = `${baseurl}${identity}.jpg`; try { const imageData = getSync(url); const filename = `${path}/${identity}.jpg`; fs.writeFileSync(filename, imageData, 'binary'); chances = 10; } catch (err) { if (err.message === 'Not 200') { chances--; } else { console.log(err); fs.appendFileSync('failed.txt', identity + '\n'); } } } |
getSync
函数应当是 http.get
函数的封装。其接受 url
作为参数,(简单起见)仅返回响应主体,或者抛出一个错误。先试着写一个出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function getSync(url) { let done = false, e = false, imageData = ''; http.get(url, res => { if (res.statusCode !== 200) { e = new Error('Not 200'); return; } res.setEncoding('binary'); res.on('data', chunk => { imageData += chunk; }); res.on('end', () => { done = true; }); }).on('error', err => { e = err; }); while (!done && !e) { // do nothing } if (e) { throw e; } return imageData; } |
点击,运行,啪!程序卡死在了 while 循环处。因为 JS 运行时是单线程模型,http.get
的回调函数被放到了消息队列中,必须等待主线程执行完毕,才会执行。换言之,while 循环阻塞了回调函数,故 done
和 e
的值永远不会发生变化。
为了修复这个问题,我们需要操控一种叫协程(coroutine),又名纤程(fiber)的东西。协程是一种微线程,表现为函数调用,可随时中断并记忆执行环境,也可随时恢复运行。其实生成器的 yield
语句就是协程的一种实现,只不过没有暴露直接操控协程的接口。所幸,有个用 C 语言底层实现的库 node-fibers 满足了我的需要。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
const Fiber = require('fibers'); function getSync(url) { const f = Fiber.current; let imageData = ''; let err; http.get(url, res => { if (res.statusCode !== 200) { err = new Error('Not 200'); return f.run(); } res.setEncoding('binary'); res.on('data', chunk => { imageData += chunk; }); res.on('end', () => { f.run(); }); }).on('error', e => { err = e; f.run(); }); Fiber.yield(); if (err) { throw err; } return imageData; } |
使用起来很简单,用 Fiber.yield()
中断当前协程,f.run()
启动指定协程。这个问题就这样解决啦!不过还要注意一点,for (let identity of iter)
这个语句要整个包在一个函数里,并传给 Fiber()
作为参数,像这样:
1 2 3 4 5 6 7 |
Fiber(function () { for (let identity of iter) { // ... } }).run(); |
当整个流程被包装成一个协程时,我们才能中断或启动它。否则我们是不能暂停主线程的。
附:基于事件回调的异步流程
iter
还是那个 iter
,倒也是挺简洁的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
(function run() { const {value: identity, done} = iter.next(); if (done) { return; } console.log(identity); const url = `${baseurl}${identity}.jpg`; http.get(url, res => { const filename = `${path}/${identity}.jpg`; if (res.statusCode !== 200) { chances--; return run(); } res.setEncoding('binary'); res.on('data', chunk => { fs.appendFileSync(filename, chunk, 'binary'); }); res.on('end', () => { chances = 10; run(); }); }).on('error', err => { console.log(err); fs.appendFileSync('failed.txt', identity + '\n'); run(); }); })(); |
附:基于 Promise 的异步流程
虽然是 ES6 的新内容,但 Promise/A+规范其实很老了,互联网上也有很多关于 Promise 的优质文章,所以这里就不详细介绍了。
简而言之,Promise 要求程序定义,一个异步操作何时叫成功(resolve),何时叫失败(reject)。而且若这个操作已成功,就绝不会再失败,反之亦然。还有个好处是,状态的处理函数可以在状态改变后添加,随后立即执行。而回调函数只能在事件触发前添加。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
function get(url) { return new Promise((resolve, reject) => { http.get(url, res => { if (res.statusCode !== 200) { reject(new Error('Not 200')); } let imageData = ''; res.setEncoding('binary'); res.on('data', chunk => { imageData += chunk; }); res.on('end', () => { resolve(imageData); }); }).on('error', err => { reject(err); }); }); } (function run() { const {value: identity, done} = iter.next(); if (done) { return; } console.log(identity); const url = `${baseurl}${identity}.jpg`; get(url).then(imageData => { const filename = `${path}/${identity}.jpg`; fs.writeFileSync(filename, imageData, 'binary'); chances = 10; run(); }).catch(err => { if (err.message === 'Not 200') { chances--; } else { console.log(err); fs.appendFileSync('failed.txt', identity + '\n'); } run(); }); })(); |
无可奉告好评!不过看到协程那里就不懂了 =_= 为啥要那么多的 run()
在外层获取主进程 f,然后在异步回调的结束状态交回执行权嘛