首页 / 文章 / 平衡 Prisma CRUD 生成与有针对性的路由控制

平衡 Prisma CRUD 生成与有针对性的路由控制

了解如何通过基于模式的 Prisma CRUD 路由生成方式来消除重复的样板代码,同时将信任机制、作用域定义及接口暴露相关决策保留在应用程序代码中。

2767 词

CRUD接口大多只是重复Prisma模式已有的信息。模型名称会变成路由路径的一部分,标量字段则转化为输入验证规则,而Prisma的调用则会对应到控制器方法中。关联关系则意味着又多了一层对传入请求的解析工作,以及对应输出内容的处理。

我们对这一主题的了解是基于开发者开发的开源工具,依据是其当前的文档以及可供自行测试的示例,而非其被广泛使用的宣称。

正是由于这种重复看似无害,实际上却会带来很大成本。每复制一个手动编写的处理函数,就多了一个可能导致分页默认值、允许的字段范围、租户权限控制以及错误处理方式与其他部分出现差异的环节。

prisma-generator-express 可以帮您省去这些繁琐的工作,将其整合到 prisma generate 步骤中。它能生成适用于 Express、Fastify 或 Hono 的路由器。还有一个配套包 prisma-guard,可以同时生成具备 Prisma 感知能力的验证逻辑和作用域元数据,而操作规范则明确了每种调用者被允许传递哪些参数。

最终得到的并非无需代码的应用程序,而是一个边界层逻辑大幅减少、对剩余决策拥有更清晰掌控权的应用程序。

这一点至关重要。凡是架构本身能够完全描述的内容,都应由生成工具来处理。而认证机制、哪些操作会被公开、调用者是谁,以及任何需要架构之外信息才能决定的策略,都仍需体现在应用程序代码中。

将可重复的任务整合到单个生成步骤中

生成的 API 由三个相互关联的部分组成:Prisma Client、保护元数据以及 HTTP 路由器本身。

generator client {
  provider = "prisma-client-js"
}
generator guard {
  provider          = "prisma-guard"
  output            = "../generated/guard"
  enforceProjection = "true"
}generator express {
  provider = "prisma-generator-express"
  target   = "express"
}

运行一次 npx prisma generate 即可在模式或生成器配置发生变化时重新生成这三个组件。

模式仍是数据模型的唯一真实来源。生成的路由器文件仅仅是构建输出而已。路由配置决定了哪些操作会被实际加载,而保护结构则决定了特定调用者可以使用哪些 Prisma 参数。

将这些问题分开处理远比把生成的代码视为架构的替代品更有用。整个流程刻意使用两种不同的输入:基于模式的生成负责处理可重复的机制,而应用层策略则负责处理信任决策和路由暴露问题。

如果某些规则无法通过生成器或防护结构准确表达,就不要强行将其放入配置中。专门的处理程序或在数据库层面强制执行的策略,比那些会隐瞒实际功能的声明式设置能形成更清晰的边界。

代码生成方式也会改变代码审查的实际内容。手写的CRUD代码会促使审查者逐行检查重复的解析和委托逻辑,而生成的CRUD代码则将审查重点集中在更小的范围:Prisma模式、生成器选项、路由描述符、数据结构,以及用于建立可信上下文的解析器。

这并不意味着生成后的代码不重要——只是直接编辑它并非正确的处理方式。如果某个路由需要修改,应更改生成该路由的配置并重新生成代码。手动添加到生成的路由器文件中的修补内容可能在下一次模式更改时消失,而且不会留下关于原本预期接口的任何记录。

版本升级同样需要遵循严格的规范。将 Prisma、防护包以及路由生成器锁定在特定版本,从干净的代码库重新生成代码,然后针对生成的代码运行契约测试。即便仓库并不将每行生成的代码视为人工编写的代码,这些生成代码依然属于项目的依赖范围。

真正的效率提升源于可重复性。只需修改一次架构,就能同时更新验证元数据、客户端类型以及路由逻辑。这样一来,审查工作就可以集中在相对较小的策略层上——那部分内容确实无法仅通过模型本身推导出来。

让一个模型服务于多个明确的契约

一个 Prisma 模型可以同时支撑多个面向产品的接口。

例如,一个酒店房间记录可能会出现在公共搜索页面、合作伙伴数据源以及内部员工控制台中。这三种使用该数据的系统不应被迫共享一个包含所有可能用到的字段和操作的大型联合结构。

命名形状使得单个生成的操作能够同时承载多个不同的契约:

const roomRoutes = {
  findMany: {
    shape: {
      storefront: {
        where: {
          isPublished: { equals: force(true) },
          name: { contains: true },
        },
        select: { id: true, name: true, nightlyRate: true },
        take: { max: 40, default: 20 },
      },
      backoffice: {
        where: {
          name: { contains: true },
          floor: { equals: true },
        },
        select: {
          id: true,
          name: true,
          nightlyRate: true,
          floor: true,
          internalNote: true,
        },
        take: { max: 200, default: 50 },
      },
    },
  },
}

每个命名键都定义了一个完整且独立的 API 契约。公共接口的使用者无法扩展其数据获取范围以包含 internalNote,因为该字段并不存在于公共形状中。而员工则可以获取更丰富的数据信息,无需让其他所有客户端都使用自己手动编写的路由逻辑。

当仅需要在不同使用者之间区分 Prisma 层级的契约时,可使用 shape;而当特定使用者还需要专属的钩子函数时,则应使用 variants

识别调用者的方式本身就属于安全边界的一部分。请求头被视为客户端提供的输入——适用于那些有意公开的区分方式,比如简洁视图与详细视图,但不应用于选择具有特殊权限的员工合同。

对于任何需要特殊权限的操作,应通过resolveVariant并利用经过验证的服务器端状态来确定调用者。首先会匹配精确的调用者密钥,然后再匹配参数化形式的密钥。default密钥用于处理缺失、为空或无法匹配的调用者,因此只有在确定这三种情况都会落入该默认处理方式时才需定义它。

有时完全不设置某种合同机制,比再增加一项授权检查更为妥当。如果合作伙伴绝不能删除房间,那就根本不要为生成的删除操作提供合作伙伴密钥。

可以将来电路由视为一个网格来查看:一条轴代表操作,另一条轴代表受众。每个单元格要么包含带有适当钩子的图形,要么刻意留空。

即便这些契约在某些字段上有大量重叠,也应分别命名。在同一信任层级内共享对象是相对安全的,但若在公共受众和特权受众之间重复使用同一个共享对象,一旦有人添加新字段,就有可能悄悄扩大两端的范围。在信任边界处稍作重复处理,往往能显著简化对谁实际获取哪些数据的分析工作。

参数化的调用者键为您提供了另一个理由,让您选择使用内置的解析器,而非在钩子函数中自行实现调用者选择逻辑。路由器会将原始的调用者值与其匹配的指定键区分开,并直接拒绝任何模糊的参数模式。若要实现同样的保障,手动进行的字符串比较必须完全复现精确匹配、参数优先级、默认处理方式以及失败时的行为表现。

使用钩子处理生命周期决策,而非隐藏查询构建

自动生成的路由并不会消除应用层决策的需要,只是为这些决策提供了可预测的处理方式。

对于匹配特定变体的请求,执行流程为:先执行操作级别的前置钩子,接着是变体级别的前置钩子,然后是生成的处理器本身,之后是变体级别的后置钩子,最后才是操作级别的后置钩子。

操作钩子适用于那些需要针对该操作的每一个调用者生效的策略,无论这些调用者匹配了哪种契约。而变体钩子则用于处理特定声明的调用者格式所对应的逻辑。

const transferRoutes = {
  update: {
    before: [authenticateOperator],
    variants: {
      warehouse: {
        before: [authorizeTransferLocation],
        shape: warehouseTransferShape,
      },
      supervisor: {
        before: [requireSupervisorApproval],
        shape: supervisorTransferShape,
      },
    },
  },
}

前置钩子可以查看生成的处理器即将使用的确切标识符,如果该标识符未通过检查,它可以直接拒绝请求。但它绝不能在授权某个标识符的同时,悄悄地在实际查询中替换为另一个标识符。这种隐秘的修改完全违背了设置可检查处理器的初衷。

永不改变的约束应归属于特定形状。在查询开头应用的租户级过滤功能,则应与可信上下文结合,通过生成的范围映射来实现。不同调用者类型之间的差异则应归类为各种变体。每种元素都有其固定的位置,若将它们混为一谈,逻辑就会被埋藏在无人会去查找的地方。

在某些情况下,服务器确实需要构建某种形状无法表达的查询。这时就需要专门的处理程序来发挥作用——尤其是当需要真正的服务器级析取操作时。需要注意的是,嵌套在布尔组合器中的强制条件会成为查询的硬性约束,而非用于表达任意授权规则的灵活机制。若将其视为通用逻辑引擎,往往会导致最终生成的规则无法实现预期的管控效果。

after-hooks会在处理程序之后执行,但它们并非可以无条件依赖的清理阶段。如果响应提前终止,或请求过程中出现错误,那么包括after-hooks在内的后续阶段可能根本无法执行。如果某种资源无论如何都必须被释放,那就需要为其设计独立的生命周期,并在生成的钩子链之外、而非内部,设置明确的finally块。

具体实现方式还取决于所使用的目标框架。Express、Fastify和Hono在钩子签名及短路处理机制上各有不同。虽然三个框架中关于各决策应归属何处的一般原则是相同的,但实际的应用代码必须符合所针对的框架规范。

将可信的上下文置于Prisma参数之外

租户身份及已认证调用者状态绝不应作为客户端在查询体中控制的字段传送到服务器。

相反,应在租户模型上添加@scope-root标记,通过生成机制创建相应的作用域映射,并利用 Prisma Client 的扩展机制为其附加上下文解析器:

const prisma = new PrismaClient().$extends(
  guard.extension(() => ({
    Nursery: requestStore.getStore()?.nurseryId,
  }))
)

该值源自已认证的、请求级别的状态——而非客户端发送的任何内容。随后,扩展机制会将其注入到作为该作用域根节点子节点的模型所支持的高级操作中。

这是一个真实且定义明确的特性,并非能自动锁定所有关系的全面保障。作用域强制机制并不适用于嵌套的读取或写入操作。根委托模型本身也不会被其自身的作用域标记所过滤。而那些没有自动生成映射的模型仍需要额外的显式保护——默认情况下作用域上下文无法覆盖它们。

速度提升的价值恰恰在于这些边界是可见的而非隐藏的。你可以直接查看作用域映射表。嵌套投影可以拥有独立的过滤条件和限制。对于任何不符合标准模式的特殊所有权规则,都可以将其放入应用程序代码中处理,或直接在数据库层进行管理。

自定义应用状态——即超出租户范围的内容——应放在请求上下文中,而非 Prisma 参数本身中。将调用者身份或授权元数据偷偷放入 Prisma 请求体中,会使得后续的数据结构更难以理解,同时也会触发那些期望参数格式规范的严格验证机制。

将路由暴露视为产品设计

生成器能够为大量 Prisma 操作创建处理程序,但这一能力并不能说明哪些处理程序实际上应该被启用并可访问。

针对单条记录的修改、批量修改、关系写入以及返回数据的操作,都应分别进行评估,而不能一概而论。不同提供商对某些返回批量数据的操作的支持程度各异,因此这不仅是一个政策问题,也是一个兼容性问题。任何未指定shapevariants的路径都会直接调用Prisma,而不会进行任何防护检查。

合理的配置并非简单地将所有功能都启用后再事后添加拒绝检查。它应从少量明确的操作开始,只有当实际的产品工作流显示出需要更多操作时才逐步扩展。

读取投影需要与写入访问相同的重视程度。在受保护的读取操作中,若客户端请求未指定自身的投影,则在形状层级声明的selectinclude语句既可作为白名单,也可作为默认值。而修改投影则遵循不同的默认规则;如果绝不允许因省略投影而导致响应内容扩大,就必须明确启用enforceProjection功能。

批量路由每次都需要单独决策。只有当某种批量方法的形状能够定义合适的过滤规则,且运行时传入的请求仍能提供有意义的条件时,该批量方法在生成的查询结果中才被视为有效。仅仅因为已允许单条记录的删除就启用deleteMany,会忽略那第二个独立的风险。返回批量操作的变体会带来对提供方及Prisma支持的依赖,因此路由配置应反映实际部署的数据库能够执行的功能,而非产品路线图期望其执行的功能。

生成的 OpenAPI 文档可以描述路由路径以及由这些结构衍生的请求格式。但它无法查看任意的钩子函数,因此也无法描述隐藏在这些函数中的策略。如果某个钩子会阻止那些不在操作员指定仓库范围内的传输请求,那么这一条件就需要记录在路由配置旁边,并通过针对应用程序行为的测试来验证——生成的文档绝不能被当作其根本无法检测的逻辑的证明。

读取端点的 GET 和 POST 版本应使用相同的查询规范。GET 依赖编码后的查询参数,而 POST 则可以直接接收 JSON 格式的数据,这对于处理较为复杂的参数结构更为实用。仅操作请求体的钩子会使得功能实现隐含地依赖于传输方式,这正是为何不应在钩子中设置稳定性的限制的原因。

采用生成式方法而不放弃审核机制

评估此类架构的实用方法可遵循以下简短步骤:

  1. 为单个只读模型生成一个路由器。
  2. 仅暴露实际所需的操作。
  3. 添加一个带有明确投影和页面大小限制的直接形状。
  4. 检查路由器实际发出的Prisma参数。
  5. 如果该模型用于租户隔离,则添加受信任的作用域上下文。
  6. 仅当调用方需求真正存在差异时,才将某个操作拆分为独立的调用合约。
  7. 仅对于形状、作用域和变体自身无法处理的决策,才添加钩子函数。
  8. 在创建完整性、批量过滤和关系所有权都已有明确测试覆盖之后,才引入写入功能。

即使在浏览器端到端测试使用完全跳过校验机制的配置进行时,也应当保留对真实防护逻辑的测试。浏览器测试擅长覆盖路由和用户界面行为,但当实际上并不存在防护层时,它们无法证明生产环境会拒绝包含非法字段的请求。

代码生成工具的价值在于让团队能够专注于真正重要的决策。Prisma负责描述数据结构,生成器则处理那些可重复的机械性工作,而数据模型则定义了哪些调用是被允许的。应用程序代码则负责提供信任机制、特定于产品的策略,以及那些无法通过其他方式明确声明的异常情况。

相关阅读

  • 原始SQL、Prisma还是Drizzle:如何真正选择数据库层 — 了解原始SQL、Prisma和Drizzle在控制能力、类型安全性和开发者体验方面的差异,学习如何为项目挑选合适的数据库层。
  • MovieVault实战指南:基于Express 5、Prisma 7和JWT的观看列表API — 一份带时间限制的全栈开发练习方案,涵盖Express、Prisma和JWT后端实现,同时包含关于所有权检查、级联操作及错误处理的分析说明。
  • 审视 prisma-guard Shapes:所有权、投影与写入合约 — 通过探究每个值的归属者、哪些数据可以出现在响应中以及生成的端点允许执行何种写入操作,学习如何将 prisma-guard shapes 视为 API 合约进行审查。
  • 利用增量式 tRPC 部署在编译时捕获 API 合约偏差 — tRPC 如何将重命名的后端字段转化为编译错误,如何在 REST 之外逐个端点采用该技术,以及何时它并非合适的工具。