從零搭建 Node.js 企業級 Web 服務器(五):數據庫訪問

模型層與持久化

回顧 從零搭建 Node.js 企業級 Web 服務器(一):接口與分層,一塊完整的業務邏輯是由視圖層、控制層、服務層、模型層共同定義與實現的,控制層與服務層實現了業務處理過程,模型層定義了業務實體並以 對象-關係映射 訪問數據庫提供持久化能力。css

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 安裝命令:html

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

使用 sequelize 須要安裝對應數據庫的驅動,本章使用 sqlite 做爲底層數據庫,執行驅動模塊 sqlite3 的安裝命令:node

$ # 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 安裝命令:mysql

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

初始化模型層

如今配置 sequelize-cli 而後靈活使用 sequelize-cli 初始化模型層:git

// .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.jsgithub

// 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/configsql

$ # 將 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',
+  },
+};

新增模型層店鋪的業務實體定義:docker

$ # 生成店鋪 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.jsnpm

// ...
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

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

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 服務器(五):數據庫訪問

相關文章
相關標籤/搜索