如何從單體中萃取資料豐富的服務

在將單體拆分為較小的服務時,最困難的部分實際上是拆分存在於單體資料庫中的資料。若要萃取資料豐富的服務,遵循一系列步驟很有用,這些步驟始終保留資料的單一寫入副本。這些步驟從在現有單體中進行邏輯分離開始:將服務行為拆分為一個單獨的模組,然後將資料分隔到一個單獨的表格中。這些元素可以單獨移到一個新的自主服務中。

2018 年 8 月 30 日



業界正從單體轉向較小的服務,這是一個重大的轉變。組織投資這項轉變的一個關鍵原因是,圍繞業務功能建置的較小服務可以提高開發人員的生產力。可以擁有這些較小服務的團隊可以「掌握自己的命運」,這表示他們可以獨立於系統中的其他服務來發展自己的服務。

在將巨石架構拆分為較小的服務時,最困難的部分實際上是拆分存在於巨石架構資料庫中的資料。將巨石架構中的邏輯切成較小的部分,同時仍連接到同一個資料庫,相對來說比較容易。在這種情況下,資料庫基本上是一個 IntegrationDatabase,它給人一種可以獨立發展的分布式系統的假象,但事實上它是一個在資料庫層級 緊密耦合 的系統。為了讓服務真正獨立,讓團隊「掌握自己的命運」,他們也需要一個獨立的資料庫,也就是該服務的架構和對應資料。

在本文中,我將討論一個模式,這是一系列步驟,用於從巨石架構中提取資料豐富的服務,同時對服務使用者造成的干擾最小。

服務萃取指導原則

在我們深入探討實際模式之前,我想談談兩個對服務提取至關重要的指導原則。這些原則有助於從巨石架構的世界順利且安全地過渡到多個較小的服務。

在整個轉換過程中,資料只有一個寫入副本

在過渡期間,我們將為要提取的服務資料保留一個單一寫入副本。讓客戶端可以寫入多個資料副本會導致產生寫入衝突的可能性。當多個客戶端同時寫入同一段資料時,就會發生寫入衝突。處理寫入衝突的邏輯很複雜,這可能表示選擇一種方案,例如「最後寫入者獲勝」,這可能會從客戶端的角度產生不良結果。這也可能表示通知寫入失敗的客戶端,並讓他們採取糾正措施。撰寫此類邏輯充滿了複雜性,最好避免。

本文中描述的服務提取模式將確保在任何時間點都存在一個單一寫入副本,以避免管理寫入衝突所帶來的複雜性。

尊重「架構演化的原子步驟」原則

我的同事 Zhamak Dehghani 創造了術語 「架構演化的原子步驟」,這是一系列在架構遷移歷程中以原子方式(全有或全無)執行的步驟。在執行完這系列步驟後,架構將產生預期的回報。如果沒有完整執行這些步驟(中途放棄),架構將會比您開始時更糟。例如,如果您決定提取服務,但最後只提取邏輯而沒有提取資料,您仍然會在資料庫層面耦合,這會導致開發和執行時間耦合。這會造成顯著的複雜性,並可能讓開發和除錯問題比單一巨石架構更困難。

在以下服務提取模式中,建議您完成為特定服務列出的所有步驟。服務提取模式中最大的障礙之一實際上並非技術問題,而是取得組織共識,讓巨石架構的所有現有客戶端轉移到新服務。這將在步驟 5 中進一步說明。

服務萃取步驟

現在,讓我們深入探討實際的服務提取模式。為了讓您更容易遵循這些步驟,我們將舉例說明服務提取如何運作。

假設我們有一個巨石型目錄系統,可為我們的電子商務平台提供產品資訊。隨著時間推移,目錄系統已發展成一個巨石架構,這表示除了核心產品資訊(例如產品名稱、類別名稱和相關邏輯)之外,它還吸收了產品定價邏輯和資料。系統的核心產品部分和定價部分之間沒有明確的界線。

此外,系統定價部分的變更率(系統中引入變更的速率)遠高於核心產品。這兩個系統部分的資料存取模式也不同。產品定價的變動比核心產品屬性更為動態。因此,將系統的定價部分從巨石架構中提取出來,成為一個可以獨立演化的獨立服務,是非常有意義的。

與核心產品相比,提取定價的誘因在於定價是目錄巨石架構中的「葉子」依賴項。核心產品功能也是巨石架構中其他功能的依賴項,例如產品庫存、產品行銷等,為了簡潔起見,這裡沒有顯示這些功能。如果您要將核心產品提取為服務,這表示必須同時切斷巨石架構中的太多「連線」,這可能會讓遷移過程非常冒險。首先,您應該在巨石架構依賴關係圖中提取有價值的業務功能,例如定價功能,這是一個葉子依賴項。

圖 1:目錄單體由核心產品的應用程式邏輯和資料庫,以及產品定價組成。目錄單體有兩個客戶端,分別是網頁應用程式和 iOS 應用程式。

程式碼的初始狀態

以下是目錄系統程式碼的初始狀態。顯然地,程式碼缺乏此類系統的真實世界「混亂」或複雜性。然而,它足夠複雜,足以說明將資料豐富的服務從單體中抽出的重構精神。我們將看到以下程式碼如何在步驟過程中進行重構。

程式碼包含一個 CatalogService,它代表單體提供給其客戶端的介面。它使用 productRepository 類別從資料庫中擷取和儲存狀態。Product 類別是一個愚蠢的資料類別(表示 貧血領域模型),它包含產品資訊。愚蠢的資料類別顯然是一種反模式,但它們並非本文的主要重點,因此就範例而言,我們將將就使用它。SkuPriceCategoryPriceRange 是「微小類型」。

類別 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:核心產品邏輯和資料以綠色標示,而產品定價資料和邏輯則以藍色標示。

這是我們先前看過的相同程式碼,但現在已用顏色標示,以顯示程式碼中屬於 核心產品產品定價 的部分。

類別 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:連線到同一個產品資料表時,核心產品邏輯和產品定價邏輯之間的邏輯分離。

以下是重構後的程式碼。您將看到新建立的 ProductPricingService,其中包含特定於定價的邏輯。我們也有 productPriceRepository 來與 Products 資料表中的特定於定價的資料對話。現在我們有資料類別 ProductPriceCoreProduct,用於儲存各自的產品定價和核心產品資料,而不是 Product 資料類別。

getPriceForcalculatePriceFor 函式很容易轉換為指向新的 productPriceRepository 類別。

類別 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

類別 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 方法。

類別 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 邏輯也相當直接,必須重新整理如下。

類別 ProductPricingService…

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

searchProduct 方法指向新建立的 coreProductRepository 以搜尋產品。

類別 CoreProductService…

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

CatalogService(巨石的頂層介面)將重新整理以委派服務方法呼叫至適當的服務 – CoreProductServiceProductPricingService。這很重要,這樣我們才不會中斷與巨石客戶的現有合約。

searchProduct 方法委派給 coreProductService

類別 CatalogService…

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

定價相關方法委派給 productPricingService

類別 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 的外觀。我們還增加了指定促銷價格的功能,這是之前沒有的。這也可能是透過將部分服務相關的複雜性推回服務(可能已外洩到客戶端)來簡化介面的好時機。從服務使用者的角度來看,這將是一個受歡迎的變更。

類別 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. 將客戶端指向新服務

在此步驟中,有興趣使用產品定價功能的巨石客戶端需要移轉到新的服務。此步驟中的工作將取決於兩件事。首先,這將取決於巨石和新服務之間的介面變更程度。其次,從組織的觀點來看,更複雜的是,客戶端團隊必須及時完成此步驟的頻寬(容量)。

如果此步驟拖延,很可能會讓架構處於半完成狀態,其中一些客戶端指向新的服務,而另一些客戶端指向巨石。這無疑讓架構比您開始之前更糟。這就是為什麼我們之前討論的「架構演進的原子步驟」原則很重要。在開始遷移之前,請確保您已獲得新服務功能的所有客戶端的組織調整,以便及時移轉到新的服務。在讓架構處於半生不熟的狀態下,很容易被其他高優先順序事項分散注意力。

現在好消息是,並非所有服務客戶都必須在完全相同的時間進行遷移,或需要彼此協調遷移。但是,在進行下一步之前,遷移所有客戶非常重要。如果尚未存在,您可以針對定價相關方法在服務層級中導入一些監控,以找出「遷移落後者」,也就是尚未遷移到新服務的服務使用者。

理論上,您可以在客戶遷移之前處理一些後續步驟,特別是下一個步驟,其中包含建立定價資料庫,但為了簡化起見,我建議盡可能循序漸進。

圖 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 日:發布第一步