去年年底開始寫的一個小專案,斷斷續續做了些優化,在此簡單的記錄一下。
源頭
起源是之前一直沒什麼機會接觸到 Node 專案,工作中接觸到的也僅限於用 Node 寫指令碼,做一些小工具,與伺服器上跑的 Node 服務相差甚遠。所以想寫一個在伺服器上跑的 Node 小專案練手。
一直喜歡用 RSS 訂閱資訊這種方式,簡單高效,與其每天不定時地接收推送,開啟各網站 App 來接收資訊,不如自己拿到主動權集中在同一時間段統一閱讀。這樣避免了每天不定時接受資訊的焦慮堆積,但是又常常想不起來開啟?,過了一週開啟 Reeder,發現累積的未讀資訊又爆炸了,人真是很難滿足。
於是決定自己搞個資訊推送服務吧,滿足自己的核心訴求,每個工作日早上 10 點微信推送 RSS 前端資訊的更新,這樣就可以在每天抵達工位的時候舒舒服服瀏覽一下新鮮事,挑一些有用的存起來慢慢研讀。
專案倉庫: github.com/Colafornia/…
推送大概長這樣:
掃碼獲取推送服務:
現在推送源主要是各廠的知乎專欄,大佬們的個人部落格,掘金前端熱門文章,都是我自己的個人口味。
下面來講一下開發(與自己給自己加需求)歷程。
開始
最開始感覺這個需求是很簡單的,具體操作可以分解為:
- 寫一個配置檔案,把我想抓取的 RSS 源地址寫在裡面
- 找一個能解析 RSS 的 npm 包,遍歷配置檔案裡的源,解析之後處理資料
- 僅篩出在過去 24 小時內更新的文章,把資料處理一下,彙總成一段字串,用微信推送
- 以上寫出的指令碼通過定時任務跑起來,done!
最後選擇了 rss-parser 作為解析工具包,PushBear 作為推送服務,node-schedule 任務排程工具寫出來了一版。
然後就發現自己知識的匱乏了,沒有考慮到指令碼部署到伺服器上時,程式守護的問題,於是研習了一波 pm2,完美完成任務。
過渡
專案寫到這裡其實是可以湊和用了,但是看起來很 low 很難受。主要問題有:
- 當時 RSS 源大概有四五十個,一次性遍歷解析所有的源經常會有超時或者出錯的
- RSS 源寫在配置檔案裡,每次想新增、修改源都需要改程式碼,很 low
- PushBear 這個推送服務只能儲存三天內的推送,三天前,一週前的推送內容都看不了,這也很難受
- 掘金的 RSS 源內容不多,也不是按照熱門程度排序的(也可能是我姿勢不對?),不太符合要求
第一點稍微有點複雜,可能現在解決的方案依然很原始。出現第一個問題一是需要控制請求的併發數量,二是 RSS 源本身有一定的不穩定性。目前的解決方案是:
- 把抓取任務和推送任務分開,預留出可以迴圈抓取三次的時間,後面兩次只抓取之前失敗的源
- 用 async 的
mapLimit
和timeout
方法設定最大併發數量和超時時間
大致程式碼如下(有一些細節處理沒貼上來):
// 抓取定時器 ID
let fetchInterval = null;
// 抓取次數
let fetchTimes = 0;
function setPushSchedule () {
schedule.scheduleJob('00 30 09 * * *', () => {
// 抓取任務
log.info('rss schedule fetching fire at ' + new Date());
activateFetchTask();
});
schedule.scheduleJob('00 00 10 * * *', () => {
// 傳送任務
log.info('rss schedule delivery fire at ' + new Date());
let message = makeUpMessage();
log.info(message);
sendToWeChat(message);
});
}
function activateFetchTask() {
fetchInterval = setInterval(fetchRSSUpdate, 120000);
fetchRSSUpdate();
}
function fetchRSSUpdate() {
fetchTimes++;
if (toFetchList.length && fetchTimes < 4) {
// 若抓取次數少於三次,且仍存在未成功抓取的源
log.info(`第${fetchTimes}次抓取,有 ${toFetchList.length} 篇`);
// 最大併發數為15,超時時間設定為 8000ms
return mapLimit(toFetchList, 15, (source, callback) => {
timeout(parseRSS(source, callback), 8000);
})
}
log.info('fetching is done');
clearInterval(fetchInterval);
return fetchDataCb();
}
複製程式碼
這樣基本解決了 90% 以上的抓取問題,保證了指令碼的穩定性。
針對 RSS 源寫在配置檔案裡,每次想新增、修改源都需要改程式碼的問題,解決方法很簡單,把源配置寫到 MongoDB 裡也就好了,有一些 GUI 軟體可以直接在圖形介面來新增、修改資料。
為了解決推送服務只能儲存三天內的推送,決定新增一個每週五的周抓取任務,抓取一週內的新文章,把內容作為 issue 發到倉庫。也還算是一個解決方案。
針對掘金的 RSS 源問題,最後決定直接呼叫掘金的介面來取資料,這就可以隨心所欲按自己的需求來了,每天只抓取❤️點贊數在 70 以上的文章。
順便給抓取的文章時間範圍加了一個偏移值,避免篩掉質量好但是由於剛剛釋出點贊較少的文章。感覺自己棒棒噠~
function filterArticlesByDateAndCollection () {
const threshold = 70;
// articles 是已按❤️數由高到低排序的文章列表
let results = articles.filter((article) => {
// 偏移值五小時,避免篩掉質量好但是由於剛剛釋出點贊較少的文章
return moment(article.createdAt).isAfter(moment(startTime).subtract(5, 'hours'))
&& moment(article.createdAt).isBefore(moment(endTime).subtract(5, 'hours'))
&& article.collectionCount > threshold;
});
// 掘金文章最多收錄 8 篇,避免資訊爆炸
return results.slice(0, 8);
}
複製程式碼
在這個期間也充分感受到了日誌的重要性,在資料庫裡新增了一個表用來存每天的推送內容。
另外在 PushBear 上新新增了一個 Channel 來給自己推送日誌,每天在抓取任務完成後,先給我傳送一下抓取到的內容,如果發現有任何問題,我可以自己登伺服器緊急修復一下(這麼想來還是很 low ?)。
升級
做完以上改動之後,指令碼穩定地跑了快半年,這期間我也一直在忙著搬磚,沒什麼精力再來改造它。
一直沒做推廣,但某天突然發現已經有了三十多個使用者在訂閱這個服務,於是良心發現,本著對使用者負責(也是自己有了新的想練習的技術?),就又做了一次改造。
此時專案的問題有:
- 沒有文章去重,如果文章在知乎專欄發了,掘金也發了,作者個人部落格也發了的話,就相當於會重複出現幾次
- 推送的時間間隔不精確,都是當前時間的過去 24 小時來篩的
- 指令碼直連資料庫進行存取操作也不太好,感覺這個形式做成 server,對外暴露 api 更合理(等哪天想寫個 RSS 閱讀器也就用上了)
- 每次程式碼有更新,依賴有更新,都 ssh 上伺服器然後
npm install
感覺也不太專業,有提升空間(其實就是想用docker
了)
1,2 問題很好解決,每次抓取之前先查一下日誌,上次推送的具體時間。每抓到新文章時,再與最近 7 天日誌裡的文章比對一下,重複的不放到抓取結果中,也就解決了。
對於問題 3,於是決定搭建 Koa Server,先把從 MongoDB 讀取推送源,存取推送日誌變成 api。
目錄結構如下,新增 Model
與 Controller
。把 RSS 抓取指令碼與掘金爬蟲放到 task 檔案。
沒什麼難點,就可以呼叫 api 來獲取 RSS 源了:
此時想到了一個重要問題,身份驗證。肯定不能把所有 api 都隨意暴露出去,讓外界可以任意呼叫,這也就相當於把資料庫都暴露出去了。
最終決定用 JSON Web Token(縮寫 JWT)
作為認證方案,主要原因是 JWT 適合一次性、短時間的命令認證,目前我的服務僅限於伺服器端的 api 呼叫,每天的使用時間也不長,無需簽發有效期很長的令牌。
Koa 有一個 jwt 的中介軟體
// index.js
app.use(jwtKoa({ secret: config.secretKey }).unless({
path: [/^\/api\/source/, /^\/api\/login/]
}))
複製程式碼
加上中介軟體後,除了 /api/source
與 /api/login
介面就都需要經過 jwt 認證才能訪問了。
因此寫了一個 /api/login
介面,用於簽發令牌,拿到令牌之後,把令牌設定到請求頭裡就可以通過認證了:
// api/base.js
// 用於封裝 axios
// http request 攔截器
import axios from 'axios';
const config = require('../config');
const Instance = axios.create({
baseURL: `http://localhost:${config.port}/api`,
timeout: 3000,
headers: {
post: {
'Content-Type': 'application/json',
}
}
});
Instance.interceptors.request.use(
(config) => {
// jwt 驗證
const token = config.token;
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config;
},
error => {
return Promise.reject(error);
}
);
複製程式碼
如果請求頭裡沒有正確的 token,則會返回 Authentication Error
。
至於問題 4,現在服務比較簡單,也只在一個機器上部署,手動登機器 npm install 問題還不大,如果機器很多,依賴項也複雜的話,很容易出問題,具體參見科普文:為什麼不能在伺服器上 npm install ?。
於是決定基於 Docker
做構建部署。
FROM daocloud.io/node:8.4.0-onbuild
COPY package*.json ./
RUN npm install -g cnpm --registry=https://registry.npm.taobao.org
RUN cnpm install
RUN echo "Asia/Shanghai" > /etc/timezone
RUN dpkg-reconfigure -f noninteractive tzdata
COPY . .
EXPOSE 3001
CMD [ "npm", "start", "$value1", "$value2", "$value3"]
複製程式碼
用的比較簡單,主要就是負責安裝依賴,啟動服務。需要注意的主要有兩點:
- 國內拉去外網的映象很慢,像 Node 官方的映象我都拉了好久都沒拉下來,這樣的話推薦使用國內的映象,比如我用的 DaoCloud,還有阿里雲映象等等
- 由於推送服務是對時間敏感的,基礎映象的時區並不是國內時區,要手動設定一下
然後去 DaoCloud 等提供公有云服務的網站授權訪問 Github 倉庫,連線自己的主機,就可以實現持續整合,自動構建部署我們的映象了。具體步驟可參考基於 Docker 打造前端持續整合開發環境。
本次優化大概就到這裡了。接下來要做的可能是提供一個推送歷史檢視頁面,優先順序不是很高,有時間再做吧(順便練習一下 Nginx)。
現在的實現方案可能還是有很不合理的地方,歡迎提出建議。