express的基本使用
express是专门处理请求和响应的第三方框架
express会导出一个函数,调用该函数即可创建出一个app应用
app应用本质上也是一个函数,它可以作为创建服务器时所要使用的回调函数
const express = require("express");
const http = require("http");
const app = express();
const server = http.createServer(app);
server.listen(9527, ()=>{
console.log("监听端口成功!");
});
但是app支持更为方便的模式,下面的方式等同于上面的方式
app.listen(9527, ()=>{
console.log("监听端口成功!");
});
app应用能够接收任何请求,但它可以根据请求的请求方法和请求路径让其被特定的处理函数进行处理
// 当请求方法为get,请求的路径为"/home"时,app会交给该回调函数进行处理
app.get("/home", (req, res)=>{
console.log(req.headers); // 获取请求头
console.log(req.method); // 获取请求方法
console.log(req.path); // 获取请求路径
console.log(req.query); // 获取请求参数
res.setHeader("a", 1); // 设置响应头,该方法返回的是undefined
res.send("Hello"); // 发送响应,参数为响应体,该方法内部会自动调用end(只有调用了end,响应才会真正地被发送出去)
});
// 匹配动态的路径
// 当请求方法为get,请求的路径为"/news/?"时,app会交给该回调函数进行处理
app.get("/news/:id", (req, res)=>{
console.log(req.params); // 获取路径中的动态部分
// 这里的req和res并不是http.createServer中原生的request和response,它是对这两个原生对象进行封装之后的结果
// status方法用于设置响应码,该方法返回this
// header方法也可以设置响应头,该方法返回this
res.status(301).header("location", "https://www.baidu.com").end();
// 下面实际上是上面的简写
res.redirect(301, "https://www.baidu.com");
});
// 当请求方法为post,请求的路径为"/home"时,app会交给该回调函数进行处理
app.post("/home", (req, res)=>{
// 请求体需要使用流的方式来获取
});
// 只要请求方法为get,就会交付给该回调函数进行处理
app.get("*", (req, res)=>{});
// 请求方法任意,但当请求路径为"/home"时,会交给该回调函数进行处理
app.all("/home", (req, res)=>{});
req.path本身是只读的,并且其值是映射req.url的结果,直接对req.path重新赋值是无效的,但可以通过修改req.url来间接修改req.path的内容
app.use((req, res)=>{ console.log(req.url === req.path); // true req.url = "/123"; console.log(req.url, req.path); // "/123" "/123" req.path = "/abc"; console.log(req.url, req.path); // "/123" "/123" });
若没有合适的请求处理函数与请求进行匹配,则app应用会自动响应404
app应用能够根据send的响应体的数据类型自动设置响应头中的Content-Type字段,例如send的是字符串,则Content-Type为"text/html";send的是对象,则Content-Type为"application/json"
nodemon
nodemon是一个监视器,用于监控工程中代码的变化,如果发现代码发生变化,就会重新启动工程
nodemon命令的使用方式和node命令基本相同,因此可以使用下面的方式来启动工程:
npx nodemon index.js
或者利用向nodemon命令中加入-x参数来通过nodemon运行其他脚本命令
{
"script": {
"server": "node --inspect index.js"
}
}
npx nodemon -x npm run server
默认情况下,nodemon会监视工程中的所有文件,可以为nodemon添加配置来使其只监视特定的文件或文件夹
在工程根目录下建立nodemon.json文件,在使用nodemon命令时,nodemon就会读取该文件中的内容并将其作为配置选项
// nodemon.json
{
"env": { // 临时设置一些环境变量(仅在node运行时有效)
"NODE_ENV": "development"
},
"watch": ["*.js", "*.json"], // 监视的文件类型
// 忽视的文件或文件夹
"ignore": ["package.json", "nodemon.json", "node_modules", "public"]
}
除了可以在工程根目录下设置nodemon.json文件,还可以在package.json中添加"nodemonConfig"配置来实现相同的效果
express中间件
一个中间件实际上就是一个处理函数
默认情况下,app应用在匹配到请求时,只会将请求交付给一个处理函数进行处理
有了中间件后,就能够让多个处理函数对同一个请求进行处理
需要注意的是,处理同一个请求的中间件,只允许其中的一个中间件对请求做出响应,如果对出现多次响应,将会报错
中间件需要调用next方法,才能让下一个中间件得到调用
app.get("/news", (req, res, next)=>{
console.log("handler1");
next();
}, (req, res, next)=>{
console.log("handler2");
next();
});
app.get("/news", (req, res, next=>{
console.log("handler3");
});
下一个中间件中使用的参数其实是上一个中间件使用的参数
app.get("/news", (req, res, next)=>{
req.a = 1;
next();
});
app.get("/news", (req, res)=>{
console.log(req.a); // 1
});
若针对某个请求的所有中间件都没能做出响应(即没有调用res.end()),并且最后一个中间件还调用了next方法,则express将会自动响应404
app.get("/news", (req, res, next)=>{
console.log("handler1");
next();
}, (req, res, next)=>{
console.log("handler2");
next();
});
app.get("/news", (req, res, next=>{
console.log("handler3");
next();
});
中间件在调用next方法时可以传入一个参数,此时该参数会被视为错误信息并传递给来自于同一个方法下的后续的错误处理中间件,抛出错误的中间件以及错误处理中间件之间的中间件将不会被调用
错误处理中间件需要书写四个参数,其中的第一个参数就是错误信息,剩下的就是中间件的常规参数
app.get("/news", (req, res, next)=>{
console.log("handler1");
next("error msg");
}, (req, res, next)=>{
console.log("handler2");
next();
}, (err, req, res, next)=>{
console.log("handler3", err);
next();
});
// 该get方法的中间件不能作为错误处理中间件,因为这些中间件与抛出错误的中间件不来自同一个get方法
app.get("/news", (req, res, next)=>{
console.log("handler4");
});
如果存在错误处理中间件,则其之后的中间件就会按照正常的模式执行;如果没有错误处理中间件,则express会直接响应一个响应状态为500,响应体为错误信息的消息给客户端
中间件抛出了一个错误且没有被处理时,这并不会导致nodejs的执行停止,而是相当于调用next方法,并将错误对象作为next的参数
app.get("/news", (req, res, next)=>{
console.log("handler1");
throw new Error("msg");
// 相当于next(new Error("msg"))
}, (err, req, res, next=>{
console.log("handler2");
});
中间件函数通常在use中进行定义,use能够处理来自其他方法之中的传递出错误信息的中间件
app.get("/news", (req, res, next)=>{
console.log("handler1");
next();
});
app.use("/news", (err, req, res, next)=>{
console.log("handler2", err);
});
use匹配的是基路径,并且use不区分请求方法
// 能够匹配"/news"或"/news/..."
app.use("/news", (req, res)=>{
console.log(req.baseUrl); // 获取请求的基路径
console.log(req.path); // 获取除去基路径外的剩余路径
});
use方法如果不给定匹配的基路径,则会匹配所有的请求
app.use((req, res)=>{});
注意,在前面没有中间件传递过来错误信息的情况下,错误处理中间件将不会被调用
常用中间件
-
express.static()
该函数是一个高阶函数,它会返回一个中间件函数,该中间件用于响应静态资源(html文件,css文件等)
使用该函数,需要传入静态资源所在的目录(绝对路径)
当请求到来时,该中间件会根据
req.path,从该路径中寻找是否存在相应的静态资源文件(或目录),如果存在,则将其内容响应出去,并且不再移交给后续的中间件;否则,交给后续的中间件进行处理如果映射的结果是一个目录,则默认会响应目录下的index.html的内容,可以通过第二个参数进行配置
const express = require("express"); const path = require("path"); const app = express(); const publicPath = path.resolve(__dirname, "public"); app.use(express.static(publicPath, { index: "index.html" // 设置映射结果为目录时读取的静态文件 })); -
express.json()、express.urlencoded()
这两个中间件用于解析请求体
正常的请求体需要通过流的方式获取,而这两个函数在内部会自动使用流的方式将请求体全部获取到,并将请求体内容解析为普通的对象,然后赋值给req的body属性,最后再转交给下一个中间件进行处理
urlencoded()用于处理Content-Type为"application/x-www-form-urlencoded"的请求体,json()用于处理Content-Type为"application/json"的请求体
app.use(express.urlencoded({ extended: true // 使用第三方库qs辅助解析请求体 }); app.use(express.json()) app.post("/news", (req, res)=>{ console.log(req.body); // Object { ... } });urlencoded的大致实现原理:
const qs = require("qs"); express.urlencoded = ()=>{ return (req, res, next)=>{ if(req.headers["Content-Type"] === "application/x-www-form-urlencoded"){ let result = ""; req.on("data", (chunk)=>{ result += chunk.toString("utf-8"); }); req.on("end", ()=>{ const body = qs.parse(result); // 转换为普通对象 req.body = body; next(); }); }else{ next(); } } }
express路由
使用express.Router方法可以创建出一个路由,路由本质上也是一个中间件
让路由中间件与use方法配合,可以减少代码冗余
当use使用的路由匹配到满足基地址要求的请求后,路由就可以根据请求的除基地址外的剩余地址,让其跟自己内部的中间件进行匹配,并让这些中间件进行处理
若路由内部没有可以处理的中间件,则直接响应404
const router = express.Router();
// 匹配"/api/student",请求方法为get
router.get("/", (req, res, next)=>{
console.log("分页获取学生");
});
// 匹配"/api/student/:id",请求方法为get
router.get("/:id", (req, res, next)=>{
console.log("获取单个学生");
});
// 匹配"/api/student",请求方法为post
router.post("/", (req, res, next)=>{
console.log("添加新学生");
});
// 如果请求的基路径为"/api/student",则会将其交给路由进行处理
app.use("/api/student", router);
cookie的基本概念
http协议是一种无状态协议,无状态是指每次的http请求响应都是独立的,即使同一个客户端连续向同一个服务器发送了多次请求,服务器也无法根据这些请求判断出请求是否来自于同一个客户端
cookie的组成
-
key:键
-
value:值
-
domain:域
表示cookie是属于哪个网站的
-
path:路径
表示cookie是属于网站的哪个基路径的
-
secure:是否安全传输
-
expire:过期时间
浏览器在发送请求时,若发现cookie同时满足以下的所有条件,则就会自动地该cookie捎带到请求的请求头中:
-
cookie没有过期
-
cookie的域与本次请求的域是否匹配
例如:cookie中的域为zhangsan.tech,则可以匹配的请求的域有zhangsan.tech、xxx.zhangsan.tech、xxx.xxx.zhangsan.tech等
注意:cookie在匹配域时,会忽略端口号
-
cookie的路径与本次请求的路径是否匹配
例如:cookie中的路径为/news,则可以匹配的请求的路径有/news、/news/xxx、/news/xxx/xxx等
如果cookie的路径为
/,则可以匹配任何请求的路径 -
判断cookie是否为安全传输
如果cookie的secure属性为true,则只有使用https的请求才会将cookie附带过去
如果cookie的secure属性为false,则请求所使用的协议可以是http,也可以是https
当浏览器向服务器发送请求时,会自动将所有满足条件的cookie加入到本次请求的请求头中,具体为:在请求设置一个Cookie请求头,并采用键=值; 键=值; 键=值的格式将cookie附带过去
Cookie: key1=value1; key2=value2; key3=value3
注意:客户端分为很多种,可以是浏览器,也可以是其他应用,但并不是所有客户端都会在发送请求时自动携带cookie
如何设置cookie
cookie的设置方式有以下两种:
-
服务器响应
当服务器决定给客户端发送一个用户凭证时,就会在响应消息中包含一个set-cookie响应头,之后客户端在收到响应后就可以将该响应头中的cookie保存下来
-
客户端自行设置
客户端可以直接通过JS代码将一些内容作为cookie保存下来
注意:cookie只会保存在客户端中
服务器端响应cookie
服务器在响应消息中加入set-cookie响应头来向客户端发送cookie
一个cookie对应一个set-cookie响应头
set-cookie: ...
set-cookie: ...
set-cookie: ...
...
每个set-cookie的内容格式如下:
键=值; path=?; domain=?; expire=?; max-age=?; secure; httponly
其中键=值是必须存在的,其它属性是可选的,这些属性的具体含义如下:
-
path
如果set-cookie中没有指定该值,则默认使用请求的完整路径
-
domain
如果set-cookie中没有指定该值,则默认使用请求的完整域
注意:如果set-cookie中响应了domain,浏览器还会判断该domain是否是有效的域,如果响应内容中的domain与请求的域不匹配(即请求域不等于响应的domain且不以响应的domain结尾),则认为是无效域,此时浏览器保存的就还是默认域
例如:请求a.com,但服务器响应的domain为b.com,则这就是一个无效的域
-
expire
cookie的绝对过期时间,它必须是一个有效的GMT时间(格林威治标准时间字符串),例如:
Fri, 17 Apr 2020 09:35:59 GMT当浏览器对应的utc时间到达了该时间时,会自动销毁对应的cookie
-
max-age
cookie的相对过期时间,它是一个整数数值,表示cookie在多少秒之后过期
一个set-cookie中只需要expire和max-age的其中之一即可,如果两个都没有设置,则在会话结束时cookie就会被销毁
对于大部分浏览器,关闭浏览器就意味着会话结束
max-age最终还是要被转换为expire,例如:当max-age为1000时,浏览器会将其换算为当前GMT时间加上1000秒后的GMT时间,并将该时间作为expire属性应用到cookie中
-
secure
cookie是否为安全连接
-
httponly
设置cookie是否仅能用于传输
如果添加了该属性,则该cookie就只能在传输时使用,而不能在客户端通过代码对cookie进行获取
当浏览器在保存新的cookie时,如果发现已经存在了一个一模一样的旧cookie(需要key、domain、path都完全相同),则会自动将新cookie覆盖掉旧cookie
浏览器通过
post请求服务器http://zhangsan.tech/login,并在消息体中给予了账号和密码,服务器验证登录成功后,在响应头中加入了以下内容:set-cookie: token=123456; path=/; max-age=3600; httponly当响应到达浏览器后,浏览器会创建下面的cookie:
key: token value: 123456 domain: zhangsan.tech path: / expire: 2020-04-17 18:55:00 #假设当前时间是2020-04-17 17:55:00 secure: false #任何请求都可以附带这个cookie,只要满足其他要求 httponly: true #不允许JS获取该cookie于是,随着浏览器后续对服务器的请求,只要满足要求,这个cookie就会被附带到请求头中传给服务器:
cookie: token=123456; 其他cookie...当服务器需要删除浏览器中保存的某个cookie时,就可以通过响应一个同样domain、同样path、同样key,但max-age为-1的cookie,让其覆盖掉要删除的cookie即可
set-cookie: token=; domain=zhangsan.tech; path=/; max-age=-1浏览器按照要求修改了cookie后,会发现cookie已经过期,于是自然就会删除了
客户端自行设置cookie
以浏览器作为客户端为例,利用document对象的cookie属性可以直接在浏览器中访问cookie
获取:
console.log(document.cookie); // "token=123456"
设置:
document.cookie = "键=值; path=?; domain=?; expire=?; max-age=?; secure";
在浏览器中设置cookie,和服务器响应的cookie的格式一致,但浏览器自行设置的cookie与服务器响应的cookie还是会存在以下区别:
-
没有httponly
httponly是服务器为了限制在客户端通过代码进行cookie访问的
-
path的默认值
浏览器主动设置的cookie并不会产生请求与响应,因此(在没有指定path的情况下)path的默认值也就不可能使用根据请求设置
在这种情况下,cookie的path会设置为当前网页的url的完整path
-
domain的默认值
和path同理,cookie的domain会设置为当前网页的url的完整domain
cookie-parser
cookie-parser是一个第三方库,利用它可以让对cookie的操作更加简单
cookie-parser导出了一个函数,调用该函数可以得到一个中间件
该中间件会往req对象中注入一个cookies属性,用于获取请求对象中的所有cookie
const cookieParser = require("cookie-parser");
app.use(cookieParser);
app.use((req, res)=>{
console.log(req.cookies);
// res对象自带cookie方法,利用该方法可以很方便地设置cookie
res.cookie("token", "123456", { // 添加一个cookie响应头
path: "/",
domain: "localhost",
maxAge: 1000 * 1000, // 这里设置的是毫秒
httpOnly: true
});
res.end();
});
跨域
同源策略
同源是指页面的url与请求的url的协议、主机名、端口号都必须完全相同(需要看上去相同)
不同源也称之为跨域,浏览器不允许在页面中请求非同源的数据
需要注意的是,同源策略不是指浏览器不发送请求给服务器,实际上请求还是发出去了,并且也会到达服务器,服务器也会对该请求做出响应,只是浏览器在收到响应后,会判断该响应是否是允许跨域访问的,如果不是,浏览器就不把响应结果交付给JS代码,因此JS才获取不到响应数据
浏览器对img、link、script元素等发出的请求限制比较宽松,一般允许跨域,但对使用AJAX发送的请求比较严格,一般不允许跨域
处理同源策略的方法有两种:
- JSONP
- CORS
本节所述的域是“协议 + 主机 + 端口号”,而cookie中匹配的域只有“主机”
JSONP
步骤:
- 浏览器端事先准备好一个函数,该函数需要有一个参数,参数对应的实参将会是服务器响应的数据
- 浏览器端生成一个script元素,并设置该script元素的src为要访问数据的接口地址
- 服务器端响应一段JS代码,在该代码中需要调用浏览器端事先准备好那个函数,调用该函数时需要将接口数据作为实参传递过去
JSONP存在两个严重缺陷:
-
只能处理GET请求
因为浏览器请求script元素的内容时使用的就是GET请求
-
严重影响服务器响应数据的格式
JSONP要求服务器响应一段JS代码,这在非跨域情况下又是不希望出现的
CORS
CORS,Cross-Origin Resource Sharing(跨域资源共享)是基于http1.1的一种跨域解决方案
它的总体思路是:如果浏览器要跨域访问服务器的资源,需要获得服务器的允许
一个请求可以附带很多信息,从而会对服务器造成不同程度的影响,特别是那些会改动服务器数据的请求,例如:post请求可能会造成服务器在数据库中增加一条记录,delete请求可能会让服务器从数据库中删除一些记录,因此针对不同的请求,CORS规定出了三种不同的交互模式:
- 简单请求
- 需要预检的请求
- 附带身份凭证的请求
这三种模式从上到下层层递进,请求可以做的事情也越来越多,但要求也越来越严格
当浏览器运行了一段ajax代码(XHR或Fetch API),浏览器首先会判断该ajax发出的请求属于哪一种模式
简单请求
当请求同时满足以下条件时,浏览器会认为它是一个简单请求:
-
请求方法属于下面的一种
GET、POST、HEAD
-
请求头中只包含安全的字段
常见的安全的字段有:Accept、Accept-Language、Content-Type、DPR、Downlink、Save-Data、Viewport-Width、Width
-
请求头中如果包含Content-Type,仅限下面的值之一
text/plain、multipart/form-data、application/x-www-form-urlencoded
若一个请求,同时满足了以上所有条件,则它就是一个简单请求
当浏览器判断出一个请求是简单请求时,就会在该请求的请求头中添加Origin字段,字段的值为发出该请求的页面的源(协议 + 主机 + 端口),添加成功再将请求发送给服务器
服务器收到请求后,若允许页面跨域访问资源,则需要在响应头中添加Access-Control-Allow-Origin字段,字段的值可以是:
*:允许任何页面对该资源进行访问- Origin请求头的值:仅允许该页面访问此资源
浏览器收到了来自服务器的响应后,发现了Access-Control-Allow-Origin响应头,并判断出其能与页面的源匹配,于是就将响应结果交给JS代码
需要预检的请求
简单请求对服务器的影响不大,因此只需要进行简单的配置即可完成请求与响应
如果请求不是简单请求,则浏览器就会按照下面的流程进行处理:
- 浏览器首先发送一个预检请求给服务器,询问服务器是否允许
- 若服务器允许,则浏览器发送真实请求,服务器随之完成响应
假设在页面
http://my.com/index.html中有以下代码造成了跨域:fetch("http://crossdomain.com/api/user", { method: "POST", headers: { a: 1, b: 2, "content-type": "application/json", }, body: JSON.stringify({ name: "张三", age: 18 }), });浏览器发现它不是一个简单请求,则会按照下面的流程与服务器交互:
发送一个预检请求
预检请求的请求消息如下所示:
OPTIONS /api/user HTTP/1.1 Host: crossdomain.com ... Origin: http://my.com Access-Control-Request-Method: POST Access-Control-Request-Headers: a, b, content-type预检请求没有请求体,并且预检请求具有以下的特征:
请求方法为OPTIONS
没有请求体
请求头中包含:
Origin:发送请求的页面的源
Access-Control-Request-Method:表明后续的真实请求将使用到的请求方法
Access-Control-Request-Headers:表明后续的真实请求会使用到的请求头
若服务器允许
服务器收到预检请求后,可以检查预检请求中包含的信息
服务器如果允许这样的请求,则需要响应下面的消息格式
HTTP/1.1 200 OK Date: Tue, 21 Apr 2020 08:03:35 GMT ... Access-Control-Allow-Origin: http://my.com Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: a, b, content-type Access-Control-Max-Age: 86400 ...对于预检请求,不需要为其的响应消息中加入响应体,而只需要添加如下的响应头:
Access-Control-Allow-Origin:和简单请求一样,表示允许的源
Access-Control-Allow-Methods:表示允许的后续真实的请求方法
Access-Control-Allow-Headers:表示允许改动的请求头
Access-Control-Max-Age:告诉浏览器,多少秒内,对于同样的请求源、方法、头,都不需要再发送预检请求了,
该字段不一定需要有,若没有,则每一次都需要服务器针对预检请求进行响应
若服务器针对预检请求的响应与预检请求中的字段不对应,或者响应的响应头中缺少了上面的除
Access-Control-Max-Age的某个字段,则就表示服务器拒绝页面进行跨域访问
Access-Control-Max-Age响应头的作用是告诉浏览器在之后的86400秒的时间内,如果需要再次发送相同真实请求过来,就可以不需要先发送预检请求了,而可以直接将真实请求发送过来浏览器发送真实请求
预检被服务器允许后,浏览器就会发送真实请求,真实请求的交互过程与简单请求一致(浏览器还是会往请求中加入Origin字段,并且服务器还是需要再响应消息中加入Access-Control-Allow-Origin字段且值需要与请求的Origin匹配,否则还是可能会跨域)
POST /api/user HTTP/1.1 Host: crossdomain.com Connection: keep-alive ... Referer: http://my.com/index.html Origin: http://my.com {"name": "张三", "age": 18 }服务器响应真实请求
HTTP/1.1 200 OK Date: Tue, 21 Apr 2020 08:03:35 GMT ... Access-Control-Allow-Origin: http://my.com ... 添加用户成功
附带身份凭证的请求
身份凭证即cookie
默认情况下,通过ajax发出的请求不会附带cookie,这样一来,需要使用到cookie的操作就无法进行
但可以通过JS代码进行设置:
// 针对XHR
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
// 针对Fetch API
fetch(url, {
credentials: "include"
});
当ajax发出的请求中存在cookie时(存在cookie请求头),则浏览器对这种请求的要求就更为严格了
对于这种附带了cookie的请求,还需要让服务器在响应消息中加入Access-Control-Allow-Credentials字段,且值为true,否则浏览器还是会认为该请求是在跨域访问数据
注意:对于附带了cookie的请求,不得在对应的响应消息中设置Access-Control-Allow-Origin响应头的值为*,只能设置为具体的源
补充
对于跨域成功的请求,在JS代码中只能获取到响应中最基本的一些响应头,例如:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,而其他的响应头JS代码是获取不到的
如果需要访问其他的响应头,则需要在请求的时候添加一个Access-Control-Expose-Headers请求头,例如:
Access-Control-Expose-Headers: authorization, a, b
之后JS代码就能够从响应消息中获取到这些指定的响应头了
session
和cookie一样,session也可以用于实现客户端的身份认证操作
cookie存储在客户端,因此不需要占用服务器的资源,但cookie只能存储字符串,且存储的数据量十分有限;此外,由于浏览器提供了操作cookie的接口,因此cookie也有容易获取的篡改的缺陷
session存储在服务器端,它能存储任何格式的数据,并且存储容量理论上是无限的(取决于服务器的存储容量),由于其存储在服务器端,因此session数据难以被截获也更难以被篡改,不过也正因为存储在服务器端,因此会占用服务器的资源
基本原理
服务器会在内部维护一个映射表,表中的每行就是一个session与其对应的数据
映射表可以存储在服务器的内存中,也可以作为存储在数据库中,后者通常出现于大型项目之中
当客户端发送请求给服务器时,服务器会为该客户端生成一个sessionID,并在表中进行记录
每个sessionID都是全球唯一的,通常为uuid(universal unique Identity)
之后服务器会在响应消息中通过cookie的形式将为客户端生成出来的sessionID附带过去
客户端收到带有sessionID的cookie后,将其保存下来,于是在后续的请求中就可以通过附带cookie将sessionID一并附带过去
若客户端在请求时没有在请求中附带sessionID,则服务器就会为其生成一个sessionID并响应过去
以在浏览器中进行登录为例,假设浏览器发送的登录请求中附带了sessionID,当服务器收到登录请求时,如果判断登录成功,服务器就可以为该sessionID对应的行中记录一些内容(value),例如将用户身份信息记录下来,之后如果浏览器又发来了其它请求,并且请求中也附带了该sessionID,则服务器通过查看对应的value中的用户身份信息就可以得知这个用户是登录成功的,于是就可以把数据响应过去
若表格中的某个session对应的客户端长期没有发来请求,则该session就会被服务器删除
注意:session并不与客户端存在一一对应的关系,由于其是通过cookie进行传递的,因此更准确的描述应该是:一个session对应着一个客户端中的一个源(协议 + 主机 + 端口)
express-session
express-session是一个第三方库,利用它可以很方便地实现对session的操作
express-session会导出一个函数,调用该函数可以得到一个中间件,调用函数时可以传入一些配置
const session = require("express-session");
app.use(session({
secret: "zhangsan", // 加密sessionId的秘钥
name: "sessionId" // 设置session对应的cookie的键名称
}));
该中间件会自动维护一个表格,记录所有的sessionID与它们的value
当请求到来时,如果请求没有携带sessionID(或没有携带正确的sessionID),则该中间件就会往响应头中设置一个session对应的cookie,于是之后的响应就能够将session响应过去
如果到来的请求中携带了正确的sessionID,则该中间件就会往req对象中注入一个session属性,该属性的值是根据sessionID找到表格中对应行,取出这行的value值作为其值
express-session默认会将session映射表保存在内存中,每次重启服务器时都会导致表格被重新初始化(被清空)
JWT
基本概念
在实际应用中,服务器服务的客户端种类可并不只有浏览器,还可能是移动端应用,而移动端应用通常是不支持cookie的,这就在移动端应用中实现身份认证操作时遇到了麻烦,JWT的出现就是为了解决这种问题的
JWT(Json Web Token)提供了一个所有客户端都需要遵循的统一的规范,它规定了一种统一的、安全的令牌格式
注意:JWT仅仅是一种令牌格式,你可以将它存储到cookie当中,或者是localstorage当中,也可以将其作为一个请求或响应的消息头进行传递
HTTP/1.1 200 OK
...
set-cookie: token=jwt令牌
authorization: jwt令牌
...
{ token: jwt令牌 }
利用JWT令牌,就可以实现对客户端的身份验证:当客户端收到服务器响应的JWT令牌后,就需要将其存储起来,并需要在之后的请求中将该JWT附带到请求之中(附带方式任意),服务器收到请求后,就能够根据请求当中的JWT验证客户端的身份是否有效,于是就完成了身份认证的工作
令牌的组成
JWT令牌主要由三个部分组成,分别是:
- header:令牌头部,记录了整个令牌的类型和签名算法
- payload:令牌负荷,记录了令牌的主体信息
- signature:令牌签名,按照头部固定的签名算法对整个令牌进行签名,以保证令牌不会被伪造和篡改
三个部分通过.进行拼接,就可以得到一个完整的JWT令牌:
header.payload.signature
例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9.BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc
header
header的真实面目是一个JSON对象,例如:
{
"alg": "HS256",
"typ": "JWT"
}
-
alg:指定signature部分使用的签名算法,通常为下面那两个值
HS256:一种对称加密算法,使用同一个秘钥对signature加密和解密
RS256:一种非对称加密算法,使用私钥加密,公钥解密
-
typ:整个令牌的类型,固定为"JWT"即可
在具体的令牌中,需要将header的JSON对象进行base64 url编码,将编码过后的内容作为JWT的组成部分
在浏览器中,可以使用window.btoa函数完成对某个JS对象的base64 url编码
const res = window.btoa({ a: 1, b: 2 }); console.log(res); // "W29iamVjdCBPYmplY3Rd"使用window.atob函数完成对某个base64 url字符串的解码
const res = window.atob("W29iamVjdCBPYmplY3Rd"); console.log(res); // { a: 1, b: 2 }
payload
payload的真实面目也是一个JSON对象,例如:
{
"ss": "发行者",
"iat": "发布时间",
"exp": "到期时间",
"sub": "主题",
"aud": "听众",
"nbf": "在此之前不可用",
"jti": "JWT ID"
}
上面的属性全部都是可选的,可以一个都不写,并且token允许在这部分自定义属性
和header一样,JWT令牌中的这一部分,也是对payload的JSON对象进行base64 url编码后的结果
signature
这一部分是JWT的签名,正因为该部分的存在,才保证了JWT令牌不会被伪造和篡改
该部分的内容,是对header.payload使用header中指定的加密算法(例如"HS256")进行加密后的结果
将三个部分拼接起来,就得到了一个完整的JWT令牌:
JWT令牌 = "header.payload" + "." + HS256("header.payload", key);
由于加密的秘钥保存在服务器端,因此客户端就无法对JWT令牌进行伪造,因为没有秘钥就无法得到正确的signature,也就无法得到能够在服务器端验证通过的JWT令牌
服务器验证JWT令牌的方式很简单,就是对令牌的
header.payload再进行一次加密,并对照加密结果与令牌中的signature是否相同,相同就表示令牌正确,是没有被伪造和篡改过的
jsonwebtoken
jsonwebtoken是一个第三方库,利用它可以很方便地得到一个JWT令牌,也可以很方便地对一个JWT令牌进行验证与解密
jsonwebtoken会导出一个对象,利用该对象的sign方法可以得到一个JWT令牌,sign方法的第一个是令牌的payload,第二个参数是秘钥,第三个参数是配置对象
利用verify方法可以对令牌进行验证与解码,若令牌验证不通过或令牌已过期时,会抛出异常;否则将会得到解码后的对象,调用verify时需要传入生成令牌时所使用的秘钥
const JWT = require("jsonwebtoken");
const secret = "zhangsan";
const token = JWT.sign({ // 获得令牌
name: "张三",
age: 20
}, secret, {
algorithm: "HS256", // 加密令牌时使用的算法
expiresIn: 3600 * 1000 // 令牌在多少毫秒后过期
});
try{
const obj = JWT.verify(token, secret); // 验证与解码令牌
console.log(obj);
}catch(err){
console.log("token不正确或已过期");
}
文件上传
在上传文件时,通常选择使用form-data格式的请求体进行文件数据的传输,请求方法通常为POST
包含form-data格式的请求体的完整请求如下所示:
POST 上传地址 HTTP/1.1
其他请求头
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="img"; filename="aaa.jpg"
Content-Type: image/jpeg
(文件的二进制数据...)
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="img1"; filename="bbb.gif"
Content-Type: image/gif
(文件的完整二进制数据...)
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="img1"
123123
----WebKitFormBoundary7MA4YWxkTrZu0gW
其中,----WebKitFormBoundary7MA4YWxkTrZu0gW是用于分隔两个相邻form-data键值对的分隔符
每个form-data键值对中的Content-Disposition之后的name的值,就是该键值对的键的名称,而键所对应的值会出现在该键值对的最后一行
注意:在同一个form-data格式的请求体中允许出现多个相同的键名称,如上,出现了两个键叫做"img"
form-data格式的请求体处理起来比较复杂,因此通常使用第三方库multer对form-data格式的请求体进行读取
导入multer库会得到一个multer函数,调用multer函数能够得到一个upload对象,利用upload对象中的一些方法,即可完成对form-data请求体的解析
upload对象的single方法可以对form-data中请求体的所有普通键值对和一个文件的键值对进行解析,该方法会返回一个中间件,调用该方法时需要传入一个字符串参数,表示对哪个文件键值对进行解析
请求体全部解析完成后,signle方法会往req对象中注入file属性和body属性,body属性中记录着解析的所有普通键值对的信息,file属性中记录着解析的文件键值对的信息
const multer = require("multer");
const upload = multer();
uploadRouter.post("/", upload.single("img"), (req, res, next)=>{
console.log(req.body);
console.log(req.file);
});
upload对象的array方法可以对form-data中请求体的所有普通键值对和多个同名的文件键值对进行解析,该方法会返回一个中间件,调用该方法时需要传入一个字符串参数和一个数字参数,字符串参数表示对哪些同名的文件键值对进行解析,数字参数表示最多处理多少个文件键值对
请求体全部解析完成后,array方法会往req对象中注入files属性和body属性,body属性中记录着解析的所有普通键值对的信息,files属性中记录着解析的多个同名文件键值对的信息
uploadRouter.post("/", upload.array("img1", 2), (req, res, next)=>{
console.log(req.body);
console.log(req.files);
});
upload对象的fields方法可以对form-data中请求体的所有普通键值对和多个文件键值对进行解析,该方法会返回一个中间件,调用该方法时需要传入一个数组,数组元素是一个个的要解析的文件键值对的配置对象,对象的name属性表示要对哪个文件键值对进行解析,maxCount属性表示最多要处理多少个这样的文件键值对
请求体全部解析完成后,fields方法会往req对象中注入files属性和body属性,body属性中记录着解析的所有普通键值对的信息,files属性中记录着解析的多个文件键值对的信息
// 解析最多1个的键名称为"img"的文件键值对 以及 最多2个的键名称为"img1"的文件键值对
uploadRouter.post("/", upload.fields([{name: "img", maxCount: 1}, {name: "img1", maxCount: 2}]), (req, res, next)=>{
console.log(req.body);
console.log(req.files);
});
如果不想使用upload处理文件键值对,只让它处理普通的键值对,则可以使用none方法
none方法会往req对象中注入body属性,body属性中就记录着解析的所有普通键值对的信息
// 解析
uploadRouter.post("/", upload.fields([{name: "img", maxCount: 1}, {name: "img1", maxCount: 2}]), (req, res, next)=>{
console.log(req.body);
});
上面的所有方法中,如果对文件的键值对进行了解析,则multer会在指定的目录下将文件数据保存下来
注意:不要将upload所返回的中间件作为全局中间件使用,应该让其只出现在相关的路由中,因为可能存在恶意用户上传一个特殊文件给服务器导致服务器做出一些不可预料的行为
在调用multer函数创建upload对象时,可以为multer传递一个配置对象,常见的配置有:
const upload = multer({
dest: "", // 上传的文件所存放的位置
fileFilter(req, file, callback) { // 控制哪些文件能够被接受,该函数会在文件被保存之前调用
const extname = path.extname(file.originalname);
const whitelist = [".jpeg", ".gif", ".png"];
if (whitelist.includes(extname)) {
// 第一个参数为null,第二个参数为true表示验证通过
callback(null, true);
} else {
// 验证不通过,第一个参数插入错误对象
callback(new Error(`your extname of file is not support`));
}
},
limits: { // 限制规则集合
fileSize: 1024 * 1024 // 限制上传的文件的大小最多为多少字节
}
});
由于multer能够处理和保存任何后缀名的文件,因此为了安全,multer保存在服务器的文件是不会包含后缀名的
这可以通过磁盘存储引擎DiskStorage来处理,磁盘存储引擎能够控制文件的具体存储形式,其具体配置方式如下:
const path = require("path");
const storage = multer.diskStorage({
// destination用于控制文件存储的位置
destination(req, file, callback) {
// req就是中间件中的req,file是当前所处理的文件的相关信息
// 如果不存在该目录,则需要手动创建对应的目录
const dirPath = path.resolve(__dirname, "./public/upload");
// callback的第一个参数为错误对象,如果有错误的话就需要传入
callback(null, dirPath);
},
// filename用于控制保存的文件的文件名称
filename(req, file, callback) {
const timestamp = Date.now();
const randomStr = Math.random().toString().slice(-6);
const ext = path.extname(file.originalname); // 所上传的文件的完整名称
callback(null, `${timestamp}-${randomStr}${ext}`);
}
});
const upload = multer({ storage });
在以上的所有配置中如果调用callback函数时传入了有效的第一个参数(即传入的不是null或undefined),则multer会在中间件中抛出一个异常,抛出的异常继承自multer.MulterError,因此可以对multer抛出的错误进行如下处理
// errorMiddleware.js
module.exports = (err, req, res, next)=>{
if(err instanceof multer.MulterError){
res.status(403).send(err.messae);
}
}
文件下载
文件下载的请求通常选用get方法,并将请求下载的文件的文件名称作为url的path的一部分
中间件的res对象中自带一个download方法,利用该方法可以轻松实现文件下载功能
download方法调用时需要传入文件所在的绝对路径,第二个参数是客户端下载文件时文件的默认名称
downloadRouter.get("/:filename", (req, res, next)=>{
const filepath = path.resolve(__dirname, "./download", req.params.filename);
res.download(filepath, "default");
});
在调用download方法时,方法内部会往响应消息中增加一个Content-Disposition响应头,其值中包含一个重要的内容attachment,正因为该响应头以及响应头中的attachment内容的存在,才导致了浏览器在接收到这种响应后自动触发下载行为
attachment之后还会存在一个filename="xxx",xxx就是客户端下载文件时文件的默认名称
断点续传
断点续传本质上就是在客户端针对同一个文件向服务器发送多次请求的过程
这些针对同一文件的不同请求,其实请求的是该文件中的某一个部分,不断发送请求直至将文件的所有内容都得到后,客户端就会对数据进行组装,最终形成一个完整的文件
断点续传需要得到服务器的支持,如果服务器支持断点续传,则应该在文件的响应消息中增加响应头Accept-Ranges: bytes,bytes表示支持以字节为单位的断点续传,如果响应消息中没有该响应头或者响应体的值为none,都表示服务器不支持断点续传
客户端在发送文件的断点续传请求时,请求头中应该包含如Range: bytes=10000-20000的字段,bytes=10000-20000表示本次请求的内容为文件的第10000至20000字节的数据
res.download方法会自动读取请求头中的range字段,根据其内容将该范围内的文件数据响应过去,即download会自动处理断点续传的请求响应,无需开发者进行额外的工作
通过响应消息的Content-Length响应头可以得到响应体中包含的数据大小(字节)
代理
在实际应用中,通常不会只使用node搭建完整的服务器,而是选择使用node搭建中间服务器
node服务器处理的请求通常是获取静态资源或者与业务逻辑无关的请求,例如:获取html页面、js脚本、css样式文件等,以及文件下载和上传等,包括日志记录等功能通常也是由node服务器负责完成
node服务器会直接与客户端进行交互,而真实服务器不会,因此node服务器大部分情况下,充当的都是一个代理服务器的角色
当node服务器收到了来自于客户端的与业务逻辑相关的请求时,需要将其转交给处理业务逻辑的真实服务器;当真实服务器处理完请求后,会向node服务器响应一个数据,node服务器收到后又会将其转交给客户端
真正的服务器通常使用Java、C#等语言进行搭建
常见的业务逻辑包括:登录注册、添加或删除一个用户、分页获取数据等
以下是代理的实现代码:
// proxyMiddleware.js
const http = require("http");
// 如果请求的路径以/api开头,则将其转交给真实服务器
const basePath = "/api";
module.epxorts = function(req, res, next){
if(!req.path.startsWith(basePath)){
// 不需要代理
next();
}else{
// 需要代理
const request = http.request({
host: "mysite.com",
port: 9876,
path: req.path,
method: req.method,
headers: req.headers
}, (response)=>{
res.status(response.statusCode);
for (const key in response.headers) {
res.setHeader(key, response.headers[key]);
}
// 把response的响应体写入到res的响应体中
response.pipe(res);
});
// 把req的请求体写入到request的请求体中
req.pipe(request);
}
}
客户端缓存
客户端缓存是指客户端将某一次请求的结果(响应内容)缓存下来,之后再次发送相同的请求时,只需要从缓存中读取请求结果即可
客户端缓存策略能够极大降低服务器压力
来自于服务器的缓存指令
客户端并不会将所有的响应内容都缓存下来,真正会被缓存的响应内容中会出现下面的响应头字段:
Cache-Control: max-age=3600
ETag: W/"121-171ca289ebf"
Date: Thu, 30 Apr 2020 12:39:56 GMT
Last-Modified: Thu, 30 Apr 2020 08:16:31 GMT
-
Cache-Control: max-age=3600表示服务器希望客户端将该资源缓存起来,缓存时间是3600秒
-
ETag: W/"121-171ca289ebf"资源的编号为
W/"121-171ca289ebf"编号通常是根据资源的内容生成出来的,资源一旦发生变化,编号也会发生变化
-
Date: Thu, 30 Apr 2020 12:39:56 GMT服务器响应该资源时的格林威治时间为
2020-04-30 12:39:56客户端发送的时间是可以由用户手动调整的,因此资源的缓存时间应该以服务器中记录的时间为准
-
Last-Modified: Thu, 30 Apr 2020 08:16:31 GMT资源的上一次修改时间是格林威治时间
2020-04-30 08:16:31
缓存的响应内容通常都对应的是get请求
如果接收到该响应的客户端是其他应用程序,即使看到这些响应体,可能也是无动于衷,但如果客户端是浏览器,则看到这些响应头后,就会进行下面的处理:
- 把响应的消息体缓存到本地文件之中
- 记录本次请求的请求方法和请求路径
- 记录本次请求到的资源的缓存时间
- 记录服务器响应的消息中的Date响应头的内容
- 记录服务器响应的消息中的ETag响应头的内容
- 记录服务器响应的消息中的Last-Modified响应头的内容
之后当客户端需要再次获取该资源时,就可以根据这些信息判断是否需要发送请求给服务器
来自客户端的缓存指令
当客户端需要向服务器请求资源时,客户端需要先判断本次请求的结果是否已经在缓存之中
客户端查找缓存的具体过程:首先判断缓存中是否有匹配的请求方法的请求路径,如果没有,则正常发送资源请求给服务器;如果有,还需要判断资源是否有效,如果资源无效,则客户端需要发送一个带缓存的请求(又称缓存确认请求)给服务器;如果资源有效,则不发送任何请求,直接使用缓存结果
客户端判断缓存是否有效的方法为:把max-age和Date相加得到资源的过期时间(GMT时间),如果发送请求时的当前的GMT时间超过了过期时间,则说明资源已失效,否则说明资源还有效
若缓存资源有效,此时客户端会直接使用缓存的内容,而完全不会请求服务,在这种情况下,即使客户端将网络断开,资源依然能够出现在页面之中
若缓存资源无效,此时客户端并不会简单地将缓存内容删除,而是会询问服务器在这种情况下,缓存的内容是否还能够使用,询问的过程就是通过向服务器发送缓存确认请求来实现的
缓存确认请求和普通请求相比,会多出下面两个请求头:
If-Modified-Since: Thu, 30 Apr 2020 08:16:31 GMT
If-None-Match: W/"121-171ca289ebf"
-
If-Modified-Since: Thu, 30 Apr 2020 08:16:31 GMT资源的上一次修改时间是格林威治时间
2020-04-30 08:16:31 -
If-None-Match: W/"121-171ca289ebf"资源的编号是
W/"121-171ca289ebf
服务器收到客户端发来的缓存确认请求后,就会将请求中的这两个请求头与实际资源所对应的相关信息进行比较,若发现不相同,则说明客户端缓存的资源真的过期了
实际应用中,大部分服务器并不会同时使用两个请求头,而是只使用其中一个即可
之所以要发两个信息,是为了兼容不同的服务器,因为有些服务器只认
If-Modified-Since,有些服务器只认If-None-Match,有些服务器两个都认目前的很多服务器,只要发现
If-None-Match存在,就不会去看``If-Modified-Since`
If-Modified-Since是http1.0版本的规范,If-None-Match是http1.1的规范
服务器若发现客户端缓存的资源已失效,则就会向客户端发送一个状态码为200 OK,响应体为最新的资源内容,同时附带着最新的缓存指令的响应消息,客户端收到后就将缓存内容更新即可
服务器若发现客户端缓存的资源还有效,则就会向客户端发送一个状态码为304 Not Modified,包含了最新的缓存指令,且没有响应体的响应消息
由于针对资源有效的响应消息没有响应体,因此服务器传输的内容也小了很多,相比于正常的发送完整的响应体给客户端的响应,服务器压力还是得到了缓解,同时请求响应的效率也得到了提高
express会自动处理请求和响应消息中的缓存指令,无需开发者手动进行处理和设置缓存指令
细节
Cache-Control
cache-control除了可以设置为max-age值为还可以设置为下面几个值(也可以一次性设置多个):
public:指示该服务器资源是公开的private:指示该服务器资源是私有的no-cache:告知客户端将该资源缓存下来,但是不管什么情况,在使用该资源时,需要先向服务器发送一个缓存确认请求,只有服务器响应304消息码的消息后,客户端才能够使用该资源no-store:告知客户端不要对该资源进行缓存,当之后需要再次使用该资源时,都需要向服务器发送普通请求来获取资源
Cache-Control除了可以设置在响应消息之中,也可以设置在请求消息之中
Expire
在http1.0版本中,是通过Expire响应头来指定过期时间(绝对过期时间)的,例如:
Expire: Thu, 30 Apr 2020 23:38:38 GMT
到了http1.1版本,已更改为通过Cache-Control的max-age来记录过期时间(相对过期时间)
缓存过期时间的具体确定过程
客户端会根据服务器的响应消息的不同,按照不同的方式设置缓存过期时间
当响应头中的
max-age为0时,客户端仍然会把资源缓存下来,不过在缓存时资源就已经过期了,后续使用该缓存资源时,就需要发送缓存确认请求给服务器,因此,Cache-Control: max-age=0等价于Cache-Control: no-cache
Pragma
Pragma是http1.0版本的消息头,当该消息头出现在请求中时,表示客户端向服务器指示:我不会对资源进行缓存(或者不会使用缓存的内容),请给我完整的响应结果
在http1.1版本中,可以在请求消息中加入Cache-Control: no-cache实现同样的含义。
Cache-Control可以出现在请求头中,并且Cache-Control: no-cache在请求消息中与在响应消息中的含义还不一样
在
Chrome浏览器中调试控制台时,如果勾选了Disable cache,则发送的请求中会附带这些信息
Vary
某些情况下,是否有缓存,不仅仅是判断请求方法和请求路径是否匹配,可能还要判断头部信息是否匹配,此时,就可以使用Vary字段来指定参与匹配的消息头
比如,当使用GET /personal.html请求服务器时,请求头中cookie的值不一样,得到的页面的具体内容也不一样
如果仅仅只是匹配请求方法和请求路径,此时无论cookie怎样变化,得到的仍然是相同的页面
正确的做法如下:
使用hash
在使用Vue开发单页应用程序时,会在响应给客户端的静态页面中引用许多外部css文件和js文件
服务器在响应这些静态资源,通常不希望让客户端对页面进行缓存,但希望客户端对css文件和js文件进行缓存
这在实际应用中会遇到一些问题,例如:当服务器保存的css文件或js文件发生了更新后,由于更新的文件的名称和路径并没有发生变化,因此页面中引用的外部文件地址也不会发生变化,这就可能导致客户端使用它所缓存的旧的资源内容
处理该问题的方法很简单,就是在文件内容变化时,将文件名称也进行修改
例如:webpack在打包css文件时,会根据文件内容生成一个hash字符串添加到文件名称之中
app.68297cd8.css
当文件内容变化后,文件hash值也会随之发送变化,而页面中在引用该文件资源时,就需要使用新的文件路径
之后,客户端在解析页面代码时,就需要请求新的文件,而不能使用缓存的结果
总结
服务器视角:
服务器无法知道客户端到底有没有像浏览器那样缓存文件,它只管根据请求的情况来决定如何响应
浏览器视角:
浏览器在发出请求时会判断要不要使用缓存
当收到服务器响应时,会自动根据缓存指令进行处理