1. 程式人生 > >原生node寫一個靜態資源伺服器

原生node寫一個靜態資源伺服器

myanywhere

用原生node做一個簡易閹割版的anywhere靜態資源伺服器,以提升對node與http的理解。

demo

相關知識

  • es6及es7語法
  • http的相關網路知識
    • 響應頭
    • 快取相關
    • 壓縮相關
  • path模組

    • path.join拼接路徑
    • path.relative
    • path.basename
    • path.extname
  • http模組

  • fs模組

    • fs.stat函式

      使用 fs.stat函式取得stats來獲取檔案或資料夾的引數

      • stats.isFile 判斷是否為資料夾
    • fs.createReadStream(filePath).pipe(res)

      檔案可讀流的形式,使讀取效率更高

    • fs.readdir

    • ...

  • promisify

    • async await

1.實現讀取檔案或資料夾

const http= require('http')
const conf = require('./config/defaultConfig')
const path = require('path')
const fs = require('fs')

const server = http.createServer((req, res) => {
  const filePath = path.join(conf.root, req.url)
  // http://nodejs.cn/api/fs.html#fs_class_fs_stats
  fs.stat(filePath, (err, stats) => {
    if (err) {
      res.statusCode = 404
      res.setHeader('Content-text', 'text/plain')
      res.end(`${filePath} is not a directoru or file`)
    }
    // 如果是一個檔案
    if (stats.isFile()) {
      res.statusCode = 200
      res.setHeader('Content-text', 'text/plain')
      fs.createReadStream(filePath).pipe(res)
    } else if (stats.isDirectory()) {
      fs.readdir(filePath, (err, files) => {
        res.statusCode = 200
        res.setHeader('Content-text', 'text/plain')
        res.end(files.join(','))
      })
    }
  })
})

server.listen(conf.port, conf.hostname, () => {
  const addr = `http:${conf.hostname}:${conf.port}`
  console.info(`run at ${addr}`)
})

2. async await非同步修改

為了避免多層回調出現,我們使用jsasync 和 await來 改造我們的程式碼

router.js

把邏輯相關的程式碼從app.js中抽離出來放入router.js中,分模組開發

const fs = require('fs')
const promisify = require('util').promisify
const stat = promisify(fs.stat)
const readdir = promisify(fs.readdir)

module.exports = async function (req, res, filePath) {
  try {
    const stats = await stat(filePath)
    if (stats.isFile()) {
      res.statusCode = 200
      res.setHeader('Content-text', 'text/plain')
      fs.createReadStream(filePath).pipe(res)
    } else if (stats.isDirectory()) {
      const files = await readdir(filePath)
      res.statusCode = 200
      res.setHeader('Content-text', 'text/plain')
      res.end(files.join(','))
    }
  } catch (error) {
    res.statusCode = 404
    res.setHeader('Content-text', 'text/plain')
    res.end(`${filePath} is not a directoru or file`)
  }
}

app.js

const http= require('http')
const conf = require('./config/defaultConfig')
const path = require('path')
const route = require('./help/router')

const server = http.createServer((req, res) => {
  const filePath = path.join(conf.root, req.url)
  route(req, res, filePath)
})

server.listen(conf.port, conf.hostname, () => {
  const addr = `http:${conf.hostname}:${conf.port}`
  console.info(`run at ${addr}`)
})

3. 完善可點選

上面的工作 已經可以讓我們在頁面中看到資料夾的目錄,但是是文字,不可點選

使用handlebars渲染

  • 引用handlebars

    const Handlebars = require('handlebars')
  • 建立模板html

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>{{title}}</title>
      <style>
        body {
          margin: 10px
        }
        a {
          display: block;
          margin-bottom: 10px;
          font-weight: 600;
        }
      </style>
    </head>
    <body>
      {{#each files}}
        <a href="{{../dir}}/{{file}}">{{file}}</a>
      {{/each}}
    </body>
    </html>
    

  • router.js配置

    引用時使用絕對路徑

    const tplPath = path.join(__dirname, '../template/dir.html')
    const source = fs.readFileSync(tplPath, 'utf8')
    const template = Handlebars.compile(source)
  • 建立資料 data

    ....
    module.exports = async function (req, res, filePath) {
      try {
       ...
        } else if (stats.isDirectory()) {
          const files = await readdir(filePath)
          res.statusCode = 200
          res.setHeader('Content-text', 'text/html')
          const dir = path.relative(config.root, filePath)
          const data = {
            // path.basename() 方法返回一個 path 的最後一部分
            title: path.basename(filePath),
            // path.relative('/data/orandea/test/aaa',      '/data/orandea/impl/bbb');
            // 返回: '../../impl/bbb'
            dir: dir ? `/${dir}` : '',
            files
          }
          console.info(files)
          res.end(template(data))
        }
      } catch (error) {
    ...
      }
    }
    

4. mime

新建mime.js檔案

const path = require('path')

const mimeTypes = {
    ....
}
module.exports = (filePath) => {
  let ext = path.extname(filePath).toLowerCase()

  if (!ext) {
    ext = filePath
  }

  return mimeTypes[ext] || mimeTypes['.txt']
}

mine.js 根據檔案字尾名來返回對應的mime

5. 壓縮頁面優化效能

對讀取的stream壓縮

在 defaultConfig.js中 新增 compress項

module.exports = {
  // process.cwd() 路徑能隨著執行路徑的改變而改變
  // process cwd() 方法返回 Node.js 程序當前工作的目錄。
  root: process.cwd(),
  hostname: '127.0.0.1',
  port: 9527,
  compress: /\.(html|js|css|md)/
}

編寫壓縮處理 compress

const {createGzip, createDeflate} = require('zlib')
module.exports = (rs, req, res) => {
  const acceptEncoding = req.headers['accept-encoding']
  if (!acceptEncoding || !acceptEncoding.match(/\b(gzip|deflate)\b/)) {
    return
  } else if (acceptEncoding.match(/\bgzip\b/)) {
    res.setHeader('Content-Encoding', 'gzip')
    return rs.pipe(createGzip())
  } else if (acceptEncoding.match(/\bdeflate\b/)) {
    res.setHeader('Content-Encoding', 'deflate')
    return rs.pipe(createGzip())
  }
}

/*
 match() 方法可在字串內檢索指定的值,或找到一個或多個正則表示式的匹配。
 該方法類似 indexOf() 和 lastIndexOf() ,但是它返回指定的值,而不是字串的位置。
 */

router.js中讀取檔案的更改

...
let rs = fs.createReadStream(filePath)
if (filePath.match(config.compress)) {
    rs = compress(rs, req, res)
  }
rs.pipe(res)

檔案結果compress壓縮後,壓縮率可達 70%

6.處理快取

快取大致原理

使用者請求 本地快取 --no--> 請求資源 --> 協商快取 返回響應

使用者請求 本地快取 --yes--> 判斷換存是否有效 --有效--> 本地快取 --無效--> 協商快取 返回響應

快取header

  • expires 老舊 現在不用
  • Cache-Control 相對與上次請求的時間
  • If-Modified-Since / Last-Modified
  • If-None-Match / ETag

cache.js

const {cache} = require('../config/defaultConfig')
function refreshRes(stats, res) {
  const { maxAge, expires, cacheControl, lastModified, etag } = cache
  if (expires) {
    res.setHeader('Expores', (new Date(Date.now() + maxAge * 1000)).toUTCString())
  }
  if (cacheControl) {
    res.setHeader('Cache-Control', `public, max-age=${maxAge}`)
  }
  if (lastModified) {
    res.setHeader('Last-Modified', stats.mtime.toUTCString())
  }
  if (etag) {
    res.setHeader('ETag', `${stats.size}-${stats.mtime.toUTCString()}`)
  }
}

module.exports = function isFresh(stats, req, res) {
  refreshRes(stats, res)

  const lastModified = req.headers['if-modified-since']
  const etag = req.headers['if-none-match']

  if (!lastModified && !etag) {
    return false
  }
  if (lastModified && lastModified !== res.getHeader('Last-Modified')) {
    return false
  }
  if (etag && res.getHeader('ETag').indexOf(etag) ) {
    return false
  }
  return true
}

router.js

// 如果檔案是是新鮮的 不用更改,就設定響應頭 直接返回
if (isFresh(stats, req, res)) {
      res.statusCode = 304
      res.end()
      return
    }

7.自動開啟瀏覽器

編寫openUrl.js

const {exec} = require('child_process')

module.exports = url => {
  switch (process.platform) {
  case 'darwin':
    exec(`open ${url}`)
    break

  case 'win32':
    exec(`start ${url}`)
  }
}

只支援Windows和 mac系統

在app.js中使用

server.listen(conf.port, conf.hostname, () => {
  const addr = `http:${conf.hostname}:${conf.port}`
  console.info(`run at ${addr}`)
  openUrl(addr)
})

總結

domo不難,但是涉及到的零碎知識點比較多,對底層的node有個更進一步瞭解,也感受到了node在處理網路請求這一塊的強大之處,另外es6和es7的新語法很是強大,以後要多做功課。