Node.js 获取操作 Session
一、Session
1、Session 的定义
Session 是一种记录服务器和客户端会话状态的机制,基于 Cookie 实现,存储在服务器。Session 不能跨域。
Session 认证流程:
- 浏览器第一次请求服务器时,服务器根据提交的相关信息,创建对应的 Session;
- 服务器返回请求时将此 Session 的唯一标识信息 SessionID 返回给浏览器;
- 浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入 Cookie,同时 Cookie 记录此 SessionID 属于哪个域名;
- 当浏览器再次访问服务器时,请求会自动判断此域名下是否存在 Cookie 信息,如果存在则将 Cookie 信息也发给服务器,服务器会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没找到说明用户没有登录或登录失效,如果找到 Session 则证明用户已登录,可执行后面操作。
根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,Session 相当于程序在服务器上建立的一份客户档案,客户来访的时候只需查询客户档案表即可,大部分系统也是根据此原理来验证用户登录状态。
2、与 Cookie 的区别
- 安全性不同:Cookie 存储在客户端,而 Session 存储在服务端,比 Cookie 安全;
- 存取值的类型不同:Cookie 只支持存字符串数据,而 Session 可以存任意数据类型;
- 有效期不同:Cookie 在设置的有效期结束前有效,而 Session 在超时或客户端关闭时都会失效;
- 存储大小不同:单个 Cookie 保存的数据不能超过 4k,而 Session 可存储数据远高于 Cookie,但当访问量过多时会占用服务器资源。
3、与 sessionStorage 的区别
- Session 存储在服务端,而 sessionStorage 存储在客户端;
- Session 是用于维持会话状态的 key,而 sessionStorage 是存储在会话期间的数据。
二、Node.js 操作 Session
继前面的 Node.js 项目示例,服务器根据 Cookie 中有无 username 来判断用户是否已经登录,这种方式会暴露 username,存在安全性问题,用 Session 的优化方法:
- 服务器给 username 匹配一个随机的
userid
发给浏览器; - 浏览器将此
userid
存入 Cookie,再次访问服务器时跟随 Cookie 一起发送; - 服务端根据是否有
userid
对上的 username 来判断用户是否已经登录。
1、原生 Node.js 操作 Session
下面对原先示例进行 Cookie ▶ Session 改造:
- app.js
- src/router/user.js
app.js
做了以下优化:
- 未登录:Cookie 没有 userid,
SESSION_DATA[userId]
为空,req.session
也为空,拿不到req.session.username
,登录验证接口将返回「尚未登录」- 另外,没有登录的情况下访问接口,会创建 SessionID,发送给 Cookie。
- 已登录:Cookie 有 userid,
SESSION_DATA[userId]
在登录时已被赋为{ username: 'xx' }
,访问登录验证接口时再赋给req.session
,拿到req.session.username
后,登录验证接口将返回登录后的信息。
// ...
// Session 数据
const SESSION_DATA = {}
const serverHandle = (req, res) => {
// ...
// 统一解析 Session
let userId = req.cookie.userid
let isNeedSetCookie = false // 是否需要设置 Session
if (userId) { // Cookie 有 userid 时
if (!SESSION_DATA[userId]) {
SESSION_DATA[userId] = {}
}
} else { // Cookie 没有 userid 时
isNeedSetCookie = true
userId = `${Date.now()}_${Math.random()}` // 生成一个随机的 userId
SESSION_DATA[userId] = {}
}
// 浅拷贝
// SESSION_DATA[userId] 会随 req.session 的变化而变化
req.session = SESSION_DATA[userId]
// 处理 post data
getPostData(req).then(postData => {
req.body = postData
// 处理 blog 路由
const blogResult = handleBlogRouter(req, res)
if (blogResult) {
blogResult.then(blogData => {
if (isNeedSetCookie) {
res.setHeader('Set-Cookie', `userid=${userId}; path=/; HttpOnly`)
}
res.end(
JSON.stringify(blogData)
)
})
return
}
// 处理 user 路由
const userResult = handleUserRouter(req, res)
if (userResult) {
userResult.then(userData => {
if (isNeedSetCookie) {
res.setHeader('Set-Cookie', `userid=${userId}; path=/; HttpOnly`)
}
res.end(
JSON.stringify(userData)
)
})
return
}
// 未命中路由,返回 404
res.writeHead(404, { "Content-type": "text/plain" })
res.write("404 not Found\n")
res.end()
})
}
module.exports = serverHandle
user.js
做了以下优化:
- 登录成功后,将 username 赋给
req.session
。由于外层 app.js 执行了req.session = SESSION_DATA[userId]
浅拷贝,所以 username 同时赋给了 app.js 的SESSION_DATA[userId]
- 当再次访问登录验证接口时,拿到的
req.session
已经是外层 app.js 赋值后的SESSION_DATA[userId]
,即{ username: 'dorki' }
const { login } = require('../controller/user')
const { SuccessModel, ErrorModel } = require('../model/resModel')
// 获取 Cookie 的过期时间
const getCookieExpires = () => {
const d = new Date()
d.setTime(d.getTime() + (24 * 60 * 60 * 1000))
return d.toGMTString()
}
const handleUserRouter = (req, res) => {
const method = req.method
// 登录
if (method === 'GET' && req.path === '/api/user/login') {
const { username, password } = req.query
const result = login(username, password)
return result.then(data => {
if (data.username) {
// 操作 Cookie
res.setHeader('Set-Cookie', `username=${data.username}; path=/; HttpOnly; Expires=${getCookieExpires()}`)
// 设置 Session
req.session.username = data.username
console.log('req.session: ', req.session)
return new SuccessModel()
}
return new ErrorModel('登录失败')
})
}
// 登陆验证的测试
if (method === 'GET' && req.path === '/api/user/login-test') {
if (req.cookie.username) {
if (req.session.username) {
return Promise.resolve(new SuccessModel({
username: req.cookie.username
username: req.session.username
}))
}
return Promise.resolve(new ErrorModel('尚未登录'))
}
}
module.exports = handleUserRouter
2、Session 实现效果
接下来验证下效果 ▼
2-1、登录验证
第一次访问 http://127.0.0.1:7676/api/user/login-test
登录验证接口,控制台网络可以看到 Set-Cookie 中设置了 userid:
在应用中也可以看到 Cookie 添加了 userid:
但返回的是「尚未登录」,因为还没有访问登录接口:
2-2、登录
此时访问 http://127.0.0.1:7676/api/user/login?username=dorki&password=123
登录接口,控制台网络可以看到发送的 Cookie 中有 userid:
同时打印了 req.session:
2-3、再次登录验证
访问完登录接口后,再次访问 http://127.0.0.1:7676/api/user/login-test
登录验证接口,可以看到:
点击查看原生 Node.js 操作 Session 完整代码
3、Express 中操作 Session
上面是使用原生 Node.js 实现 Session 操作,Express 中可以直接使用 express-session 中间件来实现,不需要统一解析 Session 的步骤,安装如下:
npm i express-session -D
实现如下:
- app.js
- routes/user.js
// ...
const session = require('express-session');
// 每次请求时生成随机 session,并赋给 req.session
app.use(session({
secret: 'leophen_0810#', // 密钥
resave: false,
saveUninitialized: true,
cookie: {
path: '/',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24小时后失效
}
}))
// ...
const express = require('express');
const router = express.Router();
const { login } = require('../controller/user')
const { SuccessModel, ErrorModel} = require('../model/resModel')
// 登录
router.post('/login', (req, res, next) => {
const { username, password } = req.body
const result = login(username, password)
return result.then(data => {
if (data.username) {
// 设置 Session
req.session.username = data.username
res.json(new SuccessModel({
username: req.session.username
}))
} else {
res.json(new ErrorModel('登录失败'))
}
})
});
// 退出登录
router.get('/logout', (req, res, next) => {
if (req.session.username) {
req.session.username = null
res.json(new SuccessModel('退出登录成功'))
} else {
res.json(new ErrorModel('退出登录失败'))
}
});
// 登陆验证的测试
router.get('/login-test', (req, res, next) => {
if (req.session.username) {
res.json(new SuccessModel({
username: req.session.username
}))
} else {
res.json(new ErrorModel('尚未登录'))
}
});
module.exports = router;
express-session
从 1.5 开始不再需要依赖 cookie-parser 中间件,如果在 cookie-parser 中使用的 secret 与 express-session
中的 secret 不一致会起冲突。所以如果要在项目中使用 session,可以不需要 cookie-parser。
另外 express-session
默认将 session 数据缓存在服务器所在主机的内存中,随着用户数的增加,session 数据会越来越多,极大的占用服务器的内存,而且一旦服务器重启,内存中的 session 数据也将丢失,因此 session 数据可以通过 Redis 来存储。
4、Koa 中操作 Session
Koa 中可直接使用 koa-generic-session 中间件来实现,安装如下:
npm i koa-generic-session --save
实现如下:
- app.js
- routes/user.js
// ...
const session = require('koa-generic-session')
// 操作 Session,注意要写在路由配置之前
app.keys = ['leophen_0810#']
app.use(session({
// 配置 Cookie
cookie: {
path: '/',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24小时后失效
},
}))
const router = require('koa-router')()
const { login, register } = require('../controller/user')
const { SuccessModel, ErrorModel } = require('../model/resModel')
router.prefix('/api/user')
// 登录
router.post('/login', async (ctx, next) => {
const { username, password } = ctx.request.body
const result = await login(username, password)
if (result.username) {
// 设置 Session
ctx.session.username = result.username
ctx.body = new SuccessModel({
username: ctx.session.username
})
} else {
ctx.body = new ErrorModel('登录失败')
}
});
// 退出登录
router.get('/logout', (ctx, next) => {
if (ctx.session.username) {
ctx.session.username = null
ctx.body = new SuccessModel('退出登录成功')
} else {
ctx.body = new ErrorModel('退出登录失败')
}
});
// 登陆验证的测试
router.get('/login-test', (ctx, next) => {
if (ctx.session.username) {
ctx.body = new SuccessModel({
username: ctx.session.username
})
} else {
ctx.body = new ErrorModel('尚未登录')
}
});
module.exports = router
三、使用 Redis 存储 Session
为什么 Session 适合用 Redis 存储?
- Session 访问频繁,对性能要求极高;
- Session 可不考虑断电丢失数据的问题;
- Session 数据量不会太大。
1、连接配置 Redis
const env = process.env.NODE_ENV // 环境参数
// 配置
let REDIS_CONFIG
if (env === 'dev') {
REDIS_CONFIG = {
port: 6379,
host: '127.0.0.1'
}
}
if (env === 'production') {
// 这里应为线上配置,暂时用本地替代
REDIS_CONFIG = {
port: 6379,
host: '127.0.0.1'
}
}
module.exports = {
REDIS_CONFIG
}
2、封装设置和读取 Redis 函数
封装用于设置和读取 Redis 数据的 set
和 get
方法:
const redis = require('redis')
const { REDIS_CONFIG } = require('../config/db')
// 创建客户端
const redisClient = redis.createClient(REDIS_CONFIG.port, REDIS_CONFIG.host);
(async function () {
await redisClient.connect()
.then(() => console.log('Redis connect success.'))
.catch(console.error)
})()
async function set(key, val) {
const objVal = typeof val === 'object' ? JSON.stringify(val) : val
await redisClient.set(key, objVal)
}
async function get(key) {
const result = JSON.parse(await redisClient.get(key))
return result
}
module.exports = {
set,
get
}
3、改造 Session 的存储方式
- app.js
- src/router/user.js
app.js
做了以下改造:
- 未登录:未登录时 Cookie 没有 userid,原本通过
SESSION_DATA[userId]
上是否存在username
来判断登录状态,现在改为根据封装的 Redisget
方法拿到 sessionData,判断 sessionData 上是否存在username
来得出登录状态;- 另外,没有登录的情况下访问接口,仍会创建 SessionID 发给 Cookie。
- 已登录:判断登录状态同上。
// ...
const { get, set } = require('./src/db/redis')
// 初始 Session 数据
const SESSION_DATA = {}
const serverHandle = (req, res) => {
// ...
// 使用 Redis 统一解析 Session
let userId = req.cookie.userid
let isNeedSetCookie = false // 是否需要设置 Session
if (userId) { // Cookie 有 userid 时
if (!SESSION_DATA[userId]) {
SESSION_DATA[userId] = {}
}
} else { // Cookie 没有 userid 时
isNeedSetCookie = true
userId = `${Date.now()}_${Math.random()}` // 生成一个随机的 userId
SESSION_DATA[userId] = {}
}
// 浅拷贝
// SESSION_DATA[userId] 会随 req.session 的变化而变化
req.session = SESSION_DATA[userId]
if (!userId) {
isNeedSetCookie = true
userId = `${Date.now()}_${Math.random()}` // 生成一个随机的 userId
// 初始化 Redis 中的 Session 值
set(userId, {})
}
req.sessionId = userId
// 获取 Session
get(req.sessionId).then(sessionData => {
if (sessionData === null) {
// 初始化 Redis 中的 Session 值
set(req.sessionId, {})
req.session = {}
} else {
req.session = sessionData
}
console.log('req.session ', req.session)
// 处理 post data
return getPostData(req)
}).then(postData => {
// ...
})
}
module.exports = serverHandle
user.js
做了以下改造:
- 登录成功后,会将 username 赋给
req.session
。原本通过浅拷贝的方式将 username 赋给外层 app.js 声明的SESSION_DATA[userId]
,现在通过封装的 Redisset
方法将 userId 和带有username
的req.session
对应上; - 当再次访问登录验证接口时,拿到的
req.session
已被赋为外层 app.js 执行 Redisget
方法后拿到的 sessionData,带有username
,因此登录成功。
const { login } = require('../controller/user')
const { SuccessModel, ErrorModel } = require('../model/resModel')
const { set } = require('../db/redis')
const handleUserRouter = (req, res) => {
const method = req.method
// 登录
if (method === 'GET' && req.path === '/api/user/login') {
const { username, password } = req.query
const result = login(username, password)
return result.then(data => {
if (data.username) {
// 设置 Session
req.session.username = data.username
// 同步到 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.username
}))
}
return Promise.resolve(new ErrorModel('尚未登录'))
}
}
module.exports = handleUserRouter
4、Redis 改造后的效果
接下来验证下用 Redis 存储 Session 后的效果 ▼
4-1、登录验证
第一次访问 http://127.0.0.1:7676/api/user/login-test
登录验证接口,控制台网络可以看到 Set-Cookie 中设置了 userid:
但返回的是「尚未登录」,因为还没有访问登录接口:
此时执行 Redis get
方法得到的 sessionData 为 null
,因此打印的 req.session 为 {}
:
4-2、登录
此时访问 http://127.0.0.1:7676/api/user/login?username=dorki&password=123
登录接口,控制台网络可以看到发送的 Cookie 中有 userid:
登录成功后代码中执行了 set(req.sessionId, req.session)
,此时:
- 执行 Redis 的
keys *
命令查看数据,可以看到作为 key 值的 userId; - 执行
get
命令即可看到作为 value 值的 req.session:
因此,登录时执行的 Redis get
方法,可以得到存储在 Redis 中的 sessionData,因此打印的 req.session 如下:
4-3、再次登录验证
访问完登录接口后,再次访问 http://127.0.0.1:7676/api/user/login-test
登录验证接口,可以看到:
5、Express 用 Redis 存储 Session
上面是使用原生 Node.js 实现 Redis 存储 Session 操作,Express 中可以使用 connect-redis 中间件来实现,不需要额外封装设置和读取 Redis 数据的函数,安装如下:
npm i redis connect-redis express-session -D
实现如下:
- app.js
- db/redis.js
- config/db.js
// ...
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const redisClient = require('./db/redis');
// 操作 Session,每次请求时生成随机 session
app.use(session({
secret: 'leophen_0810#', // 密钥
resave: false,
saveUninitialized: false,
cookie: {
path: '/',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24小时后失效
},
store: new RedisStore({ client: redisClient }),
}))
const { createClient } = require('redis')
const { REDIS_CONFIG } = require('../config/db')
// 创建客户端
const redisClient = createClient({
url: `redis://${REDIS_CONFIG.host}:${REDIS_CONFIG.port}`,
legacyMode: true
});
// 连接
redisClient.connect()
.then(() => console.log('Redis connect success.'))
.catch(console.error)
module.exports = redisClient
const env = process.env.NODE_ENV // 环境参数
// 配置
let REDIS_CONFIG
if (env === 'dev') {
REDIS_CONFIG = {
port: 6379,
host: '127.0.0.1'
}
}
if (env === 'production') {
// 这里为线上配置
REDIS_CONFIG = {
port: xxx,
host: 'xxx'
}
}
module.exports = {
MYSQL_CONFIG
}
6、Koa 用 Redis 存储 Session
Express 中可以使用 koa-redis 中间件来实现,安装如下:
npm i koa-redis redis --save
使用如下:
- app.js
- config/db.js
// ...
const session = require('koa-generic-session')
const redisStore = require('koa-redis')
const { REDIS_CONFIG } = require('./config/db')
// 操作 Session,注意要写在路由配置之前
app.keys = ['leophen_0810#']
app.use(session({
// 配置 Cookie
cookie: {
path: '/',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24小时后失效
},
// 配置 Redis
store: redisStore({
all: `${REDIS_CONFIG.host}:${REDIS_CONFIG.port}`
})
}))
const env = process.env.NODE_ENV // 环境参数
// 配置
let REDIS_CONFIG
if (env === 'dev') {
REDIS_CONFIG = {
port: 6379,
host: '127.0.0.1'
}
}
if (env === 'production') {
// 这里为线上配置
REDIS_CONFIG = {
port: xxx,
host: 'xxx'
}
}
module.exports = {
MYSQL_CONFIG
}