使用 Pongo 实现 CRUD


本博客的主题是事件驱动方法。我坚信这是一种让我们的应用程序更贴近业务的方法。通过这样做,我们可以在系统设计和代码中更好地反映业务流程。这很棒,因为它带来了多种好处:更容易演进、弹性以及更好地管理和跟踪工作流。

不过,有时你并不需要所有这些。

有时您只需要一个用于存放数据包的袋子,或者我们大多数人更喜欢称之为CRUD 。

CRUD 源自我们对数据执行的一组常见操作:创建、读取、更新和删除。这是一种适合内容管理系统的实现方式。其职责是存储和管理数据。当然,我们用基本验证、一致性规则和授权来包装它。我们运行简单的业务逻辑或使用仅在后端可用的信息来丰富数据。您只需输入一些数据并检索它。您输入的就是您得到的。

在 CRUD 中,您没有特定的行为。而且,从系统的角度来看,这些数据没有太多的业务背景。这就是实现是通用的原因。它只对将这些数据放入并检索的用户有意义。用户在阅读时对其进行解释,并根据它在系统之外做出决策。

CRUD 也可以成为概念验证的有效方法。例如,如果我们有一个产品创意,我们会启动一个基本应用程序,无需大量业务工作流,只需基本的数据提取和可视化,以确保这个想法具有潜力。

总体而言,进行 CRUD 没有什么可羞耻的。这是一种有效的实现方式,但用例有限。如果我们明智地选择它,那么一切都很好。
我们还需要记住,我们不应该把这个决定一成不变。

这就是为什么我喜欢将 CRUD 与CQRS搭配使用。为什么?它们不是互相矛盾的吗?在我的世界里不是这样!

CQRS 代表命令查询职责分离。这是一种结构模式。它告诉我们根据行为对业务应用程序进行切分,然后将其分为两个职责:

  • 命令处理——可以改变状态但不应返回业务数据的业务逻辑,
  • 查询处理 - 返回数据但不改变状态(“提出问题不应该改变答案”)。
如果满足这些规则,我们的内部实现可以是 CRUD,我们可以应用 CQRS 原则。这对于概念验证至关重要。我们从简单、通用的实现开始,一旦出现新的业务需求,我们就会对其进行改进。由于我们已经分离了业务功能,因此我们可以精确调整需要更改的地方。CRUD 不一定是难以维护的混合体!

不过,无论你选择哪种方式,我相信 Pongo 是一款不错的 CRUD 工具。我行我素还是随心所欲?不,我不会告诉你如何生活!

为什么是 Pongo?

  • 它是一款 Node.js 工具,因此它是一个轻量级且易于访问的环境,拥有庞大而充满活力的社区。你不应该在潜在的招聘方面遇到问题,
  • 它在 PostgreSQL 上运行,因此操作起来很简单,并且可以轻松地在云提供商、本地或NeonSupabaseVercel等服务中设置托管。
  • 类似 MongoDB 的 API 很容易学习并且为我们许多人所熟知。
  • 文档方法和非规范化数据有助于更轻松地设置数据。它非常适合 CRUD 模型,在这种模型中,您希望以与输入相同的形式存储和检索数据,
  • Pongo 将为您提供内置迁移,因此无需过多关心数据库模式。
所有这些为快速引导提供了良好的组合。
对于基础知识,你可以查看以前的文章:文档

所以请原谅,我不会重复,特别是因为您可能已经读过它们了。让我向您展示一些特别的东西:Pongo 命令处理。

CRUD 中更新记录的典型流程如下:

  1. 验证传入的请求。
  2. 读取当前状态。
  3. 在此基础上做必要的验证。
  4. 运行业务逻辑。
  5. 更新状态。
  6. 保存它。
同样,对于删除:
  1. 验证传入的请求。
  2. 您读到了当前状态。
  3. 进行必要的验证,检查是否可以删除它。
  4. 删除它。
对于创作来说,它甚至更简单;你只需:
  1. 验证传入的请求。
  2. 根据请求数据生成新状态。
  3. 保存它。
如果我们想偷偷摸摸地做点什么,我们可以把它包装成一个涵盖所有这些情况的单一流程。
  1. 验证传入的请求。
  2. 读取当前状态。
  3. 如果某个状态存在并且您想要更新它,请基于它进行必要的验证。
  4. 运行业务逻辑。返回新状态、更新状态或 null(如果要删除状态)。
  5. 取决于业务逻辑的结果:
  • 如果状态不存在且结果状态不为空,则创建
  • 如果状态存在且结果状态不同且不为空,则更新,
  • 如果状态存在且结果状态为空,则删除,
  • 否则不做任何事,并安全地处理幂等性。
猜猜怎么着?这正是 Pongo 能为您做的事情。

让我们使用我们最喜欢的购物车示例。其类型可能如下所示:

interface ProductItem {
  productId: string;
  quantity: number;
}

type PricedProductItem = ProductItem & {
  unitPrice: number;
};

type ShoppingCart = {
  _id: string;
  clientId: string;
  productItems: PricedProductItem[];
  productItemsCount: number;
  totalAmount: number;
  status: 'Opened' | 'Confirmed';
  openedAt: Date;
  confirmedAt?: Date | undefined;
  cancelledAt?: Date | undefined;
};

我们可以定义一组基本的操作:

  • 添加产品项目 - 可能未确认或购物车不存在,
  • 删除产品项目 - 当我们在未确认的购物车中已经有足够的产品时,这是可能的,
  • 确认购物车非空(我们可以确认两次,安全地处理幂等性),
  • 取消打开的购物车(我们可以取消两次,安全地处理幂等性)。

业务逻辑可能如下所示:
添加产品项目:

const addProductItem = (
  command: {
    clientId: string;
    shoppingCartId: string;
    productItem: PricedProductItem;
    now: Date;
 },
  state: ShoppingCart | null,
): ShoppingCart => {
  if (state && state.status === 'Confirmed')
    throw new Error('Shopping Cart already closed');

  const { shoppingCartId, clientId, productItem, now } = command;

  const shoppingCart: ShoppingCart = state ?? {
    _id: shoppingCartId,
    clientId,
    openedAt: now,
    status: 'Opened',
    productItems: [],
    totalAmount: 0,
    productItemsCount: 0,
  };

  const currentProductItem = shoppingCart.productItems.find(
    (pi) =>
          pi.productId === productItem.productId &&
          pi.unitPrice === productItem.unitPrice,
    );

  if (currentProductItem !== undefined) {
    currentProductItem.quantity += productItem.quantity;
  } else {
    shoppingCart.productItems.push(productItem);
  }

  shoppingCart.totalAmount += productItem.unitPrice * productItem.quantity;
  shoppingCart.productItemsCount += productItem.quantity;

  return shoppingCart;
};

删除产品项目:

const removeProductItem = (
  command: {
    productItem: PricedProductItem;
    now: Date;
 },
  state: ShoppingCart | null,
): ShoppingCart => {
  if (state === null || state.status !== 'Opened')
    throw new Error('Shopping Cart is not opened');

  const { productItem } = command;

  const currentProductItem = state.productItems.find(
    (pi) =>
          pi.productId === productItem.productId &&
          pi.unitPrice === productItem.unitPrice,
    );

  if (
    currentProductItem === undefined ||
    currentProductItem.quantity < productItem.quantity
  ) {
    throw new Error('Not enough products in shopping carts');
  }

  state.totalAmount -= productItem.unitPrice * productItem.quantity;
  state.productItemsCount -= productItem.quantity;

  return state;
};

确认

const confirm = (
  command: {
    now: Date;
 },
  state: ShoppingCart | null,
): ShoppingCart => {
  if (state === null) throw new Error('Shopping Cart is not opened');

  if (state.status === 'Confirmed') return state;

  if (state.productItems.length === 0)
    throw new Error('Shopping Cart is empty');

  const { now } = command;

  state.status = 'Confirmed';
  state.confirmedAt = now;

  return state;
};

取消:

const cancel = (state: ShoppingCart | null): ShoppingCart | null => {
  if (state != null && state.status === 'Confirmed')
    throw new Error('Cannot cancel confirmed Shopping Cart');
  return null;
};

然后我们可以进行基本的 Pongo 设置:

import { pongoClient } from "@event-driven-io/pongo";

const connectionString =
 
"postgresql://dbuser:secretpassword@database.server.com:3211/mydb";

const pongo = pongoClient(connectionString);
const pongoDb = pongo.db();

const shoppingCarts = pongoDb.collection<ShoppingCart>(
"shoppingCarts");

并将我们的代码插入到一些请求处理管道(例如 Web API)中:

type AddProductItemRequest = Request<
  { clientId: string; shoppingCartId: string },
  unknown,
  { productId: string; quantity: number }
>;

router.post(
  '/clients/:clientId/shopping-carts/current/product-items',
  on(async (request: AddProductItemRequest) => {
    const command = {
      clientId: request.params.clientId,
      productItem: {
        productId: request.body.productId,
        quantity: request.body.quantity),
        unitPrice: request.body.unitPrice,
      },
    };
    
    const result = await shoppingCarts.handle((state) =>
      addProductItem(command, state),
    );

    return Ok(result.document);
  })
);

对于所有其他端点,代码将看起来相应。这不是很好吗?

我认为这是启动新的 CRUD 系统或引导新的概念证明的简单快捷的方法。