打造企业级 Express 服务器

1,006 阅读12分钟

Express.js 作为 Node.js 生态中最流行的框架,以其简洁、灵活的特点受到了广大开发者的青睐。然而,要将 Express 服务器提升到企业级水平,还需要考虑许多关键因素。

本文将带你深入了解如何从零开始构建一个企业级的 Express 服务器。我们将探讨以下几个核心方面:

  1. 代码分层:合理组织路由和中间件,提高代码的可读性和可维护性。
  2. 中间件:构建高效可扩展的 Express 核心例如:日志中间件、内容转义中间件。
  3. 数据库集成:使用 Prisma ORM 连接 Mysql 数据库为例。
  4. 身份验证与授权:JWT 用户认证管理机制。
  5. 错误捕获:统一的错误捕获处理。
  6. 安全性:使用 helmet 等中间件增强服务器的安全性,防范常见的网络攻击。
  7. 日志记录与错误处理:实现详细的日志记录和优雅的错误处理机制。

代码分层

/express-best-practice                # 项目根目录
├── node_modules/            # Node.js 依赖包
├── prisma/                  # Prisma 相关文件
│   ├── schema.prisma        # Prisma 数据库模型定义文件
├── src/                     # 源代码目录
│   ├── controllers/         # 控制器层,处理HTTP请求和响应
│   │   └── User.js 				# 用户相关路由的控制器
│   │   └── Login.js 				# 登录相关路由的控制器
│   │   └── Other.. 				# 其他相关路由的控制器
│   ├── services/           # 服务层,包含业务逻辑
│   │   └── userService.js  # 用户相关的业务逻辑
│   │   └── Login.js 				# 登录相关路由的业务逻辑
│   ├── middlewares/        # 中间件层,用于处理请求前后的逻辑
│   │   └── escapeHtmlMiddle.js # 转义中间件
│   │   └── logMiddle.js 				# 日志中间件
│   ├── routes/             # 路由层,定义路由和控制器的映射
│   │   ├── index.js        # 应用的入口路由文件
│   └── utils/              # 工具函数和帮助程序
│   └── logs/               # 日志文件

代码分层参考 Nest.js 框架

Controller

  • 路由管理:据请求的URL来决定调用哪个方法处理请求
  • 业务逻辑调用:Controller会调用 Service 层的代码来执行具体的业务逻辑
  • 依赖注入:可以调用注入的 Service
const postServiceInstance = require("../services/PostService");
const userServiceInstance = require("../services/UserService");
class PostController {
  // 依赖注入
  postService = postServiceInstance;
  userService = userServiceInstance;
  controller = "/posts";
  routes = [
    {
      path: "",
      handler: this.getAll,
      method: "get",
    },
    {
      path: "",
      handler: this.create,
      method: "post",
    },
  ];
  async getAll(req, res) {
    const posts = await this.postService.find();
    res.json(posts);
  }
  async create(req, res) {
    const authorId = req.body.authorId;
    const user = await this.userService.findById(authorId);
    const dto = {
      title: req.body.title,
      content: req.body.content,
      authorId: parseInt(req.body.authorId),
      author: user,
    };
    await this.postService.create(dto);
    res.status(200).json({
      message: "成功",
    });
  }
}

module.exports = new PostController();

Service

  • 业务逻辑封装:Service层包含了应用的业务逻辑,它封装了与业务相关的操作。
  • 可复用性:Service层的代码通常设计为可复用的,这意味着它可以被不同的Controller调用,甚至在不同的应用中使用。
  • 数据访问抽象:Service层通常通过 ORM 对数据库进行访问。
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient();

class PostService {
    async find() {
        return await prisma.post.findMany({
            include: {
                author: true
              }
        });
    }
    
    async create(postDto) {
        return await prisma.post.create({
            data: {
                title: postDto.title,
                content: postDto.content,
                authorId: postDto.authorId
            }
        })
    }
}

module.exports = new PostService();

路由注册

  • 我们在 Controller 中定义了 routes,维护了路由,在 routes/index.js 是统一注册路由的位置。
  • 并且通过 asyncHandler 给路由添加异步异常捕获。当发生异常捕获的时候,asyncHandler 会通过 next(error) 将异步异常再抛出去,这样可以在全局的异常中捕获。
  • 这里我们是通过 router.use("/api", routers); 添加全局的路由前缀。通过 const routerPath = prefix + path; 添加每个 Controller 路由前缀,减少了路由共通前缀部分的重复定义。
const asyncHandler = require("express-async-handler");
const { Router } = require('express');
const {
  userController,
  postController,
  loginController,
} = require("../controllers/index");
const router = Router();
const allController = [userController, postController, loginController];
const routers = allController.map((item) => {
  const router = Router();
  const prefix = item.controller;
  item.routes.forEach((route) => {
    // 将 Controller 中定义的路由实际注册到 router 上
    const { method, path, handler } = route;
    const routerPath = prefix + path;
    router[method.toLowerCase()](routerPath, asyncHandler(handler.bind(item)));
  });
  return router;
});
router.use("/api", routers);
module.exports = router;

中间件

Express是一个路由和中间件web框架,它自己的功能很少:Express应用程序本质上是一系列中间件函数调用。

那什么是中间件呢?

在Express框架中,中间件是一个函数,它处理HTTP请求和响应对象,并且可以向请求-响应循环中的下一个中间件传递执行权限。一个中间件可以执行以下任务:

  1. 执行任何代码。如进行一些逻辑验证,逻辑计算,日志输出等。
  2. 对请求和响应对象进行更改。
  3. 结束请求-响应周期。也就是对应 http 中 res.end() 返回 http 请求响应。
  4. 调用堆栈中的下一个中间件。也就是调用 next() 函数。

如果当前中间件没有结束请求-响应循环,它必须调用 next() 方法将控制权传递给下一个中间件。否则,请求将被挂起

转义中间件

对请求中的某些数据进行清理,以防止例如跨站脚本攻击(XSS)等安全问题。转义中间件可能会对请求体、查询参数、路径参数等进行HTML实体编码,以确保任何特殊字符都被正确处理。

  • 中间件模板函数就是 (req, res, next) => {}
  • 在每次请求中间件都会被调用,所以你可以对请求中的参数进行转义。

const escapeHtml = require('escape-html')
module.exports = (req, res, next) => {
  if (req.body) {
    req.body = JSON.parse(JSON.stringify(req.body), (key, value) => {
      if (typeof value === "string") {
        return escapeHtml(value);
      }
      return value;
    });
  }
  if (req.query) {
    req.query = JSON.parse(JSON.stringify(req.query), (key, value) => {
      if (typeof value === "string") {
        return escapeHtml(value);
      }
      return value;
    });
  }

  if (req.params) {
    req.params = JSON.parse(JSON.stringify(req.params), (key, value) => {
      if (typeof value === "string") {
        return escapeHtml(value);
      }
      return value;
    });
  }
  next();
};

日志记录中间件

Prisma 连接数据库

  1. 安装 Prisma CLI:
    在项目中安装 Prisma CLI 是第一步。通常,我们会将其作为开发依赖安装到项目中,以避免不同项目间的版本冲突。
npm install prisma --save-dev
  1. 初始化 Prisma 项目:
    使用 Prisma CLI 提供的 init 命令来初始化项目,这会创建一个 prisma 目录,其中包含 schema.prisma 文件,以及一个 .env 文件用于存储环境变量。
npx prisma init
  1. 配置环境变量,连接 mysql 数据库:
    .env 文件中配置数据库连接字符串。这个字符串包含了数据库的类型、用户名、密码、主机地址、端口和数据库名称。
    1. root用户名
    2. 123456 密码
    3. ip和端口号
    4. 数据库名称
DATABASE_URL="mysql://root:123456@localhost:3306/primadb"
  1. 定义数据模型:
    schema.prisma 文件中定义你的数据模型。这些模型将映射到数据库中的表。
model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
}
  1. 生成 Prisma 客户端,通过客户端来操作数据库:
    使用 generate 命令来生成 Prisma 客户端,这是与数据库交互的 JavaScript/TypeScript 客户端。
npx prisma generate
  1. 迁移数据库,生成迁移数据 sql ,记录变更历史:
    使用 migrate 命令来创建数据库迁移,这将根据你的模型定义来更新数据库架构。
npx prisma migrate dev --name init

迁移记录如下

  1. 使用 Prisma 客户端:
    现在你可以在代码中使用 Prisma 客户端来进行数据库操作了。例如,创建一个新的用户:

PrismaClient 实例在创建时会自动连接到数据库。当实例不再使用时,Prisma 会管理连接的关闭。

const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
async function main() {
  const newUser = await prisma.user.create({
    data: {
      name: 'Alice',
      email: 'alice@prisma.io',
    },
  })
  console.log(newUser)
}
main()
  .catch(e => {
    throw e
  })
  .finally(async () => {
    await prisma.$disconnect()
  })

JWT JSON Web Tokens

传统模式

1、用户向服务器发送用户名和密码。

2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。

3、服务器向用户返回一个 session_id,写入用户的 Cookie。

4、用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。

5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,需要依赖 Redis 服务器统一管理 session 数据。

举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

  • 一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。
  • 另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表

JWT

  • Header(头部) 表示算法和token类型
  • Payload(负载) 实际存放数据的地方
  • Signature(签名)密钥

jsonwebtoken 生成 token

const jwt = require('jsonwebtoken');
const secretKey = 'shhhhh'; 
const token = jwt.sign(
  {
    username: user.username
  },
  secretKey,
  {
    expiresIn: '1m' // 设置过期时间为1分钟
  }
);
  • username 、expiresIn 对应 payload
  • secretKey 用于创建和验证JWT的签名的密钥。
  • 默认情况下,如果你不指定算法,它将使用HS256
{
  "alg": "HS256",
  "typ": "JWT"
}

express-jwt JWT验证

const {expressjwt: jwt} = require("express-jwt");
const server = express();   
server.use(
  jwt({ secret: secretKey,  algorithms: ["HS256"] }).unless({
    path: [/^/api/login/],
  })
);
  • server.use():将 express-jwt 中间件添加到服务器的中间件链中。这意味着,对于进入服务器的每个HTTP请求,express-jwt 中间件都会尝试验证请求中的JWT。
  • jwt({ secret: secretKey, algorithms: ["HS256"] }):配置 express-jwt 中间件,指定使用 secretKey 作为签名密钥,并且仅接受 HS256 算法签名的JWT。
  • .unless({ path: [/^/api/login/] }):这是一个条件中间件,它告诉 express-jwt 中间件在某些路径上跳过JWT验证。在这个例子中,任何以 /api/login 开头的路径都不会进行JWT验证。

特点

  1. JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
  2. JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
  3. JWT 本身由于传输的内容包含三部分,会导致每次请求传输到内容增大。

错误捕获

在 Express 应用中,你可能会遇到以下几种异常情况:

  1. 数据库错误: 如连接失败、唯一性约束错误、类型约束错误等。
  2. 无效的用户输入: 用户提交的数据不符合预期,如类型错误、格式错误等。
  3. 文件系统错误: 如文件不存在、权限问题等。
  4. 代码错误: 如逻辑错误、未处理的异常、手动抛出错误等。
  5. 资源限制: 如内存不足、数据库连接数超限等。

自定义的错误处理函数

同步错误捕获

举个简单的例子

const express = require('express');
const app = express();
let port = 3001;

app.use((error,req,res,next)=> {
  console.log('error :', error)
})

// 开始监听端口
app.listen(port, () => {
  console.log(`Express server is running on port ${port}`);
});

异步错误捕获

但是错误中间件有个缺陷只能捕获同步错误,如下几个路由中,如果在异步中抛出错误,则错误中间件是无法捕获错误

const express = require('express');
const app = express();
let port = 3001;

// 同步路由处理器
app.get('/sync-error', (req, res, next) => {
  throw new Error('同步错误'); // 这个错误可以被错误中间件捕获
});

app.get('/async-callback-error', (req, res, next) => {
  setTimeout(() => {
    throw new Error('异步回调错误'); // 这个错误无法被错误中间件捕获
  }, 1000);
});

// 未处理的 Promise 拒绝
app.get('/unhandled-rejection', (req, res, next) => {
  Promise.reject(new Error('未处理的 Promise 拒绝')); // 这个拒绝无法被错误中间件捕获
});

// 异步路由处理器
app.get('/async-error', async (req, res, next) => {
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('异步错误')); // 这个拒绝无法被错误中间件捕获
    })
    }, 1000);
  });
});

app.use((error,req,res,next)=> {
  console.log('error :', error)
})

// 开始监听端口
app.listen(port, () => {
  console.log(`Express server is running on port ${port}`);
});
  1. 这个时候我们可以使用 express-async-handler 来包裹回调函数。
  2. 并且函数改为 async 函数,在 async 函数中抛出的错误会被错误中间件捕获。
  3. express-async-handler 本质就是说一个高阶函数,返回一个中间件函数。
    1. 在中间件函数中执行了原本的路由函数,由于路由函数改写为 async 函数,它返回的是一个 Promise 对象。
    2. 这里对 promise 添加了 catch 错误捕获,如果有错误就回调用 next(error) 函数,next() 中如果有参数就会最终调用错误中间件。
const asyncHandler = require('express-async-handler')

express.get('/', asyncHandler(async (req, res, next) => {
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('异步错误')); // 这个拒绝无法被错误中间件捕获
    })
    }, 1000);
  });
}))

unhandledRejection 事件

如果我们通过 express-async-handler 包裹了路由回调函数,基本场景的 Promise 拒绝都会被捕获。如有遗漏的场景可以使用,unhandledRejection 事件作为兜底。

对于未处理的Promise拒绝,Node.js提供了 unhandledRejection 事件。当Promise被拒绝且在事件循环的一个轮询内没有错误句柄附加时,会触发此事件。一般作为兜底处理未处理的Promise拒绝。

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);

  // 记录错误到日志文件
  logErrorToDatabase(reason);
});

uncaughtException 事件

'uncaughtException' 是用于异常处理的粗略机制,仅用作最后的手段。 事件_不应该_用作 On Error Resume Next 的等价物。 未处理的异常本质上意味着应用程序处于未定义状态。 在没有从异常中正确恢复的情况下尝试恢复应用程序代码可能会导致其他不可预见和不可预测的问题。

'uncaughtException' 的正确用法是在关闭进程之前对分配的资源(例如文件描述符、句柄等)执行同步清理。 'uncaughtException' 之后恢复正常操作是不安全的。

process.on('uncaughtException', (error, origin) => {
  console.error('Uncaught Exception:', error);
  console.error('Origin:', origin);

  // 记录错误到日志文件
  logErrorToDatabase(error);

  // 执行必要的清理操作
  cleanupResources();

  // 优雅地关闭应用程序
  process.exit(1); // 非零状态码表示异常退出

});

function logErrorToDatabase(error) {
  // 实现记录错误到数据库的逻辑
  console.error('Logging error to database:', error.message);
}

function cleanupResources() {
  // 实现资源清理逻辑
  console.log('Cleaning up resources...');
}

网络安全

常见的网络安全问题

跨站脚本攻击 (XSS)用户通过表单或其他方式提交恶意脚本,这些脚本可以在其他用户的浏览器中执行,从而窃取信息或进行其他恶意活动。使用输入验证和输出编码,使用 helmet设置 X-XSS-Protection头部。
跨站请求伪造 (CSRF)攻击者利用已认证用户的浏览器发送恶意请求,这些请求看起来像是合法的用户操作。使用 csurf中间件生成和验证 CSRF 令牌。
SQL 注入攻击者通过在输入字段中插入恶意 SQL 代码,尝试操控数据库查询。使用参数化查询或 ORM(如 Sequelize、Mongoose)来避免直接拼接 SQL 语句。
HTTP 头部注入攻击者可能通过注入 HTTP 头部来操纵客户端的行为。使用 helmet设置安全的 HTTP 头部。
点击劫持 (Clickjacking)攻击者通过嵌入 iframe 的方式诱骗用户点击某个按钮或链接。使用 helmet设置 X-Frame-Options头部,防止页面被嵌入到 iframe 中。
内容安全策略 (CSP)CSP 是一种 HTTP 头部,它允许网站开发者定义一个白名单,指定哪些来源的内容是可信的。浏览器会根据这个白名单来决定是否加载和执行页面中的资源,如脚本、样式表、图像等。使用 helmet设置 Content-Security-Policy头部,限制页面可以加载的资源。

使用 helmet

通过设置HTTP响应头来保护Express应用,常见的响应头如下,能解决如下问题:

跨站脚本攻击 (XSS)用户输入恶意脚本,可能在其他用户的浏览器中执行。xssFilter()X-XSS-ProtectionX-XSS-Protection: 1; mode=block
点击劫持 (Clickjacking)攻击者通过嵌入 iframe 的方式诱骗用户点击某个按钮或链接。frameguard()X-Frame-OptionsX-Frame-Options: DENYX-Frame-Options: SAMEORIGIN
内容安全策略 (CSP)控制页面可以加载的资源,防止 XSS 和其他注入攻击。contentSecurityPolicy()Content-Security-PolicyContent-Security-Policy: default-src 'self'; script-src 'self' https://code.jquery.com https://stackpath.bootstrapcdn.com; style-src 'self' https://stackpath.bootstrapcdn.com; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; media-src 'self'; frame-src 'none'; report-uri /csp-report-endpoint/

接口测试

在 client/client.html 文件中写了一个简单的接口测试页面,测试步骤如下。

  • 打卡测试 html 文件
  • 对接口权限、接口错误处理、接口响应进行测试

总结

通过完成一个完整的 Express 项目,我们配置了一个企业级项目通用的一些基础功能。你可以查看 gitee express-best-practice完整代码了解更多。
如果对你有帮助的话不妨点个赞,感谢你的时间,下篇再会!