如何利用“Outbox模式”来解决Node.js中的双写问题
想象一下,你正在构建一个电子商务平台,在这个平台上下订单时需要同时触发多个操作:必须通知仓库准备发货,电子邮件服务需要发送确认邮件,同时欺诈检测系统也需要审核这笔交易。 订单处理模块负责完成结账流程,将订单信息保存到数据库中,然后向消息队列发布一条 order.created 事件,这样下游的所有系统就可以各自独立地对这条事件作出响应。 这种设计非常常见且合理,但它存在一个可靠性问题——在生产环境中出现问题之前,这个问题往往不容易被察觉。 当顾客下订单并且支付成功后,应用程序需要执行两步操作:将订单信息保存到数据库中,以及向消息队列发布事件。这两项操作分别针对两个不同的系统进行,目前没有办法让
想象一下,你正在构建一个电子商务平台,在这个平台上下订单时需要同时触发多个操作:必须通知仓库准备发货,电子邮件服务需要发送确认邮件,同时欺诈检测系统也需要审核这笔交易。
订单处理模块负责完成结账流程,将订单信息保存到数据库中,然后向消息队列发布一条order.created事件,这样下游的所有系统就可以各自独立地对这条事件作出响应。
这种设计非常常见且合理,但它存在一个可靠性问题——在生产环境中出现问题之前,这个问题往往不容易被察觉。
当顾客下订单并且支付成功后,应用程序需要执行两步操作:将订单信息保存到数据库中,以及向消息队列发布事件。这两项操作分别针对两个不同的系统进行,目前没有办法让它们共享同一个原子性事务。如果过程中出现故障、网络出现问题,或者在两次操作之间进行了其他部署操作,就可能会导致其中一方完成了操作而另一方没有完成。结果就是,顾客的屏幕上会显示订单已确认的状态,但仓库却根本不知道这个订单的存在。
事务性出箱模式是解决这一问题的标准方案。在本文中,我们将使用Node.js从零开始实现这一模式,订单服务数据库使用PostgreSQL,消息队列使用SQS,而配送服务的数据库则使用DynamoDB。对于本地开发环境,我们会使用floci——这是一个免费的开源AWS模拟工具,可以通过一个Docker容器同时运行这三个服务。
我们将要讨论的内容
先决条件
要想顺利跟随本文的学习,你需要掌握以下内容:
Node.js以及async/await编程模型
数据库事务处理(BEGIN、COMMIT、ROLLBACK命令)
消息队列的基本概念
你不需要具备使用AWS、SQS或DynamoDB的经验,因为我们在本地环境中进行所有的测试和开发。
您需要在自己的机器上安装 Node.js 20 或更高版本以及 Docker。
两次写操作带来的问题
在介绍中提到的订单服务场景就是这种问题出现的典型例子,但在许多其他情况下也会出现同样的问题。
用户完成注册后,应用程序会将其账户信息插入数据库,随后会发送一条消息来触发欢迎邮件的发送及相关入职流程。同时还会上传文件,API会将文件的元数据写入数据库,并发布另一条消息以启动病毒扫描或生成缩略图的进程。当支付通知到达时,处理程序会将其记录在数据库中,然后通知下游系统支付已经完成。
在每一种情况下,应用程序都需要进行两次写操作才能确保事务成功完成:一次是向数据库写入数据,另一次则是将相关信息发送到队列或外部系统中。如果第二次写操作失败了,第一次写操作的结果也无法得到确认。
如果你想更深入地了解数据库事务到底能提供什么保障,以及在哪些情况下它们会失效,请阅读《超越理想路径工程:数据库》这篇文章。
这种简单的实现方式看起来似乎很直接:
await db.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
首先会进行数据库写操作,然后才是队列写操作。在正常情况下,这种做法是可行的。但问题在于,如果在两次写操作之间出现了故障,就会导致严重的后果。
如果程序崩溃、内存不足,或者在数据库写操作完成之后但在调用sqs.send之前被终止,那么订单记录虽然存在于数据库中,但相关的事件却永远不会被发送出去。仓库系统、电子邮件服务以及欺诈检测机制也就无法得知这笔订单的存在。从顾客的角度来看,这笔订单似乎已经完成了交易;但从所有下游系统的角度来看,这笔订单其实并不存在。
这种故障也有可能朝另一个方向发展。如果sqs.send操作成功了,但由于后续步骤中出现了约束违规或其他错误,导致数据库写操作被回滚,那么就会发布一条关于实际上并不存在的订单的事件。基于这条事件进行操作的系统可能会尝试完成一笔根本不存在的订单,或者向顾客收取从未实际发生的费用。
即使两次写操作最终都成功了,也仍然存在时间上的延迟问题。在数据库事务提交与sqs.send操作成功之间,如果某个系统在收到事件后立即查询数据库,可能会因为事务隔离机制或数据复制延迟的原因而找不到相关的订单记录。这两者都是独立的系统,没有共同的事务处理边界,因此再仔细安排执行顺序也无法完全消除这种延迟带来的问题。
这些并不是只有在极端情况下才会发生的异常情况。在部署过程中,系统重启是可能发生的事情;内存不足导致的程序终止往往毫无征兆;网络连接也可能在任何时候中断。任何一种这样的情况都可能打断这两次写操作的正常顺序,其结果就是系统会出现不一致的状态,但却不会产生任何错误日志或警报提示。
“请千万不要这样做。”
const client = await pool.connect();
await client.query('BEGIN');
await client.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
await client.query('COMMIT');
这种设计的初衷是让这两次写操作看起来像是一个完整的单元,但实际上数据库事务对SQS并没有控制权。事务只能用来回滚数据库中的操作。如果`sqs.send`成功执行了,但`COMMIT`失败,那么消息就已经被发送到了队列中,无法再被撤销。如果程序在`COMMIT`之后但在函数返回之前崩溃,那么事务虽然已经提交,消息也已经被发送,但调用者可能会重新尝试发送操作,从而导致重复插入相同的数据。
除了上述逻辑错误之外,这种模式还会导致数据库连接在整个SQS网络请求期间保持打开状态,并且会占用相应的行锁资源。通常情况下,SQS的性能相当快,但在高负载环境下、在发生重试操作时,或者当队列系统出现故障时,这次请求可能会花费数秒钟才能完成。其他试图读取或写入相同数据的请求都不得不等待。对于一个繁忙的应用程序来说,这种做法很可能会导致连接池资源被耗尽,进而影响系统中其他无关部分的正常运行。
“出箱模式”
这种模式的核心思想是:不再将消息发送到队列这一操作视为在数据库写操作之后的第二次写操作,而是将其纳入同一个数据库事务中。
应用程序不会直接调用`sqs.send`方法,而是在与业务记录相同的数据库事务中,将相关数据插入到一个名为“outbox”的表中。然后会有一个单独的中间处理进程读取这个“outbox”表,并将消息发送到SQS队列中。在接收端,消费者会从SQS队列中获取这些消息,并将其写入自己的数据存储系统中。在我们的例子中,这个接收系统是负责订单处理的DynamoDB数据库,而它与订单服务所使用的PostgreSQL数据库是完全分开的。
如果由于某种原因事务被回滚,那么“outbox”表中的相关记录也会随之被删除。因此队列中不会出现任何未被成功发送的消息。如果应用程序在事务提交之后但在中间处理进程开始运行之前崩溃,“outbox”表中的记录仍然会保留下来,其状态会被标记为“pending”,中间处理进程会在下一次循环中再次处理这些记录。
这种模式所依赖的唯一保障就是数据库本身所提供的特性:在同一事务范围内,所有操作都具有原子性。
中间处理进程负责确保消息最终能够被成功送达。它会定期运行,从“outbox”表中选取那些尚未被发送的消息,将它们发送到SQS队列中,并且只有当SQS确认已经收到这些消息后,才会将这些记录的状态标记为“已发送”。如果中间处理进程在运行过程中发生崩溃,它会在下一次循环中重新处理这些消息,因此有些消息可能会被重复发送多次。正因为如此,消费者必须具备幂等性:它必须能够正确处理接收同一条消息两次的情况,而不会因此产生重复的履行记录。在构建消费者组件时,我们会详细介绍如何实现这一功能。
这种职责的分离正是使该设计模式具有实际应用价值的原因。请求处理程序会完成一次原子级的数据库操作后立即返回结果;而中继组件则会以自己的节奏异步地与SQS进行网络通信,并使用自身的重试机制来处理请求,同时不会导致数据库连接保持打开状态或阻碍请求的处理流程。消费者组件与订单服务是完全解耦的,它拥有自己独立的数据存储系统。
下图展示了整个流程:
我们将要构建什么
现在让我们通过实际操作来了解整个系统的运作方式。我们会实现一个简单的订单放置API:当客户发起订单请求时,订单服务会将其保存到PostgreSQL数据库中,并在同一原子事务中在“outbox”表中插入相应的记录。随后,中继组件会定期读取这些待处理的订单信息,并将它们作为消息发送到SQS队列中;最后,履行服务会从队列中接收这些消息,并在DynamoDB数据库中创建相应的履行记录。
最终,你将会得到一个可以调用的HTTP接口,而且你可以验证:下订单这一操作确实会触发另一个完全独立的系统来创建履行记录,而这两个系统之间从未直接进行过交互。
完整的源代码可以在github.com/gkoos/article-outbox处找到。
项目准备
在开始运行任何代码之前,你需要先确保floci环境已经搭建完成,这样你才能在本地环境中使用PostgreSQL、SQS和DynamoDB。此外,你还需要安装Node.js 20或更高版本以及Docker。
首先,克隆该仓库并安装所需的依赖项:
git clone https://github.com/gkoos/article-outbox
cd article-outbox
npm install
接下来,启动floci服务。这个命令会下载最新的floci镜像,并创建一个Docker容器,该容器会暴露出一个本地AWS API接口(请确保Docker已经运行):
npm run floci:start
在Linux和macOS系统上,直接执行上述命令即可。但在使用Docker Desktop的Windows系统中,你需要修改package.json文件中的floci:start脚本,将Docker套接字的挂载路径从/var/run/docker.sock改为//var/run/docker.sock。
现在,floci容器已经开始在4566端口上监听请求,根据需要也可以随时启动PostgreSQL、SQS和DynamoDB实例。
现在,只需通过一条命令即可配置AWS资源:
npm run setup
此脚本会创建一个RDS PostgreSQL数据库实例、一个名为orders的SQS队列,以及一个名为fulfillments的DynamoDB表。它会等待RDS准备好使用,然后生成一个包含正确连接信息的.env文件。此时,环境变量PG_PORT、SQS_QUEUE_URL和DYNAMODB_TABLE_NAME都会指向这些本地的模拟服务。
最后,创建PostgreSQL表:
npm run migrate
此命令会在PostgreSQL中创建orders表和outbox表。这样,你就拥有了一个可以用于开发的完整本地环境。
数据库架构
这两个表的结构非常简单。orders表保存业务数据:每条订单都包含客户ID、金额(以分为单位)以及创建时间。outbox表则是整个系统的核心:应用程序会将需要发布的事件信息写入这个表中。
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT now(),
sent_at TIMESTAMPTZ
);
CREATE INDEX ON outbox (status, created_at) WHERE status = 'pending';
orders表不需要任何特殊设置。outbox表则用于存储事件的元数据:事件的类型(event_type)、其中包含的数据(payload,以JSON格式保存),以及事件是否已经发送(status)。
事件的状态最初为pending。当中继系统将事件发送到SQS时,其状态会变为sent>,同时会记录下发送时间。由于存在(status, created_at) WHERE status = 'pending'这个索引,中继系统可以快速找到下一批尚未发送的事件,而无需遍历整个表。
请求处理程序
整个流程就是从这里开始的。请求处理程序会接收HTTP POST请求,将订单信息插入数据库,并在outbox表中添加相应的记录,最后通过一次原子性事务完成所有操作。关键在于:只有当这两项操作都成功时,整个流程才会完成。
const client = await pool.connect();
try {
await client.query('BEGIN');
// 插入订单记录
const { rows } = await client.query(
'INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2) RETURNING *',
[customerId, amountCents]
);
const order = rows[0];
// 在同一事务中插入outbox记录
await client.query(
`INSERT INTO outbox (event_type, payload)
VALUES ($1, $2)`,
['order.created', JSON.stringify({ orderId: order.id, customer_id: order.customer_id, amount_cents: order.amount_cents, created_at: order.created_at })],
);
await client.query('COMMIT');
res.status(201).json(order);
} catch (err) {
await client.query('ROLLBACK');
next(err);
} finally {
client.release();
}
处理程序会从请求体中获取`customerId`和`amountCents`,然后使用`BEGIN`语句启动一个事务,并将订单信息插入数据库。随后,它还会在数据库的“待发送”表中添加一条记录,将这些订单数据作为该记录的内容。
所有操作都会以原子性方式完成。如果其中任何一步失败,所有操作都会被回滚,客户端也会收到错误提示。如果在提交操作和返回响应之间系统发生崩溃,客户端虽然不会收到201状态码,但订单信息以及“待发送”表中的记录仍然会安全地保存在数据库中,后续的Relay进程会自动处理这些数据。需要注意的是,处理程序本身并不直接调用SQS服务,这一任务是由Relay进程来完成的。
Relay进程
Relay进程是一个独立的进程,它每秒钟会检查一次“待发送”表,并将其中尚未被处理的记录发送到SQS服务器。该进程与HTTP服务器是独立运行的,两者之间也没有任何共享状态。
async function relay() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(`
SELECT *
FROM outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED -- 防止多个Relay进程同时处理同一批记录
`);
for (const row of rows) {
await sqsClient.send(new SendMessageCommand({
QueueUrl: QUEUE_URL,
MessageBody: JSON.stringify(row.payload),
MessageAttributes: {
EventType: { DataType: 'String', StringValue: row.event_type },
},
});
await client.query(
`UPDATE outbox SET status = 'sent', sent_at = now() WHERE id = $1`,
[row.id],
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
console.error('Relay error:', err.message);
} finally {
client.release();
}
}
setInterval(relay, 1000);
FOR UPDATE SKIP LOCKED这一语句是确保多个Relay进程能够安全运行的关键:当某个Relay进程开始处理一批记录时,它会将这些记录锁定起来;其他Relay进程如果尝试访问这些被锁定的记录,就会跳过它们,继续处理下一批记录。因此,永远不会有两个Relay进程同时发送相同的内容。
只有当`sqsClient.send`方法执行完成之后,Relay进程才会将对应的记录状态标记为“已发送”。如果在向SQS服务器发送数据后但尚未更新数据库记录之前,Relay进程发生崩溃,那么这些记录的状态仍然会保持为“待发送”,Relay进程会在下一次运行时重新尝试发送这些数据。
需要注意的是,`UPDATE`操作是在与`SELECT FOR UPDATE`相同的事务中完成的。因此,如果Relay进程在处理一批记录的过程中发生崩溃,那么这一批记录都会被回滚,所有记录都需要重新处理,包括那些已经成功发送到SQS服务器的记录。
“至少一次交付”的保证是针对整批记录而言的,而不是针对单条记录。关于这个问题,你可以参考《超越快乐路径工程:网络层面的设计考虑》这篇文章:当某个响应丢失时,调用方无法确定操作是否成功,因此会重新尝试发送请求,而接收方可能会收到同一条请求两次。这就是为什么在消费者端确保操作的幂等性如此重要。
消费者服务
消费者服务是一个完全独立的服务。它对订单服务所使用的 PostgreSQL 数据库一无所知。它的唯一输入是 SQS 队列,而唯一的输出则是 DynamoDB 中的 fulfillments 表。这种设计的关键在于:通过队列将这两项服务解耦开来,使得每一项服务都拥有自己独立的数据存储系统。
正如我们前面所提到的,由于 SQS 至少会交付一次消息(也就是说,同一条消息可能会被多次传递),因此消费者服务必须具备幂等性。在调用 PutItem 方法时,会使用一个 ConditionExpression 来确保:如果该订单已经有了对应的完成记录,那么这次写入操作就会被视为无效操作,从而保证重复传递的消息能够被正确处理。
async function consume() {
const { Messages } = await sqsClient.send(new ReceiveMessageCommand({
QueueUrl: QUEUE_URL,
WaitTimeSeconds: 20, // 长轮询:最多等待 20 秒以接收消息
MaxNumberOfMessages: 10,
MessageAttributeNames: ['All'],
}));
for (const msg of Messages ?? []) {
const event = JSON.parse(msg.Body);
try {
await dynamoClient.send(new PutItemCommand({
TableName: 'fulfillments',
Item: {
orderId: { S: event.orderId },
customerId: { S: event.customerId },
amountCents: { N: String(event.amountCents) },
status: { S: 'received' },
createdAt: { S: new Date().toISOString() },
},
ConditionExpression: 'attribute_not_exists(orderId)', // 幂等性检查
}));
} catch (err) {
if (err.name !== 'ConditionalCheckFailedException') throw err;
// 如果已经处理过这条消息,就可以继续执行后续操作。
}
// 只有在写入操作成功之后,才会删除该消息。
await sqsClient.send(new DeleteMessageCommand({
QueueUrl: QUEUE_URL,
ReceiptHandle: msg.ReceiptHandle,
}));
}
}
ConditionExpression: 'attribute_not_exists(orderId)' 这条条件语句会让 DynamoDB 在遇到已经存在具有相同 orderId 的记录时,拒绝执行写入操作。当这种情况发生时,DynamoDB 会抛出 ConditionalCheckFailedException 异常。消费者服务会捕获这种特定的异常并忽略它,然后从队列中删除该消息并继续执行后续任务。而对于其他类型的异常,系统会重新抛出错误,导致消息仍然留在队列中,等待下一次尝试被处理。
DeleteMessage 操作是在 DynamoDB 完成写入操作之后才执行的,而不是在之前。如果在写入操作和删除操作之间进程发生了崩溃,SQS 会再次传递该消息,此时条件检查机制会确保消息能够被正确处理。如果进程在写入操作之前就崩溃了,那么消息就会留在队列中,等待下一次交付时再被处理。
整体运行流程
当 floci 服务已经启动并且所有资源都已经配置完毕之后,打开三个终端窗口,分别运行以下进程:
node src/server.js # 这个进程负责提供订单相关的 API,运行在 3000 端口
node src/relay.js # 负责中转消息的进程
node src/consumer.js # 负责处理完成通知的进程
现在,请下订单:
curl -X POST localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":"c1","amountCents":4999}'
您应该会收到一个状态码为201的响应,其中包含新的订单记录:
{"id":"1768d35b-083d-45f1-adb5-4063d8d7fcab","customer_id":"c1","amount_cents":4999,"created_at":"2026-07-30T20:27:10.628Z"}
在一秒钟之内,中继系统会读取这些订单信息并将其发布到SQS队列中。消费者接收到这些消息后,会将相应的处理结果记录到DynamoDB数据库中。该代码库中还包含一个用于验证这一流程的脚本:
npm run check
您应该会看到一条包含您刚刚下订单的orderId的处理记录:
{
orderId: 'c335640e-bc4a-47e4-afed-484c95fbd6d3',
customer_id: 'c1',
amountCents: '4999',
status: 'received',
creado_at: '2026-07-30T19:02:54.929Z'
}
投入生产环境
由于本地开发环境使用floci来模拟AWS的功能,因此切换到真正的AWS环境时完全不需要修改任何代码。AWS SDK会从环境变量AWS_ENDPOINT_URL中获取端点信息;在生产环境中,只需不设置该变量即可,SDK会使用标准环境变量中的凭证和区域信息来与真实的AWS系统进行交互(这些变量包括AWS_REGION、AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY,或者在运行在EC2或ECS上时使用的IAM角色)。
由于使用了FOR UPDATE SKIP LOCKED机制,同时运行多个中继实例也是完全安全的。您可以水平扩展这些中继节点,每个节点都会处理不同的一组数据,从而避免消息重复。
在将系统投入生产环境之前,还需要考虑如何处理中继系统中可能出现的永久性故障。目前,中继系统只区分pending和sent两种状态,因此应该添加failed状态以及重试机制:当某条记录经过N次尝试仍然无法成功处理时,应将其标记为failed并停止重试。同时,还需要在orders SQS队列上配置一个死信队列,这样那些经过最大次数重试后仍无法被处理的消息就会被保存到这个队列中,以便后续进行检查。
对于那些对polling延迟要求较高的高吞吐量系统来说,变更数据捕获技术是一种常见的替代方案。像Debezium这样的工具可以直接从PostgreSQL的写前日志中读取数据变化,并将其实时发布到Kafka或SQS队列中,而不会产生任何polling延迟。在这种情况下,只需要替换原有的中继系统即可,其他组件都可以保持不变。
不过,与传统的polling方式相比,采用变更数据捕获技术需要投入更多的运营资源,因此对于大多数系统来说,polling方式仍然是更合适的起点。
结论
“双重写入问题很容易被忽视,因为天真的实现方式在大多数情况下都能正常工作。只有当两个独立的系统写操作之间存在时间间隔时,才会出现问题,而这种间隔只有在某些事情恰好在错误的时间发生时才会显现出来。等到你在生产环境中注意到这个问题时,数据已经出现了不一致的情况,而且也没有任何可靠的方法可以恢复数据。
“事务性出箱模式能够在数据库层面弥补这一缺陷。出箱记录与业务记录属于同一次原子性提交操作,因此它们的状态始终是一致的。中继组件会独立地处理与SQS的网络通信,并使用自身的重试逻辑来完成这些操作,而不会影响请求的处理流程。消费者则通过在写入操作时进行一次条件检查来确保消息至少被送达一次。
“这些组件单独来看都很简单,但结合起来使用时,它们就能提供一种可靠、解耦的事件传递机制,而且完全不需要依赖分布式事务。 相关文章
Netflix是如何扩展其实时服务架构的
Netflix详细说明了自己是如何重新设计其实时服务依赖关系映射系统“Service Topology”,以便使其能够支持大规模生产环境的。该系统通过三个阶段来区分数据的中转处理、内容丰富化处理以及持久化存储操作;它将产生的压力反馈给Kafka系统,而不是直接丢弃这些数据;同时,在进行大量内部数据传输时,它使用服务器发送的事件机制,而非gRPC协议。 作者:Eran Stiller
阅读全文
Pinterest是如何通过集中式的Terraform管道来大规模保护其AWS基础设施的?
Pinterest公布了其自主研发的Terraform执行引擎——资源供应管道系统(RPP)。该系统能够确保最小权限访问机制,并需要经过双重审核流程。对于Pinterest的AWS基础设施而言,这一系统至关重要,因为它为GitHub Actions工作流程提供了严格的防护机制。 作者:Claudio Masolo
阅读全文
Canva分享了其基于S3架构的技术方案,该方案能够用于撤销数亿次会话中的会话信息。
Canva对会话撤销机制的基础设施进行了重新设计,使其能够在支持多达1亿个活跃会话的同时减少数据库查询次数。该架构使用Amazon S3来存储持久性的撤销记录,并将紧凑型内存索引分发到应用程序网关中。Canva表示,这种设计提高了部署速度,降低了对数据库基础设施的需求,同时还将撤销缓存所需的内存占用量减少了87.5%。 作者:Leela Kumili
阅读全文
深入探讨行为模式:访问者设计模式及其在复杂对象结构中的应用
几乎每一个正在发展的软件系统中都会出现这样一个问题,而大多数开发人员直到问题造成了实际的损害之后才意识到自己遇到了它。 你有一组对象:它们具有不同的类型、形状和数据结构。而在某个时刻,会有人要求你对这些对象执行某种操作,比如将它们导出为PDF格式、向它们发送通知、生成报告或计算相关费用。 你的第一反应可能是编写一个函数,根据对象的类型来决定执行哪些操作,例如使用if-else语句或switch结构。这样的代码逻辑是:如果这个对象是NewUser类型,就执行这个操作;如果是JointAccountUser类型,就执行另一个操作。这种方法确实有效,你将其实现后,大家都很满意。 然而,后来又出现了新
阅读全文