前言

续上篇 Node.js 后端开发笔记 文章时隔几个月没有折腾,趁现在有时间继续完善开发过程内容。开头先把上篇重点内容过一下,毕竟几个月内有些技术发生一些改变。

项目原型是 Node.js 作为后端支持服务器,hapi 作为后端Web服务开发,并且有数据库支持和一些接口约定。在开发项目中要善于使用调试技能,找出项目的问题所在并且解决完善它。

初始化项目

npm init # 初始化项目
npm install @hapi/hapi # 安装hapi

新建服务器(app.js):

'use strict';
 
const Hapi = require('@hapi/hapi');
 
const init = async () => {
 
    const server = Hapi.server({
        port: 3000,
        host: 'localhost'
    });
 
    server.route([{
        method: 'GET',
        path: '/',
        handler: (request, h) => {
            return 'hello hapi'
        }
    }])
 
    await server.start();
    console.log('Server running on %s', server.info.uri);
};
 
process.on('unhandledRejection', (err) => {
 
    console.log(err);
    process.exit(1);
});
 
init();

然后在终端启动:node ./app.js 后,进入 http://localhost:3000 可以访问服务了。

添加 es6 支持

由于之前客户端开发一直使用 es6 开发,所以后端项目也打算支持 es6 开发支持:

npm install --save-dev @babel/core @babel/node @babel/preset-env nodemon # 添加模块

package.json 中的 scripts 字段添加启动命令:

"scripts": {
    "start": "nodemon --exec babel-node ./app.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

新建.babelrc

{
  "presets": [ "@babel/preset-env" ]
}

然后运行 npm run start 即可启动项目。

由于部分插件不支持 es6 编写,故放弃。

优化项目结构

根据上一篇一个完整项目应该是:

├── config                       # 项目配置目录
|   ├── index.js                 # 配置项目中的配置信息
├── models                       # 数据库 model
├── node_modules                 # node.js 的依赖目录
├── plugins                      # 插件目录
├── routes                       # 路由目录
│   ├── hello-world.js           # 测试接口 hello-world
├── utils                        # 工具类相关目录
├── app.js                       # 项目入口文件
├── package.json                 # JS 项目工程依赖库
├── readme.md                    # 项目工程如何被使用的说明手册...

config/index.js 添加配置内容:

module.exports = {
    port: 3000,
    host: 'localhost'
}

routes/hello-hapi.js 添加路由模块:

module.exports = [{
    method: 'GET',
    path: '/',
    handler: (request, h) => {
        return 'hello hapi'
    }
}]

重写 app.js

const Hapi = require('@hapi/hapi');
const config = require('./config');
const routesHelloHapi = require('./routes/hello-hapi');
 
const init = async () => {
 
    const server = Hapi.server({
        port: config.port,
        host: config.host
    });
 
    server.route([...routesHelloHapi]);
 
    await server.start();
    console.log('Server running on %s', server.info.uri);
};
 
process.on('unhandledRejection', (err) => {
 
    console.log(err);
    process.exit(1);
});
 
init();

这样一个基础项目框架出现了,可以在需求上增长的业务需求的设计弹性。但是对于应用程序运行的环境来说,不同的环境往往有不同的配置。例如本地数据库密码和线上的数据库密码是不一致的,并且这些都是敏感数据不得上传到代码托管服务上,解决这个问题也很简单,先新建个 .gitignore 文件,内容下面再说,在新建个文件 .env.example 内容填写:

# .env.expamle
HOST = 127.0.0.1
PORT = 3000

然后复制出一份真实的 .env 文件,来供系统最终使用,填入真实可用的配置信息。并且把 .env 文件名写入 .gitignore 里面,这样不会被 git 上传到服务器上。同样可以使用这里维护规则:Node.gitignore

可以通过 dotenv 插件读取 .env 里面的配置信息,具体配置如下:

npm install dotenv

修改 app.js

const Hapi = require('@hapi/hapi');
const dotenv = require('dotenv');
const config = require('./config');
const routesHelloHapi = require('./routes/hello-hapi');
 
 
dotenv.config('.env');
 
const init = async () => {
 
    const server = Hapi.server({
        port: process.env.PORT,
        host: process.env.HOST
    });
 
    server.route([...routesHelloHapi]);
 
    await server.start();
    console.log('Server running on %s', server.info.uri);
};
 
process.on('unhandledRejection', (err) => {
 
    console.log(err);
    process.exit(1);
});
 
init();

再重启项目就可以了,这样环境参数的配置交由生产环境的运维人员处理,项目的敏感配置数据的安全性可以得到一定程度的保证。

接口约定

接口文档:

使用 hapi-swagger 来达到接口描述文档:

npm install hapi-swagger --save
npm install @hapi/inert --save
npm install @hapi/vision --save

添加插件 plugins/hapi-swagger.js

const Inert = require('@hapi/inert');
const Vision = require('@hapi/vision');
const HapiSwagger = require('hapi-swagger');
const Pack = require('../package');

module.exports = [
    Inert,
    Vision,
    {
        plugin: HapiSwagger,
        options: {
            info: {
                title: 'Test API Documentation',
                version: Pack.version,
            }
        }
    }
]

修改 app.js,注册插件:

const Hapi = require('@hapi/hapi');
const dotenv = require('dotenv');
const config = require('./config');
// 引入路由
const routesHelloHapi = require('./routes/hello-hapi');
// 引入自定义的 hapi-swagger 插件配置
const pluginHapiSwagger = require('./plugins/hapi-swagger');
 
dotenv.config('.env');
 
const init = async () => {
 
    const server = Hapi.server({
        port: process.env.PORT,
        host: process.env.HOST
    });
 
    // 注册插件
    await server.register([
        // 为系统使用 hapi-swagger
        ...pluginHapiSwagger,
    ]);
 
    server.route([...routesHelloHapi]);
 
    await server.start();
    console.log('Server running on %s', server.info.uri);
};
 
process.on('unhandledRejection', (err) => {
 
    console.log(err);
    process.exit(1);
});
 
init();

然后访问 http://127.0.0.1:3000/documentation 看到相关接口文档信息。

接口校验:

Joi 是一种验证模块,与 hapi 一样出自沃尔玛实验室团队。JoiAPI 因其丰富的功能,使得验证数据结构与数值的合规,变得格外容易。安装依赖包:

npm install @hapi/joi --save

这里实例就在下一段展示,更多内容请参考:https://github.com/hapijs/joi

实践业务接口雏形

根据上面已存在的功能,做一个用户类的相关的接口,以及文档描述,编写路由配置(routes/users.js):

const Joi = require('@hapi/joi');
 
const GROUP_NAME = 'users';
 
module.exports = [
    {
        method: 'GET',
        path: `/${GROUP_NAME}`,
        options: {
            handler: async (request, h) => {
                return 'yong';
            },
            description: '获取用户列表',
            notes: '这是获取用户列表信息展示笔记',
            tags: ['api', GROUP_NAME]
        },
    },
    {
        method: 'GET',
        path: `/${GROUP_NAME}/{userId}/info`,
        options: {
            handler: async (request, h) => {
                return '';
            },
            description: '获取某个用户基本信息',
            notes: '注意事项笔记说明',
            tags: ['api', GROUP_NAME],
            validate: {
                params: {
                    userId: Joi.number()
                        .required()
                        .description('用户id'),
                }
            }
        },
    },
]

新建事项清单路由配置(routes/todos.js):

const Joi = require('@hapi/joi');
 
const GROUP_NAME = 'todos';
 
module.exports = [
    {
        method: 'GET',
        path: `/${GROUP_NAME}`,
        options: {
            handler: async (request, h) => {
                return '';
            },
            description: '获取事项清单列表',
            notes: '这是获取事项清单列表信息展示笔记',
            tags: ['api', GROUP_NAME]
        },
    },
    {
        method: 'GET',
        path: `/${GROUP_NAME}/{todo}/info`,
        options: {
            handler: async (request, h) => {
                return '';
            },
            description: '获取某个事项清单基本信息',
            notes: '注意事项笔记说明',
            tags: ['api', GROUP_NAME],
            validate: {
                params: {
                    userId: Joi.number()
                        .required()
                        .description('事项清单id'),
                }
            }
        },
    },
]

app.js里注册路由:

const routesUsers = require('./routes/users');
const routesTodos = require('./routes/todos');
 
//....
    server.route([
        ...routesUsers,
        ...routesTodos
    ]);
//....

然后重启系统,在文档可以看到项目相关接口说明。

数据设计

要实现前端数据展示业务功能,离不开数据库的支持。数据库的连接创建、表结构的初始化、起始数据的填充都是需要解决的基础技术问题。

设计数据表:

创建用户表:

字段 字段类型 字段说明
id integer 用户 ID,自增
name varchar(255) 用户昵称
avatar varchar(255) 用户头像
created_at datetime 记录的创建时间
updated_at datetime 记录的更新时间

MySQL 与 Sequelize

本文数据库环境使用最新版本:

PS C:\Windows\system32> mysql.exe -V
D:\Development\MySQL\bin\mysql.exe  Ver 8.0.17 for Win64 on x86_64 (MySQL Community Server - GPL)

MySQL 数据库是现在后端比较主流的数据库,Sequelize 则是 Node.js 生态中一款知名的基于 promise 数据库 ORM 插件,提供了大量常用数据库增删改查的函数式 API,在实际开发中,大量减少书写冗长的基础数据库查询语句。

Sequelize 支持的数据库有:PostgreSQLMySQLMariaDBSQLiteMSSQL。在使用不同的数据库时候,需要额外安装不同的对应数据库连接驱动,比如本文使用的 MySQL,则依赖于插件 MySQL2

Sequelize-cli

Sequelize 插件的主要应用场景是实际应用开发过程中的代码逻辑层。与其相伴的还有一套 cli 工具,Sequelize-cli,提供了一系列好用的终端指令来完成一些常用的琐碎任务。

npm install --save sequelize-cli
npm install --save sequelize
npm install --save mysql2

安装好相关依赖文件,进行 sequelize 初始化结构:

D:\Projects\NodeHapiDev>sequelize init
 
Sequelize CLI [Node: 10.16.0, CLI: 5.5.0, ORM: 5.10.2]
 
Created "config\config.json"
models folder at "D:\Projects\NodeHapiDev\models" already exists.
Successfully created migrations folder at "D:\Projects\NodeHapiDev\migrations".
Successfully created seeders folder at "D:\Projects\NodeHapiDev\seeders".

如果 sequelize init 执行错误尝试使用 node_modules/.bin/sequelize init

  • config/config.json:配置了开发、测试、生产三个默认的样板环境,里面的变量可以根据前面所说的 .env 进行配置,但由于是 json 文件不支持动态载入,可以改成 config.js 来达到目的,sequelize-cli 也兼容脚本模式的 config.js 的形式。
  • models/index.js:用于定义数据库表结构对应关系的模块目录,sequelize-cli 会在 models 目录中自动生成一个 index.js,该模块会自动读取 config/config.js 中的数据库连接配置,并且动态加载未来在 models 目录中所增加的数据库表结构定义的模块,最终可以方便通过 models.tableName.operations 的形式来展开一系列的数据库表操作行为。
  • migrations/:用于通过管理数据库表结构迁移的配置目录,初始化完成后目录中暂无内容。
  • seeders/:用于在数据库完成 migrations 初始化后,填补一些打底数据的配置目录。初始化完成后目录中暂无内容。

sequelize db:create

根据前文配置了 config/config.js 中的数据库连接信息,分别有开发环境与生产环境两个。执行下面的命令,可以默认使用 development 下的配置,来创建项目数据库。增加例如 --env production,则使用 config/config.js 中的 production 项配置,来完成数据库的创建。

sequelize db:create
# 通过 --env 参数,指定为生产环境创建项目数据库
# sequelize db:create --env production

migrate 数据迁移

sequelize migration:create

数据库被创建完成后,数据表的创建,初学者的时候或许会借助于诸如 navicat 之类的 GUI 工具,通过图形化界面的引导,来完成一张一张的数据库表结构定义。这样的编辑手法能达成目的,但是对于一个持续迭代,长期维护的数据库而言,表结构的调整,字段的新增,回退,缺乏一种可追溯的程序化迁移管理,则会陷入各种潜在人为操作过程中的风险。

Migration 迁移的功能的出现,正是为了解决上述人为操作所不可追溯的管理痛点。Migration 就像使用 Git / SVN 来管理源代码的更改一样,来跟踪数据库表结构的更改。 通过 migration可以将现有的数据库表结构迁移到另一个表结构定义,反之亦然。这些表结构的转换,将保存在数据库的迁移文件定义中,它们描述了如何进入新状态以及如何还原更改以恢复旧状态。

下面新建个 users 表为例,使用 sequelize migration:create 来创建一个迁移文件 create-users-table

sequelize migration:create --name create-users-table
 
D:\Projects\NodeHapiDev>sequelize migration:create --name create-users-table
 
Sequelize CLI [Node: 10.16.0, CLI: 5.5.0, ORM: 5.10.2]
 
migrations folder at "D:\Projects\NodeHapiDev\migrations" already exists.
New migration was created at D:\Projects\NodeHapiDev\migrations\20190723082807-create-users-table.js .

migrations 的目录中,会新增出一个 xxxxxxxxx-create-users-table.js 的迁移文件,xxxxxxxxx 为迁移表文件创建的时间戳,用来备注标记表结构改变的时间顺序。自动生成的文件里,包涵有 updown 两个空函数, up 用于定义表结构正向改变的细节,down 则用于定义表结构的回退逻辑。比如 up 中有 createTable 的建表行为,则 down 中配套有一个对应的 dropTable 删除表行为。

create-users-table 表定义如下:

'use strict';
 
module.exports = {
    up: (queryInterface, Sequelize) => {
        /*
          Add altering commands here.
          Return a promise to correctly handle asynchronicity.
 
          Example:
          return queryInterface.createTable('users', { id: Sequelize.INTEGER });
        */
        return queryInterface.createTable('users', {
            id: {
                type: Sequelize.INTEGER,
                autoIncrement: true,
                primaryKey: true,
            },
            name: {
                type: Sequelize.STRING,
                allowNull: false,
            },
            avatar: Sequelize.STRING,
            created_at: Sequelize.DATE,
            updated_at: Sequelize.DATE,
        });
    },
 
    down: (queryInterface, Sequelize) => {
        /*
          Add reverting commands here.
          Return a promise to correctly handle asynchronicity.
 
          Example:
          return queryInterface.dropTable('users');
        */
        return queryInterface.dropTable('users');
    }
};

sequelize db:migrate

sequelize db:migrate 的命令,可以最终将 migrations 目录下的迁移行为定义,按时间戳的顺序,逐个地执行迁移描述,最终完成数据库表结构的自动化创建。并且在数据库中会默认创建一个名为 SequelizeMeta 的表,用于记录在当前数据库上所运行的迁移历史版本。

sequelize db:migrate

sequelize db:migrate:undo

sequelize db:migrate:undo 则可以按照 down 方法中所定义的规则,回退一个数据库表结构迁移的状态。

sequelize db:migrate:undo

通过使用 sequelize db:migrate:undo:all 命令撤消所有迁移,可以恢复到初始状态。 还可以通过将其名称传递到 --to 选项中来恢复到特定的迁移。

sequelize db:migrate:undo:all --to xxxxxxxxx-create-shops-table.js

向表中追加字段

并非所有的迁移场景都是创建新表,随着业务的不断深入展开,表结构的字段新增,也是常见的需求。例如用户表添加个性别的字段,就得创建一个名叫 add-columns-to-users-table 的迁移迁移文件:

sequelize migration:create --name add-columns-to-users-table

文件内容如下:

'use strict';
 
module.exports = {
    up: (queryInterface, Sequelize) => {
        return queryInterface.addColumn(
            'users',
            'gender',
            {
                type: Sequelize.BOOL
            });
    },
 
    down: (queryInterface, Sequelize) => {
        return queryInterface.removeColumn('users', 'gender');
    }
};

然后再执行 sequelize db:migrate 命令进行对数据库补丁。

seeders 种子数据填充

数据库表结构初始化完,想要向表中初始化一些基础数据可以使用 seeders 来完成,使用方式与数据库表结构迁移相似。

sequelize seed:create

users 表为例,为表中添加基础数据:

sequelize seed:create --name init-users

这个命令将会在 seeders 文件夹中创建一个种子文件。文件名看起来像是 xxxxxxxxx-init-users.js,它遵循相同的 up/down 语义,如迁移文件。编写文件如下:

'use strict';

const timestamps = {
  created_at: new Date(),
  updated_at: new Date(),
};

module.exports = {
  up: (queryInterface, Sequelize) => {
    /*
      Add altering commands here.
      Return a promise to correctly handle asynchronicity.

      Example:
      return queryInterface.bulkInsert('People', [{
        name: 'John Doe',
        isBetaMember: false
      }], {});
    */
    return queryInterface.bulkInsert('users', [
      {
        name: '小王',
        avatar: 'https://cdn.iiong.com/201810/myheader.jpg',
        ...timestamps
      },
      {
        name: '小王',
        avatar: 'https://cdn.iiong.com/201810/myheader.jpg',
        ...timestamps
      },
      {
        name: '小王',
        avatar: 'https://cdn.iiong.com/201810/myheader.jpg',
        ...timestamps
      },
      {
        name: '小王',
        avatar: 'https://cdn.iiong.com/201810/myheader.jpg',
        ...timestamps
      },
    ], {});
  },

  down: (queryInterface, Sequelize) => {
    /*
      Add reverting commands here.
      Return a promise to correctly handle asynchronicity.

      Example:
      return queryInterface.bulkDelete('People', null, {});
    */
    return queryInterface.bulkDelete('users', null, {});
  }
};

sequelize db:seed:all

db:migrate 相似,执行 sequelize db:seed:all ,将向数据库填充 seeders 目录中所有 up 方法所定义的数据。

sequelize db:seed:all

注意: seeders 的执行,不会将状态存储在 SequelizeMeta 表中。

当然也可以通过 --seed 来制定特定的 seed 配置来做填充:

sequelize db:seed --seed xxxxxxxxx-init-users.js

sequelize db:seed:undo

Seeders 所填充的数据,也与迁移的 db:migrate:undo 相仿,只是不会进入 SequelizeMeta 记录。两个可用的命令如下,很简单,不再赘述:

# 撤销所有的种子
sequelize db:seed:undo:all

# 撤销指定的种子
sequelize db:seed:undo --seed XXXXXXXXXXXXXX-demo-users.js

总结

以上内容利用 sequelize-cli 完成了数据库的创建、迁移、填充。

已上传到 Github:Api-test