更换Google Firebase-开源替代堆栈
#网络开发人员 #开源 #database #firebase

在过去的一个月左右的时间里,Google设法惹恼了两个不同的人群。第一个管理域名的小组,无论是为雇主还是他们自己,都受到Google的selling its domain service to SquareSpace的意外攻击。另一组是被迫进入switch from Universal Analytics to Google Analytics V4的数字营销人员,据说很难使用。

由于Google拥有a good tradition of killing products,我们可以帮助您想知道谁将成为下一个。会是fifebaseð?

这篇文章是何时进入Google墓地的预测。实际上,Firebase在其启动中是开创性的,展示了世界的后端服务是什么,并且仍然是当今的绝佳工具。十年后,我们有很多选择。熟悉该领域的人们可能已经知道supabase,这是最初被定位为燃箱替代品的流行的BAA。今天,让我们回顾另一个由多个流行的OSS项目组成的替代:

  • Next.js-使用React.js
  • 构建Web应用程序的全栈框架
  • NextAuth-开源身份验证框架
  • Prisma-下一代节点/typescript orm
  • ZenStack-用强大的访问控制层增压Prisma

为了促进两种解决方案之间的比较,我将使用一个简单的博客应用程序作为参考:

Blog App

业务要求是:

  1. 基于电子邮件/密码的登录/注册。
  2. 用户可以自己创建帖子。
  3. 发布所有者可以更新/发布/untublish/删除自己的帖子。
  4. 用户无法更改不属于他们的帖子。
  5. 所有登录用户都可以查看已发布的帖子。

评论将重点放在比较以下方面:

  • ð身份验证
  • - ð»数据建模
  • ð访问控制
  • ð©ð»ð»前端数据查询

让我们开始。

验证

Firebase提供了一项支持一组身份提供者的身份验证服务。基于电子邮件/密码的身份验证非常简单,因为Google的后端服务为我们处理大部分工作:

import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';

const auth = getAuth();

async function onSignin(email: string, password: string) {
  try {
    await signInWithEmailAndPassword(auth, email, password);
    Router.push('/');
  } catch (err) {
    alert('Unable to sign in: ' + (err as Error).message);
  }
}

使用next.js + NextAuth进行身份验证需要更多的工作,这主要是因为您需要设置存储后端以持久使用用户帐户。我们对数据层使用PostgreSQL + PRISMA,因此我们必须首先定义相关的数据模式。简而

// schema.prisma

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
}

model Account { ... }

model Session { ... }

下一步是将身份验证后端安装为Next中的API路由。JS:

// pages/api/auth/[...nextauth].ts

import NextAuth, { type NextAuthOptions } from 'next-auth';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prisma } from '../../../server/db';
import type { PrismaClient } from '@prisma/client';

export const authOptions: NextAuthOptions = {
  ...
  adapter: PrismaAdapter(prisma),
  providers: [
    CredentialsProvider({
      credentials: {
        email: { type: 'email'},
        password: { type: 'password' },
      },
      authorize: authorize(prisma),
    }),
  ],
};

function authorize(prisma: PrismaClient) {
  return async (
    credentials: Record<'email' | 'password', string> | undefined
  ) => {
    // verify email password against database
    ..
  };
}

export default NextAuth(authOptions);

将这些前端部分非常简单:

import { signIn } from "next-auth/react";

async function onSignin(email: string, password: string) {
  const result = await signIn("credentials", {
    redirect: false,
    email,
    password,
  });

  if (result?.ok) {
    Router.push("/");
  } else {
    alert("Sign in failed");
  }
}

比较

Firebase身份验证在表面上看起来很容易,但是没有限制。主要问题是其用户存储与应用程序的主要存储-Firestore分开。结果,您只能在用户上设置很少的固定属性,而用户的配置文件只能由自己访问。要摆脱限制,您需要使用云功能来收听用户注册事件并在Firestore中创建单独的用户文档,并且一堆复杂性正在恢复。

数据建模

好吧,这是最具争议的话题。图架还是架构? SQL还是NOSQL?艰难的选择。

firebase是一个无模式的NOSQL数据库,这实际上意味着没有形式的数据建模。但是,这并不意味着在关系数据方面没有做出艰难的决定:

  • 您是否将它们建模为嵌套文档?
  • 或子收集?
  • 或同级顶级收藏?
  • 当您不可避免地需要加入数据时,您是否依赖于非正式化,还是在云功能中进行多个获取和数据组件?

每个选择都使某些操作变得更容易,而其他选择则更加困难,主要是由于缺乏本地人的功能。

回到我们的博客应用程序,在Firestore中,我们将使用模型用户,并使用两个单独的顶级收藏来发布,并具有以下形状(使用非规范化方法):

// just a mental model in your head since Firestore is schema-less

// "users" collection
type User {
  id: string;    // references uid on the Firebase auth side
  email: string; // duplicated from Firebase auth
  createdAt: Date;
  updatedAt: Date;
}

// "posts" collection
type Post {
  id: string;          // auto id
  authorId: string;    // post author's uid
  authorEmail: string  // author email denormalized
  title: string;
  published: boolean;
  createdAt: Date;
  updatedAt: Date;
}

在我们的替代堆栈中,将Prisma Orm与关系数据库一起使用时,您可以通过正式模式进行明确建模:

// schema.prisma

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  posts Post[]
  ...
}

model Post {
  id String @id() @default(cuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt()
  title String
  published Boolean @default(false)
  author User @relation(fields: [authorId], references: [id])
  authorId String
}

比较

使用无架构数据库的好处是显而易见的 - 入门很快。您可以从代码中编写您想要的任何数据,并且数据存储愉快地接受它们,而无需您预先定义模型。但是,无论您是否正式进行建模,都需要建模关系数据。当使用诸如Firestore之类的数据存储时,模式在您的脑海中,这带来了许多问题:

  1. 随着您的应用程序的发展,架构会漂移,最终您在商店中拥有许多相同数据的版本,并且您需要处理解决代码的差异。
  2. 由于没有正式的关系,因此没有完整性检查,而且数据集中很容易悬挂的指针。同样,您需要仔细避免或在代码中处理它们。
  3. 加入是不自然的,但不可避免,可悲的是,随着应用程序的增长,您会发现自己需要越来越多的加入。

回到SQL与NOSQL的关键选择。这是我的看法:

  • 明确的模式对于任何非平凡应用的成功至关重要。您要么让数据存储执行它,要么以某种方式自己管理。
  • 在可扩展性方面,关系数据库比几年前要强得多。他们可能永远不会像NOSQL数据库一样成为网络规模,但是您应该问自己是否有网络规模问题,您肯定想付出代价。
  • 无模式数据库提供的编码灵活性是一个谎言。它带来的效率比决心要多得多。

越来越多的人具有相同的信念,这就是为什么我们拥有关系数据库文艺复兴时期。

访问控制

Firebase开创了将访问控制集成到数据存储中,并将其直接暴露于Internet。它取得了成功。您使用安全规则来表达文档的CRUD权限。安全部件与Firebase身份验证有着紧密的集成,因此您可以在规则中引用当前用户的身份。
我们的博客应用程序的业务要求可以建模如下:

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{post} {
      // published posts are readable to all login users
      allow read: if request.auth != null && resource.data.published == true;

      // all posts are readable to their author
      allow read: if request.auth != null && request.auth.uid == resource.data.authorId;

            // login users can create posts for themselves
      allow create: if request.auth != null && request.auth.uid == request.resource.data.authorId;

      // login users can update posts but cannot change the author of a post
      allow update: if request.auth != null
        && request.auth.uid == resource.data.authorId
        && request.resource.data.authorId == resource.data.authorId;

            // login users can delete their own posts
      allow delete: if request.auth != null
        && request.auth.uid == resource.data.authorId;
    }
  }
}

我们的替代解决方案使用Zenstack处理访问控制部件。 Zenstack是Prisma上方建造的工具包,并将其架构扩展为支持建模权限,类似于Firestore安全规则。

// schema.zmodel

model Post {
  id String @id @default(cuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  title String
  published Boolean @default(false)
  author User @relation(fields: [authorId], references: [id])
  authorId String

  // published posts are readable to all login users
  @@allow('read', auth() != null && published)

  // all posts are readable to their author
  @@allow('read', auth() == author)

  // users can create posts for themselves
  @@allow('create', auth() == author)

  // author can update posts but can't change a post's author
  @@allow('update', auth() == author && auth() == future().author)
}

然后,您可以创建一个在运行时执行这些规则的增强的Prisma客户端。

比较

在表面上,这两种方法看起来相似,实际上,Zenstack的政策规则受到Firebase的极大启发。但是,有两个关键区别:

  1. firebase安全规则不是隐式过滤器,而Zenstack是。

    如果您在Firestore中查询整个帖子,例如:

    const posts = await getDocs(query(collection(db, 'posts')));
    

    您将被拒绝,因为firebase确定结果集违反了阅读规则。您有责任在客户端添加过滤器,以确保查询完全符合规则。您基本上重复了规则,并且可能在许多地方。

    虽然Zenstack的策略是自动读取过滤器,并且以下查询返回帖子应为当前用户阅读:

    const posts = await db.Post.findMany();
    
  2. Firestore安全规则属于Firestore,而Zenstack访问策略属于源代码。

    人们经常在管理员控制台中修改他们的Firestore安全规则。那些具有更好流程的人将规则保留在源代码中,并在CI期间将其部署到Firebase中。但是,即使这样,规则仍需要一些时间来传播并不会立即生效。他们大多觉得自己属于火箱服务方面,而不是您的应用程序。

    相反,Zenstack的策略规则是Prisma数据模式的组成部分,这是您的源代码的重要组成部分。您可以使用应用程序代码来控制它并与它们部署。

前端数据查询

使用Firebase的最大好处之一是,由于安全规则,您可以直接从客户端操纵数据库,从而保留了包装CRUD操作的后端服务的需求。查询和突变非常简单:

const posts = await getDocs(
  query(collection(db, 'posts'),
    or(
      where("published", "==", true),
      where("authorId", "==", user.uid)))
);

但是,在现代前端,您通常需要使用SWR或Tanstack查询等数据查询库来帮助您管理状态,缓存和无效。整合它们也不困难;这里是SWR的一个例子:

export function Posts(user: User) {
  const fsQuery = query(
    collection(db, 'posts'),
      or(
        where("published", "==", true),
        where("authorId", "==", user.uid)));

  const { data: posts } = useSWR("posts", async () => {
    const snapshot = await getDocs(fsQuery);
    const data = snapshot.docs.map((doc) => ({
      id: doc.id,
      ...doc.data(),
    }));
    return data;
  });

  return <ul>{posts?.map((post) => (<Post key={post.id} data={post} />))}</ul>;
}

在我们的替代解决方案中,我们可以使用Next.js + Zenstack获得更好的结果。首先,安装Zenstack提供的自动CRUD API作为Next.js API路线:

// src/pages/api/model/[...path].ts

import { withPolicy } from "@zenstackhq/runtime";
import { NextRequestHandler } from "@zenstackhq/server/next";
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerAuthSession } from "../../../server/auth";
import { prisma } from "../../../server/db";

async function getPrisma(req: NextApiRequest, res: NextApiResponse) {
  const session = await getServerAuthSession({ req, res });
  // create a wrapper of Prisma client that enforces access policy
  return withPolicy(prisma, { user: session?.user });
}

export default NextRequestHandler({ getPrisma });

然后启用数据模式中的SWR插件以生成我们的模型的客户端查询挂钩:

// schema.zmodel

plugin hooks {
  provider = '@zenstackhq/swr'
  output = "./src/lib/hooks"
}

然后使用钩子从客户端查询:

import { useFindManyPost } from "../lib/hooks";

export function Posts(user: User) {
  // you can use the "include" clause to join the "User" table really easy
  const { data: posts } = useFindManyPost({ include: { author: true } });

  // posts is automatically typed as `Array<Post & { author: User }>`
  return <ul>{posts?.map((post) => (<Post key={post.id} data={post} />))}</ul>;
}

比较

两种解决方案都允许直接从前端操纵数据,但是存在一些重要差异:

  • 需要过滤

    如上一节所述,由于Zenstack的策略规则像隐式过滤器一样,您不需要在查询代码中复制规则。

  • 类型安全

    firebase不含模式,因此无法生成或推断模型类型。您要么完全不打字,要么根据您对数据形状的理解手动声明类型。 Prisma的模式很强,因此Zenstack可以生成完全类型的安全模型类型和挂钩代码,即使对于动态包含的关系查询(作者字段)也可以生成。

  • 关系查询

    由于没有加入Firestore,因此我们不得不在帖子集合中对作者的电子邮件不利并复制以渲染它。但是,对于Prisma和关系数据库而言,加入就像基本的本能。你可以自然地做。

结论

Firebase是一项伟大的创新,今天仍然是一个很好的工具。但是,在过去十年中,许多事情发生了变化。当Firebase出生时,打字稿仍处于起步阶段,Orms是一件难得的事情。现在,我们重新配备了更好的工具和不同的思维方式。现在是尝试不同的东西的好时机。


希望您喜欢阅读并发现文章很有趣。我们构建了Zenstack工具包,认为强大的模式可以带来许多好处,以简化全堆栈应用程序的构建。如果您喜欢这个主意,请查看我们的GitHub页面以获取更多详细信息!

https://github.com/zenstackhq/zenstack

Star me on github