首页 / 文章 / 超越CRUD:让MERN应用保持可维护性的十种架构习惯

超越CRUD:让MERN应用保持可维护性的十种架构习惯

了解在 MERN 应用不断发展过程中能保持其健康运行的系统级习惯:数据所有权、API 接口规范、派生状态、分层代码结构、精简的负载数据以及统一的错误处理方式。

1832 词

大多数 MERN 教程都以一个可运行的 CRUD 应用作为结尾:MongoDB 用于存储数据,Express 提供若干路由,React 负责渲染列表,可能还会包含登录界面。这样的应用虽然能运行,但很难在规模扩大后仍保持不变。随着功能增多和团队成员增加,API 变得难以修改,状态容易不同步,页面加载速度变慢,而且调试所花费的时间甚至超过开发时间。问题往往出在架构而非工具上。本指南介绍了十种能够解决这些问题的架构习惯,帮助你将 MERN 应用视为一个整体系统,而非四个独立库的简单组合。

将 MERN 视为数据流,而非工具列表

通常人们对这个技术栈的描述仅限于其组成部分:

MongoDB + Express + React + Node.js

这虽然准确,但并未说明各部分之间是如何协作的,就好比只将汽车描述为引擎、四个轮子和方向盘而已。更直观的表述应是展示数据从用户端传送到数据库再返回的流程:

User
   │
   ▼
React
   │
HTTP
   │
   ▼
Express + Node
   │
Database Queries
   │
   ▼
MongoDB

每根箭头都代表一个具有特定规则的边界:浏览器可以发送什么内容,服务器能接收什么,数据库又会存储什么。以下大部分原则都是关于确定这些边界上会发生什么事的。

1. 为每条数据指定一个负责人

在开发新功能时,首先要明确相关数据的归属者。在许多年轻的代码库中,这个答案往往并不清晰。当前用户的详细信息可能同时存在于组件状态、Redux 存储、localStorage、最新的 API 响应以及其他缓存中。迟早会有一份数据落后于其他版本,从而导致界面显示同一事实的两个矛盾版本。

更明确的职责划分如下:

  • MongoDB 是持久化数据的真实来源。
  • 后端 负责制定决定数据如何变化的业务规则。
  • 前端 负责展示数据并通过 API 提出修改请求;任何客户端端的副本都只是缓存,并非权威数据源。

核心原则是每条数据都只有一个真实来源,其他所有副本都应意识到这些数据可能是过时的。服务器状态库的存在主要就是为了明确管理这种缓存问题;关于该问题的更多内容,请参阅使用 React Query 和 Redux 重新思考服务器状态。

2. 将 API 设计为契约

典型的第一个接口如下所示:

app.get("/users", async (req, res) => {
  const users = await User.find();
  res.json(users);
});

这种方式虽然可行,但却隐含了一个承诺:无论模型包含哪些字段,响应始终会是完整的用户文档数组。一旦有移动应用、控制面板、合作伙伴集成或其他团队依赖这种数据结构,对其进行更改就可能会破坏它们的功能。

在添加新接口之前,请先决定:

  • 它返回的具体字段是什么,而不是直接输出整个模型(因为这可能会泄露内部或敏感字段);
  • 日后是否可以对其进行修改而不影响客户端,或者是否需要版本控制;
  • 还有哪些系统可能会使用它。

精心设计的 API 可以比多个前端框架更长久地使用;而粗心设计的 API 几个月内就会变成技术债务。

3. 尽量减少 React 状态的存储

React 虽然被视作一个 UI 库,但在实际应用中大部分难题都源于状态管理。一个常见的错误是将本可以通过计算得到的值也存储在状态中:

const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);

此处 filteredUsers 完全由 users 决定。如果将其单独存储,每次更新时都必须保持两者同步,一旦忘记就会导致列表过时。应在渲染时直接计算它:

const filteredUsers = users.filter(user => user.active);

规则是只存储那些无法计算的内容,其余所有数据都应通过推导获得。如果某种推导过程的成本实在过高,可以使用useMemo进行缓存,但它依然属于推导出的数据,而非第二套真实数据源。

4. 记住CRUD才是最简单的部分

许多项目仅停留在这四种基本操作上:

Create
Read
Update
Delete

实际的生产环境后端会在这些操作之外增加更多功能:数据验证、身份认证、权限控制、业务规则、速率限制、审计追踪、日志记录以及通知功能。以下是简单粗暴的创建操作示例:

await User.create(req.body);

与先检查输入值的版本相比

if (!isValid(req.body))
    throw new Error("Invalid input");

并在写入数据前确认调用者具有相应操作权限:

if (!canCreateUser(req.user))
    throw new Error("Unauthorized");await User.create(req.body);

这种简单的实现方式也存在批量赋值的风险:直接将req.body传递给create函数,会导致客户端能够设置架构所允许的任何字段,包括类似role这样的标志位。应当明确地对字段进行验证并仅选择允许的那些字段。还需注意,无论错误信息如何显示,权限检查失败在概念上都属于403(禁止)状态而非401状态。写入数据很简单,但保护数据才是后端开发中较为棘手的部分。

5. 按层次划分职责

业余代码库与专业代码库最明显的区别在于逻辑的分布位置。如果混杂不同职责,会导致路由处理函数中充斥着数据库查询语句,React组件里满是验证规则,而控制器则塞满了业务逻辑,而且这些文件的规模会不断增大。

分层架构的后端为每一层分配特定的任务:

Routes
   │
Controllers
   │
Services
   │
Repositories
   │
Database

路由将URL映射到处理函数,控制器负责将HTTP请求转换为函数调用,服务层存储业务规则,数据访问层则与数据库交互。结构小巧且功能单一的组件更易于测试和修改。如需深入了解,可参阅分层式Node.js API设计。

6. 首先从数据源层面解决性能问题

当被问及如何加快React应用的运行速度时,大多数开发者会首先想到使用useMemo、React.memo和useCallback。这些方法确实有帮助,但许多性能问题其实早在React介入之前就已经存在了。想象这样一个请求场景:

GET /users

它返回

50,000 users

而屏幕上仅显示

10 users

再多的缓存也无法弥补传输和解析数万条不必要的记录所带来的开销。应从源头解决此问题:

  • 对结果进行分页处理;
  • 在服务器端进行过滤;
  • 仅返回客户端所需的字段;
  • 压缩响应数据;
  • 有计划地缓存数据,并制定明确的失效策略。

最容易渲染的组件就是那些从不接收其不需要的数据的组件。

7. 将错误处理纳入设计之中

开发代码常常会默默吞下这类错误:

try {
   ...
}
catch(error){
   console.log(error);
}

仅记录日志后继续处理会掩盖故障,使客户端和监控系统无法察觉。生产环境中的 API 需要格式统一且机器可读的错误信息:

return res.status(400).json({
    message: "Invalid email address",
    code: "INVALID_EMAIL"
});

具有易于人类理解的message以及机器可读的code的稳定结构意味着前端可以将代码映射到具体的UI消息,日志可以按代码分类,异常频率过高时能触发警报,而且调试可以从已知的类别开始而非堆栈跟踪。故障是不可避免的,关键在于能够以可预测的方式处理它们。

8. 按功能组织代码

当文件只有二十个时,任何文件夹结构都行得通;但一旦达到五百个,文件夹结构的合理性就变得至关重要。按技术类型划分的布局会将某个功能分散到整个目录树中:

routes/
controllers/
models/

按功能分组能让与某一领域相关的所有内容集中在一处:

users/
    routes.js
    controller.js
    service.js
    validation.js
orders/
    routes.js
    controller.js
    service.js

当订单逻辑发生变化时,只需打开orders文件夹即可,无需处理其他内容。功能文件夹还能让代码的所有权划分、代码审查以及后续拆分到独立服务中的工作变得更加容易。

9. 从系统的角度思考,而非单个任务

像“添加登录功能”这样的需求请求可以通过简单的表单和路由来满足。但具备系统思维的人会进一步思考相关问题:身份验证的完整流程是怎样的、令牌存储在何处、权限是如何控制的、令牌过期后会发生什么,以及未来的移动客户端将如何登录。提前解答这些问题虽然会花费更多时间,但能避免日后重新编写代码。

10. 有意识地权衡取舍

没有哪种架构在所有情况下都是最优的。每个选择都会带来收益,同时也会付出代价:

  • 简单架构:构建更快,但扩展性较差。
  • 微服务:可独立扩展,但运营复杂性显著提高。
  • 全局状态:便于在各个组件间共享,但调试更为困难。
  • 规范化数据库设计:数据重复较少,但需要更多连接或查询操作。
  • 积极使用缓存:响应速度更快,但存在缓存失效的持续问题。

优秀的工程师并非那些掌握所有模式的人,而是能够判断在何种情况下使用某种模式才是值得的那些人。

MERN项目在发展过程中通常如何衰败

阶段1:一切都很简单

首个版本仅包含最基本的功能:

CRUD
Authentication
Dashboard
Deployment

代码量很小,每个人都能理解其全部内容。

阶段2:发展过程中暴露出捷径

越来越多的用户、功能以及开发者加入。代码库中到处都是重复的逻辑、不一致的接口端点、缓慢的页面加载速度、混乱的状态管理,以及极其繁琐的调试过程。

第三阶段:工具成为替罪羊

团队会得出结论,认为 React 无法支撑大规模应用,或者选择 MongoDB 是个错误。但实际上通常并非如此,只是架构从未随着应用程序的发展而同步进化。

用餐厅来类比各层结构

可以把这个技术栈想象成一家餐厅。MongoDB 就是储存所有食材的食品储藏室,Express 和 Node 则是厨房:它们决定要烹饪什么、如何准备以及谁有权限点餐。React 则是前台,负责将做好的菜肴呈现在客人面前。客人无需了解厨房的运作方式,而厨房也不必关心盘子如何摆放在桌上。每个部分都专注于做好自己的工作,这正是 MERN 应用所需要的分离结构。

核心要点

理解 MERN 并不在于编写查询、路由和组件,而在于了解数据流动的路径、将业务规则放在合适的层级、在不破坏客户端的前提下升级 API、保持状态最小化,以及认识到早期决策的累积效应。

  • 为每份数据指定一个负责人,将其他副本视为缓存使用。
  • 将接口视为契约,返回结构清晰、意图明确的响应。
  • 通过推导生成状态,而非直接复制。
  • 在写入任何内容之前先对字段进行验证、授权和白名单检查。
  • 在优化渲染之前,先确保源端的负载量较小。
  • 返回一致的编码错误信息,并按功能对代码进行分类。
  • 当出现新功能时,最重要的问题不是如何构建它,而是各项职责应归属于何处。

    相关阅读