130行實現Express風格的Node.js框架
很多時候我們使用Express,只是用到了它方便的路由和中介軟體系統。其實這個功能我們用一百多行程式碼可以輕鬆實現,且沒有任何依賴,而不必專門引入Express。
我們先來分析一下需求,我們要做的是一個路由系統,書寫的方式為:
//路由系統
app.method(path, handler);
//例如
app.get('/', (req, res) => {
//do something
});
app.post('/user', (req, res) => {
//do something
});
//中介軟體
app.use('/blog', (req, res, next) => {
if (/*校驗通過*/) {
next();
} else {
//校驗不能通過的錯誤資訊
}
});
//模式匹配
app.get('/blog/:id', (req, res) => {
const id = req.params.id;
});
//監聽啟動服務
app.listen(port, host);
一個簡單的Node.js伺服器
在開始之前,我們先看看普通的Node.js伺服器是什麼樣的:
const http = require('http');
http.createServer((req, res) => {
//do something
}) .listen(port, host, callback);
每當http請求到來,就會執行回撥函式(即do something位置)的程式碼。那麼我們要做的就是實現一個路由池,當請求到來的時候通過httpServer的回撥函式遍歷路由池,選擇匹配的路由,執行響應的邏輯。一個路由包括三個屬性:請求方法(method),請求路徑(path)和處理函式(handler)。
實現路由池
路由池是一個物件陣列,我們要定義好如何新增路由。按照Express的API,我們通過app.method(path, handler)來新增路由。
const app = {};
const routes = [];
['get' , 'post', 'put', 'delete', 'options', 'all'].forEach((method) => {
app[method] = (path, fn) => {
routes.push({method, path, fn});
};
});
現在,我們呼叫app的get、post、put、delete、options, all方法時,就會新增一個路由物件到routes陣列中了。例如:
app.get('/', (req, res) => {
res.end('hello world');
});
//此時routes為
[{
method: 'get',
path: '/',
fn: (req, res)=>{
res.end('hello world');
}
}]
路由池的遍歷
路由池的遍歷很簡單,通過迴圈遍歷陣列即可。
const passRouter = (method, path) => {
let fn;
for(let route of routes) {
if((route.path === path
|| route.path === '*')
&& (route.method === method
|| route.method === 'all')) {
//匹配到了符合的路由
//路由的method為all時匹配所有請求的方法
//路由path為*時匹配所有請求的路徑
fn = route.fn;
}
}
if(!fn) {
fn = (req, res) => {
res.end(`Cannot ${method} ${pathname}.`);
}
}
return fn;
}
這樣我們就寫好了遍歷router的函式,現在要做的就是把它新增到server中。
http.createServer((req, res) => {
//獲取請求的方法
const method = req.method.toLowerCase()
//解析url
const urlObj = url.parse(req.url, true)
//獲取path部分
const pathname = urlObj.pathname
//遍歷路由池
const router = passRouter(method, pathname);
router(req, res);
}).listen(port, host, callback);
我們可以把建立server的方法放在app物件中,把這個方法和app一起暴露出去。
app.listen = (port, host) => {
http.createServer((req, res) => {
const method = req.method.toLowerCase()
const urlObj = url.parse(req.url, true)
const pathname = urlObj.pathname
const router = passRouter(method, pathname);
router(req, res);
}).listen(port, host, () => {
console.log(`Server running at ${host}\:${port}.`)
});
}
這樣,我們只要呼叫app.listen(port, host),就可以建立伺服器了。
新增中介軟體
什麼是中介軟體?中介軟體是請求到達匹配的路由前經過的一層邏輯,這層邏輯可以對請求進行過濾、修改等操作。舉個例子:
app.use('/blog', (req, res, next) => {
if(req.username) {
next();
} else {
res.writeHead(404, {'Content-Type': 'text/html'});
res.end('對不起,你沒有相應許可權');
}
});
在這個例子中,每當請求/blog這個路徑的時候,請求都會經過這個中介軟體,只有request物件有username這個方法時,請求才能繼續向後傳遞,否則就會返回一個404資訊。要實現中介軟體也很簡單,我們把中介軟體與get, post等方法一樣看成是一種路由即可。於是問題的核心就變成了由於中介軟體中使用next函式來確認請求通過了中介軟體,我們不再能通過for..in遍歷的方法來遍歷路由池了。如果你對ES6足夠熟悉,那麼這個next方法一定能讓你想起一個很有趣的新語法:generator函式。
使用generator函式來遍歷陣列
generator函式是一種生成器函式,允許我們在退出函式後重新進入之前的狀態(可以理解為一個狀態機),我們可以用它實現函式式中的惰性求值特性,用這種辦法來遍歷陣列,舉個例子:
const lazy = function* (arr) {
yield* arr;
}
const lazyArray = lazy([1, 2, 3]);
lazy.next(); // {value: 1, done: false}
lazy.next(); // {value: 2, done: false}
lazy.next(); // {value: 3, done: false}
lazy.next(); // {value: undefined, done: true}
重寫路由遍歷函式
那麼我們現在可以重寫路由遍歷的函數了,需要注意的是,中介軟體匹配過程中是可以匹配子目錄的,例如/path可以匹配到/path/a、/path/a/b/c這些目錄。
//lazy函式,使陣列可被惰性求值
const lazy = function* (arr) {
yield* arr;
}
//路由遍歷
const passRouter = (routes, method, path) => (req, res) => {
const lazyRoutes = lazy(routes);
(function next () {
//當前遍歷狀態
const it = lazyRoutes.next().value;
if (!it) {
//已經遍歷所有路由,沒有匹配的路由,停止遍歷
res.end(`Cannot ${method} ${pathname}`)
return;
} else if (it.method === 'use'
&& (it.path === '/'
|| it.path === path
|| path.startsWith(it.path.concat('/')))) {
//匹配到了中介軟體
it.fn(req, res, next);
} else if ((it.method === method
|| it.method === 'all')
&& (it.path === path
|| it.path === '*')) {
//匹配到了路由
it.fn(req, res);
} else {
//繼續匹配
next();
}
}());
};
這樣我們就得到了一個可以新增中介軟體的路由系統。
模式匹配
匹配路由
模式匹配是每個後端框架必不可少的功能之一。他允許我們匹配一類路由,例如/blog/:id可以匹配類似/blog/123、/blog/qw13之類的一系列請求路徑。既然是模式匹配,那麼肯定少不了正則表示式了。我們以/blog/:id為例,想要匹配一系列這樣的路由,只要請求的路徑能夠通過正則表示式/^\/blog\/\w[^\/]+$/即可。也就是說,我們把路由中的:whatever替換成正則表示式\w[^\/]+就能匹配到相應的路由了。JavaScript中提供了new Exp來把字串轉換為正則表示式因此轉化的步驟為:
將路由中模式匹配的部分轉換為\w[^\/]+
用替換好的字串生成正則表示式
用這一正則表示式匹配請求路徑,判斷是否匹配
實現:
//轉換模式為相應正則表示式
const replaceParams = (path) => new RegExp(`\^${path.replace(/:\w[^\/]+/g, '\\w[^\/]+')}\$`);
//判斷模式是否吻合
//...在passRouter函式中最後一個else之前新增一層if else
} else if ( it.path.includes(':')
&& (it.method === method
|| it.method === 'all')
&& (replaceParams(it.path).test(path))) {
//匹配成功
} else {
next();
}
轉換匹配到的路徑為相應物件
匹配成功後我們需要把模式轉為物件以便呼叫:
//匹配成功時邏輯
let index = 0;
//分割路由
const param2Array = it.path.split('/');
//分割請求路徑
const path2Array = path.split('/');
const params = {};
param2Array.forEach((path) => {
if(/\:/.test(path)) {
//如果是模式匹配的路徑,就新增入params物件中
params[path.slice(1)] = path2Array[index]
}
index++
})
req.params = params
it.fn(req, res);
我們把params物件加入了req物件中,呼叫時很方便,例如:/blog/:id在呼叫時為const id = req.params.id。
靜態檔案處理
請求時如果請求了靜態檔案,我們的伺服器還沒有做出處理,這點很不合理,我們需要新增靜態檔案處理邏輯。
//常用的靜態檔案格式
const mime = {
"html": "text/html",
"css": "text/css",
"js": "text/javascript",
"json": "application/json",
"gif": "image/gif",
"ico": "image/x-icon",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png"
}
//處理靜態檔案
function handleStatic(res, pathname, ext) {
fs.exists(pathname, (exists) => {
if(!exists) {
res.writeHead(404, {'Content-Type': 'text/plain'})
res.write('The request url' + pathname + 'was not found on this server')
res.end()
} else {
fs.readFile(pathname, (err, file) => {
if(err) {
res.writeHead(500, {'Content-Type': 'text/plain'})
res.end(err)
} else {
const contentType = mime[ext] || 'text/plain'
res.writeHead(200, {'Content-Type': contentType})
res.write(file)
res.end()
}
})
}
})
}
然後我們找到app.listen函式,新增判斷靜態檔案的邏輯。
let _static = 'static' //預設靜態資料夾位置
//更改靜態資料夾的函式
app.setStatic = (path) => {
_static = path;
};
//...server回撥函式中內容
const method = req.method.toLowerCase()
const urlObj = url.parse(req.url, true)
const pathname = urlObj.pathname
//獲取字尾
const ext = path.extname(pathname).slice(1)
//如果有後綴,則是靜態檔案
if(ext) {
handleStatic(res, _static + pathname, ext)
} else {
passRouter(_routes, method, pathname)(req, res)
}
至此,我們已經實現了一個完整的後端路由控制器,有中介軟體功能,靜態檔案處理和模式匹配功能。
一個彩蛋
有時我們希望node應用從命令列退出時不是直接退出,而是向我們輸出一些資訊(比如道個別),就像這樣:
^C
Good Day!
這一功能借助node中process模組的SIGINT事件也可以輕鬆實現,我們只需要在建立server成功的回撥函式加上幾行就可以了:
http.createServer(/*...*/).listen(port, host, () => {
console.log(`Server running at ${host}\:${port}.`)
//新增的程式碼:
process.stdin.resume();
process.on('SIGINT', function() {
console.log('\n');
console.log('Good Day!');
process.exit(2);
});
});
現在,我們的退出小彩蛋也完成了。
完整程式碼放在我的gist上。
至此,我們就完成了整個應用,如果重量級的框架對你來說比較多餘,就試試自己動手實現吧。水平有限,歡迎吐槽。
作者:mirone
連結:https://zhuanlan.zhihu.com/p/24781172
來源:知乎
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。