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
}
..
..
})
这样,我们的静态文件服务器才算差不多写完了