node 初步 (四) --- HTTP 模块和静态文件服务器

小小狐狸 2019-09-29

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
  }
                     ..
                     ..

})

这样,我们的静态文件服务器才算差不多写完了

相关推荐