整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

手摸手带你实现一个静态服务器,超详细

手摸手带你实现一个静态服务器,超详细

是学习 Node.js 的第七篇,这一篇主要是了解 http,同时实现一个静态资源服务器。先看一下这个服务器有什么功能。

服务器功能展示

首先我们在命令行工具输入 ss (意为:super server),它会帮我们在当前目录启动一个静态资源服务器。服务器的地址为 「http://localhost:3000」

当我们访问 「http://localhost:3000」时,它把我们当前目录的所有文件都罗列了出来。

我们点击一个文件,例如 pacakge.json,它会把当前文件的内容显示出来:

OK,主要功能就是这些,下面我们一起来实现一下。

可以通过 ss --port 3001 指定端口号,通过 ss --directory C:\foo\bar 指定服务器的工作目录,即静态资源的根目录。

http 模块

既然是服务器,那一定是使用了 Node 的 http 模块,我们先简单的了解下如何使用 http 创建一个服务器。

创建一个服务器

const http = require('http')

const server = http.createServer((req, res) => {
  console.log('有请求过来了~~~')
})

let port = 3000
server.listen(port, () => {
  console.log(`server start ${port}`)
})

使用 http.createServer 即可创建一个服务器,然后再调用 server.listen() 方法监听一个端口,就算正式创建成功了。这时我们直接访问 http://localhost:3000 即可在命令行看到打印 有请求过来了

那么我们如何获得这个请求的具体信息,并给客户端做出相应呢?

其实,每次请求过来的时候都会执行 createServer(callback) 中传入的回调,回调内会传入两个参数:「req(request) 与 res(response)」。req 就代表请求信息与相关操作,res 代表响应信息与相关操作。

我们具体来使用一下这两个对象。

const http = require('http')
const url = require('url')

const server = http.createServer((req, res) => {
  // 请求方法名
  console.log(req.method)
  // 请求url
  console.log(req.url)
  // 请求头
  console.log(req.headers)

  // req 是一个可读流
  req.on('data', chunk => {
    console.log(chunk)
  })
  req.on('end', () => {})

  // 响应行->响应头->响应体顺序不能变
  
  // 首先设置响应行(状态码与状态码描述)
  res.statusCode = 200
  res.statusMessage = 'success'
  // 设置响应头
  res.setHeader('name', 'superYue')
  // 最后设置响应体
  // res 是一个可写流
  res.write('ok')
  res.end('1')
})

let port = 3000
server.listen(port, () => {
  console.log(`server start ${port}`)
})

此时,我们在浏览器访问 http://localhost:3000 就可以下看到如下内容:

「这里有一些点需要大家注意」

  • req 是一个可读流,我们获取请求体的时候,必须要以流的形式获取,正如我上述代码所写的那样
  • res 是一个可写流,所以我们设置响应体的时候,必须用 write() 方法,结束响应时,必须用 end() 方法

「对可读流、可写流不清楚的同学可以看下此系列文章下的《手写文件流》」

开始写代码

现在我们进入主题——「实现一个静态资源服务器」

先看一下我们的目录结构。

bin 目录是命令行逻辑代码

src/satic-server.js 静态资源服务器

src/template.html html 模板

实现命令行功能

我们的服务器是在命令行内输入 ss 之后自动启动的,我们看一下这个功能是怎么实现的?

首先,我们要在 package.json 内增加一个 bin 字段,如下代码所示:

// pacakge.json
{
  "bin": {
    "ss": "./bin/www.js"
  },
}

ss 是我们运行的命令,./bin/www.js 是运行 ss 后要被执行的 js 文件。

然后,./bin/www.js 内注意要添加这行代码 #! /usr/bin/env node,这行代码的意思是用 node 环境来执行以下代码,这样我们就可以尽情的去写 Node 代码了。

最后,我们要在当前的工作目录去执行 npm link,这样才能将 ss 命令注册到全局变量中去,不然系统是不认识 ss 的。

现在我们已经可以执行 ss 命令了,理论上就可以在 bin/www.js 内去实现一个静态服务器了,但是在真正实现之前,我想有一些定制化的功能,比如自定义启动服务的端口号,自定义静态服务器的工作目录。

要实现这样的定制化功能,那肯定是在命令行内去输入,例如:

ss --port 3000 启动一个 3000 端口的服务器

ss --directory C: 静态资源服务器的根目录是 C 盘。

然后我们要解析 ss 输入的参数,这些参数 Node 都帮我们保存在了 process.argv 属性里,打印出来的结果如下图所示。

如果我们想得到正确的结果,需要我们自己去解析。这里给大家推荐一个工具——commander,它是一个完整的 node.js 命令行解决方案,github链接点这。

我们来看一下示例:

const { program } = require('commander)

// 声明一个 prot 参数,要求必须有值,默认值是 3000
// 'set your server port' 是命令描述
program.option('-p, --port <v>', 'set your server port', 3000);
// 开始解析命令
program.parse(process.argv);
// 通过 program.port 拿到解析好参数
console.log(`port: ${program.port}`);

可以看到,最终我们输入的命令都会被解析到 program 内。

这只是 commander 一部分功能,完整功能可以看具体文档。

接下来把我 www.js 代码贴出来

#! /usr/bin/env node

const program = require('commander')
const StaticServer = require('../src/static-server')
console.log(process.argv)
program.name('ss')

program
  .option('-p, --port <v>', 'set your server port', 3000)
  .option('-d, --directory <v>', 'set your server start directory', process.cwd())

program.on('--help', () => {
  console.log('\nExamples:')
  console.log('ss -p 3000 / ss --port 3000')
  console.log('ss -d C: / ss --directory C:')
})

program.parse(process.argv)

const config = {}
config.port = program.port ?? process.cwd()

new StaticServer(config).start()

program.on('--help') 的意思是监听 --help 命令。每当用户输入 ss --help 的时候,我们都把操作提示给打印出来。

静态资源服务器

在上段代码内,我们在 www.js 里执行了 new StaticServer(config).start(),这句代码的意思是启动一个静态资源服务器,接下来,我们就来实现一下这个。

初始化参数

首先,我们声明一个类,并初始化参数。

class StaticServer {
  constructor(config) {
    this.port = config.port
    this.directory = config.directory
  }
}

start()

然后,调用 start 的时候,我们创建一个服务器。

start() {
  const server = http.createServer(this.handleRequest.bind(this))
  server.listen(this.port, () => {
    console.log(`${chalk.yellow('Starting up super-server: ')}${this.directory}`)
    console.log(`http://localhost:${chalk.green(this.port)}`)
  })
}

为了更好的处理请求,我们把处理请求的逻辑全都放到了 handleRequest() 方法内。

chalk 中文意思为粉笔,是专门用来改变控制台输出颜色的第三方包。

handleRequest()

这个方法是专门用来处理请求的。

我们现在想一下,当一个静态资源的请求过来时,我们应该做什么操作?

  1. 判断请求的资源是一个文件还是一个文件夹。如果是文件,则直接返回文件内容;如果是文件夹,则返回文件夹内存在的资源列表。
  2. 对文件资源进行缓存。因为是静态资源服务器,所以肯定要有缓存功能,因为文件是不会经常变的。
  3. 响应文件内容。

我们看下具体代码

async handleRequest(req, res) {
  // 获取请求路径
  // url 为 Node 的核心模块
  const { pathname } = url.parse(req.url)
  // 工作目录与请求路径拼接,得到最终的静态资源地址
  // 这里的工作目录默认是 process.cwd(),意思是当前代码启动的目录
  // 可以通过 --directory 去指定
  const filePath = path.join(this.directory, pathname)

  try {
    // 获取文件信息
    const stat = await fs.stat(filePath)

    if (stat.isFile()) {
      // 如果是文件,则返回文件信息
      this.sendFile(req, res, filePath, stat)
    } else {
      // 如果是文件夹,则返回资源列表
      this.sendFolder(req, res, filePath, pathname)
    }
  } catch(e) {
    // 返回错误信息
    this.sendError(req, res, e)
  }
}

代码注释非常详细,相信不用做过多的解释。

这里最终获取静态资源的地址是:请求的路径 + 服务器工作目录(默认是 process.cwd(),可以通过 --dircetory 去指定

sendFile()

sendFile 对客户端响应文件信息,在响应之前,要做缓存相关的操作,这些操作都放在了 cache() 方法内。

缓存包括强缓存与协商缓存,强缓存取的是浏览器客户端内的内容,浏览器不会对服务器发起响应。协商缓存需要服务器判断文件是否发生了变化,如果未发生变化则返回 304。

在具体返回响应之前,要设置响应内容的 mime 格式,用来告诉客户端如何处理这段内容。例如,如果是 html 内容,那我们的 Content-Type 响应头必须是 text/html,不然浏览器不能正确的解析。这里我们使用了 mime 这个第三方包,它可以根据文件后缀得到正确的 mime 类型。

响应内容的时候,我们会以流的形式去响应,所以这里我们创建了一个文件可读流。

sendFile(req, res, filePath, stat) {
  if (this.cache(req, res, filePath, stat)) {
    res.statusCode = 304
    res.end()
    return;
  }

  res.setHeader('Content-Type', mime.getType(filePath))
  createReadStream(filePath).pipe(res)
}

sendFolder()

返回文件夹内的文件列表。

将文件夹内的文件全部读取出来,并以 html 的形式返回给浏览器以供展示。这里使用了 ejs 模板引擎来渲染 html。

async sendFolder(req, res, filePath, pathname) {
  let dirs = await fs.readdir(filePath)
  dirs = dirs.map(item => ({
    filename: item,
    href: path.join(pathname, item)
  }))
  console.log(dirs)
  const template = await fs.readFile(path.resolve(__dirname, './template.html'), 'utf-8')

  const html = await ejs.render(template, { dirs }, { async: true })

  res.setHeader('Content-Type', 'text/html;charset=utf-8')
  res.end(html)
}

我们将读取出来的文件列表传给 template 静态模板,然后利用 ejs 的得到渲染后的 html。

template.html 模板代码如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>zs</title>
</head>
<body>

<!-- 出路路径 尽量不要采用./ ../  绝对路径 /a/a.js -->

  <%dirs.forEach(item=>{%>
      <li><a href="<%=item.href%>"><%=item.filename%></a></li>
  <%})%>
</body>
</html>

cache()

cache() 方法封装了文件缓存操作。

首先对文件应用缓存,设置 ExpiresCache-Control 响应头,这两个字段设置任何一个字段都可以实现缓存,但为了最大的保证兼容性,我们这里都做了设置。

如果浏览器缓存失效,会重新发起请求,这时需要服务器判断资源是否真的被更改了,判断文件资源的缓存是否失效有两种方案。

  1. 通过判断文件的修改时间来确定是否失效。第一次请求时,将文件的修改时间通过 Last-Modified 响应头带给浏览器,浏览器下次请求时会将修改时间放入 if-modified-since 请求头内,这时我们就可以通过 if-modified-since 字段与当前文件的修改时间做比较,来判断文件是否被修改了。

但是这种做法有缺陷,假如我们将文件修改了,然后过一会又修改成原来的内容,这时最终的文件是没有变化的,但是文件的修改时间却变了,这样就导致缓存失效。

  1. 第二种解决了上述问题。第一次请求时,返回给浏览器的是一个文件摘要,以后请求时,根据文件摘要来判断资源是否过期。由于文件摘要是根据文件内容生成的,所以文件内容不变,摘要就不会变。用来控制缓存的字段分别是,浏览器:ifNoneMatch;客户端:Etag。
cache(req, res, filePath, stat) {
  res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toGMTString())
  res.setHeader('Cache-Control', `max-age=${10}`)

  const ifModifiedSince = req.headers['if-modified-since']
  const ctime = stat.ctime.toGMTString()
  if (ifModifiedSince !== ctime) {
    return false
  }

  const ifNoneMatch = req.headers['if-none-match']
  // 利用 MD5 生成文件摘要
  // crypto 为内置的加密算法
  const etag = crypto.createHash('md5').update( readFileSync(filePath)).digest('base64')
  if (ifNoneMatch !== etag) {
    return false
  }

  res.setHeader('Last-Modified', ctime)
  res.setHeader('Etag', etag)

  return true;
}

完整代码

const http = require('http')
const url = require('url')
const fs = require('fs').promises
const path = require('path')
const { createReadStream, readFileSync } = require('fs')
const crypto = require('crypto')

const chalk = require('chalk')
const mime = require('mime')
const ejs = require('ejs')

class StaticServer {
  constructor(config) {
    this.port = config.port
    this.directory = config.directory
  }

  start() {
    const server = http.createServer(this.handleRequest.bind(this))
    server.listen(this.port, () => {
      console.log(`${chalk.yellow('Starting up super-server: ')}${this.directory}`)
      console.log(`http://localhost:${chalk.green(this.port)}`)
    })
  }

  async handleRequest(req, res) {
    const { pathname } = url.parse(req.url)
    const filePath = path.join(this.directory, pathname)
    console.log(filePath)

    try {
      const stat = await fs.stat(filePath)
      
      if (stat.isFile()) {
        this.sendFile(req, res, filePath, stat)
      } else {
        this.sendFolder(req, res, filePath, pathname)
      }
    } catch(e) {
      this.sendError(req, res, e)
    }
  }

  cache(req, res, filePath, stat) {
    res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toGMTString())
    res.setHeader('Cache-Control', `max-age=${10}`)

    const ifModifiedSince = req.headers['if-modified-since']
    const ctime = stat.ctime.toGMTString()
    if (ifModifiedSince === ctime) {
      return true
    }

    const ifNoneMatch = req.headers['if-none-match']
    const etag = crypto.createHash('md5').update( readFileSync(filePath)).digest('base64')
    if (ifNoneMatch === etag) {
      return true
    }

    res.setHeader('Last-Modified', ctime)
    res.setHeader('Etag', etag)

    return false;
  }

  sendFile(req, res, filePath, stat) {
    if (this.cache(req, res, filePath, stat)) {
      res.statusCode = 304
      res.end()
      return;
    }

    res.setHeader('Content-Type', mime.getType(filePath))
    createReadStream(filePath).pipe(res)
  }

  async sendFolder(req, res, filePath, pathname) {
    let dirs = await fs.readdir(filePath)
    dirs = dirs.map(item => ({
      filename: item,
      href: path.join(pathname, item)
    }))
    console.log(dirs)
    const template = await fs.readFile(path.resolve(__dirname, './template.html'), 'utf-8')

    const html = await ejs.render(template, { dirs }, { async: true })

    res.setHeader('Content-Type', 'text/html;charset=utf-8')
    res.end(html)
  }

  sendError(req, res, e) {
    res.end(e.message)
  }
}

module.exports = StaticServer

总结

可以看到,这个静态资源服务器并不是特别复杂,但是它却给我们带来了不少知识点。

  • 命令行工具的使用
  • 一个 http 请求与响应的具体过程
  • http 缓存策略
  • http、url、crypto 等核心模块的使用

希望这篇文章可以给大家带来一些收获~~~

也可以看下这一系列的其它文章~~~

模板完全静态化,也就是通过模板完全生成纯静态的网页,相比动态页面和伪静态页面更安全更利于SEO访问更快。相比前二者各有利弊吧,现在稍微对这三种形式的优缺点对比一下,以及在ThinkPHP5项目中实现完全静态化的基本过程。

对比

1. 动态与真静态

页面静态化与动态页的对比,静态没有了SQL和一些后端脚本运行,安全稳定,访问速度快,对SEO友好(网上也有说现在的搜索引擎已经对动态网页的抓取没什么压力了),但是搜索引擎再强大,静态的URL也比动态的后面带问号冒号什么的要好看,不对SEO友好对普通浏览用户者也是友好(好看第一)。但是生成静态页面的弊端,也就是如果一个博客网站,随着文章内容的增多,那生成的页面也不断增多,就算一个html就30几Kb,数量多的情况下也挺耗存储空间,网上也有说频繁生成静态页面化,容易让硬盘出现坏道。这个我的看法是不好测试可以忽略,因为现在多数是使用云服务器或云虚拟主机,那些都不是物理硬件,就算太过碎片导致硬盘损坏,网站也能正常访问的,因为那是云服务器。

2. 真静态与伪静态

这二者的对比看起来像是正统之争,因为大家都知道伪静态还是动态页,只是Apache通过URL重写规则让其变成了像静态网页的样子。主要也是让自己对SEO友好,但是相比真静态多了Apache的步骤,所以也就比较耗费一些服务器的资源。而真静态的缺点上面也说了,在项目中的选择看需求,各有利弊,北桥苏的使用主要是自己网站有时要优化一下速度所以就做了模板静态化,以下是操作过程。

实现思路

1. 根据模块控制器自动递归创建目录。

2. file_exists判断生成的静态页是否存在

3. 或判断过期与否,存在重定向到静态网页

4. file_put_contents($file,$content)函数生成页面。

编码

1. 目录的创建

/*
 * 递归创建目录
 * @param string $dir 文件目录路径
 * @return boolean 创建结果
 * **/
function mkdirs($dir)
{
 if(!is_dir($dir))
 {
 if(!mkdirs(dirname($dir))){
 return false;
 }
 if(!mkdir($dir,0777)){
 return false;
 }
 }
 return true;
}

2. 在基类中初始化需创建的目录

protected $staticHtmlDir=""; //静态模板生成目录
protected $staticHtmlFile=""; //静态文件
protected function _initialize() {
 parent::_initialize();
 $this->staticHtmlDir="html".DS.$this->request->controller().DS;
//……………………………………………………………………

3. 基类中的生成前与生成后的方法。

//判断是否存在静态
public function beforeBuild($param) {
 //生成静态
 //$baseDir="html".DS.$this->request->controller().DS;
 if(is_array($param)) {
 $param=implode("_",$param);
 }
 $this->staticHtmlFile=$this->staticHtmlDir.$this->request->action().($param?$param:'').'.html';
 if(mkdirs($this->staticHtmlDir)) {
 if(file_exists($this->staticHtmlFile) && filectime($this->staticHtmlFile)>=time()-60*60*24*5) { //静态文件存在
 $this->redirect('/'.$this->staticHtmlFile);
 }
 }
 }
//开始生成静态文件
public function afterBuild($html) {
 if(!empty($this->staticHtmlFile) && !empty($html)) {
 if(file_exists($this->staticHtmlFile)) {
 unlinnk($this->staticHtmlFile);
 }
 if(file_put_contents($this->staticHtmlFile,$html)) {
 $this->redirect('/'.$this->staticHtmlFile);
 }
 }
 }

4. 视图控制器中的使用。

ThinkPHP5中fetch方法返回给file_put_contents函数作为content就可以生成一个完整的静态页面了。

态站点生成器是使用手工编码静态站点和完整CMS(内容管理系统)之间的折衷方案,同时保留两者的优点。本质上,你可以使用类似CMS的概念(如模板)生成基于静态HTML页面的网站。可以从数据库、标记文件、API或任何实际存储位置提取内容。想对静态站点生成器有更深的认识和了解,可以通过参加Web前端培训来学习,在老师的教导下,你会获得更大的进步。

下面是3个常用的静态站点生成器的比较,他们各有特点,通过比较,你能知道什么时候使用它们。

1Next.js

Next.js是一个用于静态导出React应用程序的免费开源框架。特包括:

预渲染(下一步支持服务器端渲染)

零配置

扩展性

JS中的CSS

很棒的文档

2Gatsby

Gatsby是一个基于React的免费开源框架,有助于开发人员创建速度极快的网站和应用程序。在Web前端培训中,有很多课程让你学习Gatsby以及其他静态站点生成器的使用,老师面对面教学指导,及时解决疑难杂症,让你获得快速提升。

Gatsby提供了大量功能,如:

CSSJavaScript的现代力量

丰富的数据插件生态系统

渐进式web应用程序生成

超级容易部署

起动器,或根据不同用例定制的预包装Gatsby站点

3Hugo

Hugo是专门用来提高速度的,它在毫秒之间产生网站。由于其速度快和各种内置功能,你会发现Hugo常被用于生成博客和文档。它得到了广泛的应用,并继续得到改善。Hugo的特点有:

速度非常快,任何东西都无法与之匹敌

有很多内置的功能,几乎不需要第三方插件

很容易搞定

有适当的文档

模板语言并不难学

静态站点生成器使静态网站更易维护和制作,如果你是做前端的,那么学会一些静态站点生成器的使用是很有必要的。想学习的同学可以考虑报名参加Web前端培训,通过理论课程和实操项目的训练,在短时间就能学到有用的知识和技能。


了解更多