如何在 10 分钟内用 100 行代码构建可扩展的 SaaS 后端 🚀 构建可扩展的 SaaS 系统很难

2025-06-10

如何在 10 分钟内用 100 行代码构建可扩展的 SaaS 后端

构建可扩展的 SaaS 系统很困难

构建可扩展的 SaaS 系统很困难

在我之前的公司参与过四款商业 SaaS 产品的开发之后,我意识到与典型的消费产品相比,这类产品存在诸多复杂性。在这些复杂性中,一个突出的领域是权限控制和访问策略的复杂性。

除了单个用户的权限管理之外,还需要在组织层面进行权限管理,更不用说用户容易忽略的租户隔离这一关键环节。无论您选择 RBAC 还是 ABAC 访问控制机制,系统的复杂性都会随着功能的增加而增加。因此,添加新功能所需的时间会逐渐减慢,类似于 Martin Fowler 的 “设计耐力假说”中蓝线所示的下降趋势:

设计耐力假设

时间并非最坏的因素,因为我亲眼目睹过一些案例,添加新功能可能会破坏现有功能。这在 B2B SaaS 领域尤其成问题,这类问题可能会造成致命的后果。

访问控制代码分散

造成这一挑战的一个突出因素是代码库中访问控制处理的分散性。一些访问控制逻辑集中在特定组件中,例如中间件或数据库的 RLS(行级安全性),而其他访问控制逻辑则在各个组件中实现。因此,即使是看似简单的更改,也需要对整个代码库及其复杂性有深入的理解。

开发人员不仅必须小心不要无意中破坏现有功能,还必须确定实施更改的最佳位置,而不会引入潜在问题。

如果有一种方法可以将所有访问控制逻辑整合到一个集中位置,那会怎样?

单一事实来源

ZenStack来了:一个基于Prisma ORM 构建的 Typescirpt 工具包。它使用 Prisma 之上的声明式数据模型,添加访问策略和验证规则,并自动为您生成 RESTful 或 tRPC API。

给我看代码

使用 ZenStack 的 SaaS 项目

以下是您可以开始的 SaaS 后端项目:

https://github.com/zenstackhq/saas-backend-template

特征

  • 多租户
  • 软删除
  • 群组共享

数据模型

其中schema.zmodel,有4个模型,它们之间的关系如下:
数据模型

  • 组织 (Organization) 是顶级租户。任何用户 (User)、帖子 (Post) 和群组 (Group) 的实例都属于某个组织。
  • 一个用户可以属于多个组织和组
  • 一个帖子属于一个用户,但可能属于多个群组。

权限

让我们看一下帖子的所有权限以及如何使用 ZenStack 的访问策略来表达它们。

💡您可以在下面找到访问策略语法的详细参考:
https://zenstack.dev/docs/reference/zmodel-language#access-policy

  • 创造

所有者必须设置为当前用户,组织必须设置为当前用户所属的组织。

@@allow('create', owner == auth() && org.members?[id == auth().id])
Enter fullscreen mode Exit fullscreen mode
  • 更新

    只有所有者可以更新它,并且不允许更改组织或所有者

    @@allow('update', owner == auth() && org.future().members?[id == auth().id] && future().owner == owner)
    
    • 允许所有者阅读

      @@allow('read', owner == auth())
      
    • 如果它是公开的,则允许组织成员阅读

      @@allow('read', isPublic && org.members?[id == auth().id])
      
    • 允许小组成员阅读

      @@allow('read', groups?[users?[id == auth().id]])
      
  • 删除

    • 不允许删除如果没有指定规则,则默认不允许该操作。
    • 如果为真,则该记录被视为已删除isDeleted,即软删除。

      @@deny('all', isDeleted == true)
      

您可以在以下位置查看完整的数据模型以及上述访问策略schema.zmodel

abstract model organizationBaseEntity {
    id String @id @default(uuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
    isDeleted Boolean @default(false) @omit
    isPublic Boolean @default(false)
    owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
    ownerId String
    org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
    orgId String
    groups Group[]

    // when create, owner must be set to current user, and user must be in the organization
    @@allow('create', owner == auth() && org.members?[id == auth().id])
    // only the owner can update it and is not allowed to change the owner
    @@allow('update', owner == auth() && org.members?[id == auth().id] && future().owner == owner)
    // allow owner to read
    @@allow('read', owner == auth())
    // allow shared group members to read it
    @@allow('read', groups?[users?[id == auth().id]])
    // allow organization to access if public
    @@allow('read', isPublic && org.members?[id == auth().id])
    // can not be read if deleted
    @@deny('all', isDeleted == true)
}

model Post extends organizationBaseEntity {
    title String
    content String
}
Enter fullscreen mode Exit fullscreen mode

模型继承

您可能好奇,为什么这些规则是在抽象organizationBaseEntity模型中定义,而不是在具体Post模型中定义。这就是为什么我说它是可扩展的。借助 ZenStack 的模型继承功能,所有常见的权限都可以在抽象基础模型中方便地处理。

考虑一下这样一个场景:一个新雇佣的开发人员需要添加一个新ToDo模型。他可以通过简单地扩展以下内容轻松实现这一点organizationBaseEntity

model ToDo extends organizationBaseEntity {
    name String
    isCompleted Boolean @default(false)
}
Enter fullscreen mode Exit fullscreen mode

所有多租户、软删除和共享功能都将自动运行。此外,如果需要任何专门的访问控制逻辑ToDo,例如允许共享用户更新,您可以轻松在ToDo模型中添加相应的策略规则,而不必担心破坏现有功能:

@@allow('update', groups?[users?[id == auth().id]] )
Enter fullscreen mode Exit fullscreen mode

我需要编写多少 NodeJS 代码

至此,您已经将架构作为业务模型和访问控制的唯一真实来源。接下来,您可能希望看到需要编写的 Typescript/JavaScript 代码。接下来是激动人心的部分:

就这样。您几乎不需要编写任何 TS/JS 代码。ZenStack 会自动为您生成 API,并在运行时无缝注入访问控制。

虽然您可能会看到中的几行代码index.ts,但它主要用于将自动生成的 RESTful API 安装到 Express.js 中并启动应用程序。

💡它支持大多数 Node.js 框架,如 Next.js、Sveletkit、Fastify 等。

让我们玩一玩

我创建了示例数据供您使用。您可以运行以下命令来为其设定种子:

npm run seed
Enter fullscreen mode Exit fullscreen mode

数据如下:

数据

因此在 Prisma 团队中,每个用户都创建了一个帖子:

  • 加入 Discord未共享,因此只有 Robin 可以看到
  • 加入 Slack在 Robin 所属的群组中共享,以便 Robin 和 Bryan 都可以看到。
  • 关注 Twitter是公开的,以便 Robin、Bryan 和 Gavin 可以看到

您可以简单地调用 Post 端点来查看模拟不同用户的结果:

curl -H "X-USER-ID: robin@prisma.io" localhost:3000/api/post
Enter fullscreen mode Exit fullscreen mode

💡为了方便测试,这里使用了纯文本形式的用户 ID。实际应用中,你应该使用更安全的方式传递 ID,例如 JWT 令牌。

根据样本数据,每个用户应该看到不同的帖子数量,从 0 到 3。

软删除

由于是软删除,实际操作是更新isDeleted为 true。我们来删除 Robin 的“加入 Salck”帖子,运行以下命令:

curl -X PUT \
-H "X-USER-ID: robin@prisma.io" -H "Content-Type: application/json" \
-d '{"data":{ "type":"post", "attributes":{  "isDeleted": true } } }'\
localhost:3000/api/post/slack
Enter fullscreen mode Exit fullscreen mode

此后,如果您再次尝试访问 Post 端点,结果将不再包含“加入 Slack”的帖子。如果您对钩子下的工作原理感兴趣,请查看另一篇文章:

最后的

SaaS 产品MermaidChart近期推出了由 Z​​enStack 提供支持的 Teams 功能。如果您想了解他们采用 ZenStack 的经验,欢迎加入我们的Discord。更多相关信息和示例,请访问我们的官方网站


你能幫我嗎?

如果您觉得 ZenStack 能帮到您,请给我一颗星,我会非常高兴,这样它就能真正帮助更多人快速行动!❤️

https://github.com/zenstackhq/zenstack

帮助我们

继续阅读:https://dev.to/zenstack/how-to-build-a-scalable-saas-backend-in-10-minutes-with-100-lines-of-code-using-zenstack-1h7c
PREV
人们使用 WebAssembly 构建什么?
NEXT
理解 JavaScript 原型