💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
[TOC] # 第 8 章 构建 Web 应用 ## Cookie Cookie 的处理分为如下几步: - 服务器向客户端发送 Cookie - 浏览器将 Cookie 保存 - 之后每次浏览器都会将 Cookie 发向服务器端 Cookie 是被放在请求头中的而不是请求体中,原生 Node 可以通过 req.headers.cookie 来获取到。 我们来看下设置 Cookie 的 Set-Cookie 字段: ```js 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 连接则不会传递该信息。 ```js // 封装方法快速设置 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。 ```js 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 状态码,客户端就使用本地版本;如果服务器有新版本,就将新的内容发送给客户端,客户端放弃本地版本,代码如下: ```js 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。 ```js 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 寻找到对应的控制器和行为 - 行为调用相关的模型,进行数据操作 - 数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端 ![](https://box.kancloud.cn/f836e4ea117c6f332535e4b68e347fbe_569x323.png =400x) ## RESTFUL REST 全称是 Representational State Transfer,它是一个关于 URL 的设计规范 比如我们过去对用户的增删改查或许是这么设计 URL 的: ```shell POST /user/add?username=jacksontian GET /user/remove?username=jacksontian POST /user/update/username=jacksontian GET /user/get?username=jacksontian ``` 在 RESTFUL 设计中,它应该是这样的: ```shell POST /user/username=jacksontian DELETE /user/username=jacksontian PUT /user/username=jacksontian GET /user/username=jacksontian ``` 过去设计资源的格式与后缀有很大的关联,比如: ```shell 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) ![](https://box.kancloud.cn/22d3c8400e5948030aa16412085e73c0_901x501.png) ```js // 模拟中间件的实现 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() } ``` # 第 9 章 玩转进程 ## 服务模型的变迁 <span style="font-famil: 楷体; font-size: 20px;" >石器时代:同步</span> 最早的服务器,其执行模型是同步的,其服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。这意味着除了当前的请求被处理外,其余请求都处于耽误的状态。这类架构如今已基本被淘汰,只在一些无并发要求的应用中存在。 <span style="font-famil: 楷体; font-size: 20px;" >青铜时代:复制进程</span> 通过进程的复制同时服务更多的请求和用户。这样每个连接都需要一个进程来服务,即 100 个连接需要启动 100 个进程来进行服务,这是非常昂贵的代价。在复制进程的过程中,需要复制进程内部的状态,对于每个连接都进行这样的复制的话,相同的状态将会在内存中存在很多份,造成浪费。并且这个过程由于要复制较多的数据,启动是较为缓慢的。 为了解决启动缓慢的问题,预复制(prefork)被引入服务模型中,即预先复制一定数量的进程。同时将进程复用,避免进程创建、销毁带来的开销。但是这个模型不具备伸缩性,一旦并发请求过高,内存使用随着进程数的增长将会被耗尽。 <span style="font-famil: 楷体; font-size: 20px;" >白银时代:多线程</span> 为了解决进程复制中的浪费问题,多线程被引入服务模型,让一个线程服务一个请求。线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。但是多线程所面临的并发问题只能说比多进程略好,因为每个县城都拥有自己独立的堆栈,这个堆栈需要占用一定的内存空间。另外,由于一个 CPU 核心在一个时刻只能做一件事,操作系统只能通过将 CPU 切分为时间片的方法,让线程可以较为均匀地使用 CPU 资源,但是操作系统内核在切换线程的同时也要切换线程的上下文,当线程数量过多时,时间将会被耗用在上下文切换中。所以在大并发量时,多线程结构还是无法做到强大的伸缩性。 <span style="font-famil: 楷体; font-size: 20px;" >黄金时代:事件驱动</span> Node 与 Nginx 均是基于事件驱动的方式实现的,采用单线程避免了不必要的内存开销和上下文切换开销。 基于事件的服务模型存在的问题主要由两个:CPU 的利用率和进程的健壮性,对于 node 来说,所有请求的上下文都是统一的,它的稳定性是亟待解决的问题。 由于所有处理都在单线程上进行,CPU 的计算能力的上限决定了这类服务模型的性能上线,但它不受多进程或多线程模型中资源上限的影响,可伸缩性远比前两者高。 ## 创建子进程 child_process 模块给予 Node 可以随意创建子进程的能力,它提供 4 个方法用于创建子进程: - spawn():启动一个子进程来执行命令 - exec():启动一个子进程来执行命令,与 spawn() 不同的是其接口不同,它有一个回调函数获知子进程的状况 - execFile():启动一个子进程来执行可执行文件 - fork():与 spawn() 类似,不同点在于它创建 Node 的子进程只需指定要执行的 JavaScript 文件模块即可 spawn() 与 exec()、execFile() 不同的是,后两者创建时可以指定 timeout 属性设置超时时间,一旦创建的进程运行超过设定的时间将会被杀死。 exec() 与 execFile() 不同的是,exec() 适合执行已有的命令,execFile() 适合执行文件。 ```js var cp = require('child_process') cp.spawn('node', ['worker.js']) cp.exec('node worker.js', function (err, stdout, stderr) { // some code }) cp.execFile('worker.js', function (err, stdout, stderr) { // some code }) cp.fork('./worker.js') ``` | 类型 | 回调/异常 | 进程类型 | 执行类型 | 可设置超时 | | :---: | :---: | :---:| :---: | :---: | | spawn() | × | 任意 | 命令 | × | | exec() | √| 任意 | 命令 | √| | execFile() | √| 任意 | 可执行文件| √| | fork() | × | Node| JavaScript 文件| × | 这里的可执行文件是指可以直接执行的文件,如果是 JavaScript 文件通过 execFile() 运行,它的首行必须添加如下代码: ```shell #!/usr/bin/env node ``` ## 进程间通信 在前端浏览器中,JavaScript 主线程与 UI 渲染线程是互相阻塞的,长时间执行 JavaScript 会造成 UI 停顿不响应,为了解决这个问题,HTML5 提出了 WebWorker API。WebWorker 允许创建工作线程并在后台运行,使得一些阻塞较为严重的计算不影响主线程上的 UI 渲染,它的简单用法如下: ```js var worker = new Worker('worker.js') worker.onmessage = function (event) { document.getElementById('result').textContent = event.data } // worker.js var n = 1 search: while (true) { n += 1 for (var i = 2; i <= Math.sqrt(n); i += 1) if (n % i === 0) continue search // found a prime postMessage(n) } ``` 具体的使用可以阅读:[Web Worker 使用教程](http://www.ruanyifeng.com/blog/2018/07/web-worker.html) Node 中对应示例如下: ```js // parent.js var cp = require('child_process') var n = cp.fork(__dirname + '/sub.js') n.on('message', function (m) { console.log('PARENT got message: ', m) }) n.send({ hello: 'world' }) // sub.js process.on('message', function (m) { console.log('CHILD got message: ', m) }) process.send({ foo: 'bar' }) ``` 通过 fork() 或其他 API,创建子进程之后,为了实现父子进程之间的通信,父进程与子进程之间将会创建 IPC 通道,通过 IPC 通道,父子进程之间才能通过 message 和 send() 传递消息。 IPC 全称是 Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作,实现进程间通信的技术有很多,如命名管道、匿名管道、socket、共享内存、消息队列、Domain Socket、信号量等。Node 实现 IPC 通道的是管道(pipe)技术,但此管道非彼管道,在 Node 中管道是一个抽象层面的称呼,具体细节实现由 libuv 提供。 >TODO:child_process 模块实现单机集群 ## Cluster 模块 通过 child_process 实现单机集群要注意比较多的细节问题,因此 node 提供了 cluster 模块来解决多核 CPU 的利用率问题 ```js const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; if (cluster.isMaster) { console.log(`主进程 ${process.pid} 正在运行`); // 衍生工作进程。 for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(`工作进程 ${worker.process.pid} 已退出`); }); } else { // 工作进程可以共享任何 TCP 连接。 // 在本例子中,共享的是 HTTP 服务器。 http.createServer((req, res) => { res.writeHead(200); res.end('你好世界\n'); }).listen(8000); console.log(`工作进程 ${process.pid} 已启动`); } ``` 运行代码,则工作进程会共享 8000 端口: ```txt $ node server.js 主进程 3596 正在运行 工作进程 4324 已启动 工作进程 4520 已启动 工作进程 6056 已启动 工作进程 5644 已启动 ```