前言

续上篇文章 Node.js 后端开发笔记后续 - 1 利用 Sequelize-Cli 工具已经完成表结构设计以及数据填充,下面使用 Sequelize 插件库本身的数据模型 model 的查询能力来实现表查询。

Sequelize 连接 MySQL 数据库

Sequelize 连接数据库的核心代码主要就是通过 new Sequelizedatabase, username, password, options) 来实现,其中 options 中的配置选项,除了最基础的 hostport、数据库类型外,还可以设置连接池的连接参数 pool,数据模型命名规范 underscored 等等。

之前使用 cli 工具初始化 models 目录,里面包含数据库表模型入口模块,希望遵循 MySQL 数据库表字段的下划线命名规范,所以,需要全局开启一个 underscore: true 的定义,来使系统中默认的 createdAtupdatedAt 能以下划线的方式,与表结构保持一致。修改下 model/index.js 文件:

const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const configs = require('../config/config.js');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = {
    ...configs[env],
    define: {
        underscored: true,
    }
};
const db = {};
 
let sequelize;
if (config.use_env_variable) {
    sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
    sequelize = new Sequelize(config.database, config.username, config.password, config);
}
 
fs.readdirSync(__dirname).filter(file => {
    return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
}).forEach(file => {
    const model = sequelize['import'](path.join(__dirname, file));
    db[model.name] = model;
});
 
Object.keys(db).forEach(modelName => {
    if (db[modelName].associate) {
        db[modelName].associate(db);
    }
});
 
db.sequelize = sequelize;
db.Sequelize = Sequelize;
 
module.exports = db;

定义数据库业务相关的 model

在项目中我创建个事项清单表设计,根据上诉生成的文件:

module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable('todos', {
      id: {
        type: Sequelize.INTEGER,
        autoIncrement: true,
        primaryKey: true,
        comment: '主键',
      },
      content: {
        type: Sequelize.STRING,
        allowNull: false,
        comment: '事项清单内容描述',
      },
      user_id: {
        type: Sequelize.INTEGER,
        allowNull: false,
        comment: '关联 users 表用户id',
      },
      is_complete: {
        type: Sequelize.BOOLEAN,
        allowNull: false,
        comment: '是否已经完成事项清单',
      },
      created_at: {
        type: Sequelize.DATE,
        comment: '事项清单创建时间',
      },
    });
  },
 
  down: (queryInterface, Sequelize) => {
    return queryInterface.dropTable('todos');
  }
};

迁移导入即可,结合业务所需在 models 目录下继续创建一系列的 model 来与数据库表结构做对应:

├── models                       # 数据库 model
│   ├── index.js                 # model 入口与连接
│   ├── users.js                 # 用户表
│   ├── todos.js                 # 事项清单表

定义用户的数据模型 users

module.exports = (sequelize, DataTypes) => sequelize.define(
    'users',
    {
        id: {
            type: DataTypes.INTEGER,
            primaryKey: true,
            autoIncrement: true,
        },
        name: {
            type: DataTypes.STRING,
            allowNull: false,
        },
        avatar: DataTypes.STRING,
    },
    {
        tableName: 'users',
    }
);

定义事项清单的数据模型 todos

module.exports = (sequelize, DataTypes) => sequelize.define(
    'todos',
    {
        id: {
            type: DataTypes.INTEGER,
            primaryKey: true,
            autoIncrement: true,
        },
        content: {
            type: DataTypes.STRING,
            allowNull: false,
        },
        user_id: {
            type: DataTypes.INTEGER,
            allowNull: false,
        },
        is_complete: {
            type: DataTypes.BOOLEAN,
            allowNull: false,
        },
    },
    {
        tableName: 'todos',
    }
);

实现用户列表接口

简单实现用户列表接口

const Joi = require('@hapi/joi');
const models = require("../models");
 
const GROUP_NAME = 'users';
 
module.exports = [
    {
        method: 'GET',
        path: `/${GROUP_NAME}`,
        options: {
            handler: async (request, h) => {
                // 通过 await 来异步查取数据
                return await models.users.findAll();
            },
            description: '获取用户列表',
            notes: '这是获取用户列表信息展示笔记',
            tags: ['api', GROUP_NAME]
        },
    }
];

隐藏返回列表中不需要的字段

如果项目并不希望 findAll 来将数据表中的所有数据全都暴露出来,比如在查询用户列表时,用户的密码的值,便是特别敏感的数据。 项目可以在 findAll 中加入一个 attributes 的约束,可以是一个要查询的属性(字段)列表,或者是一个 keyincludeexclude 对象的键,比如对于用户表,findAll({ attributes: { exclude: ['password'] } }),就可以排除密码字段的查询露出。

下面隐藏用户的更新时间和创建时间的字段,只显示 id name avatar

const Joi = require('@hapi/joi');
const models = require("../models");
 
const GROUP_NAME = 'users';
 
module.exports = [
    {
        method: 'GET',
        path: `/${GROUP_NAME}`,
        options: {
            handler: async (request, h) => {
                // 通过 await 来异步查取数据
                return await models.users.findAll({
                    attributes: [
                        'id', 'name', 'avatar'
                    ]
                });
            },
            description: '获取用户列表',
            notes: '这是获取用户列表信息展示笔记',
            tags: ['api', GROUP_NAME]
        },
    }
];

再次访问接口,发现功能已经实现了。

列表分页

当数据很多的时候,分页也是个很重要的业务,实现这个业务很简单,同样可以利用 hapi-pagination 插件来完成需求。

npm install hapi-pagination --save # 安装插件

plugins 目录下新增一个 hapi-pagination 的插件。options 的具体配置参数细节说明,参见 hapi-pagination

const hapiPagination = require('hapi-pagination');
 
const options = {
    query: {
        // 按页面划分的资源数量。默认值为25,默认名称为limit
        limit: {
            name: 'limit',
            default: 25
        },
        // 将返回的页面数。默认值为1,默认名称为page
        page: {
            name: 'page',
            default: 1
        },
        // 是否开启分页功能,默认 true
        pagination: {
            name: 'pagination',
            default: true,
            active: true
        },
        // 无效返回结果
        invalid: 'defaults',
    },
    meta: {
        name: 'meta',
        // ... 此处篇幅考虑省略 meta 的相关配置代码,参看章节  github 案例
    },
    results: {
        name: 'results'
    },
    reply: {
        paginate: 'paginate'
    },
    routes: {
        include: [
            '/users'  // 用户列表支持分页特性
        ],
        exclude: []
    }
};
 
module.exports = {
    plugin: hapiPagination,
    options: options,
};

app.js 中注册使用 hapi-pagination

// 引入自定义的 hapi-pagination 插件配置
const pluginHapiPagination = require('./plugins/hapi-pagination');
 
// 注册插件
await server.register([
    // 为系统使用 hapi-swagger
    ...pluginHapiSwagger,
    // 为系统使用 hapi-pagination
    pluginHapiPagination,
]);

GET /users 的接口添加分页的入参校验,同时更新 Swagger 文档的入参契约。考虑到系统中未来会有不少接口需要做分页处理,在 utils/router-helper.js 中增加一个公共的分页入参校验配置:

const Joi = require('@hapi/joi');
 
const paginationDefine = {
    limit: Joi.number().integer().min(1).default(10).description('每页的条目数'),
    page: Joi.number().integer().min(1).default(1).description('页码数'),
    pagination: Joi.boolean().description('是否开启分页,默认为true'),
};
 
module.exports = {paginationDefine};

回到 router/users.js,实现最后的分页配置逻辑。考虑到分页的查询功能除了拉取列表外,还要获取总条目数,Sequelize 提供了 findAndCountAllAPI,来为分页查询提供更高效的封装实现,返回的列表与总条数会分别存放在 rowscount 字段的对象中。

const models = require("../models");
const { paginationDefine } = require('../utils/router-helper');
 
const GROUP_NAME = 'users';
 
module.exports = [
    {
        method: 'GET',
        path: `/${GROUP_NAME}`,
        options: {
            handler: async (request, h) => {
                const { rows: results, count: totalCount } = await models.users.findAndCountAll({
                    attributes: [
                        'id', 'name', 'avatar'
                    ],
                    limit: request.query.libraries,
                    offset: (request.query.page - 1) * request.query.limit,
                });
 
                return {results, totalCount}
            },
            auth: false,
            description: '获取用户列表',
            notes: '这是获取用户列表信息展示笔记',
            tags: ['api', GROUP_NAME],
            validate: {
                query: {
                    ...paginationDefine
                }
            }
        },
    },
];

通过 Swagger 文档工具 http://localhost:3000/documentation 查看用列表的接口调用返回数据,以及调用所需要的参数信息。

关联表数据查询

之前新建个 todos 表,里面包含用户 id 字段,下面尝试利用某个用户 id 去查询事项清单的列表信息,同样也需要支持分页信息展示:

const Joi = require("@hapi/joi");
const models = require("../models");
const { paginationDefine } = require('../utils/router-helper');
 
const GROUP_NAME = 'users';
 
module.exports = [
    {
        method: 'GET',
        path: `/${GROUP_NAME}`,
        options: {
            handler: async (request, h) => {
                const { rows: results, count: totalCount } = await models.users.findAndCountAll({
                    attributes: [
                        'id', 'name', 'avatar'
                    ],
                    limit: request.query.libraries,
                    offset: (request.query.page - 1) * request.query.limit,
                });
 
                return {results, totalCount}
            },
            auth: false,
            description: '获取用户列表',
            notes: '这是获取用户列表信息展示笔记',
            tags: ['api', GROUP_NAME],
            validate: {
                query: {
                    ...paginationDefine
                }
            }
        },
    },
    {
        method: 'GET',
        path: `/${GROUP_NAME}/{userId}/todos`,
        options: {
            handler: async (request, h) => {
                const { rows: results, count: totalCount } = await models.todos.findAndCountAll({
                    // 基于 user_id 的条件查询
                    where: {
                        user_id: request.params.userId
                    },
                    attributes: [
                        'id', 'content', 'is_complete', 'created_at'
                    ],
                    limit: request.query.libraries,
                    offset: (request.query.page - 1) * request.query.limit,
                });
 
                return {results, totalCount}
            },
            auth: false,
            description: '获取某个用户事项清单列表',
            notes: '这是获取某个用户事项清单列表信息展示笔记',
            tags: ['api', GROUP_NAME],
            validate: {
                params: {
                    userId: Joi.number().integer().required().description('用户id'),
                },
                query: {
                    ...paginationDefine
                }
            }
        },
    },
];

同样在 plugins/hapi-pagination.js 添加白名单路由:

//......
    routes: {
        include: [
            '/users',  // 用户列表支持分页特性
            '/users/{user_id}/todos',
        ],
    }
//......

更多关于 models 的操作请查看官方手册 Model 使用

身份验证实现

这里的项目使用当今主流的 JWT 验证身份方案,JWT 全称 JSON Web Token,是为了方便在各系统之间安全地传送 JSON 对象格式的信息,而采用的一个开发标准,基于 RFC 7519 定义。服务器在接收到 JWT 之后,可以验证它的合法性,用户登录与否的身份验证便是 JWT 的使用场景之一。具体原理和设计内容请参考:JSON Web Token 入门教程

基于 JWT 的通用身份验证流程

在实际的项目应用场景中,JWT 的身份验证流程大致如下:

  1. 用户使用用户名密码、或第三方授权登录后,请求应用服务器;
  2. 服务器验证用户信息是否合法;
  3. 对通过验证的用户,签发一个包涵用户 ID、其他少量用户信息(比如用户角色)以及失效时间的 JWT token
  4. 客户端存储 JWT token,并在调用需要身份验证的接口服务时,带上这个 JWT token 值;
  5. 服务器验证 JWT token 的签发合法性,时效性,验证通过后,返回业务数据。

使用 jsonwebtoken 签发 JWT

jsonwebtokenNode.js 生态里用于签发与校验 JWT 的流行插件,这里需要该插件来完成 JWT 字符串的生成签发。

npm i jsonwebtoken --save # 安装插件

根据文档得知 JWT 的签发语法是 jwt.sign(payload, secretOrPrivateKey, [options, callback])。默认的签发算法基于 HS256 (HMAC SHA256),可以在 options 参数的 algorithm 另行修改。JWT 签发规范中的一些标准保留字段比如 expnbfaudsubiss 等都没有默认值,可以一并在 payload 参数中按需声明使用,亦可以在第三个参数 options 中,通过 expiresInnotBeforeaudiencesubjectissuer 来分别赋值,但是不允许在两处同时声明。

下面是一个最简单的默认签发,1 小时后失效。

const jwt = require('jsonwebtoken');
// 签发一条 1 小时后失效的 JWT
const token = jwt.sign(
  {
    foo: 'bar',
    exp: Math.floor(Date.now() / 1000) + (60 * 60),
  },
  'your-secret'
);

实现接口 POST /users/createJWT

实际应用中的 JWT 签发会把便于识别用户的 userId 的信息,签发在 payload 中,并同时给予一个失效时间。继续完善 route/users.js 路由,增加一个 JWT 测试性质的签发接口定义 POST /users/createJWT

const JWT = require('jsonwebtoken');
const env = require('../dotenv');
 
const GROUP_NAME = 'users';
 
module.exports = [
    {
        method: 'post',
        path: `/${GROUP_NAME}/createJWT`,
        options: {
            handler: async (request, h) => {
                const generateJWT = (jwtInfo) => {
                    const payload = {
                        userId: jwtInfo.userId,
                        exp: Math.floor(new Date().getTime() / 1000) + 7 * 24 * 60 * 60,
                    };
                    return JWT.sign(payload, env.JWT_SECRET);
                };
                return generateJWT({
                    userId: 1,
                })
            },
            auth: false, // 约定此接口不参与 JWT 的用户验证,会结合下面的 hapi-auth-jwt 来使用
            description: '用于测试的用户 JWT 签发',
            notes: '这是用于测试的用户 JWT 签发信息展示笔记',
            tags: ['api', 'test'],
        },
    },
];

jwt.sign 的第二个参数 secret 是一个重要的敏感信息,可以通过 .env 的配置 JWT_SECRET 来分离。

Secret 的秘钥签发,可以通过一些在线的 AES 加密工具来生成一串长度 32 或 64 的随机字符串。比如: http://tool.oschina.net/encrypt/ 。太长的字符串会一定程度上影响 jwt 验证的计算效率,所以找寻一个平衡点为宜。

访问 swagger-ui 测试 JWT 签发,可以得到 JWT 的测试签发结果,通过 jwt.iodecode JWT 中的 payload 信息,看能否拿到 userId

hapi-auth-jwt2 接口用户验证

通过 hapi-auth-jwt2 插件,来赋予系统中的部分接口,需要用户登录授权后才能访问的能力。

npm install hapi-auth-jwt2 --save # 安装插件

配置插件 plugins/hapi-auth-jwt2.js

const env = require('../dotenv');
 
const validate = (decoded, request, callback) => {
    let error;
    /*
      接口 POST /users/createJWT 中的 jwt 签发规则
 
      const payload = {
        userId: jwtInfo.userId,
        exp: Math.floor(new Date().getTime() / 1000) + 7 * 24 * 60 * 60,
      };
      return JWT.sign(payload, process.env.JWT_SECRET);
    */
 
    // decoded 为 JWT payload 被解码后的数据
    const { userId } = decoded;
 
    if (!userId) {
        return callback(error, false, userId);
    }
    const credentials = {
        userId,
    };
    // 在路由接口的 handler 通过 request.auth.credentials 获取 jwt decoded 的值
    return callback(error, true, credentials);
};
 
module.exports = (server) => {
    server.auth.strategy('jwt', 'jwt', {
        key: env.JWT_SECRET,
        validate: validate,
    });
    server.auth.default('jwt');
};

app.js 中注册 hapi-auth-jwt2 插件,hapi-auth-jwt2 的注册使用方式与其他插件略有不同,是在插件完成 register 注册之后,通过获取 server 实例后才完成最终的配置,所以,在代码书写上,存在一个先后顺序问题。

const Hapi = require('@hapi/hapi');
const hapiAuthJWT2 = require('hapi-auth-jwt2');
 
// 引入路由
const routesHelloHapi = require('./routes/hello-hapi');
const routesUsers = require('./routes/users');
 
// 引入自定义的 hapi-swagger 插件配置
const pluginHapiSwagger = require('./plugins/hapi-swagger');
// 引入自定义的 hapi-pagination 插件配置
const pluginHapiPagination = require('./plugins/hapi-pagination');
// 引入自定义的 hapi-auth-jwt2 插件配置
const pluginHapiAuthJWT2 = require('./plugins/hapi-auth-jwt2');
 
// 载入环境配置文件
const env = require('./dotenv');
 
const init = async () => {
 
    const server = Hapi.server({
        port: env.SERVER_PORT,
        host: env.SERVER_HOST
    });
 
    // 注册插件
    await server.register([
        // 为系统使用 hapi-swagger
        ...pluginHapiSwagger,
        // 为系统使用 hapi-pagination
        pluginHapiPagination,
        // 为系统使用 hapi-auth-jwt2
        hapiAuthJWT2,
    ]);
 
    // 载入身份验证插件
    pluginHapiAuthJWT2(server);
 
    server.route([
        ...routesHelloHapi,
        ...routesUsers,
    ]);
 
    await server.start();
    console.log('Server running on %s', server.info.uri);
};
 
process.on('unhandledRejection', (err) => {
 
    console.log(err);
    process.exit(1);
});
 
init();

一旦在 app.js 中,引入 hapi-auth-jwt 插件后,所有的接口都默认开启 JWT 认证(接口配置 auth: 'jwt'),需要在接口调用的过程中,在 header 中添加带有 JWTauthorization 的字段。此时通过 Swagger 文档访问先前的 users 任意接口,由于没有传输 JWT ,接口都会返回 401 的错误。

{
    "statusCode": 401,
    "error": "Unauthorized",
    "message": "Missing authentication"
}

如果希望一些特定接口不通过 JWT 验证,可以在 router 中的 options 定义 auth: false 的配置,再通过 Swagger 文档试试对应配置的接口。同步更新 validate 中针对 authorizationheader 入参校验,在 Swagger 文档中也会同步自动更新。

options: {
  validate: {
    headers: Joi.object({
      authorization: Joi.string().required(),
    }).unknown(),
  }
}

迅速重构整理公共的 header 定义

编写 utils/router-helper.js 文件实现公用业务:

const Joi = require('@hapi/joi');
 
const paginationDefine = {
    limit: Joi.number().integer().min(1).default(10).description('每页的条目数'),
    page: Joi.number().integer().min(1).default(1).description('页码数'),
    pagination: Joi.boolean().description('是否开启分页,默认为true'),
};
 
const jwtHeaderDefine = {
    headers: Joi.object({
        authorization: Joi.string().required(),
    }).unknown(),
};
 
module.exports = {paginationDefine, jwtHeaderDefine};

在需要使用到 authorizationheader 配置处只需要使用如下语法即可:

options: {
  validate: {
    ...jwtHeaderDefine
  }
}

handler 中使用 JWT 的获取 userId

plugins/hapi-auth-jwt2.js 会通过 callback(error, true, credentials) 的第三个参数,将 JWT 解码过所需要露出的数据字段与值追加到 request.auth 中,然后在路由 handler 的生命周期中,通过 request.auth.credentials 来获取对应的信息。使用 POST /users/createJWT 来生成一段 JWT。 再通过 routes/hello-hapi.js 的接口 /restricted 做一个实验性验证,修改 plugins/hapi-auth-jwt2.js 内容:

const env = require('../dotenv');
 
const validate = async (decoded, request, h) => {
    let error;
    /*
      接口 POST /users/createJWT 中的 jwt 签发规则
 
      const payload = {
        userId: jwtInfo.userId,
        exp: Math.floor(new Date().getTime() / 1000) + 7 * 24 * 60 * 60,
      };
      return JWT.sign(payload, process.env.JWT_SECRET);
    */
 
    // decoded 为 JWT payload 被解码后的数据
    const { userId } = decoded;
 
    if (!userId) {
        return callback(error, false, userId);
    }
 
    // 验证数字是否有效
    if (!userId) {
        return {isValid: false};
    } else {
        return {isValid: true};
    }
};
 
module.exports = (server) => {
    server.auth.strategy('jwt', 'jwt', {
        key: env.JWT_SECRET,
        validate,
    });
    server.auth.default('jwt');
};

然后编写 /restricted 接口测试验证,编写 routes/hello-hapi.js

const {jwtHeaderDefine} = require('../utils/router-helper');
 
module.exports = [
    {
        method: 'GET',
        path: '/restricted',
        options: {
            handler: (request, h) => {
                const response = h.response({
                    message: '您使用有效的JWT令牌来访问限制接口!'
                });
                response.header("Authorization", request.headers.authorization);
                console.log('输出信息:', request.auth.credentials); // 控制台输出 { userId: 1}
                return 'hello hapi';
            },
            auth: 'jwt',
            description: '用于测试的用户 JWT 签发',
            notes: '这是用于测试的用户 JWT 签发信息展示笔记',
            tags: ['api', 'test'],
            validate: {
                ...jwtHeaderDefine, // 增加需要 jwt auth 认证的接口 header 校验
            }
        },
    },
];

重启项目后使用系统 PowerShell 或者 Bash 终端进行测试:

curl -v -H "Authorization: 控制台输出的Token值" http://localhost:3000/restricted
 
#例如:
curl -v -H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmFtZSI6IkFudGhvbnkgVmFsaWQgVXNlciIsImlhdCI6MTU2Mzk1ODkwNn0.X-YfqA850TVQdV6KyGypEQSzJ_6f5A_-PTzAVM5yobk" http://localhost:3000/restricted

如果返回信息:

$ curl -v -H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmFtZSI6IkFudGhvbnkgVmFsaWQgVXNlciIsImlhdCI6MTU2Mzk1ODkwNn0.X-YfqA850TVQ
dV6KyGypEQSzJ_6f5A_-PTzAVM5yobk" http://localhost:3000/restricted
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /restricted HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.58.0
> Accept: */*
> Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmFtZSI6IkFudGhvbnkgVmFsaWQgVXNlciIsImlhdCI6MTU2Mzk1ODkwNn0.X-YfqA850TVQdV6KyGypEQSzJ_6f5A_-PTzAVM5yobk
>
< HTTP/1.1 200 OK
< authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmFtZSI6IkFudGhvbnkgVmFsaWQgVXNlciIsImlhdCI6MTU2Mzk1ODkwNn0.X-YfqA850TVQdV6KyGypEQSzJ_6f5A_-PTzAVM5yobk
< content-type: application/json; charset=utf-8
< cache-control: no-cache
< content-length: 65
< accept-ranges: bytes
< Date: Wed, 24 Jul 2019 09:02:34 GMT
< Connection: keep-alive
<
* Connection #0 to host localhost left intact
{"message":"您使用有效的JWT令牌来访问限制接口!"}

看到成功结果说明通过了。

JWT 项目运用

如果在项目实战使用大多数都是登录这边,当用户访问登录接口的时候,后端校验用户信息正确后,返回用户基本信息,例如:用户ID、用户权限等级、用户角色等级等等签发 JWT 返回给客户端,这样客户端访问其他接口,例如访问编辑用户信息的接口不需要传参用户ID,因为在 header 头有个 Authorization 字段包含基本信息,大大减少前后端判断工作量。

理解事务的使用场景

例如现在有个创建订单系统,包含 orders 表和 order_goods 表。

下面这个表是 orders 表描述:

字段 字段类型 字段类型
id integer 订单的 ID,自增
user_id integer 用户的 ID
payment_status enum 付款状态

下面这个表是 order_goods 表描述:

字段 字段类型 字段类型
id integer 订单商品的 ID,自增
order_id integer 订单的 ID
goods_id integer 商品的 ID
single_price float 商品的价格
count integer 商品的数量

从表结构的设计关系来看,创建一次订单,依赖于先创建产生一条 orders 表的记录,获得一个 order_id, 然后在 order_goods 表中通过 order_id 插入订单中的每一条商品记录,以最终完成一次完整的订单创建行为。中途若商品记录的插入遇到了失败,则一个订单记录的创建行为便是不完整的,orders 表中却产生了一条数据不完整的垃圾数据。在这样的场景下可以尝试引入事务操作。

数据库中的事务是指单个逻辑所包含的一系列数据操作,要么全部执行,要么全部不执行。在一个事务中,可能会包含开始(start)、提交(commit)、回滚(rollback)等操作,Sequelize 通过 Transaction 类来实现事务相关功能。以满足一些对操作过程的完整性比较高的使用场景。

Sequelize 支持两种使用事务的方法:

  • 托管事务
  • 非托管事务

托管事务基于 Promise 结果链进行自动提交或回滚。非托管事务则交由用户自行控制提交或回滚。

使用托管事务创建订单

handler: async (request, h) => {
    await models.sequelize.transaction((t) => {
        return models.orders.create(
            {user_id: request.auth.credentials.userId}, // 从 JWT 获取用户id
            {transaction: t},
        ).then((order) => {
            const goodsList = [];
            request.payload.goodsList.forEach((item) => {
                goodsList.push(models.order_goods.create({
                    order_id: order.dataValues.id,
                    goods_id: item.goods_id,
                    // 此处单价的数值应该从商品表中反查出写入
                    goods_price: 4.9,
                    count: item.count,
                }));
            });
            return Promise.all(goodsList);
        });
    }).then(() => {
        // 事务已被提交
        return 'success';
    }).catch(() => {
        // 事务已被回滚
        return 'error';
    });
}

无论是托管事务还是非托管事务,只要 sequelize.transaction 中抛出异常,sequelize.transaction 中所有关于数据库的操作都将被回滚。

更多功能请查看官方手册 Transactions

系统监控和记录

在上述文章把后端开发核心重要点描述差不多,大多数系统过程都是这样。当系统上线的时候需要了解系统的状况情况,这时候需要一些插件辅助来得知系统运行情况,hapi 提供的用于检索和打印日志的内置方法非常少,要获得功能更丰富的日志记录体验可以使用 Good 插件,Good 是一个 hapi 插件,用于监视和报告来自主机的各种 hapi 服务器事件以及 ops 信息。它侦听 hapi 服务器实例发出的事件,并将标准化事件推送到流集合中。Good 插件目前有这四个扩展功能: good-squeezegood-console、good-file、good-http。

npm install @hapi/good --save
npm install @hapi/good-squeeze --save
npm install @hapi/good-console --save

配置插件 plugins/hapi-good.js

const hapiGood = require('@hapi/good');
 
const options = {
    ops: {
        interval: 1000
    },
    reporters: {
        myConsoleReporter: [
            {
                module: '@hapi/good-squeeze',
                name: 'Squeeze',
                args: [{ log: '*', response: '*' }]
            },
            {
                module: '@hapi/good-console'
            },
            'stdout'
        ]
    }
};
 
module.exports = {
    plugin: hapiGood,
    options: options,
};

然后在 app.js 引入注册就可以了,在控制台可以看到系统日志信息,当然在 Hapi 文档插件列表还有更多插件,这里就不再详细描述,可以找个合适的体验下。

系统稳定性测试

测试框架,是运行测试的工具。通过它可以为 JavaScript 应用添加测试,从而保证代码的质量。现行的 Javascript 常用流行测试库有 JasmineMochaKarma 等,虽然框架的名称不同,但背后的核心套件却大同小异。

Lab

Lab 库支持 async/await,尽可能保持测试引擎的足够简单,并包含了现代 Node.js 测试框架程序中需要的所有特性。提供了 describeit,以及生命周期的钩子等功能。

Code

Code 库用于提供 expect 断言的相关函数库,code-expectmocha-expect 用法上几乎完全一致。

npm install --save-dev @hapi/lab
npm install --save-dev @hapi/code

然后在项目 test/unit.js 编写实例:

const { expect } = require('@hapi/code');
const { it } = exports.lab = require('@hapi/lab').script();
 
it('returns true when 1 + 1 equals 2', () => {
    expect(1 + 1).to.equal(2);
});

运行终端命令:

$ lab ./test/unit.js
1 tests complete
Test duration: 8 ms
Leaks: No issues

测试接口

'use strict';
 
const Lab = require('@hapi/lab');
const { expect } = require('@hapi/code');
const { afterEach, beforeEach, describe, it } = exports.lab = Lab.script();
const { init } = require('../lib/server');
 
describe('GET /', () => {
    let server;
 
    beforeEach(async () => {
        server = await init();
    });
 
    afterEach(async () => {
        await server.stop();
    });
 
    it('responds with 200', async () => {
        const res = await server.inject({
            method: 'get',
            url: '/'
        });
        expect(res.statusCode).to.equal(200);
    });
});

总结

热重载

上述开发流程大致是这样,继续完善开发过程所遇到的问题,现在有个问题每当编写好文件,还得需要重启服务才能看到改动效果,要实现热加载很简单,可以安装 nodemon 插件完成需求:

npm install --save-dev nodemon

修改 package.json 字段:

"scripts": {
  "dev": "nodemon --inspect ./app.js",
},

更多内容请参考文档:nodemon

已上传到 Github:Api-test