Hướng dẫn về Bazel: Tạo một dự án Go

Hướng dẫn này giới thiệu cho bạn những kiến thức cơ bản về Bazel bằng cách hướng dẫn bạn cách tạo một dự án Go (Golang). Bạn sẽ tìm hiểu cách thiết lập không gian làm việc, tạo một chương trình nhỏ, nhập thư viện và chạy chương trình kiểm thử. Trong quá trình này, bạn sẽ tìm hiểu các khái niệm chính về Bazel, chẳng hạn như mục tiêu và tệp BUILD.

Thời gian hoàn thành ước tính: 30 phút

Trước khi bắt đầu

Cài đặt Bazel

Trước khi bắt đầu, trước tiên hãy cài đặt Bazel nếu bạn chưa làm rồi.

Bạn có thể kiểm tra xem Bazel đã được cài đặt hay chưa bằng cách chạy bazel version trong bất kỳ thư mục nào.

Cài đặt Go (không bắt buộc)

Bạn không cần cài đặt Go để tạo dự án Go bằng Bazel. Bộ quy tắc Bazel Go tự động tải xuống và sử dụng chuỗi công cụ Go thay vì sử dụng chuỗi công cụ được cài đặt trên máy của bạn. Điều này đảm bảo tất cả nhà phát triển trong một dự án đều tạo bằng cùng một phiên bản Go.

Tuy nhiên, bạn vẫn có thể muốn cài đặt chuỗi công cụ Go để chạy các lệnh như go getgo mod tidy.

Bạn có thể kiểm tra xem Go đã được cài đặt hay chưa bằng cách chạy go version trong bất kỳ thư mục nào.

Tải dự án mẫu

Các ví dụ về Bazel được lưu trữ trong kho lưu trữ Git, vì vậy, bạn cần cài đặt Git nếu bạn chưa làm. Để tải kho lưu trữ ví dụ xuống, hãy chạy lệnh sau:

git clone https://github.com/bazelbuild/examples

Dự án mẫu cho hướng dẫn này nằm trong thư mục examples/go-tutorial. Xem nội dung của dự án:

go-tutorial/
└── stage1
└── stage2
└── stage3

Có 3 thư mục con (stage1, stage2stage3), mỗi thư mục dành cho một phần khác nhau của hướng dẫn này. Mỗi giai đoạn đều dựa trên giai đoạn trước đó.

Tạo bằng Bazel

Bắt đầu trong thư mục stage1, nơi chúng ta sẽ tìm thấy một chương trình. Chúng ta có thể tạo chương trình này bằng bazel build, sau đó chạy chương trình:

$ cd go-tutorial/stage1/
$ bazel build //:hello
INFO: Analyzed target //:hello (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //:hello up-to-date:
  bazel-bin/hello_/hello
INFO: Elapsed time: 0.473s, Critical Path: 0.25s
INFO: 3 processes: 1 internal, 2 darwin-sandbox.
INFO: Build completed successfully, 3 total actions

$ bazel-bin/hello_/hello
Hello, Bazel! 💚

Chúng ta cũng có thể tạo chương trình bằng một lệnh bazel run duy nhất:

$ bazel run //:hello
bazel run //:hello
INFO: Analyzed target //:hello (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //:hello up-to-date:
  bazel-bin/hello_/hello
INFO: Elapsed time: 0.128s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/hello_/hello
Hello, Bazel! 💚

Tìm hiểu cấu trúc dự án

Hãy xem dự án mà chúng ta vừa tạo.

hello.go chứa mã nguồn Go cho chương trình.

package main

import "fmt"

func main() {
    fmt.Println("Hello, Bazel! 💚")
}

BUILD chứa một số hướng dẫn cho Bazel, cho biết những gì chúng ta muốn tạo. Bạn thường sẽ viết một tệp như thế này trong mỗi thư mục. Đối với dự án này, chúng ta có một mục tiêu go_binary duy nhất tạo chương trình từ hello.go.

load("@rules_go//go:def.bzl", "go_binary")

go_binary(
    name = "hello",
    srcs = ["hello.go"],
)

MODULE.bazel theo dõi các phần phụ thuộc của dự án. Tệp này cũng đánh dấu thư mục gốc của dự án, vì vậy, bạn chỉ cần viết một tệp MODULE.bazel cho mỗi dự án. Tệp này có mục đích tương tự như tệp go.mod của Go. Bạn không thực sự cần tệp go.mod trong dự án Bazel, nhưng bạn vẫn nên có tệp này để có thể tiếp tục sử dụng go getgo mod tidy để quản lý phần phụ thuộc. Bộ quy tắc Bazel Go có thể nhập các phần phụ thuộc từ go.mod, nhưng chúng ta sẽ đề cập đến vấn đề này trong một hướng dẫn khác.

Tệp MODULE.bazel của chúng ta chứa một phần phụ thuộc duy nhất vào rules_go, bộ quy tắc Go. Chúng ta cần phần phụ thuộc này vì Bazel không có hỗ trợ tích hợp cho Go.

bazel_dep(
    name = "rules_go",
    version = "0.50.1",
)

Cuối cùng, MODULE.bazel.lock là một tệp do Bazel tạo, chứa các giá trị băm và siêu dữ liệu khác về các phần phụ thuộc của chúng ta. Tệp này bao gồm các phần phụ thuộc ngầm ẩn do chính Bazel thêm vào, vì vậy, tệp này khá dài và chúng ta sẽ không hiển thị tệp này ở đây. Giống như go.sum, bạn nên chuyển phát tệp MODULE.bazel.lock với hệ thống quản lý phiên bản để đảm bảo mọi người trong dự án của bạn đều nhận được cùng một phiên bản của mỗi phần phụ thuộc. Bạn không cần chỉnh sửa MODULE.bazel.lock theo cách thủ công.

Tìm hiểu tệp BUILD

Hầu hết hoạt động tương tác của bạn với Bazel sẽ thông qua các tệp BUILD (hoặc tương đương, tệp BUILD.bazel), vì vậy, bạn cần hiểu rõ chức năng của các tệp này.

Các tệp BUILD được viết bằng ngôn ngữ tập lệnh có tên là Starlark, một tập hợp con giới hạn của Python.

Tệp BUILD chứa danh sách các mục tiêu. Mục tiêu là thứ mà Bazel có thể tạo, chẳng hạn như tệp nhị phân, thư viện hoặc chương trình kiểm thử.

Mục tiêu gọi hàm quy tắc bằng danh sách thuộc tính để mô tả những gì cần tạo. Ví dụ của chúng ta có 2 thuộc tính: name xác định mục tiêu trên dòng lệnh và srcs là danh sách đường dẫn tệp nguồn (được phân tách bằng dấu gạch chéo, tương ứng với thư mục chứa tệp BUILD).

Một quy tắc cho Bazel biết cách tạo mục tiêu. Trong ví dụ của chúng ta, chúng ta đã sử dụng quy tắc go_binary. Mỗi quy tắc xác định các hành động (lệnh) tạo ra một tập hợp tệp đầu ra. Ví dụ: go_binary xác định các hành động biên dịch và liên kết Go tạo ra tệp đầu ra thực thi.

Bazel có các quy tắc tích hợp cho một số ngôn ngữ như Java và C++. Bạn có thể tìm thấy tài liệu về các quy tắc này trong Bách khoa toàn thư về bản dựng. Bạn có thể tìm thấy bộ quy tắc cho nhiều ngôn ngữ và công cụ khác trên Sổ đăng ký trung tâm Bazel (BCR).

Thêm thư viện

Chuyển sang thư mục stage2, nơi chúng ta sẽ tạo một chương trình mới in ra vận may của bạn. Chương trình này sử dụng một gói Go riêng biệt làm thư viện chọn vận may từ danh sách tin nhắn được xác định trước.

go-tutorial/stage2
├── BUILD
├── MODULE.bazel
├── MODULE.bazel.lock
├── fortune
│   ├── BUILD
│   └── fortune.go
└── print_fortune.go

fortune.go là tệp nguồn cho thư viện. Thư viện fortune là một gói Go riêng biệt, vì vậy, các tệp nguồn của thư viện này nằm trong một thư mục riêng biệt. Bazel không yêu cầu bạn giữ các gói Go trong các thư mục riêng biệt, nhưng đây là một quy ước mạnh mẽ trong hệ sinh thái Go và việc tuân theo quy ước này sẽ giúp bạn duy trì khả năng tương thích với các công cụ Go khác.

package fortune

import "math/rand"

var fortunes = []string{
    "Your build will complete quickly.",
    "Your dependencies will be free of bugs.",
    "Your tests will pass.",
}

func Get() string {
    return fortunes[rand.Intn(len(fortunes))]
}

Thư mục fortune có tệp BUILD riêng cho Bazel biết cách tạo gói này. Chúng ta sử dụng go_library ở đây thay vì go_binary.

Chúng ta cũng cần đặt thuộc tính importpath thành một chuỗi mà thư viện có thể được nhập vào các tệp nguồn Go khác. Tên này phải là đường dẫn kho lưu trữ (hoặc đường dẫn mô-đun) được nối với thư mục trong kho lưu trữ.

Cuối cùng, chúng ta cần đặt thuộc tính visibility thành ["//visibility:public"]. visibility có thể được đặt trên bất kỳ mục tiêu nào. Thuộc tính này xác định những gói Bazel có thể phụ thuộc vào mục tiêu này. Trong trường hợp của chúng ta, chúng ta muốn bất kỳ mục tiêu nào cũng có thể phụ thuộc vào thư viện này, vì vậy, chúng ta sử dụng giá trị đặc biệt //visibility:public.

load("@rules_go//go:def.bzl", "go_library")

go_library(
    name = "fortune",
    srcs = ["fortune.go"],
    importpath = "github.com/bazelbuild/examples/go-tutorial/stage2/fortune",
    visibility = ["//visibility:public"],
)

Bạn có thể tạo thư viện này bằng:

$ bazel build //fortune

Tiếp theo, hãy xem cách print_fortune.go sử dụng gói này.

package main

import (
    "fmt"

    "github.com/bazelbuild/examples/go-tutorial/stage2/fortune"
)

func main() {
    fmt.Println(fortune.Get())
}

print_fortune.go nhập gói bằng cùng một chuỗi được khai báo trong thuộc tính importpath của thư viện fortune.

Chúng ta cũng cần khai báo phần phụ thuộc này cho Bazel. Dưới đây là tệp BUILD trong thư mục stage2.

load("@rules_go//go:def.bzl", "go_binary")

go_binary(
    name = "print_fortune",
    srcs = ["print_fortune.go"],
    deps = ["//fortune"],
)

Bạn có thể chạy tệp này bằng lệnh bên dưới.

bazel run //:print_fortune

Mục tiêu print_fortune có thuộc tính deps, danh sách các mục tiêu khác mà mục tiêu này phụ thuộc vào. Thuộc tính này chứa "//fortune", một chuỗi nhãn tham chiếu đến mục tiêu trong thư mục fortune có tên là fortune.

Bazel yêu cầu tất cả mục tiêu khai báo rõ ràng các phần phụ thuộc bằng các thuộc tính như deps. Điều này có thể gây khó khăn vì các phần phụ thuộc cũng được chỉ định trong tệp nguồn, nhưng tính rõ ràng của Bazel mang lại lợi thế cho Bazel. Bazel tạo biểu đồ hành động chứa tất cả các lệnh, đầu vào và đầu ra trước khi chạy bất kỳ lệnh nào, mà không cần đọc bất kỳ tệp nguồn nào. Sau đó, Bazel có thể lưu kết quả hành động vào bộ nhớ đệm hoặc gửi các hành động để thực thi từ xa mà không cần logic tích hợp dành riêng cho ngôn ngữ.

Tìm hiểu nhãn

Một nhãn là một chuỗi mà Bazel sử dụng để xác định mục tiêu hoặc tệp. Nhãn được sử dụng trong các đối số dòng lệnh và trong các thuộc tính tệp BUILD như deps. Chúng ta đã thấy một số nhãn, chẳng hạn như //fortune, //:print-fortune@rules_go//go:def.bzl.

Nhãn có 3 phần: tên kho lưu trữ, tên gói và tên mục tiêu (hoặc tệp).

Tên kho lưu trữ được viết giữa @// và được dùng để tham chiếu đến mục tiêu từ một mô-đun Bazel khác (vì các lý do trước đây, mô-đunkho lưu trữ đôi khi được dùng thay thế cho nhau). Trong nhãn @rules_go//go:def.bzl, tên kho lưu trữ là rules_go. Bạn có thể bỏ qua tên kho lưu trữ khi tham chiếu đến các mục tiêu trong cùng một kho lưu trữ.

Tên gói được viết giữa //: và được dùng để tham chiếu đến mục tiêu trong một gói Bazel khác. Trong nhãn @rules_go//go:def.bzl, tên gói là go. Gói Bazel package là một tập hợp các tệp và mục tiêu được xác định bởi tệp BUILD hoặc BUILD.bazel trong thư mục cấp cao nhất. Tên gói là đường dẫn được phân tách bằng dấu gạch chéo từ thư mục gốc của mô-đun (chứa MODULE.bazel) đến thư mục chứa tệp BUILD. Gói có thể bao gồm các thư mục con, nhưng chỉ khi các thư mục con đó không chứa tệp BUILD xác định gói riêng của chúng.

Hầu hết các dự án Go đều có một tệp BUILD cho mỗi thư mục và một gói Go cho mỗi tệp BUILD. Bạn có thể bỏ qua tên gói trong nhãn khi tham chiếu đến các mục tiêu trong cùng một thư mục.

Tên mục tiêu được viết sau : và tham chiếu đến mục tiêu trong gói. Bạn có thể bỏ qua tên mục tiêu nếu tên này giống với thành phần cuối cùng của tên gói (vì vậy, //a/b/c:c giống với //a/b/c; //fortune:fortune giống với //fortune).

Trên dòng lệnh, bạn có thể sử dụng ... làm ký tự đại diện để tham chiếu đến tất cả các mục tiêu trong gói. Điều này hữu ích khi tạo hoặc kiểm thử tất cả các mục tiêu trong kho lưu trữ.

# Build everything
$ bazel build //...

Kiểm thử dự án

Tiếp theo, hãy chuyển đến thư mục stage3, nơi chúng ta sẽ thêm chương trình kiểm thử.

go-tutorial/stage3
├── BUILD
├── MODULE.bazel
├── MODULE.bazel.lock
├── fortune
│   ├── BUILD
│   ├── fortune.go
│   └── fortune_test.go
└── print-fortune.go

fortune/fortune_test.go là tệp nguồn kiểm thử mới của chúng ta.

package fortune

import (
    "slices"
    "testing"
)

// TestGet checks that Get returns one of the strings from fortunes.
func TestGet(t *testing.T) {
    msg := Get()
    if i := slices.Index(fortunes, msg); i < 0 {
        t.Errorf("Get returned %q, not one the expected messages", msg)
    }
}

Tệp này sử dụng biến fortunes chưa xuất, vì vậy, tệp này cần được biên dịch thành cùng một gói Go với fortune.go. Hãy xem tệp BUILD để biết cách hoạt động:

load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
    name = "fortune",
    srcs = ["fortune.go"],
    importpath = "github.com/bazelbuild/examples/go-tutorial/stage3/fortune",
    visibility = ["//visibility:public"],
)

go_test(
    name = "fortune_test",
    srcs = ["fortune_test.go"],
    embed = [":fortune"],
)

Chúng ta có mục tiêu fortune_test mới sử dụng quy tắc go_test để biên dịch và liên kết tệp thực thi kiểm thử. go_test cần biên dịch fortune.gofortune_test.go cùng với cùng một lệnh, vì vậy, chúng ta sử dụng thuộc tính embed ở đây để kết hợp các thuộc tính của mục tiêu fortune vào fortune_test. embed thường được dùng nhất với go_testgo_binary, nhưng cũng hoạt động với go_library, đôi khi hữu ích cho mã đã tạo.

Bạn có thể thắc mắc liệu thuộc tính embed có liên quan đến gói embed của Go hay không. Gói này được dùng để truy cập các tệp dữ liệu được sao chép vào tệp thực thi. Đây là trường hợp trùng tên đáng tiếc: thuộc tính embed của rules_go được giới thiệu trước gói embed của Go. Thay vào đó, rules_go sử dụng embedsrcs để liệt kê các tệp có thể được tải bằng gói embed.

Hãy thử chạy chương trình kiểm thử bằng bazel test:

$ bazel test //fortune:fortune_test
INFO: Analyzed target //fortune:fortune_test (0 packages loaded, 0 targets configured).
INFO: Found 1 test target...
Target //fortune:fortune_test up-to-date:
  bazel-bin/fortune/fortune_test_/fortune_test
INFO: Elapsed time: 0.168s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
//fortune:fortune_test                                          PASSED in 0.3s

Executed 0 out of 1 test: 1 test passes.

Bạn có thể sử dụng ký tự đại diện ... để chạy tất cả các chương trình kiểm thử. Bazel cũng sẽ tạo các mục tiêu không phải là chương trình kiểm thử, vì vậy, điều này có thể phát hiện lỗi biên dịch ngay cả trong các gói không có chương trình kiểm thử.

$ bazel test //...

Kết luận và tài liệu đọc thêm

Trong hướng dẫn này, chúng ta đã tạo và kiểm thử một dự án Go nhỏ bằng Bazel, đồng thời tìm hiểu một số khái niệm cốt lõi về Bazel.

  • Để bắt đầu tạo các ứng dụng khác bằng Bazel, hãy xem hướng dẫn về C++, Java, Android, và iOS.
  • Bạn cũng có thể xem danh sách các quy tắc được đề xuất cho các ngôn ngữ khác.
  • Để biết thêm thông tin về Go, hãy xem mô-đun rules_go, đặc biệt là tài liệu về các quy tắc cốt lõi của Go.
  • Để tìm hiểu thêm về cách làm việc với các mô-đun Bazel bên ngoài dự án của bạn, hãy xem phần phụ thuộc bên ngoài. Cụ thể, để biết thông tin về cách phụ thuộc vào các mô-đun và chuỗi công cụ Go thông qua hệ thống mô-đun của Bazel, hãy xem phần Go với bzlmod.