HTTP 模塊
HTTP 模塊,是 node 中最重要的模塊,沒有之一。
該模塊提供了執行 HTTP 服務和產生 HTTP 請求的功能,實際上我們之後利用 node 寫的服務器,主要就是用的 HTTP 模塊,先來看一下一個簡單 HTTP 服務器需要的代碼。
const http = require('http')
const server = http.createServer()
const port = 8899
server.on('request', (request, response) => {
console.log(request.method, request.url)
console.log(request.headers)
response.writeHead(200, {'content-Type': 'text/html'})
response.write('<h1>hello world</h1>')
response.end()
})
server.listen(port, () => {
console.log('server listening on port', port)
})
HTTP 模塊背後實際上是建立了一個 TCP 鏈接並監聽 port 端口,只有在有人鏈接過來併發送正確的 HTTP 報文且能夠正確的被解析出來時 request 事件纔會觸發,該事件的回調函數一共有兩個對象,一個是請求對象一個是響應對象,可以通過這兩個對象對 request 事件進行相應的響應。這裏的 request 和 response 已經被解析好了,可以直接從 request 中讀取相關屬性,同時 response 寫入的內容會直接寫入響應體中, 因爲響應頭已經被自動寫入好了。
HTTP 模塊是如何實現的呢,大體代碼如下:
class httpServer {
constructor(requestHandler) {
var net = require('net')
this.server = net.createServer(conn => {
var head = parse() // parse data comming from conn
if (isHttp(conn)) {
requestHandler(new RequestObject(conn), new ResponseObject(conn))
} else {
conn.end()
}
})
}
listen(prot, f) {
this.server.listen(port, f)
}
}
在 node 中執行該腳本,打開瀏覽器訪問 localhost:8899 端口,瀏覽器就會向服務器發送一個請求,服務器就會響應一個簡單的 HTML 頁面。
一個真實的 web 服務器要比這個複雜得多,它需要根據請求的方法來判斷客戶端嘗試執行的動作,並根據請求的 URL 來找出動作處理的資源,而不是像我們這樣無腦的輸出。
HTTP 模塊的 request 函數還可以用來充當一個 HTTP 客戶端,由於不在瀏覽器環境下運行了,所以不存在跨域請求的問題,我們可以向任何的服務器發送請求。
$ node
> req = http.request('http://www.baidu.com/',response => global.res = response)
> req.end()
> global.res.statusCode
具體的 API ,可以查看文檔
練習 - 簡易留言板
用 HTTP 模塊來寫一個簡單的留言板
const http = require('http')
const server = http.createServer()
const port = 8890
const querystring = require('querystring')
const msgs = [{
content: 'hello',
name: 'zhangsan'
}]
server.on('request', (request, response) => {
if (request.method == 'GET') {
response.writeHead(200, {
'Content-Type': 'text/html; charset=UTF-8'
})
response.write(`
<form action='/' method='post'>
<input name='name'/>
<textarea name='content'></textarea>
<button>提交</button>
</form>
<ul>
${
msgs.map(msg => {
return `
<li>
<h3>${msg.name}</h3>
<pre>${msg.content}</pre>
</li>
`
}).join('')
}
</ul>
`)
}
if (request.method == 'POST') {
request.on('data', data => {
var msg = querystring.parse(data.toString())
msgs.push(msg)
})
response.writeHead(301, {
'Location': '/'
})
response.end()
}
})
server.listen(port, () => {
console.log('server listening on port', port)
})
練習 - 靜態文件服務器
用 HTTP 模塊實現一個靜態文件服務器:
- 讓 http://localhost:8090/ 能夠訪問到電腦某一個文件夾(如:c:/foo/bar/baz/ )的內容
- 如果訪問到文件夾,那麼返回該文件夾下的 index.html 文件
- 如果該文件不存在,返回包含該文件夾的內容的一個頁面,且內容可以點擊
- 需要對特定的文件返回正確的 Content-Type
const http = require('http')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const port = 8090
/* const baseDir = './' // 這裏的'./'相對於 node 的工作目錄路徑,而不是文件所在的路徑*/
const baseDir = __dirname // 這裏的__dirname 是文件所在的絕對路徑
const server = http.createServer((req, res) => {
var targetPath = decodeURIComponent(path.join(baseDir, req.url)) //目標地址是基準路徑和文件相對路徑的拼接,decodeURIComponent()是將路徑中的漢字進行解碼
console.log(req.method, req.url, baseDir, targetPath)
fs.stat(targetPath, (err, stat) => { // 判斷文件是否存在
if (err) { // 如果不存在,返回404
res.writeHead(404)
res.end('404 Not Found')
} else {
if (stat.isFile()) { // 判斷是否是文件
fs.readFile(targetPath, (err, data) => {
if (err) { // 即使文件存在也有打不開的可能,比如閱讀權限等
res.writeHead(502)
res.end('502 Internal Server Error')
} else {
res.end(data)
}
})
} else if (stat.isDirectory()) {
var indexPath = path.join(targetPath, 'index.html') // 如果是文件夾,拼接index.html文件的地址
fs.stat(indexPath, (err, stat) => {
if (err) { // 如果文件夾中沒有index.html文件
if (!req.url.endsWith('/')) { // 如果地址欄裏不以/結尾,則跳轉到以/結尾的相同地址
res.writeHead(301, {
'Location': req.url + '/'
})
res.end()
return
}
fs.readdir(targetPath, {withFileTypes: true}, (err, entries) => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=UTF-8'
})
res.end(`
${
entries.map(entry => {// 判斷是否是文件夾, 如果是文件夾,在後面添加一個'/',返回一個頁面,包含文件夾內的文件明,且每個文件名都是一個鏈接
var slash = entry.isDirectory() ? '/' : ''
return `
<div>
<a href='${entry.name}${slash}'>${entry.name}${slash}</a>
</div>
`
}).join('')
}
`)
})
} else { // 如果有index.html文件 直接返回文件內容
fs.readFile(indexPath, (err, data) => {
res.end(data)
})
}
})
}
}
})
})
server.listen(port, () => {
console.log(port)
})
上面的這端代碼利用了回調函數的方式,已經實現了一個簡單的靜態文件服務器,可是代碼的縮進層級過高,可以用async,await的方式使代碼簡潔一點,同時還有一些細節需要完善,比如不同類型的文件需要以不同的格式來打開等,下面一段代碼進行優化
const http = require('http')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const port = 8090
const baseDir = __dirname
var mimeMap = { // 創建一個對象,包含一些文件類型
'.jpg': 'image/jpeg',
'.html': 'text/html',
'.css': 'text/stylesheet',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.txt': 'text/plain',
'xxx': 'application/octet-stream',
}
const server = http.createServer(async (req, res) => {
var targetPath = decodeURIComponent(path.join(baseDir, req.url))
console.log(req.method, req.url, baseDir, targetPath)
try {
var stat = await fsp.stat(targetPath)
if (stat.isFile()) {
try {
var data = await fsp.readFile(targetPath)
var type = mimeMap[path.extname(targetPath)]
if (type) {// 如果文件類型在 mimeMap 對象中,就使用相應的解碼方式
res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})
} else { //如果不在,就以流的方式解碼
res.writeHead(200, {'Content-Type': `application/octet-stream`})
}
res.end(data)
} catch(e) {
res.writeHead(502)
res.end('502 Internal Server Error')
}
} else if (stat.isDirectory()) {
var indexPath = path.join(targetPath, 'index.html')
try {
await fsp.stat(indexPath)
var indexContent = await fsp.readFile(indexPath)
var type = mimeMap[path.extname(indexPath)]
if (type) {// 如果文件類型在 mimeMap 對象中,就使用相應的解碼方式
res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})
} else { //如果不在,就以流的方式解碼
res.writeHead(200, {'Content-Type': `application/octet-stream`})
}
res.end(indexContent)
} catch(e) {
if (!req.url.endsWith('/')) {
res.writeHead(301, {
'Location': req.url + '/'
})
res.end()
return
}
var entries = await fsp.readdir(targetPath, {withFileTypes: true})
res.writeHead(200, {
'Content-Type': 'text/html; charset=UTF-8'
})
res.end(`
${
entries.map(entry => {
var slash = entry.isDirectory() ? '/' : ''
return `
<div>
<a href='${entry.name}${slash}'>${entry.name}${slash}</a>
</div>
`
}).join('')
}
`)
}
}
} catch(e) {
res.writeHead(404)
res.end('404 Not Found')
}
})
server.listen(port, () => {
console.log(port)
})
比如這樣,縮進層級明顯減少,利用自己創建 mimeMap 對象的方式找到對應的解碼方式雖然可以,不過還是比較麻煩,需要自己寫很多內容,而且也不能說列出所有的文件格式。其實 npm 上有可以專門通過拓展名來查詢文件 mime 類型的安裝包(如 mime)。使用前需要提前安裝一下 npm i mime,這樣寫會方便很多。
const http = require('http')
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const mime = require('mime')
const port = 8090
const baseDir = __dirname
const server = http.createServer(async (req, res) => {
var targetPath = decodeURIComponent(path.join(baseDir, req.url))
console.log(req.method, req.url, baseDir, targetPath)
try {
var stat = await fsp.stat(targetPath)
if (stat.isFile()) {
try {
var data = await fsp.readFile(targetPath)
var type = mime.getType(targetPath)
if (type) {// 如果文件類型在 mimeMap 對象中,就使用相應的解碼方式
res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})
} else { //如果不在,就以流的方式解碼
res.writeHead(200, {'Content-Type': `application/octet-stream`})
}
res.end(data)
} catch(e) {
res.writeHead(502)
res.end('502 Internal Server Error')
}
} else if (stat.isDirectory()) {
var indexPath = path.join(targetPath, 'index.html')
try {
await fsp.stat(indexPath)
var indexContent = await fsp.readFile(indexPath)
var type = mime.getType(indexPath)
if (type) {
res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})
} else {
res.writeHead(200, {'Content-Type': `application/octet-stream`})
}
res.end(indexContent)
} catch(e) {
if (!req.url.endsWith('/')) {
res.writeHead(301, {
'Location': req.url + '/'
})
res.end()
return
}
var entries = await fsp.readdir(targetPath, {withFileTypes: true})
res.writeHead(200, {
'Content-Type': 'text/html; charset=UTF-8'
})
res.end(`
${
entries.map(entry => {
var slash = entry.isDirectory() ? '/' : ''
return `
<div>
<a href='${entry.name}${slash}'>${entry.name}${slash}</a>
</div>
`
}).join('')
}
`)
}
}
} catch(e) {
res.writeHead(404)
res.end('404 Not Found')
}
})
server.listen(port, () => {
console.log(port)
})
在剛剛的代碼中,路徑問題和解碼問題已經得到了很好的解決。我們還剩下最後一個,也是比較重要的一個問題,就是安全問題。
例如:訪問的基準路徑爲 /home/pi/www/, 而輸入的網址爲 http://localhost:8090/../../../../../../../etc/passwd
兩個路徑一合併,就化簡爲 /etc/passwd, 這裏有可能存儲的是用戶組的相關信息
同理,理論上可以通過這個方式將計算機上的任意一個文件訪問到
實際上,我們想要的是將基準路徑作爲 HTTP 服務器的根目錄,而無法將根目錄以外的文件訪問到
解決辦法就是 HTTP 服務器的訪問路徑一定要以基準路徑開頭
再例如:文件夾中可能會有一些隱藏文件夾,如.git,裏面存儲了一些用戶的提交信息。或者文件夾中有一些配置文件,裏面存有一些敏感信息,這個是不想被外界所訪問的
const server = http.createServer(async (req, res) => {
..
..
const baseDir = path.resolve('./')
// 注意 這裏的 baseDir 必須是一個絕對路徑了
var targetPath = decodeURIComponent(path.join(baseDir, req.url))
// 阻止將 baseDir 以外的文件發送出去
if (!targetPath.startsWith(baseDir)) {
res.end('hello hacker')
return
}
// 阻止發送以點開頭的文件夾(隱藏文件)裏的文件
if (targetPath.split(path.sep).some(seg => seg.startsWith('.'))) { // 這裏的path.sep 是讀取系統分隔符的方法
res.end('hello hacker')
return
}
..
..
})
這樣,我們的靜態文件服務器纔算差不多寫完了