技术探究 | 开篇 | 深度探讨:Apache Pulsar 在事件驱动型业务中的应用

原创
03/19 08:00
阅读数 56

技术探究 | 开篇 | 深度探讨:Apache Pulsar 在事件驱动型业务中的应用

本篇为《深度探讨:Apache Pulsar 在事件驱动型业务中的应用》系列文章的开篇,后续章节正在加紧提炼中。欢迎扫描下方二维码加入 Apache Pulsar 社区,可通过填写社区表单,优先获取后续完整小册子的推送。

作者:Deepak Chauhan, Max N  译者:Teng Fu、方阗

注:为了行为顺畅,有部分调整。翻译不易,有任何问题,都可以小窗公众号私信,或者直接联系 Pulsar Bot  :)

事件驱动架构


事件驱动架构(EDA)是一种软件架构模式,它通过使用事件来实现系统内各个组件或服务之间的通信和协调。

一个事件是一个不可变的消息,表示某件事情在特定时间发生了。事件是一个既定事实,这意味着它发生时的事实和状态都是不可变的。

EDA是消息驱动架构(MDA)的一种风格,它遵循发布-订阅模型。MDA在面向服务架构(SOA)中被广泛使用,并被称为企业应用集成(EAI)。EAI利用企业集成模式(EIP)通过企业服务总线(ESB)进行通信。

ESB为不同服务和应用程序之间的消息路由提供了一个中央枢纽。根据定义的路由规则,消息可以进行转换、过滤和定向到其预定目的地。

EDA和MDA有两个主要区别:

  1. EDA是严格的 单向通信。MDA使用单向和双向通信。在SOA中,请求-响应是用于双向异步通信的集成模式之一。
  2. EDA仅向特定主题发出事件。该主题由发出事件的同一组件拥有。多个组件可以订阅该主题以处理事件。而MDA除了通过ESB/队列 进行异步处理外,还可以发送命令和任意消息。
事件驱动架构描述了从服务A到服务B和C的通信

在上图中,服务A直接或间接地发布事件消息到一个主题。该主题有两个订阅S1和S2。这些订阅上有服务B和服务C的多个消费者。我们将在后续章节中讨论整个概念。

发布

在EDA中,可以使用以下方法发布事件。

应用层

在这种方法中,应用程序本身在事务或主要功能完成后将变更作为事件的一部分推送到主题。这是最常见和最有利的方法,适用于任何类型的事件驱动用例。事件在应用程序层准备,这样可以在事件过程中丰富所需的处理信息,以供订阅服务使用。

这里存在一种反模式,即通过调用其他应用程序的 API 来丰富事件信息。这样的应用程序很难扩展,因为您无法有效地估计负载并且会产生巨大的基础设施成本。如果必须这么做,就得在订阅端而非发布端进行。

事件发布是任何领域功能的横切关注点和最后一步,应处于数据库事务边界之外。有时,应用程序面临由以下事件导致的数据丢失的困境:

  1. 系统无法连接到 Broker,因此无法发布事件
  2. 系统在即将发布事件时崩溃了

通过在应用程序中实现弹性(resiliency)可以有效解决这些难题。弹性系统确保它能从失败的步骤中恢复过来。只有当应用程序将每个面向数据的步骤持久化到其他地方时,才能实现系统的弹性。实现这样的解决方案需要出色的工程能力。所幸,市场上有一些高可扩展性的平台可用于解决这个难题,如 Uber Cadence、Netflix Conductor 以及 Temporal。

变更数据捕获

CDC 获取数据源中诸如“INSERT”、“UPDATE”以及“DELETE”这样的操作,并将它们复制到目标系统中,这个过程通常是实时或准实时的。这种方式通常用于数据迁移、流式处理和实时分析,但因其具备严格的顺序行为,它也被广泛使用在 EDA 的场景中。CDC 使用推送(push)或拉取(pull)的方式将事件以流式传入主题,而不是直接从应用层推送事件。此后该主题将被其他组件订阅以便消费、处理事件。

A. 查询式的CDC

这是一种众所周知的数据迁移方法,同样也适用于很多 EDA 场景。在这种方法中,特定的表(tables)/集合(collections)会引入一个额外的时间戳列,用来表征记录的变更时间。ETL 执行增量式时间绑定查询以在特定间隔(通常按最小一秒,以实现准实时同步)获取数据。然而这种方法也存在一些限制。

由于这是一种基于间隔的拉取方法,您可能无法消费所有的变更。请看下面的图片。

在上面的例子中,有一条记录 R,在不同的时间戳 T1 到 T8 上,其属性名称已经改变了 8 次。Cron 在 X1、X2 和 X3 时间执行。Cron 在 X1 时间捕获了 T1 时间状态(名称为 a),Cron 在 X2 时间捕获了 T4 时间状态(名称为 d),并错过了时间戳 T2(名称为 b)和 T3(名称为 c)的两次更改。

另一个问题是,由于数据库查询只能获取未删除的数据,所以您无法硬删除记录。应用程序应该进行软删除以捕获已删除的更改。

如何实施

  1. 在表上定义时间戳列,该列必须捕获记录更改的时间。这可以是插入、更新和软删除的时间。该列应按升序进行索引。
  2. 定义一个持久变量(例如:last_record_timestamp),用于维护最后一条记录的时间戳。在第一次运行时,将其设置为0。
  3. 使用您喜欢的ETL工具,并定义数据提取查询。请记住,查询应该提取指定数量的条目,否则可能会因为数据量过大而导致超时或延迟,并可能进入无限失败循环。
  4. 将结果集中接收到的每条记录进行转换,并在每次推送到 Broker时更新last_record_timestamp。
  5. 始终定义两个间隔,一个间隔用于在获取空行集之前持续执行查询。如果由于任何原因作业长时间关闭,此间隔将有助于更快地进行数据清理。第二个间隔定义了固定间隔的频率。
  6. 始终从副本而不是应用程序写入的数据库中读取。

基于ETL查询的CDC可能无法满足要求,如果应用程序在单个事务中修改多个表中的多个元组,并且您需要在单个事件中获取所有更改的元组。让我们以电子商务应用程序为例,其中单个事务正在进行以下更改:

  1. 订单表:对订单元组进行修改,订单ID为XYZ
  2. 行条目表:添加了一个带有订单号XYZ的额外行项目

为了解决这个问题,两个表都必须有记录更改时间戳列。您可以编写一个多步骤的ETL,可能涉及以下步骤:

  1. 使用拉取查询捕获更改的订单
  2. 获取结果集中每个订单的更改行项目
  3. 合并订单和行项目,并推送到主题

这可能不是一个好的方法,如果给定的数据库存在扩展挑战。在这种情况下,您应该考虑使用应用层发布或基于日志的CDC等替代方案来满足正确的期望。

B. 基于变更日志的CDC

这是一种更现代的数据迁移方法,经常用于EDA。像Debezium这样的OSS工具和特定于云的CDC技术允许您通过数据库日志同步来自各种数据库的数据。

基于日志的CDC工具实际上是在数据库日志上创建一个流。对于MySQL来说,它是binLog,对于Mongo来说,它是opLog,对于Cassandra来说,它是commit log。它的实现因数据库而异,因此需要根据源数据库的限制来选择EDA。

关系型数据库(例如:MySQL / Postgres)将完整记录(包括之前和之后的内容)存储在日志中,并允许CDC工具作为事件的一部分捕获完整的记录信息。

RDBMS也是在一个事务中涉及多个表的多个元组更改的用例中的一个不错的选择。它为整个事务维护一个单一的日志条目,并将所有更改的元组存储在日志中。这样CDC工具可以在一个单一事件中捕获完整的信息。

Mongo不会在opLog中存储完整的记录信息,但它提供了一个变更流(change stream),您可以启用完整记录查找以接收事件的完整记录。

Cassandra 提交日志不记录更改行中每个列的值,它只记录已被修改的列的值(除了分区键列,它们始终被记录,因为在Cassandra DML命令中需要)。许多其他数据库仅在日志中保留记录的部分信息,可能就不适用于用 CDC 来实现EDA 场景了。

处理这种部分记录捕获限制有以下几种方法。

  1. 您可以选择应用层事件发布或基于拉取的流水线来克服这个限制。但是您应该考虑两种方法中列出的所有因素。
  2. 创建一个桥接服务,可以消费CDC事件,进一步丰富它们,并将完整内容事件发布到目标主题。可以使用事件溯源或ID查找来进行此丰富。

事件溯源:与传统数据库中持久化对象或实体的当前状态不同,事件溯源捕获并存储随时间发生的每个变化或事件。这提供了应用程序状态如何演变的历史记录。

桥梁使用事件存储数据库,该数据库存储所有带有ID和事件时间戳的事件。它作为一个订阅者,从CDC工具准备事件存储。每当收到一个事件时

  1. 步骤1:首先存储在事件存储中。
  2. 步骤2:从事件存储中获取接收到的ID的所有事件。
  3. 步骤3:这些事件按时间戳排序并合并,以准备完整的内容,并进一步推送到另一个主题。

Kafka提供了一个内置的事件存储KSQL。如果您正在使用Kafka,那么您可以探索KSQL作为事件存储数据库,而不是实施桥接方法。

ID查找:一旦桥接器接收到事件,它会在源数据库中通过ID查找整个记录。如果源数据库无法在高频率下读取数据,这可能不是一种高效的方法。

订阅

订阅是在一个主题上创建的一组消费者。您可以创建多个订阅来处理多个用例。例如,您可以在oms-order主题上创建通知和订阅。通知订阅用于向用户通知订单变更,履行订阅用于履行订单。

一个订阅必须与一个服务关联,并根据规模可以容纳多个消费者。消息在这些订阅之间进行复制,并在订阅内进行分发。例如,与主题相关联的所有订阅都会处理相同的消息E1。然而,在每个订阅内,只允许一个消费者处理该消息。

在EDA中,事件的顺序非常重要。对于订阅者来说,事件应该按照发布的顺序接收。

您可以在应用程序中使用以下类型的订阅。

重复订阅

重复订阅可以使多个消费者能够同时从主题中拉取相同数据进行处理。支持重复订阅的服务包括Apache Pulsar、RabbitMQ、Google Pubsub和AWS SQS等。虽然这种类型的订阅在某种程度上类似于FIFO队列,但不同之处在于当多个消费者被分配到一个队列时,队列中的消息会分布在消费者之间,可能会产生竞争条件。

为了更好地说明这个竞态条件,让我们考虑一个来自电子商务应用程序的例子。想象一下,有一个由订单管理系统(OMS)服务拥有的“订单”主题。该服务会将订单事件发布到这个“订单”主题,无论是直接从应用程序层发布还是通过变更数据捕获(CDC)间接发布。在下面的图表中,订购键对应于事件的订单ID。

服务A和服务B各自有两个消费者,分别是C1和C2,期望每个消费者按顺序处理事件。例如,服务A的消费者C1按顺序处理事件E1、E3、E5和E8。由于服务A使用了共享订阅S1,具有排序键O1和O2的事件被分发给消费者C1和C2。

在现实世界的场景中,比如电子商务系统,具有相同排序键的顺序事件通常在它们之间有合理的时间间隔。因此,即使这些事件在消费者之间分布,多个消费者并行处理相同排序键事件的可能性相对较低。然而,必须承认这并不是绝对的确定性,仍然存在各种因素导致并行处理或事件被以不正确的顺序处理的情况。

  1. 系统本身以高频率为域实体(相同的排序键)生成事件。
  2. 服务长时间停机可能导致队列中事件的显著积压。当服务重新激活时,可能会导致所有消费者之间的流量激增,潜在地引发并行处理。
  3. 在上面的例子中,E6事件的时间T6比E3事件的时间T3要晚。如果由于任何原因消费者的速度足够慢,那么它们可能会在分配给不同的消费者之前处理E6而不是E3。

此外,事件分类起着至关重要的作用。在某些情况下,即使来自完全不同模块的多个服务也可能为同一领域实体生成事件。必须使用不同的主题对这些事件进行分类和分离。例如,生产者服务X和Y应该使用不同的主题,如X-topic和Y-topic,以确保清晰区分。

在“构建订阅者的最佳实践”部分中详细描述了解决重复性、乱序事件和并行处理的解决方案。

有序订阅

有序订阅采用顺序方法,并通过基于排序键对消费者进行分区来实现可扩展性。当发布消息时,事件会附带一个排序键,使其可以分配到特定的分区。总之,具有相同排序键的事件由订阅中的一个消费者按顺序处理,确保顺序保留并防止竞争条件的发生。

Apache Pulsar、Kafka、Google pubsub、AWS SQS 是支持有序订阅的几个例子。

不同的MQ供应商使用不同的架构来支持有序订阅。

Kafka为客户端提供了灵活性,可以提交最新的偏移量以推进处理。相比之下,Google Pubsub、Apache Pulsar和AWS SQS采用ACK/NACK方法。

有序订阅通常采用分区方法,其中具有相同哈希排序键的事件被分组到同一个分区中。

Kafka通过允许订阅中的一个消费者处理分区中的事件来强制排序。在分区内向前移动的责任由消费者承担,消费者必须提交最新的偏移量以在流中前进。这种设计将完全的拉取责任转移到了客户端层,要求客户端能够有效处理故障以保持流的进展。在客户端无法从一个有问题的事件中恢复时,可能会导致该分区内的“停止世界”情况,影响不仅是具有问题排序键的事件的处理,还包括同一分区内的所有其他排序键。

相比之下,Google Pubsub、SQS和Pulsar提供了使用累积ACK的消费者能够向前移动的能力。这些平台不直接将消费者与分区关联,而是在代理端处理消费者负载均衡。这些供应商确保一旦消费者接收到特定排序键(比如说‘X’)的事件,它将在其生命周期内继续接收具有相同排序键的事件。它们采用各种负载均衡算法,如粘性会话、简单哈希或一致性哈希,以在消费者之间分配工作负载。如果客户端无法处理特定事件,它不会导致像Kafka中那样的“停止世界”的情况。相反,它只会影响与该特定排序键相关的事件的处理。其他排序键事件可以继续按顺序处理,而不会中断。

ACK方法存在一些缺点,在实施有序订阅时应予以考虑。

  1. 注意使用Apache Pulsar,在故障转移或重试的情况下可能会导致消息乱序。
  2. 一些SDK或框架可能不适合有序订阅,并可能导致在同一批次中之前失败的具有相同排序键事件的后续事件被意外处理。

构建订阅者的最佳实践

隔离(Isolation)

在同一类别中防止绑定相同排序键的两个事件的同时处理非常重要。如果有多个消费者处于活动状态,共享订阅可以引入并行处理,强调了保持正确排序的重要性。为了确保共享订阅中的准确排序,使用独占锁是必不可少的。需要强调的是,如“有序订阅”部分所解释的那样,有序订阅不需要锁来实现正确排序。

幂等性(Idempotency)

幂等性是计算机科学和其他领域常用的概念,用于描述操作或函数在多次应用时产生与仅应用一次相同的结果。换句话说,幂等操作可以重复或重试,而在初始应用后不会导致不同的结果或副作用。

系统内发生重复事件可能是由于多种因素造成的,比如重复提交或重试。虽然在许多情况下有方法来减轻这个问题,但是实现100%的预防率并不总是可行的。系统必须具备幂等能力,以确保如果它多次处理相同的事件,它能够有效地识别并在后续处理中忽略重复的事件实例。

在某些情况下,消费者可能在处理过程中部分处理了一个事件,但在处理过程中失败了。在这种情况下,如果尝试重试,关键是确保系统不会重复已经处理过的步骤。消费者应该能够跳过已完成的步骤,并继续进行下一个未处理的步骤,以成功完成工作流程。

停车场故障(Parking out of order)

尽管在生产者端事件出现顺序错误的可能性极小,但这种情况偶尔会发生,原因可能是时钟偏差或网络流量拥塞等因素。例如,可能会出现一个状态为“ORDER_CANCELLED”的事件的时间戳早于一个“ORDER_CREATED”事件,并且以与预期不同的顺序推送到主题中。或者在使用共享订阅时,即使事件在正确的时间生成,系统仍可能遇到事件顺序错误的情况。

系统必须具备将这些事件暂存以便在正确的顺序发生时进行处理的能力。可以通过以下方式实现。

重试并进行退避延迟

多个 Broker提供了重试功能,并可以配置重试频率。确保两次连续的重试之间的时间间隔不要太短是非常重要的。连续的重试应该按指数方式延迟,以防止消费者过载。当重试次数用尽时,事件将被排队到一个死信队列(DLQ)进行手动处理。然而,对于高流量应用程序来说,这种方法可能不是理想的,因为DLQ中可能会有大量的事件。

在事件顺序错乱的情况下,您可以向Broker发送负面的ACK。当重新尝试处理顺序错乱的事件时,您可能已经处理了正确的事件,确保了正确的顺序。这种方法特别适用于共享订阅。

停在DB中并查找

这种方法与事件溯源非常相似,其中一个只追加的表用于将事件存储在订阅服务的数据库中。当接收到一个乱序事件时,不会立即处理,而是保存在事件存储中。当随后接收到正确的事件时,两个事件按顺序处理。这种方法被认为是一种更安全的方法,因为它确保只有在按正确顺序接收事件时才会执行操作。它非常适用于共享和有序的订阅。

拉而不是推

拉取方法可以让客户更好地控制其处理事件的能力,而推送方法在处理高负载时可能会给消费者带来压力。

慢消费者

确保代理商不会错误地认为消费者已经停止工作或事件无法处理,建立正确的消费者心跳间隔和适当的确认时间非常重要。在这种情况下,代理商可能会将相同的批次重新发送给另一个或同一个消费者。

处理未恢复的异常

每当发生未恢复的异常时,建议将事件暂停。这些异常可能包括接收到API的503错误或数据库连接池故障等情况。如果此类异常的频率增加,重要的是要有能力暂停消费者,直到解决底层问题为止。

采用 Apache Pulsar

在事件流领域中,Apache Pulsar 已经成为一个强大的框架,简化了处理实时数据的复杂性。在本文中,我们将踏上 Apache Pulsar 框架的入门之旅,探索其基础知识,并提供实际的代码示例,以启动您的事件流处理工作。

在深入研究技术细节之前,让我们先了解一下 Apache Pulsar 的本质。简单来说,它是一个开源的分布式消息传递和事件流平台,旨在实现高性能、可扩展性和可靠性。它专为实时处理大量数据而构建,因此成为需要响应性和可靠性的应用程序的首选。

环境设置

开始我们的探索之前,让我们先让 Apache Pulsar 运行起来。第一步是在本地安装 Pulsar,或者使用云托管服务。为了简单起见,我们将介绍本地安装。

代码示例1:使用Homebrew(Mac)安装Apache Pulsar

# Install Homebrew (if not installed)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Install Apache Pulsar
brew install pulsar

主题和分区

Apache Pulsar 的核心是主题(topic),类似于其他消息系统中的队列(queue)或通道(channel)。主题是消息数据生产者与消息数据消费者之间的通信媒介。每个主题都可以进行分区以便于更高效地分发数据。

代码示例2:创建一个主题

# Create a Pulsar topic named 'my_topic'
pulsar-admin topics create persistent://public/default/my_topic

生产者和消费者

生产者负责将消息发布到 Pulsar 主题中,而消费者则订阅主题以接收和处理这些消息。生产者和消费者的解耦使得系统具备灵活性和可扩展性。

代码示例3:生成消息

// Java code example using Pulsar Producer
Producer<String> producer = PulsarClient.builder()
    .serviceUrl("pulsar://localhost:6650")
    .build()
    .newProducer(Schema.STRING)
    .topic("my_topic")
    .create();

// Send a message to the topic
producer.send("Hello, Apache Pulsar!");

// Close the producer
producer.close();

代码示例4:消费消息

// Java code example using Pulsar Consumer
Consumer<String> consumer = PulsarClient.builder()
    .serviceUrl("pulsar://localhost:6650")
    .build()
    .newConsumer(Schema.STRING)
    .subscriptionName("my_subscription")
    .subscribe("my_topic");

// Receive and process messages
Message<String> message = consumer.receive();
System.out.println("Received message: " + message.getValue());

// Acknowledge the message
consumer.acknowledge(message);

// Close the consumer
consumer.close();

使用Pulsar Function进行水平扩展

Apache Pulsar的一大亮点是与Pulsar Functions的集成,它允许您在消息通过系统时对其进行实时处理。Pulsar Functions支持多种语言,包括Java、Python和Go,这使其适用于不同的用例。

代码示例5:创建一个Pulsar函数

// Java code example for a Pulsar Function
public class MyFunction implements PulsarFunction<StringVoid{
    @Override
    public Void process(Record<String> record, Context context) {
        // Process the incoming message
        String message = record.getValue();
        System.out.println("Processing message: " + message);

        // Perform your custom logic here

        return null;
    }
}

容错性和耐久性

Apache Pulsar 设计时考虑了容错性和耐用性。它确保即使在硬件故障或网络异常的情况下,系统仍能稳定运作。这是通过自动数据复制和持久化存储等功能实现的。

监控和管理

Apache Pulsar 提供了监控和管理事件流基础设施的工具。Pulsar Admin 工具允许您查看指标,创建和管理主题,并监控系统的整体健康状况。

代码示例6:使用Pulsar管理员

# Check the health of the Pulsar cluster
pulsar-admin clusters health

# Get the list of topics in a namespace
pulsar-admin topics list public/default

结论

本文作为开篇,介绍了事件驱动架构的相关概念,引入了如何使用 Apache Pulsar 来应用事件驱动的业务场景。

Apache Pulsar 在事件驱动场景中的尤其独特的优势,主要体现在以下几个方面:

  1. 高可扩展性和易用性:Apache Pulsar 原生集成 Pulsar Functions(类似 FaaS 云函数),使 Pulsar 可以做到一种简单且可扩展的事件行为处理,使得用户可以在无须额外组件的情况下进行实时处理任务。
  2. 多种消息特性:Apache Pulsar 支持多种消息特性,包括顺序性保障,多消息语义支持,延迟队列、死信队列、重试队列,支持多种消费模式和订阅方式,支持事务消息,消息路由,消息过滤和很容易做追踪等。
  3. 低成本和低运维要求: Apache Pulsar 支持多租户模型,允许多个组织或者部门将不同业务在一个集群上并行运行,互不干扰。同时受益于 Apache Pulsar 存储和计算分离的架构,可以将多个集群合并为一个集群,这将有效的减少运维复杂性以及避免资源的浪费。
  4. 高可用和持久性:Apache Pulsar 模型中内建了数据复制和自我修复功能,一旦集群中的部分节点出现故障,剩余的节点将会接管故障节点的数据,从而保证系统的高可用和持久性。
  5. 高吞吐率和低延迟性: Apache Pulsar 使用了分布式架构并且数据存储和服务层是分离的,这使得用户可以独立的扩展需要读写的存储量和并发数据的读写能力,使其可以提供极高的吞吐率和低延迟性。

总体来说,Apache Pulsar 能够很好地满足事件驱动型业务场景的需求,在各类消息传递等各种事件驱动的场景中,都能提供出色的效果。


参考资料
[1]

Event Driven Architecture — Part 1: https://medium.com/@foreverdeepak/event-driven-architecture-part-1-eeed34f4bd72

[2]

Event Driven Architecture — Part 2: https://medium.com/@foreverdeepak/event-driven-architecture-part-2-94d3ba05c2eb

[3]

Demystifying Event Streaming: A Beginner’s Guide to Apache Pulsar Framework: https://mysteryweevil.medium.com/demystifying-event-streaming-a-beginners-guide-to-apache-pulsar-framework-c2c166b4a6da



注:本篇为《深度探讨:Apache Pulsar 在事件驱动型业务中的应用》系列文章的开篇,后续章节正在加紧提炼中。欢迎扫描下方二维码加入 Apache Pulsar 社区,可通过填写社区表单,优先获取后续完整小册子的推送。


联系我们

Apache Pulsar 是 Apache 软件基金会顶级项目,是下一代云原生分布式消息流平台,集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性。GitHub 地址:http://github.com/apache/pulsar/

Pulsar 中文社区 Logo(部分)

诚挚邀请您加入 Apache Pulsar 社区,与全球开发者一起学习、分享和成长,共同塑造云原生消息流平台的未来,一起打造更加开放和高效的开源技术生态!

Pulsar 进群说明

推荐阅读

  • 干货文章
技术探究 | Flipkart 带来 Apache Pulsar 集群调优指南

Apache Pulsar 为滴滴大数据运维带来了哪些收益?

本文分享自微信公众号 - ApachePulsar(ApachePulsar)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部