让我们使用 JAMstack 创建一个功能性日历应用

Avatar of Chris Nwamba
Chris Nwamba

DigitalOcean 为您旅程的每个阶段提供云产品。开始使用 200 美元的免费积分!

让我们使用 JAMstack 创建一个功能性日历应用

我一直很好奇动态调度是如何工作的,所以我决定进行广泛的研究,学习新事物,并撰写关于旅程技术部分的文章。公平起见,我需要提醒你:这里我介绍的所有内容都是三周的研究浓缩成一篇单独的文章。即使它对初学者友好,但也需要大量的阅读。所以,请拉一把椅子,坐下来,让我们开始一段冒险吧。

我的计划是构建一个类似 Google 日历的东西,但只演示三个核心功能

  1. 列出日历上所有现有的事件
  2. 创建新的事件
  3. 基于创建期间选择的日期安排和发送电子邮件通知。该计划应运行一些代码,以便在时间合适时向用户发送电子邮件。

很不错,对吧?请阅读完本文,因为这就是我们将要实现的目标。

A calendar month view with a pop-up form for creating a new event as an overlay.

我唯一了解的关于要求我的代码在稍后或延迟时间运行的知识是 CRON 作业。使用 CRON 作业的最简单方法是在代码中**静态**定义作业。这是临时性的——**静态**意味着我不能像 Google 日历那样简单地安排事件并轻松地更新我的 CRON 代码。如果您有编写 CRON 触发器的经验,您会理解我的痛苦。如果您没有,那么您很幸运,您可能永远不必以这种方式使用 CRON。

为了更详细地阐述我的沮丧,我需要根据HTTP请求的有效负载触发计划。有关此计划的日期和信息将通过HTTP请求传递。这意味着无法预先知道计划日期等信息。

我们(我和我的同事)找到了解决此问题的方法,并且——在Sarah Drasner关于持久函数的文章的帮助下——我了解了我需要学习的内容(以及需要忘掉的内容)。您将在这篇文章中了解我所做的一切,从事件创建到电子邮件调度到日历列表。这是一个应用程序运行的视频

https://www.youtube.com/watch?v=simaM4FxPoo&

您可能会注意到细微的延迟。这与计划的执行时间或代码的运行无关。我正在使用一个免费的SendGrid帐户进行测试,我怀疑它存在某种形式的延迟。您可以通过测试负责的无服务器函数(无需发送电子邮件)来确认这一点。您会注意到代码在计划的准确时间运行。

工具和架构

以下是该项目的三个基本单元

  1. React 前端:日历UI,包括创建、更新或删除事件的UI
  2. 8Base GraphQL:应用程序的后端数据库层。我们将在这里存储、读取和更新我们的日期。有趣的是,您无需为此后端编写任何代码。
  3. 持久函数:持久函数是一种无服务器函数,它具有记住先前执行状态的功能。这就是替换 CRON 作业并解决我们之前描述的临时问题的方法。

查看 CodePen 上 Chris Nwamba (@codebeast) 编写的
durable-func1

CodePen上。

本文的其余部分将根据我们上面看到的三个单元分为三个主要部分。我们将依次进行,构建它们,测试它们,甚至部署工作。在我们开始之前,让我们使用我创建的启动项目进行设置,以便我们开始。

项目仓库

入门

您可以通过多种方式设置此项目——要么作为一个完整栈项目,将三个单元放在一个项目中,要么作为一个独立项目,每个单元都位于自己的根目录中。嗯,我选择了第一个,因为它更简洁,更容易教授,并且易于管理,因为它是一个项目。

该应用程序将是一个 create-react-app 项目,我为我们创建了一个启动器以降低设置障碍。它附带了一些补充代码和逻辑,我们不需要解释,因为它们超出了本文的范围。以下为我们设置

  1. 日历组件
  2. 用于显示事件表单的模态和弹出窗口组件
  3. 事件表单组件
  4. 一些用于查询和更改数据的 GraphQL 逻辑
  5. 一个持久无服务器函数脚手架,我们将在其中编写调度程序

提示:我们关心的每个现有文件都在文档顶部都有一个注释块。注释块告诉您代码文件中当前正在发生什么,以及一个描述接下来我们需要做什么的待办事项部分。

首先从 Github 克隆启动器

git clone -b starter --single-branch https://github.com/christiannwamba/calendar-app.git

安装根package.json文件中描述的 npm 依赖项以及 serverless package.json

npm install

用于调度的编排持久函数

在我们理解这个术语是什么之前,我们需要先解决两个词——编排持久

编排最初用于描述协调良好的事件、动作等的集合。它在计算中被大量借用,以描述计算机系统的平滑协调。关键词是协调。我们需要以协调的方式将系统的两个或多个单元放在一起。

持久用于描述任何具有持久特征的事物。

将系统协调和持久性放在一起,您就得到了持久函数。这是 Azure 无服务器函数最强大的功能。基于我们现在所知,持久函数具有这两个功能

  1. 它们可用于组装两个或多个函数的执行并协调它们,以防止出现竞争条件(编排)。
  2. 持久函数会记住内容。这就是它如此强大的原因。它打破了HTTP的第一条规则:无状态。无论持久函数需要等待多长时间,它都会保持其状态完整。为未来一百万年创建一个计划,一个持久函数将在一百万年后执行,同时记住在触发当天传递给它的参数。这意味着持久函数是有状态的

这些持久性功能为无服务器函数开启了一个新的机会领域,这就是我们今天探索其中一个功能的原因。我强烈建议再次阅读Sarah 的文章,以了解一些持久函数可能用例的可视化版本。

我还对我们今天将要编写的持久函数的行为进行了可视化表示。将其视为一个动画架构图

Shows the touch-points of a serverless system.

来自外部系统(8Base)的数据更改通过调用HTTP 触发器触发编排。然后,触发器调用编排函数,该函数安排一个事件。当执行时间到期时,会再次调用编排函数,但这次会跳过编排并调用活动函数。活动函数是动作执行者。这就是实际发生的事情,例如“发送电子邮件通知”。

创建编排持久函数

让我指导您使用VS Code 创建函数。您需要两样东西

  1. 一个 Azure 帐户
  2. 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 持久活动

从触发器开始。

  1. 单击 Azure 扩展图标,然后按照下图创建schedule函数
    Shows the interface steps going from Browse to JavaScript to Durable Functions HTTP start to naming the function schedule.
  2. 由于这是第一个函数,我们选择文件夹图标来创建一个函数项目。之后的图标会创建一个单独的函数(而不是一个项目)。
  3. 单击“浏览”,并在项目中创建一个serverless文件夹。选择新的serverless文件夹。
  4. 选择 JavaScript 作为语言。如果 TypeScript(或任何其他语言)是您的首选,请随意选择。
  5. 选择持久函数 HTTP 启动器。这是触发器。
  6. 将第一个函数命名为schedule

接下来,创建编排器。不要创建函数项目,而是创建一个函数。

  1. 单击函数图标


  2. 选择Durable Functions 编排器
  3. 给它命名为scheduleOrchestrator 并按下 Enter
  4. 系统会要求您选择一个存储帐户。编排器使用存储来保留正在处理的函数的状态。
  5. 在您的 Azure 帐户中选择一个订阅。在我的例子中,我选择了免费试用订阅。
  6. 按照剩下的几个步骤创建存储帐户。

最后,重复上一步创建活动。这次,以下内容应该有所不同

  • 选择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);
};

这里发生了什么?

  1. 我们在客户端创建了一个基于请求上下文的持久化函数。
  2. 我们使用客户端的startNew() 函数调用编排器。编排器函数名称作为第一个参数通过params 对象传递给startNew()req.body 也作为第三个参数传递给startNew(),并转发给编排器。
  3. 最后,我们返回一组数据,这些数据可用于检查编排器函数的状态,甚至在完成之前取消该过程。

调用上述函数的 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.bindingspayload 可以是您命名的任何内容,因此请转到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,它们包括

  1. 日历:一个显示所有现有事件的日历UI
  2. 事件模态框:一个渲染EventForm组件以创建组件的 React 模态框
  3. 事件弹出框:用于读取单个事件、使用EventForm更新事件或删除事件的弹出框UI
  4. 事件表单:用于创建新事件的 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组件。然后

  1. 我们使用事件列表渲染Calendar
  2. 我们为Calendar提供了一个自定义弹出框(EventPopover)组件,该组件将用于编辑事件。
  3. 我们渲染一个模态框(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处,导入useCreateUpdateMutationuseDeleteMutation

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,邮件应该已经收到了。现在我们有一个应用,它允许我们创建事件并收到事件提交的详细信息通知。