从零搭建 Node.js 企业级 Web 服务器(十一):定时任务

配置定时任务

定时任务也就是由时间触发的执行过程,属于很常见的业务逻辑。Unix 在早期版本就提供了定时任务调度模块 Cron,并在各类 Linux 系统上沿用至今。Cron 的配置文件 crontab 具有全面却清晰的格式,能够解决大多数场景下的定时任务配置问题,企业级服务器可以使用类 crontab 的格式灵活配置的各种定时任务逻辑,以下为 crontab 的格式:

# Example of job definition:
# .---------------- minute (0 - 59)
# |  .------------- hour (0 - 23)
# |  |  .---------- day of month (1 - 31)
# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# |  |  |  |  |
# *  *  *  *  * user-name  command to be executed

本章将基于上一章已完成的工程 licg9999/nodejs-server-examples - 10-log 通过 node-schedule 以类似 crontab 的方式配置定时任务,检测可能含有网络攻击的店铺信息并通过 nodemailer 将可疑店铺信息邮件发送给管理员。在工程根目录执行 node-schedule 与 nodemailer 的安装命令:

$ yarn add node-schedule nodemailer # 本地安装 node-schedule、nodemailer
# ...
info Direct dependencies
├─ node-schedule@1.3.2
└─ nodemailer@6.4.11
# ...

网络攻击巡检

现在实现对网络攻击信息的定时检测与报警的逻辑。先补充服务层逻辑:

// src/services/shop.js
const { Shop } = require('../models');

class ShopService {
  async init() {}

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

    return await Shop.findAll({
      offset: pageIndex * pageSize,
      limit: pageSize,
+      where,
      logging,
    });
  }
  // ...
}
// ...
// src/services/mail.js
const { promisify } = require('util');
const nodemailer = require('nodemailer');
const { mailerOptions } = require('../config');

class MailService {
  mailer;

  async init() {
    this.mailer = nodemailer.createTransport(mailerOptions);
    await promisify(this.mailer.verify)();
  }

  async sendMail(params) {
    return await this.mailer.sendMail({
      from: mailerOptions.auth.user,
      ...params,
    });
  }
}

let service;
module.exports = async () => {
  if (!service) {
    service = new MailService();
    await service.init();
  }
  return service;
};
// src/config/index.js
const merge = require('lodash.merge');
const logger = require('../utils/logger');
const { logging } = logger;

const config = {
  // 默认配置
  default: {
    // ...
+    mailerOptions: {
+      host: 'smtp.126.com',
+      port: 465,
+      secure: true,
+      logger: logger.child({ type: 'mail' }),
+      auth: {
+        user: process.env.MAILER_USER,
+        pass: process.env.MAILER_PASS,
+      },
+    },
  },
  // ...
};
// ...
# .env.local
GITHUB_CLIENT_ID='b8ada004c6d682426cfb'
GITHUB_CLIENT_SECRET='0b13f2ab5651f33f879a535fc2b316c6c731a041'
+
+MAILER_USER='ht_nse@126.com'
+MAILER_PASS='CAEJHSTBWNOKHRVL'

注意由于应用节点可能不止 1 个,执行巡检时将使用分布式锁限制执行节点数量以避免重复报警,这里借助数据库来实现分布式锁:

$ # 生成定时任务锁的 model 文件与 schema 迁移文件
$ yarn sequelize model:generate --name scheduleLock --attributes name:string,counter:integer

$ # 将 src/models/schedulelock.js 命名为 src/models/scheduleLock.js
$ mv src/models/schedulelock.js src/models/scheduleLock.js

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

调整 src/models/scheduleLock.jssrc/models/migrate/20200801120113-create-schedule-lock.js

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

module.exports = (sequelize, DataTypes) => {
  class scheduleLock 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
    }
  }
  scheduleLock.init(
    {
      name: DataTypes.STRING,
      counter: DataTypes.INTEGER,
    },
    {
      sequelize,
      modelName: 'ScheduleLock',
      tableName: 'schedule_lock',
    }
  );
  return scheduleLock;
};
// src/models/migrate/20200801120113-create-schedule-lock.js
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('schedule_lock', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER,
      },
      name: {
        type: Sequelize.STRING,
      },
      counter: {
        type: Sequelize.INTEGER,
      },
      created_at: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updated_at: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('schedule_lock');
  },
};

然后写入巡检逻辑:

$ mkdir src/schedules # 新建 src/schedules 存放定时任务

$ tree src -L 1       # 展示 src 目录内容结构
src
├── config
├── controllers
├── middlewares
├── models
├── moulds
├── schedules
├── server.js
├── services
└── utils
// src/schedules/inspectAttack.js
const { basename } = require('path');
const schedule = require('node-schedule');
const { sequelize, ScheduleLock, Sequelize } = require('../models');
const mailService = require('../services/mail');
const shopService = require('../services/shop');
const escapeHtmlInObject = require('../utils/escape-html-in-object');
const logger = require('../utils/logger');
const { Op } = Sequelize;

// 当前任务的锁名称
const LOCK_NAME = basename(__dirname);
// 锁的最长占用时间
const LOCK_TIMEOUT = 15 * 60 * 1000;
// 分布式任务并发数
const CONCURRENCY = 1;
// 报警邮件发送对象
const MAIL_RECEIVER = 'licg9999@126.com';

class InspectAttack {
  mailService;
  shopService;

  async init() {
    this.mailService = await mailService();
    this.shopService = await shopService();

    // 每到 15 分时巡检一次
    schedule.scheduleJob('*/15 * * * *', this.findAttackedShopInfoAndSendMail);
  }

  findAttackedShopInfoAndSendMail = async () => {
    // 上锁
    const lockUpT = await sequelize.transaction();
    try {
      const [lock] = await ScheduleLock.findOrCreate({
        where: { name: LOCK_NAME },
        defaults: { name: LOCK_NAME, counter: 0 },
        transaction: lockUpT,
      });

      if (lock.counter >= CONCURRENCY) {
        if (Date.now() - lock.updatedAt.valueOf() > LOCK_TIMEOUT) {
          lock.counter--;
          await lock.save({ transaction: lockUpT });
        }
        await lockUpT.commit();
        return;
      }

      lock.counter++;
      await lock.save({ transaction: lockUpT });
      await lockUpT.commit();
    } catch (err) {
      logger.error(err);
      await lockUpT.rollback();
      return;
    }

    try {
      // 寻找异常数据
      const shops = await this.shopService.find({
        pageSize: 100,
        where: {
          name: { [Op.or]: [{ [Op.like]: '<%' }, { [Op.like]: '%>' }] },
        },
      });

      // 发送报警邮件
      if (shops.length) {
        const subject = '安全警告,发现可疑店铺信息!';
        const html = `
  <div>以下是服务器巡检发现的疑似含有网络攻击的店铺信息:</div>
  <pre>
  ${shops
    .map((shop) => JSON.stringify(escapeHtmlInObject(shop), null, 2))
    .join('\n')}
  </pre>`;
        await this.mailService.sendMail({ to: MAIL_RECEIVER, subject, html });
      }
    } catch {}

    // 解锁
    const lockDownT = await sequelize.transaction();
    try {
      const lock = await ScheduleLock.findOne({
        where: { name: LOCK_NAME },
        transaction: lockDownT,
      });
      if (lock.counter > 0) {
        lock.counter--;
        await lock.save({ transaction: lockDownT });
      }
      await lockDownT.commit();
    } catch {
      await lockDownT.rollback();
    }
  };
}

module.exports = async () => {
  const s = new InspectAttack();
  await s.init();
};
// src/schedules/index.js
const inspectAttackSchedule = require('./inspectAttack');

module.exports = async function initSchedules() {
  await inspectAttackSchedule();
};
// src/server.js
const express = require('express');
const { resolve } = require('path');
const { promisify } = require('util');
const initMiddlewares = require('./middlewares');
const initControllers = require('./controllers');
+const initSchedules = require('./schedules');
const logger = require('./utils/logger');

const server = express();
const port = parseInt(process.env.PORT || '9000');
const publicDir = resolve('public');
const mouldsDir = resolve('src/moulds');

async function bootstrap() {
  server.use(await initMiddlewares());
  server.use(express.static(publicDir));
  server.use('/moulds', express.static(mouldsDir));
  server.use(await initControllers());
  server.use(errorHandler);
+  await initSchedules();
  await promisify(server.listen.bind(server, port))();
  logger.info(`> Started on port ${port}`);
}
// ...

查看报警

在新增两个含有网络攻击的店铺信息之后,即可在分钟数为 15 的倍数时收到一则警告邮件:

6b237b86e0f827754b34a9218855c44e3ba7dc1b.jpg

5b65bc396739366f0dd8d89b023b2097fb6045f7.jpg

本章源码

licg9999/nodejs-server-examples - 11-schedule

更多阅读

从零搭建 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 服务器(十五):总结与展望

认真写点好代码。

2.2k 声望
1.1k 粉丝
0 条评论
推荐阅读
再见了 Redux、Recoil、MobX、Zustand、Jotai 还有 Valtio,状态管理还可以这样做?
坚持在一线写前端代码大概有七八年了,写过一些项目,有过一些反思,越来越确信平日里一直用得心安理得某些的东西也许存在着问题,比如:在 状态管理 上一直比较流行的实践 🙏,所以试着分享出来探讨一下。

乌柏木3阅读 630

ESlint + Stylelint + VSCode自动格式化代码(2023)
安装插件 ESLint,然后 File -&gt; Preference-&gt; Settings(如果装了中文插件包应该是 文件 -&gt; 选项 -&gt; 设置),搜索 eslint,点击 Edit in setting.json

谭光志34阅读 20.8k评论 9

安全地在前后端之间传输数据 - 「3」真的安全吗?
在「2」注册和登录示例中,我们通过非对称加密算法实现了浏览器和 Web 服务器之间的安全传输。看起来一切都很美好,但是危险就在哪里,有些人发现了,有些人嗅到了,更多人却浑然不知。就像是给门上了把好锁,还...

边城32阅读 7.3k评论 5

封面图
涨姿势了,有意思的气泡 Loading 效果
今日,群友提问,如何实现这么一个 Loading 效果:这个确实有点意思,但是这是 CSS 能够完成的?没错,这个效果中的核心气泡效果,其实借助 CSS 中的滤镜,能够比较轻松的实现,就是所需的元素可能多点。参考我们...

chokcoco24阅读 2.3k评论 3

你可能不需要JS!CSS实现一个计时器
CSS现在可不仅仅只是改一个颜色这么简单,还可以做很多交互,比如做一个功能齐全的计时器?样式上并不复杂,主要是几个交互的地方数字时钟的变化开始、暂停操作重置操作如何仅使用 CSS 来实现这样的功能呢?一起...

XboxYan25阅读 1.7k评论 1

封面图
在前端使用 JS 进行分类汇总
最近遇到一些同学在问 JS 中进行数据统计的问题。虽然数据统计一般会在数据库中进行,但是后端遇到需要使用程序来进行统计的情况也非常多。.NET 就为了对内存数据和数据库数据进行统一地数据处理,发明了 LINQ (L...

边城17阅读 2k

封面图
过滤/筛选树节点
又是树,是我跟树杠上了吗?—— 不,是树的问题太多了!🔗 相关文章推荐:使用递归遍历并转换树形数据(以 TypeScript 为例)从列表生成树 (JavaScript/TypeScript) 过滤和筛选是一个意思,都是 filter。对于列表来...

边城18阅读 7.9k评论 3

封面图

认真写点好代码。

2.2k 声望
1.1k 粉丝
宣传栏