定时任务也就是由时间触发的执行过程,属于很常见的业务逻辑。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
本章将基于上一章已完成的工程 host1-tech/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.js
与 src/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 的倍数时收到一则警告邮件:
host1-tech/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 服务器(十一):定时任务