Cách chúng tôi đồng bộ hóa Virtual Monorepo của .NET

Trong bài viết trước của chúng tôi, “Reinventing how .NET Builds and Ships”, Matt đã trình bày về cuộc đại tu gần đây của chúng tôi đối với quy trình xây dựng và phát hành .NET. Một phần quan trọng trong nỗ lực kéo dài nhiều năm này, mà chúng tôi gọi là Unified Build, chính là việc giới thiệu Virtual Monolithic Repository (VMR) – nơi tập hợp toàn bộ mã nguồn và hạ tầng cần thiết để xây dựng .NET SDK. Bài viết này tập trung vào chính monorepo: cách nó được tạo ra và các chi tiết kỹ thuật của cơ chế đồng bộ hai chiều giúp nó vận hành.

Cho đến gần đây, .NET SDK vẫn được xây dựng từ việc tổng hợp các build artifact của hàng chục repository. Các artifact này chảy xuống theo cây repository và được ghép nối ở cuối để tạo ra .NET SDK hoàn chỉnh. Cách tiếp cận này đã phục vụ chúng tôi tốt trong nhiều năm, nhưng cũng mang lại sự phức tạp đáng kể và chi phí bảo trì cao. Kể từ .NET 10 Preview 4, chúng tôi đã chuyển sang xây dựng .NET SDK từ một commit duy nhất của một monorepo.

Virtual Monolithic Repository là gì?

Virtual Monolithic Repository (VMR) là một git repository duy nhất bao gồm toàn bộ mã nguồn và hạ tầng cần thiết để xây dựng .NET SDK. Bạn có thể tìm thấy VMR trên GitHub tại dotnet/dotnet. Trên thực tế, nó chủ yếu là sự tổng hợp của hàng chục repository độc lập khác (như dotnet/runtime hay dotnet/sdk), mà chúng tôi gọi là “product repositories”. Bên cạnh đó, nó còn chứa các mã nguồn bổ sung như hạ tầng build, định nghĩa pipeline và các script cần thiết.

Các product repository vẫn tồn tại độc lập và được đồng bộ với phiên bản tương ứng của chúng dưới dạng thư mục con của VMR. Đây chính là nguồn gốc của từ virtual (ảo). Thay đổi có thể được thực hiện ở product repositories hoặc trực tiếp trong VMR. Hạ tầng của chúng tôi sau đó giữ cho hai phía được đồng bộ bằng cách tạo các pull request mang theo thay đổi mã nguồn giữa hai phía.

Con đường dẫn tới VMR

Việc có một monorepo đồng bộ hai chiều luôn là nền tảng cần thiết của dự án Unified Build. Tuy nhiên, để đạt được điều này là một hành trình nhiều giai đoạn, trong suốt quá trình đó chúng tôi vẫn phải tiếp tục phát hành sản phẩm.

Giai đoạn #1 – Source Build Tarball

Hành trình này bắt đầu trong khoảng thời gian .NET 6, khi chúng tôi đầu tư mạnh mẽ vào khả năng đưa .NET lên các bản phân phối Linux như Ubuntu, Fedora, Debian và các trình quản lý gói như Homebrew. Để làm được điều này, chúng tôi phải tuân thủ các quy tắc của những người duy trì các bản phân phối này, vốn thường quy về:

  • Mã nguồn cho mọi thứ, không được phép có binary
  • Hạn chế hoặc không có quyền truy cập mạng

Nói cách khác, chúng tôi phải trao cho những người duy trì một bộ các tệp mã nguồn phi-binary, có thể biên dịch thành .NET SDK mà không cần tải bất cứ thứ gì từ internet. Chúng tôi gọi quy trình này là Source Build.

Phương pháp Source Build khác với cách chúng tôi từng xây dựng .NET SDK cho các bản phát hành của chính mình – trước VMR, các bản phát hành được dựng từ dòng chảy dần dần của build artifact qua một cây phụ thuộc gồm hàng chục repository. Cả hai quy trình đều có chung một nhu cầu – dòng chảy phụ thuộc phải đến được repository cuối cùng (ban đầu là dotnet/installer, sau này là dotnet/sdk). Sau đó, bạn thu thập các binary hoặc mã nguồn đằng sau các binary đó và đưa vào bản build cuối cùng.

Phiên bản đầu tiên của Source Build sẽ duyệt qua các commit của từng repository trong cây, thêm hạ tầng Source Build (logic đằng sau Source Build) và tạo ra một tarball ngay lập tức. Archive này sau đó được trao cho các nhà duy trì bên thứ ba, họ sẽ build nó trên hệ thống của mình và kiểm tra các gói được tạo ra rồi đưa vào kho gói của họ.

Source Build Patches

Thường thì chúng tôi nhận thấy các mã nguồn thu thập được không thể build thành công từ mã nguồn. Các phương pháp build khác nhau, và việc phát hiện lỗi trước khi dòng chảy phụ thuộc sản phẩm hoàn tất thường quá phức tạp và tốn kém. Đôi khi điều này thậm chí còn phát hiện ra một vấn đề tích hợp đã tồn tại trước khi phát hành. Khi xảy ra lỗi Source Build, giải pháp là phải sửa trong một trong các product repository rồi truyền xuống lại cây phụ thuộc. Đây là một quy trình tẻ nhạt, kéo dài, tốn kém và dễ mắc lỗi.

Để giảm bớt khó khăn, chúng tôi cho phép kiểm tra các “Source Build patches” vào repository cuối cùng. Các bản vá bổ sung với các bản sửa lỗi này sẽ được áp dụng lên trên các mã nguồn đã thu thập. Sau đó, chúng tôi đưa bản vá ngược về repository gốc nơi các mã nguồn được vá xuất phát. Khi các mã nguồn đã sửa chảy xuống cây lần nữa, bản vá có thể được gỡ bỏ – vì lúc đó nó sẽ không thể áp dụng lên các mã nguồn đã thu thập do thay đổi đã có sẵn.

Giai đoạn #2 – VMR-lite

Để thực hiện bước tiến đáng kể đầu tiên hướng tới dòng chảy mã VMR đầy đủ, chúng tôi cần rời khỏi cách tiếp cận dựa trên tarball và chuyển sang một git repository chuyên dụng. Nội dung sẽ giống với tarball, nhưng việc chuyển sang git đòi hỏi đầu tư vào quản lý mã nguồn và thay đổi – điều cần thiết cho VMR Unified Build cuối cùng. Vào tháng 10 năm 2022, repository gốc dotnet/dotnet được tạo ra. Nó có tên mã là “VMR-lite” và là một bản sao chỉ-đọc (một projection) của mã nguồn từ các product repository.

Mỗi lần chúng tôi merge một commit vào SDK repository, một pipeline đồng bộ một chiều sẽ được kích hoạt. Nó duyệt qua cây phụ thuộc, thu thập các commit đằng sau tất cả các phụ thuộc, và cập nhật các thư mục con tương ứng trong VMR.

Một sơ đồ đơn giản hóa minh họa quy trình đồng bộ một chiều từ các product repository vào VMR-lite.

Các tệp không mong muốn như binary bị cấm bởi các quy tắc Source Build sẽ bị loại trừ trong quá trình này. Các Source Build patches cũng được áp dụng.

VMR-lite đã trở thành phương tiện phát hành cho Source Build linux-x64 của các bản phân phối Linux bắt đầu từ .NET 8 Preview 1 và tiếp tục được sử dụng cho việc bảo trì (servicing) .NET 8 và .NET 9 cho đến ngày nay.

Giai đoạn #3 – VMR có thể ghi (Writeable VMR)

Việc chuyển quy trình phát triển Source Build lên VMR là một cột mốc quan trọng và cải thiện đáng kể quy trình làm việc. Việc phân tích các lỗi Source Build trở nên dễ dàng hơn nhiều nhờ lịch sử commit của VMR. Các lợi ích khác cũng trở nên rõ ràng khi chúng tôi kết nối VMR vào hạ tầng quét tuân thủ và bảo mật của mình. Tuy nhiên, chúng tôi nhắm tới mục tiêu cao hơn nhiều. Mục tiêu cuối cùng là hợp nhất hai phương pháp build hướng binary và hướng mã nguồn, đồng thời dùng VMR làm nơi phát triển và phát hành tất cả các bản build .NET SDK.

Có hai mảnh ghép còn thiếu trong bức tranh này. Thứ nhất, chúng tôi phải làm cho VMR có thể ghi được. Điều này cũng kéo theo khả năng đưa các thay đổi trở lại các product repository. Thứ hai, mã nguồn không còn đi qua đỉnh của cây phụ thuộc nữa. Thay vào đó, mỗi product repository sẽ được đồng bộ trực tiếp với VMR theo mô hình dòng chảy mã “phẳng” (flat).

Minh họa cấu trúc dòng chảy mã phẳng giữa các repository và VMR.

Một điểm khác biệt quan trọng nữa nằm ở cách toàn bộ dòng chảy được hiện thực hóa. Trong VMR-lite, chúng tôi biên dịch bộ mã nguồn mới trong một pipeline Azure DevOps và đẩy trực tiếp vào VMR. Giờ đây, dịch vụ đám mây dòng chảy phụ thuộc của chúng tôi điều khiển dòng chảy bằng cách tính toán các diff và tạo pull request mang theo thay đổi. Điều này cũng cho phép chúng tôi chạy các cổng xác thực (validation gates) của PR và thực hiện các bản sửa bổ sung trong PR trước khi merge.

Với việc VMR có thể ghi, chúng tôi có thể dễ dàng đưa các thay đổi phá vỡ (breaking changes) vào một repository cụ thể, đưa thay đổi đó vào một PR của VMR và sửa mã phụ thuộc của các repository khác ngay trong cùng một PR. Các thay đổi của các thư mục repository phụ thuộc sau đó chảy ngược về các repository gốc tương ứng của chúng. Dòng chảy ngược cũng chứa các binary được build từ VMR với breaking change nói trên mà các repository dùng để build.

Quá trình chuyển đổi từ cây phụ thuộc sang dòng chảy phẳng diễn ra cùng với bản phát hành .NET 10 Preview 5 và VMR đầy đủ đồng bộ hai chiều bắt đầu hoạt động từ đó.

Mô hình lưu trữ của VMR

Quyết định đầu tiên cần đưa ra là xác định cách cấu trúc và tạo ra chính VMR. Chúng tôi có một loạt yêu cầu cần cân nhắc, đến từ cả khả năng đáp ứng nhu cầu Source Build lẫn kế hoạch tương lai cho việc đồng bộ hai chiều:

  • Có một commit duy nhất, mạch lạc, nắm bắt được trạng thái nhất quán, có thể build được tại bất kỳ thời điểm nào.
  • Có thể áp dụng các Source Build patches (một delta vĩnh viễn).
  • Có thể ánh xạ các đường dẫn bổ sung – để chiếu mã nguồn tới các phần khác của VMR, ví dụ như nội dung của thư mục gốc.
  • Có thể loại trừ một số đường dẫn/tệp nhất định – ví dụ loại trừ binary bị một số bản phân phối Linux cấm.
  • Có thể thực hiện các thay đổi trong VMR để chúng có thể chảy ngược về các product repository.

Chúng tôi đã khám phá nhiều cách để tổng hợp nhiều repository thành một:

Sau khi xem xét kỹ lưỡng, không có lựa chọn nào đáp ứng đầy đủ mọi yêu cầu của chúng tôi, vì vậy chúng tôi quyết định tự triển khai một quy trình tùy chỉnh, duy trì các tệp trong một monorepository duy nhất dưới dạng các bản sao được kiểm tra (checked-in copies) của mã nguồn gốc. Chi tiết hơn về quyết định này có thể tìm thấy trong thiết kế gốc.

Xử lý Submodules

Một số product repository của chúng tôi đã chứa git submodules và cần chúng để build thành công. Một số trong đó thậm chí nằm ngoài .NET Foundation. Về mặt kỹ thuật, chúng có thể được giữ dưới dạng submodules trong VMR. Tuy nhiên, điều này xung đột với một số yêu cầu và mục tiêu của VMR:

  • Mọi commit đều chứa toàn bộ mã nguồn cần thiết để build .NET SDK.
  • Source Build yêu cầu build phải diễn ra mà không cần kết nối internet để đảm bảo không tải thêm artifact nào trong quá trình này.
  • Source Build cấm các tệp phi-văn-bản trong VMR.
  • Là một công dân mã nguồn mở tốt, chúng tôi muốn đóng góp ngược (upstream) càng nhiều thay đổi vào các submodules càng tốt.
  • Chúng tôi muốn tránh phụ thuộc lâu dài vào sự tồn tại của remote submodule bên ngoài để đảm bảo nhu cầu servicing của .NET.

Các giới hạn trên cho chúng tôi hai lựa chọn:

  • Fork tất cả các submodule, loại bỏ mọi tệp phi-văn-bản và tham chiếu các fork này như submodules trong VMR. Sau đó chúng tôi phải giữ các fork này đồng bộ với upstream.
  • Đưa các submodule vào VMR dưới dạng bản sao cứng của mã nguồn thay vì giữ chúng như một liên kết submodule, đồng thời loại bỏ các binary trong quá trình này.

Chúng tôi đã cân nhắc cả hai lựa chọn và quyết định chọn phương án sau – nó giảm ma sát khi làm việc với upstream. Không có các fork trung gian (man-in-the-middle), chúng tôi có thể tiêu thụ các phiên bản mới nhanh hơn và đảm bảo dễ dàng đóng góp ngược cho upstream. Đồng thời, chúng tôi luôn có sẵn toàn bộ mã nguồn và việc sử dụng VMR không gặp phải mọi phức tạp liên quan đến việc sử dụng submodule.

Di chuyển các thay đổi

Cốt lõi của quy trình đồng bộ là khả năng di chuyển các tệp – hay chính xác hơn là các thay đổi của tệp – giữa các repository một cách hiệu quả. Cần lưu ý rằng các thay đổi có thể đến dưới nhiều hình dạng khác nhau. Bắt đầu từ việc thêm/xóa và sửa nội dung hiển nhiên, một tệp cũng có thể được thay đổi quyền (bit thực thi), thay đổi encoding, hoặc toàn bộ tệp được di chuyển.

Ngay từ giai đoạn đầu của quá trình thiết kế, đã rõ ràng rằng nếu không muốn tự triển khai tất cả các chi tiết vụn vặt của các thao tác này, chúng tôi cần ủy thác càng nhiều càng tốt cho git. Điều này đồng nghĩa với việc phương tiện chính để di chuyển các thay đổi giữa các repository sẽ là các bản vá (patches) cổ điển, với các lý do chính:

  • Patches mã hóa đầy đủ tất cả các loại thay đổi khác nhau có thể xảy ra với tệp.
  • Patches có thể được áp dụng cho các đường dẫn khác nhau, ví dụ như ánh xạ thư mục gốc.
  • Dễ dàng loại trừ/bao gồm một số tệp hoặc mẫu (patterns) nhất định khi tạo patches, cho phép chúng tôi lọc ra các tệp không mong muốn như binary.
  • Việc áp dụng patch sẽ thất bại khi có nội dung bất ngờ, đảm bảo tính chính xác của quy trình và bảo vệ chống lại việc ghi đè vô tình.

Để minh họa cách hoạt động trong thực tế, chúng tôi chỉ cần gọi:

git diff --patch --binary --relative -- <ex/inclusion patterns>

Sau đó chúng tôi lấy patch kết quả và áp dụng nó vào đường dẫn đích bằng:

git apply --cached --ignore-space-change --directory=<target dir>

Như đã thảo luận ở trên, chúng tôi cố tình giữ điều này đơn giản để ủy thác phần việc nặng nề liên quan đến các phức tạp xung quanh thay đổi tệp cho chính git. Quyết định này đã chứng tỏ khá vững chắc, ngoài một vài trường hợp góc cạnh nhỏ cần xử lý đặc biệt. Ví dụ, git apply giới hạn kích thước tối đa của một patch dưới 1 GB. Để xử lý giới hạn này, chúng tôi phát hiện ra nó và chia nhỏ patch một cách đệ quy thành các phần nhỏ hơn.

Theo dõi các nguồn mã

Vì rõ ràng việc đồng bộ sẽ liên quan đến patches, bước tiếp theo là tìm cách theo dõi nguồn mã nào đã được đồng bộ ở đâu. Để theo dõi những gì bên trong VMR, chúng tôi duy trì một tệp manifest. Tệp này chứa các commit SHA của tất cả các product repository hiện đang được đồng bộ trong commit VMR đó. Ngoài ra, nó còn ghi nhớ các SHA của các submodule được vendor hóa. Tương tự, chúng tôi cũng theo dõi commit VMR được đồng bộ cuối cùng trong các product repository (phục vụ cho mục đích đồng bộ hai chiều). Để làm điều đó, chúng tôi giữ SHA của commit VMR đồng bộ cuối cùng trong tệp Version.Details.xml – tệp mà chúng tôi đã dùng để theo dõi các phụ thuộc của repository.

Bằng cách git-blame dữ liệu theo dõi, chúng tôi có thể xác định commit nào của phía đối ứng đã được đồng bộ vào repository hiện tại và khi nào. Điều này đủ để tính toán mã đã chảy giữa hai phía như thế nào theo thời gian. Chúng tôi dùng thông tin này sau đó trong thuật toán để xác định tập hợp các dòng chảy cuối cùng và hướng của chúng. Lý do và cách thức sẽ được trình bày trong các phần sau.

Quyết định này đã hoạt động khá tốt cho đến nay nhưng cũng có một số thách thức. Một ví dụ: nó có thể dẫn đến các tình huống sai lệch khi một repository quyết định merge các nhánh của chính nó với nhau và vô tình ghi đè dữ liệu theo dõi. Việc “đặt lại” dữ liệu theo dõi khi cần cũng có thể khó khăn, vì thay đổi dữ liệu theo dõi ảnh hưởng đến kết quả git blame. Chúng tôi hiện đang khám phá các cách vững chắc hơn, chẳng hạn như dùng git notes để lưu dữ liệu bên ngoài cây mã nguồn chính.

Đồng bộ một chiều

Như đã mô tả ở trên, VMR-lite là một projection chỉ-đọc của các product repository. Một số nội dung bị loại trừ khỏi việc đồng bộ, chẳng hạn như các binary bị các đối tác Source Build từ chối. Nội dung bổ sung có thể được ánh xạ tới các đường dẫn khác, ví dụ như thư mục gốc đến từ dotnet/installer. Cuối cùng, các Source Build patches được áp dụng lên trên nội dung đã đồng bộ.

Để cấu hình các quy tắc đồng bộ này, VMR chứa một tệp cấu hình trông giống như thế này:

{
  // Mỗi mapping đại diện cho một product repository với các quy tắc loại trừ nội dung riêng
  "mappings": [
    {
      "name": "runtime",
      "defaultRemote": "https://github.com/dotnet/runtime",
      "exclude": [
        "tests/**/*.dll"
      ]
    },
    {
      "name": "aspnetcore",
      "defaultRemote": "https://github.com/dotnet/aspnetcore",
    },
    // ...
  ],
  // Ví dụ về mapping bổ sung nội dung vào gốc của VMR
  "additionalMappings": [
    {
      "source": "src/SourceBuild/content",
      "destination": "/"
    }
  ],
  // Đường dẫn tới thư mục chứa Source Build patches
  "patchesPath": "src/installer/src/SourceBuild/patches"
}

Bản thân quy trình đồng bộ là một khối xây dựng cơ bản không chỉ cho VMR-lite mà sau này còn cho cả đồng bộ hai chiều đầy đủ. Quy trình trở nên phức tạp vì nó phải xử lý các thay đổi của submodules cũng như Source Build patches. Điều quan trọng cần lưu ý là tệp cấu hình quy định các quy tắc đồng bộ cũng có thể thay đổi. Điều này có nghĩa là cả Source Build patches lẫn nội dung được ánh xạ bổ sung đều có thể thay đổi trong quá trình, cần được gỡ bỏ một cách chính xác rồi áp dụng lại.

Với tất cả những gì đã học được, chúng tôi có thể tóm tắt quy trình theo các bước sau:

  1. Hoàn tác tất cả các Source Build patches đã áp dụng trong VMR.
  2. Xác định tập hợp các commit đại diện cho cây repository.
  3. Với mỗi repository cần cập nhật:
    1. Hoàn tác nội dung được ánh xạ bổ sung đến từ repository này.
    2. Tạo một patch trong repository gốc giữa commit đồng bộ cuối và commit mới:
      • Phạm vi commit bằng commit đã đồng bộ trước đó (từ tệp manifest) và commit đang đồng bộ hiện tại.
      • Tuân theo các quy tắc loại trừ.
      • Bỏ qua các thay đổi submodule.
      • Chia nhỏ patch nếu quá lớn.
    3. Áp dụng patch vào thư mục con của repository trong VMR.
    4. Kiểm tra các submodule của repository:
      1. Tạo patch cho các thay đổi trong mỗi submodule theo cùng mẫu như trên (đệ quy).
      2. Áp dụng các patch này vào các thư mục submodule tương ứng trong VMR.
    5. Áp dụng các thay đổi của nội dung được ánh xạ bổ sung đến từ repository này.
      • Chúng tôi lại tạo và áp dụng các patch cho các đường dẫn và phạm vi commit nhất định.
    6. Cập nhật thông tin theo dõi trong tệp manifest.
  4. Áp dụng các Source Build patches lên trên nội dung đã đồng bộ.

Tốt, giờ chúng ta đã có thể di chuyển các thay đổi từ một product repository vào VMR.

Đồng bộ hai chiều

Mục tiêu cuối cùng của toàn bộ nỗ lực này là đồng bộ hai chiều giữa VMR và các product repository. Như đã đề cập, điều này không còn được thực hiện bằng một pipeline đẩy trực tiếp các thay đổi vào VMR. Thay vào đó, dịch vụ dòng chảy mã của chúng tôi sẽ tạo ra cái gọi là “code flow pull requests” mang theo các thay đổi. Khi tạo các nhánh pull request, chúng tôi sẽ bám theo vũ khí được chọn để di chuyển thay đổi – các patches.

Mặc dù các khối xây dựng cơ bản của quy trình vẫn giữ nguyên, toàn bộ vấn đề trở nên phức tạp hơn đáng kể. Ngoài việc đảm bảo các thay đổi đúng đắn hiện diện đúng cách ở phía bên kia, chúng tôi còn phải tính đến các dòng chảy diễn ra song song, thường với tần suất khác nhau. Nói cách khác, rất có thể các thay đổi tiếp tục chảy theo một hướng với nhịp hàng ngày trong khi pull request theo hướng ngược lại bị chặn bởi một lỗi build tích hợp và phải mất nhiều ngày, thậm chí nhiều tuần mới được merge. Thuật toán dòng chảy mã phải hiểu được các tình huống này và đảm bảo xung đột chỉ xuất hiện khi thực sự có các thay đổi xung đột được tạo ra.

Việc đúng đắn cũng không phải là tất cả. Các pull request cũng phải truyền đạt những thay đổi nào được bao gồm, chúng đến từ đâu, và cố gắng đưa ra chỉ dẫn khi cần giải quyết xung đột. Khi hàng triệu dòng mã chảy qua hàng chục repository mỗi tháng, sự hỗn loạn rất dễ xuất hiện và các nhà phát triển phải được trang bị công cụ và thông tin phù hợp để có thể đưa ra quyết định đúng đắn và kiểm soát được mọi thứ. Cuối cùng, chúng tôi cũng cần hiểu trạng thái tổng thể của hệ thống để xác định các gián đoạn trong dòng chảy, các PR tồn tại lâu, và các điểm nghẽn tiềm năng khác. Trải nghiệm nhà phát triển và khả năng quan sát (observability) là rất quan trọng để chúng tôi duy trì một hệ thống khỏe mạnh.

Tương tự như trước, chúng tôi đã phát triển một thuật toán tùy chỉnh mà chúng tôi lặp đi lặp lại. Chúng tôi sẽ đi qua quá trình tiến hóa này để minh họa rõ hơn những bài học của mình. Hy vọng một số trong đó có thể hữu ích cho bất kỳ ai đang cố giải quyết một vấn đề tương tự.

Thuật ngữ

Hãy xem một số thuật ngữ chúng tôi sẽ dùng trong phần còn lại của bài:

  • Source/Product repository – Một trong các repository phát triển hiện tại, ví dụ dotnet/runtime. Không phải VMR.
  • Forward flow – Quy trình di chuyển các thay đổi từ một product repository sang VMR.
  • Backflow – Quy trình di chuyển các thay đổi từ VMR sang một product repository.
  • Code flow – Quy trình di chuyển các thay đổi giữa VMR và các product repository. Đây là thuật ngữ chung có thể chỉ cả forward flow và backflow.
  • Code flow PR – Một pull request mang theo các thay đổi mã, được mở như một phần của quy trình code flow. Đây có thể là một forward flow PR hoặc backflow PR.

Two-way Code Flow v1

Phiên bản đầu tiên của thuật toán code flow được thiết kế với mục tiêu rằng mỗi khi cần đưa thay đổi, chúng tôi phải có thể tạo ra một pull request nào đó trong repository đích. Pull request này phải chứa các thay đổi mong muốn nhưng có thể xung đột với nhánh đích. Chúng tôi sẽ cho thấy sau này chúng tôi nhận ra đây là một ngôi sao dẫn đường sai lầm vì nó đưa vào một số vấn đề thú vị.

Bản tóm tắt cách thuật toán hoạt động: chúng tôi theo dõi các dòng chảy cuối cùng giữa hai phía bằng metadata theo dõi được mô tả ở trên. Sau đó, chúng tôi tìm vị trí (commit) phù hợp để tạo nhánh PR trong repository đích, hiện thực hóa các thay đổi lên trên nó và mở một pull request. Chúng tôi phải đảm bảo với bạn rằng nếu có các thay đổi xung đột giữa hai phía, chúng cũng hiện diện trong PR. Điều này có nghĩa là nhánh PR phải dựa trên một commit đủ cũ để đưa cả commit từ nguồn lẫn thay đổi trong nhánh đích vào trạng thái xung đột.

Phiên bản đầu tiên của thuật toán code flow được dùng để phát hành hầu hết các bản preview .NET 10 cũng như bản phát hành 10.0. Thuật toán xem xét hướng của dòng chảy trước đó và áp dụng các chiến lược khác nhau dựa trên đó. Về mặt kỹ thuật, có bốn kịch bản cần xem xét (tiến-tiến, tiến-lùi, lùi-tiến, lùi-lùi) nhưng hai kịch bản sau là đối xứng nên chúng tôi sẽ không thảo luận riêng.

Các dòng chảy theo hướng ngược nhau

Hãy xem kịch bản phức tạp hơn trong hai kịch bản đầu – khi chúng ta có hai dòng chảy theo hướng ngược nhau. Các sơ đồ trong phần này dùng ký hiệu sau:

  • 🟠 Cam – Các biến đổi nội dung tệp. Một tệp bắt đầu với nội dung A, và B -> C nghĩa là một commit đã thay đổi nội dung từ B thành C.
  • 🟢 Xanh lá – Dòng chảy thành công trước đó. Cho thấy commit nào đang được đưa đi (nét đứt) và nhánh PR ở phía bên kia sẽ hình thành như thế nào (nét liền).
  • 🔵 Xanh dương – Dòng chảy hiện tại đang được thảo luận.
  • 🟣 Tím – Diff đang được mang sang repository đối ứng.
  • ⚫ Xám – Các commit không liên quan, không ảnh hưởng đến tệp đang theo dõi.
  • Các commit được đánh số theo thứ tự thời gian. Điểm 1 và 2 thường biểu thị một số điểm đồng bộ trước đó.

Một sơ đồ code flow cho thấy hai dòng chảy liên tiếp giữa một repository và VMR, mỗi dòng theo một hướng khác nhau.

Dòng chảy các thay đổi trong sơ đồ như sau:

  • 1 và 2 biểu thị một số điểm đồng bộ trước đó.
  • 4 Commit trong VMR thay đổi nội dung của A thành B.
  • 5 Một backflow bắt đầu tại điểm đó.
  • 6 Một nhánh backflow (xanh lá) được tạo trong repository. Nhánh này dựa trên commit của lần đồng bộ cuối (1). Cách dòng chảy này được tạo không phải là chủ đề của sơ đồ này. Ở đây, chúng ta quan tâm đến dòng chảy tiếp theo. Một PR từ nhánh này được mở.
  • 7 Backflow PR được merge, cập nhật hiệu quả A từ A thành B trong nhánh chính của repository.
  • 8 Một commit được tạo trong repository, thay đổi nội dung từ B thành C.
  • 9 Một commit không liên quan được tạo trong VMR.
  • 10 Một forward flow bắt đầu tại điểm đó.
  • 11 Một nhánh forward flow (xanh dương) được tạo trong repository. Nhánh này dựa trên commit của lần đồng bộ cuối (5) làm base commit. Một PR từ nhánh này được mở. Một commit bổ sung được tạo trong forward flow PR, thay đổi nội dung của A thành D.
  • 12 PR được merge, cập nhật hiệu quả A từ B thành D.

Bạn có thể nhận thấy một số đặc điểm:

  • Không có xung đột (git) nào xuất hiện. Điều này là do ví dụ cụ thể này xem xét một tệp duy nhất được thay đổi theo thời gian từ A thành D theo các bước dần dần. Trong các trường hợp hầu hết thay đổi xảy ra trong từng repository riêng lẻ, chúng tôi kỳ vọng mã sẽ chảy một cách trôi chảy.
  • Toàn bộ dòng chảy có thể so sánh với một dev làm việc trong một nhánh dev bên trong một repository duy nhất. Dev sau đó mở một PR nhắm vào nhánh chính (ở đây là repository). Bất cứ nơi nào có xung đột trong trường hợp một repository duy nhất, chúng ta cũng sẽ có xung đột ở đây – và điều này là có chủ đích.

Điều còn lại cần thảo luận là cách chúng tôi tạo commit (11) của nhánh forward flow. Chúng tôi biết rằng mình đã nhận delta từ repository như một phần của commit 7 sau khi backflow PR cuối cùng được merge. Chúng tôi tính đến việc một squash merge đã được dùng và commit 6 có thể không còn tồn tại. Cũng có thể đã có các commit bổ sung trên nhánh backflow PR giữa commit 6 và 7. Tập hợp các thay đổi chúng ta cần đưa đi khi đưa commit 10 về mặt kỹ thuật bao gồm các commit 3, 6, 7, 8 và 10. Về cơ bản, mọi thứ đã xảy ra ở phía repository mà chưa được đưa vào VMR. Nó được trực quan hóa như diff màu tím giữa 10 và 6. Diff này biểu diễn chính xác delta vì:

  • Nó chứa snapshot đã biết cuối cùng của VMR (6)
  • Tất cả các commit đã xảy ra trong VMR trong thời gian đó (kể từ commit cuối) – các commit 3 và 7.
  • Các commit khác đã xảy ra trong VMR kể từ lần đồng bộ 8 và 10.

Base commit của nhánh forward flow sau đó là base commit của backflow cuối cùng vì đó là nơi chúng ta áp dụng delta. Nếu commit 9 có các thay đổi xung đột so với delta, PR sẽ hiển thị các xung đột này và dev sẽ phải giải quyết chúng.

Hai dòng chảy theo cùng một hướng

Tình huống đơn giản hơn một chút khi chúng ta có hai dòng chảy liên tiếp theo cùng một hướng:

Một sơ đồ code flow cho thấy hai dòng chảy liên tiếp từ một repository vào VMR.

Khi tạo commit forward flow (10), chúng ta biết rằng những thứ duy nhất đã xảy ra kể từ lần cuối gửi tất cả các bản cập nhật tới VMR là các commit 9 và 10. Chúng ta có thể chỉ cần áp dụng delta mới này lên trên commit forward flow cuối cùng (8).

Xung đột

Xung đột là tình huống khi cùng một đoạn của cùng một tệp bị thay đổi theo các cách phân kỳ trong repository và VMR cùng một lúc. Khi đó cần có sự can thiệp của con người để quyết định thay đổi nào thắng. Mục tiêu của thuật toán là đảm bảo các xung đột này được đưa ra bề mặt và xử lý trước khi các thay đổi có thể chảy tiếp. Tuy nhiên, chúng tôi sẽ cho thấy cách một xung đột được tạo ra có thể quan trọng như thế nào đối với dòng chảy mã đang diễn ra. Chúng tôi cũng sẽ cho thấy xung đột có thể xuất hiện ngay cả khi không có thay đổi xung đột nào được tạo ra.

Hãy xem xét ví dụ sau, nơi một xung đột được tạo ra bởi một commit phụ trội được thực hiện trong một forward flow PR:

Một sơ đồ code flow cho thấy một xung đột được tạo ra trong một code flow PR.

Trong tình huống này, commit bổ sung được tạo trong forward flow PR đầu tiên (6) xung đột với một commit được tạo trong repository (10). Vì không có backflow, thông tin này không được biết đến ở phía repository. Forward flow tiếp theo trở nên có vấn đề vì các thay đổi 10 và 11 không thể được áp dụng lên trên 8. Trên thực tế, chúng tôi thậm chí không thể tạo bất kỳ commit nào cho nhánh PR! Trong trường hợp đó, điều duy nhất còn lại là đặt base cho nhánh PR trên commit tốt đã biết cuối cùng (2) và tái dựng dòng chảy trước đó (bằng cách áp lại 5, về mặt kỹ thuật là 1, 3 và 4), áp 10 và 11 lên trên và tạo một nhánh PR sẽ xung đột với nhánh đích vì xung đột 6/10. Người dùng sau đó sẽ được hướng dẫn merge nhánh đích (9) vào nhánh PR và giải quyết xung đột. Với các thay đổi trong 5 gần như giống với các thay đổi trong 8, git sẽ khớp chúng một cách minh bạch và chỉ các tệp thực sự xung đột được để lại để giải quyết. Backflow tiếp theo sẽ mang giải pháp này trở lại repository.

Có vô số ví dụ khác về xung đột có thể xảy ra, nhưng chúng thường biểu hiện dưới dạng xung đột trong PR. Ví dụ trên thú vị hơn vì forward flow thậm chí không thể tạo nhánh PR ngay từ đầu. Điều này là do 8 (commit forward flow trước đó) chứa 6 vốn xung đột với 10. Điều quan trọng cần lưu ý là tập hợp các tệp xung đột sẽ không chỉ chứa tệp có vấn đề mà còn cả tệp manifest theo dõi các commit đã đồng bộ cuối cùng. Điều này là do tệp manifest đã được cập nhật trong commit 8, nơi nó chứa SHA của commit 4 trong khi nhánh PR đang cập nhật cùng một dòng thành 11. Chúng tôi không thể giải quyết một phần điều này ngay cả khi biết nội dung mong muốn của source manifest vì git không cho phép giải quyết merge một phần. Điều này có nghĩa là khi một xung đột thực sự buộc chúng tôi rebase về một commit cũ hơn, nó mang theo rắc rối…

Xung đột, xung đột ở khắp mọi nơi

Khi bắt đầu thử nghiệm thuật toán trong thực tế, chúng tôi bắt đầu nhận ra rằng độ phức tạp thực sự nằm ở tính động của vấn đề trong nhịp điệu phát triển thực tế. Các dòng chảy hiếm khi xảy ra theo kiểu bóng bàn qua lại một cách có thể dự đoán. Thay vào đó, chúng ta phải kỳ vọng các dòng chảy diễn ra song song theo cả hai hướng, với tần suất riêng, thường mất nhiều thời gian giữa lúc mở và merge.

Hãy xem một kịch bản trong đó một tệp duy nhất trải qua một loạt các thay đổi dần dần trong repository nguồn và dù chúng ta không tạo bất kỳ thay đổi xung đột thực sự nào với nó, chúng ta vẫn sẽ thấy xung đột trong code flow PR:

Một sơ đồ code flow minh họa các vấn đề phát sinh trong quá trình thay đổi dần dần của một tệp.

Trong ví dụ này, một tệp trong repository đã có nội dung thay đổi dần dần từ A thành B rồi thành C. Không có thay đổi nào được thực hiện với nó trong VMR. Một forward flow 🔵 và một backflow 🟢 bắt đầu song song và mỗi cái đã được merge thành công. Forward flow 🔵 đã cập nhật thành công tệp trong VMR từ A thành B. Bây giờ hãy nhìn forward flow thứ hai 🔴, về mặt kỹ thuật sẽ mang thay đổi từ B thành C. Forward flow thứ hai tuân theo thuật toán của dòng chảy hướng ngược lại được mô tả ở trên và nhánh PR của nó dựa trên commit của backflow cuối cùng 🟢. Điều này có nghĩa là nhánh PR chứa thay đổi từ A thành C. Tuy nhiên, nhánh đích đã chứa thay đổi từ A thành B. Khi chúng ta cố merge PR, git thấy xung đột giữa thay đổi từ A thành C (nhánh PR) và thay đổi từ A thành B (nhánh đích) và chúng ta không thể merge nhánh.

Bạn có thể thấy trong ví dụ này rằng dù không có thay đổi xung đột nào được thực hiện với tệp, cách các dòng chảy xen kẽ nhau vẫn gây ra xung đột. Để giải quyết, chúng ta có thể dùng thông tin về cả hai dòng chảy trước đó – không chỉ dòng cuối cùng – merge nhánh đích vào nhánh PR và giải quyết các xung đột một cách lập trình vì chúng ta hiểu trạng thái cuối mong muốn của các tệp. Tuy nhiên, điều này chỉ hoạt động khi không có thay đổi bổ sung nào đối với tệp mà chúng ta không kỳ vọng. Hơn nữa, khi một xung đột thực sự được đưa vào hỗn hợp, việc merge các nhánh lại trở nên bất khả thi. Điều đó cũng có nghĩa là người dùng sẽ không chỉ phải xử lý xung đột thực sự mà còn phải xử lý một xung đột trong tệp thay đổi dần dần – thứ lẽ ra không nên là xung đột ngay từ đầu! Một tệp như vậy là source manifest, thay đổi trong mỗi dòng chảy và luôn kết thúc trong xung đột trong tình huống này. Nhà phát triển sau đó phải tự giải quyết xung đột trong dữ liệu theo dõi – điều không lý tưởng vì nó có thể dễ dàng dẫn đến thảm họa.

Những tình huống này không hiếm. Các quy trình làm việc giới thiệu các tệp mới rồi nhanh chóng sửa đổi chúng trong một PR tiếp theo là phổ biến (ví dụ localization).

Vấn đề Revert

Vấn đề được mô tả ở trên, dù có thể xử lý được, đã chỉ ra một số giới hạn cố hữu của cách tiếp cận. Giọt nước tràn ly khiến chúng tôi suy nghĩ lại một số mục tiêu của mình là cái gọi là vấn đề revert.

Ý tưởng tương tự như kịch bản đã mô tả trước đó nhưng thay vì thay đổi dần dần một tệp, chúng ta thực hiện một thay đổi với nó rồi sau đó revert thay đổi này. Nếu chúng ta đưa thay đổi đi tách rời khỏi revert, và dòng chảy thứ hai với revert cũng kết thúc trong xung đột, cơn bão hoàn hảo này ngăn chúng ta merge các nhánh một cách tự động và chúng ta sẽ mất hoàn toàn revert!

Một sơ đồ code flow minh họa một vấn đề phát sinh khi một thay đổi tệp bị revert.

Trong ví dụ này, chúng ta có thể quan sát những điều sau:

  • Tệp B được thêm vào 🟢 rồi bị xóa (revert) 🔴, trong khi tệp A nhận các thay đổi xung đột không liên quan 🟠.
  • Nhánh forward flow PR chứa cả ba thay đổi, trong đó revert phủ định thay đổi gốc, biểu hiện như tệp B không thay đổi gì cả.
  • Xung đột trong tệp A ngăn chúng ta đặt base cho nhánh PR trên commit 8 (backflow cuối cùng) và chúng ta phải đặt base trên commit 2, đồng thời tái tạo dòng chảy trước đó.
  • Vì nhánh PR được rebase mới về mặt kỹ thuật không chứa bất kỳ thay đổi nào với tệp B (thay đổi và revert của nó triệt tiêu lẫn nhau), khi merge PR, revert bị mất hoàn toàn và tệp B vẫn ở lại trong VMR trong khi nó đã bị xóa khỏi repository gốc.

Hơn nữa, điều này không chỉ áp dụng cho việc revert toàn bộ tệp mà còn cho bất kỳ thay đổi nào, dù nhỏ, sau đó bị revert lại. Đáng sợ!

Dù phải có nhiều điều kiện khớp nhau, điều này vẫn có thể xảy ra trong thực tế. Cụ thể, chúng tôi đã thấy điều này trong các trường hợp một workaround tạm thời hoặc feature flag được đưa vào mã rồi sau đó bị gỡ bỏ. Nó cũng xảy ra khi các PR bị revert toàn bộ trong một repository bận rộn, nơi các xung đột thực sự có thể xảy ra thường xuyên. Dù sao đi nữa, việc mất các thay đổi một cách âm thầm như thế này là không thể chấp nhận được. Quay lại bàn vẽ!

Thay đổi cách tiếp cận của chúng tôi

Vào thời điểm này, chúng tôi đã cạn kiệt mọi khả năng mà việc branching và merging đơn thuần mang lại. Với mọi workaround và ý tưởng chúng tôi nghĩ ra, luôn có một phản ví dụ phá vỡ nó!

Thay đổi sân chơi

Điều này khiến chúng tôi xem xét lại mục tiêu luôn có thể tạo một PR với một số nội dung trong repository đích. Vì không thể giải quyết xung đột một phần, việc giải quyết phải diễn ra trong một môi trường khác với UI GitHub PR. Liệu đó có thể là máy cục bộ của dev không? Chúng ta có thể để người dùng chạy một lệnh thực hiện dòng chảy cục bộ, đưa repository cục bộ vào trạng thái xung đột, giải quyết các (không-)xung đột đã biết như các thay đổi source manifest, và để người dùng xử lý các vấn đề thực sự không?

Cũng là một trò chơi khác

Một nguyên tắc thiết kế khác chúng tôi cố gắng tuân theo là so sánh giải pháp hai-repository của chúng tôi khác với việc chỉ có một repository duy nhất với nhiều nhánh như thế nào. Các dòng chảy và thao tác git của chúng tôi khác gì so với một ngày làm việc bình thường trong một nhánh feature dẫn tới một feature PR? Mọi người xử lý xung đột theo cách nào khác? Và khi họ làm vậy, quy trình trông như thế nào? Điều này dẫn chúng tôi đến việc khám phá một cách tiếp cận hoàn toàn khác. Chúng ta đang nói về quy trình git rebase.

Hãy Tương tác

Khi kết hợp các ý tưởng trên, chúng tôi đi đến một trải nghiệm code flow mới mang tính tương tác hơn và dựa vào sự can thiệp khác của người dùng khi xung đột phát sinh. Quy trình mới trông không khác biệt mấy so với một quy trình git rebase thông thường:

  • Dịch vụ code flow vẫn tính toán các thay đổi giống như trước – xét đến các dòng chảy trước đó, dựng một nhánh dựa trên dòng chảy cuối cùng, v.v.
  • Chúng tôi sau đó thử rebase nhánh PR lên đỉnh của nhánh đích. Điều này thất bại khi có xung đột và để repository ở trạng thái xung đột.
  • Khi không có xung đột, rebase được commit và đẩy vào một PR mới và chúng ta xong việc.
  • Trong trường hợp xung đột, dịch vụ code flow không thể tiếp tục. Thay vào đó, nó mở một PR trống và hướng dẫn người dùng thực hiện dòng chảy cục bộ bằng công cụ tùy chỉnh.
  • Dịch vụ sau đó chặn việc merge PR bằng một status check tùy chỉnh cho đến khi nó thấy các thay đổi mong muốn được đẩy lên nhánh PR.
  • Công cụ tùy chỉnh lấy thông tin cần thiết và thực hiện cùng một code flow cục bộ.
  • Khi xung đột xảy ra, nó giải quyết mọi xung đột đã biết – vốn có thể là kết quả của các dòng chảy diễn ra song song theo cả hai hướng như đã thảo luận ở trên. Sau đó nó để người dùng giải quyết các xung đột thực sự.
  • Người dùng sau đó commit và đẩy các thay đổi.
  • Cuối cùng, dịch vụ xác thực nội dung được đẩy và gỡ chặn PR để merge (nó bật đèn xanh cho một status check tùy chỉnh).
  • PR này là một ví dụ về PR như vậy trông ra sao.

Việc chúng ta có thể giải quyết các xung đột đã biết một phần trong khi để lại các xung đột thực sự cho người dùng chính là chìa khóa thành công ở đây. Chúng tôi không còn gặp phải vấn đề revert nữa vì chúng ta có thể tính toán các delta một cách chính xác, sử dụng mọi thông tin về các dòng chảy trước đó. Nhưng vì chúng ta đã ở trên đỉnh của nhánh đích, chúng ta cũng có thể sửa bất kỳ revert bị thiếu nào. Chúng ta có thể phát hiện các revert bị thiếu bằng cách thử áp ngược (reverse-apply) thay đổi đã đưa đi cuối cùng và xem việc áp dụng thất bại cho những tệp nào. Điều đó chỉ có thể xảy ra khi chúng thiếu một thay đổi.

Chúng tôi đã triển khai trải nghiệm mới này vào tháng 12 và cho đến nay, chúng tôi đã có trải nghiệm tốt hơn nhiều với nó. Vẫn còn một cải tiến thử nghiệm mà chúng tôi đang xem xét, trong đó chúng tôi sẽ không tạo nhánh làm việc trong repository đích mà thay vào đó áp patch trực tiếp lên trên nhánh đích. Tuy nhiên, điều này không hoạt động ngay trong git vì một số thao tác như sửa đổi các tệp mà git không biết đến (ví dụ tệp bị sửa ở một phía trong khi bị xóa ở phía kia) sẽ làm quy trình áp dụng thất bại. Cho đến nay, nó cho thấy việc hiện thực hóa các thay đổi cần thiết trong một nhánh làm việc của repository đích trước rồi rebase nó lên nhánh đích là dễ hơn, vì git có thể dùng nhiều thông tin hơn từ đồ thị commit khi rebase. Tuy nhiên, nếu chúng tôi giải quyết được các trường hợp không được hỗ trợ, nó sẽ đồng nghĩa với việc đơn giản hóa rất lớn quy trình vì một phần của việc tạo nhánh làm việc là tái dựng dòng chảy trước đó.

Những thách thức hiện tại

Chúng ta đã đi một chặng đường dài, nhưng thật ngây thơ nếu nghĩ rằng chúng ta đã đến đích. Đúng, chúng tôi đã có thể phát hành hơn chục bản phát hành bằng VMR, và đúng, nó đã mang lại trái ngọt như cho phép chúng tôi chốt sớm một bản release build trong khi vẫn có thể chấp nhận các bản sửa vào phút chót muộn hơn nhiều trong quy trình. Đáng ngạc nhiên là ngay cả việc chuyển từ cây phụ thuộc repository sang cấu trúc liên kết phẳng cũng không làm gián đoạn đáng kể quá trình phát triển hàng ngày của .NET 10. Với một số kế hoạch cẩn thận, chúng tôi đã có thể thực hiện bước chuyển chỉ trong vài giờ. Dù vậy, cần phải nói rằng đã có những trục trặc trên đường đi.

Branching & Vòng đời sản phẩm

.NET là một nền tảng khổng lồ và bao gồm nhiều sản phẩm khác nhau, thường phát hành theo nhịp riêng của chúng. Visual Studio, Aspire, .NET MAUI, Entity Framework, kể vài cái tên, có các mô hình vòng đời khác nhau và yêu cầu nhịp điệu khác nhau – một nhịp không phải lúc nào cũng khớp với .NET SDK. Tóm lại, có vài nhóm repository chính dựa trên vòng đời sản phẩm của chúng:

  • Theo SDK band – các repository như dotnet/sdk phát hành các biến thể khác nhau cho mỗi SDK band. Chúng sẽ branch giống như chính VMR, ví dụ release/10.0.1xx hoặc release/10.0.2xx.
  • Thành phần dùng chung / runtime – các repository như dotnet/runtime hoặc dotnet/aspnetcore phát hành các thành phần được chia sẻ giữa nhiều SDK band. Chúng thường branch theo từng phiên bản chính, ví dụ release/10.0 hoặc release/11.0.
  • Theo Visual Studio – các repository như dotnet/roslyn phát hành các thành phần gắn chặt với các bản phát hành Visual Studio. Chúng thường branch theo phiên bản Visual Studio, ví dụ release/17.14 hoặc release/dev18.0.

Bạn có thể đọc thêm về chiến lược branching của chúng tôi trong tài liệu VMR SDK Bands documentation.

Những khác biệt trong vòng đời này bắt đầu lộ diện khi các repository cần branch tại các thời điểm khác với VMR, vốn tuân theo mô hình tập trung vào SDK band. Thuật toán code flow được thiết kế chỉ để xử lý việc đồng bộ hai nhánh với nhau. Trong thực tế, điều này có nghĩa là khi một repository cần bắt đầu đồng bộ một nhánh khác với một nhánh VMR nhất định, chúng tôi phải đặt lại nội dung của VMR theo cách thủ công để khớp với repository. Đây là một quy trình phức tạp vì chúng tôi phải đảm bảo không có thay đổi nào được thực hiện trong VMR bị mất trong quá trình này.

Những tình huống như trên đã xảy ra hàng chục lần trong chu kỳ sản phẩm .NET 10 khi chúng tôi đang bận rộn phát hành .NET 10.0.200. Chúng tôi vẫn đang giải quyết vấn đề này, nơi chúng tôi dự định phát hiện các thay đổi trong cấu hình code flow và phát hành một PR đặt lại nội dung tự động nhằm xử lý quá trình chuyển đổi một cách êm thấm.

Chốt các nhánh release

Một tình huống có vấn đề khác có thể xảy ra khi chúng tôi chốt (snap) các nhánh cho release và mỗi product repository chốt tại thời điểm riêng của chúng. Trong quá trình phát triển một phiên bản .NET chính mới (ví dụ .NET 10), mỗi tháng chúng tôi chốt các nhánh phát triển main của mình vào các nhánh preview release tương ứng (ví dụ release/10.0.1xx-previewN). Hãy xem xét trường hợp repository chốt nhánh của nó trước VMR:

Một sơ đồ code flow minh họa các vấn đề phát sinh khi các nhánh bị chốt theo thứ tự sai.

Hãy chú ý cách các commit 3 và 5 được đưa vào VMR và tùy thuộc vào thời điểm mỗi repo chốt nhánh của nó, chúng hoặc có hoặc không có mối quan hệ cha/con. Trong sơ đồ, chúng không liên quan trong repository trong khi chúng là cha/con trong VMR. Điều này rõ ràng là sai vì cả hai đều có thể tạo ra các thay đổi xung đột nhưng hoàn toàn hợp lệ trong bối cảnh lịch sử nhánh riêng của chúng.

Chúng tôi ngăn chặn các tình huống như vậy bằng cách điều khiển việc chốt một cách tập trung, bắt đầu từ VMR. VMR đi trước, sau đó chúng tôi tìm các commit mới nhất trong mỗi product repository nơi việc chốt vẫn còn hợp lý. Sau đó chúng tôi tạo nhánh release ở đó.

Hỏng metadata

Một thách thức khác chúng tôi gặp phải liên quan đến dữ liệu theo dõi đồng bộ. Một thực hành phổ biến trong các product repository là merge các nhánh release với nhau. Ví dụ, các thay đổi thực hiện trong release/10.0.1xx thường có thể được merge vào nhánh release/10.0.2xx band cao hơn. Trong quá trình này, metadata theo dõi, nếu bị ghi đè, có thể trở nên không nhất quán. Nhánh 2xx của repository kết thúc tham chiếu đến một commit VMR đã được đồng bộ với nhánh 1xx.

Chúng tôi hiện đang thử nghiệm dùng git notes như một cơ chế lưu trữ thay thế cho metadata theo dõi. Git notes gắn metadata vào commit mà không sửa đổi chính commit đó, điều này có thể giúp chúng tôi tránh một số vấn đề phát sinh từ việc có metadata như một phần của working tree.

Những gì chưa được đề cập

Bài viết này dài ra rất nhanh và tôi xin lỗi vì điều đó. Dù vậy, vẫn còn nhiều khía cạnh thú vị liên quan xứng đáng được chú ý hơn vì chúng cũng quan trọng cho thành công tổng thể như chính thuật toán đồng bộ:

  • Trải nghiệm nhà phát triển – Tất cả những gì chúng tôi đã làm về UX để giúp các nhà phát triển điều hướng các code flow PR và theo dõi nơi các thay đổi của họ được đồng bộ vào.
  • Giám sát và khả năng quan sát – Cách chúng tôi theo dõi trạng thái và sức khỏe của code flow, phát hiện các dòng chảy bị kẹt, hoặc cảnh báo về các vấn đề trên các repository.
  • Công cụ – Các công cụ tùy chỉnh chúng tôi đã xây dựng để giúp các nhà phát triển thực hiện các code flow cục bộ, giải quyết xung đột và xác thực thay đổi trước khi đẩy.

Hãy cho chúng tôi biết nếu bạn quan tâm đến việc đọc chi tiết hơn về bất kỳ chủ đề nào trong số này.

Kết luận

Nếu bạn đọc đến đây – trước tiên, cảm ơn bạn – và thứ hai, hy vọng bạn đã có được cái nhìn sâu sắc về hành trình của chúng tôi từ một Source Build dựa trên tarball đến một monorepo được đồng bộ hoàn toàn, tất cả trong khi vẫn giữ cho hàng trăm nhà phát triển làm việc hiệu quả trên hàng chục repository, phát hành hàng tháng không gián đoạn. Virtual Monolithic Repository đã trở thành một trụ cột nền tảng của hạ tầng .NET, cho phép chúng tôi hợp nhất và tinh gọn các quy trình build và phát hành trong khi vẫn bảo toàn sự linh hoạt và quyền tự chủ của từng repository và cộng đồng của chúng. Tuy nhiên, những chiến thắng này đi kèm với cái giá là sự phức tạp trong việc đồng bộ các repository. Nếu bạn đang bắt đầu một hành trình tương tự, nơi có thể thiết lập hiện tại của chúng tôi chỉ là một bước đệm trên con đường tới một monorepo đầy đủ, chúng tôi hy vọng những kinh nghiệm và bài học của chúng tôi sẽ hữu ích. Đừng ngần ngại liên hệ với chúng tôi, đây là một vấn đề ngách và chúng tôi rất vui được trao đổi!

Tài nguyên