Cơ sở mã Bazel

Báo cáo vấn đề Xem nguồn Nightly · 7.4 . 7.3 · 7.2 · 7.1 · 7.0 · 6.5

Tài liệu này mô tả cơ sở mã và cách cấu trúc Bazel. Ứng dụng này dành cho những người sẵn sàng đóng góp cho Bazel, không phải dành cho người dùng cuối.

Giới thiệu

Cơ sở mã của Bazel rất lớn (~350KLOC mã sản xuất và ~260 KLOC mã kiểm thử) và không ai quen thuộc với toàn bộ khung cảnh: mọi người đều biết rất rõ về thung lũng cụ thể của mình, nhưng ít người biết những gì nằm trên các ngọn đồi theo mọi hướng.

Để những người đang ở giữa hành trình không bị lạc trong một khu rừng tối tăm với lối đi thẳng bị mất, tài liệu này cố gắng cung cấp thông tin tổng quan về cơ sở mã để dễ dàng bắt đầu làm việc với cơ sở mã đó.

Phiên bản công khai của mã nguồn Bazel nằm trên GitHub tại github.com/bazelbuild/bazel. Đây không phải là "nguồn đáng tin cậy"; mà là nguồn lấy từ cây nguồn nội bộ của Google, chứa chức năng bổ sung không hữu ích bên ngoài Google. Mục tiêu dài hạn là biến GitHub trở thành nguồn đáng tin cậy.

Các khoản đóng góp được chấp nhận thông qua cơ chế yêu cầu lấy dữ liệu GitHub thông thường và được nhân viên của Google nhập thủ công vào cây nguồn nội bộ, sau đó xuất lại trở lại GitHub.

Cấu trúc máy khách/máy chủ

Phần lớn Bazel nằm trong một quy trình máy chủ lưu trữ trong RAM giữa các bản dựng. Điều này cho phép Bazel duy trì trạng thái giữa các bản dựng.

Đây là lý do dòng lệnh Bazel có hai loại tuỳ chọn: khởi động và lệnh. Trong dòng lệnh như sau:

    bazel --host_jvm_args=-Xmx8G build -c opt //foo:bar

Một số tuỳ chọn (--host_jvm_args=) đứng trước tên của lệnh cần chạy và một số tuỳ chọn ở sau (-c opt); loại tuỳ chọn trước được gọi là "tuỳ chọn khởi động" và ảnh hưởng đến tổng thể quy trình máy chủ, trong khi loại sau (tuỳ chọn lệnh) chỉ ảnh hưởng đến một lệnh duy nhất.

Mỗi thực thể máy chủ có một không gian làm việc được liên kết (bộ sưu tập các cây nguồn được gọi là "kho lưu trữ") và mỗi không gian làm việc thường có một thực thể máy chủ đang hoạt động. Bạn có thể tránh né điều này bằng cách chỉ định cơ sở đầu ra tuỳ chỉnh (xem phần "Bố cục thư mục" để biết thêm thông tin).

Bazel được phân phối dưới dạng một tệp thực thi ELF duy nhất, đồng thời cũng là một tệp .zip hợp lệ. Khi bạn nhập bazel, tệp thực thi ELF ở trên được triển khai trong C++ ("ứng dụng") sẽ có quyền kiểm soát. Bot này thiết lập một quy trình máy chủ phù hợp bằng cách làm theo các bước sau:

  1. Kiểm tra xem tệp đó đã tự giải nén hay chưa. Nếu không, ứng dụng sẽ thực hiện việc đó. Đây là nơi triển khai máy chủ.
  2. Kiểm tra xem có phiên bản máy chủ nào đang hoạt động hay không: phiên bản đó đang chạy, có các tuỳ chọn khởi động phù hợp và sử dụng đúng thư mục không gian làm việc. API này tìm máy chủ đang chạy bằng cách xem thư mục $OUTPUT_BASE/server, nơi có tệp khoá với cổng mà máy chủ đang theo dõi.
  3. Nếu cần, hãy chấm dứt quy trình máy chủ cũ
  4. Khởi động một quy trình máy chủ mới nếu cần

Sau khi một quy trình máy chủ phù hợp đã sẵn sàng, lệnh cần chạy sẽ được giao tiếp với quy trình đó qua giao diện gRPC, sau đó đầu ra của Bazel sẽ được chuyển về thiết bị đầu cuối. Chỉ có thể chạy một lệnh cùng một lúc. Việc này được triển khai bằng cách sử dụng một cơ chế khoá phức tạp với các phần trong C++ và các phần trong Java. Có một số cơ sở hạ tầng để chạy song song nhiều lệnh, vì việc không thể chạy bazel version song song với một lệnh khác là điều hơi đáng xấu hổ. Vấn đề chính là vòng đời của BlazeModule và một số trạng thái trong BlazeRuntime.

Khi kết thúc một lệnh, máy chủ Bazel sẽ truyền mã thoát mà ứng dụng cần trả về. Một điểm thú vị là việc triển khai bazel run: nhiệm vụ của lệnh này là chạy một nội dung nào đó mà Bazel vừa tạo, nhưng không thể thực hiện việc đó từ quy trình máy chủ vì không có thiết bị đầu cuối. Thay vào đó, ứng dụng sẽ cho ứng dụng biết tệp nhị phân nào cần exec() và với đối số nào.

Khi người dùng nhấn tổ hợp phím Ctrl-C, ứng dụng sẽ dịch tổ hợp phím này thành lệnh Huỷ trên kết nối gRPC. Lệnh này sẽ cố gắng chấm dứt lệnh càng sớm càng tốt. Sau lần nhấn tổ hợp phím Ctrl-C thứ ba, ứng dụng sẽ gửi SIGKILL đến máy chủ.

Mã nguồn của ứng dụng nằm trong src/main/cpp và giao thức dùng để giao tiếp với máy chủ nằm trong src/main/protobuf/command_server.proto .

Điểm truy cập chính của máy chủ là BlazeRuntime.main() và các lệnh gọi gRPC từ ứng dụng được GrpcServerImpl.run() xử lý.

Bố cục thư mục

Bazel tạo một nhóm thư mục hơi phức tạp trong quá trình tạo bản dựng. Bạn có thể xem nội dung mô tả đầy đủ trong phần Bố cục thư mục đầu ra.

"Kho lưu trữ chính" là cây nguồn mà Bazel chạy trong đó. Nó thường tương ứng với nội dung bạn đã xem từ chế độ kiểm soát nguồn. Thư mục gốc của thư mục này được gọi là "gốc không gian làm việc".

Bazel đặt tất cả dữ liệu của mình trong "thư mục gốc của người dùng đầu ra". Giá trị này thường là $HOME/.cache/bazel/_bazel_${USER}, nhưng có thể được ghi đè bằng tuỳ chọn khởi động --output_user_root.

"Cơ sở cài đặt" là nơi Bazel được trích xuất. Việc này được thực hiện tự động và mỗi phiên bản Bazel sẽ có một thư mục con dựa trên tổng kiểm của phiên bản đó trong cơ sở cài đặt. Theo mặc định, giá trị này là $OUTPUT_USER_ROOT/install và có thể được thay đổi bằng cách sử dụng tuỳ chọn dòng lệnh --install_base.

"Cơ sở đầu ra" là nơi thực thể Bazel đính kèm vào một không gian làm việc cụ thể ghi vào. Mỗi cơ sở đầu ra có tối đa một phiên bản máy chủ Bazel chạy bất cứ lúc nào. Tệp này thường nằm ở $OUTPUT_USER_ROOT/<checksum of the path to the workspace>. Bạn có thể thay đổi chế độ này bằng cách sử dụng tuỳ chọn khởi động --output_base. Ngoài ra, tuỳ chọn này còn hữu ích để khắc phục hạn chế chỉ có thể chạy một thực thể Bazel trong bất kỳ không gian làm việc nào tại một thời điểm nhất định.

Thư mục đầu ra chứa, trong số những thứ khác:

  • Các kho lưu trữ bên ngoài được tìm nạp tại $OUTPUT_BASE/external.
  • Thư mục gốc thực thi, một thư mục chứa các đường liên kết tượng trưng đến tất cả mã nguồn cho bản dựng hiện tại. Tệp này nằm ở $OUTPUT_BASE/execroot. Trong quá trình dựng, thư mục đang hoạt động là $EXECROOT/<name of main repository>. Chúng tôi dự định thay đổi giá trị này thành $EXECROOT, mặc dù đây là kế hoạch dài hạn vì đây là thay đổi không tương thích.
  • Tệp được tạo trong quá trình tạo bản dựng.

Quy trình thực thi một lệnh

Sau khi máy chủ Bazel có quyền kiểm soát và được thông báo về một lệnh cần thực thi, trình tự sự kiện sau đây sẽ diễn ra:

  1. BlazeCommandDispatcher được thông báo về yêu cầu mới. Lệnh này quyết định xem lệnh có cần không gian làm việc để chạy hay không (hầu hết mọi lệnh ngoại trừ những lệnh không liên quan gì đến mã nguồn, chẳng hạn như phiên bản hoặc trợ giúp) và liệu một lệnh khác có đang chạy hay không.

  2. Đã tìm thấy lệnh phù hợp. Mỗi lệnh phải triển khai giao diện BlazeCommand và phải có chú thích @Command (đây là một chút mẫu ngược, sẽ rất tuyệt nếu tất cả siêu dữ liệu mà một lệnh cần được mô tả bằng các phương thức trên BlazeCommand)

  3. Các tuỳ chọn dòng lệnh được phân tích cú pháp. Mỗi lệnh có các tuỳ chọn dòng lệnh khác nhau, được mô tả trong chú thích @Command.

  4. Một xe buýt sự kiện đã được tạo. Xe buýt sự kiện là một luồng cho các sự kiện xảy ra trong quá trình xây dựng. Một số trong số này được xuất ra bên ngoài Bazel theo mục đích của Giao thức sự kiện bản dựng để cho mọi người biết quá trình xây dựng diễn ra như thế nào.

  5. Lệnh này sẽ có quyền kiểm soát. Các lệnh thú vị nhất là những lệnh chạy bản dựng: bản dựng, kiểm thử, chạy, mức độ phù hợp, v.v.: chức năng này do BuildTool triển khai.

  6. Tập hợp các mẫu mục tiêu trên dòng lệnh được phân tích cú pháp và các ký tự đại diện như //pkg:all//pkg/... sẽ được phân giải. Việc này được triển khai trong AnalysisPhaseRunner.evaluateTargetPatterns() và được tái hiện trong Skyframe dưới dạng TargetPatternPhaseValue.

  7. Giai đoạn tải/phân tích được chạy để tạo biểu đồ hành động (một biểu đồ không tuần hoàn có hướng của các lệnh cần thực thi cho bản dựng).

  8. Giai đoạn thực thi được chạy. Điều này có nghĩa là chạy mọi hành động cần thiết để tạo các mục tiêu cấp cao nhất được yêu cầu.

Tuỳ chọn dòng lệnh

Các tuỳ chọn dòng lệnh cho lệnh gọi Bazel được mô tả trong đối tượng OptionsParsingResult, đối tượng này chứa bản đồ từ "lớp tuỳ chọn" đến các giá trị của tuỳ chọn. "Lớp tuỳ chọn" là một lớp con của OptionsBase và nhóm các tuỳ chọn dòng lệnh lại với nhau có liên quan đến nhau. Ví dụ:

  1. Các tuỳ chọn liên quan đến ngôn ngữ lập trình (CppOptions hoặc JavaOptions). Đây sẽ là một lớp con của FragmentOptions và cuối cùng được gói vào một đối tượng BuildOptions.
  2. Các tuỳ chọn liên quan đến cách Bazel thực thi các hành động (ExecutionOptions)

Các tuỳ chọn này được thiết kế để sử dụng trong giai đoạn phân tích và (thông qua RuleContext.getFragment() trong Java hoặc ctx.fragments trong Starlark). Một số trong số đó (ví dụ: có nên thực hiện việc quét C++ hay không) được đọc trong giai đoạn thực thi, nhưng điều đó luôn đòi hỏi phải có quy trình rõ ràng vì BuildConfiguration không có sẵn. Để biết thêm thông tin, hãy xem phần "Cấu hình".

CẢNH BÁO: Chúng ta muốn giả định rằng các thực thể OptionsBase là không thể thay đổi và sử dụng các thực thể đó theo cách đó (chẳng hạn như một phần của SkyKeys). Đây không phải là trường hợp và việc sửa đổi các thực thể đó là một cách rất hiệu quả để phá vỡ Bazel theo những cách tinh vi khó gỡ lỗi. Rất tiếc, việc thực sự làm cho các giá trị này không thể thay đổi là một nhiệm vụ lớn. (Bạn có thể sửa đổi FragmentOptions ngay sau khi tạo trước khi bất kỳ ai khác có cơ hội giữ lại tệp tham chiếu đến FragmentOptions và trước khi equals() hoặc hashCode() được gọi.)

Bazel tìm hiểu về các lớp tuỳ chọn theo những cách sau:

  1. Một số được tích hợp sẵn vào Bazel (CommonCommandOptions)
  2. Từ chú giải @Command trên mỗi lệnh Bazel
  3. Từ ConfiguredRuleClassProvider (đây là các tuỳ chọn dòng lệnh liên quan đến từng ngôn ngữ lập trình)
  4. Các quy tắc Starlark cũng có thể xác định các tuỳ chọn riêng (xem tại đây)

Mỗi tuỳ chọn (ngoại trừ các tuỳ chọn do Starlark xác định) là một biến thành viên của lớp con FragmentOptions có chú thích @Option. Chú thích này chỉ định tên và loại tuỳ chọn dòng lệnh cùng với một số văn bản trợ giúp.

Loại Java của giá trị tuỳ chọn dòng lệnh thường là một giá trị đơn giản (chuỗi, số nguyên, Boolean, nhãn, v.v.). Tuy nhiên, chúng tôi cũng hỗ trợ các tuỳ chọn của các loại phức tạp hơn; trong trường hợp này, việc chuyển đổi từ chuỗi dòng lệnh sang loại dữ liệu sẽ thuộc về việc triển khai com.google.devtools.common.options.Converter.

Cây nguồn mà Bazel nhìn thấy

Bazel chuyên về việc xây dựng phần mềm, việc này diễn ra bằng cách đọc và diễn giải mã nguồn. Toàn bộ mã nguồn mà Bazel hoạt động được gọi là "không gian làm việc" và được cấu trúc thành các kho lưu trữ, gói và quy tắc.

Kho lưu trữ

"Kho lưu trữ" là một cây nguồn mà nhà phát triển làm việc; thường đại diện cho một dự án duy nhất. Đối tượng cấp trên của Bazel là Blaze, đã vận hành trên một monorepo, tức là một cây nguồn duy nhất chứa tất cả mã nguồn dùng để chạy bản dựng. Ngược lại, Bazel hỗ trợ các dự án có mã nguồn trải dài trên nhiều kho lưu trữ. Kho lưu trữ mà Bazel được gọi là "kho lưu trữ chính", các kho lưu trữ khác được gọi là "kho lưu trữ bên ngoài".

Kho lưu trữ được đánh dấu bằng tệp ranh giới kho lưu trữ (MODULE.bazel, REPO.bazel hoặc trong ngữ cảnh cũ, WORKSPACE hoặc WORKSPACE.bazel) trong thư mục gốc của kho lưu trữ. Kho lưu trữ chính là cây nguồn mà bạn đang gọi Bazel từ đó. Các kho lưu trữ bên ngoài được xác định theo nhiều cách; hãy xem phần tổng quan về phần phụ thuộc bên ngoài để biết thêm thông tin.

Mã của các kho lưu trữ bên ngoài được liên kết tượng trưng hoặc tải xuống trong $OUTPUT_BASE/external.

Khi chạy bản dựng, toàn bộ cây nguồn cần được kết hợp với nhau; việc này được thực hiện bằng SymlinkForest. Thao tác này sẽ liên kết mọi gói trong kho lưu trữ chính với $EXECROOT và mọi kho lưu trữ bên ngoài với $EXECROOT/external hoặc $EXECROOT/...

Gói

Mỗi kho lưu trữ bao gồm các gói, một tập hợp các tệp có liên quan và thông số kỹ thuật của các phần phụ thuộc. Các tệp này được chỉ định bằng một tệp có tên là BUILD hoặc BUILD.bazel. Nếu cả hai đều tồn tại, Bazel ưu tiên BUILD.bazel; lý do khiến các tệp BUILD vẫn được chấp nhận là do đối tượng cấp trên của Bazel là Blaze đã sử dụng tên tệp này. Tuy nhiên, hoá ra đây là một phân đoạn đường dẫn thường dùng, đặc biệt là trên Windows, trong đó tên tệp không phân biệt chữ hoa chữ thường.

Các gói độc lập với nhau: các thay đổi đối với tệp BUILD của một gói không được khiến các gói khác thay đổi. Việc thêm hoặc xoá các tệp BUILD _can _thay đổi các gói khác, vì các khối cầu hồi quy dừng ở ranh giới gói và do đó, sự hiện diện của tệp BUILD sẽ dừng việc đệ quy.

Việc đánh giá tệp BUILD được gọi là "tải gói". Lệnh này được triển khai trong lớp PackageFactory, hoạt động bằng cách gọi trình thông dịch Starlark và yêu cầu bạn phải có kiến thức về tập hợp các lớp quy tắc có sẵn. Kết quả của quá trình tải gói là một đối tượng Package. Đây chủ yếu là một bản đồ từ một chuỗi (tên của mục tiêu) đến chính mục tiêu đó.

Một phần lớn sự phức tạp trong quá trình tải gói là việc gộp: Bazel không yêu cầu mọi tệp nguồn phải được liệt kê rõ ràng mà thay vào đó có thể chạy các tệp glob (chẳng hạn như glob(["**/*.java"])). Không giống như shell, Bazel hỗ trợ các tệp glob đệ quy đi xuống các thư mục con (nhưng không phải vào các gói con). Điều này đòi hỏi quyền truy cập vào hệ thống tệp và vì việc đó có thể chậm, nên chúng tôi sẽ triển khai tất cả các loại thủ thuật để hệ thống chạy song song và hiệu quả nhất có thể.

Globbing được triển khai trong các lớp sau:

  • LegacyGlobber, một globber nhanh và không biết Skyframe
  • SkyframeHybridGlobber, một phiên bản sử dụng Skyframe và quay lại globber cũ để tránh "Khởi động lại Skyframe" (như mô tả bên dưới)

Bản thân lớp Package chứa một số thành viên chỉ dùng để phân tích cú pháp gói "bên ngoài" (liên quan đến các phần phụ thuộc bên ngoài) và không phù hợp với các gói thực. Đây là một lỗi thiết kế vì các đối tượng mô tả gói thông thường không được chứa các trường mô tả nội dung khác. bao gồm:

  • Các mối liên kết kho lưu trữ
  • Chuỗi công cụ đã đăng ký
  • Các nền tảng thực thi đã đăng ký

Lý tưởng nhất là bạn nên phân tách việc phân tích cú pháp gói "bên ngoài" với việc phân tích cú pháp các gói thông thường để Package không cần đáp ứng nhu cầu của cả hai. Rất tiếc, việc này khó thực hiện vì hai khái niệm này liên quan chặt chẽ với nhau.

Nhãn, mục tiêu và quy tắc

Gói bao gồm các mục tiêu, có các loại sau:

  1. Tệp: những nội dung là dữ liệu đầu vào hoặc đầu ra của bản dựng. Theo cách nói tiếng Bazel, chúng tôi gọi chúng là các cấu phần phần mềm (sẽ được thảo luận ở phần khác). Không phải tất cả các tệp được tạo trong quá trình xây dựng đều là mục tiêu; thường thì đầu ra của Bazel không có nhãn liên kết.
  2. Quy tắc: mô tả các bước để lấy kết quả từ dữ liệu đầu vào. Các loại này thường liên kết với một ngôn ngữ lập trình (chẳng hạn như cc_library, java_library hoặc py_library), nhưng có một số loại không phụ thuộc vào ngôn ngữ (chẳng hạn như genrule hoặc filegroup)
  3. Nhóm gói: được thảo luận trong phần Chế độ hiển thị.

Tên của một mục tiêu được gọi là Nhãn. Cú pháp của nhãn là @repo//pac/kage:name, trong đó repo là tên của kho lưu trữ chứa Nhãn, pac/kage là thư mục chứa tệp BUILDname là đường dẫn của tệp (nếu nhãn tham chiếu đến một tệp nguồn) so với thư mục của gói. Khi tham chiếu đến một mục tiêu trên dòng lệnh, bạn có thể bỏ qua một số phần của nhãn:

  1. Nếu bạn bỏ qua kho lưu trữ, nhãn sẽ được đưa vào kho lưu trữ chính.
  2. Nếu phần gói bị bỏ qua (chẳng hạn như name hoặc :name), nhãn sẽ được đưa vào gói của thư mục đang hoạt động hiện tại (không được phép sử dụng đường dẫn tương đối chứa tham chiếu cấp cao (..))

Một loại quy tắc (chẳng hạn như "thư viện C++") được gọi là "lớp quy tắc". Các lớp quy tắc có thể được triển khai trong Starlark (hàm rule()) hoặc trong Java (được gọi là "quy tắc gốc", loại RuleClass). Về lâu dài, mọi quy tắc dành riêng cho ngôn ngữ sẽ được triển khai trong Starlark, nhưng một số bộ quy tắc cũ (chẳng hạn như Java hoặc C++) hiện vẫn còn trong Java.

Các lớp quy tắc Starlark cần được nhập ở đầu tệp BUILD bằng câu lệnh load(), trong khi các lớp quy tắc Java "bẩm sinh" được Baazel biết đến do được đăng ký với ConfiguredRuleClassProvider.

Các lớp quy tắc chứa thông tin như:

  1. Các thuộc tính của lớp đó (chẳng hạn như srcs, deps): loại, giá trị mặc định, quy tắc ràng buộc, v.v.
  2. Các chuyển đổi cấu hình và khía cạnh đính kèm vào từng thuộc tính (nếu có)
  3. Việc triển khai quy tắc
  4. Trình cung cấp thông tin bắc cầu mà quy tắc "thường" tạo ra

Lưu ý về thuật ngữ: Trong cơ sở mã, chúng ta thường sử dụng "Quy tắc" để chỉ mục tiêu do một lớp quy tắc tạo. Nhưng trong Starlark và trong tài liệu dành cho người dùng, "Quy tắc" chỉ nên được sử dụng để tham chiếu đến chính lớp quy tắc đó; mục tiêu chỉ là một "mục tiêu". Ngoài ra, xin lưu ý rằng mặc dù RuleClass có "lớp" trong tên, nhưng không có mối quan hệ kế thừa Java nào giữa lớp quy tắc và mục tiêu thuộc loại đó.

Skyframe

Khung đánh giá cơ bản của Bazel được gọi là Skyframe. Mô hình của công cụ này là mọi thứ cần được tạo trong quá trình xây dựng được sắp xếp thành một biểu đồ không tuần hoàn có hướng với các cạnh trỏ từ bất kỳ phần dữ liệu nào đến các phần phụ thuộc của nó, tức là các phần dữ liệu khác cần được biết để tạo biểu đồ đó.

Các nút trong biểu đồ được gọi là SkyValue và tên của các nút này được gọi là SkyKey. Cả hai đều không thể thay đổi sâu sắc; chỉ có thể tiếp cận các đối tượng bất biến từ chúng. Biến không đổi này hầu như luôn đúng và trong trường hợp không đúng (chẳng hạn như đối với các lớp tuỳ chọn riêng lẻ BuildOptions, là thành viên của BuildConfigurationValueSkyKey của lớp này), chúng ta cố gắng không thay đổi các lớp này hoặc chỉ thay đổi theo những cách không thể quan sát được từ bên ngoài. Do đó, mọi thứ được tính toán trong Skyframe (chẳng hạn như các mục tiêu được định cấu hình) cũng phải không thể thay đổi.

Cách thuận tiện nhất để quan sát biểu đồ Skyframe là chạy bazel dump --skyframe=deps. Thao tác này sẽ kết xuất biểu đồ, mỗi dòng là một SkyValue. Tốt nhất là bạn nên thực hiện việc này cho các bản dựng nhỏ, vì nó có thể khá lớn.

Skyframe nằm trong gói com.google.devtools.build.skyframe. Gói có tên tương tự com.google.devtools.build.lib.skyframe chứa phương thức triển khai Bazel trên Skyframe. Bạn có thể xem thêm thông tin về Skyframe tại đây.

Để đánh giá một SkyKey nhất định thành SkyValue, Skyframe sẽ gọi SkyFunction tương ứng với loại khoá. Trong quá trình đánh giá hàm, hàm có thể yêu cầu các phần phụ thuộc khác từ Skyframe bằng cách gọi nhiều phương thức nạp chồng của SkyFunction.Environment.getValue(). Điều này có tác dụng phụ là đăng ký các phần phụ thuộc đó vào biểu đồ nội bộ của Skyframe, nhờ đó Skyframe sẽ biết đánh giá lại hàm khi có bất kỳ phần phụ thuộc nào thay đổi. Nói cách khác, tính năng lưu vào bộ nhớ đệm và tính toán gia tăng của Skyframe hoạt động ở mức độ chi tiết của SkyFunctionSkyValue.

Bất cứ khi nào SkyFunction yêu cầu một phần phụ thuộc không có sẵn, getValue() sẽ trả về giá trị rỗng. Sau đó, hàm này sẽ trả về quyền kiểm soát cho Skyframe bằng cách tự trả về giá trị rỗng. Tại một thời điểm nào đó sau này, Skyframe sẽ đánh giá phần phụ thuộc không có sẵn, sau đó khởi động lại hàm từ đầu — chỉ lần này lệnh gọi getValue() mới thành công với kết quả không rỗng.

Hậu quả của việc này là mọi phép tính được thực hiện bên trong SkyFunction trước khi khởi động lại đều phải được lặp lại. Tuy nhiên, thời gian này không bao gồm công việc đã thực hiện để đánh giá phần phụ thuộc SkyValues được lưu vào bộ nhớ đệm. Do đó, chúng ta thường giải quyết vấn đề này bằng cách:

  1. Khai báo các phần phụ thuộc theo lô (bằng cách sử dụng getValuesAndExceptions()) để hạn chế số lần khởi động lại.
  2. Chia SkyValue thành các phần riêng biệt được tính toán theo các SkyFunction khác nhau để chúng có thể được tính toán và lưu vào bộ nhớ đệm một cách độc lập. Bạn nên thực hiện việc này một cách có chiến lược vì việc này có thể làm tăng mức sử dụng bộ nhớ.
  3. Lưu trữ trạng thái giữa các lần khởi động lại, sử dụng SkyFunction.Environment.getState() hoặc giữ bộ nhớ đệm tĩnh đặc biệt "ở phía sau Skyframe". Với các SkyFunction phức tạp, việc quản lý trạng thái giữa các lần khởi động lại có thể trở nên phức tạp, vì vậy, chúng tôi đã giới thiệu StateMachine để áp dụng phương pháp có cấu trúc cho tính năng đồng thời logic, bao gồm cả các móc để tạm ngưng và tiếp tục tính toán phân cấp trong SkyFunction. Ví dụ: DependencyResolver#computeDependencies sử dụng StateMachine với getState() để tính toán tập hợp phần phụ thuộc trực tiếp có thể rất lớn của một mục tiêu đã định cấu hình, nếu không thì có thể dẫn đến việc khởi động lại tốn kém.

Về cơ bản, Bazel cần các giải pháp này vì hàng trăm nghìn nút Skyframe đang hoạt động là điều phổ biến và việc Java hỗ trợ các luồng nhẹ không hiệu quả hơn việc triển khai StateMachine kể từ năm 2023.

Starlark

Starlark là ngôn ngữ dành riêng cho từng miền mà mọi người sử dụng để định cấu hình và mở rộng Bazel. Đây được coi là một tập hợp con bị hạn chế của Python có ít loại hơn nhiều, nhiều quy tắc hạn chế hơn về luồng điều khiển và quan trọng nhất là đảm bảo tính bất biến mạnh để cho phép đọc đồng thời. Ngôn ngữ này không hoàn chỉnh theo Turing, điều này khiến một số (nhưng không phải tất cả) người dùng không muốn cố gắng hoàn thành các nhiệm vụ lập trình chung trong ngôn ngữ này.

Starlark được triển khai trong gói net.starlark.java. Công cụ này cũng có cách triển khai Go độc lập tại đây. Hoạt động triển khai Java được dùng trong Bazel hiện đang là một trình thông dịch.

Starlark được dùng trong một số ngữ cảnh, bao gồm:

  1. BUILD tệp. Đây là nơi xác định các mục tiêu bản dựng mới. Mã Starlark chạy trong ngữ cảnh này chỉ có quyền truy cập vào nội dung của chính tệp BUILD và các tệp .bzl do tệp này tải.
  2. Tệp MODULE.bazel. Đây là nơi xác định các phần phụ thuộc bên ngoài. Mã Starlark chạy trong ngữ cảnh này chỉ có quyền truy cập rất hạn chế vào một số lệnh được xác định trước.
  3. .bzl tệp. Đây là nơi xác định các quy tắc xây dựng mới, quy tắc repo, tiện ích mô-đun. Mã Starlark tại đây có thể xác định các hàm mới và tải từ các tệp .bzl khác.

Các phương ngữ có sẵn cho tệp BUILD.bzl có chút khác biệt vì chúng thể hiện những nội dung khác nhau. Bạn có thể xem danh sách các điểm khác biệt tại đây.

Bạn có thể xem thêm thông tin về Starlark tại đây.

Giai đoạn tải/phân tích

Giai đoạn tải/phân tích là nơi Bazel xác định những hành động cần thiết để tạo một quy tắc cụ thể. Đơn vị cơ bản của lớp này là "mục tiêu đã định cấu hình", tức là một cặp (mục tiêu, cấu hình) khá hợp lý.

Đây được gọi là "giai đoạn tải/phân tích" vì giai đoạn này có thể chia thành hai phần riêng biệt, thường được chuyển đổi tuần tự, nhưng giờ đây chúng có thể chồng chéo lên nhau theo thời gian:

  1. Tải gói, tức là chuyển các tệp BUILD thành các đối tượng Package đại diện cho các tệp đó
  2. Phân tích các mục tiêu đã định cấu hình, tức là chạy quá trình triển khai các quy tắc để tạo biểu đồ hành động

Bạn phải phân tích từng mục tiêu đã định cấu hình trong tập hợp đóng bắc cầu của các mục tiêu đã định cấu hình được yêu cầu trên dòng lệnh từ dưới lên; tức là trước tiên là các nút lá, sau đó là các nút trên dòng lệnh. Dữ liệu đầu vào cho bản phân tích của một mục tiêu đã định cấu hình là:

  1. Cấu hình. ("cách" tạo quy tắc đó; ví dụ: nền tảng mục tiêu cũng như những nội dung như tuỳ chọn dòng lệnh mà người dùng muốn được truyền đến trình biên dịch C++)
  2. Các phần phụ thuộc trực tiếp. Các nhà cung cấp thông tin bắc cầu của chúng có sẵn cho quy tắc đang được phân tích. Các tệp này được gọi như vậy vì chúng cung cấp tính năng "tổng hợp" thông tin trong quá trình đóng tạm thời của mục tiêu đã định cấu hình, chẳng hạn như tất cả các tệp .jar trên đường dẫn lớp hoặc tất cả các tệp .o cần được liên kết thành tệp nhị phân C++)
  3. Chính mục tiêu đó. Đây là kết quả của việc tải gói chứa mục tiêu. Đối với các quy tắc, dữ liệu này bao gồm các thuộc tính tương ứng, thường là những gì quan trọng.
  4. Cách triển khai mục tiêu đã định cấu hình. Đối với các quy tắc, bạn có thể sử dụng Starlark hoặc Java. Tất cả mục tiêu đã định cấu hình không có quy tắc đều được triển khai trong Java.

Kết quả phân tích một mục tiêu đã định cấu hình là:

  1. Các trình cung cấp thông tin bắc cầu đã định cấu hình các mục tiêu phụ thuộc vào dịch vụ đó có thể truy cập
  2. Các cấu phần phần mềm mà công cụ này có thể tạo và thao tác tạo ra các cấu phần phần mềm đó.

API được cung cấp cho các quy tắc Java là RuleContext, tương đương với đối số ctx của các quy tắc Starlark. API của nó mạnh mẽ hơn, nhưng đồng thời, bạn cũng dễ dàng làm những việc xấu hơn, chẳng hạn như viết mã có độ phức tạp về thời gian hoặc không gian là bậc hai (hoặc tệ hơn), khiến máy chủ Bazel gặp sự cố với ngoại lệ Java hoặc vi phạm các hằng số không đổi (chẳng hạn như vô tình sửa đổi một thực thể Options hoặc bằng cách làm cho một mục tiêu đã định cấu hình có thể thay đổi)

Thuật toán xác định các phần phụ thuộc trực tiếp của mạng mục tiêu đã định cấu hình trong DependencyResolver.dependentNodeMap().

Cấu hình

Cấu hình là "cách" tạo một mục tiêu: cho nền tảng nào, với tuỳ chọn dòng lệnh nào, v.v.

Bạn có thể tạo cùng một mục tiêu cho nhiều cấu hình trong cùng một bản dựng. Điều này rất hữu ích, ví dụ: khi cùng một mã được dùng cho một công cụ chạy trong quá trình xây dựng và cho mã mục tiêu, đồng thời chúng ta đang biên dịch chéo hoặc khi chúng ta đang tạo một ứng dụng Android lớn (ứng dụng chứa mã gốc cho nhiều kiến trúc CPU)

Về mặt khái niệm, cấu hình là một thực thể BuildOptions. Tuy nhiên, trong thực tế, BuildOptions được gói bằng BuildConfiguration để cung cấp thêm nhiều chức năng khác. Phương thức này truyền từ đầu biểu đồ phần phụ thuộc xuống dưới cùng. Nếu giá trị này thay đổi, bạn cần phân tích lại bản dựng.

Điều này dẫn đến các trường hợp bất thường như phải phân tích lại toàn bộ bản dựng nếu, chẳng hạn như số lần chạy kiểm thử được yêu cầu thay đổi, mặc dù điều đó chỉ ảnh hưởng đến các mục tiêu kiểm thử (chúng tôi dự định "cắt bớt" các cấu hình để không xảy ra trường hợp này, nhưng chưa sẵn sàng).

Khi quá trình triển khai quy tắc cần một phần của cấu hình, việc triển khai quy tắc đó cần phải khai báo phần cấu hình đó trong định nghĩa bằng cách sử dụng RuleClass.Builder.requiresConfigurationFragments(). Điều này vừa giúp tránh sai sót (chẳng hạn như quy tắc Python sử dụng mảnh Java) vừa hỗ trợ việc cắt bớt cấu hình để nếu các tuỳ chọn Python thay đổi, thì bạn không cần phân tích lại mục tiêu C++.

Cấu hình của một quy tắc không nhất thiết phải giống với cấu hình của quy tắc "mẹ". Quá trình thay đổi cấu hình trong một cạnh phần phụ thuộc được gọi là "chuyển đổi cấu hình". Điều này có thể xảy ra ở hai nơi:

  1. Trên cạnh phần phụ thuộc. Các quá trình chuyển đổi này được chỉ định trong Attribute.Builder.cfg() và là các hàm từ Rule (nơi diễn ra quá trình chuyển đổi) và BuildOptions (cấu hình ban đầu) đến một hoặc nhiều BuildOptions (cấu hình đầu ra).
  2. Trên bất kỳ cạnh nào sắp diễn ra đối với một mục tiêu đã định cấu hình. Các giá trị này được chỉ định trong RuleClass.Builder.cfg().

Các lớp có liên quan là TransitionFactoryConfigurationTransition.

Các chuyển đổi cấu hình được sử dụng, ví dụ:

  1. Để khai báo rằng một phần phụ thuộc cụ thể được sử dụng trong quá trình xây dựng và do đó, phần phụ thuộc đó phải được tạo trong cấu trúc thực thi
  2. Để khai báo rằng một phần phụ thuộc cụ thể phải được tạo cho nhiều cấu trúc (chẳng hạn như cho mã gốc trong tệp APK Android lớn)

Nếu một quá trình chuyển đổi cấu hình dẫn đến nhiều cấu hình, thì quá trình đó được gọi là quá trình chuyển đổi phân tách.

Chuyển đổi cấu hình cũng có thể được triển khai trong Starlark (tài liệu tại đây)

Nhà cung cấp thông tin bắc cầu

Nhà cung cấp thông tin bắc cầu là một cách (và là _cách duy nhất_) để các mục tiêu đã định cấu hình tìm hiểu về các mục tiêu đã định cấu hình khác mà chúng phụ thuộc vào, cũng là cách duy nhất để cho các mục tiêu đã định cấu hình khác biết về chính mình khi các mục tiêu đó phụ thuộc vào chúng. Lý do tên của các lớp này có từ "transitive" (mang tính bắc cầu) là vì đây thường là một số loại cuộn lên của phép đóng bắc cầu của một mục tiêu đã định cấu hình.

Thường thì có mối tương ứng 1:1 giữa trình cung cấp thông tin bắc cầu Java và trình cung cấp thông tin bắc cầu Starlark (ngoại lệ là DefaultInfo, là sự kết hợp của FileProvider, FilesToRunProviderRunfilesProvider vì API đó được coi là giống Starlark hơn là bản chuyển tự trực tiếp của API Java). Khoá của chúng là một trong những nội dung sau:

  1. Đối tượng Lớp Java. Tính năng này chỉ dành cho các nhà cung cấp không truy cập được từ Starlark. Các trình cung cấp này là một lớp con của TransitiveInfoProvider.
  2. Một chuỗi. Đây là phương pháp cũ và không được khuyến khích vì nó dễ bị xung đột tên. Những nhà cung cấp thông tin bắc cầu như vậy là các lớp con trực tiếp của build.lib.packages.Info.
  3. Biểu tượng nhà cung cấp. Bạn có thể tạo hàm này từ Starlark bằng hàm provider() và đây là cách được đề xuất để tạo trình cung cấp mới. Biểu tượng này được biểu thị bằng một thực thể Provider.Key trong Java.

Bạn nên triển khai các trình cung cấp mới được triển khai trong Java bằng cách sử dụng BuiltinProvider. NativeProvider không còn được dùng nữa (chúng tôi chưa có thời gian xoá lớp này) và không thể truy cập vào các lớp con TransitiveInfoProvider từ Starlark.

Mục tiêu được định cấu hình

Các mục tiêu đã định cấu hình được triển khai dưới dạng RuleConfiguredTargetFactory. Có một lớp con cho mỗi lớp quy tắc được triển khai trong Java. Các mục tiêu đã định cấu hình của Starlark được tạo thông qua StarlarkRuleConfiguredTargetUtil.buildRule() .

Các nhà máy mục tiêu được định cấu hình phải sử dụng RuleConfiguredTargetBuilder để tạo giá trị trả về. Bao gồm những thành phần sau:

  1. filesToBuild của họ, khái niệm mơ hồ về "nhóm tệp mà quy tắc này đại diện". Đây là những tệp được tạo khi mục tiêu đã định cấu hình nằm trên dòng lệnh hoặc trong srcs của genrule.
  2. Tệp chạy, tệp thường và tệp dữ liệu.
  3. Các nhóm đầu ra. Đây là nhiều "nhóm tệp khác" mà quy tắc có thể tạo. Bạn có thể truy cập vào các tệp này bằng cách sử dụng thuộc tính output_group của quy tắc nhóm tệp trong BUILD và sử dụng trình cung cấp OutputGroupInfo trong Java.

Tệp chạy

Một số tệp nhị phân cần tệp dữ liệu để chạy. Một ví dụ nổi bật là các kiểm thử cần tệp đầu vào. Điều này được thể hiện trong Bazel bằng khái niệm "tệp chạy". "Cây runfiles" là cây thư mục của các tệp dữ liệu cho một tệp nhị phân cụ thể. Công cụ này được tạo trong hệ thống tệp dưới dạng cây liên kết tượng trưng với các đường liên kết tượng trưng riêng lẻ trỏ đến các tệp trong cây nguồn hoặc cây đầu ra.

Một tập hợp tệp chạy được biểu thị dưới dạng một thực thể Runfiles. Về mặt khái niệm, đây là một bản đồ từ đường dẫn của một tệp trong cây runfiles đến thực thể Artifact đại diện cho tệp đó. Chế độ này phức tạp hơn một chút so với một Map vì 2 lý do:

  • Trong hầu hết các trường hợp, đường dẫn runfiles của tệp giống với execpath của tệp đó. Chúng ta dùng tuỳ chọn này để tiết kiệm RAM.
  • Có nhiều loại mục nhập cũ trong cây tệp chạy, các mục nhập này cũng cần được trình bày.

Tệp chạy được thu thập bằng RunfilesProvider: một thực thể của lớp này đại diện cho tệp chạy của một mục tiêu đã định cấu hình (chẳng hạn như thư viện) và các nhu cầu đóng vòng lặp bắc cầu của tệp chạy. Các tệp chạy này được thu thập như một tập hợp lồng nhau (thực tế, các tệp chạy này được triển khai bằng cách sử dụng các tập hợp lồng nhau trong lớp bao bọc): mỗi mục tiêu hợp nhất các tệp chạy của các phần phụ thuộc, thêm một số tệp chạy của riêng mục tiêu đó, sau đó gửi tập hợp kết quả lên trên trong biểu đồ phần phụ thuộc. Một thực thể RunfilesProvider chứa 2 thực thể Runfiles, một là khi quy tắc phụ thuộc vào thuộc tính "data" và một thực thể cho mọi loại phần phụ thuộc sắp tới khác. Điều này là do một mục tiêu đôi khi hiển thị các tệp chạy khác nhau khi phụ thuộc vào một thuộc tính dữ liệu so với các mục tiêu khác. Đây là hành vi cũ không mong muốn mà chúng tôi chưa có cách để loại bỏ.

Tệp chạy của tệp nhị phân được biểu thị dưới dạng một thực thể của RunfilesSupport. Điều này khác với RunfilesRunfilesSupport có khả năng thực sự được tạo (không giống như Runfiles, chỉ là một ánh xạ). Điều này đòi hỏi các thành phần bổ sung sau:

  • Tệp kê khai tệp chạy đầu vào. Đây là nội dung mô tả tuần tự về cây runfiles. Tệp này được dùng làm proxy cho nội dung của cây tệp chạy và Bazel giả định rằng cây tệp chạy sẽ thay đổi nếu và chỉ khi nội dung của tệp kê khai thay đổi.
  • Tệp kê khai tệp chạy đầu ra. Tính năng này được các thư viện thời gian chạy xử lý cây runfile, đặc biệt là trên Windows (đôi khi không hỗ trợ đường liên kết tượng trưng) sử dụng.
  • Người trung gian runfiles. Để cây tệp chạy tồn tại, bạn cần tạo cây đường liên kết tượng trưng và cấu phần phần mềm mà các đường liên kết tượng trưng trỏ đến. Để giảm số lượng cạnh phần phụ thuộc, bạn có thể sử dụng trình trung gian runfiles để biểu thị tất cả các cạnh này.
  • Đối số dòng lệnh để chạy tệp nhị phân mà đối tượng RunfilesSupport đại diện cho tệp chạy.

Khía cạnh

Phương diện là một cách để "truyền tính toán xuống biểu đồ phần phụ thuộc". Chúng được mô tả cho người dùng Bazel tại đây. Một ví dụ điển hình là vùng đệm giao thức: quy tắc proto_library không được biết về bất kỳ ngôn ngữ cụ thể nào, nhưng việc xây dựng việc triển khai thông báo vùng đệm giao thức ("đơn vị cơ bản" của vùng đệm giao thức) trong bất kỳ ngôn ngữ lập trình nào phải được ghép nối với quy tắc proto_library để nếu hai mục tiêu trong cùng một ngôn ngữ phụ thuộc vào cùng một vùng đệm giao thức, thì vùng đệm giao thức đó chỉ được tạo một lần.

Giống như các mục tiêu đã định cấu hình, các mục tiêu này được biểu thị trong Skyframe dưới dạng SkyValue và cách tạo các mục tiêu này rất giống với cách tạo các mục tiêu đã định cấu hình: các mục tiêu này có một lớp nhà máy có tên là ConfiguredAspectFactory có quyền truy cập vào RuleContext, nhưng không giống như các nhà máy mục tiêu đã định cấu hình, lớp này cũng biết về mục tiêu đã định cấu hình mà nó được đính kèm và các nhà cung cấp của mục tiêu đó.

Tập hợp các khía cạnh được truyền xuống biểu đồ phần phụ thuộc được chỉ định cho từng thuộc tính bằng cách sử dụng hàm Attribute.Builder.aspects(). Có một vài lớp có tên gây nhầm lẫn tham gia vào quá trình này:

  1. AspectClass là cách triển khai tỷ lệ khung hình. Tệp này có thể là trong Java (trong trường hợp này là lớp con) hoặc trong Starlark (trong trường hợp này, đây là một thực thể của StarlarkAspectClass). Tương tự như RuleConfiguredTargetFactory.
  2. AspectDefinition là định nghĩa của khía cạnh; bao gồm các nhà cung cấp mà khía cạnh đó yêu cầu, các nhà cung cấp mà khía cạnh đó cung cấp và chứa thông tin tham chiếu đến việc triển khai khía cạnh đó, chẳng hạn như thực thể AspectClass thích hợp. Phương thức này tương tự như RuleClass.
  3. AspectParameters là một cách để tham số hoá một khía cạnh được truyền xuống biểu đồ phần phụ thuộc. Hiện tại, đây là một bản đồ chuỗi đến chuỗi. Một ví dụ điển hình về lý do tại sao vùng đệm giao thức lại hữu ích là: nếu một ngôn ngữ có nhiều API, thì thông tin về API nào cần tạo vùng đệm giao thức sẽ được truyền xuống biểu đồ phần phụ thuộc.
  4. Aspect đại diện cho tất cả dữ liệu cần thiết để tính toán một khía cạnh truyền xuống biểu đồ phần phụ thuộc. Lớp này bao gồm lớp khung hình, định nghĩa và các tham số của lớp đó.
  5. RuleAspect là hàm xác định những khía cạnh mà một quy tắc cụ thể sẽ truyền tải. Đây là hàm Rule -> Aspect.

Một vấn đề phức tạp không mong muốn là các khía cạnh có thể đính kèm vào các khía cạnh khác; ví dụ: một khía cạnh thu thập đường dẫn lớp cho IDE Java có thể muốn biết về tất cả tệp .jar trên đường dẫn lớp, nhưng một số tệp trong số đó là vùng đệm giao thức. Trong trường hợp đó, khía cạnh IDE sẽ muốn đính kèm vào cặp (quy tắc proto_library + khía cạnh proto Java).

Mức độ phức tạp của các khía cạnh trên các khía cạnh được ghi lại trong lớp AspectCollection.

Nền tảng và chuỗi công cụ

Bazel hỗ trợ các bản dựng đa nền tảng, tức là các bản dựng có thể có nhiều cấu trúc để chạy các hành động bản dựng và nhiều cấu trúc để tạo mã. Các cấu trúc này được gọi là nền tảng theo cách nói của Bazel (tài liệu đầy đủ tại đây)

Một nền tảng được mô tả bằng mối liên kết khoá-giá trị từ chế độ cài đặt quy tắc ràng buộc (chẳng hạn như khái niệm "cấu trúc CPU") đến giá trị quy tắc ràng buộc (chẳng hạn như một CPU cụ thể như x86_64). Chúng ta có một "từ điển" về các chế độ cài đặt và giá trị ràng buộc được sử dụng phổ biến nhất trong kho lưu trữ @platforms.

Khái niệm chuỗi công cụ xuất phát từ thực tế là tuỳ thuộc vào nền tảng mà bản dựng đang chạy và nền tảng được nhắm đến, bạn có thể cần sử dụng các trình biên dịch khác nhau; ví dụ: một chuỗi công cụ C++ cụ thể có thể chạy trên một hệ điều hành cụ thể và có thể nhắm đến một số hệ điều hành khác. Bazel phải xác định trình biên dịch C++ được sử dụng dựa trên nền tảng thực thi và nền tảng mục tiêu đã đặt (tài liệu về chuỗi công cụ tại đây).

Để làm điều này, các chuỗi công cụ được chú giải bằng tập hợp các quy tắc ràng buộc thực thi và nhắm mục tiêu đến nền tảng mà chúng hỗ trợ. Để làm được việc này, định nghĩa về chuỗi công cụ được chia thành hai phần:

  1. Quy tắc toolchain() mô tả tập hợp các quy tắc ràng buộc thực thi và mục tiêu mà một chuỗi công cụ hỗ trợ và cho biết loại công cụ (chẳng hạn như C++ hoặc Java) của chuỗi công cụ đó (quy tắc sau được biểu thị bằng quy tắc toolchain_type())
  2. Quy tắc dành riêng cho ngôn ngữ mô tả chuỗi công cụ thực tế (chẳng hạn như cc_toolchain())

Chúng ta thực hiện việc này vì cần biết các quy tắc ràng buộc cho mọi chuỗi công cụ để phân giải chuỗi công cụ và các quy tắc *_toolchain() dành riêng cho ngôn ngữ chứa nhiều thông tin hơn thế, vì vậy, các quy tắc này mất nhiều thời gian hơn để tải.

Nền tảng thực thi được chỉ định theo một trong những cách sau:

  1. Trong tệp MODULE.bazel bằng hàm register_execution_platforms()
  2. Trên dòng lệnh bằng tuỳ chọn dòng lệnh --extra_execution_platforms

Tập hợp các nền tảng thực thi có sẵn được tính toán trong RegisteredExecutionPlatformsFunction .

Nền tảng mục tiêu cho một mục tiêu đã định cấu hình được xác định theo PlatformOptions.computeTargetPlatform() . Đây là danh sách các nền tảng vì cuối cùng chúng tôi muốn hỗ trợ nhiều nền tảng mục tiêu, nhưng chưa triển khai.

Tập hợp chuỗi công cụ sẽ được sử dụng cho một mục tiêu đã định cấu hình được xác định bằng ToolchainResolutionFunction. Đây là hàm của:

  • Tập hợp các chuỗi công cụ đã đăng ký (trong tệp MODULE.bazel và cấu hình)
  • Nền tảng thực thi và nền tảng mục tiêu mong muốn (trong cấu hình)
  • Tập hợp các loại chuỗi công cụ mà mục tiêu đã định cấu hình yêu cầu (trong UnloadedToolchainContextKey)
  • Tập hợp các điều kiện ràng buộc đối với nền tảng thực thi của mục tiêu đã định cấu hình (thuộc tính exec_compatible_with) và cấu hình (--experimental_add_exec_constraints_to_targets), trong UnloadedToolchainContextKey

Kết quả là một UnloadedToolchainContext, về cơ bản là một bản đồ từ loại chuỗi công cụ (được biểu thị là một thực thể ToolchainTypeInfo) đến nhãn của chuỗi công cụ đã chọn. Tệp này được gọi là "đã huỷ tải" vì không chứa các chuỗi công cụ mà chỉ chứa nhãn của các chuỗi công cụ đó.

Sau đó, các chuỗi công cụ thực sự được tải bằng ResolvedToolchainContext.load() và được sử dụng trong quá trình triển khai mục tiêu đã định cấu hình đã yêu cầu các chuỗi công cụ đó.

Chúng tôi cũng có một hệ thống cũ dựa trên việc có một cấu hình "máy chủ" duy nhất và các cấu hình mục tiêu được biểu thị bằng nhiều cờ cấu hình, chẳng hạn như --cpu . Chúng tôi đang từng bước chuyển sang hệ thống trên. Để xử lý các trường hợp mọi người dựa vào các giá trị cấu hình cũ, chúng tôi đã triển khai các ánh xạ nền tảng để dịch giữa các cờ cũ và các quy tắc ràng buộc nền tảng kiểu mới. Mã của chúng nằm trong PlatformMappingFunction và sử dụng một "ngôn ngữ nhỏ" không phải Starlark.

Giới hạn

Đôi khi, bạn chỉ muốn chỉ định một mục tiêu tương thích với một vài nền tảng. (Rất tiếc) Bazel có nhiều cơ chế để đạt được mục tiêu này:

  • Quy tắc ràng buộc theo quy tắc
  • environment_group()/environment()
  • Các quy tắc ràng buộc của nền tảng

Các quy tắc ràng buộc dành riêng cho quy tắc chủ yếu được sử dụng trong Google cho các quy tắc Java; các quy tắc này đang trong quá trình ngừng hoạt động và không có trong Bazel, nhưng mã nguồn có thể chứa các tệp tham chiếu đến quy tắc này. Thuộc tính quản lý việc này được gọi là constraints= .

environment_group() và environment()

Các quy tắc này là một cơ chế cũ và không được sử dụng rộng rãi.

Tất cả quy tắc bản dựng đều có thể khai báo "môi trường" mà chúng có thể được tạo, trong đó "môi trường" là một thực thể của quy tắc environment().

Bạn có thể chỉ định môi trường được hỗ trợ cho một quy tắc theo nhiều cách:

  1. Thông qua thuộc tính restricted_to=. Đây là hình thức quy cách trực tiếp nhất; quy cách này khai báo chính xác tập hợp môi trường mà quy tắc hỗ trợ.
  2. Thông qua thuộc tính compatible_with=. Thao tác này khai báo các môi trường mà quy tắc hỗ trợ ngoài các môi trường "tiêu chuẩn" được hỗ trợ theo mặc định.
  3. Thông qua các thuộc tính cấp gói default_restricted_to=default_compatible_with=.
  4. Thông qua thông số kỹ thuật mặc định trong quy tắc environment_group(). Mỗi môi trường thuộc về một nhóm các môi trường ngang hàng có liên quan theo chủ đề (chẳng hạn như "cấu trúc CPU", "phiên bản JDK" hoặc "hệ điều hành di động"). Định nghĩa của một nhóm môi trường bao gồm môi trường nào trong số này sẽ được hỗ trợ theo chế độ "mặc định" nếu các thuộc tính restricted_to=/environment() không chỉ định khác. Một quy tắc không có các thuộc tính như vậy sẽ kế thừa tất cả các giá trị mặc định.
  5. Thông qua một lớp quy tắc mặc định. Thao tác này sẽ ghi đè các giá trị mặc định chung cho tất cả các thực thể của lớp quy tắc đã cho. Bạn có thể sử dụng tính năng này để kiểm thử tất cả quy tắc *_test mà không cần mỗi thực thể phải khai báo rõ ràng chức năng này.

environment() được triển khai dưới dạng quy tắc thông thường, trong khi environment_group() vừa là lớp con của Target nhưng không phải là Rule (EnvironmentGroup) vừa là hàm có sẵn theo mặc định từ Starlark (StarlarkLibrary.environmentGroup()) và cuối cùng tạo ra một mục tiêu cùng tên. Điều này là để tránh sự phụ thuộc tuần hoàn có thể phát sinh vì mỗi môi trường cần khai báo nhóm môi trường mà nó thuộc về và mỗi nhóm môi trường cần khai báo môi trường mặc định của nhóm đó.

Bạn có thể hạn chế một bản dựng cho một môi trường nhất định bằng tuỳ chọn dòng lệnh --target_environment.

Việc triển khai quy trình kiểm tra quy tắc ràng buộc nằm trong RuleContextConstraintSemanticsTopLevelConstraintSemantics.

Các quy tắc ràng buộc của nền tảng

Cách "chính thức" hiện tại để mô tả nền tảng mà một mục tiêu tương thích là sử dụng các quy tắc ràng buộc giống như dùng để mô tả chuỗi công cụ và nền tảng. Tính năng này đã được triển khai trong yêu cầu lấy dữ liệu #10945.

Chế độ hiển thị

Nếu làm việc trên một cơ sở mã lớn với nhiều nhà phát triển (chẳng hạn như tại Google), bạn nên cẩn thận để ngăn mọi người tuỳ ý phụ thuộc vào mã của bạn. Nếu không, theo quy luật của Hyrum, mọi người sẽ dựa vào những hành vi mà bạn coi là chi tiết triển khai.

Bazel hỗ trợ việc này bằng cơ chế có tên là visibility (mức độ hiển thị): bạn có thể giới hạn những mục tiêu nào có thể phụ thuộc vào một mục tiêu cụ thể bằng cách sử dụng thuộc tính visibility (mức độ hiển thị). Thuộc tính này có một chút đặc biệt vì mặc dù chứa danh sách nhãn, nhưng các nhãn này có thể mã hoá một mẫu trên tên gói thay vì con trỏ đến bất kỳ mục tiêu cụ thể nào. (Đúng vậy, đây là một lỗi thiết kế.)

Việc này được triển khai ở những vị trí sau:

  • Giao diện RuleVisibility thể hiện nội dung khai báo chế độ hiển thị. Giá trị này có thể là một hằng số (hoàn toàn công khai hoặc hoàn toàn riêng tư) hoặc một danh sách nhãn.
  • Nhãn có thể tham chiếu đến các nhóm gói (danh sách các gói được xác định trước) đến các gói trực tiếp (//pkg:__pkg__) hoặc cây con của các gói (//pkg:__subpackages__). Điều này khác với cú pháp dòng lệnh sử dụng //pkg:* hoặc //pkg/....
  • Các nhóm gói được triển khai dưới dạng mục tiêu riêng (PackageGroup) và mục tiêu đã định cấu hình (PackageGroupConfiguredTarget). Nếu muốn, chúng ta có thể thay thế các nhóm gói này bằng các quy tắc đơn giản. Logic của chúng được triển khai với sự trợ giúp của: PackageSpecification, tương ứng với một mẫu duy nhất như //pkg/...; PackageGroupContents, tương ứng với một thuộc tính packages của package_group; và PackageSpecificationProvider, tổng hợp trên một package_groupincludes bắc cầu của nó.
  • Việc chuyển đổi từ danh sách nhãn chế độ hiển thị sang phần phụ thuộc được thực hiện trong DependencyResolver.visitTargetVisibility và một số nơi khác.
  • Quá trình kiểm tra thực tế được thực hiện trong CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility()

Tập hợp lồng nhau

Thông thường, một mục tiêu được định cấu hình sẽ tổng hợp một tập hợp tệp từ các phần phụ thuộc, thêm các tệp của riêng mục tiêu đó và gói tập hợp tổng hợp vào một trình cung cấp thông tin bắc cầu để các mục tiêu được định cấu hình phụ thuộc vào mục tiêu đó cũng có thể làm như vậy. Ví dụ:

  • Tệp tiêu đề C++ dùng cho một bản dựng
  • Các tệp đối tượng biểu thị cho đóng bắc cầu của cc_library
  • Tập hợp các tệp .jar cần có trong đường dẫn lớp để một quy tắc Java có thể biên dịch hoặc chạy
  • Tập hợp các tệp Python trong tập hợp đóng bắc cầu của một quy tắc Python

Nếu thực hiện việc này theo cách đơn giản bằng cách sử dụng, ví dụ: List hoặc Set, thì chúng ta sẽ có mức sử dụng bộ nhớ bậc hai: nếu có một chuỗi gồm N quy tắc và mỗi quy tắc thêm một tệp, thì chúng ta sẽ có 1+2+...+N thành phần của tập hợp.

Để giải quyết vấn đề này, chúng tôi đã đưa ra khái niệm về NestedSet. Đây là một cấu trúc dữ liệu bao gồm các thực thể NestedSet khác và một số thành viên của chính nó, từ đó tạo thành một biểu đồ không tuần hoàn có hướng của các tập hợp. Các tập hợp này không thể thay đổi và các thành phần của tập hợp có thể được lặp lại. Chúng ta xác định nhiều thứ tự lặp lại (NestedSet.Order): thứ tự trước, thứ tự sau, thứ tự topo (một nút luôn nằm sau các nút cấp trên) và "không quan trọng, nhưng mỗi lần phải giống nhau".

Cấu trúc dữ liệu này có tên là depset trong Starlark.

Cấu phần phần mềm và Hành động

Bản dựng thực tế bao gồm một tập hợp các lệnh cần chạy để tạo ra kết quả mà người dùng muốn. Các lệnh được biểu thị dưới dạng thực thể của lớp Action và các tệp được biểu thị dưới dạng thực thể của lớp Artifact. Các hành động này được sắp xếp trong một biểu đồ hai phân đoạn, có hướng và không tuần hoàn được gọi là "biểu đồ hành động".

Cấu phần phần mềm có hai loại: cấu phần phần mềm nguồn (cấu phần phần mềm có sẵn trước khi Bazel bắt đầu thực thi) và cấu phần phần mềm phái sinh (cấu phần phần mềm cần được tạo). Bản thân cấu phần phần mềm phái sinh có thể có nhiều loại:

  1. **Cấu phần phần mềm thông thường. **Các tệp này được kiểm tra để đảm bảo mới nhất bằng cách tính toán tổng kiểm của chúng, với mtime làm lối tắt; chúng tôi không tính tổng kiểm của tệp nếu ctime của tệp đó không thay đổi.
  2. Cấu phần phần mềm đường liên kết tượng trưng chưa được phân giải. Các tệp này được kiểm tra xem có mới nhất hay không bằng cách gọi readlink(). Không giống như các cấu phần phần mềm thông thường, đây có thể là các đường liên kết tượng trưng bị treo. Thường được dùng trong trường hợp một tệp được đóng gói vào một loại tệp lưu trữ.
  3. Cấu phần phần mềm dạng cây. Đây không phải là các tệp đơn lẻ mà là cây thư mục. Các tệp này được kiểm tra để đảm bảo mới nhất bằng cách kiểm tra tập hợp các tệp trong đó và nội dung của các tệp đó. Các lớp này được biểu thị dưới dạng TreeArtifact.
  4. Cấu phần phần mềm siêu dữ liệu không đổi. Các thay đổi đối với các cấu phần phần mềm này không kích hoạt việc tạo lại. Thuộc tính này chỉ dùng cho thông tin dấu bản dựng: chúng ta không muốn tạo lại bản dựng chỉ vì thời gian hiện tại đã thay đổi.

Không có lý do cơ bản nào khiến cấu phần phần mềm nguồn không thể là cấu phần phần mềm cây hoặc cấu phần phần mềm đường liên kết tượng trưng chưa được phân giải, chỉ là chúng ta chưa triển khai cấu phần phần mềm đó (mặc dù chúng ta nên tham chiếu thư mục nguồn trong tệp BUILD là một trong số ít vấn đề không chính xác đã biết từ lâu với Bazel; chúng ta có một cách triển khai loại công việc này được bật bằng thuộc tính JVM BAZEL_TRACK_SOURCE_DIRECTORIES=1)

Một loại Artifact đáng chú ý là trung gian. Các giá trị này được biểu thị bằng các thực thể Artifact là kết quả của MiddlemanAction. Các giá trị này được dùng cho một trường hợp đặc biệt:

  • Trình trung gian tệp chạy được dùng để đảm bảo sự hiện diện của cây tệp chạy để không cần phụ thuộc riêng vào tệp kê khai đầu ra và mọi cấu phần phần mềm mà cây tệp chạy tham chiếu.

Bạn nên hiểu hành động là một lệnh cần chạy, môi trường cần thiết và tập hợp đầu ra mà lệnh đó tạo ra. Sau đây là các thành phần chính của nội dung mô tả một hành động:

  • Dòng lệnh cần chạy
  • Các cấu phần phần mềm đầu vào mà ứng dụng cần
  • Các biến môi trường cần được đặt
  • Chú thích mô tả môi trường (chẳng hạn như nền tảng) mà ứng dụng cần chạy trong đó \

Ngoài ra, còn có một số trường hợp đặc biệt khác, chẳng hạn như ghi một tệp có nội dung mà Bazel biết. Đây là lớp con của AbstractAction. Hầu hết các thao tác đều là SpawnAction hoặc StarlarkAction (giống nhau, các thao tác này không nên là các lớp riêng biệt), mặc dù Java và C++ có những loại thao tác riêng (JavaCompileAction, CppCompileActionCppLinkAction).

Cuối cùng, chúng ta muốn chuyển mọi thứ sang SpawnAction; JavaCompileAction khá gần, nhưng C++ là một trường hợp đặc biệt do việc phân tích cú pháp tệp .d và bao gồm cả việc quét.

Biểu đồ hành động chủ yếu được "nhúng" vào biểu đồ Skyframe: về mặt khái niệm, việc thực thi một hành động được biểu thị dưới dạng lệnh gọi của ActionExecutionFunction. Việc liên kết từ cạnh phần phụ thuộc biểu đồ hành động đến cạnh phần phụ thuộc Skyframe được mô tả trong ActionExecutionFunction.getInputDeps()Artifact.key(), đồng thời có một số hoạt động tối ưu hoá để giảm thiểu số lượng cạnh Skyframe:

  • Cấu phần phần mềm phái sinh không có SkyValue riêng. Thay vào đó, Artifact.getGeneratingActionKey() được dùng để tìm khoá cho thao tác tạo khoá
  • Các tập hợp lồng nhau có khoá Skyframe riêng.

Hành động được chia sẻ

Một số hành động được tạo bởi nhiều mục tiêu được định cấu hình; các quy tắc Starlark bị hạn chế hơn vì chúng chỉ được phép đặt các hành động phái sinh vào một thư mục do cấu hình và gói của chúng xác định (nhưng ngay cả như vậy, các quy tắc trong cùng một gói có thể xung đột), nhưng các quy tắc được triển khai trong Java có thể đặt các cấu phần phần mềm phái sinh ở bất cứ đâu.

Đây được coi là một tính năng không mong muốn, nhưng việc loại bỏ tính năng này rất khó vì tính năng này giúp tiết kiệm đáng kể thời gian thực thi khi, ví dụ: một tệp nguồn cần được xử lý theo cách nào đó và tệp đó được tham chiếu bằng nhiều quy tắc (handwave-handwave). Điều này sẽ làm tiêu hao một lượng RAM: mỗi thực thể của một thao tác dùng chung cần được lưu trữ riêng trong bộ nhớ.

Nếu hai hành động tạo ra cùng một tệp đầu ra, thì các hành động đó phải giống hệt nhau: có cùng dữ liệu đầu vào, cùng dữ liệu đầu ra và chạy cùng một dòng lệnh. Mối quan hệ tương đương này được triển khai trong Actions.canBeShared() và được xác minh giữa các giai đoạn phân tích và thực thi bằng cách xem xét mọi Hành động. Việc này được triển khai trong SkyframeActionExecutor.findAndStoreArtifactConflicts() và là một trong số ít nơi trong Bazel yêu cầu chế độ xem "toàn cục" của bản dựng.

Giai đoạn thực thi

Đây là khi Bazel thực sự bắt đầu chạy các thao tác xây dựng, chẳng hạn như các lệnh tạo ra kết quả.

Điều đầu tiên Bazel làm sau giai đoạn phân tích là xác định Cấu phần phần mềm cần được tạo. Logic cho việc này được mã hoá trong TopLevelArtifactHelper; nói một cách đơn giản, đó là filesToBuild của các mục tiêu đã định cấu hình trên dòng lệnh và nội dung của một nhóm đầu ra đặc biệt nhằm mục đích rõ ràng là thể hiện "nếu mục tiêu này nằm trên dòng lệnh, hãy tạo các cấu phần phần mềm này".

Bước tiếp theo là tạo thư mục gốc thực thi. Vì Bazel có thể đọc các gói nguồn từ nhiều vị trí trong hệ thống tệp (--package_path), nên cần cung cấp các thao tác được thực thi cục bộ bằng một cây nguồn đầy đủ. Việc này được xử lý bằng lớp SymlinkForest và hoạt động bằng cách ghi lại mọi mục tiêu được sử dụng trong giai đoạn phân tích và tạo một cây thư mục duy nhất liên kết tượng trưng mọi gói với mục tiêu đã sử dụng từ vị trí thực tế của gói. Một cách khác là truyền đường dẫn chính xác đến các lệnh (có tính đến --package_path). Điều này là không mong muốn vì:

  • API này thay đổi các dòng lệnh hành động khi một gói được di chuyển từ một mục nhập đường dẫn gói sang một mục khác (đây là trường hợp phổ biến)
  • Điều này dẫn đến các dòng lệnh khác nhau nếu một hành động được chạy từ xa so với khi chạy cục bộ
  • Phương thức này yêu cầu một phép biến đổi dòng lệnh dành riêng cho công cụ đang sử dụng (xem xét sự khác biệt giữa các đường dẫn lớp Java và đường dẫn bao gồm C++)
  • Việc thay đổi dòng lệnh của một thao tác sẽ làm mất hiệu lực của mục nhập bộ nhớ đệm của thao tác đó
  • --package_path đang dần ngừng hoạt động

Sau đó, Bazel bắt đầu truyền tải biểu đồ hành động (biểu đồ hai phần, biểu đồ có hướng bao gồm các hành động cũng như các cấu phần phần mềm đầu vào và đầu ra) và chạy các hành động. Việc thực thi mỗi hành động được biểu thị bằng một thực thể của lớp SkyValue ActionExecutionValue.

Vì việc chạy một hành động tốn kém, nên chúng ta có một vài lớp lưu vào bộ nhớ đệm có thể được truy cập sau Skyframe:

  • ActionExecutionFunction.stateMap chứa dữ liệu để giúp Skyframe khởi động lại ActionExecutionFunction một cách tiết kiệm
  • Bộ nhớ đệm thao tác cục bộ chứa dữ liệu về trạng thái của hệ thống tệp
  • Các hệ thống thực thi từ xa thường cũng chứa bộ nhớ đệm riêng

Bộ nhớ đệm hành động cục bộ

Bộ nhớ đệm này là một lớp khác nằm phía sau Skyframe; ngay cả khi một thao tác được thực thi lại trong Skyframe, thì thao tác đó vẫn có thể là một lượt truy cập trong bộ nhớ đệm thao tác cục bộ. Tệp này đại diện cho trạng thái của hệ thống tệp cục bộ và được chuyển đổi tuần tự sang ổ đĩa, nghĩa là khi khởi động một máy chủ Bazel mới, bạn có thể nhận được các lượt truy cập vào bộ nhớ đệm hành động cục bộ ngay cả khi biểu đồ Skyframe trống.

Bộ nhớ đệm này được kiểm tra các lượt truy cập bằng phương thức ActionCacheChecker.getTokenIfNeedToExecute() .

Trái ngược với tên gọi, đây là một bản đồ từ đường dẫn của cấu phần phần mềm phái sinh đến hành động đã tạo ra cấu phần phần mềm đó. Hành động này được mô tả như sau:

  1. Tập hợp các tệp đầu vào và đầu ra cùng với tổng kiểm của các tệp đó
  2. "Khoá hành động" của nó thường là dòng lệnh đã được thực thi, nhưng nói chung, đại diện cho mọi thứ không được tổng kiểm của tệp đầu vào ghi lại (chẳng hạn như đối với FileWriteAction, đó là tổng kiểm của dữ liệu đã ghi)

Ngoài ra còn có một "bộ nhớ đệm thao tác từ trên xuống" có tính thử nghiệm cao vẫn đang được phát triển. Bộ nhớ này sử dụng các hàm băm bắc cầu để tránh chuyển vào bộ nhớ đệm nhiều lần.

Khám phá dữ liệu đầu vào và cắt giảm dữ liệu đầu vào

Một số hành động phức tạp hơn so với việc chỉ có một nhóm thông tin đầu vào. Các thay đổi đối với tập hợp dữ liệu đầu vào của một hành động có hai dạng:

  • Một hành động có thể khám phá dữ liệu đầu vào mới trước khi thực thi hoặc quyết định rằng một số dữ liệu đầu vào thực sự không cần thiết. Ví dụ chuẩn là C++, trong đó tốt hơn là bạn nên dự đoán những tệp tiêu đề mà tệp C++ sử dụng từ phép đóng vi mô để chúng ta không cần gửi mọi tệp đến trình thực thi từ xa; do đó, chúng ta có thể chọn không đăng ký mọi tệp tiêu đề làm "đầu vào", mà quét tệp nguồn để tìm các tiêu đề được đưa vào một cách bắc cầu và chỉ đánh dấu các tệp tiêu đề đó là đầu vào được đề cập trong câu lệnh #include (chúng ta đánh giá quá cao để không cần triển khai trình xử lý trước C đầy đủ) Tuỳ chọn này hiện được kết nối cứng với "false" trong Bazel và chỉ được sử dụng tại Google.
  • Một hành động có thể nhận ra rằng một số tệp không được sử dụng trong quá trình thực thi. Trong C++, đây được gọi là "tệp .d": trình biên dịch cho biết tệp tiêu đề nào đã được sử dụng sau khi thực tế và để tránh sự xấu hổ khi có mức tăng dần kém hơn Make, Bazel sử dụng thực tế này. Lựa chọn này mang lại kết quả ước tính tốt hơn so với trình quét bao gồm vì trình quét này dựa vào trình biên dịch.

Các hành động này được triển khai bằng cách sử dụng các phương thức trên Hành động:

  1. Action.discoverInputs() sẽ được gọi. Thao tác này sẽ trả về một tập hợp Cấu phần phần mềm lồng nhau được xác định là bắt buộc. Đây phải là các cấu phần phần mềm nguồn để không có cạnh phần phụ thuộc nào trong biểu đồ hành động không có cạnh tương đương trong biểu đồ mục tiêu đã định cấu hình.
  2. Thao tác này được thực thi bằng cách gọi Action.execute().
  3. Ở cuối Action.execute(), thao tác có thể gọi Action.updateInputs() để cho Bazel biết rằng không phải tất cả dữ liệu đầu vào đều cần thiết. Điều này có thể dẫn đến các bản dựng tăng dần không chính xác nếu dữ liệu đầu vào đã sử dụng được báo cáo là không sử dụng.

Khi bộ nhớ đệm hành động trả về một lượt truy cập trên một thực thể Hành động mới (chẳng hạn như được tạo sau khi khởi động lại máy chủ), Bazel sẽ tự gọi updateInputs() để tập hợp đầu vào phản ánh kết quả của việc khám phá và cắt bớt đầu vào đã thực hiện trước đó.

Các thao tác Starlark có thể sử dụng cơ sở này để khai báo một số dữ liệu đầu vào là không sử dụng bằng cách sử dụng đối số unused_inputs_list= của ctx.actions.run().

Nhiều cách để chạy hành động: Chiến lược/ActionContexts

Bạn có thể chạy một số thao tác theo nhiều cách. Ví dụ: một dòng lệnh có thể được thực thi cục bộ, cục bộ, nhưng trong nhiều loại hộp cát hoặc từ xa. Khái niệm thể hiện điều này được gọi là ActionContext (hoặc Strategy, vì chúng ta chỉ thực hiện được một nửa việc đổi tên thành công…)

Vòng đời của ngữ cảnh hành động như sau:

  1. Khi bắt đầu giai đoạn thực thi, các thực thể BlazeModule sẽ được hỏi về ngữ cảnh hành động mà chúng có. Điều này xảy ra trong hàm khởi tạo của ExecutionTool. Các loại ngữ cảnh hành động được xác định bằng một thực thể Class Java tham chiếu đến một giao diện phụ của ActionContext và giao diện mà ngữ cảnh hành động phải triển khai.
  2. Ngữ cảnh hành động thích hợp được chọn trong số các ngữ cảnh có sẵn và được chuyển tiếp đến ActionExecutionContextBlazeExecutor .
  3. Các hành động yêu cầu ngữ cảnh bằng ActionExecutionContext.getContext()BlazeExecutor.getStrategy() (thực sự chỉ có một cách để thực hiện việc này…)

Các chiến lược có thể tự do gọi các chiến lược khác để thực hiện công việc của mình; ví dụ: chiến lược này được sử dụng trong chiến lược động bắt đầu các hành động cả cục bộ và từ xa, sau đó sử dụng bất kỳ hành động nào kết thúc trước.

Một chiến lược đáng chú ý là chiến lược triển khai các quy trình của worker liên tục (WorkerSpawnStrategy). Ý tưởng là một số công cụ có thời gian khởi động dài và do đó nên được sử dụng lại giữa các hành động thay vì bắt đầu một lần nữa cho mọi hành động (Điều này thể hiện một vấn đề tiềm ẩn về độ chính xác, vì Bazel dựa vào hứa hẹn về quy trình worker là nó không thể quan sát trạng thái giữa các yêu cầu riêng lẻ)

Nếu công cụ thay đổi, quy trình của worker cần được khởi động lại. Việc có thể sử dụng lại một worker hay không được xác định bằng cách tính toán tổng kiểm tra cho công cụ được dùng bằng WorkerFilesHash. Phương thức này dựa vào việc biết dữ liệu đầu vào nào của hành động đại diện cho một phần của công cụ và dữ liệu đầu vào nào đại diện cho dữ liệu đầu vào; điều này do nhà sáng tạo của Hành động xác định: Spawn.getToolFiles() và tệp chạy của Spawn được tính là một phần của công cụ.

Thông tin khác về chiến lược (hoặc ngữ cảnh hành động!):

  • Bạn có thể xem thông tin về nhiều chiến lược để chạy hành động tại đây.
  • Thông tin về chiến lược động, chiến lược mà chúng ta chạy một thao tác cả trên máy và từ xa để xem thao tác nào kết thúc trước có sẵn tại đây.
  • Bạn có thể xem thông tin về các vấn đề phức tạp khi thực thi hành động trên thiết bị tại đây.

Trình quản lý tài nguyên cục bộ

Bazel có thể chạy song song nhiều thao tác. Số lượng hành động cục bộ nên chạy song song khác nhau tuỳ theo hành động: hành động càng cần nhiều tài nguyên thì càng ít thực thể chạy cùng lúc để tránh quá tải máy cục bộ.

Điều này được triển khai trong lớp ResourceManager: mỗi hành động phải được chú thích bằng số liệu ước tính về tài nguyên cục bộ mà hành động đó cần ở dạng thực thể ResourceSet (CPU và RAM). Sau đó, khi các ngữ cảnh hành động thực hiện hoạt động đòi hỏi tài nguyên cục bộ, chúng sẽ gọi ResourceManager.acquireResources() và bị chặn cho đến khi có sẵn tài nguyên cần thiết.

Bạn có thể xem nội dung mô tả chi tiết hơn về việc quản lý tài nguyên cục bộ tại đây.

Cấu trúc của thư mục đầu ra

Mỗi thao tác cần có một vị trí riêng trong thư mục đầu ra để đặt đầu ra. Vị trí của các cấu phần phần mềm phái sinh thường như sau:

$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>

Tên của thư mục liên kết với một cấu hình cụ thể được xác định như thế nào? Có hai thuộc tính mong muốn xung đột với nhau:

  1. Nếu hai cấu hình có thể xảy ra trong cùng một bản dựng, thì chúng phải có các thư mục khác nhau để cả hai có thể có phiên bản riêng của cùng một hành động; nếu không, nếu hai cấu hình không thống nhất về nhau, chẳng hạn như dòng lệnh của một hành động tạo ra cùng một tệp đầu ra, thì Bazel sẽ không biết nên chọn hành động nào ("xung đột hành động")
  2. Nếu hai cấu hình đại diện cho "gần như" cùng một nội dung, thì chúng phải có cùng tên để các hành động được thực thi trong một cấu hình có thể được sử dụng lại cho cấu hình còn lại nếu các dòng lệnh khớp nhau: ví dụ: các thay đổi đối với tuỳ chọn dòng lệnh cho trình biên dịch Java không được dẫn đến việc các hành động biên dịch C++ được chạy lại.

Cho đến nay, chúng tôi vẫn chưa đưa ra một phương pháp theo nguyên tắc để giải quyết vấn đề này. Nó có nhiều điểm tương đồng với vấn đề cắt bỏ cấu hình. Bạn có thể xem nội dung thảo luận dài hơn về các lựa chọn tại đây. Các vấn đề chính gây ra vấn đề là quy tắc Starlark (tác giả thường không quen thuộc với Bazel) và các khía cạnh, thêm một chiều khác vào không gian của những thứ có thể tạo ra "cùng một tệp đầu ra".

Phương pháp hiện tại là phân đoạn đường dẫn cho cấu hình là <CPU>-<compilation mode> với nhiều hậu tố được thêm vào để các chuyển đổi cấu hình được triển khai trong Java không dẫn đến xung đột hành động. Ngoài ra, một tổng kiểm của tập hợp các chuyển đổi cấu hình Starlark sẽ được thêm vào để người dùng không thể gây ra xung đột hành động. Nó còn rất nhiều điểm chưa hoàn thiện. Việc này được triển khai trong OutputDirectories.buildMnemonic() và dựa vào mỗi mảnh cấu hình thêm phần riêng của nó vào tên của thư mục đầu ra.

Thử nghiệm

Bazel có nhiều tính năng hỗ trợ chạy kiểm thử. API này hỗ trợ:

  • Chạy kiểm thử từ xa (nếu có phần phụ trợ thực thi từ xa)
  • Chạy song song nhiều lần kiểm thử (để loại bỏ lỗi hoặc thu thập dữ liệu về thời gian)
  • Phân đoạn kiểm thử (phân tách các trường hợp kiểm thử trong cùng một kiểm thử trên nhiều quy trình để tăng tốc độ)
  • Chạy lại kiểm thử mã không ổn định
  • Nhóm các bài kiểm thử thành bộ kiểm thử

Kiểm thử là các mục tiêu được định cấu hình thông thường có TestProvider, mô tả cách chạy kiểm thử:

  • Các cấu phần phần mềm có kết quả bản dựng trong quá trình chạy kiểm thử. Đây là tệp "trạng thái bộ nhớ đệm" chứa thông báo TestResultData đã chuyển đổi tuần tự
  • Số lần chạy kiểm thử
  • Số lượng phân đoạn mà kiểm thử sẽ được chia thành
  • Một số thông số về cách chạy kiểm thử (chẳng hạn như thời gian chờ kiểm thử)

Xác định xem nên chạy chương trình kiểm thử nào

Xác định kiểm thử nào sẽ chạy là một quá trình phức tạp.

Thứ nhất, trong quá trình phân tích cú pháp mẫu mục tiêu, bộ kiểm thử được mở rộng theo quy tắc đệ quy. Tiện ích mở rộng được triển khai trong TestsForTargetPatternFunction. Một điều hơi đáng ngạc nhiên là nếu một bộ kiểm thử không khai báo kiểm thử nào, thì bộ kiểm thử đó sẽ tham chiếu đến mọi kiểm thử trong gói của bộ kiểm thử đó. Việc này được triển khai trong Package.beforeBuild() bằng cách thêm một thuộc tính ngầm ẩn có tên là $implicit_tests để kiểm thử các quy tắc của bộ kiểm thử.

Sau đó, các bài kiểm thử được lọc theo kích thước, thẻ, thời gian chờ và ngôn ngữ theo tuỳ chọn dòng lệnh. Tính năng này được triển khai trong TestFilter và được gọi từ TargetPatternPhaseFunction.determineTests() trong quá trình phân tích cú pháp mục tiêu và kết quả được đưa vào TargetPatternPhaseValue.getTestsToRunLabels(). Lý do bạn không thể định cấu hình các thuộc tính quy tắc có thể được lọc là vì việc này xảy ra trước giai đoạn phân tích, do đó, cấu hình không có sẵn.

Sau đó, quy trình này sẽ được xử lý tiếp trong BuildView.createResult(): các mục tiêu có bản phân tích không thành công sẽ bị lọc ra và các lượt kiểm thử được tách thành các phép kiểm thử độc quyền và không độc quyền. Sau đó, tệp này được đưa vào AnalysisResult. Đây là cách ExecutionTool biết cần chạy kiểm thử nào.

Để tăng cường tính minh bạch cho quy trình phức tạp này, toán tử truy vấn tests() (được triển khai trong TestsFunction) có sẵn để cho biết các hoạt động kiểm thử nào sẽ chạy khi một mục tiêu cụ thể được chỉ định trên dòng lệnh. Rất tiếc, đây là một quá trình triển khai lại, vì vậy, có thể có nhiều điểm khác biệt nhỏ so với nội dung trên.

Chạy kiểm thử

Cách chạy kiểm thử là yêu cầu cấu phần phần mềm trạng thái bộ nhớ đệm. Sau đó, thao tác này sẽ dẫn đến việc thực thi TestRunnerAction, cuối cùng sẽ gọi TestActionContext do tuỳ chọn dòng lệnh --test_strategy chọn, tuỳ chọn này sẽ chạy kiểm thử theo cách được yêu cầu.

Các chương trình kiểm thử được chạy theo một giao thức phức tạp sử dụng các biến môi trường để cho các chương trình kiểm thử biết kết quả dự kiến. Bạn có thể xem nội dung mô tả chi tiết về những điều Bazel mong đợi từ các kiểm thử và những kiểm thử có thể mong đợi từ Bazel tại đây. Ở mức đơn giản nhất, mã thoát là 0 có nghĩa là thành công, mọi giá trị khác có nghĩa là không thành công.

Ngoài tệp trạng thái bộ nhớ đệm, mỗi quy trình kiểm thử sẽ phát ra một số tệp khác. Các tệp này được đặt trong "thư mục nhật ký kiểm thử", là thư mục con có tên testlogs của thư mục đầu ra của cấu hình mục tiêu:

  • test.xml, một tệp XML kiểu JUnit nêu chi tiết các trường hợp kiểm thử riêng lẻ trong phân đoạn kiểm thử
  • test.log, đầu ra của bảng điều khiển của kiểm thử. stdout và stderr không được tách riêng.
  • test.outputs, "thư mục đầu ra chưa khai báo"; thư mục này được các chương trình kiểm thử sử dụng để xuất tệp ngoài những gì được in ra thiết bị đầu cuối.

Có hai điều có thể xảy ra trong quá trình thực thi kiểm thử mà không thể xảy ra trong quá trình tạo mục tiêu thông thường: thực thi kiểm thử độc quyền và truyền phát đầu ra.

Một số chương trình kiểm thử cần được thực thi ở chế độ độc quyền, ví dụ như không chạy song song với các chương trình kiểm thử khác. Bạn có thể kích hoạt điều này bằng cách thêm tags=["exclusive"] vào quy tắc kiểm thử hoặc chạy kiểm thử bằng --test_strategy=exclusive . Mỗi chương trình kiểm thử độc quyền được chạy bằng một lệnh gọi Skyframe riêng biệt yêu cầu thực thi chương trình kiểm thử sau bản dựng "chính". Việc này được triển khai trong SkyframeExecutor.runExclusiveTest().

Không giống như các thao tác thông thường, trong đó đầu ra của thiết bị đầu cuối được kết xuất khi thao tác hoàn tất, người dùng có thể yêu cầu truyền phát đầu ra của các chương trình kiểm thử để họ nhận được thông báo về tiến trình của một chương trình kiểm thử chạy trong thời gian dài. Điều này được chỉ định bằng tuỳ chọn dòng lệnh --test_output=streamed và ngụ ý việc thực thi kiểm thử độc quyền để kết quả của các kiểm thử khác nhau không bị xen kẽ.

Việc này được triển khai trong lớp StreamedTestOutput được đặt tên phù hợp và hoạt động bằng cách thăm dò ý kiến về các thay đổi đối với tệp test.log của kiểm thử có liên quan và kết xuất các byte mới vào thiết bị đầu cuối nơi có quy tắc Bazel.

Bạn có thể xem kết quả của các chương trình kiểm thử đã thực thi trên bus sự kiện bằng cách quan sát nhiều sự kiện (chẳng hạn như TestAttempt, TestResult hoặc TestingCompleteEvent). Các kết quả này được chuyển vào Giao thức sự kiện bản dựng và được AggregatingTestListener phát vào bảng điều khiển.

Thu thập thông tin về mức độ phù hợp

Mức độ sử dụng được báo cáo bằng các bài kiểm thử ở định dạng LCOV trong các tệp bazel-testlogs/$PACKAGE/$TARGET/coverage.dat.

Để thu thập mức độ sử dụng, mỗi lần thực thi kiểm thử được gói trong một tập lệnh có tên là collect_coverage.sh .

Tập lệnh này thiết lập môi trường kiểm thử để cho phép thu thập mức độ sử dụng và xác định vị trí các tệp mức độ sử dụng được ghi bởi(các) thời gian chạy mức độ sử dụng. Sau đó, công cụ này sẽ chạy kiểm thử. Một kiểm thử có thể tự chạy nhiều quy trình con và bao gồm các phần được viết bằng nhiều ngôn ngữ lập trình (với thời gian chạy thu thập mức độ che phủ riêng biệt). Tập lệnh trình bao bọc chịu trách nhiệm chuyển đổi các tệp kết quả sang định dạng LCOV nếu cần và hợp nhất các tệp đó thành một tệp duy nhất.

Việc chèn collect_coverage.sh do các chiến lược kiểm thử thực hiện và yêu cầu collect_coverage.sh phải nằm trong dữ liệu đầu vào của kiểm thử. Điều này có thêm nhờ thuộc tính ngầm ẩn :coverage_support. Thuộc tính này được phân giải thành giá trị của cờ cấu hình --coverage_support (xem TestConfiguration.TestOptions.coverageSupport)

Một số ngôn ngữ thực hiện đo lường ngoại tuyến, nghĩa là tính năng đo lường mức độ sử dụng được thêm vào thời điểm biên dịch (chẳng hạn như C++) và các ngôn ngữ khác thực hiện đo lường trực tuyến, nghĩa là khả năng đo lường mức độ sử dụng sẽ được thêm vào thời điểm thực thi.

Một khái niệm cốt lõi khác là mức độ phù hợp cơ sở. Đây là mức độ sử dụng của một thư viện, tệp nhị phân hoặc kiểm thử nếu không có mã nào trong đó được chạy. Vấn đề mà công cụ này giải quyết là nếu bạn muốn tính toán mức độ bao phủ kiểm thử cho một tệp nhị phân, thì việc hợp nhất mức độ bao phủ của tất cả các kiểm thử là chưa đủ vì có thể có mã trong tệp nhị phân không được liên kết với bất kỳ kiểm thử nào. Do đó, chúng ta sẽ tạo một tệp mức độ sử dụng cho mọi tệp nhị phân chỉ chứa các tệp mà chúng ta thu thập mức độ sử dụng mà không có dòng nào được bao phủ. Tệp mức độ sử dụng cơ sở của một mục tiêu nằm ở bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat . Tệp này cũng được tạo cho các tệp nhị phân và thư viện ngoài các chương trình kiểm thử nếu bạn truyền cờ --nobuild_tests_only đến Bazel.

Mức độ sử dụng cơ sở hiện đang bị lỗi.

Chúng tôi theo dõi hai nhóm tệp để thu thập mức độ sử dụng cho mỗi quy tắc: tập hợp các tệp đo lường và tập hợp các tệp siêu dữ liệu đo lường.

Tập hợp các tệp được đo lường chỉ là một tập hợp các tệp để đo lường. Đối với thời gian chạy phạm vi trực tuyến, bạn có thể sử dụng tính năng này trong thời gian chạy để quyết định tệp nào sẽ được đo lường. Phương pháp này cũng được dùng để triển khai mức độ sử dụng cơ sở.

Tập hợp các tệp siêu dữ liệu đo lường là tập hợp các tệp bổ sung mà quy trình kiểm thử cần có để tạo các tệp LCOV mà Bazel yêu cầu. Trên thực tế, quá trình này bao gồm các tệp dành riêng cho thời gian chạy; ví dụ: gcc phát ra các tệp .gcno trong quá trình biên dịch. Các hành động này được thêm vào tập hợp dữ liệu đầu vào của các hành động kiểm thử nếu chế độ phạm vi được bật.

Liệu phạm vi phủ sóng có đang được thu thập hay không sẽ được lưu trữ trong BuildConfiguration. Điều này rất hữu ích vì đây là cách dễ dàng để thay đổi hành động kiểm thử và biểu đồ hành động tuỳ thuộc vào bit này, nhưng cũng có nghĩa là nếu bit này bị lật, tất cả các mục tiêu cần được phân tích lại (một số ngôn ngữ, chẳng hạn như C++, yêu cầu các tuỳ chọn trình biên dịch khác nhau để phát mã có thể thu thập phạm vi sử dụng, giúp giảm bớt vấn đề này, vì dù sao bạn cũng cần phân tích lại).

Các tệp hỗ trợ mức độ sử dụng phụ thuộc vào các nhãn trong một phần phụ thuộc ngầm ẩn để có thể được ghi đè bằng chính sách gọi, cho phép các tệp này khác nhau giữa các phiên bản Bazel. Lý tưởng nhất là các điểm khác biệt này sẽ bị xoá và chúng tôi đã chuẩn hoá một trong số đó.

Chúng ta cũng tạo một "báo cáo mức độ sử dụng" để hợp nhất mức độ sử dụng được thu thập cho mọi chương trình kiểm thử trong một lệnh gọi Bazel. Việc này do CoverageReportActionFactory xử lý và được gọi từ BuildView.createResult() . API này có quyền sử dụng các công cụ cần thiết bằng cách xem thuộc tính :coverage_report_generator của kiểm thử đầu tiên được thực thi.

Công cụ truy vấn

Bazel có một ngôn ngữ nhỏ dùng để hỏi về nhiều thứ liên quan đến các biểu đồ. Các loại truy vấn sau đây được cung cấp:

  • bazel query được dùng để điều tra biểu đồ mục tiêu
  • bazel cquery được dùng để điều tra biểu đồ mục tiêu đã định cấu hình
  • bazel aquery được dùng để điều tra biểu đồ hành động

Mỗi phương pháp trong số này được triển khai bằng cách phân lớp con AbstractBlazeQueryEnvironment. Bạn có thể thực hiện các hàm truy vấn bổ sung bằng cách tạo lớp con QueryFunction. Để cho phép truyền trực tuyến các kết quả truy vấn, thay vì thu thập các kết quả đó vào một cấu trúc dữ liệu nào đó, query2.engine.Callback được chuyển đến QueryFunction để gọi phương thức này cho các kết quả muốn trả về.

Kết quả của một truy vấn có thể được phát ra theo nhiều cách: nhãn, nhãn và lớp quy tắc, XML, protobuf, v.v. Các lớp này được triển khai dưới dạng lớp con của OutputFormatter.

Một yêu cầu tinh tế của một số định dạng đầu ra truy vấn (chắc chắn là proto) là Bazel cần phát _tất cả _thông tin mà quá trình tải gói cung cấp để người dùng có thể so sánh đầu ra và xác định xem một mục tiêu cụ thể có thay đổi hay không. Do đó, các giá trị thuộc tính cần phải có thể chuyển đổi tuần tự. Đó là lý do tại sao chỉ có một số ít loại thuộc tính không có thuộc tính nào có giá trị Starlark phức tạp. Giải pháp thông thường là sử dụng nhãn và đính kèm thông tin phức tạp vào quy tắc bằng nhãn đó. Đây không phải là giải pháp thỏa đáng và sẽ rất tuyệt nếu bạn có thể gỡ bỏ yêu cầu này.

Hệ thống mô-đun

Bạn có thể mở rộng Bazel bằng cách thêm các mô-đun vào đó. Mỗi mô-đun phải phân lớp con BlazeModule (tên này là một bản sao của lịch sử Bazel khi trước đây được gọi là Blaze) và nhận thông tin về các sự kiện khác nhau trong quá trình thực thi lệnh.

Các lớp này chủ yếu được dùng để triển khai nhiều phần chức năng "không phải cốt lõi" mà chỉ một số phiên bản Bazel (chẳng hạn như phiên bản chúng tôi sử dụng tại Google) cần đến:

  • Giao diện với hệ thống thực thi từ xa
  • Lệnh mới

Tập hợp các điểm mở rộng mà BlazeModule cung cấp có phần lộn xộn. Đừng sử dụng ví dụ này làm ví dụ về các nguyên tắc thiết kế tốt.

Xe buýt sự kiện

Cách chính để BlazeModules giao tiếp với phần còn lại của Bazel là thông qua một bus sự kiện (EventBus): một thực thể mới được tạo cho mỗi bản dựng, nhiều phần của Bazel có thể đăng sự kiện lên bus này và các mô-đun có thể đăng ký trình nghe cho các sự kiện mà chúng quan tâm. Ví dụ: những điều sau đây được biểu thị dưới dạng sự kiện:

  • Danh sách các mục tiêu bản dựng cần tạo đã được xác định (TargetParsingCompleteEvent)
  • Các cấu hình cấp cao nhất đã được xác định (BuildConfigurationEvent)
  • Đã tạo một mục tiêu, thành công hay không (TargetCompleteEvent)
  • Đã chạy một kiểm thử (TestAttempt, TestSummary)

Một số sự kiện này được biểu thị bên ngoài Bazel trong Giao thức sự kiện tạo (chúng là các BuildEvent). Điều này không chỉ cho phép BlazeModule mà còn cho phép các thành phần bên ngoài quy trình Bazel quan sát bản dựng. Bạn có thể truy cập các tệp này dưới dạng một tệp chứa thông báo giao thức hoặc Bazel có thể kết nối với một máy chủ (được gọi là Dịch vụ sự kiện bản dựng) để truyền trực tuyến sự kiện.

Việc này được triển khai trong các gói Java build.lib.buildeventservicebuild.lib.buildeventstream.

Kho lưu trữ bên ngoài

Trong khi ban đầu Bazel được thiết kế để sử dụng trong một monorepo (một cây nguồn duy nhất chứa mọi thứ mà người dùng cần xây dựng), thì Bazel lại sống trong một thế giới mà điều này không nhất thiết đúng. "Kho lưu trữ bên ngoài" là một khái niệm trừu tượng dùng để kết nối hai thế giới này: chúng đại diện cho mã cần thiết cho bản dựng nhưng không có trong cây nguồn chính.

Tệp WORKSPACE

Tập hợp các kho lưu trữ bên ngoài được xác định bằng cách phân tích cú pháp tệp WORKSPACE. Ví dụ: một nội dung khai báo như sau:

    local_repository(name="foo", path="/foo/bar")

Có kết quả trong kho lưu trữ có tên @foo. Điều phức tạp ở đây là bạn có thể xác định các quy tắc mới về kho lưu trữ trong tệp Starlark, sau đó sử dụng các quy tắc này để tải mã Starlark mới, từ đó xác định các quy tắc mới về kho lưu trữ, v.v.

Để xử lý trường hợp này, quá trình phân tích cú pháp tệp WORKSPACE (trong WorkspaceFileFunction) được chia thành các phần được mô tả bằng câu lệnh load(). Chỉ mục đoạn được biểu thị bằng WorkspaceFileKey.getIndex() và tính toán WorkspaceFileFunction cho đến khi chỉ mục X có nghĩa là đánh giá chỉ mục đó cho đến câu lệnh load() thứ X.

Đang tìm nạp kho lưu trữ

Trước khi có thể cung cấp mã của kho lưu trữ cho Bazel, bạn cần phải tìm nạp mã đó. Điều này khiến Bazel tạo một thư mục trong $OUTPUT_BASE/external/<repository name>.

Quá trình tìm nạp kho lưu trữ diễn ra theo các bước sau:

  1. PackageLookupFunction nhận ra rằng cần có kho lưu trữ và tạo RepositoryName dưới dạng SkyKey. Mã này sẽ gọi RepositoryLoaderFunction
  2. RepositoryLoaderFunction chuyển tiếp yêu cầu đến RepositoryDelegatorFunction vì lý do không rõ ràng (mã này cho biết là để tránh tải lại các nội dung trong trường hợp khởi động lại Skyframe, nhưng đây không phải là lý do vững chắc)
  3. RepositoryDelegatorFunction tìm ra quy tắc kho lưu trữ mà nó được yêu cầu tìm nạp bằng cách lặp lại các phần của tệp WORKSPACE cho đến khi tìm thấy kho lưu trữ được yêu cầu
  4. Tìm thấy RepositoryFunction thích hợp có chức năng triển khai tính năng tìm nạp kho lưu trữ; đó là phương thức triển khai Starlark của kho lưu trữ hoặc bản đồ được cố định giá trị trong mã cho các kho lưu trữ được triển khai trong Java.

Có nhiều lớp lưu vào bộ nhớ đệm vì việc tìm nạp kho lưu trữ có thể rất tốn kém:

  1. Có một bộ nhớ đệm cho các tệp đã tải xuống được khoá bằng tổng kiểm của tệp (RepositoryCache). Điều này yêu cầu tổng kiểm phải có trong tệp WORKSPACE, nhưng dù sao thì điều đó cũng tốt cho tính kín đáo. Mỗi thực thể máy chủ Bazel trên cùng một máy trạm đều chia sẻ thông tin này, bất kể chúng đang chạy trong không gian làm việc hay cơ sở đầu ra nào.
  2. Một "tệp điểm đánh dấu" được ghi cho mỗi kho lưu trữ trong $OUTPUT_BASE/external chứa tổng kiểm của quy tắc được dùng để tìm nạp tệp đó. Nếu máy chủ Bazel khởi động lại nhưng tổng kiểm không thay đổi, thì tổng kiểm đó sẽ không được tìm nạp lại. Việc này được triển khai trong RepositoryDelegatorFunction.DigestWriter .
  3. Tuỳ chọn dòng lệnh --distdir chỉ định một bộ nhớ đệm khác dùng để truy vấn các cấu phần phần mềm cần tải xuống. Điều này hữu ích trong chế độ cài đặt doanh nghiệp, trong đó Bazel không được tìm nạp các nội dung ngẫu nhiên trên Internet. Việc này do DownloadManager triển khai.

Sau khi tải kho lưu trữ xuống, các cấu phần phần mềm trong kho lưu trữ đó sẽ được coi là cấu phần phần mềm nguồn. Điều này gây ra một vấn đề vì Bazel thường kiểm tra tính cập nhật của các cấu phần phần mềm nguồn bằng cách gọi stat() trên các cấu phần phần mềm đó. Các cấu phần phần mềm này cũng không hợp lệ khi định nghĩa về kho lưu trữ mà chúng đang thay đổi. Do đó, FileStateValue cho một cấu phần phần mềm trong kho lưu trữ bên ngoài cần phụ thuộc vào kho lưu trữ bên ngoài của chúng. Việc này do ExternalFilesHelper xử lý.

Liên kết kho lưu trữ

Có thể xảy ra trường hợp nhiều kho lưu trữ muốn phụ thuộc vào cùng một kho lưu trữ, nhưng ở các phiên bản khác nhau (đây là một thực thể của "vấn đề phần phụ thuộc kim cương"). Ví dụ: nếu hai tệp nhị phân trong các kho lưu trữ riêng biệt trong bản dựng muốn phụ thuộc vào Guava, thì cả hai tệp nhị phân đó đều sẽ tham chiếu đến Guava bằng các nhãn bắt đầu bằng @guava// và dự kiến đó sẽ là các phiên bản khác nhau của Guava.

Do đó, Bazel cho phép ánh xạ lại các nhãn kho lưu trữ bên ngoài để chuỗi @guava// có thể tham chiếu đến một kho lưu trữ Guava (chẳng hạn như @guava1//) trong kho lưu trữ của một tệp nhị phân và một kho lưu trữ Guava khác (chẳng hạn như @guava2//) là kho lưu trữ của tệp nhị phân còn lại.

Ngoài ra, bạn cũng có thể dùng thẻ này để tham gia kim cương. Nếu một kho lưu trữ phụ thuộc vào @guava1// và một kho lưu trữ khác phụ thuộc vào @guava2//, thì tính năng liên kết kho lưu trữ cho phép liên kết lại cả hai kho lưu trữ để sử dụng kho lưu trữ @guava// chính tắc.

Việc liên kết được chỉ định trong tệp WORKSPACE dưới dạng thuộc tính repo_mapping của các định nghĩa kho lưu trữ riêng lẻ. Sau đó, thành phần này sẽ xuất hiện trong Skyframe dưới dạng một thành phần của WorkspaceFileValue, trong đó thành phần này được kết nối với:

  • Package.Builder.repositoryMapping dùng để chuyển đổi các thuộc tính có giá trị nhãn của quy tắc trong gói bằng RuleClass.populateRuleAttributeValues()
  • Package.repositoryMapping được dùng trong giai đoạn phân tích (để giải quyết các vấn đề như $(location) không được phân tích cú pháp trong giai đoạn tải)
  • BzlLoadFunction để phân giải nhãn trong câu lệnh load()

Bit JNI

Máy chủ của Bazel chủ yếu được viết bằng Java. Trường hợp ngoại lệ là những phần mà Java không thể tự làm hoặc không thể tự làm khi chúng tôi triển khai. Điều này chủ yếu giới hạn ở việc tương tác với hệ thống tệp, kiểm soát quy trình và nhiều thứ cấp thấp khác.

Mã C++ nằm trong src/main/native và các lớp Java có các phương thức gốc là:

  • NativePosixFilesNativePosixFileSystem
  • ProcessUtils
  • WindowsFileOperationsWindowsFileProcesses
  • com.google.devtools.build.lib.platform

Kết quả xuất ra trên bảng điều khiển

Việc phát ra đầu ra của bảng điều khiển có vẻ như là một việc đơn giản, nhưng sự kết hợp của việc chạy nhiều quy trình (đôi khi từ xa), lưu vào bộ nhớ đệm chi tiết, mong muốn có đầu ra của thiết bị đầu cuối đẹp và đầy màu sắc cũng như có máy chủ chạy trong thời gian dài khiến việc này không hề đơn giản.

Ngay sau khi lệnh gọi RPC đến từ ứng dụng, hai thực thể RpcOutputStream sẽ được tạo (dành cho stdout và stderr) để chuyển tiếp dữ liệu được in vào các thực thể đó đến ứng dụng. Sau đó, các dòng này được gói trong OutErr (một cặp (stdout, stderr)). Mọi nội dung cần in trên bảng điều khiển đều phải đi qua các luồng này. Sau đó, các luồng này được chuyển giao cho BlazeCommandDispatcher.execExclusively().

Theo mặc định, kết quả được in bằng các chuỗi thoát ANSI. Khi không mong muốn (--color=no), các thuộc tính này sẽ bị AnsiStrippingOutputStream xoá bỏ. Ngoài ra, System.outSystem.err được chuyển hướng đến các luồng đầu ra này. Điều này là để có thể in thông tin gỡ lỗi bằng cách sử dụng System.err.println() và vẫn kết thúc trong đầu ra của thiết bị đầu cuối của ứng dụng (khác với đầu ra của máy chủ). Hãy cẩn thận để đảm bảo rằng nếu một quy trình tạo ra đầu ra nhị phân (chẳng hạn như bazel query --output=proto), thì sẽ không có quá trình kết nối thiết bị chuẩn.

Thông báo ngắn (lỗi, cảnh báo và các thông báo tương tự) được thể hiện thông qua giao diện EventHandler. Đáng chú ý là những nội dung này khác với nội dung một bài đăng lên EventBus (điều này gây nhầm lẫn). Mỗi Event có một EventKind (lỗi, cảnh báo, thông tin và một số lỗi khác) và chúng có thể có Location (vị trí trong mã nguồn khiến sự kiện xảy ra).

Một số cách triển khai EventHandler lưu trữ các sự kiện mà chúng nhận được. Phương thức này được dùng để phát lại thông tin cho giao diện người dùng do nhiều loại hoạt động xử lý được lưu vào bộ nhớ đệm gây ra, chẳng hạn như cảnh báo do mục tiêu được định cấu hình trong bộ nhớ đệm phát ra.

Một số EventHandler cũng cho phép đăng các sự kiện cuối cùng sẽ tìm thấy đường đến xe buýt sự kiện (Event thông thường _không_ xuất hiện ở đó). Đây là các cách triển khai ExtendedEventHandler và mục đích chính của các cách này là phát lại các sự kiện EventBus đã lưu vào bộ nhớ đệm. Các sự kiện EventBus này đều triển khai Postable, nhưng không phải mọi nội dung được đăng lên EventBus đều triển khai giao diện này; chỉ những nội dung được ExtendedEventHandler lưu vào bộ nhớ đệm (điều này sẽ rất tốt và hầu hết các nội dung đều thực hiện; tuy nhiên, điều này không được thực thi)

Đầu ra của dòng lệnh chủ yếu được phát qua UiEventHandler. Thành phần này chịu trách nhiệm về tất cả định dạng đầu ra và báo cáo tiến trình mà Bazel thực hiện. Hàm này có 2 dữ liệu đầu vào:

  • Xe buýt sự kiện
  • Luồng sự kiện được dẫn đến sự kiện đó thông qua Trình báo cáo

Kết nối trực tiếp duy nhất mà cơ chế thực thi lệnh (ví dụ: phần còn lại của Bazel) có với luồng RPC đến máy khách là thông qua Reporter.getOutErr(), cho phép truy cập trực tiếp vào các luồng này. Phương thức này chỉ được dùng khi một lệnh cần kết xuất một lượng lớn dữ liệu nhị phân có thể có (chẳng hạn như bazel query).

Phân tích tài nguyên Bazel

Bazel có tốc độ nhanh. Bazel cũng hoạt động chậm vì các bản dựng có xu hướng phát triển cho đến khi đạt đến ranh giới có thể chấp nhận được. Vì lý do này, Bazel thêm một trình phân tích tài nguyên có thể dùng để phân tích tài nguyên cho các bản dựng và cho chính Bazel. Lớp này được triển khai trong một lớp có tên là Profiler. Tính năng này được bật theo mặc định, mặc dù chỉ ghi lại dữ liệu rút gọn để chi phí hao tổn có thể chấp nhận được; Dòng lệnh --record_full_profiler_data giúp tính năng này ghi lại mọi thứ có thể.

Trình phân tích tài nguyên này sẽ tạo một hồ sơ ở định dạng trình phân tích tài nguyên Chrome; bạn nên xem hồ sơ này trong Chrome. Mô hình dữ liệu của ngăn xếp tác vụ là: một ngăn xếp tác vụ có thể bắt đầu tác vụ và kết thúc tác vụ, đồng thời các tác vụ này phải được lồng ghép gọn gàng vào nhau. Mỗi luồng Java sẽ có một ngăn xếp tác vụ riêng. VIỆC CẦN LÀM: Cách hoạt động của tính năng này với các thao tác và kiểu truyền tiếp tục như thế nào?

Trình phân tích tài nguyên lần lượt bắt đầu và dừng trong BlazeRuntime.initProfiler()BlazeRuntime.afterCommand(), đồng thời cố gắng hoạt động lâu nhất có thể để chúng ta có thể lập hồ sơ mọi thứ. Để thêm nội dung vào hồ sơ, hãy gọi Profiler.instance().profile(). Phương thức này trả về một Closeable, trong đó phần đóng biểu thị việc kết thúc tác vụ. Tốt nhất bạn nên sử dụng với câu lệnh try-with-resources.

Chúng ta cũng thực hiện việc phân tích bộ nhớ cơ bản trong MemoryProfiler. Chế độ này cũng luôn bật và chủ yếu ghi lại kích thước vùng nhớ khối xếp tối đa và hành vi của GC.

Thử nghiệm Bazel

Bazel có hai loại kiểm thử chính: những kiểm thử quan sát Bazel dưới dạng "hộp đen" và những kiểm thử chỉ chạy giai đoạn phân tích. Chúng ta gọi loại kiểm thử trước là "kiểm thử tích hợp" và loại kiểm thử sau là "kiểm thử đơn vị", mặc dù chúng giống như kiểm thử tích hợp nhưng ít tích hợp hơn. Chúng ta cũng có một số kiểm thử đơn vị thực tế, khi cần thiết.

Có hai loại kiểm thử tích hợp:

  1. Những lệnh được triển khai bằng một khung kiểm thử bash rất phức tạp trong src/test/shell
  2. Những hàm được triển khai trong Java. Các lớp này được triển khai dưới dạng lớp con của BuildIntegrationTestCase

BuildIntegrationTestCase là khung kiểm thử tích hợp ưu tiên vì khung này được trang bị tốt cho hầu hết các tình huống kiểm thử. Vì là một khung Java, nên khung này có khả năng gỡ lỗi và tích hợp liền mạch với nhiều công cụ phát triển phổ biến. Có nhiều ví dụ về lớp BuildIntegrationTestCase trong kho lưu trữ Bazel.

Các bài kiểm thử phân tích được triển khai dưới dạng lớp con của BuildViewTestCase. Bạn có thể sử dụng hệ thống tệp tạm để ghi tệp BUILD, sau đó nhiều phương thức trợ giúp có thể yêu cầu các mục tiêu đã định cấu hình, thay đổi cấu hình và xác nhận nhiều thông tin về kết quả của quá trình phân tích.