从零搭建 Node.js 企业级 Web 服务器(五):数据库访问

模型层与持久化

回顾 从零搭建 Node.js 企业级 Web 服务器(一):接口与分层,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,控制层与服务层实现了业务处理过程,模型层定义了业务实体并以 对象-关系映射 访问数据库提供持久化能力。

9e3f22a75c71473d6ae02e601a10da314d507df0.jpg

对象-关系映射

对象-关系映射 是指在应用程序中的对象与关系型数据库中的数据间建立映射关系以便捷访问数据库的技术,简称 ORM。ORM 的优劣决定了 SQL 执行的高效性与稳定性,进而直接影响服务节点的性能指标,是非常重要的模块。sequelize 是 Node.js 最老牌的 ORM 模块,首个版本发布于 2010 年,维护至今单测覆盖率一直保持在 95%+,值得推荐。考虑到 sequelize 最新大版本 6.x 才发布 1 个月,本文选择了 sequelize 5.x 版本 v5.22.3 作为依赖。在上一章已完成的工程 host1-tech/nodejs-server-examples - 04-exception 的根目录执行 sequelize 安装命令:

$ yarn add 'sequelize@^5.22.3'  # 本地安装 sequelize,使用 5.x 版本
# ...
info Direct dependencies
└─ sequelize@5.22.3
# ...

使用 sequelize 需要安装对应数据库的驱动,本章使用 sqlite 作为底层数据库,执行驱动模块 sqlite3 的安装命令:

$ # sqlite3 会从海外站点下载二进制包,此处设置 sqlite3 国内镜像
$ npm config set node_sqlite3_binary_host_mirror http://npm.taobao.org/mirrors/sqlite3/

$ yarn add sqlite3  # 本地安装 sqlite3
# ...
info Direct dependencies
└─ sqlite3@5.0.0
# ...

另外 sequelize 提供了配套命令行工具 sequelize-cli,可以方便地对模型层业务实体进行管理,执行 sequelize-cli 安装命令:

$ yarn add -D sequelize-cli
# ...
info Direct dependencies
└─ sequelize-cli@6.2.0
# ...

初始化模型层

现在配置 sequelize-cli 然后灵活使用 sequelize-cli 初始化模型层:

// .sequelizerc
const { resolve } = require('path');

const modelsDir = resolve('src/models');

module.exports = {
  config: `${modelsDir}/config`,
  'migrations-path': `${modelsDir}/migrate`,
  'seeders-path': `${modelsDir}/seed`,
  'models-path': modelsDir,
};
$ yarn sequelize init           # 脚本创建 src/models 目录存放模型层逻辑

$ tree -L 3 -a -I node_modules  # 展示除了 node_modules 之外包括 . 开头的全部目录内容结构
.
├── .dockerignore
├── .sequelizerc
├── Dockerfile
├── package.json
├── public
│   ├── 500.html
│   ├── glue.js
│   ├── index.css
│   ├── index.html
│   └── index.js
├── src
│   ├── controllers
│   │   ├── chaos.js
│   │   ├── health.js
│   │   ├── index.js
│   │   └── shop.js
│   ├── middlewares
│   │   ├── index.js
│   │   └── urlnormalize.js
│   ├── models
│   │   ├── config
│   │   ├── index.js
│   │   ├── migrate
│   │   └── seed
│   ├── moulds
│   │   ├── ShopForm.js
│   │   └── yup.js
│   ├── server.js
│   ├── services
│   │   └── shop.js
│   └── utils
│       └── cc.js
└── yarn.lock

美化一下 src/models/index.js

// src/models/index.js
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/config')[env];
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 = require(path.join(__dirname, file))(
      sequelize,
      Sequelize.DataTypes
    );
    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;

调整一下 src/models/config

$ # 将 src/models/config 移动至 src/models/config/index.js
$ mv src/models/config src/models/config.js
$ mkdir src/models/config
$ mv src/models/config.js src/models/config/index.js
// src/models/config/index.js
-{
-  "development": {
-    "username": "root",
-    "password": null,
-    "database": "database_development",
-    "host": "127.0.0.1",
-    "dialect": "mysql"
-  },
-  "test": {
-    "username": "root",
-    "password": null,
-    "database": "database_test",
-    "host": "127.0.0.1",
-    "dialect": "mysql"
-  },
-  "production": {
-    "username": "root",
-    "password": null,
-    "database": "database_production",
-    "host": "127.0.0.1",
-    "dialect": "mysql"
-  }
-}
+module.exports = {
+  development: {
+    dialect: 'sqlite',
+    storage: 'database/index.db',
+    define: {
+      underscored: true,
+    },
+    migrationStorageTableName: 'sequelize_meta',
+  },
+};

新增模型层店铺的业务实体定义:

$ # 生成店铺 model 文件与 schema 迁移文件
$ yarn sequelize model:generate --name Shop --attributes name:string  

$ tree src/models # 展示 src/models 目录内容结构
src/models
├── config
│   └── index.js
├── index.js
├── migrate
│   └── 20200725045100-create-shop.js
├── seed
└── shop.js

美化一下 src/models/shop.js

// src/models/shop.js
const { Model } = require('sequelize');

module.exports = (sequelize, DataTypes) => {
  class Shop extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
    }
  }
  Shop.init(
    {
      name: DataTypes.STRING,
    },
    {
      sequelize,
      modelName: 'Shop',
    }
  );
  return Shop;
};

调整一下 src/models/shop.js

// src/models/shop.js
// ...
module.exports = (sequelize, DataTypes) => {
  // ...
  Shop.init(
    {
      name: DataTypes.STRING,
    },
    {
      sequelize,
      modelName: 'Shop',
+      tableName: 'shop',
    }
  );
  return Shop;
};

美化一下 src/models/migrate/20200725045100-create-shop.js

// src/models/migrate/20200725045100-create-shop.js
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('Shops', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER,
      },
      name: {
        type: Sequelize.STRING,
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('Shops');
  },
};

调整一下 src/models/migrate/20200725045100-create-shop.js

// src/models/migrate/20200725045100-create-shop.js
module.exports = {
  up: async (queryInterface, Sequelize) => {
-    await queryInterface.createTable('Shops', {
+    await queryInterface.createTable('shop', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER,
      },
      name: {
        type: Sequelize.STRING,
      },
-      createdAt: {
+      created_at: {
        allowNull: false,
        type: Sequelize.DATE,
      },
-      updatedAt: {
+      updated_at: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    });
  },
  down: async (queryInterface, Sequelize) => {
-    await queryInterface.dropTable('Shops');
+    await queryInterface.dropTable('shop');
  },
};

准备初始店铺数据:

$ # 生成初始店铺 seed 文件
$ yarn sequelize seed:generate --name first-shop

$ tree src/models # 展示 src/models 目录内容结构
src/models
├── config
│   └── index.js
├── index.js
├── migrate
│   └── 20200725045100-create-shop.js
├── seed
│   └── 20200725050230-first-shop.js
└── shop.js

美化一下 src/models/seed/20200725050230-first-shop.js

// src/models/seed/20200725050230-first-shop.js
module.exports = {
  up: async (queryInterface, Sequelize) => {
    /**
     * Add seed commands here.
     *
     * Example:
     * await queryInterface.bulkInsert('People', [{
     *   name: 'John Doe',
     *   isBetaMember: false
     * }], {});
     */
  },

  down: async (queryInterface, Sequelize) => {
    /**
     * Add commands to revert seed here.
     *
     * Example:
     * await queryInterface.bulkDelete('People', null, {});
     */
  },
};

调整一下 src/models/seed/20200725050230-first-shop.js

// src/models/seed/20200725050230-first-shop.js
module.exports = {
  up: async (queryInterface, Sequelize) => {
-    /**
-     * Add seed commands here.
-     *
-     * Example:
-     * await queryInterface.bulkInsert('People', [{
-     *   name: 'John Doe',
-     *   isBetaMember: false
-     * }], {});
-     */
+    await queryInterface.bulkInsert('shop', [
+      { name: '良品铺子', created_at: new Date(), updated_at: new Date() },
+      { name: '来伊份', created_at: new Date(), updated_at: new Date() },
+      { name: '三只松鼠', created_at: new Date(), updated_at: new Date() },
+      { name: '百草味', created_at: new Date(), updated_at: new Date() },
+    ]);
  },

  down: async (queryInterface, Sequelize) => {
-    /**
-     * Add commands to revert seed here.
-     *
-     * Example:
-     * await queryInterface.bulkDelete('People', null, {});
-     */
+    await queryInterface.bulkDelete('shop', null, {});
  },
};

向数据库写入表格结构与初始数据:

$ mkdir database          # 新建 database 目录存放数据库文件
$ touch database/.gitkeep # 写入 .gitkeep 让目录可以由 git 提交

$ tree -L 1               # 展示当前目录内容结构
.
├── Dockerfile
├── database
├── node_modules
├── package.json
├── public
├── src
└── yarn.lock

$ yarn sequelize db:migrate       # 向数据库写入表格结构
# ...

$ yarn yarn sequelize db:seed:all # 想数据库写入初始数据
# ...

加上数据库访问逻辑

ORM 提供了十分强大且易用的接口,只需对业务层做有限步调整即可实现持久化:

// src/services/shop.js
-// 店铺数据
-const memoryStorage = {
-  '1001': { name: '良品铺子' },
-  '1002': { name: '来伊份' },
-  '1003': { name: '三只松鼠' },
-  '1004': { name: '百草味' },
-};
-
-// 模拟延时
-async function delay(ms = 200) {
-  await new Promise((r) => setTimeout(r, ms));
-}
const { Shop } = require('../models');

class ShopService {
-  async init() {
-    await delay();
-  }
+  async init() {}

  async find({ id, pageIndex = 0, pageSize = 10 }) {
-    await delay();
-
    if (id) {
-      return [memoryStorage[id]].filter(Boolean);
+      return [await Shop.findByPk(id)];
    }

-    return Object.keys(memoryStorage)
-      .slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)
-      .map((id) => ({ id, ...memoryStorage[id] }));
+    return await Shop.findAll({
+      offset: pageIndex * pageSize,
+      limit: pageSize,
+    });
  }

  async modify({ id, values }) {
-    await delay();
-
-    const target = memoryStorage[id];
+    const target = await Shop.findByPk(id);

    if (!target) {
      return null;
    }

-    return Object.assign(target, values);
+    Object.assign(target, values);
+    return await target.save();
  }

  async remove({ id }) {
-    await delay();
-
-    const target = memoryStorage[id];
+    const target = await Shop.findByPk(id);

    if (!target) {
      return false;
    }

-    return delete memoryStorage[id];
+    return target.destroy();
  }

  async create({ values }) {
-    await delay();
-
-    const id = String(
-      1 +
-        Object.keys(memoryStorage).reduce((m, id) => Math.max(m, id), -Infinity)
-    );
-
-    return { id, ...(memoryStorage[id] = values) };
+    return await Shop.create(values);
  }
}

// 单例模式
let service;
module.exports = async function () {
  if (!service) {
    service = new ShopService();
    await service.init();
  }
  return service;
};

访问 http://localhost:9000/ 重新体验店铺管理功能:

6c1317343a3492bd27f25e72428fe14b1d7a00c8.gif

使用容器

先在本地新建 .npmrc 文件,使用国内镜像加速构建:

# .npmrc
registry=http://r.cnpmjs.org/
node_sqlite3_binary_host_mirror=http://npm.taobao.org/mirrors/sqlite3/

改用非 slim 版 Node.js 基础镜像:

-FROM node:12.18.2-slim
+FROM node:12.18.2

WORKDIR /usr/app/05-database
COPY . .
RUN yarn

EXPOSE 9000
CMD yarn start

然后构建镜像并启动容器:

$ # 构建容器镜像,命名为 05-database,标签为 1.0.0
$ docker build -t 05-database:1.0.0 .
# ...
Successfully tagged 05-database:1.0.0

$ # 以镜像 05-database:1.0.0 运行容器,命名为 05-database
$ # 挂载 database 存放数据库文件
$ # 重启策略为无条件重启
$ docker run -p 9090:9000 -v "$PWD/database":/usr/app/05-database/database -d --restart always --name 05-database 05-database:1.0.0

访问 http://localhost:9090/ 可看到与本地运行时一样的数据:

14240ff0d87c9672fe3fd28eb746798c670bed12.jpg

本章源码

host1-tech/nodejs-server-examples - 05-database

更多阅读

从零搭建 Node.js 企业级 Web 服务器(零):静态服务
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
从零搭建 Node.js 企业级 Web 服务器(二):校验
从零搭建 Node.js 企业级 Web 服务器(三):中间件
从零搭建 Node.js 企业级 Web 服务器(四):异常处理
从零搭建 Node.js 企业级 Web 服务器(五):数据库访问
从零搭建 Node.js 企业级 Web 服务器(六):会话
从零搭建 Node.js 企业级 Web 服务器(七):认证登录
从零搭建 Node.js 企业级 Web 服务器(八):网络安全
从零搭建 Node.js 企业级 Web 服务器(九):配置项
从零搭建 Node.js 企业级 Web 服务器(十):日志
从零搭建 Node.js 企业级 Web 服务器(十一):定时任务
从零搭建 Node.js 企业级 Web 服务器(十二):远程调用
从零搭建 Node.js 企业级 Web 服务器(十三):断点调试与性能分析
从零搭建 Node.js 企业级 Web 服务器(十四):自动化测试
从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望

阅读 1.1k

推荐阅读