Express 是一个非常流行的 Node.js 框架,因其简洁和灵活性而备受欢迎。结合 TypeScript 的静态类型检查功能,可以显著提升代码的可维护性和开发效率。在本文中,我们将逐步介绍如何使用 Express 和 TypeScript 搭建一个基础开发项目,包括配置管理、日志模块、错误处理、数据库封装、路由模块、用户验证授权、缓存模块以及中间件支持等方面的内容。
仓库地址
配置模块
1.1 配置管理
配置管理模块的主要目的是处理应用程序的各种配置,包括环境变量管理、配置文件结构设计以及加载和使用配置。下面是一个示例实现,展示了如何使用 dotenv 和 yaml 文件管理配置。
目录结构
my-express-ts-app/
├── src/
│ ├── config/
│ │ ├── index.ts
│ │ └── config.ts
│ ├── logger/
│ │ └── index.ts
│ └── ...
├── .config/
│ ├── .dev.yaml
│ ├── .prod.yaml
│ └── .test.yaml
├── .env
└── ...
代码实现
1. 安装依赖
首先,确保安装了必要的依赖:
npm install dotenv yaml
2. 配置文件
在 .config 目录下创建不同环境的配置文件,例如 .dev.yaml, .prod.yaml, .test.yaml。
3. 配置管理模块
在 src/config/config.ts 文件中实现配置管理逻辑:
import { readFileSync } from 'fs';
import { join } from 'path';
import { parse } from 'yaml';
import logger from '../logger';
// 定义基础配置接口
interface Config {
APP: {
name: string;
port: number;
host: string;
};
cors: CorsConfig;
database: {
host: string;
port: number;
};
}
// 跨域配置接口
interface CorsConfig {
origin: boolean | string | RegExp | Array<string | RegExp>;
methods: string[];
allowedHeaders: string[];
exposedHeaders?: string[];
maxAge?: number;
credentials?: boolean;
}
// 扩展配置接口,允许任意类型的额外属性
interface ExtendedConfig extends Config, Record<string, any> {}
// 配置缓存变量,用于存储已加载的配置
let configCache: ExtendedConfig | null = null;
// 获取当前环境变量,默认为 'dev'
export const getEnv = (): string => {
return process.env.RUNNING_ENV ?? 'dev';
};
/**
* 获取配置对象或特定配置类型
* @param type - 配置类型的键
* @returns 如果传入了type参数,则返回对应的配置类型,否则返回完整的配置对象
*/
export function getConfig(): ExtendedConfig;
export function getConfig<T extends keyof Config>(type: T): Config[T];
export function getConfig<T extends keyof Config>(
type?: T
): Config[T] | ExtendedConfig {
const environment = getEnv(); // 获取当前运行环境
// 如果已经缓存了配置,直接返回缓存中的值
if (configCache != null) {
return type !== undefined ? configCache[type] : configCache;
}
try {
// 根据环境变量构建配置文件路径
const yamlPath = join(
process.cwd(),
`.config/.${environment}.yaml`
);
// 读取配置文件内容
const file = readFileSync(yamlPath, 'utf8');
// 解析YAML格式的配置文件
const parsedConfig = parse(file) as ExtendedConfig;
configCache = parsedConfig; // 将解析后的配置缓存起来
return type !== undefined ? parsedConfig[type] : parsedConfig;
} catch (error: any) {
// 处理读取或解析配置文件时的错误
logger.error(`Failed to read or parse the config file: ${error}`);
process.exit(1); // 出错时终止程序,或者可以选择抛出异常或返回默认配置
}
}
4. 环境变量管理
在项目根目录下创建 .env 文件,并添加环境变量:
RUNNING_ENV=dev
5. 使用配置模块
在应用的主文件(src/index.ts)中使用配置模块:
import express from 'express';
import { getConfig } from './config/config';
import logger from './logger';
const app = express();
const config = getConfig();
app.listen(config.APP.port, config.APP.host, () => {
logger.info(`Server is running at http://${config.APP.host}:${config.APP.port}`);
});
日志模块
日志模块的主要目的是记录应用程序的各种日志信息,包括请求日志、错误日志和应用日志。我们可以使用 winston 和 morgan 结合来实现这些功能。
2.1 请求日志
请求日志中间件(如 morgan)
我们可以使用 morgan 中间件来记录 HTTP 请求日志。
安装依赖
npm install morgan winston
npm i --save-dev @types/morgan
实现请求日志中间件
在 src/middlewares/loggerHandler.ts 文件中实现请求日志中间件:
import morgan, { StreamOptions } from "morgan";
import logger from "../logger";
import { Request } from "express";
// 创建一个流对象,用于将 morgan 的日志输出到 winston
const stream: StreamOptions = {
write: (message: string) => logger.http(message.trim()),
};
// 自定义morgan格式
morgan.token("remote-addr", (req: Request) => req.clientIP);
morgan.token("user-agent", (req: Request) => req.headers["user-agent"] || "");
// morgan.token("req-headers", (req) => JSON.stringify(req.headers)); 如启动需要排除鉴权相关请求头
morgan.token(
"ip-address",
(req: Request) => req.headers["x-forwarded-for"]?.toString() || ""
);
// 配置 morgan 中间件
const morganMiddleware = morgan(
":remote-addr - :method :url :status :res[content-length] - :response-time ms :user-agent :ip-address",
{ stream }
);
export default morganMiddleware;
请求日志格式和存储
在 src/logger/index.ts 文件中配置 winston 日志记录器:
import { createLogger, transports, format, config } from 'winston';
import path from 'path';
import fs from 'fs';
// 确保日志目录存在,如果不存在则创建
const logDir = 'logs';
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
}
// 自定义日志级别
const customLevels = {
levels: {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
},
colors: {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'blue',
}
};
// 创建日志记录器
const logger = createLogger({
levels: customLevels.levels,
// 设置日志级别,根据环境变量决定日志级别
// 在生产环境中记录警告及以上级别的日志,在其他环境中记录信息及以上级别的日志
level: process.env.NODE_ENV === 'production' ? 'warn' : 'http',
// 设置日志格式
format: format.combine(
// 添加时间戳
format.timestamp(),
// 自定义日志输出格式
format.printf(({ timestamp, level, message }) => {
return `${timestamp} [${level}]: ${message}`;
})
),
// 设置日志输出的传输方式
transports: [
// 控制台输出,添加颜色
new transports.Console({
format: format.combine(
format.colorize({ all: true }), // 使用自定义颜色
format.printf(({ timestamp, level, message }) => {
return `${timestamp} [${level}]: ${message}`;
})
)
}),
// 错误日志文件输出,记录错误级别的日志
new transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error',
}),
// 组合日志文件输出,记录所有级别的日志
new transports.File({ filename: path.join(logDir, 'combined.log') }),
// HTTP 请求日志文件输出,记录HTTP级别的日志
new transports.File({
filename: path.join(logDir, 'http.log'),
level: 'http',
}),
],
});
// 添加颜色配置
config.addColors(customLevels.colors);
export default logger;
2.2 错误日志
错误日志捕获和存储
在 src/middlewares/errorHandler.ts 文件中实现错误日志捕获中间件:
import { Request, Response, NextFunction } from 'express';
import logger from '../logger';
// 错误处理中间件
const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
// 记录错误日志,包括请求方法、请求URL和错误信息
logger.error(`${req.method} ${req.url} - ${err.message}`);
// 返回500状态码和错误信息
res.status(500).json({
message: 'Internal Server Error',
// 在开发环境中返回错误详细信息,在生产环境中隐藏详细信息
error: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
};
export default errorHandler;
错误日志格式
在 src/logger/index.ts 文件中已经配置了错误日志的格式和存储位置,并通过 level 属性配置了不同级别的日志记录。我们可以通过 logger 的不同方法来记录不同级别的日志:
import logger from './logger';
logger.info('This is an info message');
logger.warn('This is a warning message');
logger.error('This is an error message');
使用日志模块
在应用的主文件( src/index.ts)中使用日志模块:
import express, { Request, Response } from "express";
import { getConfig } from "./config/config";
import morganMiddleware from "./middlewares/logger";
import errorHandler from "./middlewares/errorHandler";
import logger from "./logger";
import { DbMySqlConnectionError, DbRedisConnectionError } from "./errors";
const app = express();
const port = process.env.PORT || 3000;
const config = getConfig();
// 使用 morgan 中间件记录请求日志
app.use(morganMiddleware);
app.get("/", (req: Request, res: Response) => {
logger.info("This is an info message");
logger.warn("This is a warning message");
logger.error("This is an error message");
res.send("Hello, Object Storage System!");
});
// 使用错误处理中间件记录错误日志
app.use(errorHandler);
app.listen(port, () => {
logger.info(`Server is running on http://localhost:${port}`);
});
通过以上步骤,我们实现了一个完整的日志模块,能够记录请求日志、错误日志和应用运行日志。这个模块包括了请求日志中间件、错误日志捕获和存储、应用日志记录以及不同的日志级别。
错误处理模块
3.1 全局错误处理
全局错误处理中间件用于捕获和处理应用程序中的所有错误,并返回统一的错误响应格式。
错误处理中间件
在 src/middlewares/errorHandler.ts 文件中实现更新错误处理中间件:
// src/middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import logger from '../logger';
import { CustomError } from '../errors/customError';
// 错误处理器中间件
const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
// 检查错误是否是自定义错误类型
if (err instanceof CustomError) {
// 记录错误信息,包括请求方法、URL、错误信息和错误代码
logger.error(`${req.method} ${req.url} - ${err.message} - Code: ${err.errorCode}`);
// 根据错误代码返回不同的响应状态码和消息
res.status(err.errorCode > 1000 ? 500 : err.errorCode).json({
message: err.errorCode > 1000 ? 'Server error' : err.message,
code: err.errorCode,
});
} else {
// 如果不是自定义错误,记录通用错误信息
logger.error(`${req.method} ${req.url} - ${err.message}`);
// 返回通用的服务器错误响应
res.status(500).json({
message: 'Server error',
code: 500,
});
}
// 调用下一个中间件
next();
};
export default errorHandler;
异步错误处理包装函数
在src/utils/asyncWrap.ts中实现
import { Request, Response, NextFunction } from 'express';
/**
* 异步包装函数,用于将异步中间件函数转换为符合 Express 标准的中间件函数。
* @param fn 异步中间件函数
* @returns 转换后的中间件函数
*/
const asyncWrap = (fn: (req: Request, res: Response, next: NextFunction, ...args: any[]) => Promise<any>) =>
function asyncUtilWrap(req: Request, res: Response, next: NextFunction, ...args: any[]) {
const fnReturn = fn(req, res, next, ...args);
return Promise.resolve(fnReturn).catch(next);
};
export default asyncWrap;
错误响应格式
错误响应格式包含错误消息和错误码:
{
"message": "Error message",
"code": 500
}
3.2 自定义错误类
自定义错误类用于定义不同类型的错误及其对应的错误码和消息。
自定义错误类设计
在 src/errors/customError.ts 文件中实现自定义错误类:
export class CustomError extends Error {
// 定义一个公共的错误代码属性
public errorCode: number;
// 构造函数,接受错误代码和可选的错误信息
constructor(errorCode: number, message?: string) {
// 调用父类(Error)的构造函数,传递错误信息
super(message);
// 设置错误名称为当前类的名称
this.name = this.constructor.name;
// 设置错误代码
this.errorCode = errorCode;
// 捕获当前错误的堆栈信息,以便调试
Error.captureStackTrace(this, this.constructor);
}
}
错误类型和状态码
在 src/errors/index.ts 文件中实现具体的错误类型:
import { CustomError } from './customError';
// 服务器内部错误
export class ServerInternalError extends CustomError {
constructor(errorCode: number = 600000, message?: string) {
super(errorCode, message || 'Server internal error occurred.');
}
}
// 数据库连接错误
export class DbConnectionError extends ServerInternalError {
constructor(message?: string) {
super(100101, message || 'Database connection error.');
}
}
// MySQL 连接错误
export class DbMySqlConnectionError extends ServerInternalError {
constructor(message?: string) {
super(100101, message || 'MySQL connection error.');
}
}
// Redis 连接错误
export class DbRedisConnectionError extends ServerInternalError {
constructor(message?: string) {
super(100201, message || 'Redis connection error.');
}
}
// 数据库查询错误
export class DbQueryError extends ServerInternalError {
constructor(message?: string) {
super(600200, message || 'Database query error.');
}
}
// 认证失败错误
export class AuthenticationError extends ServerInternalError {
constructor(message?: string) {
super(600300, message || 'Authentication failed.');
}
}
// 授权失败错误
export class AuthorizationError extends ServerInternalError {
constructor(message?: string) {
super(600400, message || 'Authorization failed.');
}
}
// 支付处理错误
export class PaymentProcessingError extends ServerInternalError {
constructor(message?: string) {
super(600500, message || 'Payment processing error.');
}
}
// 订单处理错误
export class OrderProcessingError extends ServerInternalError {
constructor(message?: string) {
super(600600, message || 'Order processing error.');
}
}
// 客户端错误
export class ClientError extends CustomError {
constructor(errorCode: number, message?: string) {
super(errorCode, message);
}
}
// 错误的客户端请求
export class ClientBadRequestError extends ClientError {
constructor(message?: string) {
super(400, message || 'Bad request from client.');
}
}
// 客户端被风控
export class ClientForbiddenByRiskControlError extends ClientError {
constructor(message?: string) {
super(403, message || 'Client is forbidden by risk control.');
}
}
// 未经授权的客户端请求
export class ClientUnauthorizedError extends ClientError {
constructor(message?: string) {
super(401, message || 'Unauthorized client request.');
}
}
// 客户端权限不足
export class ClientForbiddenError extends ClientError {
constructor(message?: string) {
super(403, message || 'Client does not have sufficient permissions.');
}
}
// 客户端数据验证失败
export class ClientValidationError extends ClientError {
constructor(message?: string) {
super(422, message || 'Client data validation failed.');
}
}
// 客户端请求超时
export class ClientTimeoutError extends ClientError {
constructor(message?: string) {
super(408, message || 'Client request timed out.');
}
}
// 客户端提供的凭证无效
export class ClientInvalidCredentialsError extends ClientError {
constructor(message?: string) {
super(401, message || 'Invalid credentials provided by client.');
}
}
// 客户端请求过多
export class ClientTooManyRequestsError extends ClientError {
constructor(message?: string) {
super(429, message || 'Too many requests from client.');
}
}
// 客户端网络连接错误
export class ClientNetworkError extends ClientError {
constructor(message?: string) {
super(500, message || 'Client network connection error.');
}
}
基础数据库封装
4.1 数据库连接管理
数据库连接配置
安装mongoose
npm i mongoose
在 src/config/database.ts 文件中定义 MongoDB 连接配置:
import mongoose, { ConnectOptions } from 'mongoose';
const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017/mydatabase';
const options: ConnectOptions = {
connectTimeoutMS: 10000,
socketTimeoutMS: 45000,
maxPoolSize: 10,
minPoolSize: 5,
autoIndex: true,
retryWrites: true,
w: 'majority',
readPreference: 'primary',
authSource: 'admin',
};
export const initializeMongoose = async () => {
try {
await mongoose.connect(mongoUrl, options);
console.log('MongoDB 连接成功...');
} catch (err) {
console.error('MongoDB 连接错误:', err);
}
mongoose.connection.on('connected', () => {
console.log('Mongoose 已连接到数据库');
});
mongoose.connection.on('error', (err) => {
console.error('Mongoose 连接错误:', err);
});
mongoose.connection.on('disconnected', () => {
console.log('Mongoose 已断开与数据库的连接');
});
process.on('SIGINT', async () => {
await mongoose.connection.close();
console.log('应用程序终止,Mongoose 已断开连接');
process.exit(0);
});
};
数据库连接池管理
连接池管理已经在 ConnectOptions 中配置,通过 maxPoolSize 和 minPoolSize 参数进行管理。
4.2 ORM(对象关系映射)
由于使用的是 MongoDB,推荐使用 Mongoose 作为 ODM(对象文档映射)工具。
ORM 配置和使用
在 src/models/baseModel.ts 文件中实现基础的 Mongoose 模型封装:
import mongoose, {
Schema,
Document,
Model,
FilterQuery,
QueryOptions,
} from "mongoose";
export interface IBaseDocument extends Document {
deletedAt?: Date;
createdAt: Date;
updatedAt: Date;
createdBy?: mongoose.Types.ObjectId;
updatedBy?: mongoose.Types.ObjectId;
version: number;
}
type ExtendedFilterQuery<T> = FilterQuery<T> & {
deletedAt?: { $exists: boolean };
};
function toObjectId(
id: mongoose.Types.ObjectId | string
): mongoose.Types.ObjectId {
return typeof id === "string" ? new mongoose.Types.ObjectId(id) : id;
}
export const baseSchemaDict = {
createdAt: {
type: Date,
default: Date.now,
},
updatedAt: {
type: Date,
default: Date.now,
},
deletedAt: {
type: Date,
},
createdBy: {
type: mongoose.Types.ObjectId,
ref: "User",
},
updatedBy: {
type: mongoose.Types.ObjectId,
ref: "User",
},
version: {
type: Number,
default: 0,
},
};
const baseSchema = new Schema<IBaseDocument>(baseSchemaDict);
baseSchema.pre<IBaseDocument>("save", function (next) {
this.updatedAt = new Date();
this.version += 1;
next();
});
class BaseModel<T extends Document> {
private model: Model<T>;
constructor(
modelName: string,
schemaDef: mongoose.SchemaDefinition,
options: mongoose.SchemaOptions = {}
) {
const schema = new Schema(
{
...schemaDef,
...baseSchema.obj,
},
options
);
this.model = mongoose.model<T>(modelName, schema);
}
protected getModel(): Model<T> {
return this.model;
}
async create(
doc: Partial<T>,
userId?: mongoose.Types.ObjectId | null
): Promise<T> {
if (userId) {
(doc as any).createdBy = userId;
(doc as any).updatedBy = userId;
}
return await this.model.create(doc);
}
async update(
id: mongoose.Types.ObjectId | string,
update: Partial<T>,
userId: mongoose.Types.ObjectId
): Promise<T | null> {
(update as any).updatedBy = userId;
return await this.model.findByIdAndUpdate(toObjectId(id), update, { new: true }).exec();
}
async delete(
id: mongoose.Types.ObjectId | string,
userId?: mongoose.Types.ObjectId
): Promise<T | null> {
return await this.model
.findByIdAndUpdate(
toObjectId(id),
{
deletedAt: new Date(),
updatedBy: userId,
},
{ new: true }
)
.exec();
}
async find(query: ExtendedFilterQuery<T>, options: QueryOptions = {}) {
query.deletedAt = { $exists: false };
return await this.model.find(query, options).exec();
}
async findById(
id: mongoose.Types.ObjectId | string,
options: QueryOptions = {}
): Promise<T | null> {
const query: ExtendedFilterQuery<T> = {
_id: toObjectId(id),
deletedAt: { $exists: false },
};
return await this.model.findOne(query, options).exec();
}
async findOne(
query: ExtendedFilterQuery<T>,
options: QueryOptions = {}
): Promise<T | null> {
query.deletedAt = { $exists: false };
return await this.model.findOne(query, options).exec();
}
async findAllIncludingDeleted(
query: FilterQuery<T>,
options: QueryOptions = {}
) {
return await this.model.find(query, options).exec();
}
}
export default BaseModel;
更新config类型注解
import { readFileSync } from 'fs';
import { join } from 'path';
import { parse } from 'yaml';
import logger from '../logger';
import { ConnectOptions} from "mongoose"
// 定义数据库配置接口
interface DatabaseConfig {
url: string;
options: ConnectOptions
}
// 定义基础配置接口
interface Config {
APP: {
name: string;
port: number;
host: string;
};
cors: CorsConfig;
database: DatabaseConfig;
}
路由模块
5.1 路由定义
路由文件结构
建议将路由文件结构组织成模块化的形式,以便于维护和扩展。可以按照功能模块划分路由,例如:
src/
├── routes/
│ ├── index.ts
│ ├── api/
│ │ ├── testRouter.ts
│ │ ├── userRouter.ts
│ │ └── productRouter.ts
├── controllers/
│ ├── testController.ts
│ ├── userController.ts
├── middlewares/
│ ├── authMiddleware.ts
│ └── errorMiddleware.ts
└── validators/
└── userValidator.ts
路由定义和使用
在 src/routes/index.ts 文件中定义和初始化路由:
import { Express, Request, Response, Router } from "express";
import logger from "../logger";
interface RouterConf {
path: string;
router: Router;
meta?: any;
}
const routerGroup: RouterConf[] = [];
function registerRouteGroup(app: Express, routes: RouterConf[]) {
routes.forEach((route) => {
app.use(route.path, route.router);
});
}
function initRoutes(app: Express) {
logger.info("Router initialization");
app.get("/", (req: Request, res: Response) => {
logger.info("This is an info message");
logger.warn("This is a warning message");
logger.error("This is an error message");
res.send("Hello, Object Storage System!");
});
registerRouteGroup(app, routerGroup);
}
export default initRoutes;
通过以上步骤,我们实现了一个完整的路由模块,包括路由定义、路由中间件以及参数验证和错误处理。这个模块可以帮助我们更好地组织和管理路由,提高应用程序的可维护性和可靠性。
用户和验证授权
6.1 模型设计
安装 bcryptjs:
npm install bcryptjs
npm i --save-dev @types/bcryptjs
在 src/models/userModel.ts 中定义用户模型:
import mongoose, { Schema, Document } from "mongoose";
import bcrypt from "bcryptjs";
import BaseModel, { IBaseDocument, baseSchemaDict } from "./baseModel"; // 引入更新后的 BaseModel
// 用户接口,包含字段和方法
export interface IUser extends IBaseDocument {
username: string; // 用户名,必须唯一
email: string; // 邮箱,必须唯一
password: string; // 密码
role: "guest" | "user" | "admin" | "super"; // 用户角色
name?: string; // 姓名
phoneNumber?: string; // 电话号码
profilePicture?: string; // 头像URL
dateOfBirth?: Date; // 出生日期
address?: string; // 地址
isActive: boolean; // 是否激活
isVerified: boolean; // 是否验证
lastLogin?: Date; // 最后登录时间
passwordResetToken?: string; // 密码重置令牌
passwordResetExpires?: Date; // 密码重置令牌过期时间
loginAttempts: number; // 登录尝试次数
lockUntil?: Date; // 锁定账户直到某个时间
comparePassword(candidatePassword: string): Promise<boolean>; // 比较密码方法
}
// 用户 Schema 定义
const userSchemaDefinition: mongoose.SchemaDefinition = {
username: { type: String, required: true, unique: true }, // 用户名,必须唯一
email: { type: String, required: true, unique: true }, // 邮箱,必须唯一
password: { type: String, required: true }, // 密码
role: {
type: String,
enum: ["guest", "user", "admin", "super"], // 用户角色
default: "guest", // 默认角色为 guest
},
name: { type: String }, // 姓名
phoneNumber: { type: String }, // 电话号码
profilePicture: { type: String }, // 头像URL
dateOfBirth: { type: Date }, // 出生日期
address: { type: String }, // 地址
isActive: { type: Boolean, default: true }, // 是否激活,默认激活
isVerified: { type: Boolean, default: false }, // 是否验证,默认未验证
lastLogin: { type: Date }, // 最后登录时间
passwordResetToken: { type: String }, // 密码重置令牌
passwordResetExpires: { type: Date }, // 密码重置令牌过期时间
loginAttempts: { type: Number, default: 0 }, // 登录尝试次数,默认0次
lockUntil: { type: Date }, // 锁定账户直到某个时间
};
class UserModel extends BaseModel<IUser> {
constructor() {
super("User", userSchemaDefinition);
// 比较密码方法
this.getModel().schema.methods.comparePassword = function (
candidatePassword: string
): Promise<boolean> {
return bcrypt.compare(candidatePassword, this.password);
};
}
// 单独的比较密码方法
comparePassword(candidatePassword: string, hashPass: string) {
return bcrypt.compare(candidatePassword, hashPass);
}
// 用户注册
async register(userData: IUser) {
const salt = await bcrypt.genSalt(10);
userData.password = await bcrypt.hash(userData.password, salt);
const user = new (this.getModel())(userData);
await user.save();
return user;
}
// 用户登录
async login(email: string, password: string) {
const user = await this.getModel().findOne({ email });
if (!user) throw new Error("User not found");
const isMatch = await user.comparePassword(password);
if (!isMatch) throw new Error("Invalid password");
user.lastLogin = new Date();
await user.save();
return user;
}
// 用户登出
async logout(userId: string) {
// 实现登出逻辑,例如销毁会话或令牌
}
// 根据ID获取用户
async getUserById(userId: string) {
return this.getModel().findById(userId);
}
// 更新用户信息
async updateUser(userId: string, updateData: Partial<IUser>) {
if (updateData.password) {
const salt = await bcrypt.genSalt(10);
updateData.password = await bcrypt.hash(updateData.password, salt);
}
updateData.updatedAt = new Date();
return this.getModel().findByIdAndUpdate(userId, updateData, { new: true });
}
// 删除用户
async deleteUser(userId: string) {
return this.getModel().findByIdAndDelete(userId);
}
// 请求密码重置邮件token
async requestPasswordReset(email: string) {
const user = await this.getModel().findOne({ email });
if (!user) throw new Error("User not found");
// 生成密码重置令牌和过期时间
const token = bcrypt.genSaltSync(10);
user.passwordResetToken = token;
user.passwordResetExpires = new Date(Date.now() + 3600000); // 1小时后过期
await user.save();
return token;
}
// 重置密码
async resetPassword(token: string, newPassword: string) {
const user = await this.getModel().findOne({
passwordResetToken: token,
passwordResetExpires: { $gt: new Date() },
});
if (!user) throw new Error("Token is invalid or has expired");
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(newPassword, salt);
user.passwordResetToken = undefined;
user.passwordResetExpires = undefined;
await user.save();
}
// 管理员重置用户密码
async resetPasswordByAdmin(userId: string, newPassword?: string) {
const user = await this.getModel().findById(userId);
if (!user) throw new Error("User not found");
// 如果没有指定新密码,则生成一个随机密码
if (!newPassword) {
newPassword = Math.random().toString(36).slice(-8);
}
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(newPassword, salt);
await user.save();
return newPassword;
}
// 用户自己重置密码
async resetPasswordByUser(
userId: string,
oldPassword: string,
newPassword: string
) {
const user = await this.getModel().findById(userId);
if (!user) throw new Error("User not found");
const isMatch = await user.comparePassword(oldPassword);
if (!isMatch) throw new Error("Invalid old password");
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(newPassword, salt);
await user.save();
}
}
export default new UserModel();
6.2 角色和权限设计
在 src/types/user.ts 中定义角色类型和认证用户接口:
export type Role = 'guest' | 'user' | 'admin' | 'super';
export interface AuthUser {
id: string;
username: string;
role: Role;
}
6.3 权限验证中间件
首先,安装 jsonwebtoken 包及其类型定义:
npm i jsonwebtoken
npm i --save-dev @types/jsonwebtoken
在 src/middlewares/authMiddleware.ts 中定义身份验证中间件:
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AuthUser } from '../types/user';
declare global {
namespace Express {
interface Request {
user?: AuthUser;
}
}
}
export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
const token = req.header('Authorization')?.replace('Bearer ', '') || (req.query.token as string);
if (!token || Array.isArray(token)) {
req.user = { id: '', username: '', role: 'guest' }; // 设置为游客身份
return next();
}
try {
const decoded = jwt.verify(token, 'your_jwt_secret') as AuthUser;
req.user = decoded;
next();
} catch (error) {
req.user = { id: '', username: '', role: 'guest' }; // 设置为游客身份
next();
}
};
在 src/middlewares/roleMiddleware.ts 中定义角色验证中间件:
import { Request, Response, NextFunction } from "express";
import { Role } from "../types/user";
import { ClientForbiddenError, ClientUnauthorizedError } from "../errors";
export const roleMiddleware = (requiredRole: Role | Role[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
throw new ClientUnauthorizedError();
}
if (typeof requiredRole === "string") {
if (req.user.role !== requiredRole) {
throw new ClientForbiddenError();
}
} else if (Array.isArray(requiredRole)) {
if (!requiredRole.includes(req.user.role)) {
throw new ClientForbiddenError();
}
}
next();
};
};
6.4 用户注册和登录
在 src/controllers/user/authController.ts 中定义用户注册和登录逻辑:
import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import { ClientBadRequestError, ClientUnauthorizedError } from "../../errors";
import UserModel from "../../models/UserModel";
import { getConfig } from "../../config/config";
export const registerUser = async (req: Request, res: Response) => {
const { username, email, password } = req.body;
const user = await UserModel.create({
username,
email,
password,
role: "user",
});
res.status(201).json(user);
};
export const loginUser = async (req: Request, res: Response) => {
const { email, password } = req.body;
const user = await UserModel.findOne({ email });
if (!user || !(await user.comparePassword(password))) {
throw new ClientUnauthorizedError("Invalid email or password");
}
const token = jwt.sign(
{ id: user._id, username: user.username, role: user.role },
getConfig().jwt_secret,
{ expiresIn: "15d" }
);
res.json({ token });
};
在 src/routes/api/authRouter.ts 中定义用户信息和密码重置路由:
import { Router } from "express";
import { registerUser, loginUser } from "../../controllers/user/authController";
import asyncWrap from "../../utils/asyncWrap";
const authRouter = Router();
authRouter.post("/register", asyncWrap(registerUser));
authRouter.post("/login", asyncWrap(loginUser));
export default authRouter;
6.5 用户信息
在 src/controllers/user/infoController.ts 中添加获取用户信息的逻辑:
import { Request, Response } from "express";
import UserModel from "../../models/UserModel";
import { ClientBadRequestError, ClientUnauthorizedError } from "../../errors";
import { getCache, setCache, delCache } from "../../utils/cache";
const CACHE_TTL = 3600; // 缓存时间,单位为秒
// 获取用户信息
export const getUserInfo = async (req: Request, res: Response) => {
const cacheKey = `user:${req.user!.id}`;
let user = await getCache(cacheKey);
if (!user) {
user = await UserModel.findById(req.user!.id as string);
if (!user) {
throw new ClientUnauthorizedError("User not found");
}
await setCache(cacheKey, user, CACHE_TTL);
}
res.json(user);
};
// 更新用户信息
export const updateUserInfo = async (req: Request, res: Response) => {
const user = await UserModel.updateUser(req.user!.id, req.body);
if (!user) {
throw new ClientUnauthorizedError("User not found");
}
// 更新缓存
const cacheKey = `user:${req.user!.id}`;
await setCache(cacheKey, user, CACHE_TTL);
res.json(user);
};
6.6 重置密码
在 src/controllers/user/securityController.ts 中添加重置密码的逻辑:
import { Request, Response } from "express";
import UserModel from "../../models/UserModel";
import { ClientBadRequestError } from "../../errors";
import { delCache } from "../../utils/cache";
// 管理员重置用户密码
export const resetPasswordByAdminHandler = async (
req: Request,
res: Response
) => {
const { userId, newPassword } = req.body;
const password = await UserModel.resetPasswordByAdmin(userId, newPassword);
// 删除缓存
const cacheKey = `user:${userId}`;
await delCache(cacheKey);
res.json({ message: "Password has been reset", password });
};
// 用户自己重置密码
export const resetPasswordByUserHandler = async (
req: Request,
res: Response
) => {
const { userId, oldPassword, newPassword } = req.body;
await UserModel.resetPasswordByUser(userId, oldPassword, newPassword);
// 删除缓存
const cacheKey = `user:${userId}`;
await delCache(cacheKey);
res.json({ message: "Password has been reset" });
};
在 src/routes/api/userRouter.ts 中定义用户信息和密码重置路由:
import { Router } from "express";
import {
getUserInfo,
updateUserInfo,
} from "../../controllers/user/infoController";
import { authMiddleware } from "../../middlewares/authMiddleware";
import { roleMiddleware } from "../../middlewares/roleMiddleware";
const userRouter = Router();
userRouter.get(
"/me",
authMiddleware,
roleMiddleware(["admin", "super", "user"]),
getUserInfo
);
userRouter.put(
"/me",
authMiddleware,
roleMiddleware(["admin", "super", "user"]),
updateUserInfo
);
userRouter.post(
"/request-password-reset",
authMiddleware,
roleMiddleware(["admin", "super"]),
requestPasswordReset
);
userRouter.post(
"/reset-password",
authMiddleware,
roleMiddleware(["admin", "super", "user"]),
resetPassword
);
export default userRouter;
在 src/routes/index.ts 中注册 userRouter:
import userRouter from "./api/userRouter";
const routerGroup: RouterConf[] = [
{ path: "/api/auth", router: authRouter },
{ path: "/api/user", router: userRouter }
];
缓存模块
7.1 内存缓存(如 Redis)
Redis 配置和连接
首先,安装 ioredis:
npm install ioredis
更新src/config/config.ts配置
// config.ts
import { readFileSync } from "fs";
import { join } from "path";
import { parse } from "yaml";
import logger from "../logger";
import { ConnectOptions } from "mongoose";
import { RedisOptions } from "ioredis";
// 定义数据库配置接口
interface DatabaseConfig {
url: string;
options: ConnectOptions;
}
// 定义基础配置接口
interface Config {
APP: {
name: string;
port: number;
host: string;
};
jwt_secret: string;
cors: CorsConfig;
rateLimit: RateLimitConfig;
database: DatabaseConfig;
redis: RedisOptions;
}
// 其他代码不变
然后,创建一个 Redis 客户端并进行配置。在 src/config/redis.ts 文件中:
import Redis from "ioredis";
import { getConfig } from "./config";
const redis = new Redis(getConfig().redis);
redis.on("connect", () => {
console.log("Connected to Redis");
});
redis.on("error", (err) => {
console.error("Redis error", err);
});
export default redis;
Redis 缓存使用
在 src/utils/cache.ts 文件中创建缓存工具:
// src/utils/cache.ts
import redis from '../config/redis';
export const setCache = async (key: string, value: any, ttl: number) => {
await redis.set(key, JSON.stringify(value), 'EX', ttl);
};
export const getCache = async (key: string) => {
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
};
export const delCache = async (key: string) => {
await redis.del(key);
};
7.2 缓存策略和失效机制
缓存策略设计
缓存策略需要根据具体业务需求进行设计。常见的缓存策略包括:
- 读缓存:在读取数据时先检查缓存,如果缓存中有数据则直接返回,否则从数据库读取并缓存结果。
- 写缓存:在写入数据时同时更新缓存。
- 删除缓存:在删除数据时同时删除缓存。
缓存失效和更新机制
缓存失效和更新机制是保证缓存数据一致性的关键。常见的失效机制包括:
- TTL(Time-To-Live):为缓存数据设置一个过期时间,到期后自动失效。
- 主动失效:在数据更新或删除时主动删除或更新缓存。
在 src/utils/cache.ts 中,我们已经实现了 TTL 机制。在控制器中,我们通过在数据更新或删除时主动更新或删除缓存来实现主动失效。
7.3 更新用户相关接口以使用 Redis 缓存
获取用户信息
在 src/controllers/user/infoController.ts 中更新 getUserInfo 函数以使用缓存:
import { Request, Response } from "express";
import UserModel from "../../models/UserModel";
import { ClientBadRequestError, ClientUnauthorizedError } from "../../errors";
import { getCache, setCache, delCache } from "../../utils/cache";
const CACHE_TTL = 3600; // 缓存时间,单位为秒
// 获取用户信息
export const getUserInfo = async (req: Request, res: Response) => {
try {
const cacheKey = `user:${req.user!.id}`;
let user = await getCache(cacheKey);
if (!user) {
user = await UserModel.findById(req.user!.id as string);
if (!user) {
throw new ClientUnauthorizedError("User not found");
}
await setCache(cacheKey, user, CACHE_TTL);
}
res.json(user);
} catch (error: any) {
throw new ClientBadRequestError(error.message);
}
};
// 更新用户信息
export const updateUserInfo = async (req: Request, res: Response) => {
try {
const user = await UserModel.updateUser(req.user!.id, req.body);
if (!user) {
throw new ClientUnauthorizedError("User not found");
}
// 更新缓存
const cacheKey = `user:${req.user!.id}`;
await setCache(cacheKey, user, CACHE_TTL);
res.json(user);
} catch (error: any) {
throw new ClientBadRequestError(error.message);
}
};
用户登录
在 src/controllers/user/authController.ts 中更新 loginUser 函数以使用缓存:
import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import {
ClientBadRequestError,
ClientForbiddenByRiskControlError,
ClientUnauthorizedError,
} from "../../errors";
import UserModel from "../../models/UserModel";
import { getConfig } from "../../config/config";
import { setCache } from "../../utils/cache";
import { incrementLoginAttempts, resetLoginAttempts } from "../../cache/auth";
const CACHE_TTL = 3600; // 缓存时间,单位为秒
const MAX_LOGIN_ATTEMPTS = 10;
const LOGIN_ATTEMPTS_TTL = 24 * 60 * 60; // 1天,单位为秒
// 注册用户
export const registerUser = async (req: Request, res: Response) => {
try {
const { username, email, password } = req.body;
// 加密密码
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
const user = await UserModel.create({
username,
email,
password: hashedPassword,
role: "user",
});
res.status(201).json(user);
} catch (error: any) {
throw new ClientBadRequestError(error.message);
}
};
// 登录用户
export const loginUser = async (req: Request, res: Response) => {
const { email, password } = req.body;
const user = await UserModel.findOne({ email });
if (!user) {
throw new ClientUnauthorizedError("Invalid email or password");
}
const loginAttemptsKey = `loginAttempts:${email}`;
const attempts = await incrementLoginAttempts(
loginAttemptsKey,
LOGIN_ATTEMPTS_TTL
);
if (attempts > MAX_LOGIN_ATTEMPTS) {
console.log("风控");
throw new ClientForbiddenByRiskControlError(
"Too many login attempts. Please try again later."
);
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
throw new ClientUnauthorizedError("Invalid email or password");
}
// 重置登录尝试次数
await resetLoginAttempts(loginAttemptsKey);
user.lastLogin = new Date();
await user.save();
const token = jwt.sign(
{ id: user._id, username: user.username, role: user.role },
getConfig().jwt_secret,
{ expiresIn: "15d" }
);
// 更新缓存
const cacheKey = `user:${user._id}`;
await setCache(cacheKey, user, CACHE_TTL);
res.json({ token });
};
重置密码
在 src/controllers/user/securityController.ts 中更新重置密码的逻辑以使用缓存:
import { Request, Response } from "express";
import UserModel from "../../models/UserModel";
import { ClientBadRequestError } from "../../errors";
import { delCache } from "../../utils/cache";
// 管理员重置用户密码
export const resetPasswordByAdminHandler = async (req: Request, res: Response) => {
try {
const { userId, newPassword } = req.body;
const password = await UserModel.resetPasswordByAdmin(userId, newPassword);
// 删除缓存
const cacheKey = `user:${userId}`;
await delCache(cacheKey);
res.json({ message: "Password has been reset", password });
} catch (error: any) {
throw new ClientBadRequestError(error.message);
}
};
// 用户自己重置密码
export const resetPasswordByUserHandler = async (req: Request, res: Response) => {
try {
const { userId, oldPassword, newPassword } = req.body;
await UserModel.resetPasswordByUser(userId, oldPassword, newPassword);
// 删除缓存
const cacheKey = `user:${userId}`;
await delCache(cacheKey);
res.json({ message: "Password has been reset" });
} catch (error: any) {
throw new ClientBadRequestError(error.message);
}
};
通过以上步骤,我们实现了一个完整的缓存模块,并将其与用户相关的接口集成。这样可以提高应用程序的性能和响应速度,同时保证数据的一致性和可靠性。
中间件支持
8.1 请求解析(如 body-parser)
从 Express 4.16.0 开始,Express 自带了 body-parser 的功能,可以直接使用。
请求体解析配置和使用
在 src/middlewares/requestParser.ts 中:
import express, { Express } from "express";
import { getRealIp } from "../utils/ip";
declare global {
namespace Express {
interface Request {
clientIP?: string;
}
}
}
export const requestParserMiddleware = (app: Express) => {
app.use(express.json()); // 解析 JSON 格式的请求体
app.use(express.urlencoded({ extended: true })); // 解析 URL 编码的请求体
app.use((req, res, next) => {
req.clientIP = getRealIp(req.headers);
next()
});
};
解析ipsrc/utils/ip.ts
export const getRealIp = (
headers: any,
ipHeaders: string[] = [
"x-true-ip",
"x-client-ip",
"wl-proxy-client-ip",
"remoteip",
"x-real-ip",
"x-forwarded-for",
]
): string | undefined => {
for (const header of ipHeaders) {
if (headers[header]) {
let ip = headers[header];
if (header === "x-forwarded-for") {
// x-forwarded-for may contain multiple IPs, we need the first one
ip = ip.split(",")[0].trim();
}
return ip;
}
}
return undefined;
};
8.2 CORS 支持
安装 cors 中间件:
npm install cors
npm i --save-dev @types/cors
CORS 中间件配置和使用
在 src/middlewares/corsMiddleware.ts 中:
// corsMiddleware.ts
import cors from 'cors';
import { Express } from 'express';
import { getConfig } from '../config/config';
export const corsMiddleware = (app: Express) => {
const corsOptions = getConfig().cors;
app.use(cors(corsOptions));
};
8.3 请求限流
安装 express-rate-limit 中间件:
npm install express-rate-limit
请求限流工具和策略配置
在 src/middlewares/rateLimit.ts 中:
import rateLimit, { Options } from "express-rate-limit";
import { Express, Request } from "express";
import { getConfig } from "../config/config";
interface RateLimitOptions extends Options {
customHeader?: string; // 自定义头部字段
}
export const rateLimitMiddleware = (app: Express) => {
const { rateLimit: rateLimitConfig } = getConfig();
const limiter = rateLimit({
windowMs: rateLimitConfig.windowMs,
max: rateLimitConfig.max,
message: rateLimitConfig.message,
keyGenerator: (req: Request): string => {
// 从自定义头部获取 IP 地址
const ip = rateLimitConfig.customHeader
? req.headers[rateLimitConfig.customHeader]
: req.ip;
return typeof ip === "string" ? ip : "";
},
});
app.use(limiter);
};
8.4 安全头设置(如 helmet)
安装 helmet:
npm install helmet
安全头中间件配置和使用
在 src/middlewares/helmet.ts 中:
import helmet from 'helmet';
import { Express } from 'express';
export const helmetMiddleware = (app: Express) => {
app.use(helmet());
};
8.5 初始化中间件
在 src/middlewares/index.ts 中创建一个 initMiddleware 函数来注册所有中间件:
import { Express } from "express";
import { requestParserMiddleware } from "./requestParser";
import { rateLimitMiddleware } from "./rateLimit";
import { corsMiddleware } from "./corsMiddleware";
import morganMiddleware from "./loggerHandler";
import { helmetMiddleware } from "./helmet";
export const initMiddleware = (app: Express) => {
// 注册安全头中间件
helmetMiddleware(app);
// 注册请求解析中间件
requestParserMiddleware(app);
// 使用 morgan 中间件记录请求日志
app.use(morganMiddleware);
// 注册 CORS 中间件
corsMiddleware(app);
// 注册请求限流中间件
rateLimitMiddleware(app);
};
总结
通过以上步骤,我们成功地使用 Express 和 TypeScript 搭建了一个基础开发项目,并实现了配置管理、日志记录、错误处理、数据库封装、路由管理、用户验证授权、缓存和中间件等功能模块。这不仅为我们的项目提供了良好的架构和代码组织,还提高了代码的可读性和可维护性。希望本文能为正在学习或使用 Express 和 TypeScript 开发的你提供一些帮助和参考。未来,我们可以继续扩展和优化这个项目,添加更多高级功能和性能优化。