14. 项目后端API开发

本章将开发上一章介绍的项目,并且会逐一实现所有的后端功能。

另外,本章将使用之前介绍过的Postman进行接口API的请求测试。

本章涉及的知识点如下:

  • 开发一个完整的小型项目后端;

  • Node.js与MySQL及Redis的连接和使用;

  • 在项目中编写完成需求的接口。

14.1. 1.开发前的准备工作

本节使用Express新建项目,并且配置好数据库连接,为之后的API开发做好准备。

注意:在使用Express开发时,除非使用第三方工具,否则Express不会自动根据更改进行热更新,而需要手动重启服务器才能看到更改后的内容。

14.2. 2.初始化项目

首先保证本机已经安装了Express,使用如下命令生成一个新的项目:

$ express --no-view server

本项目中所有的后端内容都使用接口方式,不需要任何的模板引擎,因此使用–no- view参数初始化一个不需要模板引擎的项目。

根据提示进入项目文件夹,使用如下命令安装完整的项目依赖包并尝试启动,效果如图

$ cd server
$ cnpm install
$ SET DEBUG=server:* & npm start

14.3. 3.连接数据库

使用如下命令安装Redis依赖包,Redis的依赖包会自动添加到Express项目中,并且可以在Express中引用。

$ npm install redis -save

注意:如果使用的是Express 5.0及以上版本,部分写法可能出现变动,根据官方文档进行微调即可。在Node.js中,Redis包被编写为支持异步的形式,该包提供了基本的数据库操作,这里需要在该项目文件中进行统一处理。首先在项目文件夹中新建config文件夹,用来存放所有的配置文件,然后进行以下配置操作:

(1)在config文件夹中新建JavaScript文件,命名为db.js,用于存放Redis数据库的配置。代码如下:

exports.redisConfig = {host: '192.168.1.107', port: '6379', password: "123456",ttl: 5 * 60 * 1000}

说明:配置文件不一定必须要使用JavaScript文件形式,也可以使用专门用于存储配置的其他格式,引用时注意编写文件格式相应的解析。

(2)在项目文件的根目录下新建util文件夹,放置所有工具的JavaScript方法,数据库连接方法也存放在该文件夹中。

(3)新建一个redisDB.js文件,在该文件中连接数据库,并且对数据库提供的方法进行一些改写和封装。需要注意的是,对于Redis,我们尽可能只使用两个相关的操作方法,一个是set,用于数据的存储和改变,另一个是get,用于数据的获取。redisDB.js文件的代码如下:

let redis = require("redis");
//获取到数据库的配置
const {redisConfig} = require("../config/db")
//获取redis连接
const redis_client = redis.createClient(redisConfig);
//连接成功
redis_client.on("connect", () => {
    console.log("连接成功")
})
//错误处理
redis_client.on("error", (err) => {
    console.log(err);
});
redis = {};

//根据模式获得全部键
keys = async (cursor, re, count) => {
    let getTempKeys = await new Promise((resolve) => {
        //从连接中获得到该值,并且返回
        redis_client.scan([cursor, "MATCH", re, "COUNT", count], (err, res) => {
            console.log(err)
            return resolve(res);
        });
    });
    return getTempKeys;
}
redis.scan = async (re, cursor = 0, count = 100) => {
    return await keys(cursor, re, count)
}

//set该值进入数据库
redis.set = (key, value) => {
    // 将所有对象转换为Json字符串进行保存
    // 需要注意的是如果该字符串过大,可能会导致性能下降
    value = JSON.stringify(value);
    return redis_client.set(key, value, (err) => {
        if (err) {
            console.log(err);
        }
    });
};
// 获得text,在get时可以使用then调用
text = async (key) => {
    let getTempValue = await new Promise((resolve) => {
        //从连接中获得到该值,并且返回
        redis_client.get(key, (err, res) => {
            return resolve(res);
        });
    });
    //将该值转化为本身的对象,并且返回
    getTempValue = JSON.parse(getTempValue)
    return getTempValue;
}
//返回获得的值
redis.get = async (key) => {
    return await text(key);
}

//设置key的过期时间
redis.expire = (key, ttl) => {
    redis_client.expire(key, parseInt(ttl))
}

//获取自增id
id = async (key) => {
    console.log("查找" + key)
    let id = await new Promise((resolve => {
        redis_client.incr(key, (err, res) => {
            console.log(res)
            return resolve(res)
        })
    }))
    console.log(id)
    return id
}
redis.incr = async (key) => {
    return await id(key)
}
//有序集合
//新增有序集合(键名,成员和分值)
redis.zadd = (key, member, num) => {
    member = JSON.stringify(member)
    redis_client.zadd(key, num, member, (err) => {
        if (err) {
            console.log(err)
        }
    })
}
//获取一定范围内的元素
tempData = async (key, min, max) => {
    let tData = await new Promise((resolve => {
        redis_client.zrevrange([key, min, max, 'WITHSCORES'], (err, res) => {
            return resolve(res)
        })
    }))
    //同时获得了分值,所以需要进行转化为对象
    let oData = []
    //构造
    for (let i = 0; i < tData.length; i = i + 2) {
        console.log(tData[i])
        oData.push({member: JSON.parse(tData[i]), score: tData[i + 1]})
    }
    return oData
}

redis.zrevrange = async (key, min = 0, max = -1) => {
    return tempData(key, min, max)
}

//有序集合的自增操作
redis.zincrby = (key, member, NUM = 1) => {
    member = JSON.stringify(member)
    redis_client.zincrby(key, NUM, member, (err) => {
        if (err) console.log(err)
    })
}

//有序集合通过member获取其score值
tempZscore = async (key, member) => {
    member = JSON.stringify(member)
    return await new Promise((resolve => {
        redis_client.zscore(key, member, (err, res) => {
            console.log(res)
            return resolve(res)
        })
    }))
}
redis.zscore = async (key, member) => {
    return tempZscore(key, member)
}

module.exports = redis;

需要对用户的Token进行时间控制,不能让其一直有效,否则只要获取了该Token的人都可以模拟用户进行操作。

//设置key的过期时间
redis.expire = (key, ttl) => {
    redis_client.expire(key, parseInt(ttl))
}

一些基本的ID应当考虑使用自增变量,这里封装一个Redis的自增ID获取方法,代码如下:

//获取自增id
id = async (key) => {
    console.log("查找" + key)
    let id = await new Promise((resolve => {
        redis_client.incr(key, (err, res) => {
            console.log(res)
            return resolve(res)
        })
    }))
    console.log(id)
    return id
}
redis.incr = async (key) => {
    return await id(key)
}

虽然只使用k-v形式的JSON字符串,但是对于需要排序的内容,k-v形式过于烦琐,因此需要使用Redis中的有序序列进行一些数据的存储(类似于阅读量和热点文章等)。

在某些情况下会使用到Redis中的有序集合这个结构,例如在文章的阅读数量和热点获取时需要排序。

有序集合结构基于k-v基础,v中有一个member对象,对应一个score(分值),通过score可实现排序。如果读者不理解该结构,可以查阅有关资料。有序集合代码如下:

//有序集合
//新增有序集合(键名,成员和分值)
redis.zadd = (key, member, num) => {
    member = JSON.stringify(member)
    redis_client.zadd(key, num, member, (err) => {
        if (err) {
            console.log(err)
        }
    })
}
//获取一定范围内的元素
tempData = async (key, min, max) => {
    let tData = await new Promise((resolve => {
        redis_client.zrevrange([key, min, max, 'WITHSCORES'], (err, res) => {
            return resolve(res)
        })
    }))
    //同时获得了分值,所以需要进行转化为对象
    let oData = []
    //构造
    for (let i = 0; i < tData.length; i = i + 2) {
        console.log(tData[i])
        oData.push({member: JSON.parse(tData[i]), score: tData[i + 1]})
    }
    return oData
}

redis.zrevrange = async (key, min = 0, max = -1) => {
    return tempData(key, min, max)
}

//有序集合的自增操作
redis.zincrby = (key, member, NUM = 1) => {
    member = JSON.stringify(member)
    redis_client.zincrby(key, NUM, member, (err) => {
        if (err) console.log(err)
    })
}

//有序集合通过member获取其score值
tempZscore = async (key, member) => {
    member = JSON.stringify(member)
    return await new Promise((resolve => {
        redis_client.zscore(key, member, (err, res) => {
            console.log(res)
            return resolve(res)
        })
    }))
}
redis.zscore = async (key, member) => {
    return tempZscore(key, member)
}

module.exports = redis;

注意:为了方便读者理解,对数据库的操作基本没有采用非JSON格式,但在真正的项目中,频繁地在代码中修改JSON对象并不适宜,采用Redis提供的散列或队列等结构效果会更好。

同时,为了使代码和数据逻辑更加清晰和简单,程序中没有采用事务等形式,而全部采用Redis的基本命令进行组合。在实际项目中,例如增加文章,同时需要对类型、标签和排序进行修改,这些数据库的操作都应当在同一个事务中,如果执行任意一个操作失败,将导致整个操作失败。

14.4. 4.配置服务应用列表

(1)配置访问列表。在config文件夹中新建app.js文件,配置允许访问的应用名称,代码如下:

exports.ALLOW_APP = ['book']
exports.NAME = 'server'

(2)在前端传递一个代表该应用的参数,该参数存在于路径或post参数中,这样路径会显得有点“难看”,所以传递时可以将该参数附带在请求的头部。

在传递时,将该参数命名为fapp,也就是说,当请求头中的fapp字段为book字符串时,符合要求。在Express中,通过如下代码获取该参数:

// 获取所有的header参数
console.log(req.headers)

// 获取应用传递的参数
req.headers.fapp

(3)编写用户状态判定中间件。

所有的路由控制前都应当有用户是否处于登录状态的判断和区分,Express的中间件非常适合完成在访问路由时进行统一的用户状态判定。

中间件可以理解为一个独立于主要功能逻辑的代码块,用于实现一些附加的功能,可以在主要逻辑处理之前或处理之后进行访问,类似于Vue.js中的“守卫”。

在util文件夹中编写middleware.js文件,用于存放用户状态判定。该中间件实现的功能是对所有的用户请求进行头部判定,如果符合条件,则继续执行,如果不符合条件,则通过res.json返回一个错误。middleware.js文件的代码如下:

const {ALLOW_APP} = require('../config/app')
const util = require('./common')

exports.checkAPP = (req, res, next) => {
    console.log(req.headers)
    if (!ALLOW_APP.includes(req.headers.fapp)) {
        res.json(util.getReturnData(500, "来源不正确"))
    } else {
        next()
    }
}

中间件可以使用next()对象进行下一步操作,此时的项目需求应当是在所有的路由头部执行该中间件,因此只有条件通过next()之后,才会执行主要的业务逻辑。

上述代码使用了util.js中的一个创建JSON格式化串的方法,可以在util文件夹中新建common.js文件,用于存放一些通用的方法或验证内容。代码如下:

let util = {}
util.getReturnData = (code, message = '', data = []) => {
    //保证数据格式
    if (!data) {
        data = []
    }
    return {code: code, message: message, data: data}
}

//转化为格式化时间
util.getLocalDate = (t) => {
    let date = new Date(parseInt(t))
    return date.getFullYear() + "-" + (parseInt(date.getMonth()) + 1) + "-" + date.getDate() + " " + date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds();
}

module.exports = util

注意:箭头函数不需要花括号及显式的return,但为了统一格式,本书使用了显式的return。

(4)引入中间件,因为所有对用户的请求都需要该中间件验证,所以直接在app.js中引入并使用。更改后的app.js代码如下:

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
const {checkAPP} = require("./util/middleware");

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

//
app.use('/', checkAPP,indexRouter);
app.use('/users', usersRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

(5)编写一个测试路由,修改router文件夹中的index.js文件,修改后的代码如下:

var express = require('express');
var router = express.Router();
const util = require('../util/common')

// 获取footer显示内容
router.get('/getFooter', function (req,res,next) {
  res.json(util.getReturnData(0,'success'))
});

也就是说,当访问该路由http://localhost:3000/getFooter时,首先进行请求头的验证,只有验证成功了,才能接着执行路由对应的业务逻辑。

(5)使用Postman进行测试,如果没有增加任何请求头,则会返回一个错误信息提示

Postman可以在请求下方的Headers选项卡中填写任意的头部信息,该信息会同时发送给服务器端。

例如,在Headers中增加一个fapp=book,并且确定其处于勾选状态.请求之后,请求成功。