首页 / 文章

在 Next.js App Router 代码库中分离域层、数据层和 UI 层

一个 Pokédex 案例研究,展示如何利用 Prisma、Zod、Cookie 认证及缓存功能将 Next.js App Router 应用拆分为领域层、数据层和表现层。

发布者:REACTAPP.TOP阅读约 9 分钟2172 词

来源: Clean Architecture for Frontend with Next.js (App Router): A Practical Pokédex Case Study

Next.js App Router 将原本的 React 单页应用转变为全栈运行时:服务器端渲染、路由处理程序、缓存以及直接数据库访问都集中在同一个代码库中。如果将这个代码库仅视为“组件”,其功能会迅速下降,因为页面需要与数据库交互,小部件要调用第三方 API,而业务规则则隐藏在 JSX 条件语句中。本指南以一个简单的宝可梦图鉴应用为例,将整洁架构应用于 App Router 项目,展示各类功能应归属于何处,以及该结构会暴露出哪些 Next.js 相关的问题。

示例应用及其限制

这个宝可梦图鉴应用刻意设计得较为简单,但涵盖了真实产品所具有的各种依赖关系:

  • 远程数据:无需 API 密钥的公共 PokeAPI。
  • 本地持久化:通过 Prisma 访问 PostgreSQL。
  • 状态存储:每位用户的收藏内容,以及七天后会刷新的宝可梦详情缓存。
  • 身份验证:邮箱和密码,通过 httpOnly Cookie 保存会话信息,同时使用客户端会话管理机制。
  • 加载用户体验:利用 React Suspense 与骨架组件实现加载状态显示。
  • 类型安全:在严格模式下使用 TypeScript,并通过 Zod 进行运行时验证。
  • 数据修改:不使用 Server Actions,所有写操作均通过 /app/api/* 下的路由处理程序完成。
  • 没有边界控制会出什么问题

    Next.js 允许任何文件访问任意内容,这正是代码库变得混乱的根源。常见症状包括:

    • 直接导入 Prisma 客户端的页面文件;
    • 自行调用 PokeAPI 的组件;
    • 在用户界面中以临时条件语句形式表达的领域规则;
    • 由于所有功能都与框架绑定,因此不启动框架就无法测试的逻辑。

    整洁架构解决了这些问题:领域规则不再依赖框架,无需修改界面即可更换数据源,无需数据库或 Next.js 运行时也能测试用例,且持久化层或界面元素可以更改而无需重新编写代码。

    三层结构与单一依赖方向

    项目被划分为三层,且依赖关系仅指向内部的领域层:

    • 领域层(核心):实体、存储库接口以及用例。该层不包含任何 Next.js 的导入,也没有 Prisma 或具体实现代码。
  • 数据层:存储库实现、Prisma 访问机制、远程 API 客户端、DTO、映射器以及缓存功能。所有基础设施都归类于此。
  • 表现层:页面、视图、小部件、框架结构、会话提供器以及数据获取辅助函数。它纯粹负责用户界面,仅通过稳定的 API 或适配器暴露的用例来获取数据。
  • 一个有用的测试方法:删除数据层和表现层文件夹,应用程序仍应能通过类型检查。

    保持 App Router 文件夹简洁

    清晰的思维模型始于整洁的目录结构。/app 目录被视为框架的边界,其中仅包含 Next.js 需要识别的内容:

    • 路由及路由组;
    • layout.tsx;
    • 位于 app/api/** 下的路由处理程序。

    业务逻辑不应放在那里。在 Pokédex 中,app/ 目录包含根布局、一个 (public) 路由组(用于登录、注册和查看宝可梦详情)、一个 (app) 组(用于列表显示和收藏功能)以及 api/ 目录下的处理程序。所有核心内容都位于 libs/domain/**、libs/data/** 和 libs/presentation/** 目录下,此外还有一个存放模式定义和迁移文件的 prisma/ 目录。

    领域层:仅包含规则与契约

    领域层包含三类内容:

    • 实体,如 Pokemon 和 User。
    • 仓库接口,如 PokemonRepo、FavoritesRepo 和 UserRepo。
  • 用例包括GetPokemonDetails、ListPokemon、AddFavorite、RemoveFavorite、GetFavorites、RegisterUser以及LoginUser等。
  • 有两条规则确保其规范性。首先,该领域内不得导入Next.js、Prisma或任何特定运行时模块;其次,该领域只能返回领域实体,绝不能返回持久化模型,因此Prisma的行类型无法通过用例泄露到用户界面中。

    用例仅依赖于仓库接口,而非其实现细节。AddFavorite通过构造函数或工厂函数获取FavoritesRepo,因此无法区分Postgres数据库与内存中的测试模拟对象。这种依赖倒置正是该模式的核心,而限制导入的代码检查规则则确保了这一原则得到遵守。

    数据层:基础设施所在之处

    所有与外部世界交互的内容都集中在数据层中:

    • Prisma 客户端及模式文件;
    • 带有 Impl 后缀的存储实现类;
    • 封装了 PokeAPI 的远程服务;
    • 用于数据库的本地服务(收藏项和缓存);
    • 在数据格式与实体之间进行转换的 DTO 及映射器;
    • 具有七天有效期的缓存策略。

    libs/data/di 中的一个小型依赖注入容器将实现类与接口关联起来,这样应用程序的其他部分就可以直接从该容器获取现成的功能模块,而无需自行创建存储实现类。

    结合远程源与本地源

    远程数据通过远程服务从 PokeAPI 获取;本地存在两个表:Favorites 与已认证用户相关联,PokemonCache 则存储原始数据内容及时间戳。

    PokemonRepoImpl 负责决定查询哪个数据源,因此无论是业务逻辑还是用户界面都无需知晓缓存的存在。

    七天缓存策略

    宝可梦的详细信息会被缓存在本地,以便详情页更快加载,同时避免频繁调用免费的公共 API。决策流程很简单:首先查找缓存记录;如果存在且时间戳在七天以内,则直接返回对应数据;否则从 PokeAPI 获取数据,验证内容后将其与新的时间戳一起存入 PokemonCache,最后返回处理后的实体。

    将数据过期检查功能设计为小型纯函数(例如放在 libs/data/utils/cache.ts 中),这样就能轻松进行单元测试。如果记录已过期而 PokeAPI 又不可用,可以考虑直接返回旧版本数据而非报错;这类策略正好适合在这一层实现。

    作为稳定契约的路由处理程序

    虽然 Server Components 可以直接调用相关功能,但该项目仍通过 /app/api/** 下的处理程序来管理数据访问,充当前端的后端代理。这样做有三个原因。

    1. 统一的接口既可用于服务器端视图、客户端组件,也可服务于未来可能出现的其他外部系统。
    2. 由于所有数据访问都遵循相同的契约,因此能保持系统的一致性。
    3. 这很符合整洁架构的理念:HTTP API 仅仅是围绕核心功能存在的另一层边缘适配器而已。

    如需了解此模式的更多信息,请参阅将 Next.js 路由处理程序转换为专门的 BFF 层。

    这里有一个与版本相关的细节需要注意。在最近的 Next.js 发布版本中(从 Next.js 15 开始),动态路径段中的 params 是一个 Promise。路由处理程序和页面在读取 name 之前必须先 await params;请查阅对应版本的文档以了解其确切的接口定义。

    身份验证通过三个端点实现:

    • POST /api/auth/login 用于验证凭据并设置一个 httpOnly Cookie,例如 pokedex_token。
    • GET /api/auth/session 用于读取该 Cookie,验证令牌并返回当前的会话状态及用户信息。
  • POST /api/auth/logout会清除该cookie,并返回{ ok: true }作为响应。
  • 为何要使用httpOnly cookie

    httpOnly cookie无法被客户端JavaScript读取,因此注入的脚本无法窃取JWT,同时客户端代码也不会存储或附加任何令牌。由于浏览器会自动发送该cookie,应为其添加SameSite和Secure属性,并为写操作考虑添加CSRF防护。

    UI的会话提供机制

    用户界面仍需知道是否有用户登录、是哪位用户,以及在用户登录或注销后何时需要刷新数据。一个小型客户端 SessionProvider 可以解决这些问题:它从 /api/auth/session 加载会话信息,将 status、isLoggedIn 和 user 保存在 React 状态中,并提供 syncAfterLogin() 和 logout() 方法。组件会使用这些上下文信息,而不会直接操作令牌。

    服务器组件、Suspense 与骨架组件

    在适用的情况下,视图会在服务器端渲染并包裹在 Suspense 中。这样不仅能加快首次绘制速度、便于页面索引,还能让数据流更加清晰。一个典型的页面由两部分组成:一个是作为回退方案的骨架组件的 Suspense 边界,另一个则是负责加载数据并渲染完整用户界面的服务器端视图。

    内部请求中的 Cookie 转发

    BFF 方法中存在一个隐蔽的陷阱。当服务器组件通过绝对 URL 调用应用自身的路由处理程序时,服务器端的 fetch 操作会生成一个全新的外部请求,因此不会自动包含用户的 Cookie。解决方法是使用 headers() 方法读取传入的 cookie 标头,并在调用 /api/favorites 时将其作为 Cookie 标头显式传递。当 URL 是根据请求的 host 和协议动态生成的时,这个问题最为常见。

    另一种方案是完全跳过内部HTTP,让服务器视图通过容器直接调用用例。HTTP路由为所有操作提供统一的接口,但代价是每次渲染都需要额外一次网络传输,且依赖于服务器可访问(因此在静态构建时无法使用该方案)。直接调用用例虽然更快更简单,但会导致服务器视图和客户端组件使用不同的入口点。该项目保留了稳定的API接口并负责转发Cookie。

    收藏功能的端到端实现

    收藏功能需要经过身份验证的用户、数据库读取操作以及客户端侧的修改。相关实现分为两部分:

    • FavoritesView,这是一个服务器组件,它使用转发的Cookie获取/api/favorites的数据并渲染列表。
  • RemoveFavoriteButton是一个客户端组件,它会发送DELETE /api/favorites/:name请求,随后调用router.refresh()以重新获取服务器渲染的列表。
  • router.refresh()会在保留客户端状态的同时重新渲染该路由对应的服务器组件。

    使用Zod验证所有边界条件

    Zod会在数据跨越每个边界时对其进行检查:

    • 在映射之前,将PokeAPI的响应作为远程DTO进行验证;
    • 认证端点所接收的请求体;
    • 存储在数据库中的JSON数据,例如缓存的信息。

    在边界处进行解析可以将那些隐蔽的数据结构变化,比如上游系统中字段名称的更改,转化为明确的早期错误,并在代码中记录每一项契约要求。

    简洁但实用的测试基准

    无需庞大的测试套件即可验证架构。两组测试便已足够:

    • 缓存过期检测工具:给定时间戳后,验证其是否已超过七天,包括边界情况。
    • 用例行为测试:用模拟对象替换仓库接口,确保每个用例都能正确调用它们。完全不要在领域层测试中使用 Prisma。

    能够在不运行 Next.js 的情况下测试用例,正是分层设计带来的显著优势。

    在本地运行代码

    可通过 HTTPS 克隆该仓库:

    git clone https://github.com/mahmoudmagdi/pokemon-clean-arch.git
    cd pokemon-clean-arch
    npm install
    

    或者,如果已在 GitHub 配置了密钥,也可以通过 SSH 克隆:

    git clone git@github.com:mahmoudmagdi/pokemon-clean-arch.git
    cd pokemon-clean-arch
    npm install
    

    接下来,根据提供的模板创建环境文件,并填入其中列出的数据库地址及密钥信息:

    cp .env.example .env
    

    启动一个 PostgreSQL 16 容器。此处的数据库名称、用户名和密码仅适用于本地开发环境,应与 .env 文件中的设置保持一致;命名卷可确保数据在容器重启后依然存在。

    docker run -d \
      --name pokedex-postgres \
      -p 5432:5432 \
      -e POSTGRES_DB=pokedex_db \
      -e POSTGRES_USER=pokedex_user \
      -e POSTGRES_PASSWORD=pokedex_password \
      -v pokedex_pg_data:/var/lib/postgresql/data \
      postgres:16
    

    执行 Prisma 迁移以创建表结构:

    npx prisma migrate dev
    

    最后,启动开发服务器:

    npm run dev
    

    已部署的版本可通过 在线演示查看。

    总结

    Next.js 中的整洁架构主要在于确定各类功能应存储在何处:

    • 将 /app 作为轻量级的适配层,将逻辑代码移至 libs 目录。
    • 让领域层负责定义接口和实体,仅让数据层了解 Prisma、HTTP 和缓存相关内容。

  • 在每个边界处进行验证,从而确保合约在运行时得到遵守,而不仅仅体现在类型层面。
  • 慎重选择是使用内部路由处理程序(一个合约,需要 Cookie 转发)还是直接调用用例函数(速度更快,组件更少)。
  • 先从缓存策略和用例这些基础部分开始测试。
  • 对于周末制作的原型来说,这样的结构可能过于复杂;但对于会持续发展的应用而言,这些边界能将每次更改限制在局部范围内。