《深入浅出 node.js 笔记》 - part3

相关链接

第 8 章 构建 Web 应用

Cookie

Cookie 的处理分为如下几步:

  • 服务器向客户端发送 Cookie
  • 浏览器将 Cookie 保存
  • 之后每次浏览器都会将 Cookie 发向服务器端

Cookie 是被放在请求头中的而不是请求体中,原生 Node 可以通过 req.headers.cookie 来获取到。

我们来看下设置 Cookie 的 Set-Cookie 字段:

Set-Cookie: name=value; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;

其中 name = value 是必须包含的部分,其余部分皆是可选参数。

  • path:表示这个 Cookie 影响到的路径,当前访问的路径不满足该匹配时,浏览器则不发送这个 Cookie

  • Expires 和 Max-Age:告知浏览器这个 Cookie 何时过期,如果不设置该选项,在关闭浏览时会丢失掉这个 Cookie。
    如果设置了过期时间,浏览器会把 Cookie 内容写入到磁盘并保存,下次打开浏览器依旧有效。
    Expires 的值是一个 UTC 格式的时间字符串,告知浏览器此 Cookie 何时将过期,Max-Age 则告知浏览器此 Cookie 多久后过期。
    如果服务器端和客户端的时间不匹配,使用 Expires 就会存在偏差,为此可以使用 Max-Age。

  • HttpOnly:告知浏览器不允许通过脚本 document.cookie 去更改这个 Cookie 值。事实上,设置 HttpOnly 后,这个值在 document.cookie 中不可见,但是在 HTTP 请求的过程中,依然会发送这个 Cookie 到服务器端。

  • Secure:当 Secure 值为 true 时,在 HTTP 中是无效的,在 HTTPS 中才有效,表示创建的 Cookie 只能在 HTTPS 连接中被浏览器传递到服务器端进行会话验证,如果是 HTTP 连接则不会传递该信息。

// 封装方法快速设置 Cookie
/**
 * 
 * @param {*} name 该 Cookie 的键
 * @param {*} val  该 Cookie 的值
 * @param {*} opt  可选参数
 */
const serialize = function (name, val, opt) {
  const pairs = [`${name}=${val}`]
  opt = opt || {}

  if (opt.maxAge) pairs.push(`Max-Age=${opt.maxAge}`)
  if (opt.domain) pairs.push(`Domain=${opt.domain}`)
  if (opt.path) pairs.push(`Path=${opt.path}`)
  if (opt.expires) pairs.push(`Expires=${opt.expires}`)
  if (opt.httpOnly) pairs.push(`HttpOnly`)
  if (opt.secure) pairs.push(`Secure`)

  return pairs.join('; ')
}

const handler = function (req, res) {
  if (!req.cookies.isVisit) {
    res.setHeader('Set-Cookie', serialize('isVisit', 1))
    res.writeHead(200)
    res.end('欢迎第一次到来')
  } else {
    res.writeHead(200)
    res.end('再次欢迎你')
  }
}

// res.setHeader 的第二个参数可以是一个数组
res.setHeader('Set-Cookie', [serialize('foo', 'bar'), serialize('baz', 'val')])
// 这会在报文头部形成两条 Set-Cookie 字段
// Set-Cookie: foo=bar; Path=/; Expires= ...; Domain=.domain.com
// Set-Cookie: baz=val; Path=/; Expires= ...; Domain=.domain.com

Cookie 的性能影响:

一旦 Cookie 设置过多,将会导致报头较大,大多数的 Cookie 并不需要每次都用上。

如果在域名的根节点设置 Cookie(Path = /),几乎所有子路径下的请求都会带上这些 Cookie,而它们在有些情况下是无用的,比如静态文件。

  • 将静态文件放在不同的域名下,使得业务相关的 Cookie 不再影响静态资源。
  • 使用额外的域名的好处是减少了无效 Cookie 的传输,还可以突破浏览器下载线程数量的限制。缺点是 ¥ 以及额外的一次 DNS 查询

Session

Cookie 最严重的的问题就是前后端都可以进行修改,Session 的数据只保留在服务器端,客户端无法修改,但是仍然需要使用 Cookie 实现用户和数据的映射,一旦服务器端启用了 Session,它将约定一个键值作为 Session 的口令。

一旦服务器检查到用户请求 Cookie 中没有携带该值,它就会为之生成一个值,这个值是唯一且不重复的值,并设定超时时间。

PS:以下代码为原生 node.js 实现的,现在一般使用 express-session 插件配置一下就可以实现相同的功能(我用的时候有 bug 搞不定),所以有时候还是了解下原生实现的好。另外,这里的 session 是存在内存中的,现在一般存在 redis。

const sessions = {}
const key = 'session_id'
const EXPIRES = 20 * 60 * 1000

const generate = function () {
  const session = {}
  session.id = (new Date()).getTime() + Math.random()
  session.cookie = {
    expires: (new Date()).getTime() + EXPIRES
  }
  sessions[session.id] = session
  return session
}

function (req, res) {
  const id = req.cookies[key]
  if (!id) {
    req.session = generate()
  } else {
    const session = sessions[id]
    if (session) {
      if (session.cookie.expire > (new Date()).getTime()) {
        // 更新超时时间
        session.cookie.expire = (new Date()).getTime() + EXPIRES
        req.session = session
      } else {
        // 超时了,删除旧的数据,并重新生成
        delete session[id]
        req.session = generate()
      }
    } else {
      // 如果 sesion 过期或口令不对,重新生成 session
      req.session = generate()
    }
  }
  handler(req, res)
}

// 我们还需要再响应时添加相应头部
// hack 响应对象的 writeHead() 方法
let writeHead = res.writeHead
res.writeHead = function () {
  const cookies = res.getHeader('Set-Cookie')
  const session = serialize('Set-Cookie', req.session.id)
  cookies = Array.isArray(cookies) ? cookies.concat(session) : [cookies, session]
  res.setHeader('Set-Cookie', cookies)
  return writeHead.apply(this, arguments)
}

// 业务逻辑
const handler = function (req, res) {
  if (!req.session.isVisit) {
    res.session.isVisit = true
    res.writeHead(200)
    res.end('欢迎第一次来到动物园')
  } else {
    res.writeHead(200)
    res.end('动物园再次欢迎你')
  }
}

缓存

缓存需要浏览器与服务器共同协作来完成。

通常来说 POST、DELETE、PUT 这类待行为性的请求操作一般不做任何缓存,大多数缓存只应用在 GET 请求中。

本地没有文件时,浏览器必然会请求服务器端的内容,并将这部分内容放置在本地的某个缓存目录中。

在第二次请求时,它将对本地文件进行检查,如果不能确定这份本地文件是否可以直接使用,它将会发起一次条件请求。

所谓条件请求,就是在普通的 GET 请求报文中附带 If-Modified-Since 字段,如下所示:

If-Modified-Since: Sun, 03 Feb 2019 06:01;12 GMT

它将询问服务器端是否有更新的版本,本地文件的最后修改时间。如果服务器端没有新的版本,只需响应一个 304 状态码,客户端就使用本地版本;如果服务器有新版本,就将新的内容发送给客户端,客户端放弃本地版本,代码如下:

const handler = function (req, res) {
  fs.stat(filename, function (err, stat) {
    const lastModified = stat.mtime.toUTCString()
    if (lastModified === req.headers['if-modified-since']) {
      res.writeHead(304, 'Not Modified')
      res.end()
    } else {
      fs.readFile(filename, function (err, file) {
        const lastModified = stat.mtime.toUTCString()
        res.setHeader('Last-Modified', lastModified)
        res.writeHead(200, 'OK')
        res.end(file)
      })
    }
  })
}

浏览器在收到 Etag:"83-13591232132"这样的响应后,下次请求会将其放置在请求头中:If-None-Match:“83-13591232132”

设置 Expires 或 Cache-Control 头,浏览器就可以不向服务器发送 HTTP 请求而知晓是否直接使用本地版本。

其区别之前已经提到过,Expires 可能会出现服务器端和浏览器端时间不同步的情况,Cache-Control 设置 max-age 是倒计时的方式。

如果同时设置了 max-age 和 Expires,max-age 会覆盖 Expires。

const handler = function (req, res) {
  fs.readFile(filename, function (err, file) {
    const expires = new Date()
    expires.setTime(expires.getTime() + 10 * 365 * 24 * 60 * 60 * 1000)
    res.setHeader('Expires', expires.toUTCString())
    res.writeHead(200, 'OK')
    res.end(file)
  })
}

const handelr = function (req, res) {
  fs.readFile(filename, function (err, file) {
    res.setHeader('Cache-Control', 'max-age=' + 10 * 365 * 24 * 60 * 60 * 1000)
    res.writeHead(200, 'OK')
    res.end(file)
  })
}

如果使用 Nginx 做静态资源服务器就看看 Nginx 的缓存配置即可

清除缓存

缓存一旦设定,当服务端意外更新内容时,却无法通知客户端更新。一般有两种更新机制:

  • 每次发布,路径中跟随 Web 应用的版本号:http://url.com?v=20190502
  • 每次发布,路径中跟随该文件内容的 hash 值:http://url.com?hash=sadsadsa

一般采用的是第二种方式(所以 webpack 打包生成的文件都要哈希啊)

MVC

MVC 模型的主要思想是将业务逻辑按职责分离

  • 控制器(Controller),一组行为的集合
  • 模型(Model),数据相关的操作和封装
  • 视图(View),视图的渲染

它的工作模式如下:

  • 路由解析,根据 URL 寻找到对应的控制器和行为
  • 行为调用相关的模型,进行数据操作
  • 数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端

在这里插入图片描述

RESTFUL

REST 全称是 Representational State Transfer,它是一个关于 URL 的设计规范。

比如我们过去对用户的增删改查或许是这么设计 URL 的:

POST /user/add?username=jacksontian
GET /user/remove?username=jacksontian
POST /user/update/username=jacksontian
GET /user/get?username=jacksontian

在 RESTFUL 设计中,它应该是这样的:

POST /user/username=jacksontian
DELETE /user/username=jacksontian
PUT /user/username=jacksontian
GET /user/username=jacksontian

过去设计资源的格式与后缀有很大的关联,比如:

GET /user/jacksontian.json
GET /user/jacksontian.xml

在 RESTFUL 设计中,资源的具体格式由请求报头中的 Accept 字段和服务器端的支持情况来决定。

如果客户端同时接受 JSON 和 XML 格式的响应,那么它的 Accept 字段值是如下这样的:

Accept: application/json,application/xml

靠谱的服务器应该要顾及这个字段,然后根据自己能响应的格式做出响应,在响应报文中,通过 Content-Type 字段告知客户端是什么格式,如下

Content-Type: application/json

所以 RESTful 的设计就是:通过 URL 设计资源,请求方法定义资源的操作,通过 Accept 决定资源的表现形式

中间件

中间件的行为类似 Java 中过滤器的工作原理,就是在进入具体的业务处理之前,先让过滤器处理,比如对于每个请求我们一般都要解析 cookie,querystring 什么的,那么就设计对应的中间件处理完成后存储在上下文中(req 和 res,Koa2 合并为一个 context)

在这里插入图片描述

// 模拟中间件的实现
const http = require('http')
const slice = Array.prototype.slice

class LikeExpress {
  constructor() {
    // 存放中间件的列表
    this.routes = {
      all: [], // 存放 app.use 注册的中间件
      get: [], // 存放 app.get 注册的中间件
      post: []
    }
  }

  // 内部实现注册的方法
  register(path) {
    const info = {}
    if (typeof path === 'string') { // 字符串 - 路由
      info.path = path
      // 从第二个参数开始,转换为数组,存入stack
      info.stack = slice.call(arguments, 1) // 取出剩余的参数
    } else { // 没有显式地传入路由则默认是根路由
      info.path = '/' // 省略第一个参数 -> 根目录
      // 从第一个参数开始,转换为数组,存入stack
      info.stack = slice.call(arguments, 0)
    }
    // { path: '', stack: [middleware, ...] }
    return info
  }

  use() {
    const info = this.register.apply(this, arguments) // 把当前函数的所有参数传入
    this.routes.all.push(info)
  }

  get() {
    const info = this.register.apply(this, arguments) // 把当前函数的所有参数传入
    this.routes.get.push(info)
  }

  post() {
    const info = this.register.apply(this, arguments) // 把当前函数的所有参数传入
    this.routes.post.push(info)
  }

  match(method, url) {
    let stack = [] // resultList
    if (url === '/favicon.ico') { // 小图标无视
      return stack
    }
    // 获取 routes
    let curRoutes = []
    curRoutes = curRoutes.concat(this.routes.all)
    curRoutes = curRoutes.concat(this.routes[method])

    curRoutes.forEach(routeInfo => {
      if (url.indexOf(routeInfo.path === 0)) {
        // url === '/api/get-cookie' 且 routeInfo.path === '/'
        // url === '/api/get-cookie' 且 routeInfo.path === '/api'
        // url === '/api/get-cookie' 且 routeInfo.path === '/api/get-cookie'
        stack = stack.concat(routeInfo.stack)
      }
    })
    return stack
  }
  
  // 核心的 next 机制
  handle(req, res, stack) {
    const next = () => {
      // 拿到第一个匹配的中间件
      const middleware = stack.shift()
      if (middleware) {
        // 执行中间件函数
        middleware(req, res, next)
      }
    }
    next()
  }

  callback() {
    return (req, res) => {
      // 自己定义 res.json 方法
      res.json = data => {
        res.setHeader('Content-Type', 'application/json')
        res.end(
          JSON.stringify(data)
        )
      }

      // 获取 url 和 method :通过这两个来获得需要经过的中间件
      const url = req.url
      const method = req.method.toLowerCase()

      // match 函数匹配可用的中间件列表
      const resultList = this.match(url, method)
      this.handle(req, res, resultList)
    }
  }

  listen(...args) {
    const server = http.createServer(this.callback)
    server.listen(...args)
  }
}

// 工厂函数
module.exports = () => {
  return new LikeExpress()
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值