Hệ thống xây dựng dựa trên cấu phần phần mềm

Báo cáo sự cố Xem nguồn

Trang này đề cập đến các hệ thống xây dựng dựa trên cấu phần phần mềm và triết lý đằng sau việc tạo các hệ thống này. Bazel là một hệ thống xây dựng dựa trên cấu phần phần mềm. Mặc dù các hệ thống xây dựng dựa trên nhiệm vụ là lựa chọn tốt hơn so với tập lệnh bản dựng, nhưng các hệ thống này trao quá nhiều quyền hạn cho từng kỹ sư bằng cách cho phép họ tự xác định nhiệm vụ của mình.

Các hệ thống xây dựng dựa trên cấu phần phần mềm có một số ít nhiệm vụ do hệ thống xác định và các kỹ sư có thể định cấu hình theo cách hạn chế. Các kỹ sư vẫn cho hệ thống biết nội dung cần xây dựng, nhưng hệ thống xây dựng sẽ quyết định cách xây dựng ứng dụng đó. Tương tự như các hệ thống xây dựng dựa trên tác vụ, các hệ thống xây dựng dựa trên cấu phần phần mềm (chẳng hạn như Bazel) vẫn có các tệp bản dựng nhưng nội dung của các tệp bản dựng đó rất khác. Thay vì là một tập hợp các lệnh bắt buộc trong ngôn ngữ tập lệnh Turing-complete mô tả cách tạo đầu ra, các tệp bản dựng trong Bazel là một tệp kê khai khai báo mô tả một tập hợp cấu phần phần mềm cần tạo, các phần phụ thuộc và một tập hợp các tuỳ chọn giới hạn ảnh hưởng đến cách tạo các cấu phần phần mềm đó. Khi các kỹ sư chạy bazel trên dòng lệnh, họ sẽ chỉ định một tập hợp mục tiêu cần tạo (cái gì) và Baazel chịu trách nhiệm định cấu hình, chạy và lên lịch các bước biên dịch (cách thức). Vì hệ thống xây dựng hiện có toàn quyền kiểm soát các công cụ sẽ chạy vào thời điểm nào, nên hệ thống có thể đưa ra đảm bảo chắc chắn hơn nhiều, cho phép hệ thống hoạt động hiệu quả hơn nhiều mà vẫn đảm bảo tính chính xác.

Góc nhìn chức năng

Dễ dàng so sánh giữa các hệ thống xây dựng dựa trên cấu phần phần mềm và lập trình chức năng. Các ngôn ngữ lập trình bắt buộc truyền thống (chẳng hạn như Java, C và Python) chỉ định danh sách câu lệnh cần thực thi lần lượt, giống như cách các hệ thống xây dựng dựa trên tác vụ cho phép lập trình viên xác định một loạt bước cần thực thi. Ngược lại, các ngôn ngữ lập trình chức năng (chẳng hạn như Haskell và ML) có cấu trúc giống như một loạt phương trình toán học. Trong các ngôn ngữ chức năng, lập trình viên mô tả một phép tính cần thực hiện, nhưng để lại thông tin chi tiết về thời điểm và chính xác cách tính toán đó được thực thi cho trình biên dịch.

Điều này liên quan đến ý tưởng khai báo tệp kê khai trong một hệ thống xây dựng dựa trên cấu phần phần mềm và cho phép hệ thống tìm hiểu cách thực thi bản dựng. Nhiều vấn đề không thể dễ dàng được biểu thị bằng cách lập trình chức năng, nhưng những vấn đề đem lại lợi ích rất lớn: ngôn ngữ thường có thể song song hoá một cách bình thường các chương trình như vậy và đưa ra đảm bảo chắc chắn về tính chính xác của chúng mà sẽ không thể thực hiện được bằng ngôn ngữ bắt buộc. Các vấn đề dễ nhất để thể hiện bằng cách lập trình chức năng là những vấn đề chỉ liên quan đến việc chuyển đổi một phần dữ liệu thành một phần dữ liệu khác bằng một loạt quy tắc hoặc hàm. Và đó chính xác là hệ thống xây dựng: toàn bộ hệ thống thực sự là một hàm toán học lấy các tệp nguồn (và các công cụ như trình biên dịch) làm dữ liệu đầu vào và tạo ra các tệp nhị phân làm đầu ra. Vì vậy, không có gì đáng ngạc nhiên khi một hệ thống xây dựng dựa trên các nguyên lý lập trình chức năng hoạt động hiệu quả.

Tìm hiểu về các hệ thống xây dựng dựa trên cấu phần phần mềm

Hệ thống xây dựng của Google, Blaze, là hệ thống xây dựng dựa trên cấu phần phần mềm đầu tiên. Bazel là phiên bản nguồn mở của Blaze.

Sau đây là giao diện của một tệp bản dựng (thường có tên là BUILD) trong Bazel:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

Trong Bazel, các tệp BUILD xác định mục tiêu – hai loại mục tiêu ở đây là java_binaryjava_library. Mỗi mục tiêu tương ứng với một cấu phần phần mềm mà hệ thống có thể tạo: các mục tiêu nhị phân tạo ra các tệp nhị phân có thể thực thi trực tiếp, còn các mục tiêu thư viện tạo ra các thư viện mà hệ thống có thể sử dụng. Mỗi mục tiêu có:

  • name: cách mục tiêu được tham chiếu trên dòng lệnh và các mục tiêu khác
  • srcs: các tệp nguồn cần được biên dịch để tạo cấu phần phần mềm cho mục tiêu
  • deps: các mục tiêu khác phải được tạo trước mục tiêu này và được liên kết với mục tiêu đó

Các phần phụ thuộc có thể nằm trong cùng một gói (chẳng hạn như phần phụ thuộc của MyBinary vào :mylib) hoặc trên một gói khác trong cùng hệ phân cấp nguồn (chẳng hạn như phần phụ thuộc của mylib trên //java/com/example/common).

Giống như các hệ thống xây dựng dựa trên nhiệm vụ, bạn thực hiện các bản dựng bằng cách sử dụng công cụ dòng lệnh của Bazel. Để tạo mục tiêu MyBinary, bạn chạy bazel build :MyBinary. Sau khi nhập lệnh đó lần đầu tiên vào một kho lưu trữ sạch, Bazel:

  1. Phân tích cú pháp mọi tệp BUILD trong không gian làm việc để tạo một biểu đồ gồm các phần phụ thuộc giữa các cấu phần phần mềm.
  2. Sử dụng biểu đồ để xác định các phần phụ thuộc bắc cầu của MyBinary; tức là mọi mục tiêu mà MyBinary phụ thuộc vào và mọi mục tiêu mà các mục tiêu đó phụ thuộc theo cách đệ quy.
  3. Tạo từng phần phụ thuộc đó theo thứ tự. Bazel bắt đầu bằng cách tạo từng mục tiêu không có phần phụ thuộc nào khác, đồng thời theo dõi những phần phụ thuộc nào vẫn cần được tạo cho từng mục tiêu. Ngay khi tất cả các phần phụ thuộc của một mục tiêu được tạo, Bazel sẽ bắt đầu tạo mục tiêu đó. Quá trình này sẽ tiếp tục cho đến khi mọi phần phụ thuộc bắc cầu của MyBinary đã được tạo.
  4. Tạo MyBinary để tạo tệp nhị phân có thể thực thi cuối cùng liên kết tất cả các phần phụ thuộc được tạo ở bước 3.

Về cơ bản, có vẻ như những gì đang xảy ra ở đây không khác nhiều so với những gì đã xảy ra khi sử dụng hệ thống xây dựng dựa trên nhiệm vụ. Kết quả cuối cùng là cùng một tệp nhị phân và quy trình tạo tệp nhị phân đó bao gồm việc phân tích một loạt các bước để tìm các phần phụ thuộc giữa chúng, sau đó chạy các bước đó theo thứ tự. Tuy nhiên, có những điểm khác biệt quan trọng. Tập lệnh đầu tiên xuất hiện ở bước 3: vì Bazel biết rằng mỗi mục tiêu chỉ tạo một thư viện Java, nên Bazel biết rằng tất cả những gì cần làm là chạy trình biên dịch Java thay vì một tập lệnh tuỳ ý do người dùng xác định. Vì vậy, Bazel biết rằng có thể chạy song song các bước này một cách an toàn. Điều này có thể giúp cải thiện hiệu suất đáng kể so với việc xây dựng các mục tiêu lần lượt trên máy đa lõi, và chỉ có thể xảy ra vì phương pháp dựa trên cấu phần phần mềm sẽ khiến hệ thống xây dựng phụ trách chiến lược thực thi của riêng mình để có thể đưa ra đảm bảo chắc chắn hơn về tính song song.

Tuy nhiên, lợi ích của tính năng này không chỉ dừng lại ở tính song song. Điều tiếp theo mà phương pháp này mang lại cho chúng ta trở nên rõ ràng khi nhà phát triển nhập bazel build :MyBinary lần thứ hai mà không thực hiện bất kỳ thay đổi nào: Bazel thoát trong chưa đầy một giây với thông báo cho biết mục tiêu đã được cập nhật. Điều này có thể xảy ra do mô hình lập trình chức năng mà chúng ta đã đề cập trước đó. Bazel biết rằng mỗi mục tiêu chỉ là kết quả của việc chạy trình biên dịch Java và biết rằng đầu ra từ trình biên dịch Java chỉ phụ thuộc vào đầu vào của trình biên dịch Java, miễn là đầu vào không thay đổi, đầu ra có thể được sử dụng lại. Bản phân tích này hoạt động ở mọi cấp độ; nếu MyBinary.java thay đổi, Bazel biết rằng cần tạo lại MyBinary nhưng sử dụng lại mylib. Nếu một tệp nguồn cho //java/com/example/common thay đổi, Bazel biết phải tạo lại thư viện đó, mylibMyBinary, nhưng sử dụng lại //java/com/example/myproduct/otherlib. Vì biết về các thuộc tính của các công cụ mà Bazel chạy ở mọi bước, nên mỗi lần Bazel chỉ có thể tạo lại tập hợp cấu phần phần mềm tối thiểu, đồng thời đảm bảo rằng sẽ không tạo ra các bản dựng cũ.

Việc định lại khung hình cho quá trình xây dựng về cấu phần phần mềm thay vì tác vụ là một cách tinh tế nhưng mạnh mẽ. Bằng cách giảm tính linh hoạt mà lập trình viên gặp phải, hệ thống xây dựng có thể biết thêm về những gì đang được thực hiện ở mỗi bước của quá trình tạo bản dựng. SDK có thể vận dụng kiến thức này để tăng hiệu quả tạo bản dựng bằng cách song song hoá các quy trình xây dựng và tái sử dụng kết quả đầu ra của các quy trình đó. Nhưng đây thực sự chỉ là bước đầu tiên và các khối xây dựng tính song song và tái sử dụng này tạo thành cơ sở cho một hệ thống xây dựng được phân phối và có khả năng mở rộng cao.

Các thủ thuật Bazel tiện lợi khác

Các hệ thống xây dựng dựa trên cấu phần phần mềm về cơ bản sẽ giải quyết các vấn đề về tính song song và tái sử dụng vốn có trong các hệ thống xây dựng dựa trên nhiệm vụ. Tuy nhiên, vẫn còn một vài vấn đề phát sinh trước đó mà chúng tôi chưa giải quyết. Bazel có những cách thông minh để giải quyết từng vấn đề này. Chúng ta nên thảo luận về chúng trước khi tiếp tục.

Công cụ dưới dạng phần phụ thuộc

Một vấn đề mà chúng tôi gặp phải trước đó là các bản dựng phụ thuộc vào các công cụ được cài đặt trên máy của chúng tôi và việc tái tạo các bản dựng trên các hệ thống có thể khó khăn do các phiên bản công cụ hoặc vị trí khác nhau. Vấn đề này càng trở nên khó khăn hơn khi dự án của bạn sử dụng các ngôn ngữ yêu cầu nhiều công cụ khác nhau dựa trên nền tảng xây dựng hoặc biên dịch ngôn ngữ đó (chẳng hạn như Windows so với Linux) và mỗi nền tảng trong số đó yêu cầu một bộ công cụ hơi khác nhau để thực hiện cùng một công việc.

Bazel giải quyết phần đầu tiên của vấn đề này bằng cách coi các công cụ là phần phụ thuộc đối với từng mục tiêu. Mọi java_library trong không gian làm việc ngầm ẩn phụ thuộc vào một trình biên dịch Java (mặc định là một trình biên dịch phổ biến). Mỗi khi tạo một java_library, Bazel sẽ kiểm tra để đảm bảo rằng trình biên dịch đã chỉ định đang hoạt động tại một vị trí đã biết. Giống như mọi phần phụ thuộc khác, nếu trình biên dịch Java thay đổi, thì mọi cấu phần phần mềm phụ thuộc vào cấu phần phần mềm đó sẽ được xây dựng lại.

Bazel giải quyết phần thứ hai của vấn đề là sự độc lập của nền tảng bằng cách đặt cấu hình bản dựng. Thay vì các mục tiêu phụ thuộc trực tiếp vào công cụ, chúng phụ thuộc vào loại cấu hình:

  • Cấu hình máy chủ lưu trữ: tạo các công cụ chạy trong quá trình tạo bản dựng
  • Cấu hình mục tiêu: tạo tệp nhị phân mà bạn yêu cầu sau cùng

Mở rộng hệ thống xây dựng

Bazel đưa ra các mục tiêu cho một số ngôn ngữ lập trình phổ biến ngay từ đầu, nhưng các kỹ sư luôn muốn làm nhiều việc hơn – một phần lợi ích của hệ thống dựa trên nhiệm vụ là tính linh hoạt trong việc hỗ trợ mọi loại quy trình xây dựng và tốt hơn là không nên từ bỏ điều đó trong một hệ thống xây dựng dựa trên cấu phần phần mềm. May mắn là Bazel cho phép mở rộng các loại mục tiêu được hỗ trợ bằng cách thêm các quy tắc tuỳ chỉnh.

Để xác định một quy tắc trong Bazel, tác giả quy tắc sẽ khai báo dữ liệu đầu vào mà quy tắc đó yêu cầu (ở dạng các thuộc tính được truyền trong tệp BUILD) và tập hợp các kết quả cố định mà quy tắc đó tạo ra. Tác giả cũng xác định các thao tác sẽ được tạo theo quy tắc đó. Mỗi hành động đều khai báo dữ liệu đầu vào và đầu ra, chạy một tệp thực thi cụ thể hoặc ghi một chuỗi cụ thể vào một tệp và có thể được kết nối với các hành động khác thông qua dữ liệu đầu vào và đầu ra của hành động đó. Tức là hành động là đơn vị thành phần kết hợp cấp thấp nhất trong hệ thống xây dựng. Hành động có thể thực hiện bất cứ điều gì nó muốn, miễn là hành động đó chỉ sử dụng dữ liệu đầu vào và đầu ra đã khai báo, còn Bazel sẽ xử lý việc lên lịch hành động và lưu kết quả vào bộ nhớ đệm khi phù hợp.

Hệ thống này không chắc chắn hiệu quả vì không có cách nào để ngăn chặn một nhà phát triển hành động làm điều gì đó, chẳng hạn như đưa ra một quy trình không tất định trong hành động của họ. Tuy nhiên, điều này không xảy ra quá thường xuyên trong thực tế và việc đẩy hết khả năng lạm dụng xuống đến cấp độ hành động sẽ làm giảm đáng kể khả năng xảy ra lỗi. Các quy tắc hỗ trợ nhiều ngôn ngữ và công cụ phổ biến được cung cấp rộng rãi trên mạng và hầu hết dự án sẽ không bao giờ cần xác định quy tắc riêng. Ngay cả đối với những quy tắc đó, bạn chỉ cần xác định định nghĩa quy tắc ở một vị trí trung tâm trong kho lưu trữ, tức là hầu hết các kỹ sư sẽ có thể sử dụng các quy tắc đó mà không phải lo lắng về việc triển khai.

Cô lập môi trường

Các thao tác có vẻ như có thể gặp phải cùng một vấn đề như các tác vụ trong các hệ thống khác – không phải là vẫn có thể viết các thao tác vừa ghi vào cùng một tệp và cuối cùng lại xung đột với nhau phải không? Trên thực tế, Bazel loại bỏ những xung đột này bằng cách sử dụng hộp cát. Trên các hệ thống được hỗ trợ, mọi thao tác sẽ được tách biệt với mọi thao tác khác qua hộp cát hệ thống tệp. Trên thực tế, mỗi hành động chỉ có thể thấy một khung hiển thị bị hạn chế của hệ thống tệp, bao gồm các đầu vào mà thao tác đã khai báo và mọi đầu ra mà thao tác đã tạo. Việc này được thực thi bằng các hệ thống như LXC trên Linux, chính là công nghệ phía sau Docker. Điều này có nghĩa là các hành động không thể xung đột với một hành động khác vì chúng không thể đọc bất kỳ tệp nào mà chúng không khai báo, đồng thời mọi tệp mà chúng ghi nhưng không khai báo sẽ bị loại bỏ khi thao tác kết thúc. Bazel cũng sử dụng hộp cát để hạn chế các thao tác giao tiếp qua mạng.

Xác định các phần phụ thuộc bên ngoài

Vẫn còn một vấn đề nữa: các hệ thống xây dựng thường cần tải các phần phụ thuộc xuống (cho dù là công cụ hay thư viện) từ các nguồn bên ngoài, thay vì trực tiếp tạo các phần phụ thuộc đó. Bạn có thể thấy điều này trong ví dụ thông qua phần phụ thuộc @com_google_common_guava_guava//jar. Phần phụ thuộc này tải tệp JAR xuống qua Maven.

Việc phụ thuộc vào các tệp bên ngoài không gian làm việc hiện tại sẽ gây ra nhiều rủi ro. Các tệp đó có thể thay đổi bất cứ lúc nào, do đó có thể yêu cầu hệ thống xây dựng phải liên tục kiểm tra xem chúng có mới hay không. Nếu một tệp từ xa thay đổi mà không có thay đổi tương ứng trong mã nguồn không gian làm việc, thì điều này cũng có thể dẫn đến các bản dựng không thể mô phỏng – một bản dựng có thể hoạt động vào một ngày và bị lỗi vào ngày tiếp theo mà không có lý do rõ ràng do sự thay đổi về phần phụ thuộc mà không được chú ý. Cuối cùng, phần phụ thuộc bên ngoài có thể gây ra rủi ro bảo mật rất lớn khi thuộc sở hữu của bên thứ ba: nếu kẻ tấn công có thể xâm nhập vào máy chủ của bên thứ ba đó, chúng có thể thay thế tệp phần phụ thuộc bằng một nội dung nào đó do chính họ thiết kế, qua đó có thể cho chúng toàn quyền kiểm soát môi trường tạo bản dựng và đầu ra của tệp đó.

Vấn đề cơ bản là chúng ta muốn hệ thống xây dựng nhận biết được những tệp này mà không cần phải kiểm tra trong phần kiểm soát nguồn. Việc cập nhật phần phụ thuộc là một lựa chọn sáng suốt, nhưng bạn nên thực hiện lựa chọn đó một lần ở vị trí trung tâm thay vì do từng kỹ sư riêng lẻ quản lý hoặc do hệ thống tự động quản lý. Lý do là ngay cả với mô hình "Trực tiếp", chúng tôi vẫn muốn các bản dựng có tính xác định, tức là nếu kiểm tra một cam kết từ tuần trước, bạn sẽ thấy các phần phụ thuộc như lúc trước chứ không phải như bây giờ.

Bazel và một số hệ thống xây dựng khác giải quyết vấn đề này bằng cách yêu cầu một tệp kê khai trên toàn workspace có liệt kê hàm băm mật mã cho mọi phần phụ thuộc bên ngoài trong không gian làm việc. Hàm băm là một cách ngắn gọn để biểu thị riêng từng tệp mà không cần kiểm tra toàn bộ tệp trong phần kiểm soát nguồn. Bất cứ khi nào một phần phụ thuộc mới bên ngoài được tham chiếu từ một không gian làm việc, hàm băm của phần phụ thuộc đó sẽ được thêm vào tệp kê khai, theo cách thủ công hoặc tự động. Khi chạy một bản dựng, Bazel sẽ kiểm tra hàm băm thực tế của phần phụ thuộc đã lưu vào bộ nhớ đệm so với hàm băm dự kiến được xác định trong tệp kê khai và chỉ tải lại tệp xuống nếu hàm băm đó khác.

Nếu cấu phần phần mềm mà chúng ta tải xuống có hàm băm khác với hàm băm được khai báo trong tệp kê khai, thì bản dựng đó sẽ không thành công trừ phi bạn cập nhật hàm băm trong tệp kê khai. Việc này có thể được thực hiện tự động, nhưng thay đổi đó phải được phê duyệt và kiểm tra trong phần kiểm soát nguồn trước khi bản dựng chấp nhận phần phụ thuộc mới. Điều này có nghĩa là luôn có bản ghi về thời điểm cập nhật phần phụ thuộc, đồng thời phần phụ thuộc bên ngoài không thể thay đổi nếu không có thay đổi tương ứng trong nguồn không gian làm việc. Điều này cũng có nghĩa là khi kiểm tra phiên bản cũ của mã nguồn, bản dựng được đảm bảo sử dụng chính các phần phụ thuộc mà bản dựng đang sử dụng tại thời điểm phiên bản đó được đăng ký (nếu không, bản dựng sẽ không thành công nếu những phần phụ thuộc đó không còn nữa).

Đương nhiên, vấn đề vẫn có thể xảy ra nếu một máy chủ từ xa không hoạt động hoặc bắt đầu phân phát dữ liệu bị hỏng. Điều này có thể khiến tất cả các bản dựng gặp lỗi nếu bạn không có sẵn bản sao khác của phần phụ thuộc đó. Để tránh vấn đề này, đối với mọi dự án không quan trọng, bạn nên phản chiếu tất cả các phần phụ thuộc của dự án đó lên các máy chủ hoặc dịch vụ mà bạn tin tưởng và kiểm soát. Nếu không, bạn sẽ luôn đứng trước sự can thiệp của bên thứ ba về khả năng hoạt động của hệ thống xây dựng, ngay cả khi hàm băm đăng ký đảm bảo tính bảo mật của hệ thống.