logo

使用Zipper快速构建无服务器(Serverless)应用

277
2023年09月20日
在回忆了Ruby on Rails的美好时光后,我发现了Zipper平台,并想看看我能多快构建出有价值的东西。

我记得第一次看到 Ruby on Rails 的演示时,印象深刻。演示者们几乎不费吹灰之力就创建了一个可以用于真实业务目的的全栈 Web 应用。我印象深刻,尤其是当我想到我用 Seam 和 Struts 框架提供类似解决方案时花了多少时间。

Ruby 是在 1993 年创建的,旨在成为易于使用的脚本语言,同时还包括面向对象的特性。Ruby on Rails 在 2000 年代中期将事情推向了一个新的水平——恰好在合适的时间出现,成为 Twitter、Shopify、GitHub 和 Airbnb 初创企业的首选技术。

我开始思考一个问题:“是否可能拥有一个像 Ruby on Rails 这样的产品,而无需担心基础架构或底层数据层呢?”

这就是我发现 Zipper 平台的时候。

关于 Zipper

Zipper 是一个使用简单的 TypeScript 函数构建 Web 服务的平台。您可以使用 Zipper 创建 applet(与 Java 无关,尽管它们有相同的名称),然后在 Zipper 平台上构建和部署它们。

Zipper 最酷的地方在于,它让您专注于使用 TypeScript 编写解决方案,而无需担心其他任何事情。Zipper 负责:

  • 用户界面
  • 托管解决方案的基础设施
  • 持久化层
  • 与您的 applet 交互的 API
  • 身份验证

尽管该平台目前处于测试阶段,但已经向消费者开放使用。在我写这篇文章的时候,已经有四个模板可供新用户开始使用:

  • Hello World——一个基本的 applet,让您开始
  • CRUD 模板——提供一个待办事项列表,其中可以创建、查看、更新和删除项目
  • Slack 应用模板——提供了如何与 Slack 服务交互的示例
  • AI 生成的代码——用人类语言表达您的解决方案,并让 AI 为您创建 applet

Zipper 平台还有一个画廊,其中提供了可以像基于 Git 的存储库一样分叉的 applet。

Zipper 仪表板

我想测试一下 Zipper 平台,并创建一个投票 applet。

HOA 投票使用案例

美国的房主协会(HOA)概念在 20 世纪开始在美国获得了一定的势头。分区成立 HOA 来处理诸如公共区域的护理以及为居民制定规则和指南等事务。他们的目标是在房屋建筑商完成开发后,长期维护分区的整体生活质量。

HOA 经常举行选举,让房主投票选择他们认为最符合他们观点和观念的候选人。事实上,去年我发表了一篇关于如何使用 Web3 技术创建 HOA 投票的文章。

对于这篇文章,我想使用 Zipper 采取相同的方法。

投票要求

投票 applet 的要求是:

  1. 作为投票所有者,我需要能够创建候选人名单。
  2. 作为投票所有者,我需要能够创建注册选民名单。
  3. 作为选民,我需要能够查看候选人名单。
  4. 作为选民,我需要能够为单个候选人投一票。
  5. 作为选民,我需要能够查看每个候选人获得的当前票数。

此外,我认为一些额外的目标也很不错:

  1. 作为投票所有者,我需要能够清除所有候选人。
  2. 作为投票所有者,我需要能够清除所有选民。
  3. 作为投票所有者,我需要能够为投票设置一个标题。
  4. 作为投票所有者,我需要能够为投票设置一个副标题。

设计投票 applet

要开始使用 Zipper 平台,我导航到 Zipper 的网站,然后点击 登录 按钮。接下来,我选择了一个认证来源:

Zipper 登录

登录后,我从仪表板中使用 创建 Applet 按钮创建了一个新的 applet:

创建 Applet

生成了一个唯一的名称,但可以更改以更好地识别您的用例。目前,我保留了所有默认设置,并点击了 下一步 按钮——这使我可以从四种不同的模板中选择 applet 创建:

创建 Applet

我从 CRUD 模板开始,因为它提供了一个很好的示例,展示了在 Zipper 平台上如何进行常见的创建、查看、更新和删除流程。代码创建后,屏幕显示如下:

显示代码创建后的屏幕

有了一个完全功能的 applet,我们现在可以更新代码以满足 HOA 投票的要求。

建立核心元素

对于投票 applet,我想要做的第一件事是更新 types.ts 文件,如下所示:

export type Candidate = {
  id: string;
  name: string;
  votes: number;
};

export type Voter = {
  email: string;
  name: string;
  voted: boolean;
};

我想在一个新文件 constants.ts 中为投票标题和副标题建立常量值:

export class Constants {
  static readonly BALLOT_TITLE = "样本投票";
  static readonly BALLOT_SUBTITLE = "样本投票副标题";
};

为了只允许投票所有者对投票进行更改,我使用了 applet 的 Secrets 标签来创建一个所有者密钥,其值为我的电子邮件地址。

Secrets

然后,我引入了一个 common.ts 文件,其中包含了 validateRequest() 函数:

export function validateRequest(context: Zipper.HandlerContext) {
  if (context.userInfo?.email !== Deno.env.get('owner')) {
    return (
      <>
         <Markdown>
         {`### 错误:
         您无权执行此操作`}
         </Markdown>
      </>
    );
  }
};

这样,我可以将上下文传递给此函数,以确保只有 owner 密钥中的值被允许对投票和选民进行更改。

建立候选人

在了解了原始 CRUD applet 中如何创建待办事项后,我能够引入 create-candidate.ts 文件,如下所示:

import { Candidate } from "./types.ts";
import { validateRequest } from "./common.ts";

type Input = {
  name: string;
};

export async function handler({ name }: Input, context: Zipper.HandlerContext) {
  validateRequest(context);

  const candidates =
    (await Zipper.storage.get<Candidate[]>("candidates")) || [];
  const newCandidate: Candidate = {
    id: crypto.randomUUID(),
    name: name,
    votes: 0,
  };
  candidates.push(newCandidate);
  await Zipper.storage.set("candidates", candidates);
  return newCandidate;
}

对于这个用例,我们只需要提供候选人姓名,但 Candidate 对象包含了一个唯一的 ID 和获得的票数。

在这里,我继续编写了 delete-all-candidates.ts 文件,用于从键/值数据存储中删除所有候选人:

import { validateRequest } from "./common.ts";

type Input = {
  force: boolean;
};

export async function handler(
  { force }: Input,
  context: Zipper.HandlerContext
) {
  validateRequest(context);

  if (force) {
    await Zipper.storage.set("candidates", []);
  }
}

此时,我使用了 预览 功能来创建候选人 A、候选人 B 和候选人 C:

使用预览功能创建候选人 A、B 和 C

注册选民

有了准备好的投票,我需要为投票注册选民的能力。因此,我添加了一个 create-voter.ts 文件,内容如下:

import { Voter } from "./types.ts";
import { validateRequest } from "./common.ts";

type Input = {
  email: string;
  name: string;
};

export async function handler(
  { email, name }: Input,
  context: Zipper.HandlerContext
) {
  validateRequest(context);

  const voters = (await Zipper.storage.get<Voter[]>("voters")) || [];
  const newVoter: Voter = {
    email: email,
    name: name,
    voted: false,
  };
  voters.push(newVoter);
  await Zipper.storage.set("voters", voters);
  return newVoter;
}

为了注册选民,我决定提供电子邮件地址和姓名的输入。还有一个名为 voted 的布尔属性,将用于执行一票一次的规则。

与之前一样,我继续创建了 delete-all-voters.ts 文件:

import { validateRequest } from "./common.ts";

type Input = {
  force: boolean;
};

export async function handler(
  { force }: Input,
  context: Zipper.HandlerContext
) {
  validateRequest(context);

  if (force) {
    await Zipper.storage.set("voters", []);
  }
}

现在,我们准备注册一些选民,我为投票注册了自己:

为投票注册为选民

创建投票

我需要做的最后一件事是建立投票。这涉及更新 main.ts,内容如下:

import { Constants } from "./constants.ts";
import { Candidate, Voter } from "./types.ts";

type Input = {
  email: string;
};

export async function handler({ email }: Input) {
  const voters = (await Zipper.storage.get<Voter[]>("voters")) || [];
  const voter = voters.find((v) => v.email == email);
  const candidates =
    (await Zipper.storage.get<Candidate[]>("candidates")) || [];

  if (email && voter && candidates.length > 0) {
    return {
      candidates: candidates.map((candidate) => {
        return {
          Candidate: candidate.name,
          Votes: candidate.votes,
          actions: [
            Zipper.Action.create({
              actionType: "button",
              showAs: "refresh",
              path: "vote",
              text: `Vote for ${candidate.name}`,
              isDisabled: voter.voted,
              inputs: {
                candidateId: candidate.id,
                voterId: voter.email,
              },
            }),
          ],
        };
      }),
    };
  } else if (!email) {
    <>
      <h4>错误:</h4>
      <p>
        您必须提供有效的电子邮件地址才能为此投票投票。
      </p>
    </>;
  } else if (!voter) {
    return (
      <>
        <h4>无效的电子邮件地址:</h4>
        <p>
          提供的电子邮件地址({email})未经授权为此投票投票。
        </p>
      </>
    );
  } else {
    return (
      <>
        <h4>投票尚未准备好:</h4>
        <p>此投票尚未配置任何候选人。</p>
        <p>请稍后再试。</p>
      </>
    );
  }
}

export const config: Zipper.HandlerConfig = {
  description: {
    title: Constants.BALLOT_TITLE,
    subtitle: Constants.BALLOT_SUBTITLE,
  },
};

我添加了以下验证作为处理逻辑的一部分:

  • 必须包含电子邮件属性,否则将显示“您必须提供有效的电子邮件地址才能为此投票投票”消息。

  • 提供的电子邮件值必须与注册选民匹配,否则将显示“提供的电子邮件地址未经授权为此投票投票”消息。

  • 必须至少有一个候选人可供投票,否则将显示“此投票尚未配置任何候选人”消息。

  • 如果已注册选民已经投票,那么选票按钮将对选票上的所有候选人进行禁用。

main.ts 文件包含每个候选人的按钮,所有这些按钮都调用下面显示的 vote.ts 文件:

import { Candidate, Voter } from "./types.ts";

type Input = {
  candidateId: string;
  voterId: string;
};

export async function handler({ candidateId, voterId }: Input) {
  const candidates = (await Zipper.storage.get<Candidate[]>("candidates")) || [];
  const candidate = candidates.find((c) => c.id == candidateId);
  const candidateIndex = candidates.findIndex(c => c.id == candidateId);

  const voters = (await Zipper.storage.get<Voter[]>("voters")) || [];
  const voter = voters.find((v) => v.email == voterId);

  const voterIndex = voters.findIndex(v => v.email == voterId);

  if (candidate && voter) {
    candidate.votes++;
    candidates[candidateIndex] = candidate;

    voter.voted = true;
    voters[voterIndex] = voter;

    await Zipper.storage.set("candidates", candidates);
    await Zipper.storage.set("voters", voters);
    return `${voter.name} 成功为 ${candidate.name} 投票`;
  }

  return `无法投票。候选人=${ candidate },选民=${ voter }`;
}

此时,选票小程序已经准备就绪。

HOA 投票小程序的运行

对于每个已注册选民,我会发送一封类似于以下链接的电子邮件:

https://squeeking-echoing-cricket.zipper.run/run/main.ts?email=some.email@example.com

该链接将被定制,以为 email 查询参数提供适当的电子邮件地址。点击链接将运行 main.ts 文件并传入电子邮件参数,避免已注册选民需要输入他们的电子邮件地址。

选票显示如下:

样本选票

我决定为候选人 B 投票。一旦我点击按钮,选票将更新如下:

显示已投票的更新选票

候选人 B 的票数增加了一票,并且所有的选票按钮都被禁用。成功!

本文链接:https://www.iokks.com/art/74ee74342939
本博客所有文章除特别声明外,均采用CC BY 4.0 CN协议 许可协议。转载请注明出处!