开发博客项目之登录
1. Cookie
1.1 cookie 介绍
什么是 cookie
- 存储在浏览器的一段字符串(最大 5 kb)
- 跨域不共享
- 格式如 k1=v1; k2=v2; k3=v3; 因此可以存储结构化数据
- 每次发送 http 请求,会将请求域的 cookie 一起发送给 server
- server 可以修改 cookie 并返回给浏览器
- 浏览器中也可以通过 JavaScript 修改 cookie(有限制)
客户端 JavaScript 操作 cookie
-
客户端查看 cookie,三种方式
-
浏览器开发者工具 Network 里可以看到请求头里的 Cookie,和响应头里的 Set-Cookie
-
开发者工具 Application 里可以看到浏览器里存储的 Cookie
-
在控制台中输入
document.cookie
查看。支持 cookie 的累加,server 端可以通过设置 httpOnly 来使得 cookie 不可被修改。// js 追加 cookie document.cookie = "k1=100;"
-
1.2 server 端 nodejs 操作 cookie
- 查看 cookie
- 修改 cookie
- 实现登录验证
基本流程是,输入账号密码成功后,后端设置 cookie 来标记已经登录,同时设置一个过期的时间。后续如果需要进行一些操作,都需要看是否存在 cookie,如果没有 cookie 说明尚未登录,就不能做相关的操作。
// router/user.js
const { login } = require("../controller/user");
const { SuccessModel, ErrorModel } = require("../model/resModel");
const getCookieExpires = () => {
const d = new Date();
// 设置一天后 cookie 过期
d.setTime(d.getTime() + 24 * 60 * 60 * 1000);
return d.toUTCString();
};
const handleUserRouter = (req, res) => {
const method = req.method;
// 登录
if (method === "POST" && req.path === "/api/user/login") {
const { username, password } = req.body;
const result = login(username, password);
return result.then((data) => {
if (data.username) {
// 操作 cookie
res.setHeader(
"Set-Cookie",
`username=${
data.username
}; path=/; httpOnly; expires=${getCookieExpires()}`
);
return new SuccessModel();
}
return new ErrorModel("登录失败");
});
}
// 登录验证的测试
if (method === "GET" && req.path === `/api/user/login-test`) {
if (req.cookie.username) {
return Promise.resolve(new SuccessModel());
}
return Promise.resolve(new ErrorModel("尚未登录"));
}
};
module.exports = {
handleUserRouter,
};
因为涉及到了 cookie 的获取,因此 App.js 需要实现解析 cookie 内容的功能。
const qs = require("qs");
const { handleBlogRouter } = require("./src/router/blog");
const { handleUserRouter } = require("./src/router/user");
const getPostData = (req) => {
return new Promise((resolve, reject) => {
// 非 POST 请求不存在 POST data 的问题
if (req.method !== "POST") {
resolve({});
return;
}
// 如果 POST data 不是 JSON 格式的数据,直接忽略(本项目的 POST data 都是 JSON 格式)
if (req.headers["content-type"] !== "application/json") {
resolve({});
return;
}
let postData = "";
req.on("data", (chunk) => {
postData += chunk.toString();
});
req.on("end", () => {
if (!postData) {
resolve({});
return;
}
resolve(JSON.parse(postData));
});
});
};
const serverHandle = async (req, res) => {
// 设置返回格式 JSON
res.setHeader("Content-type", "application/json");
// 获取 path
const url = req.url;
req.path = url.split("?")[0];
// 解析 query
req.query = qs.parse(url.split("?")[1]);
// 解析 cookie
req.cookie = {};
const cookieStr = req.headers.cookie || "";
cookieStr.split(";").forEach((element) => {
if (!element) {
return;
}
const [key, val] = element.split("=");
req.cookie[key] = val;
});
// 解析 POST data 后放在 req.body 内
const postData = await getPostData(req);
req.body = postData;
// 处理 blog 路由
const blogData = await handleBlogRouter(req, res);
if (blogData) {
res.end(JSON.stringify(blogData));
// 需 return,否则会继续往下执行
return;
}
// 处理 user 路由
const userData = await handleUserRouter(req, res);
if (userData) {
res.end(JSON.stringify(userData));
return;
}
// 未命中路由:纯文本返回 404 信息
res.writeHead(404, { "content-type": "text/plain" });
res.write("404 not found");
res.end();
};
module.exports = {
serverHandle,
};
2. session
- cookie 的问题:会暴露用户信息,很危险
- 如何解决:cookie 中存储 userid,server 端对应 username
- 解决方案:session,即 server 端存储用户信息
session 使用流程
在全局里添加存储 session 数据的变量 SESSION_DATA:
const SESSION_DATA = {};
在每次请求的时候解析 session。这个项目的 user 的 session 为 userid。如果没有 userid,则生成一个 userid ,并在 SESSION_DATA 里创建一个存储 userid 的地址,并将地址赋值给 req.session,便于响应请求时的 SESSION_DATA 修改。
// 解析 session
let needSetCookie = false;
let userId = req.cookie.userid;
if (userId) {
if (!SESSION_DATA[userId]) {
SESSION_DATA[userId] = {};
}
} else {
needSetCookie = true;
// 先用一个伪随机数代替
userId = `${Date.now()}_${Math.random()}`;
SESSION_DATA[userId] = {};
}
req.session = SESSION_DATA[userId];
路由里每次接收请求返回结果前判断是否有 userid,如果没有则添加 key 为 userid 的 cookie。
// 处理 blog 路由
const blogData = await handleBlogRouter(req, res);
if (blogData) {
if (needSetCookie) {
res.setHeader(
"Set-Cookie",
`userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`
);
}
res.end(JSON.stringify(blogData));
// 需 return,否则会继续往下执行
return;
}
// 处理 user 路由
const userData = await handleUserRouter(req, res);
if (userData) {
if (needSetCookie) {
res.setHeader(
"Set-Cookie",
`userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`
);
}
res.end(JSON.stringify(userData));
return;
}
上面的代码都在 app.js 中完成。代码如下:
const qs = require("qs");
const { handleBlogRouter } = require("./src/router/blog");
const { handleUserRouter, getCookieExpires } = require("./src/router/user");
// session 数据
const SESSION_DATA = {};
// 获取 post 数据
const getPostData = (req) => {
return new Promise((resolve, reject) => {
// 非 POST 请求不存在 POST data 的问题
if (req.method !== "POST") {
resolve({});
return;
}
// 如果 POST data 不是 JSON 格式的数据,直接忽略(本项目的 POST data 都是 JSON 格式)
if (req.headers["content-type"] !== "application/json") {
resolve({});
return;
}
let postData = "";
req.on("data", (chunk) => {
postData += chunk.toString();
});
req.on("end", () => {
if (!postData) {
resolve({});
return;
}
resolve(JSON.parse(postData));
});
});
};
const serverHandle = async (req, res) => {
// 设置返回格式 JSON
res.setHeader("Content-type", "application/json");
// 获取 path
const url = req.url;
req.path = url.split("?")[0];
// 解析 query
req.query = qs.parse(url.split("?")[1]);
// 解析 cookie
req.cookie = {};
const cookieStr = req.headers.cookie || "";
cookieStr.split(";").forEach((element) => {
if (!element) {
return;
}
const [key, val] = element.split("=");
req.cookie[key] = val;
});
// 解析 session
let needSetCookie = false;
let userId = req.cookie.userid;
if (userId) {
if (!SESSION_DATA[userId]) {
SESSION_DATA[userId] = {};
}
} else {
needSetCookie = true;
// 先用一个伪随机数代替
userId = `${Date.now()}_${Math.random()}`;
SESSION_DATA[userId] = {};
}
req.session = SESSION_DATA[userId];
// 解析 POST data 后放在 req.body 内
const postData = await getPostData(req);
req.body = postData;
// 处理 blog 路由
const blogData = await handleBlogRouter(req, res);
if (blogData) {
if (needSetCookie) {
res.setHeader(
"Set-Cookie",
`userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`
);
}
res.end(JSON.stringify(blogData));
// 需 return,否则会继续往下执行
return;
}
// 处理 user 路由
const userData = await handleUserRouter(req, res);
if (userData) {
if (needSetCookie) {
res.setHeader(
"Set-Cookie",
`userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`
);
}
res.end(JSON.stringify(userData));
return;
}
// 未命中路由:纯文本返回 404 信息
res.writeHead(404, { "content-type": "text/plain" });
res.write("404 not found");
res.end();
};
module.exports = {
serverHandle,
};
接下来,需要进行登录和登录验证的测试。如果登录成功了,就向 SESSION_DATA[userId]
里放置 username 和 realName。
// router/user.js
// 登录
if (method === "POST" && req.path === "/api/user/login") {
const { username, password } = req.body;
const result = login(username, password);
return result.then((data) => {
if (data.username) {
// 设置 session
req.session.username = data.username;
req.session.realName = data.realname;
return new SuccessModel();
}
return new ErrorModel("登录失败");
});
}
session 放置了 username 后,后面如果需要判断用户是否登录,就只需要获取 session 看里边是否有 username。
// router/user.js
// 登录验证的测试
if (method === "GET" && req.path === `/api/user/login-test`) {
if (req.session.username) {
return Promise.resolve(
new SuccessModel({ username: req.session.realName })
);
}
return Promise.resolve(new ErrorModel("尚未登录"));
}
当前的局限性
- 目前 session 直接是 js 变量,放在 nodejs 进程内存中
- 第一,进程内存有限,访问量过大,内存暴增怎么办?
- 第二,正式线上运行是多进程,进程之间内存无法共享。
3. redis
3.1 从 session 到 redis
基于上边把 session 放在 nodejs 进程内存里的弊端,redis 是一个很好的解决办法。
redis 特点
- web server 最常用的缓存数据库,数据存放在内存中
- 相比于 MySQL,访问速度快(内存和硬盘不是一个数量级的)
- 但是成本更高,可存储的数据量更小
- 将 web server 和 redis 拆分为两个单独的服务
- 双方都是独立的,都是可扩展的(例如都扩展成集群)
- 类似于 MySQL,也是一个单独的服务,也可扩展
为何 session 适合用 redis
- session 访问频繁,对性能要求极高
- session 可不考虑断电丢失数据的问题(内存的硬伤)
- session 数据量不会太大(相比于 MySQL 中存储的数据)
为何网站数据不适合用 redis
- 操作频率不是太高(相比于 session 操作)
- 断电不能丢失,必须保留
- 数据量太大,内存成本太高
3.2 redis 基本使用
在看完网上的安装教程后,在两个终端分别输入 redis-server
和 redis-cli
。一个是启动 redis 服务(服务端必须开启才能用!),另外一个是开启客户端,用来操作 redis。
redis 存储的格式是键值对,可以想象成是一个很大的 map。
放置键值对和获取键值对:
set myname sjh
get myname # 返回 "sjh"
获取所有的键:
keys *
删除某个键值对
del myname
3.3 nodejs 连接 redis - 封装工具函数
了解了相关使用后,写一个工具,方便在 node 里面操作 redis。
注意,nodejs 里 redis 的操作都是异步的,因此都需要用 await 来接收。
const redis = require("redis");
const { REDIS_CONF } = require("../conf/db");
// 创建 redis 客户端
const redisClient = redis.createClient(REDIS_CONF);
redisClient.connect();
const set = async (key, val) => {
// 键值对必须为字符串形式
if (typeof val === "object") {
val = JSON.stringify(val);
}
await redisClient.set(key, val);
return true;
};
const get = async (key) => {
const value = await redisClient.get(key);
if (!value) {
return null;
}
// 尝试解析 redis 字符串变为对象
try {
return JSON.parse(value);
} catch (error) {
return value;
}
};
module.exports = {
set,
get,
};
其中 set 方法用来设置键值对,get 用于获取 key 对应的值。
3.4 session 存入 redis
在 app.js 里面进行解析 session。
先获取 cookie 里的 userid,如果不存在,则标记成需要设置 cookie,并且初始化 redis 中的 session 值。session 的 key 为唯一标识的 userId。
// 解析 session(使用 redis)
let needSetCookie = false;
let userId = req.cookie.userid;
if (!userId) {
needSetCookie = true;
userId = `${Date.now()}_${Math.random()}`;
// 初始化 redis 中的 session 值
set(userId, {});
}
然后将 userId 放在 req 里,方便路由获取 sessionId。同时通过 sessionId ,从 redis 里获取 sessionId 对应的 sessionData。如果 sessionData 不存在,也需要初始化 sessionData 和 redis 中的 session 值。最后,将 sessionData 地址放在 req 里,方便路由修改和获取 sessionData。
// 获取 session
req.sessionId = userId
const sessionData = await get(req.sessionId);
if (sessionData == null) {
// 初始化 redis 中的 session 值
set(req.sessionId, {})
// 设置 session
req.session = {}
} else {
// 设置 session
req.session = sessionData
}
app.js 所有代码:
// app.js
const qs = require("qs");
const { handleBlogRouter } = require("./src/router/blog");
const { handleUserRouter } = require("./src/router/user");
const { get, set } = require("./src/db/redis");
const getCookieExpires = () => {
const d = new Date();
// 设置一天后 cookie 过期
d.setTime(d.getTime() + 24 * 60 * 60 * 1000);
return d.toUTCString();
};
// 获取 post 数据
const getPostData = (req) => {
return new Promise((resolve, reject) => {
// 非 POST 请求不存在 POST data 的问题
if (req.method !== "POST") {
resolve({});
return;
}
// 如果 POST data 不是 JSON 格式的数据,直接忽略(本项目的 POST data 都是 JSON 格式)
if (req.headers["content-type"] !== "application/json") {
resolve({});
return;
}
let postData = "";
req.on("data", (chunk) => {
postData += chunk.toString();
});
req.on("end", () => {
if (!postData) {
resolve({});
return;
}
resolve(JSON.parse(postData));
});
});
};
const serverHandle = async (req, res) => {
// 设置返回格式 JSON
res.setHeader("Content-type", "application/json");
// 获取 path
const url = req.url;
req.path = url.split("?")[0];
// 解析 query
req.query = qs.parse(url.split("?")[1]);
// 解析 cookie
req.cookie = {};
const cookieStr = req.headers.cookie || "";
cookieStr.split(";").forEach((element) => {
if (!element) {
return;
}
const [key, val] = element.split("=");
req.cookie[key] = val;
});
// 解析 session(使用 redis)
let needSetCookie = false;
let userId = req.cookie.userid;
if (!userId) {
needSetCookie = true;
userId = `${Date.now()}_${Math.random()}`;
// 初始化 redis 中的 session 值
set(userId, {});
}
// 获取 session
req.sessionId = userId
const sessionData = await get(req.sessionId);
if (sessionData == null) {
// 初始化 redis 中的 session 值
set(req.sessionId, {})
// 设置 session
req.session = {}
} else {
// 设置 session
req.session = sessionData
}
// 解析 POST data 后放在 req.body 内
const postData = await getPostData(req);
req.body = postData;
// 处理 blog 路由
const blogData = await handleBlogRouter(req, res);
if (blogData) {
if (needSetCookie) {
res.setHeader(
"Set-Cookie",
`userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`
);
}
res.end(JSON.stringify(blogData));
// 需 return,否则会继续往下执行
return;
}
// 处理 user 路由
const userData = await handleUserRouter(req, res);
if (userData) {
if (needSetCookie) {
res.setHeader(
"Set-Cookie",
`userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`
);
}
res.end(JSON.stringify(userData));
return;
}
// 未命中路由:纯文本返回 404 信息
res.writeHead(404, { "content-type": "text/plain" });
res.write("404 not found");
res.end();
};
module.exports = {
serverHandle,
};
现在来看登录和登录校验功能。如果登录成功了,则将 username 和 realname 设置到 req.session 中,同时同步到 redis 里。在登录校验中,通过 req.session 获得 username,如果 username 存在,说明用户已经登录。
// router/user.js
const { login } = require("../controller/user");
const { set } = require("../db/redis");
const { SuccessModel, ErrorModel } = require("../model/resModel");
const handleUserRouter = (req, res) => {
const method = req.method;
// 登录
if (method === "POST" && req.path === "/api/user/login") {
const { username, password } = req.body;
const result = login(username, password);
return result.then((data) => {
if (data.username) {
// 设置 session
req.session.username = data.username;
req.session.realName = data.realname;
// 同步到 redis 中
set(req.sessionId, req.session);
return new SuccessModel();
}
return new ErrorModel("登录失败");
});
}
// 登录验证的测试
if (method === "GET" && req.path === `/api/user/login-test`) {
if (req.session.username) {
return Promise.resolve(
new SuccessModel({ username: req.session })
);
}
return Promise.resolve(new ErrorModel("尚未登录"));
}
};
module.exports = {
handleUserRouter,
};
3.5 登录验证中间件
在新建博客,更新博客,删除博客的时候,都需要做登录验证,因此将登录验证写成中间件会比较合理。
// router/blog.js
// 统一的登录验证函数
const loginCheck = (req) => {
if (!req.session.username) {
return Promise.resolve(new ErrorModel("尚未登录"));
}
};
上面的函数即为登录验证的中间件,如果 session 里没有 username,返回尚未登录的相关信息。
在进行博客添加、修改、删除等逻辑前,先走一下中间件逻辑,如果中间件返回了数据,说明未登录,则直接 return 中断后续的操作。
blog 相关所有代码:
// router/blog.js
const {
getList,
getDetail,
newBlog,
updateBlog,
delBlog,
} = require("../controller/blog");
const { SuccessModel, ErrorModel } = require("./../model/resModel");
// 统一的登录验证函数
const loginCheck = (req) => {
if (!req.session.username) {
return Promise.resolve(new ErrorModel("尚未登录"));
}
};
const handleBlogRouter = (req, res) => {
const method = req.method;
const id = req.query.id;
// 获取博客列表
if (method === "GET" && req.path === "/api/blog/list") {
const author = req.query.author || "";
const keyword = req.query.keyword || "";
const result = getList(author, keyword);
// 返回 promise
return result.then((listData) => {
return new SuccessModel(listData);
});
}
// 获取博客详情
if (method === "GET" && req.path === "/api/blog/detail") {
const result = getDetail(id);
return result.then((data) => {
return new SuccessModel(data);
});
}
// 新建一篇博客
if (method === "POST" && req.path === "/api/blog/new") {
const loginCheckResult = loginCheck(req);
// 如果有返回值,说明未登录
if (loginCheckResult) {
return loginCheckResult;
}
// 获取用户信息
req.body.author = req.session.username;
const result = newBlog(req.body);
return result.then((data) => {
return new SuccessModel(data);
});
}
// 更新一篇博客
if (method === "POST" && req.path === "/api/blog/update") {
const loginCheckResult = loginCheck(req);
// 如果有返回值,说明未登录,直接返回信息
if (loginCheckResult) {
return loginCheckResult;
}
req.body.author = req.session.username;
const result = updateBlog(id, req.body);
return result.then((val) => {
if (val) {
return new SuccessModel();
} else {
return new ErrorModel("更新博客失败");
}
});
}
// 删除一篇博客
if (method === "POST" && req.path === "/api/blog/del") {
const loginCheckResult = loginCheck(req);
// 如果有返回值,说明未登录
if (loginCheckResult) {
return loginCheckResult;
}
const author = req.session.username;
const result = delBlog(id, author);
return result.then((val) => {
if (val) {
return new SuccessModel();
} else {
return new ErrorModel("删除博客失败");
}
});
}
};
module.exports = {
handleBlogRouter,
};
4. 和前端联调
4.1 跨域问题
- 登录功能依赖 cookie,需要用浏览器来联调
- cookie 跨域不共享,前端和 server 端必须同域
- 需要用到 nginx 做代理,让前后端同域
后端和前端同时启动项目,因为端口的不同,会产生跨域的问题。
前端启动服务:
先安装 http-server 用于启动服务器
yarn global add http-server
然后在前端所在文件夹设置一个端口启动:
http-server -p 8001
启动后效果如下:
但是是请求不到博客列表的,因为端口不同(我的后端端口为 8000),因此 cookie 也不能携带过去。
4.2 NGINX 解决跨域
-
高性能的 web 服务器,开源免费
-
一般用于静态服务、负载均衡
-
反向代理
反向代理是对客户端不可见的代理。与反向代理相反的是正向代理,即客户端能控制的代理,例如,公司的内网需要下载相关代理,浏览器才能访问到。
nginx 根据请求的不同,代理到不同的地方。
nginx 安装
- windows 用户在官网上安装
- mac 用户使用 homebrew:
brew install nginx
安装后的详细信息:
Docroot is: /usr/local/var/www
The default port has been set in /usr/local/etc/nginx/nginx.conf to 8080 so that
nginx can run without sudo.
nginx will load all files in /usr/local/etc/nginx/servers/.
To restart nginx after an upgrade:
brew services restart nginx
Or, if you don't want/need a background service you can just run:
/usr/local/opt/nginx/bin/nginx -g daemon off;
nginx 默认配置文件
- Windows:
C:\nginx\conf\nginx.conf
- Mac:
/usr/local/etc/nginx/nginx.conf
nginx 命令
- 测试配置文件格式是否正确:
nginx -t
- 启动 nginx:
nginx
- 重启 nginx:
nginx -s reload
- 停止 nginx:
nginx -s stop
代理配置
在配置文件 nginx.conf
中进行以下修改:
server {
listen 8080;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
# 静态文件端口
location / {
proxy_pass http://localhost:8001;
}
# 服务器端口
location /api/ {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
}
直接看内容还是很好理解的。nginx 服务器访问的端口为 8080。当接到根目录,就代理到 8001 端口下,接到 /api/ 路径,则代理到 8000 端口。
代理配置完后即可看到效果,跨域成功解决: