JS全栈后端Server部分笔记 (2) - 接口鉴权、中间件使用、http-assert

JS全栈后端Server部分笔记 (2) - 接口鉴权、中间件使用、http-assert

前言

NodeJS编写的后台管理系统笔记,github地址:(待补充,)

通用CURD抽离

上文完成基本的框架搭建和分类的CRUD操作,由于后续对物品、图片资源都具有相同操作,因此可以抽象出CRUD,简化程序。

分类CURD部分实现:

const Category = require("../../models/Category.js")

  //新增分类
  router.post("/categories", async (req, res) => {
    const model = await Category.create(req.body)//将提交内容保存到mongoDB中
    res.send(model);
  })
......
//其他接口处理

观察以上代码,思考发现以下两点,

  1. 我们前端在接口请求分类资源 (复数形式的分类单词 categories ),需要导入 分类模型(单数首字母大写的分类单词Category),接口资源与导入数据库schema对应。

  2. 对于分类资源数据库CURD操作方法与对其他资源操作相同

于是我们可以将资源单独作为一个请求的入参,实时导入资源模型model,再对Schema进行CURD操作返回结果,比如请求对categories 的操作,那么动态导入Category 的模型;请求objectes操作,则动态导入 Object的模型。实现如下

......
const router = express.Router({
    mergeParams: true,
  });//子路由
......
router.get("/", async (req, res) => {
    const modelName = require("inflection").classify(req.params.resource);//categories->Category
    const Model = require(`../..//models/${modelName}`)//动态引入
    const items = await Model.find().populate("parent").limit(10);//查询,限制10条
    res.send(items);
  })
......
  app.use("/admin/api/rest/:resource", router)//将子路由挂在到实例上
......
  • 以分类列表接口为例,来修改。
  • 主路由修改为:app.use("/admin/api/rest/:resource", router),表示将子路由挂在到实例上,其中resource表示 请求的资源名称。mergeParams: true,表示将express实例的url参数传递到子路由router中,在router中能够拿到resourse,也就是在router中拿到资源名称的值。
  • 新增的rest 表示资源操作符合RestFul API风格
  • 利用inflection库将资源名称转为模型名称(复数名词转化为首字母大写单数的类名 categories->Category),那么只要接口请求的resourse不同,程序导入的数据库schema也就不同,CURD操作对象也不同,实现CURD的抽离。

由于接口多,不能将上述动态导入写在每个接口内,动态导入模型model可以利用中间件实现。

  app.use("/admin/api/rest/:resource", async (req, res, next) => {
    const modelName = require("inflection").classify(req.params.resource);
    req.Model = require(`../..//models/${modelName}`)//动态引入资源,也就是resource对应的modeal;绑定到req上,便于中间件传递
    next();
  }, router)//将子路由挂在到实例上
  • 在子路由与父路由绑定过程中增加中间件函数,实现根据资源动态导入模型
  • 将导入模型绑定到req中(req.Modal),因为中间件函数只有req,res可以传递。

最后注意修改子路由接口的路径

  //资源列表
  router.get("/", async (req, res) => {
  ......
  //资源详情
  router.get("/:id", async (req, res) => {
 ......
  //编辑资源
  router.put("/:id", async (req, res) => {
  ......
  //删除资源
  router.delete("/:id", async (req, res) => {
  ......

最后通用 的CURD也要具有扩展性,比如在分类列表查询时对于父级分类的关联查询,这就属于特殊操作,因此也要考虑到这点。

  //资源列表
  router.get("/", async (req, res) => {
    let queryOptions = {}
    if (req.Model.modelName == 'Category') {//modelName是mongoose中model的内置属性,等于model的名称
      queryOptions.populate = 'parent'; //指定关联查询属性
    }
    const items = await req.Model.find().setOptions(queryOptions).limit(10);//查询,限制10条
    res.send(items);
  })
  • 如果是对资源的列表进行请求的话,那么可以进行管理查询。

物品管理CURD接口

在完成通用CURD封装后,为物品提供接口只需要在指定目录下定义物品的schema和model即可,因为前端请求 items 资源时,后端后自动载入 Item 的模型进行操作。

//models/Item.js
const mongoose = require("mongoose");
const schema = new mongoose.Schema({
  name: { type: String },
  icon: { type: String },
})
module.exports = mongoose.model("Item", schema);
  • 图表icon的类型也定义 为字符串,因为一般在图片都是存储在OSS中,数据库只保留其url。

文件上传

在新建物品(装备时)有图片(ICON)字段,之前接口只做了图片的url存储,并没有做图片的处理。一般在软件开发中图片等文件的上传时存储在专门的服务器中(如阿里云的OSS,七牛云的空间),主要原因是为了以后的扩展性和使用时的载入速度。如果将文件存储到服务器中,之后对服务器进行横向扩展到多台时会造成无法共享文件的局面,因此将文件保存在独立的空间中。

这里我们暂时放在自己的服务器中,学习文件在express框架的接收。

express处理文件需要依赖第三方库multer,这个库是专门处理文件上传的,执行。

npm install multer --save

修改路由文件

//routes/admin/indexjs
  const multer = require("multer"); 
  const upload = multer({ dest: __dirname + "../../uploads" })
  app.use("/admin/api/upload", upload.single("file"), async (req, res) => { 
    const file = req.file;
        file.imgTrueUrl = `http://localhost:9999/uploads/${file.filename}`
    res.send(file);
  })
  • 上传处理 不是对资源的操作,单独起一个路由

  • upload是multer的实例化,其中参数 dest指定目标存储路径, __dirname是nodejs内置全局变量,代表本文件所在位置,这里是设置文件上传后的存储位置。

  • 在路由处理,插入中间件 upload.single(“file”),single指单个上传文件的处理 file指el上传组件二进制文件的字段名

  • 在最后的处理函数中,req本无file,与req.Model相同,file是multer中间件加上的。imgTrueUrl是实际图片地址,部署时修改为域名+路径+文件名,用与前端显示使用和存储到数据库。

图片上传不仅要提供上传接口,而且要提供下载显示接口,我们将图片保存在了后端项目的根目录下的uploads文件夹内,所以可以直接指定一个url路径知道该文件夹做静态文件,如下:

//index.js
app.use("/uploads", express.static(__dirname + "/uploads"));
  • static是express内置方法,用于指定静态目录

multer保存到电脑指定文件夹下的图片名字已经被替换为哈希值,且无扩展名,应该是为了安全

在这里插入图片描述

英雄管理CURD接口

英雄管理与物品管理大同小异,只是将icon变成了avatar。

享受通用CURD带来的便利,只需要增加英雄的模型即可。

模型中数据取自页面,从页面或者从实际生活中提取模型才是关键!部分页面如下,用手机访问 pvp.qq.com查看完整页面

在这里插入图片描述

const mongoose = require("mongoose");
const schema = new mongoose.Schema({
  name: { type: String },
  avatar: { type: String },
  title: { type: String },//称号
  categories: [{//一个英雄可能是多种类型
    type: mongoose.SchemaTypes.ObjectId, ref: "Category"
  }],//这里表示数组里存储多个id,id对应Category模型; 英雄分类用之前的分类模型来存储;
  scores: {//分数
    difficult: { type: Number },//困难
    skills: { type: Number },//技能
    attack: { type: Number },//攻击
    survive: { type: Number },//生存
  },
  skills: [{//技能,一个英雄包含多个技能,所以是一个数组,每个技能有名称、描述等,是一个对象
    name: { type: String },
    icon: { type: String },
    description: { type: String },
    tips: { type: String },
  }],
  items1: [{//顺风出装备,是一个数组
    type: mongoose.SchemaTypes.ObjectId, ref: "Items"
  }],
  items2: [{//逆风出装备,是一个数组
    type: mongoose.SchemaTypes.ObjectId, ref: "Items"
  }],
  //铭文与装备类似,新建铭文类型,然后这里是一个指向铭文id的数组
  usageTips: { type: String },//使用技巧
  battleTips: { type: String }, //对抗技巧
  teamTips: { type: String },//团战技巧
  partners: [{//英雄关系,注意这里,关系是数组,元素是英雄和相关关系描述,所以英雄可直接用英雄模型
    hero: { type: mongoose.SchemaTypes.ObjectId, ref: "Hero" },
    descption: { type: String }
  }]
})
module.exports = mongoose.model("Hero", schema);
  • 在mongoose表示1对多关系时,可以使用数组来实现,比如上述的分类,schema是一个数组包对象的结构,实际中代表本模型中够一个分类对应多个 分类模型。再如技能也是一对多,不过多的一方模型定义在本schema中
  • partners 英雄关系中该属性包含一个关联的英雄和一个描述,关联的英雄是独立的模型。

文章管理CRUD接口

与英雄管理大致相同,增加对应Article的model即可。

用户管理CRUD接口

与上述大致相同,不同点在于用户的密码需要加密后存储,这是能看到数据库人员的道德,避免密码泄露,密码加密用到了b’c’r’yptjs,使用npm 安装

npm install bcryptjs --save

在用户名的schema定义中使用set方法来加密密码字段。

const schema = new mongoose.Schema({
  username: { type: String },
  password: {
    type: String, 
    select:false,//默认不查出  
    set: (val) => {/
      return require("bcryptjs").hashSync("val", 10) //加密:散列密码,
    }
  },
})
  • set是mongose自带的方法,用于处理原来的值。

散列后的值如下:

在这里插入图片描述

  • 这里制作演示,实际系统不会返回密码,select:false设定查询时该字段(密码)不被查出。
  • 即使输入的密码相同,经过散列后的值也不会相同,采用hashSync 加密的字段比md5加密以及加盐(随机字符串)都要安全。

登录接口

本系统鉴权通过token实现,具体原理是前端通过用户名和密码来换取登录的token,之后每次请求都携带token。

登录接口首先校验用户名和密码,当用户名和密码不正确时返回给前端状态码和提示信息

//routes/admin/index.js  
app.use("/admin/api/login", async (req, res) => {

    const { username, password } = req.body;//从请求体中得到具体请求参数

    // 1. 查找用户名
    const AdminUser = require("../../models/AdminUser.js");
    const user = await AdminUser.findOne({ username }).select('+password')
    if (!user) {
      //如果用户名不存在则返回422状态码和提示信息
      res.status(422).send({
        message: "用户不存在"
      })
    }
    // 2. 验证密码
    const isValid = require('bcryptjs').compareSync(password, user.password)
    if (!isValid) {
      res.status(422).send({
        message: "密码错误"
      })
    }

密码和用户名验证通过后,返回token,token通过jsonwebtoken库来生成,安装命令如下:

npm install jsonwebtoken --save

安装完毕后完善登录接口

//routes/admin/index.js  
......
 // 3. 返回token
const jwt = require("jsonwebtoken").sign({ _id: user._id }, app.get("secret"))
res.send({jwt,jwt});
  • 利用user_id作为载荷通过定义在express的secret来生成加密字符串token
  • secret 是在 server.js文件中为express实例app增加的变量,实际加密秘钥要写在环境变量当中。

服务端登录校验

在接口插入中间节处理函数,以资源列表为例

//routes/admin/index.js
router.get("/", async (req, res, next) => {
    //中间件函数
    const token = (req.headers.authorization || " ").split(" ").pop();
    const { id } = jwt.verify(token, app.get("secret"))//通过secret解码token
    //根据id找用户,验证是否为伪造的token
    const user = await AdminUser.findById(id);
    console.log("user", user);
    if (user) {
      req.user = user;
    }

    console.log("token", token);
    next();
  }, async (req, res) => {
  .......
  })
  • 前端请求携带在header中的Authorization通过req取出,然后利用数组分解得到token,再利用jwt的verify函数和加密时用的secret来解密前端携带的token,解密得到id查询是否具有这一用户来验证token的有效性。
  • 可以看出,在实际工程中,上述提取token、解密、查库只要有一个不满足条件就需要中断响应。每次判断操作结果,如果结果不存在则返回对应状态码在写起来繁琐,利用断言可以简化。

http-assert

断言处理库安装

npm install http-assert --save

以登录接口判断用户名是否存在来演示 http-assert 的使用

//routes/admin/index.js
const assert = require("http-assert");
......
const user = await AdminUser.findOne({ username }).select('+password')
assert(user, 422, "用户不存在");
  • assert(user, 422, “用户不存在”);//三个参数分别是 判断条件,不满足条件的状态码,不满足条件的信息。

通过前端接口请求发现http-assert的作用是当不满足设定条件会抛出一个错误,如下

在这里插入图片描述

可以使用express的错误处理中间件来统一处理这样的错误,简化所有接口的判断书写,

//routes/admin/index.js
//统一的异常错误处理,用于捕获http-assert抛出的异常
app.use((error, req, res, next) => {
    //发送使用assert语句传入的状态码和错误提示信息
    res.status(error.statusCode).send({
      message: error.message,
    })
  })

上述代码相互配合相当于

if (!user) {
      res.status(422).send({//如果用户名不存在则返回422状态码和提示信息
        message: "用户不存在"
      })
    }

判断条件太多使用http-assert配合express的错误处理可简化代码。以后只要遇到判断一个东西是否存在,如果不存在就返回一个状态码和相关的提示信息就可以使用http-assert来简化

同理,使用 http-assert 处理 token验证 如下:

const token = (req.headers.authorization || " ").split(" ").pop();//提取请求头中的token pop是将数组最后一个元素取出来
    assert(token, 401, "请提供 jwt token,请先登录");
    const { id } = jwt.verify(token, app.get("secret"))//通过secret解码token
    assert(id, 401, "请先登录");
    req.user = await AdminUser.findById(id); //根据id找用户,验证是否为伪造的token
    assert(req.user, 401, "用户不存在,请先登录")

前后端统一约定 状态码对应的含义,前端根据状态码可以记性处理,比如前端检测到返回的状态码是401,则跳转到登录页面。

抽离为中间件

因为在文件上传和资源操作接口都用到了token的检查,所以可以单独抽离为一个中间件文件,然后导入使用即可

//middleware/Auth.js
module.exports = (options) => { //options是为了以后扩展使用,一般中间件都写成带参返回的函数
  const jwt = require("jsonwebtoken");
  const AdminUser = require("../models/AdminUser.js");
  const assert = require("http-assert");

  return async (req, res, next) => {
    const token = (req.headers.authorization || " ").split(" ").pop();//提取请求头中的token pop是将数组最后一个元素取出来
    assert(token, 401, "请提供 jwt token,请先登录");
    const { id } = jwt.verify(token, req.app.get("secret"))//通过secret解码token,注意这里的req.app 等于 express实例app
    assert(id, 401, "请先登录");
    req.user = await AdminUser.findById(id); //根据id找用户,验证是否为伪造的token
    assert(req.user, 401, "用户不存在,请先登录")
    await next();
  };
}
  • 作为一个函数导出的好处是可自定义配置,入参options预留配置的入口。

使用时直接引用即可

//routes/admin/index.js
  app.use("/admin/api/rest/:resource", authMiddleware(), resourceMiddleware(), router)
  • 将自定义的router挂载到express的app实例上,同时配置两个中间件。
  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 10
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值