有個(gè)批量調(diào)用 API 抓取數(shù)據(jù)的需求,類似爬蟲抓數(shù)據(jù)的感覺。聽到爬蟲二字,我們常常想到的是 Python, Beautiful Soup 之流,而對(duì)于簡單地抓取數(shù)據(jù)這種需求來說,一個(gè)小米加步槍就能干掉的東西,拉個(gè)加農(nóng)炮來,顯得有些大材小用。實(shí)際上,只需要圍繞著 抓取->格式轉(zhuǎn)換處理->保存 這簡單三步,然后用合適的工具或編程語言實(shí)現(xiàn)就好了。
驅(qū)動(dòng)整個(gè)批量抓取過程的核心在于一個(gè)循環(huán),把所有要訪問的 URL 放在一個(gè)數(shù)組,循環(huán)遍歷一下。對(duì)于我這樣搞前端的來說,結(jié)合現(xiàn)代 JS 的 async/await 很容易就可以寫出類似下方的代碼(這里我用了 Axios 庫處理 HTTP 請(qǐng)求)。
// Input
let read = fs.readFileSync('url-list.txt', 'utf-8');
let urlList = read.split('\n');
(async () => {
for (let current = 0; current < urlList.length; current++) {
const url = urlList[current];
console.log(current, url);
// get
let { data } = await Axios.get(url);
fs.writeFileSync(`result/${url}`, JSON.stringify(data));
}
})();
簡簡單單一個(gè)循環(huán),就可以解決這個(gè)問題,但問題來了,萬一中途出錯(cuò)退出,再次啟動(dòng),腳本得重頭開始跑,這顯然有點(diǎn)不夠智能,有沒有辦法實(shí)現(xiàn)在程序中斷過后再次啟動(dòng)時(shí)讓程序恢復(fù)上次的進(jìn)度?
想起 SICP 講到的遞歸與迭代的思維。迭代,實(shí)際上是用固定數(shù)目的狀態(tài)變量表示當(dāng)前程序的狀態(tài)的計(jì)算過程。迭代計(jì)算過程中,程序根據(jù)之前設(shè)定好的規(guī)則從一個(gè)狀態(tài)轉(zhuǎn)移到下一個(gè)狀態(tài),直到狀態(tài)不再滿足某個(gè)設(shè)定條件才結(jié)束。實(shí)現(xiàn)上來說,“迭代”二字指的是用來表示狀態(tài)的變量的迭代更新。由此可見,我們的關(guān)注點(diǎn)應(yīng)該聚焦在狀態(tài)(state)上,for 循環(huán)本身也是服務(wù)于迭代計(jì)算過程的一種語法糖而已。
于是我們很容易可以看出,這個(gè)簡單循環(huán)過程所迭代更新的狀態(tài)變量只有 current,代表當(dāng)前抓取的 URL 在數(shù)組的位置。這個(gè)變量存在于內(nèi)存,而內(nèi)存中的狀態(tài)隨著程序的中止而消失,所以關(guān)鍵在于如何把這個(gè)狀態(tài)固定到磁盤或數(shù)據(jù)庫等地方。這里能想到的思路是,在程序啟動(dòng)時(shí)把狀態(tài)加載進(jìn)來,在狀態(tài)更新的同時(shí)把它固定下來。
在這里,我把這個(gè)狀態(tài)變量序列化成 JSON,然后存儲(chǔ)到文件,實(shí)現(xiàn)狀態(tài)的固定。
// Input
let read = fs.readFileSync('url-list.txt', 'utf-8');
let urlList = read.split('\n');
let current = JSON.parse(fs.readFileSync('state', 'utf-8'));
(async () => {
for (; current < urlList.length; current++) {
const url = urlList[current];
console.log(current, url);
// get
let { data } = await Axios.get(url);
fs.writeFileSync(`result/${url}`, JSON.stringify(data));
// save state
fs.writeFileSync(`state`, JSON.stringify(current));
}
})();
對(duì)于本文這個(gè)小需求來說,這樣做已經(jīng)夠用,但擴(kuò)展一下之后,還是有一些問題的,當(dāng)狀態(tài)變得復(fù)雜,需要更多的狀態(tài)變量表示的時(shí)候,可能會(huì)導(dǎo)致持久化的語句遍布整個(gè)迭代過程中的每一個(gè)涉及到狀態(tài)改變的地方,代碼的可讀性也降低了很多,讓人不容易抓住重點(diǎn)。有沒有什么辦法把這些操作集中起來?想到了 Vue.js 的 MVVM 模型,它可以通過監(jiān)視一個(gè) Object 的變化而驅(qū)動(dòng)視圖的變化,或許我們可以實(shí)現(xiàn)類似的一些監(jiān)聽和觸發(fā)機(jī)制,在變化的時(shí)候?qū)崿F(xiàn)保存呢?
搜索發(fā)現(xiàn),ES6 的 Proxy 可以滿足這個(gè)需求,通過 Proxy 對(duì)象,把真正用來保存狀態(tài)的對(duì)象包裹起來,只要定義一個(gè) set 方法,在接到對(duì)象的改變的請(qǐng)求的時(shí)候,加入這個(gè)持久化操作就好了。另外,由于可能有多級(jí)的 Object 的存在,所以也對(duì)子對(duì)象遞歸加入 Proxy 的監(jiān)控。
// save state
const Store = {
fileName: 'state',
_state: {},
init: function () {
if (fs.existsSync(this.fileName)) {
let content = fs.readFileSync(this.fileName, 'utf-8');
if (content) {
this._state = JSON.parse(content);
}
}
// state
this.state = new Proxy(this._state, this.proxyHandler);
},
saveState: function () {
// save
fs.writeFileSync(this.fileName, JSON.stringify(this._state));
},
proxyHandler: {
set: (target, key, value) => {
// 遞歸 Proxy
if (typeof value === "object") {
value = new Proxy(value, this.proxyHandler);
}
target[key] = value;
Store.saveState();
return true;
}
}
};
Store.init();
const state = Store.state;
然后把循環(huán)里面的 current 換成 state.current,小爬蟲就可以放飛自我,隨意中止,再也不用擔(dān)心跑的過程出問題而需要重來了~
當(dāng)然,這里的 saveState 的實(shí)現(xiàn)可以很多樣,不一定要寫入文件,還可以改成 Redis, Sqlite 什么的。