发布于 2026-01-06 8 阅读
0

使用 React、Auth0 和 FaunaDB 创建无服务器学校管理系统 简介 入门 数据库设置 创建您的第一个文档! 使用演示数据填充集合 索引 Auth0 设置 学生部分 教师部分 管理员部分 总结

使用 React、Auth0 和 FaunaDB 创建一个无服务器学校管理系统

介绍

入门

数据库设置

创建您的第一个文档!

使用演示数据填充集合

指数

Auth0 设置

学生区

教师专区

经理部门

包起来

介绍

受近期疫情影响,全球许多学校已全面转为线上教学。随着各学校后端系统使用量激增,无服务器解决方案的重要性也日益凸显。本文将详细介绍如何使用 Auth0 进行身份管理,并使用 FaunaDB 作为无服务器数据库,构建一个强大的学校管理系统。FaunaDB 允许我们创建几乎没有流量限制的全球分布式数据库,您可以根据需要执行任意次数的读写操作。

我们正在开发的学校管理系统(名为“skulment”)有三类用户:学生、教师和管理人员。以下是对每种角色应具备的功能的基本描述。

用户

学生应该能够:
注册/取消注册课程;
查看已注册的课程;
查看每门课程的授课教师。

教师应该能够:
查看所有选修其课程的学生;
查看其管理的所有课程。

管理人员应能够:
阅读和修改学生、课程和教师资源

这是针对每个角色的基本规则集。在实际应用中,每个角色都会有更多保护措施和规则。为了简单起见,我们将使用这套基本规则。

建筑学

多年来,NoSQL 数据库严重缺乏关系型数据库的功能。关系建模能力对于数据库的健康稳定发展至关重要,尤其是在应用程序迭代开发过程中。FaunaDB 的创始人深知,如果 FaunaDB 想要保持竞争力,支持关系型数据是必不可少的。

在这个应用中,我们将构建一对多和多对多关系的模型。除了用户之外,我们还需要构建课程和班级的模型。下图是我们即将推出的学校管理系统的示意图。请注意,实际应用中可能会涉及更大的数据结构,但为了便于说明,我们将保持简单。

头骨数据库

人际关系

学生与课程的关系(多对多):一个学生可以选修多门课程,一门课程也可以有多名学生。
教师与课程的关系(多对多):一名教师可以教授多门课程,一门课程也可以有多位教师。
班级与课程的关系(一对多):一门课程只能属于一个班级,一个班级可以包含多门课程。

入门

我们将从后端资源入手,特别是 FaunaDB 和 Auth0,然后再逐步过渡到项目的前端部分,也就是 ReactJS。为了方便起见,我编写了一个示例环境文件(.env),您可以复制它来创建自己的环境文件,同时还提供了一个用于搭建后端框架的 Node.js 脚本。要使用它们,您需要克隆我的仓库并按如下方式初始化前端:

git clone https://github.com/vicradon/skulment-demo.git
cd skulment-demo
yarn # or `npm i` if you prefer
cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

数据库设置

FaunaDB 作为一款无服务器数据库,让我们能够专注于业务逻辑,而无需过多担心设置维护。创建数据库就像运行一条CreateDatabase({name:"some_db"})命令一样简单。所有维护工作都由 FaunaDB 的工程师和自动化 DevOps 团队在后台完成。其他数据库常见的繁琐操作,例如选择区域和配置存储,在 FaunaDB 中都不存在;FaunaDB 默认是全球/多区域的。

如果您还没有Fauna 帐户,请在此处创建一个。我们将使用Fauna shell,它允许我们在 FaunaDB 上创建/修改资源。请注意,Fauna 在云控制台中也提供了一个 Web shell,它拥有出色的用户界面,可用于调试 FQL 查询。

npm install -g fauna-shell
fauna cloud-login
Enter fullscreen mode Exit fullscreen mode

太棒了!现在,让我们创建第一个数据库。

fauna create-database skulment_demo && fauna shell skulment_demo 
Enter fullscreen mode Exit fullscreen mode

这将启动一个类似 REPL 的环境,我们可以在其中执行 FQL 查询。虽然许多没有 SQL 接口的数据库选择使用简单的 CRUD API,但 FaunaDB 提供了Fauna 查询语言 (FQL),一种函数式数据库查询语言。如果您熟悉 SQL,这里有一个关于两者的精彩对比。FaunaDB 可以通过其GraphQL 客户端或 FQL将我们的数据转换为 API 。这意味着您无需从头开始构建 API,即可在应用程序中使用您的数据库!现在我们可以创建第一个集合了。

CreateCollection({ name: "Students" })
# Should return something like this…
# {
#   ref: Collection("Students"),
#   ts: 1600697704360000,
#   history_days: 30,
#   name: "Students"
# }
Enter fullscreen mode Exit fullscreen mode

这将创建一个名为 `<Collection_name>` 的集合Students。FaunaDB 集合类似于关系数据库中的表。但是,它存储的是文档而不是行,并且默认情况下数据结构要求较为宽松(可以自行强制执行)。现在,我们将像之前一样,在 shell 中创建其他集合。

# `fauna shell skulment_demo` if not in shell already
CreateCollection({name: "Teachers"});
CreateCollection({name: "Managers"});
CreateCollection({name: "Courses"});
CreateCollection({name: "Classes"});
Enter fullscreen mode Exit fullscreen mode

我们目前所有 5 个收藏集都是空的。让我们看看如何通过添加学生来填补空缺Students

创建您的第一个文档!

我们将使用 FQL 函数向 Students 集合中添加学生文档Create

Create(Collection("Students"), {
  data: {
    firstName: "Wangari",
    lastName: "Maathai",
    email: "wangari.maathai@skulment.edu",
  },
});
# should return something like this
# {
#   ref: Ref(Collection("Students"), "277574932032913921"),
#   ts: 1600974933615000,
#   data: {
#     firstName: 'Wangari',
#     lastName: 'Maathai',
#     email: 'wangari.maathai@skulment.edu',
#   }
# }
Enter fullscreen mode Exit fullscreen mode

参考文献

当我们检查返回的 JSON 数据时,会看到一个ref字段。引用(简称“ref”)是 FaunaDB 的一个原生对象,用于唯一标识一个文档及其所属的集合,其用法类似于外键。ref 中的 18 位数字是文档的 ID。虽然可以提取文档的 ID 并将其用于其他用途,但强烈建议将其与其对应的集合名称关联起来,因为仅凭 ID 不足以作为指针或检索文档。

使用PaginateandDocuments函数,我们可以检索我们最近创建的 Student 的 ref(因为它是目前集合中唯一的文档)。

Paginate(Documents(Collection("Students")))
# Should return something like this
# { data: [ Ref(Collection("Students"), "277574932032913921") ] }
Enter fullscreen mode Exit fullscreen mode

如果我们假设数据库是一个实体图书馆,你可以在这里阅读或借阅书籍,并且所有书籍都是一个集合:该Collection函数返回书籍在图书馆中的位置(或“ref”),该Documents函数打开书籍,并Paginate读取书籍中的一页。然而,在这种情况下,书籍的页面是一个文档ref数组,而不是文档的全部数据。请注意,Paginate使用自定义索引时,该函数可以返回ref以外的数据(稍后会详细介绍)。现在,我们可以通过将第一个学生的ref复制粘贴到函数中来读取整个文档Get

Get(Ref(Collection("Students"), "277574932032913921"))
Enter fullscreen mode Exit fullscreen mode

注意:函数中应该使用的引用Get是终端中的引用,而不是上面的引用。

更新和删除

要修改此文档,我们使用以下Update函数。该Update函数接收一个引用和要写入的字段,并返回修改后的文档。

Update(Ref(Collection("Students"), "277574932032913921"), {
    data: {
      email: "wangari-nobel@skulment.edu"
    }
  }
)
# returns the full document
Enter fullscreen mode Exit fullscreen mode

要删除此文档,我们对其引用调用 FQL 删除函数,如下所示

Delete(Ref(Collection("Students"), "277574932032913921"))
Enter fullscreen mode Exit fullscreen mode

使用演示数据填充集合

现在我们已经了解了如何使用 FQL 执行 CRUD 操作,接下来我们将使用populate-collections.js项目 scripts 目录中的脚本,向所有新建的集合填充演示数据;创建过程如下:

  • 50名学生
  • 10位老师
  • 2位经理
  • 20门课程和
  • 6 门课程。

由于我们使用的是脚本,这意味着我们在 shell 之外操作数据库。为此,我们需要 FaunaDB JavaScript 驱动程序和一个服务器密钥

JavaScript 驱动程序是一个 npm 包,它允许我们在 JavaScript 文件中使用 FQL。服务器密钥是一个可以绕过数据库中所有权限检查的密钥,必须谨慎处理。

如果服务器密钥Delete已被泄露,您可以随时使用该功能或​​在 Fauna 控制面板上使其失效。请参见下图。

撤销密钥

在 shell 中运行此命令,并从结果中复制密钥。

CreateKey({ role: "server" })
# Returns something like this
# {
#   ref: Ref(Keys(), "278091949991264787"),
#   ts: 1601468000353000,
#   role: 'server',
#   secret: 'fnAD2_sntiACE_xHweiTXMNvy7Z4vJ2OkA7yZAd1', # copy this
#   hashed_secret: '$2a$05$AjuS2MrHwgBCUKepWp/KLOniI4hinzLbUqIHf1PZsOlu3qbSncgr.'
# }
Enter fullscreen mode Exit fullscreen mode

将密钥粘贴到 .env 文件的 FAUNA_SERVER_SECRET 键中。之后,打开一个新的终端,并从你之前克隆到仓库的位置运行以下命令。

node scripts/populate-collections
# outputs
# Successfully created collections on FaunaDB
Enter fullscreen mode Exit fullscreen mode

如果没有抛出错误,您应该能够在新建的集合中看到生成的文档。

Map(
  Paginate(Documents(Collection("Students"))),
  Lambda("ref", Get(Var("ref")))
);
# Run this for any collection you'd like, to verify the script worked
Enter fullscreen mode Exit fullscreen mode

编写 populate-collections 脚本非常轻松愉快,因为 FQL 是一种设计精良的语言,函数式程序员会感到非常熟悉。虽然我们使用的是 JavaScript 驱动程序,但 FaunaDB 也提供了其他语言的驱动程序,例如 Scala、Go、Python、Java 等。由于 FQL 非常灵活且易于兼容,开发人员可以将大部分业务/后端逻辑迁移到 Fauna 的服务器上,FQL 在完全符合 ACID 标准的分布式事务中执行。借助用户定义函数 (UDF) 和索引,代码组合和重用也变得轻而易举,稍后会详细介绍。有了 FQL,编写无服务器后端代码从未如此简单;是的,甚至比传统的无服务器函数还要简单,因为根本不需要部署流程。

指数

如果我们不知道文档的引用(ref),可以使用其他字段(例如电子邮件或名字)通过 FaunaDB 索引来搜索文档。索引还可以用于对特定文档的引用和数据进行排序和反转。此外,索引还可以施加约束(例如唯一性),防止返回重复结果。点击此处了解更多关于索引的信息。

索引示例:通过电子邮件获取用户

此应用程序的用户文档位于`<username> Students`、` Teachers<username>` 和Managers`<username>` 集合中。这意味着在构建此索引时,我们将把这些集合作为索引的源,用于搜索。要搜索的字段将放在 `<fields>`terms属性中。用户的电子邮件地址将在data.email其文档的 `<fields>` 属性中搜索,在 FQL 中,该属性写成数组路径:`<fields>[<fields>[<fields>] ["data", "email"]...

CreateIndex({
  name: "users_by_email",
  source: [
    {collection: Collection("Students")},
    {collection: Collection("Teachers")},
    {collection: Collection("Managers")},
  ],
  terms: [{ field: ["data", "email"] }]
});
# should give a result similar to
# {
#   ref: Index("users_by_email"),
#   ts: 1601538868340000,
#   active: false,
#   serialized: true,
#   name: "users_by_email",
#   source: [
#     {
#       collection: Collection("Students")
#   ...
Enter fullscreen mode Exit fullscreen mode

由于可搜索集合中已经有一些数据,因此构建此索引可能需要一些时间。您可以在 Fauna 控制面板上查看构建进度,或者通过索引文档中的“active”字段查看(FaunaDB 中的所有记录都是文档,即使是原生记录也不例外!)。

指数构建

当索引准备就绪后,我们可以使用 FQL Match 函数和用户的电子邮件地址来获取已创建的用户之一。

# Can also be used with "teacher1@skulment.edu" and "manager1@skulment.edu"

Get(Match(Index("users_by_email"), "student1@skulment.edu"))

# Should return something like this
# {
#   ref: Ref(Collection("Students"), "277574932032913921"),
#   ts: 1600974933615000,
#   data: {
#     "firstName": 'Student',
#     "lastName": 'Default',
#     "email": 'student1@skulment.edu',
#     "currentClass": Ref(Collection("Classes"), "277915816413890055"),
#     "courses": [
#       Ref(Collection("Courses"), "277915818192273921")
#     ]
#   }
# }
Enter fullscreen mode Exit fullscreen mode

注意 `class`currentClass和 ` coursesclass` 字段。它们都包含引用,在本例中,这些引用本质上是外键。它们建立了学生与班级以及学生与课程之间的关系。我们之前使用的 `populate-collections` 脚本将课程和班级引用传递给负责创建学生的代码,从而建立了它们之间的关系。
除了我们的用例之外,索引还有许多其他应用,例如文本搜索、排序、反向搜索,甚至地理位置搜索!

Auth0 设置

现在我们已经搭建好了数据库的基础架构,接下来需要编写逻辑来处理 Auth0 的身份验证。Auth0 允许我们定义身份验证规则,将身份验证限制在 FaunaDB 数据库中已存在的电子邮件地址。在同一条规则中,我们将生成一个 FaunaDB 密钥并将其附加到用户文档(例如“教师”)。该密钥将确保用户只能读取/修改其角色范围内定义的资源(稍后会详细介绍)。

首先,请在此处创建一个免费的 Auth0 帐户,并创建一个单页应用程序。您可以为您的租户和应用程序选择任何名称。创建应用程序后,创建一个新的空规则

完成规则创建,插入以下代码片段,该代码片段将返回一个用户对象和一个 Fauna 密钥。我们需要将 Fauna 数据库服务器密钥添加到Auth0 的环境配置中,类似于我在下面所做的。

env-vars-auth0

剧本节选

让我们把脚本分成几个部分,以便理解它的功能。

第一部分:初始化

async function loginFaunaOnUserLogin(user, context, callback) {
  const { Client, query:q } = require("faunadb@2.11.1"); // from Auth0 registry. See https://auth0.com/docs/rules

  const client = new Client({
    secret: configuration.SERVER_SECRET,
  });
  // more code below...
Enter fullscreen mode Exit fullscreen mode

前几行代码用于设置。我们需要 FaunaDB JavaScript 驱动程序,并且还需要使用 Fauna 服务器密钥来设置客户端。

第 2 部分:如果用户文档存在,则生成凭据

// inside loginFaunaOnUserLogin(), more code above...
try {
    /* return user document if present in the database */
    let user_from_fauna;
    try {
      user_from_fauna = await client.query(
        q.Get(q.Match(q.Index("users_by_email"), user.email))
      );
    } catch (error) {
      throw new Error("No user with this email exists");
    }

    /* create a secret from the user's ref in the Tokens collection */
    const credential = await client.query(
      q.Create(q.Tokens(null), { instance: user_from_fauna.ref })
    );
  // more code below...
Enter fullscreen mode Exit fullscreen mode

在第二部分,我们检查尝试登录的用户是否已存在于数据库中。此检查通过users_by_email索引完成。FQL函数帮助我们将搜索查询与索引进行匹配。然后使用 FQL Get 函数返回匹配结果,在本例中为用户文档。使用TokensMatch集合上的 Create 函数为特定用户创建凭据。现在,我们将从凭据对象中提取密钥,用户将使用该密钥进行数据库查询。

第 3 部分:将用户元数据附加到返回的对象

    /* Attach the secret, user_id and role to the user_metadata */
    user.user_metadata = {
      secret: credential.secret,
      user_id: credential.instance.id,
      role: user_from_fauna.ref.collection.id.toLowerCase().slice(0, -1),
    };

    /* The custom claim allows us to attach the user_metadata to the returned object */
    const namespace = "https://fauna.com/"; // fauna because we are using FaunaDB
    context.idToken[namespace + "user_metadata"] = user.user_metadata;

    auth0.users
      .updateUserMetadata(user.user_id, user.user_metadata)
      .then(() => callback(null, user, context))
      .catch((err) => callback(err, user, context));
  } catch (err) {
    callback(err, user, context);
  }
}
Enter fullscreen mode Exit fullscreen mode

在本节中,我们将用户名secret、密码user_id和用户信息附加role到用户的元数据对象。之后,我们使用自定义声明将其附加user_metadata到返回的用户对象。之所以需要自定义声明,是因为 Auth0 会过滤掉所有非命名空间的值。最后,我们使用回调函数退出。

将 React 应用连接到 Auth0

现在我们需要将我们的 React 应用信息告知 Auth0。请导航至 Auth0 控制面板上的应用设置选项卡,并将 URL(http://localhost:3000)添加到配置中。

  1. 允许的回调 URL
  2. 允许的注销网址
  3. 允许的 Web 源字段

URL修复

请确保点击Save Changes页面底部的按钮。
您需要从应用程序设置页面顶部Client ID获取 `REACT_APP_CLIENT_ID` 和 `REACT_APP_DOMAIN` 的值。复制这些值,并将它们分别修改到React 应用程序文件中的 `REACT_APP_CLIENT_ID` 和 `REACT_APP_DOMAIN` 键值对中。Domain.env

选择客户端 ID 和密钥

重启开发服务器以确保环境变量生效。

npm start
Enter fullscreen mode Exit fullscreen mode

学生区

验证学生身份

在构建此部分时,我们将创建一个学生角色并构建与课程注册相关的功能。请记住,populate-collections 脚本已经创建了学生、教师和管理员角色供我们测试使用。运行开发服务器并使用这些详细信息对学生进行身份验证。

Email: student1@skulment.edu
Password: Test1234
Enter fullscreen mode Exit fullscreen mode

注册成功后,尝试访问课程路径。

学生课程页面

您应该会看到两条权限被拒绝的提示信息。这是因为我们尚未授予该学生读取课程集合的权限。要授予此权限,我们使用自定义角色。

明确学生的角色

Student角色是一个自定义角色,用于设置学生的资源访问规则。除了身份验证系统外,FaunaDB 还提供了一个开箱即用的授权系统,该系统实现了基于属性的访问控制 (ABAC)。附加到用户的密钥/角色只能执行其规则中规定的操作。如果我们没有定义学生角色,用户将无法使用其密钥执行任何操作。

以下定义的“学生”角色赋予学生以下权限:
读取和写入“课程”集合
;读取“教师”集合;
读取“班级”集合;
读取和写入“学生”集合。

学生角色是使用 CreateRole FQL 函数创建的。

CreateRole({
  name: "Student",
  privileges: [
    {
      resource: Collection("Courses"),
      actions: { read: true, write: true },
    },
    {
      resource: Collection("Teachers"),
      actions: { read: true },
    },
    {
      resource: Collection("Classes"),
      actions: { read: true },
    },
    {
      resource: Collection("Students"),
      actions: { read: true,  write: true },
    },
  ],
  membership: [{ resource: Collection("Students") }],
});

# should return something similar to
# {
#   ref: Role("Student"),
#   ts: 1601542095001000,
#   name: "Student",
#   privileges: [
#     {
#       resource: Collection("Courses"),
#       actions: {
#         read: true,
#         write: true
#       }
#     },
#     {
#       resource: Collection("Teachers"),
#       actions: {
#    ...
Enter fullscreen mode Exit fullscreen mode

学生角色已分配给“学生”集合中的所有成员。请尝试导航到课程路径,查看是否仍然会弹出提示信息。现在一切应该都正常了。

学生只能阅读和修改课程,不能创建课程。我们可以通过尝试创建课程来测试这一限制。首先,从浏览器上运行的应用程序中获取当前登录用户的密钥。

学生主页

然后修改 .env 文件中的 CURRENT_USER_SECRET 键。打开create_course_test.js脚本目录中的一个名为 .env 的文件。验证我们是否正在尝试在Courses集合中创建一个文档。运行此脚本node scripts/create_course_test。结果如何?是否抛出错误?检查错误描述是否与以下内容类似:

[PermissionDenied: permission denied] {
  description: 'Insufficient privileges to perform the action.',
Enter fullscreen mode Exit fullscreen mode

现在运行 retrieve_courses_test.js。有没有报错?所有课程文档引用都应该被检索出来,没有报错。这意味着我们的功能运行正常。

注意:您绝不应该在应用程序中显示密钥。此处显示的用户密钥仅为方便起见。您可以移除将此密钥渲染到用户界面的代码行。

注册课程

目前为止做得非常棒!有趣的是,FaunaDB 的事务避免使用悲观锁,而是采用了一种受Calvin启发的新型乐观锁机制。简而言之,这可以实现大规模并发。因此,在新学期开始时,即使数据库写入量很大,学生们也能顺利注册课程,不会出现并发错误、延迟等问题。此外,如果我们的学校支持远程国际学生,由于 FaunaDB 的全球分布式特性,他们在争相注册热门课程时也不会遇到不公平的高延迟。现在,让我们开始构建用户界面吧!

选择组件

我们将构建一个支持同时注册多门课程的课程注册组件。为此,我们将使用 react-select。在 RegisterCourseModal 组件中,找到表单并将其添加到占位符文本下方。

<Select
  closeMenuOnSelect={false}
  components={animatedComponents}
  isMulti
  options={courses}
  value={selected_courses}
  onChange={handleChange}
/>
// more code below
Enter fullscreen mode Exit fullscreen mode

我们还需要包含 onChange 处理程序react-select。将其添加到 RegisterCourseModal 组件中。

  const handleChange = (values, actionMeta) => {
    if (actionMeta.action === "remove-value") {
      setSelectedCourses(
        selected_courses.filter((course) => course !== actionMeta.removedValue)
      );
    }
    if (actionMeta.action === "select-option") {
      setSelectedCourses(values);
    }
  };
Enter fullscreen mode Exit fullscreen mode

我们添加了条件判断语句,以便能够移除已选课程。我们不希望学生在取消选择已选课程时需要刷新页面。

注册课程

课程注册逻辑

接下来,我们将添加一些课程注册逻辑。下面的代码块创建了一个用户自定义函数 (UDF)。UDF 非常适合用于那些我们希望避免前端恶意用户访问的代码,或者涉及受保护资源的代码。如果您熟悉 SQL 数据库,您会发现它们与 SQL 数据库类似stored procedures。要调用 UDF,我们使用带有参数(如果有)的 FQLCall函数。

Call(Function("some_function_name"), [...params])
Enter fullscreen mode Exit fullscreen mode

让我们分析下面的用户自定义函数 (UDF)。
课程引用数组由输入的课程 ID 数组构建而成。请求者
的已注册课程信息student在 Select 函数中获取。这里使用 Identity 来获取学生的引用。因此,我们无需将学生的引用直接传递给 UDF,而是使用 Identity。这确保了学生不能为其他学生注册课程。
可注册课程数组是通过检查学生课程数组中未包含的课程而获得的。
(updatedCourseArray) 由已注册课程数组和可注册课程数组合并而成。
更新操作在 Do 函数中完成。首先更新学生的课程数组,然后再更新待注册的各个课程。
对各个课程的更新操作在 Map 函数中完成。学生的引用(使用 Identity 获取)被添加到每个可注册课程的注册数组中。

CreateFunction({
  name: "register_course",
  body: Query(
    Lambda(
      "course_ids",
      Let(
        {
          courseRefs: Map(
            Var("course_ids"),
            Lambda("course_id", Ref(Collection("Courses"), Var("course_id")))
          ),
          registeredCourses: Select(["data", "courses"], Get(Identity()), []),
          registrableCourses: Difference(Var("courseRefs"), Var("registeredCourses")),
          updatedCourseArray: Append(Var("registeredCourses"), Var("registrableCourses")),

          updateOperations: Do([
            Update(Identity(), {
              data: { courses: Var("updatedCourseArray") },
            }),
            Map(Var("registrableCourses"), Lambda("ref", Let(
              {
                registrations: Select(['data', 'registrations'], Get(Var('ref'))),
                updatedRegistrations: Append(Var('registrations'), [Identity()])
              },
              Update(Var('ref'), {
                data: { registrations: Var("updatedRegistrations") },
              })
            )))
          ])
        },
        Map(Var("registrableCourses"), Lambda("ref", Get(Var("ref"))))
      )
    )
  ),
});
# returns something similar to
# {
#   ref: Function("register_course"),
#   ts: 1601556750630000,
#   name: 'register_course',
#   body: Query(Lambda(["course_id", "student_id"], ... ))))
# }
Enter fullscreen mode Exit fullscreen mode

简单来说,这个用户自定义函数 (UDF) 接收一个 course_ids 数组,然后使用引用更新课程和学生文档的registrations数组。这样,就在课程和学生文档集合courses之间建立了多对多的关系CoursesStudents

多对多关系学生和课程

现在我们有了用于处理课程注册的用户自定义函数 (UDF),我们需要更新学生角色,赋予其调用此 UDF 的权限。

Let(
  {
    prevPrivileges: Select(["privileges"], Get(Role("Student"))),
    newPrivileges: [
      {
        resource: Function("register_course"),
        actions: { call: true },
      },
    ],
  },
   Update(Role("Student"), {
      privileges: Append(Var("prevPrivileges"), Var("newPrivileges")),
    }),
);
# output similar to that from UDF creation
Enter fullscreen mode Exit fullscreen mode

处理注册

到目前为止,我们已经添加了一个选择组件并定义了一个用户自定义函数 (UDF)。现在我们需要为这个 UDF 添加一个触发器。我们在 `handleSubmit` 函数中实现这一点。在这个项目中,数据库查询是用 FaunaDB JavaScript 驱动程序/客户端编写的函数。为了遵循这种模式,我们在Pages/Student/functions.js名为 `.` 的文件中定义了一个函数registerCourses

export const registerCourses = async (courses, secret) => {
  const client = new faunadb.Client({ secret });
  const course_ids = courses.map((course) => course.value);
  const response = await client.query(
    q.Call(q.Function("register_courses"), course_ids)
  );
  return response;
};
Enter fullscreen mode Exit fullscreen mode

如函数所示,我们使用用户的密钥初始化一个新的客户端。我们的用户register_courses自定义函数 (UDF) 需要一个课程 ID 数组,因此我们从 courses 参数中提取课程 ID。然后,我们调用 UDF 并将 course_ids 作为参数来查询 FaunaDB register_courses,并等待结果并将其存储起来response。最后,我们返回 UDF 执行的结果。

返回到该RegisterCourseModal组件,并将此代码添加到 handleSubmit 处理程序中。

setRegistering(true);
event.preventDefault();

// new code
registerCourses(selected_courses, secret)
    .then((newCourses) => {
    toast.success("courses registered successfully");
    setRegistering(false);
    setCourses([]);
    setSelectedCourses([]);
    addToCourses(newCourses);
    onClose();
    })
    .catch((error) => {
    setRegistering(false);
    toast.error(error.message);
    });
Enter fullscreen mode Exit fullscreen mode

现在就尝试注册课程吧!

课程注册

太好了!学生现在可以注册课程了。

取消注册课程

取消注册课程的逻辑与注册课程类似。主要区别在于,Difference取消注册课程时,需要使用返回与传入课程引用不相等引用的函数。

CreateFunction({
  name: "unregister_course",
  body: Query(
    Lambda(
      "course_id",
      Let(
        {
          courseRef: Ref(Collection("Courses"), Var("course_id")),
          courses: Select(["data", "courses"], Get(Identity()), []),
          registrations: Select(
            ["data", "registrations"],
            Get(Var("courseRef")),
            []
          ),
          updatedCourseArray: Difference(Var("courses"), [Var("courseRef")]),
          updatedRegistrationsArray: Difference(Var("registrations"), [
            Identity(),
          ]),
        },
        Do([
          Update(Identity(), {
            data: { courses: Var("updatedCourseArray") },
          }),
          Update(Var("courseRef"), {
            data: { registrations: Var("updatedRegistrationsArray") },
          }),
        ])
      )
    )
  ),
});
Enter fullscreen mode Exit fullscreen mode

现在,我们只需要像之前一样,更新学生角色以调用这个用户自定义函数 (UDF)。之后,尝试取消注册课程。

课程取消注册

好啦,这一部分到此结束!恭喜你坚持到了这里!

教师专区

首先,请使用以下信息注册一位教师。

Email: teacher1@skulment.edu
Password: Test1234
Enter fullscreen mode Exit fullscreen mode

现在,创建教师角色

CreateRole({
  name: "Teacher",
  privileges: [
    {
      resource: Collection("Courses"),
      actions: { read: true },
    },
    {
      resource: Collection("Students"),
      actions: { read: true },
    },
    {
      resource: Collection("Classes"),
      actions: { read: true },
    },
    {
      resource: Collection("Teachers"),
      actions: {
        read: true,
        write: Query(
          Lambda("ref", Equals(Identity(), Var("ref")))
        ),
      },
    },
  ],
  membership: [
    {
      resource: Collection("Teachers"),
    },
  ],
});
# The output should be similar to that from the Student role creation
Enter fullscreen mode Exit fullscreen mode

教师仪表盘

目前,仪表盘上会显示“权限被拒绝”的提示信息。

教师仪表盘权限被拒绝

教师仪表盘旨在显示选修该教师课程的学生人数以及分配给该教师的课程数量。满足第一个要求的一种方法是结合使用 reduce 和 count 函数,例如下面的代码块。我们在 reduce 函数中定义了一个 lambda 表达式,该表达式对累加器和课程注册人数进行求和。

student_count: q.Reduce(
  q.Lambda(
    ["acc", "ref"],
    q.Add(
      q.Var("acc"),
      q.Count(
        q.Select(["data", "registrations"], q.Get(q.Var("ref")), [])
      )
    )
  ),
  0,
  q.Var("course_refs")
)
Enter fullscreen mode Exit fullscreen mode

虽然上述方法可行,但使用索引效果更好。索引可以加快查询速度,因为我们只需查找所需的字段。

CreateIndex({
  name: "students_taking_course_by_teacher",
  source: Collection("Courses"),
  terms: [{ field: ["data", "teachers"] }],
  values: [{ field: ["data", "registrations"] }]
})
Enter fullscreen mode Exit fullscreen mode

此索引会搜索teachers课程文档的字段,然后返回课程的注册信息,在本例中即为学生参考编号。您可能想知道索引如何搜索数组。当数组作为参数提供时,索引会像搜索标量字段一样搜索每个数组元素。现在,我们只需将 FQLCount函数应用于返回的学生参考编号即可。FQL Count 函数是一个聚合函数,类似于Max`count`Min和 `count` Sum。由于 FQL 提供了如此多的函数,我们的应用程序无需进行任何繁重的计算。所有繁重的计算都由 FaunaDB 在云端完成。这意味着我们的应用程序能够保持快速运行。

在继续操作之前,请授予教师调用此索引的权限,方法与之前相同(使用 prevPrivileges 和 newPrivileges)。最后,请验证这些数字是否显示在仪表板上。

教师仪表盘

经理部门

管理员应该能够对课程、教师、学生和班级执行增删改查操作。我们先来创建管理员角色。

CreateRole({
  name: "Manager",
  privileges: [
    {
      resource: Collection("Courses"),
      actions: { read: true, write: true, create: true, delete: true },
    },
    {
      resource: Collection("Teachers"),
      actions: { read: true, write: true, create: true, delete: true },
    },
    {
      resource: Collection("Students"),
      actions: { read: true, write: true, create: true, delete: true },
    },
    {
      resource: Collection("Classes"),
      actions: { read: true, write: true, create: true, delete: true },
    },
  ],
  membership: [
    {
      resource: Collection("Managers"),
    },
  ],
});
# output should be similar to that of the role creation from the Student and Teacher roles
Enter fullscreen mode Exit fullscreen mode

仔细查看管理员角色,我们会发现管理员拥有对四个集合的 CRUD 权限,仅此而已。管理员不能创建新的角色、索引、集合或数据库。经验法则是只授予用户所需的权限。我们可以放心,不会有任何不称职的管理员篡改学校的数据库。万一真的发生了这种情况,FaunaDB 允许我们恢复历史数据并还原数据库。

现在,请使用以下信息在前端注册一位经理。

Email: manager1@skulment.edu
Password: Test1234
Enter fullscreen mode Exit fullscreen mode

您应该能够在仪表盘首页看到学生、教师和课程的数量。

经理仪表盘

课程删除

由于课程引用之间存在关联,课程删除并非简单的删除操作。学生注册课程时,课程引用会被保存到他们的文档中。同样,当课程分配给教师时,也会发生这种情况。为了确保删除操作能够彻底清除课程引用,我们必须在用户自定义函数 (UDF) 中自行处理相关逻辑。

CreateFunction({
  name: "cascade_delete_course",
  body: Query(
    Lambda(
      "course_id",
      Let(
        {
          course_ref: Ref(Collection("Courses"), Var("course_id")),
          course: Get(Var("course_ref")),
          registrations: Select(["data", "registrations"], Var("course"), []),
          class_ref: Select(["data", "availableFor"], Var("course")),
          teachers: Select(["data", "teachers"], Var("course"), []),
          removeRefFromTeachers: Map(
            Var("teachers"),
            Lambda(
              "teacher_ref",
              Let(
                {
                  courses: Select(["data", "courses"], Get(Var("teacher_ref")), []),
                  updated_courses_for_teacher: Difference(Var("courses"), [Var("course_ref")])
                },
                Update(Var("teacher_ref"), {
                  data: { courses: Var("updated_courses_for_teacher") },
                })
              )
            )
          ),
          removeRefFromStudents: Map(
            Var("registrations"),
            Lambda(
              "student_ref",
              Let(
                {
                  courses: Select(["data", "courses"], Get(Var("student_ref"))),
                  updated_courses_for_student: Difference(Var("courses"), [Var("course_ref")])
                },
                Update(Var("student_ref"), {
                  data: { courses: Var("updated_courses_for_student") },
                })
              )
            )
          ),
          removeRefFromClasses: Let(
            {
              courses: Select(["data", "courses"], Get(Var("class_ref"))),
              updated_courses_for_class: Difference(Var("courses"), [Var("course_ref")])
            },
            Update(Var("class_ref"), {
              data: { courses: Var("updated_courses_for_class") },
            })
          ),
          deleteCourse: Delete(Var("course_ref")),
        },
        { status: "success" }
      )
    )
  )
});
Enter fullscreen mode Exit fullscreen mode

现在,像之前一样,赋予“经理”角色使用 `prevPrivileges - newPrivileges` 模式调用此用户自定义函数 (UDF) 的权限。最后,我们可以通过向教师分配课程,然后在用户界面中删除该课程来测试我们的 UDF。该课程的引用将从教师的已分配课程列表中移除。请参见下面的演示。

课程删除

包起来

起立鼓掌 1 分钟

哇,哇,哇!你终于读完了!我真为你骄傲!给自己一个大大的赞吧!这真是一段漫长的旅程,充满了角色、用户自定义函数 (UDF) 和各种精彩内容。让我们回顾一下本文中学到的内容:
设置 FaunaDB 数据库、
使用 Auth0 规则、使用
FaunaDB 索引、
在 FaunaDB 中使用角色、
创建和调用用户自定义函数 (UDF) 。

目前,我们已经有了一个类似MVP(最小可行产品)的应用。但是,我们的应用缺少一些关键功能,例如学生作业、评估界面、通知和细粒度的角色保护。我鼓励你们自己添加这些功能。你们可以通过Twitter私信@vicradon分享你们的作品。你们也可以在代码仓库中提交pull request。

如果您使用过其他数据库,您可能已经注意到 FaunaDB 可以让您快速上手。但这还不是全部。随着我们学校招生人数的增加,学生人数达到数千,我们无需担心可扩展性问题,因为 FaunaDB 的设计初衷就是为了自动扩展。如果您还没意识到,我们刚刚构建了一个包含身份验证和访问控制的全栈应用程序,而且完全无需构建传统的后端!我希望您能感受到 FaunaDB 的强大功能,它能够帮助您开发覆盖全球、低延迟的强大应用程序。

接下来该怎么办?

要系统地学习 FQL,您可以参考这个五部分组成的系列教程。在实际操作 FQL 之后,您应该看看这篇关于名为 Fwitter 的 Twitter 克隆平台的文章。Fwitter 的创建初衷是向开发者展示如何将 FaunaDB 应用于实际项目,例如本项目。您还可以浏览这个由 FaunaDB 社区创建的精彩 GitHub 代码库,其中包含丰富的 FaunaDB 资源。

感谢阅读。再见!

文章来源:https://dev.to/vicradon/create-a-serverless-school-management-system-with-react-auth0-and-faunadb-4p1a