让我们使用 JAMstack 创建一个功能性日历应用
我一直很好奇动态调度是如何工作的,所以我决定进行广泛的研究,学习新事物,并撰写关于旅程技术部分的文章。公平起见,我需要提醒你:这里我介绍的所有内容都是三周的研究浓缩成一篇单独的文章。即使它对初学者友好,但也需要大量的阅读。所以,请拉一把椅子,坐下来,让我们开始一段冒险吧。
我的计划是构建一个类似 Google 日历的东西,但只演示三个核心功能
- 列出日历上所有现有的事件
- 创建新的事件
- 基于创建期间选择的日期安排和发送电子邮件通知。该计划应运行一些代码,以便在时间合适时向用户发送电子邮件。
很不错,对吧?请阅读完本文,因为这就是我们将要实现的目标。

我唯一了解的关于要求我的代码在稍后或延迟时间运行的知识是 CRON 作业。使用 CRON 作业的最简单方法是在代码中**静态**定义作业。这是临时性的——**静态**意味着我不能像 Google 日历那样简单地安排事件并轻松地更新我的 CRON 代码。如果您有编写 CRON 触发器的经验,您会理解我的痛苦。如果您没有,那么您很幸运,您可能永远不必以这种方式使用 CRON。
为了更详细地阐述我的沮丧,我需要根据HTTP请求的有效负载触发计划。有关此计划的日期和信息将通过HTTP请求传递。这意味着无法预先知道计划日期等信息。
我们(我和我的同事)找到了解决此问题的方法,并且——在Sarah Drasner关于持久函数的文章的帮助下——我了解了我需要学习的内容(以及需要忘掉的内容)。您将在这篇文章中了解我所做的一切,从事件创建到电子邮件调度到日历列表。这是一个应用程序运行的视频
https://www.youtube.com/watch?v=simaM4FxPoo&
您可能会注意到细微的延迟。这与计划的执行时间或代码的运行无关。我正在使用一个免费的SendGrid帐户进行测试,我怀疑它存在某种形式的延迟。您可以通过测试负责的无服务器函数(无需发送电子邮件)来确认这一点。您会注意到代码在计划的准确时间运行。
工具和架构
以下是该项目的三个基本单元
- React 前端:日历UI,包括创建、更新或删除事件的UI。
- 8Base GraphQL:应用程序的后端数据库层。我们将在这里存储、读取和更新我们的日期。有趣的是,您无需为此后端编写任何代码。
- 持久函数:持久函数是一种无服务器函数,它具有记住先前执行状态的功能。这就是替换 CRON 作业并解决我们之前描述的临时问题的方法。
查看 CodePen 上 Chris Nwamba (@codebeast) 编写的
durable-func1。
在CodePen上。
本文的其余部分将根据我们上面看到的三个单元分为三个主要部分。我们将依次进行,构建它们,测试它们,甚至部署工作。在我们开始之前,让我们使用我创建的启动项目进行设置,以便我们开始。
入门
您可以通过多种方式设置此项目——要么作为一个完整栈项目,将三个单元放在一个项目中,要么作为一个独立项目,每个单元都位于自己的根目录中。嗯,我选择了第一个,因为它更简洁,更容易教授,并且易于管理,因为它是一个项目。
该应用程序将是一个 create-react-app 项目,我为我们创建了一个启动器以降低设置障碍。它附带了一些补充代码和逻辑,我们不需要解释,因为它们超出了本文的范围。以下为我们设置
- 日历组件
- 用于显示事件表单的模态和弹出窗口组件
- 事件表单组件
- 一些用于查询和更改数据的 GraphQL 逻辑
- 一个持久无服务器函数脚手架,我们将在其中编写调度程序
提示:我们关心的每个现有文件都在文档顶部都有一个注释块。注释块告诉您代码文件中当前正在发生什么,以及一个描述接下来我们需要做什么的待办事项部分。
首先从 Github 克隆启动器
git clone -b starter --single-branch https://github.com/christiannwamba/calendar-app.git
安装根package.json
文件中描述的 npm 依赖项以及 serverless package.json
npm install
用于调度的编排持久函数
在我们理解这个术语是什么之前,我们需要先解决两个词——编排和持久。
编排最初用于描述协调良好的事件、动作等的集合。它在计算中被大量借用,以描述计算机系统的平滑协调。关键词是协调。我们需要以协调的方式将系统的两个或多个单元放在一起。
持久用于描述任何具有持久特征的事物。
将系统协调和持久性放在一起,您就得到了持久函数。这是 Azure 无服务器函数最强大的功能。基于我们现在所知,持久函数具有这两个功能
- 它们可用于组装两个或多个函数的执行并协调它们,以防止出现竞争条件(编排)。
- 持久函数会记住内容。这就是它如此强大的原因。它打破了HTTP的第一条规则:无状态。无论持久函数需要等待多长时间,它都会保持其状态完整。为未来一百万年创建一个计划,一个持久函数将在一百万年后执行,同时记住在触发当天传递给它的参数。这意味着持久函数是有状态的。
这些持久性功能为无服务器函数开启了一个新的机会领域,这就是我们今天探索其中一个功能的原因。我强烈建议再次阅读Sarah 的文章,以了解一些持久函数可能用例的可视化版本。
我还对我们今天将要编写的持久函数的行为进行了可视化表示。将其视为一个动画架构图
来自外部系统(8Base)的数据更改通过调用HTTP 触发器触发编排。然后,触发器调用编排函数,该函数安排一个事件。当执行时间到期时,会再次调用编排函数,但这次会跳过编排并调用活动函数。活动函数是动作执行者。这就是实际发生的事情,例如“发送电子邮件通知”。
创建编排持久函数
让我指导您使用VS Code 创建函数。您需要两样东西
设置好这两个后,您需要将它们绑定在一起。您可以使用VS Code 扩展和 Node CLI 工具来实现。首先安装CLI工具
npm install -g azure-functions-core-tools
# OR
brew tap azure/functions
brew install azure-functions-core-tools
接下来,安装Azure 函数扩展,以便将VS Code 绑定到 Azure 上的函数。您可以从我之前的文章中阅读更多关于设置 Azure 函数的信息。
现在您已经完成了所有设置,让我们开始创建这些函数。我们将创建的函数将映射到以下文件夹。
文件夹 | 函数 |
---|---|
schedule |
持久HTTP触发器 |
scheduleOrchestrator |
持久编排 |
sendEmail |
持久活动 |
从触发器开始。
- 单击 Azure 扩展图标,然后按照下图创建
schedule
函数
- 由于这是第一个函数,我们选择文件夹图标来创建一个函数项目。之后的图标会创建一个单独的函数(而不是一个项目)。
- 单击“浏览”,并在项目中创建一个
serverless
文件夹。选择新的serverless
文件夹。 - 选择 JavaScript 作为语言。如果 TypeScript(或任何其他语言)是您的首选,请随意选择。
- 选择
持久函数 HTTP 启动器
。这是触发器。 - 将第一个函数命名为
schedule
接下来,创建编排器。不要创建函数项目,而是创建一个函数。
- 单击函数图标
- 选择
Durable Functions 编排器
。 - 给它命名为
scheduleOrchestrator
并按下 Enter。 - 系统会要求您选择一个存储帐户。编排器使用存储来保留正在处理的函数的状态。
- 在您的 Azure 帐户中选择一个订阅。在我的例子中,我选择了免费试用订阅。
- 按照剩下的几个步骤创建存储帐户。
最后,重复上一步创建活动。这次,以下内容应该有所不同
- 选择
Durable Functions 活动
。 - 将其命名为
sendEmail
。 - 不需要存储帐户。
使用持久化 HTTP 触发器进行调度
serverless/schedule/index.js
中的代码不需要修改。这是使用 VS Code 或 CLI 工具搭建函数时的原始样子。
const df = require("durable-functions");
module.exports = async function (context, req) {
const client = df.getClient(context);
const instanceId = await client.startNew(req.params.functionName, undefined, req.body);
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(context.bindingData.req, instanceId);
};
这里发生了什么?
- 我们在客户端创建了一个基于请求上下文的持久化函数。
- 我们使用客户端的
startNew()
函数调用编排器。编排器函数名称作为第一个参数通过params
对象传递给startNew()
。req.body
也作为第三个参数传递给startNew()
,并转发给编排器。 - 最后,我们返回一组数据,这些数据可用于检查编排器函数的状态,甚至在完成之前取消该过程。
调用上述函数的 URL 如下所示
https://#:7071/api/orchestrators/{functionName}
其中functionName
是传递给startNew
的名称。在我们的例子中,它应该是
//#:7071/api/orchestrators/scheduleOrchestrator
还需要知道您可以更改此 URL 的外观。
使用持久化编排器进行编排
HTTP 触发器startNew
调用根据我们传递给它的名称调用函数。该名称对应于包含编排逻辑的函数和文件夹的名称。serverless/scheduleOrchestrator/index.js
文件导出一个持久化函数。将内容替换为以下内容
const df = require("durable-functions");
module.exports = df.orchestrator(function* (context) {
const input = context.df.getInput()
// TODO -- 1
// TODO -- 2
});
编排器函数使用context.df.getInput()
从 HTTP 触发器中检索请求正文。
将TODO -- 1
替换为以下代码行,这可能是整个演示中最重要的事情
yield context.df.createTimer(new Date(input.startAt))
此代码行使用持久化函数根据从 HTTP 触发器通过请求正文传递的日期创建一个计时器。
当此函数执行并到达此处时,它将触发计时器并暂时退出。当计划到期时,它将返回,跳过此行并调用以下行,您应该将其用于替换TODO -- 2
。
return yield context.df.callActivity('sendEmail', input);
该函数将调用活动函数发送电子邮件。我们还将有效负载作为第二个参数传递。
这是完成后的函数的样子
const df = require("durable-functions");
module.exports = df.orchestrator(function* (context) {
const input = context.df.getInput()
yield context.df.createTimer(new Date(input.startAt))
return yield context.df.callActivity('sendEmail', input);
});
使用持久化活动发送电子邮件
当计划到期时,编排器返回调用活动。活动文件位于serverless/sendEmail/index.js
中。将其中的内容替换为以下内容
const sgMail = require('@sendgrid/mail');
sgMail.setApiKey(process.env['SENDGRID_API_KEY']);
module.exports = async function(context) {
// TODO -- 1
const msg = {}
// TODO -- 2
return msg;
};
它目前导入 SendGrid 的邮件发送器并设置 API 密钥。您可以按照 这些说明获取 API 密钥。
我将密钥设置为环境变量以确保我的凭据安全。您可以通过在serverless/local.settings.json
中创建SENDGRID_API_KEY
密钥并将您的 SendGrid 密钥作为值来以相同的方式安全地存储您的密钥
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "<<AzureWebJobsStorage>",
"FUNCTIONS_WORKER_RUNTIME": "node",
"SENDGRID_API_KEY": "<<SENDGRID_API_KEY>"
}
}
将TODO -- 1
替换为以下代码行
const { email, title, startAt, description } = context.bindings.payload;
这从编排器函数的输入中提取事件信息。输入附加到context.bindings
。payload
可以是您命名的任何内容,因此请转到serverless/sendEmail/function.json
并将name
值更改为payload
{
"bindings": [
{
"name": "payload",
"type": "activityTrigger",
"direction": "in"
}
]
}
接下来,使用以下代码块更新TODO -- 2
以发送电子邮件
const msg = {
to: email,
from: { email: '[email protected]', name: 'Codebeast Calendar' },
subject: `Event: ${title}`,
html: `<h4>${title} @ ${startAt}</h4> <p>${description}</p>`
};
sgMail.send(msg);
return msg;
这是完整版本
const sgMail = require('@sendgrid/mail');
sgMail.setApiKey(process.env['SENDGRID_API_KEY']);
module.exports = async function(context) {
const { email, title, startAt, description } = context.bindings.payload;
const msg = {
to: email,
from: { email: '[email protected]', name: 'Codebeast Calendar' },
subject: `Event: ${title}`,
html: `<h4>${title} @ ${startAt}</h4> <p>${description}</p>`
};
sgMail.send(msg);
return msg;
};
将函数部署到 Azure
将函数部署到 Azure 很简单。只需点击 VS Code 编辑器中的一个图标即可。点击带圆圈的图标进行部署并获取部署 URL

到目前为止您都还在坚持吗?您正在取得很大的进步!在这里休息一下、小睡一下、伸展一下或休息一下完全没问题。我在写这篇文章的时候也确实这么做了。
使用 8Base 进行数据和 GraphQL 层
我对 8Base 最简单的描述和理解是“GraphQL 版 Firebase”。8Base 是任何类型的应用程序的数据库层,它最有趣的一点是它基于 GraphQL。
描述 8Base 在您的技术栈中所处位置的最佳方法是描绘一个场景。
假设您是一位自由职业者开发人员,承接了一个中小型电子商务商店的开发合同。您的核心技能在前端,因此您不太擅长后端,尽管您可以编写一些 Node 代码。
不幸的是,电子商务需要管理库存、订单管理、管理购买、管理身份验证和身份等。“管理”在根本层面上只是意味着数据 CRUD 和数据访问。
如果我们可以在 UI 中描述这些业务需求,而不是重复且枯燥地在后端代码中创建、读取、更新、删除和管理实体的访问权限,那该多好?如果我们可以创建允许我们配置 CRUD 操作、身份验证和访问权限的表格,那该多好?如果我们有这样的帮助,并且只需要专注于构建前端代码和编写查询,那该多好?我们刚才描述的所有内容都由 8Base 解决
这是一个依赖 8Base 作为数据层的无后端应用程序的架构
创建用于事件存储和检索的 8Base 表格
在创建表格之前,我们需要做的第一件事是 创建一个帐户。拥有帐户后,创建一个工作区来保存给定项目的所有表格和逻辑。

接下来,创建一个表格,将表格命名为Events
并填写表格字段。

我们需要配置访问级别。目前,没有任何内容需要对每个用户隐藏,因此我们可以打开对我们创建的 Events 表格的所有访问权限

使用 8base 设置身份验证非常简单,因为它与 Auth0 集成。如果您有需要保护的实体或希望扩展我们的示例以使用身份验证,请尽情尝试。
最后,获取您的端点 URL 以在 React 应用程序中使用

在 Playground 中测试 GraphQL 查询和变异
为了确保我们已准备好将 URL 发布到互联网并开始构建客户端,让我们首先使用 GraphQL Playground 测试 API,看看设置是否正常。点击资源管理器。

将以下查询粘贴到编辑器中。
query {
eventsList {
count
items {
id
title
startAt
endAt
description
allDay
email
}
}
}
我通过 8base UI 创建了一些测试数据,并在运行查询时获得了结果

您可以使用资源管理器页面右侧的架构文档浏览整个数据库。
日历和事件表单界面
我们项目的第三个(也是最后一个)单元是构建用户界面的 React 应用。有四个主要组件构成UI,它们包括
- 日历:一个显示所有现有事件的日历UI
- 事件模态框:一个渲染
EventForm
组件以创建组件的 React 模态框 - 事件弹出框:用于读取单个事件、使用
EventForm
更新事件或删除事件的弹出框UI - 事件表单:用于创建新事件的 HTML 表单
在我们深入研究日历组件之前,我们需要设置 React Apollo 客户端。React Apollo 提供程序为你提供了使用 React 模式查询 GraphQL 数据源的工具。原始提供程序允许你使用高阶组件或渲染 props 来查询和修改数据。我们将使用一个原始提供程序的包装器,它允许你使用 React Hooks 来查询和修改数据。
在src/index.js
中,在TODO -- 1
处导入 React Apollo Hooks 和 8base 客户端
import { ApolloProvider } from 'react-apollo-hooks';
import { EightBaseApolloClient } from '@8base/apollo-client';
在TODO -- 2
处,使用我们在 8base 设置阶段获得的端点URL配置客户端
const URI = 'https://api.8base.com/cjvuk51i0000701s0hvvcbnxg';
const apolloClient = new EightBaseApolloClient({
uri: URI,
withAuth: false
});
使用此客户端在TODO -- 3
处用提供程序包装整个App
树
ReactDOM.render(
<ApolloProvider client={apolloClient}>
<App />
</ApolloProvider>,
document.getElementById('root')
);
在日历上显示事件
Calendar
组件渲染在App
组件内部,并且从 npm 导入BigCalendar
组件。然后
- 我们使用事件列表渲染
Calendar
。 - 我们为
Calendar
提供了一个自定义弹出框(EventPopover
)组件,该组件将用于编辑事件。 - 我们渲染一个模态框(
EventModal
),该模态框将用于创建新事件。
我们唯一需要更新的是事件列表。我们不希望使用静态的事件数组,而是希望查询 8base 获取所有存储的事件。
将TODO -- 1
替换为以下代码行
const { data, error, loading } = useQuery(EVENTS_QUERY);
在文件开头导入来自 npm 的useQuery
库和EVENTS_QUERY
import { useQuery } from 'react-apollo-hooks';
import { EVENTS_QUERY } from '../../queries';
EVENTS_QUERY
与我们在 8base 浏览器中测试的查询完全相同。它位于src/queries
中,看起来像这样
export const EVENTS_QUERY = gql`
query {
eventsList {
count
items {
id
...
}
}
}
`;
让我们在TODO -- 2
处添加一个简单的错误和加载处理程序
if (error) return console.log(error);
if (loading)
return (
<div className="calendar">
<p>Loading...</p>
</div>
);
请注意,Calendar
组件使用EventPopover
组件来渲染自定义事件。你还可以观察到Calendar
组件文件也渲染了EventModal
。这两个组件都已为你设置,它们唯一的责任是渲染EventForm
。
使用事件表单组件创建、更新和删除事件
src/components/Event/EventForm.js
中的组件渲染一个表单。该表单用于创建、编辑或删除事件。在TODO -- 1
处,导入useCreateUpdateMutation
和useDeleteMutation
import {useCreateUpdateMutation, useDeleteMutation} from './eventMutationHooks'
useCreateUpdateMutation
:此变异根据事件是否已存在来创建或更新事件。useDeleteMutation
:此变异删除现有事件。
对任何这些函数的调用都会返回另一个函数。然后,返回的函数可以用作事件处理程序。
现在,继续使用对这两个函数的调用替换TODO -- 2
const createUpdateEvent = useCreateUpdateMutation(
payload,
event,
eventExists,
() => closeModal()
);
const deleteEvent = useDeleteMutation(event, () => closeModal());
这些是我编写的自定义钩子,用于包装 React Apollo Hooks 公开的useMutation
。每个钩子创建一个变异并将变异变量传递给useMutation
查询。src/components/Event/eventMutationHooks.js
中如下所示的代码块是最重要的部分
useMutation(mutationType, {
variables: {
data
},
update: (cache, { data }) => {
const { eventsList } = cache.readQuery({
query: EVENTS_QUERY
});
cache.writeQuery({
query: EVENTS_QUERY,
data: {
eventsList: transformCacheUpdateData(eventsList, data)
}
});
//..
}
});
从 8Base 调用 Durable Function HTTP 触发器
我们已经花费了相当多的时间来构建日历应用的无服务器结构、数据存储和UI层。概括地说,UI将数据发送到 8base 进行存储,**8base 保存数据并触发** Durable Function HTTP 触发器,HTTP 触发器启动编排,其余的都是历史了。目前,我们正在使用变异保存数据,但我们没有在 8base 的任何地方调用无服务器函数。
8base 允许你编写自定义逻辑,这使其变得非常强大和可扩展。自定义逻辑是根据在 8base 数据库上执行的操作调用的简单函数。例如,我们可以设置一个逻辑,以便在每次对表执行变异时调用。让我们创建一个在创建事件时调用的逻辑。
首先安装 8base CLI
npm install -g 8base
在日历应用项目上运行以下命令以创建一个启动逻辑
8base init 8base
8base init
命令创建一个新的 8base 逻辑项目。你可以传递一个目录名称,在本例中,我们将 8base 逻辑文件夹命名为8base
——不要搞混了。
触发调度逻辑
删除8base/src
中的所有内容,并在src
文件夹中创建一个triggerSchedule.js
文件。完成后,将以下内容放入文件中
const fetch = require('node-fetch');
module.exports = async event => {
const res = await fetch('<HTTP Trigger URL>', {
method: 'POST',
body: JSON.stringify(event.data),
headers: { 'Content-Type': 'application/json' }
})
const json = await res.json();
console.log(event, json)
return json;
};
有关 GraphQL 变异的信息在event
对象中作为数据可用。
将<HTTP Trigger URL>
替换为你部署函数后获得的URL。你可以通过转到 Azure URL中的函数并点击“复制 URL”来获取 URL。
你还需要安装node-fetch
模块,它将从 API 获取数据
npm install --save node-fetch
8base 逻辑配置
接下来要做的是告诉 8base 需要触发此逻辑的确切变异或查询。在我们的例子中,是对Events
表的创建变异。你可以在8base.yml
文件中描述此信息
functions:
triggerSchedule:
handler:
code: src/triggerSchedule.js
type: trigger.after
operation: Events.create
从某种意义上说,这意味着“当对 Events 表发生创建变异时,请在变异发生后调用src/triggerSchedule.js
”。
我们想要部署所有东西
在任何内容可以部署之前,我们需要登录到 8Base 帐户,这可以通过命令行完成
8base login
然后,让我们运行deploy
命令将应用逻辑发送并设置到你的工作区实例中。
8base deploy
测试整个流程
要查看应用的全部功能,请点击日历中的一天。你应该会看到包含表单的事件模态框。填写表单并设置一个未来的开始日期,以便触发通知。尝试设置一个比当前时间晚 2-5 分钟的日期,因为我还没有能够触发比这更快的通知。
https://www.youtube.com/watch?v=simaM4FxPoo&
耶,去检查你的邮箱!感谢 SendGrid,邮件应该已经收到了。现在我们有一个应用,它允许我们创建事件并收到事件提交的详细信息通知。