如何从单体应用中提取数据密集型服务

当将单体应用分解成更小的服务时,最难的部分实际上是分解驻留在单体应用数据库中的数据。为了提取数据密集型服务,遵循一系列步骤非常有用,这些步骤始终保留数据的单个写入副本。这些步骤从在现有单体应用中进行逻辑分离开始:将服务行为拆分为单独的模块,然后将数据拆分为单独的表。这些元素可以分别移动到新的自治服务中。

2018 年 8 月 30 日



行业正在发生重大转变,从单体应用转向更小的服务。组织投资这种转变的一个关键原因是,围绕业务能力构建的更小的服务可以提高开发人员的生产力。能够拥有这些更小的服务/的团队可以成为“自己命运的主人”,这意味着他们可以独立于系统中的其他服务来发展他们的服务/。

当将单体应用分解成更小的服务时,最难的部分实际上是分解驻留在单体应用数据库中的数据。将单体应用中的逻辑分解成更小的部分相对容易,同时仍然连接到同一个数据库。在这种情况下,数据库本质上是一个集成数据库,它给出了一个可以独立发展但实际上在数据库级别是一个紧密耦合系统的分布式系统的表象。为了使服务真正独立,从而使团队成为“自己命运的主人”,他们还需要拥有独立的数据库——服务的模式和相应的数据。

在本文中,我将讨论一种模式,它是一系列步骤,用于从单体应用中提取数据密集型服务,同时对服务消费者造成最小的干扰。

服务提取指导原则

在我们深入了解实际模式之前,我想谈谈两个指导原则,它们对于服务提取至关重要。这些有助于从拥有单体应用的世界平稳安全地过渡到拥有多个更小服务的 world。

在整个过渡过程中,数据只有一个写入副本

在过渡期间,我们将为正在提取的服务的数据保留一个写入副本。拥有多个客户端可以写入的多个数据副本会引入写入冲突的可能性。写入冲突发生在多个客户端同时写入同一数据片段时。处理写入冲突的逻辑很复杂——它可能意味着选择一种方案,例如“最后写入者获胜”,这可能会从客户端的角度产生不希望的结果。它也可能意味着通知写入失败的客户端,并让他们采取纠正措施。编写这种逻辑充满了复杂性,最好避免。

本文中描述的服务提取模式将确保在任何给定时间点,正在提取的服务都存在一个写入副本,以避免管理写入冲突带来的复杂性。

遵守“架构演进的原子步骤”原则

我的同事 Zhamak Dehghani 创造了“架构演进的原子步骤”一词,它是在架构迁移过程中原子地(全部或无)采取的一系列步骤。在这一系列步骤结束时,架构将产生承诺的回报。如果这些步骤没有完全执行(中途停止),那么架构将处于比你开始时更糟糕的状态。例如,如果你决定提取一个服务,而你只提取了逻辑而不是数据,那么你仍然在数据库层耦合,这会引入开发和运行时耦合。这会引入相当大的复杂性,并且可以说使开发和调试问题比单个单体应用更难。

在以下服务提取模式中,建议你完成为给定服务列出的所有步骤。服务提取模式中最大的障碍之一实际上不是技术性的,而是获得组织一致性,让单体应用的所有现有客户端迁移到新的服务。这将在步骤 5 中进一步解释。

服务提取步骤

现在,让我们深入了解实际的服务提取模式。为了便于理解步骤,我们将举一个例子来了解服务提取是如何工作的。

假设我们有一个单体目录系统,它为我们的电子商务平台提供产品信息。随着时间的推移,目录系统已经发展成一个单体应用,这意味着除了核心产品信息(如产品名称、类别名称和相关逻辑)之外,它还吞噬了产品定价逻辑和数据。系统核心产品部分和定价部分之间没有明确的界限。

此外,系统定价部分的变化率(系统中引入变化的速率)远高于核心产品。这两个系统部分的数据访问模式也不同。产品的定价比核心产品属性动态变化得多。因此,将系统的定价部分从单体应用中提取出来,使其成为可以独立发展的单独服务,很有意义。

与核心产品相比,提取定价更具吸引力的是,定价是目录单体应用中的“叶子”依赖项。核心产品功能也是单体应用中其他功能的依赖项,例如产品库存、产品营销等,为了简单起见,这里没有显示。如果你要将核心产品提取为服务,则意味着要同时切断单体应用中过多的“连接”,这会使迁移过程非常冒险。首先,你想要拆分一个有价值的业务能力,它是单体应用依赖关系图中的叶子依赖项,例如定价功能。

图 1:目录单体应用包含核心产品和产品定价的应用程序逻辑和数据库。目录单体应用有两个客户端——Web 应用程序和 iOS 应用程序。

代码的初始状态

以下是目录系统的代码的初始状态。显然,代码缺少这种系统的真实世界“混乱”即复杂性。但是,它足够复杂,可以展示涉及从单体应用中提取数据密集型服务的重构精神。我们将看到下面的代码如何在这些步骤中进行重构。

代码包含一个CatalogService,它代表单体应用提供给其客户端的接口。它使用一个productRepository类从数据库中获取和持久化状态。Product类是一个哑数据类(表示贫血领域模型),它包含产品信息。哑数据类显然是一种反模式,但它们不是本文的重点,因此就这个例子而言,我们将使用它。SkuPriceCategoryPriceRange是“微型类型”。

class CatalogService…

  public Sku searchProduct(String searchString) {
      return productRepository.searchProduct(searchString);
  }

  public Price getPriceFor(Sku sku) {
      Product product = productRepository.queryProduct(sku);
      return calculatePriceFor(product);
  }

  private Price calculatePriceFor(Product product) {
      if(product.isOnSale()) return product.getSalePrice();
      return product.getOriginalPrice();
  }

  public CategoryPriceRange getPriceRangeFor(Category category) {
      List<Product> products = productRepository.findProductsFor(category);
      Price maxPrice = null;
      Price minPrice = null;
      for (Product product : products) {
          if (product.isActive()) {
              Price productPrice = calculatePriceFor(product);
              if (maxPrice == null || productPrice.isGreaterThan(maxPrice)) {
                  maxPrice = productPrice;
              }
              if (minPrice == null || productPrice.isLesserThan(minPrice)) {
                  minPrice = productPrice;
              }
          }
      }
      return new CategoryPriceRange(category, minPrice, maxPrice);
  }

  public void updateIsOnSaleFor(Sku sku) {
      final Product product = productRepository.queryProduct(sku);
      product.setOnSale(true);
      productRepository.save(product);
  }

让我们迈出第一步,将“产品定价”服务从目录单体应用中提取出来。

步骤 1. 识别与新服务相关的逻辑和数据

第一步是识别与驻留在单体应用中的产品定价服务相关的數據和逻辑。我们的目录应用程序有一个Products表,其中包含核心产品属性,如name、SKU、category_nameis_active标志(指示产品是激活还是已停产)。每个产品都属于一个产品类别。产品类别是产品的分组。例如,“男士衬衫”类别包含“花衬衫”和“燕尾服衬衫”等产品。单体应用中存在与核心产品相关的逻辑,例如按名称搜索产品。

Products表还包含与定价相关的字段,如original_price、sale_priceis_on_sale标志,指示产品是否在促销中。单体应用具有与定价相关的逻辑,例如计算产品的价格和更新is_on_sale标志。获取类别的价格范围很有趣,因为它主要是产品定价逻辑,但也包含一些核心产品逻辑。

图 2:核心产品逻辑和数据以绿色突出显示,而产品定价数据和逻辑以蓝色突出显示。

这与我们之前看到的代码相同,只是现在它被颜色编码,以显示属于核心产品产品定价的代码部分。

class CatalogService…

  public Sku searchProduct(String searchString) {
      return productRepository.searchProduct(searchString);
  }

  public Price getPriceFor(Sku sku) {
      Product product = productRepository.queryProduct(sku);
      return calculatePriceFor(product);
  }

  private Price calculatePriceFor(Product product) {
      if(product.isOnSale()) return product.getSalePrice();
      return product.getOriginalPrice();
  }

  public CategoryPriceRange getPriceRangeFor(Category category) {
      List<Product> products = productRepository.findProductsFor(category);
      Price maxPrice = null;
      Price minPrice = null;
      for (Product product : products) {
          if (product.isActive()) {
              Price productPrice = calculatePriceFor(product);
              if (maxPrice == null || productPrice.isGreaterThan(maxPrice)) {
                  maxPrice = productPrice;
              }
              if (minPrice == null || productPrice.isLesserThan(minPrice)) {
                  minPrice = productPrice;
              }
          }
      }
      return new CategoryPriceRange(category, minPrice, maxPrice);
  }

  public void updateIsOnSaleFor(Sku sku) {
      final Product product = productRepository.queryProduct(sku);
      product.setOnSale(true);
      productRepository.save(product);
  }

步骤 2. 在单体应用中为新服务的逻辑创建逻辑分离

步骤 2 和 3 是关于为产品定价服务的逻辑和数据创建逻辑分离,同时仍然在单体应用中工作。你实际上是在产品定价数据和逻辑从更大的单体应用中隔离出来,然后才将其提取到新的服务中。这样做的优势在于,如果你弄错了产品定价服务的边界(逻辑或数据),那么在同一个单体应用代码库中重构代码会比将其提取出来并“跨线”重构容易得多。

作为步骤 2 的一部分,我们将为包装产品定价和核心产品的逻辑创建服务类,分别称为ProductPricingServiceCoreProductService。这些服务类将与我们的“物理”服务一一对应——产品定价和核心产品,正如你将在后面的步骤中看到的那样。我们还将创建单独的存储库类——ProductPriceRepositoryCoreProductRepository。这些将分别用于从Products表访问产品定价数据和核心产品数据。

在这一步中要记住的关键点是,ProductPricingServiceProductPriceRepository不应该访问Products表以获取核心产品信息。相反,对于任何与核心产品相关的信息,产品定价代码应该严格通过CoreProductService进行。你将在下面的重构getPriceRangeFor方法中看到一个示例。

不允许从属于系统核心产品部分的表到属于产品定价的表进行任何表连接。同样,数据库中核心产品数据和产品定价数据之间不应该存在任何“硬”约束,例如外键或数据库触发器。所有连接以及约束都必须从数据库层移动到逻辑层。不幸的是,说起来容易做起来难,这是最难做的事情之一,但绝对有必要将数据库拆分。

话虽如此,核心产品和产品定价确实有一个共享标识符——产品 SKU,用于在系统的两个部分中唯一标识产品,一直到数据库级别。这个“跨系统标识符”将用于跨服务通信(如后续步骤所示),因此明智地选择此标识符非常重要。应该有一个服务拥有跨系统标识符。所有其他服务都应该将标识符用作参考,但不要更改它。从他们的角度来看,它是不可变的。最适合管理标识符存在实体的生命周期的服务应该拥有标识符。例如,在我们的案例中,核心产品拥有产品生命周期,因此拥有 SKU 标识符。

图 3:核心产品逻辑和产品定价逻辑之间的逻辑分离,同时连接到同一个 Products 表。

以下是重构后的代码。您将看到新创建的 ProductPricingService,它包含特定于定价的逻辑。我们还有 productPriceRepository 用于与 Products 表中特定于定价的数据进行通信。现在,我们有数据类 ProductPriceCoreProduct 用于保存各自的产品定价和核心产品数据,而不是 Product 数据类。

getPriceForcalculatePriceFor 函数非常容易转换为指向新的 productPriceRepository 类。

class ProductPricingService…

  public Price getPriceFor(Sku sku) {
      ProductPrice productPrice = productPriceRepository.getPriceFor(sku);
      return calculatePriceFor(productPrice);
  }

  private Price calculatePriceFor(ProductPrice productPrice) {
      if(productPrice.isOnSale()) return productPrice.getSalePrice();
      return productPrice.getOriginalPrice();
  }

获取类别价格范围的逻辑更加复杂,因为它需要知道哪些产品属于应用程序核心产品部分中的类别。getPriceRangeFor 方法调用 coreProductService 中的 getActiveProductsFor 方法来获取给定类别的活动产品列表。这里需要注意的是,由于 is_active 是核心产品的属性,因此我们将 isActive 检查移到了 coreProductService 中。

class ProductPricingService…

  public CategoryPriceRange getPriceRangeFor(Category category) {
      List<CoreProduct> products = coreProductService.getActiveProductsFor(category);

      List<ProductPrice> productPrices = productPriceRepository.getProductPricesFor(mapCoreProductToSku(products));

      Price maxPrice = null;
      Price minPrice = null;
      for (ProductPrice productPrice : productPrices) {
              Price currentProductPrice = calculatePriceFor(productPrice);
              if (maxPrice == null || currentProductPrice.isGreaterThan(maxPrice)) {
                  maxPrice = currentProductPrice;
              }
              if (minPrice == null || currentProductPrice.isLesserThan(minPrice)) {
                  minPrice = currentProductPrice;
              }
      }
      return new CategoryPriceRange(category, minPrice, maxPrice);
  }

  private List<Sku> mapCoreProductToSku(List<CoreProduct> coreProducts) {
      return coreProducts.stream().map(p -> p.getSku()).collect(Collectors.toList());
  }

以下是用于获取给定类别的活动产品的新的 getActiveProductsFor 方法。

class CoreProductService…

  public List<CoreProduct> getActiveProductsFor(Category category) {
      List<CoreProduct> productsForCategory = coreProductRepository.getProductsFor(category);
      return filterActiveProducts(productsForCategory);
  }

  private List<CoreProduct> filterActiveProducts(List<CoreProduct> products) {
      return products.stream().filter(p -> p.isActive()).collect(Collectors.toList());
  }

在这种情况下,我们将 isActive 检查保留在服务类中,但这可以轻松地向下移动到数据库查询中。事实上,这种将功能拆分为多个服务的重构类型通常可以轻松地发现将逻辑移动到数据库查询中的机会,从而使代码性能更高。

updateIsOnSale 逻辑也很简单,需要按如下方式重构。

class ProductPricingService…

  public void updateIsOnSaleFor(Sku sku) {
      final ProductPrice productPrice = productPriceRepository.getPriceFor(sku);
      productPrice.setOnSale(true);
      productPriceRepository.save(productPrice);
  }

searchProduct 方法指向新创建的 coreProductRepository 用于搜索产品。

class CoreProductService…

  public Sku searchProduct(String searchString) {
      return coreProductRepository.searchProduct(searchString);
  }

CatalogService(单体应用程序的顶级接口)将被重构为将服务方法调用委托给相应的服务——CoreProductServiceProductPricingService。这很重要,这样我们不会破坏与单体应用程序客户端的现有契约。

searchProduct 方法被委托给 coreProductService

class CatalogService…

  public Sku searchProduct(String searchString) {
      return coreProductService.searchProduct(searchString);
  }

与定价相关的 method 被委托给 productPricingService

class CatalogService…

  public Price getPriceFor(Sku sku) {
      return productPricingService.getPriceFor(sku);
  }

  public CategoryPriceRange getPriceRangeFor(Category category) {
      return productPricingService.getPriceRangeFor(category);
  }

  public void updateIsOnSaleFor(Sku sku) {
      productPricingService.updateIsOnSaleFor(sku);
  }

步骤 3. 创建新的表来支持单体应用中新服务的逻辑

作为此步骤的一部分,您将把与定价相关的数据拆分为一个新表——ProductPrices。在此步骤结束时,产品定价逻辑应该访问 ProductPrices 表,而不是直接访问 Products 表。对于它需要从 Products 表中获取的与核心产品信息相关的任何信息,它应该通过核心产品逻辑层。此步骤应该只导致 productPricingRepository 类中的代码更改,而不是任何其他类,尤其是服务类。

重要的是要注意,此步骤涉及从 Products 表到 ProductPrices 表的数据迁移。确保您设计新表中的列,使其与 Products 表中与产品定价相关的列完全相同。这将使存储库代码保持简单,并使数据迁移变得简单。如果您在将 productPricingRepository 指向新表后发现错误,您可以将 productPricingRepository 代码指向回 Products 表。您可以在成功完成此步骤后选择从 Products 表中删除与产品定价相关的字段。

从本质上讲,我们在这里做的是数据库迁移,它涉及将一个表拆分为两个表并将数据从原始表移动到新创建的表中。我的同事 Pramod Sadalage 专门写了一本关于 重构数据库 的书,如果您想了解更多关于这个主题的信息,可以查看一下。作为快速参考,您可以参考 Pramod 和 Martin Fowler 的 演化数据库设计 文章。

在此步骤结束时,您应该能够获得关于新服务对整个系统在功能方面以及跨功能需求(尤其是性能)方面可能产生的影响的指示。您应该能够看到逻辑层中“内存数据连接”的性能影响。在我们的案例中,getPriceRangeFor 在核心产品和产品定价信息之间进行内存数据连接。逻辑层中的内存数据连接总是比在数据库层进行这些连接更昂贵,但这是拥有解耦数据系统的代价。如果性能在此阶段受到影响,那么当数据通过网络在物理服务之间来回传输时,性能会变得更糟。如果性能要求(或任何其他要求)没有得到满足,那么您可能需要重新考虑服务边界。至少,客户端(Web 应用程序和 iOS 应用程序)在很大程度上对这种更改是透明的,因为我们还没有更改任何客户端交互。这允许对服务边界进行快速且廉价的实验,这是此步骤的优势。

图 4:核心产品逻辑和数据与产品定价逻辑和数据之间的逻辑分离。

步骤 4. 构建指向单体数据库中表的新的服务

在此步骤中,您将为产品定价构建一个全新的“物理”服务,其中包含来自 ProductPricingService 的逻辑,同时仍然指向单体数据库中的 ProductPrices 表。请注意,此时,从 ProductPricingService 调用 CoreProductService 将是一个网络调用,并将产生性能损失,以及必须处理与远程调用相关的诸如超时之类的问题,这些问题应该得到相应的处理。

这可能是一个创建 “业务真实” 产品定价服务抽象的好机会,这样您就可以对服务进行建模以表示业务意图,而不是解决方案的机制。例如,当业务用户更新 updateIsOnSale 标志时,他们实际上是在系统中为给定产品创建“促销”。以下是重构后 updateIsOnSaleFor 的样子。我们还添加了指定促销价格的功能,这是以前没有的。这可能也是一个将一些与服务相关的复杂性推回到服务中的好时机,这些复杂性可能已经泄露到客户端中。从服务消费者的角度来看,这将是一个受欢迎的改变。

class ProductPricingService…

  public void createPromotion(Promotion promotion) {
      final ProductPrice productPrice = productPriceRepository.getPriceFor(promotion.getSku());
      productPrice.setOnSale(true);
      productPrice.setSalePrice(promotion.getPrice());
      productPriceRepository.save(productPrice);
  }

但是,围绕此的限制是,更改不应以任何方式要求更改表结构或数据语义,因为这会破坏单体应用程序中的现有功能。一旦服务被完全提取(在步骤 9 中),那么您就可以随心所欲地更改数据库,因为这与在逻辑层中进行代码更改一样好。

您可能希望在将客户端移交之前进行这些更改,因为更改服务接口可能是一个昂贵且耗时的过程,尤其是在大型组织中,因为它涉及从不同的服务消费者那里获得认可,以便及时迁移到新接口。这将在下一步中详细讨论。您可以安全地将此新的定价服务发布到生产环境并对其进行测试。目前还没有此服务的客户端。此外,此步骤中没有对单体应用程序的客户端(Web 应用程序和 iOS 应用程序)进行任何更改。

图 5:新的物理产品定价服务,它指向单体应用程序中的 ProductPrices 表,同时依赖于单体应用程序以获得核心产品功能。

步骤 5. 将客户端指向新的服务

在此步骤中,对单体应用程序感兴趣的产品定价功能的客户端需要迁移到新服务。此步骤中的工作将取决于两件事。首先,它将取决于单体应用程序和新服务之间的接口更改了多少。其次,从组织角度来看,更复杂的是客户端团队及时完成此步骤的带宽(能力)。

如果此步骤拖延,架构很可能处于半完成状态,其中一些客户端指向新服务,而另一些客户端指向单体应用程序。这可以说使架构比您开始之前处于更糟糕的状态。这就是我们之前讨论的“架构演化的原子步骤”原则很重要的原因。在开始迁移之旅之前,请确保您已从新服务功能的所有客户端获得组织上的认可,以便及时迁移到新服务。在将架构置于半生不熟的状态下,很容易被其他高优先级事项分散注意力。

现在好消息是,并非所有服务客户端都必须在完全相同的时间迁移,也不需要彼此协调迁移。但是,在进入下一步之前,迁移所有客户端非常重要。如果还没有,您可以在服务级别引入一些监控,用于与定价相关的 method,以识别“迁移滞后者”——尚未迁移到新服务的服务消费者。

从理论上讲,您可以在客户端迁移之前处理一些后续步骤,尤其是下一步,它涉及创建定价数据库,但为了简单起见,我建议尽可能按顺序进行。

图 6:对单体应用程序感兴趣的定价功能的客户端已迁移到新的产品定价服务。

步骤 6. 为新的服务创建数据库

此步骤相对容易,您可以在其中构建一个定价数据库,该数据库镜像单体应用程序中的表结构。在构建全新的服务时,您可能很想构建一个全新的定价架构。但是,拥有一个全新的架构会使后续步骤中的数据迁移变得更加困难。这也意味着新的定价服务必须支持两种不同的架构——一种来自单体应用程序,另一种来自新数据库。我建议保持简单——首先提取定价服务(完成此处提到的所有步骤),然后重构定价服务的内部结构。一旦定价数据库被隔离,更改它应该与更改服务中的任何代码一样,因为没有客户端会直接访问定价数据库。

图 7:已创建新的独立定价数据库。

步骤 7. 将数据从单体应用同步到新的数据库

在此步骤中,您将从单体数据库中同步定价表数据到新的定价数据库。如果新数据库中的模式与单体数据库中的定价表相同,则在单体数据库和新服务数据库之间同步数据非常简单。它本质上与将定价数据库设置为单体数据库的“只读副本”相同(尽管仅针对定价相关的表)。这将确保新定价数据库中的数据是最新的。

现在,您已准备好将定价服务连接到下一步中的新定价数据库。

图 8:产品定价相关表和新定价数据库表之间的数据同步。

步骤 8. 将新的服务指向新的数据库

在开始此步骤之前,绝对重要的是,所有对单体数据库感兴趣的定价信息的客户端都已迁移到新服务。否则,您可能会遇到写入冲突,这违反了我们之前讨论的“对数据拥有单个写入副本”原则。在所有客户端迁移到新服务后,您将定价服务指向新的定价数据库。您实际上是将数据库连接从单体数据库切换到新数据库。

这种设计的一个优点是,如果您发现任何问题,可以轻松地将连接切换回旧数据库。您可能遇到的一个问题是,新服务中的代码依赖于新数据库中不存在但在旧数据库中存在的某些表/字段。这可能是因为您在步骤 1 中未能识别出该数据。这可能发生在诸如“参考”数据之类的场景中,例如支持的货币。成功解决这些问题后,您可以进入下一步。

图 9:产品定价服务指向定价数据库。

步骤 9. 删除与新服务相关的单体应用中的逻辑和模式

在此步骤中,您将从单体数据库中删除定价相关的逻辑和模式。团队经常将旧表永远保留在数据库中,因为他们担心“将来可能需要它”。备份整个数据库可能有助于缓解其中一些担忧。

此时,CatalogService所做的只是将核心产品方法调用委托给CoreProductService,因此我们可以删除间接层,让客户端直接调用CoreProductService

图 10:核心产品仅包含核心产品相关的逻辑和数据,而产品定价包含定价相关的数据和逻辑。它们仅通过逻辑层相互通信。

总结

就是这样!我们刚刚从单体数据库中分离出一个数据丰富的服务。太棒了!

当您第一次执行此操作时,将会有很大的痛苦,也会学到宝贵的经验,您可以利用这些经验来指导您下次服务提取。在您第一次服务提取时,最好不要将步骤合并,即使这样做可能很诱人。一次一步地进行,会使分解单体数据库的过程不那么令人生畏、更安全和更可预测。一旦您对这种模式掌握了一定程度的精通,您就可以根据您的学习成果开始优化流程。

去分解那个单体数据库吧!祝您好运!


致谢

我要感谢 Martin Fowler 主持这篇文章,并慷慨地抽出时间审阅这篇文章。他的审阅意见真正将这篇文章提升到了一个新的水平。我还想感谢Jorge Lee 提供了重要的评论。我要感谢我的 Thoughtworks 同事 Joey Guerra、Matt Newman、Vanessa Towers、Ran Xiao 和 Kuldeep Singh 对我们内部邮件列表的评论。

重大修订

2018 年 8 月 30 日:发布了文章的剩余部分

2018 年 8 月 29 日:发布了第六和第七步

2018 年 8 月 28 日:发布了第五步

2018 年 8 月 27 日:发布了第四步

2018 年 8 月 25 日:发布了第三步

2018 年 8 月 24 日:发布了第二步

2018 年 8 月 23 日:发布了第一步