JWT鉴权
一、JWT是什么
JWT(JSON Web Token),本质就是一个字符串书写规范,如下图,作用是用来在用户和服务器之间传递安全可靠的信息
在目前前后端分离的开发过程中,使用token
鉴权机制用于身份验证是最常见的方案,流程如下:
- 服务器当验证用户账号和密码正确的时候,给用户颁发一个令牌,这个令牌作为后续用户访问一些接口的凭证
- 后续访问会根据这个令牌判断用户时候有权限进行访问
Token
,分成了三部分,头部(Header)、载荷(Payload)、签名(Signature),并以.
进行拼接。其中头部和载荷都是以JSON
格式存放数据,只是进行了编码
header
每个JWT都会带有头部信息,这里主要声明使用的算法。声明算法的字段名为alg
,同时还有一个typ
的字段,默认JWT
即可。以下示例中算法为HS256
{ "alg": "HS256", "typ": "JWT" }
因为JWT是字符串,所以我们还需要对以上内容进行Base64编码,编码后字符串如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload
载荷即消息体,这里会存放实际的内容,也就是Token
的数据声明,例如用户的id
和name
,默认情况下也会携带令牌的签发时间iat
,通过还可以设置过期时间,如下:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
同样进行Base64编码后,字符串如下:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
Signature
签名是对头部和载荷内容进行签名,一般情况,设置一个secretKey
,对前两个的结果进行HMACSHA25
算法,公式如下:
Signature = HMACSHA256(base64Url(header)+.+base64Url(payload),secretKey)
一旦前面两部分数据被篡改,只要服务器加密用的密钥没有泄露,得到的签名肯定和之前的签名不一致
二、如何实现
Token
的使用分成了两部分:
- 生成token:登录成功的时候,颁发token
- 验证token:访问某些资源或者接口时,验证token
生成 token
借助第三方库jsonwebtoken
,通过jsonwebtoken
的 sign
方法生成一个 token
:
- 第一个参数指的是 Payload
- 第二个是秘钥,服务端特有
- 第三个参数是 option,可以定义 token 过期时间
const crypto = require("crypto"),
jwt = require("jsonwebtoken");
// TODO:使用数据库
// 这里应该是用数据库存储,这里只是演示用
let userList = [];
class UserController {
// 用户登录
static async login(ctx) {
const data = ctx.request.body;
if (!data.name || !data.password) {
return ctx.body = {
code: "000002",
message: "参数不合法"
}
}
const result = userList.find(item => item.name === data.name && item.password === crypto.createHash('md5').update(data.password).digest('hex'))
if (result) {
// 生成token
const token = jwt.sign(
{
name: result.name
},
"test_token", // secret
{ expiresIn: 60 * 60 } // 过期时间:60 * 60 s
);
return ctx.body = {
code: "0",
message: "登录成功",
data: {
token
}
};
} else {
return ctx.body = {
code: "000002",
message: "用户名或密码错误"
};
}
}
}
module.exports = UserController;
在前端接收到token
后,一般情况会通过localStorage
进行缓存,然后将token
放到HTTP
请求头Authorization
中,关于Authorization
的设置,前面要加上 Bearer ,注意后面带有空格
axios.interceptors.request.use(config => {
const token = localStorage.getItem('token');
config.headers.common['Authorization'] = 'Bearer ' + token; // 留意这里的 Authorization
return config;
})
三、优缺点
优点:
- json具有通用性,所以可以跨语言
- 组成简单,字节占用小,便于传输
- 服务端无需保存会话信息,很容易进行水平扩展
- 一处生成,多处使用,可以在分布式系统中,解决单点登录问题
- 可防护CSRF攻击
缺点:
- payload部分仅仅是进行简单编码,所以只能用于存储逻辑必需的非敏感信息
- 需要保护好加密密钥,一旦泄露后果不堪设想
- 为避免token被劫持,最好使用https协议
文件上传
一、是什么
文件上传在日常开发中应用很广泛,我们发微博、发微信朋友圈都会用到了图片上传功能
因为浏览器限制,浏览器不能直接操作文件系统的,需要通过浏览器所暴露出来的统一接口,由用户主动授权发起来访问文件动作,然后读取文件内容进指定内存里,最后执行提交请求操作,将内存里的文件内容数据上传到服务端,服务端解析前端传来的数据信息后存入文件里
对于文件上传,我们需要设置请求头为content-type:multipart/form-data
multipart互联网上的混合资源,就是资源由多种元素组成,form-data表示可以使用HTML Forms 和 POST 方法上传文件
结构如下:
POST /t2/upload.do HTTP/1.1
User-Agent: SOHUWapRebot
Accept-Language: zh-cn,zh;q=0.5
Accept-Charset: GBK,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Content-Length: 60408
Content-Type:multipart/form-data; boundary=ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC
Host: w.sohu.com
--ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC
Content-Disposition: form-data; name="city"
Santa colo
--ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC
Content-Disposition: form-data;name="desc"
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
...
--ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC
Content-Disposition: form-data;name="pic"; filename="photo.jpg"
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary
... binary data of the jpg ...
--ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC--
boundary
表示分隔符,如果要上传多个表单项,就要使用boundary
分割,每个表单项由———XXX
开始,以———XXX
结尾
而xxx
是即时生成的字符串,用以确保整个分隔符不会在文件或表单项的内容中出现
每个表单项必须包含一个 Content-Disposition
头,其他的头信息则为可选项, 比如 Content-Type
Content-Disposition
包含了 type
和 一个名字为name
的 parameter
,type
是 form-data
,name
参数的值则为表单控件(也即 field)的名字,如果是文件,那么还有一个 filename
参数,值就是文件名
Content-Disposition: form-data; name="user"; filename="logo.png"
至于使用multipart/form-data
,是因为文件是以二进制的形式存在,其作用是专门用于传输大型二进制数据,效率高
二、如何实现
关于文件的上传的上传,我们可以分成两步骤:
- 文件的上传
- 文件的解析
文件上传
传统前端文件上传的表单结构如下:
<form action="http://localhost:8080/api/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" id="file" value="" multiple="multiple" />
<input type="submit" value="提交"/>
</form>
action
就是我们的提交到的接口,enctype="multipart/form-data"
就是指定上传文件格式,input
的 name
属性一定要等于file
文件解析
在服务器中,这里采用koa2
中间件的形式解析上传的文件数据,分别有下面两种形式:
- koa-body
- koa-multer
koa-body
安装依赖
npm install koa-body
引入koa-body
中间件
const koaBody = require('koa-body');
app.use(koaBody({
multipart: true,
formidable: {
maxFileSize: 200*1024*1024 // 设置上传文件大小最大限制,默认2M
}
}));
获取上传的文件
const file = ctx.request.files.file; // 获取上传文件
获取文件数据后,可以通过fs
模块将文件保存到指定目录
router.post('/uploadfile', async (ctx, next) => {
// 上传单个文件
const file = ctx.request.files.file; // 获取上传文件
// 创建可读流
const reader = fs.createReadStream(file.path);
let filePath = path.join(__dirname, 'public/upload/') + `/${file.name}`;
// 创建可写流
const upStream = fs.createWriteStream(filePath);
// 可读流通过管道写入可写流
reader.pipe(upStream);
return ctx.body = "上传成功!";
});
koa-multer
安装依赖:
npm install koa-multer
使用 multer
中间件实现文件上传
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, "./upload/")
},
filename: (req, file, cb) => {
cb(null, Date.now() + path.extname(file.originalname))
}
})
const upload = multer({
storage
});
const fileRouter = new Router();
fileRouter.post("/upload", upload.single('file'), (ctx, next) => {
console.log(ctx.req.file); // 获取文件
})
app.use(fileRouter.routes());
分页
一、是什么
在我们做数据查询的时候,如果数据量很大,比如几万条数据,放在一个页面显示的话显然不友好,这时候就需要采用分页显示的形式,如每次只显示10条数据
要实现分页功能,实际上就是从结果集中显示第1~10条记录作为第1页,显示第11~20条记录作为第2页,以此类推
因此,分页实际上就是从结果集中截取出第M~N条记录
二、如何实现
前端实现分页功能,需要后端返回必要的数据,如总的页数,总的数据量,当前页,当前的数据
{
"totalCount": 1836, // 总的条数
"totalPages": 92, // 总页数
"currentPage": 1 // 当前页数
"data": [ // 当前页的数据
{
...
}
]
后端采用mysql
作为数据的持久性存储
前端向后端发送目标的页码page
以及每页显示数据的数量pageSize
,默认情况每次取10条数据,则每一条数据的起始位置start
为:
const start = (page - 1) * pageSize
当确定了limit
和start
的值后,就能够确定SQL
语句:
const sql = `SELECT * FROM record limit ${pageSize} OFFSET ${start};`
上诉SQL
语句表达的意思为:截取从start
到start
+pageSize
之间(左闭右开)的数据
关于查询数据总数的SQL
语句为,record
为表名:
SELECT COUNT(*) FROM record
因此后端的处理逻辑为:
- 获取用户参数页码数page和每页显示的数目 pageSize ,其中page 是必须传递的参数,pageSize为可选参数,默认为10
- 编写 SQL 语句,利用 limit 和 OFFSET 关键字进行分页查询
- 查询数据库,返回总数据量、总页数、当前页、当前页数据给前端
代码如下所示:
router.all('/api', function (req, res, next) {
var param = '';
// 获取参数
if (req.method == "POST") {
param = req.body;
} else {
param = req.query || req.params;
}
if (param.page == '' || param.page == null || param.page == undefined) {
res.end(JSON.stringify({ msg: '请传入参数page', status: '102' }));
return;
}
const pageSize = param.pageSize || 10;
const start = (param.page - 1) * pageSize;
const sql = `SELECT * FROM record limit ${pageSize} OFFSET ${start};`
pool.getConnection(function (err, connection) {
if (err) throw err;
connection.query(sql, function (err, results) {
connection.release();
if (err) {
throw err
} else {
// 计算总页数
var allCount = results[0][0]['COUNT(*)'];
var allPage = parseInt(allCount) / 20;
var pageStr = allPage.toString();
// 不能被整除
if (pageStr.indexOf('.') > 0) {
allPage = parseInt(pageStr.split('.')[0]) + 1;
}
var list = results[1];
res.end(JSON.stringify({ msg: '操作成功', status: '200', totalPages: allPage, currentPage: param.page, totalCount: allCount, data: list }));
}
})
})
});
三、总结
通过上面的分析,可以看到分页查询的关键在于,要首先确定每页显示的数量pageSize
,然后根据当前页的索引pageIndex
(从1开始),确定LIMIT
和OFFSET
应该设定的值:
- LIMIT 总是设定为 pageSize
- OFFSET 计算公式为 pageSize * (pageIndex - 1)
确定了这两个值,就能查询出第 N
页的数据。
性能监控和优化
一、 是什么
Node
作为一门服务端语言,性能方面尤为重要,其衡量指标一般有如下:
- CPU
- 内存
- I/O
- 网络
CPU
主要分成了两部分:
- CPU负载:在某个时间段内,占用以及等待CPU的进程总数
- CPU使用率:CPU时间占用状况,等于 1 - 空闲CPU时间(idle time) / CPU总时间
这两个指标都是用来评估系统当前CPU的繁忙程度的量化指标
Node
应用一般不会消耗很多的CPU
,如果CPU
占用率高,则表明应用存在很多同步操作,导致异步任务回调被阻塞
内存指标
内存是一个非常容易量化的指标。 内存占用率是评判一个系统的内存瓶颈的常见指标。 对于Node来说,内部内存堆栈的使用状态也是一个可以量化的指标
// /app/lib/memory.js
const os = require('os');
// 获取当前Node内存堆栈情况
const { rss, heapUsed, heapTotal } = process.memoryUsage();
// 获取系统空闲内存
const sysFree = os.freemem();
// 获取系统总内存
const sysTotal = os.totalmem();
module.exports = {
memory: () => {
return {
sys: 1 - sysFree / sysTotal, // 系统内存占用率
heap: heapUsed / headTotal, // Node堆内存占用率
node: rss / sysTotal, // Node占用系统内存的比例
}
}
}
- rss:表示node进程占用的内存总量。
- heapTotal:表示堆内存的总量。
- heapUsed:实际堆内存的使用量。
- external :外部程序的内存使用量,包含Node核心的C++程序的内存使用量
在Node
中,一个进程的最大内存容量为1.5GB。因此我们需要减少内存泄露
磁盘 I/O
硬盘的IO
开销是非常昂贵的,硬盘 IO 花费的 CPU 时钟周期是内存的 164000 倍
内存 IO
比磁盘IO
快非常多,所以使用内存缓存数据是有效的优化方法。常用的工具如 redis
、memcached
等
并不是所有数据都需要缓存,访问频率高,生成代价比较高的才考虑是否缓存,也就是说影响你性能瓶颈的考虑去缓存,并且而且缓存还有缓存雪崩、缓存穿透等问题要解决
二、如何监控
关于性能方面的监控,一般情况都需要借助工具来实现
这里采用Easy-Monitor 2.0
,其是轻量级的 Node.js
项目内核性能监控 + 分析工具,在默认模式下,只需要在项目入口文件 require
一次,无需改动任何业务代码即可开启内核级别的性能监控分析
使用方法如下:
在你的项目入口文件中按照如下方式引入,当然请传入你的项目名称:
const easyMonitor = require('easy-monitor');
easyMonitor('你的项目名称');
打开你的浏览器,访问 http://localhost:12333
,即可看到进程界面
关于定制化开发、通用配置项以及如何动态更新配置项详见官方文档
三、如何优化
关于Node
的性能优化的方式有:
- 使用最新版本Node.js
- 正确使用流 Stream
- 代码层面优化
- 内存管理优化
使用最新版本Node.js
每个版本的性能提升主要来自于两个方面:
- V8 的版本更新
- Node.js 内部代码的更新优化
正确使用流 Stream
在Node
中,很多对象都实现了流,对于一个大文件可以通过流的形式发送,不需要将其完全读入内存
const http = require('http');
const fs = require('fs');
// bad
http.createServer(function (req, res) {
fs.readFile(__dirname + '/data.txt', function (err, data) {
res.end(data);
});
});
// good
http.createServer(function (req, res) {
const stream = fs.createReadStream(__dirname + '/data.txt');
stream.pipe(res);
});
代码层面优化
合并查询,将多次查询合并一次,减少数据库的查询次数
// bad
for user_id in userIds
let account = user_account.findOne(user_id)
// good
const user_account_map = {} // 注意这个对象将会消耗大量内存。
user_account.find(user_id in user_ids).forEach(account){
user_account_map[account.user_id] = account
}
for user_id in userIds
var account = user_account_map[user_id]
内存管理优化
在 V8 中,主要将内存分为新生代和老生代两代:
- 新生代:对象的存活时间较短。新生对象或只经过一次垃圾回收的对象
- 老生代:对象存活时间较长。经历过一次或多次垃圾回收的对象
若新生代内存空间不够,直接分配到老生代
通过减少内存占用,可以提高服务器的性能。如果有内存泄露,也会导致大量的对象存储到老生代中,服务器性能会大大降低
如下面情况:
const buffer = fs.readFileSync(__dirname + '/source/index.htm');
app.use(
mount('/', async (ctx) => {
ctx.status = 200;
ctx.type = 'html';
ctx.body = buffer;
leak.push(fs.readFileSync(__dirname + '/source/index.htm'));
})
);
const leak = [];
leak
的内存非常大,造成内存泄露,应当避免这样的操作,通过减少内存使用,是提高服务性能的手段之一
而节省内存最好的方式是使用池,其将频用、可复用对象存储起来,减少创建和销毁操作
例如有个图片请求接口,每次请求,都需要用到类。若每次都需要重新new这些类,并不是很合适,在大量请求时,频繁创建和销毁这些类,造成内存抖动
使用对象池的机制,对这种频繁需要创建和销毁的对象保存在一个对象池中。每次用到该对象时,就取对象池空闲的对象,并对它进行初始化操作,从而提高框架的性能。