node 初步 (四) --- HTTP 模块和静态文件服务器
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 } .. .. })
这样,我们的静态文件服务器才算差不多写完了