一、前言
碩一下這學期相對課業沒那麼重,剛剛好友人陳同學告訴我有一個Dcard後端實習面試,完整詳讀之後發現其實題目不難且待遇豐厚,引起我對於實習面試的熱誠。
學習了後端也有一段時間了,時常因為沒有做筆記,需要翻找己以前的程式碼片段(還好都沒有刪掉),許多相關知識也因此停留在記憶的角落而堆積灰塵,若我不去主動複習,也許他就真的被我遺忘了吧?
因此讓我知道建立學習紀錄的重要性,所以我才倉促架設這個部落格,一部分想記錄我實作這項作業的方式,另外一部分是想分享自己的知識讓需要的人可以了解。
由於篇幅關係,後續還會有兩篇文章,主要是關於這篇基礎功能的優化和加強,那就請大家來閱讀敝人對於作業的解題。
- Repo:https://github.com/POABOB/Dcard-intern-project
二、題目
三、解題思路
1. 題目解釋
使用 Golang 或 Nodejs 其中一個語言建立兩個Restful API(包含Unit Test)
可以上傳一個URL網址和過期時間,並且返回一個被縮短好的URL
判斷系統生成的短網址是否存在且有無到期,如果到期和不存在,則返回404;反之,為原本URL進行轉址
可以使用任意三方函式庫和資料庫或Cache資料庫
替兩個API進行錯誤處理
不用Auth
要考慮到客戶端同時大量請求短網址(包括不存在的短網址)的問題,將性能納入考量
2. 程式邏輯
這項作業有三大重點:
製作兩個Restful API,分別是產生短網址(POST)和轉址短網址(GET)。
要考慮到Client端大量同時請求的性能表現,並且做出解決方案。
使用測試(Unit、Integration、E2E)來避免開發後難以找出程式碼的錯誤。
a. 產生短網址:POST /api/v1/urls
方法
短網址的 url_id 必須是一個唯一值,如果說使用md5取前幾位數的話,那麼很容易產生碰撞,所以不適合。使用三方函式庫shortid,自動生成短網址(我覺得都是解題,對於程式的流程解釋相對來說是一個必要的功課,不採用)。手寫兩個function可以使用64進位的方式,將
url
和expireAt
資料插入mysql中返回的自增id(唯一且以主鍵搜尋很快)作轉換並且將自增id和資料插入Redis(查詢較Mysql快)
最後返回要求格式
程式流程
src/controller/index.js
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
33const { get, set } = require('../db/redis');
const { ErrorModel, BaseModel } = require('../utils/response');
const { HOST_CONF } = require('../config/url');
const { getURL, insertURL} = require('../model/index');
const { validateUrl, validateExpire, convertIdToShortId, convertShortIdToId } = require("../utils/url");
const { datetimeRegex } = require("../utils/const");
const insertOriginUrl = async (req, res) => {
try {
let url = req.body.url;
let expireAt = req.body.expireAt;
if(url === "" || !validateUrl(url)) {
res.writeHead(400, {"Content-type": "text/plain"});
return new ErrorModel(`The post data url = ${url} is invalid!!!`);
} else if(expireAt.match(datetimeRegex) === null || Date.parse(expireAt) < Date.now() / 1000) {
res.writeHead(400, {"Content-type": "text/plain"});
return new ErrorModel(`The post data expireAt = ${expireAt} is invalid!!!`);
}
expireAt = Math.floor(new Date(expireAt).getTime() / 1000);
const data = await insertURL(url, expireAt)
// 插入redis
set(data['id'], { url: url, expireAt: expireAt })
// 得到新增的id後
const ShortId = convertIdToShortId(data['id'])
// 返回BaseModel
return new BaseModel(ShortId, HOST_CONF + ShortId);
} catch(e) {
res.writeHead(500, {"Content-type": "text/plain"});
return new ErrorModel(`${e.stack}`);
}
};轉換url_id位數
目前使用5位數字串(64 ^ 5 = 1,073,741,824),原因是我使用unsigned int (4,294,967,295),為了避免int不夠用
如果之後想要改更長,可以使用unsigned bigint(2 ^ 64 - 1),就可以讓字串數增加至多到10位數字串(64 ^ 10)
src/utils/url.js
1 | const { _64Bit, urlMaxLength } = require('./const'); |
- 64進位
- 我將 A-Z, a-z, 0-9, -, ~ 這些字元打亂順序之後,放入一個陣列當作進位表
src/utils/const.js
1 | const urlMaxLength = 5; |
b. 轉址短網址:GET /:ShortId([a-zA-Z0-9-~]{5})
方法
由於後端性能的問題主要出在 網路頻寬速度 和 Disk I/O,在程式碼中就必須為資料庫方面減少負荷,多多採用記憶體作為一個快速的解決方案
所以我會先讓Nodejs先去從Redis中查找id是否存在
有則,判斷資料是否有效且有無過期,然後返回404或302
- 使用302(暫時轉址)原因是因為短網址是有期限的,所以每次轉址都必須讓server判斷資料是否有效,雖然301(永久轉址)性能較好,但是他會被cache在瀏覽器,導致無法到server判斷資料
無則,向Mysql查找id是否存在
若有該筆id,使用異步的方式儲存到Redis並判斷資料是否有效且有無過期,然後返回404或302
若無該筆id,異步新增一個{ url: null, expireAt: Date.now() / 1000 }資料到Redis,返回404
- 因為題目中有提到如果該筆資料不存在那一直查找不存在的資料也是浪費性能,不如就儲存一個無效值在Redis,直接返回404
- 程式流程
src/controller/index.js
1 | const { get, set } = require('../db/redis'); |
三、性能比較(使用ab)
1. 對比有使用redis和沒使用redis的性能
a. 同時一百個請求,總共訪問一萬次有效短網址
有redis
1 | C:\Users\poabob\Desktop> .\ab.exe -n 10000 -c 100 http://localhost/NNNNB |
無redis,只有mysql
1 | C:\Users\poabob\Desktop> .\ab.exe -n 10000 -c 100 http://localhost/NNNNB |
b. 同時一千個請求,總共訪問十萬次有效短網址
有redis
1 | C:\Users\poabob\Desktop> .\ab.exe -n 100000 -c 1000 http://localhost/NNNNB |
無redis,只有mysql
1 | C:\Users\poabob\Desktop> .\ab.exe -n 100000 -c 1000 http://localhost/NNNNB |
2. 還可不可以優化性能?
a. 使用Pm2
因為Nodejs是單線程的設計,我們可以使用pm2來實現多個Nodejs Cluster提高效率
安裝
1 | npm i pm2 --save-dev |
- 新增一些pm2的常用指令, -i 是要啟用的process數量
package.json
1 | "scripts": { |
- 開啟服務
1 | C:\Users\poabob\Desktop\Dcard> npm run prd |
b. 同時一百個請求,總共訪問一萬次有效短網址
1 | C:\Users\poabob\Desktop> .\ab.exe -n 10000 -c 100 http://localhost/NNNNB |
c. 同時一千個請求,總共訪問十萬次有效短網址
1 | C:\Users\poabob\Desktop> .\ab.exe -n 100000 -c 1000 http://localhost/NNNNB |
3. 其他擴充提案
Proposal A. 新增Nginx用反向代理、實現限流機制再分析access.log。
4. 結論
Redis確實可以替Mysql作到提速的作用
使用pm2來管理Nodejs Cluster,增加性能是可行的
四、測試(Unit Test、Integration Test)
1. Unit Test
測試ShortId和id雙向轉換
驗證日期是否過期
驗證url是否合法
src/utils/url.js
1 | const { validateUrl, validateExpire, convertIdToShortId, convertShortIdToId } = require('../../src/utils/url'); |
- 測試response返回格式
src/utils/response.js
1 | const { ErrorModel, BaseModel } = require('../../src/utils/response'); |
2. Integration Test
整合測試前,先新增url_test的測試表
測試POST API
測試GET API
整合測試後,刪除url_test的測試表
src/route.js
1 | const request = require('supertest'); |
3. 測試結果
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
---|---|---|---|---|---|
All files | 83.91 | 70.42 | 96.77 | 83.93 | |
Dcard | 100 | 100 | 100 | 100 | |
app.js | 100 | 100 | 100 | 100 | |
Dcard/bin | 100 | 100 | 100 | 100 | |
www.js | 100 | 100 | 100 | 100 | |
Dcard/src/config | 78.57 | 66.66 | 100 | 78.57 | |
db.js | 75 | 66.66 | 100 | 75 | 28-39 |
url.js | 83.33 | 66.66 | 100 | 83.33 | 9 |
Dcard/src/controller | 73.8 | 75 | 100 | 73.8 | |
index.js | 73.8 | 75 | 100 | 73.8 | 16-23,31-32,37-38,64-65 |
Dcard/src/db | 74.41 | 50 | 100 | 74.41 | |
mysql.js | 73.68 | 50 | 100 | 73.68 | 19,24-25,35-36 |
redis.js | 75 | 50 | 100 | 75 | 20-21,38,41-42,47 |
Dcard/src/model | 80 | 100 | 66.66 | 80 | |
index.js | 80 | 100 | 66.66 | 80 | 6-8 |
Dcard/src/router | 100 | 100 | 100 | 100 | |
index.js | 100 | 100 | 100 | 100 | |
Dcard/src/utils | 91.22 | 68.42 | 100 | 92.3 | |
const.js | 100 | 100 | 100 | 100 | |
post.js | 75 | 75 | 100 | 75 | 12-13,24-25 |
response.js | 88.88 | 50 | 100 | 100 | 3-11 |
url.js | 100 | 100 | 100 | 100 |
五、程式架構
1. 目錄結構
1 | C:\Users\poabob\Desktop> tree -I 'node_modules|img' |
2. 引用的三方lib
主要引用mysql、redis、xss這三種作為本次作業的lib
mysql、redis主要是讓nodejs連接兩個資料庫
xss用來避免mysql被插入惡意程式片段
cross-env:方便在npm run指令的時候,建立環境變數,ex. mode=dev
jest、supertest:jest用來跑測試的lib,supertest可以測試api是否符合預期
nodemon、pm2:nodejs的開發(nodemon)和部屬(pm2)工具
package.json
1 | { |
3. 程式解析
- 執行檔案,主要是創建http服務
bin/www.js
1 | const http = require('http'); |
獲取url path
獲取postData
Router判斷
app.js
1 | const { getPostData } = require('./src/utils/post'); |
- 使用stream的方式去擷取data,並判斷method和header是否正確
src/utils/post.js
1 | // 獲取post過來的data |
判斷method和用正則來判斷url path是否正確,如果沒有就不return,直接404
原本想要使用path-to-regexp來判斷url path,但其實也只有一個路由需要判斷,所以決定手寫
src/router/index.js
1 | const { getOriginUrlById, insertOriginUrl } = require("../controller/index"); |
- Mysql功能模組化
src/db/mysql.js
1 | const mysql = require('mysql'); |
- Redis功能模組化
src/db/redis.js
1 | const redis = require('redis'); |
- 執行sql語法,並返回結果
src/model/index.js
1 | const mysql = require('../db/mysql'); |
- 作業心得
其實原本一開始想說用express直接來簡單寫完就好,不過後來想想不用auth session,也只有兩個路由。不如就直接來動手寫http server,相對較有挑戰之外,也開始讓我更熟悉nodejs的Emit機制。我有好幾次都被異步給搞到頭很痛(習慣php寫法),經過這次練習,我不但更熟悉了Promise,也複習以前曾經學習過的知識,還順便找回寫程式的熱情。