使用BullMQ实现异步验证短信与邮件发送
学习目标
- 使用BullMQ+Redis构建消息队列
- 使用腾讯云SDK通过队列异步发送短信
- 使用Nodemailer通过队列异步发送邮件
- 使用email-templates制作邮件模板并整合Nodemailer
流程图
发信队列流程图
业务流程图
预装类库
在开始编码之前请安装以下类库
由于chalk5和find-up6需要使用esm,我们当前的应用没有使用esm导致无法兼容,所以装老版本即可
~ pnpm add @nestjs/bullmq bullmq chalk@^4.1.2 dotenv email-templates find-up@5 nodemailer tencentcloud-sdk-nodejs ioredis
~ pnpm add @types/nodemailer @types/email-templates -D
文件结构
把原来的src/core/helpers.ts
抽出来放到src/helpers
目录中,否则会因为循环引用导致我们后面的env
函数无法使用
创建一个assets
目录用于存放静态文件,我们这节课只用来存放邮件模板
新的文件结构如下
src
├── app.module.ts
├── assets
│ └── emails # 邮件模板
│ ├── registration
│ └── reset-password
├── config
│ ├── app.config.ts
│ ├── database.config.ts
│ ├── index.ts
│ ├── queue.config.ts # bullmq消息队列配置
│ ├── sms.config.ts # 短信发送配置
│ ├── smtp.config.ts # smtp邮件发送配置
│ └── user.config.ts
├── helpers # 辅助函数集合
│ ├── constants.ts # 函数常量
│ ├── data.ts # 数据类函数
│ ├── env.ts # 环境类函数
│ ├── index.ts
│ ├── time.ts # 时间函数
│ ├── types.ts # 函数类型
│ └── utils.ts # 工具类函数
├── main.ts
└── modules
├── content
├── core
└── user
核心模块
src/modules/core
├── constants.ts
├── constraints
├── core.module.ts
├── crud
├── decorators
├── filters
├── providers
├── services
│ ├── index.ts
│ ├── sms.service.ts # 短信发送提供者
│ └── smtp.service.ts # 邮件发送提供者
└── types.ts
用户模块
src/modules/user
├── constants.ts
├── controllers
│ ├── account.controller.ts # 已登录账户操作
│ ├── auth.controller.ts # 未登录用户的Auth操作
│ ├── captcha.controller.ts # 验证码操作
│ ├── index.ts
│ └── user.controller.ts # 用户管理操作
├── decorators
├── dtos
│ ├── account.dto.ts # 已登录账户操作请求验证
│ ├── auth.dto.ts # 未登录用户的Auth操作请求验证
│ ├── captcha.dto.ts # 验证码类操作的请求验证
│ ├── guest.dto.ts # 基础验证类
│ ├── index.ts
│ └── manage.dto.ts # 用户管理操作的请求验证
├── entities
├── guards
├── helpers.ts
├── repositories
├── services
│ ├── ...
│ ├── captcha # 消息队列服务
│ │ ├── queue.service.ts # 添加队列和任务以及初始化消费者
│ │ └── worker.service.ts # 执行任务
│ ├── index.ts
├── strategies
├── subscribers
├── types.ts
└── user.module.ts
核心编码
更改CLI设置
为了在编译后能复制邮件模板到dist
目录,需要更改一下nest-cli.json
文件
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"deleteOutDir": true,
"assets": ["assets"],
"watchAssets": true
}
辅助函数
新增一个src/helpers
目录
在目录中新增types.ts
和constants.ts
文件分别用于放置辅助函数的类型和常量,把原来的Core模块中的以下类型和常量给抽出来放置
// src/helpers/types.ts
export interface TimeOptions
export type OrderQueryType
export interface PaginateDto
// src/helpers/constants.ts
export enum EnvironmentType
export enum OrderType
把原来src/core/helpers.ts
中的函数搬出来放到新增目录中并按各自功能区分,分别放在data.ts
,time.ts
,utils.ts
,env.ts
中,并新增一个deepMerge
函数用于深度合并对象,如下
// src/helpers/utils.ts
export function tNumber(value?: string | number): string | number | undefined
export function tBoolean(value?: string | boolean): string | boolean | undefined
export function tNull(value?: string | null): string | null | undefined
/**
* 深度合并对象
* @param x 初始值
* @param y 新值
* @param arrayMode 对于数组采取的策略,`replace`为直接替换,`merge`为合并数组
*/
export const deepMerge = <T1, T2>((
x: Partial<T1>,
y: Partial<T2>,
arrayMode: 'replace' | 'merge' = 'merge',
) => { ... }
// src/helpers/time.ts
import { TimeOptions } from './types';
export const getTime = (options?: TimeOptions) => {
};
// src/helpers/data.ts
import { OrderQueryType, PaginateDto } from './types';
export function manualPaginate<T extends ObjectLiteral>
export const getOrderByQuery = <E extends ObjectLiteral>
// src/helpers/env.ts
import { EnvironmentType } from './constants';
export const setRunEnv
export const getRunEnv = (): EnvironmentType
在env.ts
中增加以下函数
/**
* 加载.env{.当前环境}文件并合并到process.env
*/
export function loadEnvs() {
// ...
}
/**
* 获取环境变量
* @param key 变量名
* @param parseTo 转义函数
* @param defaultValue 默认值
*/
export function env<T extends BaseType = string>(
key?: string,
parseTo?: ParseType<T> | T,
defaultValue?: T,
) {
// ...
}
添加一个src/modules/user/helpers.ts
文件,把encrypt
和decrypt
迁移到这里
/**
* 加密明文密码
* @param password
*/
export const encrypt = (password: string) => {
return bcrypt.hashSync(password, userConfig().hash);
};
/**
* 验证密码
* @param password
* @param hashed
*/
export const decrypt = (password: string, hashed: string) => {
return bcrypt.compareSync(password, hashed);
};
最后删除src/modules/core/helpers.ts
文件
修改应用
把所有因为路径更改而标红的错误给修复,同时把setRunEnv
从src/main.ts
移动到src/config/index.ts
顶部,并在后面加上loadEnvs
这样就能提前加载当前的环境变量文件以备在配置中使用
// src/config/index.ts
import { loadEnvs, setRunEnv } from '@/helpers';
setRunEnv();
loadEnvs();
export * from './app.config';
export * from './database.config';
export * from './user.config';
新增一个.env
或者.env.development
文件用于存放环境变量(需要在.gitignore
中排除)写进你的配置,同时新增一个env.example
来设置配置模板,如下
# env.example
DB_PASSWORD=123456
SMTP_HOST=smtp.qq.com
SMTP_USER=pincman@qq.com
SMTP_PASSWORD=xxx
SMTP_SSL=true
SMTP_FROM=pincman<pincman@qq.com>
SMS_QCLOUD_ID=xxx
SMS_QCLOUD_KEY=xxx
SMS_LOGIN_CAPTCHA_QCLOUD=896643
SMS_REGISTER_CAPTCHA_QCLOUD=776692
SMS_RETRIEVEPASSWORD_CAPTCHA_QCLOUD=891841
把传入CoreModule
的配置全部改成函数执行以方便读取环境变量
// src/modules/core/types.ts
/**
* core模块参数选项
*/
export interface CoreOptions {
database?: () => TypeOrmModuleOptions;
}
// src/modules/core/core.module.ts
public static forRoot(options: CoreOptions = {}): DynamicModule {
const imports: ModuleMetadata['imports'] = [];
if (options.database) imports.push(TypeOrmModule.forRoot(options.database()));
...
// src/app.module.ts
@Module({
imports: [CoreModule.forRoot({ database }), UserModule, ContentModule],
})
export class AppModule {}
短信发送
类型
新增两个类型设置腾讯云短信驱动配置和发送接口参数并在CoreOptions
类型中加上sms
// src/modules/core/types.ts
/**
* core模块参数选项
*/
export interface CoreOptions {
database?: () => TypeOrmModuleOptions;
sms?: () => SmsOptions;
}
/**
* 腾讯云短信驱动配置
*/
export type SmsOptions<T extends NestedRecord = RecordNever> = {
...
} & T;
/**
* 发送接口参数
*/
export interface SmsSendParams {
...
}
驱动配置
新增一个SMS的驱动配置文件
别忘了在
src/config/index.ts
中导出
// src/config/sms.config.ts
export const sms: () => SmsOptions = () => ({
sign: env('SMS_QCLOUD_SING', '极客科技'),
region: env('SMS_QCLOUD_REGION', 'ap-guangzhou'),
appid: env('SMS_QCLOUD_APPID', '1400437232'),
secretId: env('SMS_QCLOUD_ID', 'your-secret-id'),
secretKey: env('SMS_QCLOUD_KEY', 'your-secret-key'),
});
服务类
新增一个src/modules/core/services/sms.service.ts
文件用于编写短信服务
其方法列表如下
const SmsClient = tencentcloud.sms.v20210111.Client;
/**
* 腾讯云短信驱动
*/
@Injectable()
export class SmsService {
/**
* 初始化配置
* @param options 短信发送选项
*/
constructor(protected readonly options: SmsOptions) {}
/**
* 合并配置并发送短信
* @param params 短信发送参数
* @param options 自定义驱动选项(可用于临时覆盖默认选项)
*/
async send<T>(params: SmsSendParams & T, options?: SmsOptions)
/**
* 创建短信发送驱动实例
* @param options 驱动选项
*/
protected makeClient(options: SmsOptions)
/**
* 转义通用发送参数为腾讯云短信服务发送参数
* @param params 发送参数
* @param options 驱动选项
*/
protected transSendParams(params: SmsSendParams, options: SmsOptions): SendSmsRequest
}
邮件发送
与短信类似的编写流程
添加类型->驱动配置->服务类->修改CoreModule
// src/modules/core/types.ts
/**
* core模块参数选项
*/
export interface CoreOptions {
database?: () => TypeOrmModuleOptions;
sms?: () => SmsOptions;
smtp?: () => SmtpOptions;
}
/**
* SMTP邮件发送配置
*/
export type SmtpOptions<T extends NestedRecord = RecordNever> = {
...
} & T;
/**
* 公共发送接口配置
*/
export interface SmtpSendParams {
...
}
// src/config/smtp.config.ts
export const smtp: () => SmtpOptions = () => ({
host: env('SMTP_HOST', 'localhost'),
user: env('SMTP_USER', 'test'),
password: env('SMTP_PASSWORD', ''),
from: env('SMTP_FROM', '平克小站<support@localhost>'),
port: env('SMTP_PORT', (v) => Number(v), 25),
secure: env('SMTP_SSL', (v) => JSON.parse(v), false),
// Email模板路径
resource: path.resolve(__dirname, '../../assets/emails'),
});
// src/modules/core/services/smtp.service.ts
/**
* SMTP邮件发送驱动
*/
@Injectable()
export class SmtpService {
/**
* 初始化配置
* @param options
*/
constructor(protected readonly options: SmtpOptions) {}
/**
* 合并配置并发送邮件
* @param params
* @param options
*/
async send<T>(params: SmtpSendParams & T, options?: SmtpOptions)
/**
* 创建NodeMailer客户端
* @param options
*/
protected makeClient(options: SmtpOptions)
/**
* 转义通用发送参数为NodeMailer发送参数
* @param client
* @param params
* @param options
*/
protected async makeSend(client: Mail, params: SmtpSendParams, options: SmtpOptions)
}
消息队列
消息队列使用BullMQ+Redis实现,所以我们需要同时增加两个配置分别用于Redis和BullMQ
类型
// src/helpers/types.ts
/**
* Redis配置
*/
export type RedisOptions = IoRedisOptions | Array<RedisOption>;
/**
* Redis连接配置
*/
export type RedisOption = Omit<IoRedisOptions, 'name'> & { name: string };
/**
* BullMQ模块注册配置
*/
export type BullOptions = BullMQOptions | Array<{ name: string } & BullMQOptions>;
/**
* 队列配置
*/
export type QueueOptions = QueueOption | Array<{ name: string } & QueueOption>;
/**
* 队列项配置
*/
export type QueueOption = Omit<BullMQOptions, 'connection'> & { redis?: string };
// src/modules/core/types.ts
/**
* core模块参数选项
*/
export interface CoreOptions {
database?: () => TypeOrmModuleOptions;
queue?: () => QueueOptions;
sms?: () => SmsOptions;
smtp?: () => SmtpOptions;
redis?: () => RedisOptions;
}
配置生成
BullMQ根据Redis的连接名称来设置connection
属性,所以需要添加两个配置生成函数
// src/helpers/options.ts
/**
* 生成Redis配置
* @param options
*/
export const createRedisOptions = (options: RedisOptions) => {
...
};
/**
* 生成BullMQ模块的peizhi
* @param options
* @param redis
*/
export const createQueueOptions = (
options: QueueOptions,
redis: Array<RedisOption>,
): BullOptions | undefined => {
...
};
添加配置与注册BullMQ
模块
// src/config/redis.config.ts
export const redis: () => RedisOptions = () => ({
host: 'localhost',
port: 6379,
});
// src/config/queue.config.ts
export const queue: () => QueueOptions = () => ({
redis: 'default',
});