BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangCourse

Xử lý lỗi trong Go

Go không sử dụng exception như cơ chế xử lý lỗi thông thường. Bạn sẽ không viết try, catch hay throw như trong Java, C# hoặc JavaScript. Thay vào đó, một hàm có khả năng thất bại thường trả về error bằng return của hàm. Nếu bạn không quen trông có vẻ khó chịu, nhưng đối với Gopher thì cảm giác nó rất tường minh.

DUKE315 viewsJuly 20, 2026
Xử lý lỗi trong Go

Bên gọi nhận lỗi, kiểm tra nó và quyết định cách xử lý:

result, err := doSomething()
if err != nil {
// Xử lý lỗi
}

Ban đầu, cách viết này có thể tạo cảm giác lặp lại. Tuy nhiên, nó mang lại một lợi ích quan trọng: mọi điểm có thể thất bại đều được thể hiện trực tiếp trong luồng thực thi của chương trình.

Bạn không cần tìm xem một exception được throw ở tầng dưới sẽ được bắt bởi khối catch nào ở phía trên.

Trong bài này, chúng ta sẽ tìm hiểu:

  • Interface error.
  • Cách tạo và trả về error.
  • Cách xử lý error theo phong cách idiomatic.
  • Sentinel error và custom error.
  • errors.Is và errors.As.
  • Error wrapping và unwrapping.
  • errors.Join.
  • Sự khác nhau giữa error và panic.
  • Những lỗi phổ biến khi xử lý error trong Go.

Interface error

Trong Go, error là một interface có sẵn trong ngôn ngữ:

type error interface {
Error() string
}

Interface này chỉ yêu cầu một method:

Error() string

Bất kỳ kiểu dữ liệu nào có method Error() string đều tự động thỏa mãn interface error.

Không cần khai báo implements, bởi vì Go sử dụng cơ chế hiện thực interface ngầm định.

Ví dụ:

type MyError struct{}

func (MyError) Error() string {
return "something went wrong"
}

MyError có method Error() string, vì vậy nó có thể được sử dụng như một error:

var err error = MyError{}

fmt.Println(err)
something went wrong

Điều này cũng cho thấy một đặc điểm quan trọng:

Error trong Go chỉ là một giá trị bình thường.

Bạn có thể:

  • Gán error vào biến.
  • Truyền error vào hàm.
  • Trả error từ hàm.
  • Wrap error bên trong một error khác.
  • Kiểm tra giá trị hoặc kiểu cụ thể của error.

Ví dụ cơ bản: hàm Divide

Hãy viết một hàm chia hai số nguyên:

func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}

return a / b, nil
}

Hàm trả về hai giá trị:

(int, error)

Trong đó:

  • int là kết quả phép chia.
  • error cho biết thao tác có thất bại hay không.

Nếu b bằng 0, hàm trả về:

return 0, errors.New("cannot divide by zero")

Nếu phép chia hợp lệ, hàm trả về kết quả cùng với nil:

return a / b, nil

Trong Go, nil ở vị trí error thường có nghĩa là thao tác đã thành công.

Vì sao error thường được đặt cuối cùng?

Theo convention của Go, khi một hàm trả về nhiều giá trị, error nên được đặt ở vị trí cuối cùng:

func Divide(a, b int) (int, error)

Không nên viết:

func Divide(a, b int) (error, int)

Đây không phải là giới hạn của compiler, nhưng việc phá vỡ convention sẽ khiến API khó đọc và không đồng nhất với phần còn lại của hệ sinh thái Go.

Tạo error bằng errors.New

errors.New là cách đơn giản nhất để tạo một error:

err := errors.New("cannot divide by zero")

Hàm này nhận vào một chuỗi và trả về một giá trị thỏa mãn interface error.

Ví dụ:

func OpenConnection(address string) error {
if address == "" {
return errors.New("address is required")
}

return nil
}

errors.New phù hợp khi bạn chỉ cần một thông báo lỗi cố định và không cần chèn thêm dữ liệu động.

Xử lý error theo cách idiomatic

Pattern phổ biến nhất trong Go là kiểm tra error ngay sau khi gọi hàm:

result, err := Divide(4, 0)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(result)

Kết quả:

cannot divide by zero

Luồng thực thi diễn ra như sau:

  1. Divide(4, 0) phát hiện mẫu số bằng 0.
  2. Hàm trả về 0 và một error.
  3. Bên gọi kiểm tra err != nil.
  4. Chương trình xử lý lỗi và return sớm.
  5. result không được sử dụng.

Đây là một nguyên tắc quan trọng:

Luôn kiểm tra error trước khi sử dụng kết quả đi kèm với error đó.

Trong trường hợp thất bại, giá trị kết quả thường chỉ là zero value hoặc một giá trị chưa hoàn chỉnh. Bên gọi không nên giả định rằng nó vẫn hợp lệ.

Pattern sau xuất hiện rất thường xuyên trong Go:

value, err := doSomething()
if err != nil {
return err
}

// Sử dụng value

Mặc dù lặp lại, pattern này khiến đường đi của lỗi luôn rõ ràng và nằm gần nơi lỗi có thể xảy ra.

Thêm ngữ cảnh bằng fmt.Errorf

Thông báo sau đây cho biết có lỗi chia cho 0:

cannot divide by zero

Tuy nhiên, nó không cho biết giá trị nào đang được xử lý.

Khi cần tạo error chứa dữ liệu động, chúng ta có thể sử dụng fmt.Errorf:

func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide %d by zero", a)
}

return a / b, nil
}

Khi gọi:

result, err := Divide(4, 0)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(result)

Kết quả:

cannot divide 4 by zero

Thông báo mới cung cấp thêm giá trị a, nhờ đó việc debug trở nên dễ dàng hơn.

Sự khác nhau cơ bản là:

errors.New("cannot divide by zero")

phù hợp với thông báo cố định, trong khi:

fmt.Errorf("cannot divide %d by zero", a)

phù hợp với thông báo cần định dạng hoặc chứa dữ liệu động.

Sentinel error

Đôi khi bên gọi không chỉ muốn in thông báo lỗi. Nó cần xác định chính xác loại tình huống nào đã xảy ra để đưa ra cách xử lý tương ứng.

Ví dụ:

  • Không tìm thấy dữ liệu.
  • Không có quyền truy cập.
  • Mẫu số bằng 0.
  • Resource đã tồn tại.
  • Request đã hết thời gian chờ.

Trong trường hợp đó, chúng ta có thể định nghĩa một error cố định ở cấp package. Giá trị này được gọi là sentinel error.

var ErrDivideByZero = errors.New("cannot divide by zero")

Sau đó trả về sentinel error từ hàm:

var ErrDivideByZero = errors.New("cannot divide by zero")

func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}

return a / b, nil
}

Theo convention, sentinel error thường được đặt tên với tiền tố Err:

var ErrNotFound = errors.New("not found")
var ErrPermissionDenied = errors.New("permission denied")
var ErrAlreadyExists = errors.New("already exists")

Cách đặt tên này giúp error dễ nhận biết trong code và trong autocomplete của editor.

Kiểm tra sentinel error bằng errors.Is

Để kiểm tra một error có phải là một sentinel cụ thể hay không, hãy sử dụng errors.Is:

result, err := Divide(4, 0)

switch {
case errors.Is(err, ErrDivideByZero):
fmt.Println("invalid division")
case err != nil:
fmt.Println("unexpected error:", err)
default:
fmt.Println(result)
}

Kết quả:

invalid division

Trong trường hợp error chưa bị wrap, phép so sánh sau có thể hoạt động:

err == ErrDivideByZero

Tuy nhiên, cách này sẽ thất bại khi error đã được wrap bên trong một error khác.

Vì vậy, cách idiomatic là:

errors.Is(err, ErrDivideByZero)

errors.Is không chỉ kiểm tra error ngoài cùng. Nó còn duyệt qua toàn bộ chuỗi error đã được wrap để tìm sentinel tương ứng.

Custom error

Sentinel error phù hợp khi bên gọi chỉ cần phân biệt một trạng thái lỗi.

Tuy nhiên, đôi khi chúng ta cần đính kèm thêm dữ liệu có cấu trúc, chẳng hạn:

  • Mã lỗi.
  • Tên field không hợp lệ.
  • Resource ID.
  • Giá trị đầu vào.
  • Số lần retry.
  • Trạng thái HTTP tương ứng.

Trong trường hợp này, chúng ta có thể định nghĩa một custom error.

type DivisionError struct {
Code int
Msg string
}

Để DivisionError thỏa mãn interface error, chúng ta định nghĩa method Error():

func (e *DivisionError) Error() string {
return fmt.Sprintf("code %d: %s", e.Code, e.Msg)
}

Sau đó sử dụng custom error trong hàm Divide:

func Divide(a, b int) (int, error) {
if b == 0 {
return 0, &DivisionError{
Code: 400,
Msg: "cannot divide by zero",
}
}

return a / b, nil
}

Custom error vẫn có thể được in như một error bình thường:

result, err := Divide(4, 0)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(result)

Kết quả:

code 400: cannot divide by zero

Điểm khác biệt là error này không chỉ chứa một chuỗi. Nó còn chứa các field có thể được truy cập bằng code:

Code int
Msg string

Kiểm tra custom error bằng errors.As

Khi cần lấy một kiểu error cụ thể từ một giá trị error, hãy sử dụng errors.As.

result, err := Divide(4, 0)
if err != nil {
var divErr *DivisionError

if errors.As(err, &divErr) {
fmt.Println("code:", divErr.Code)
fmt.Println("message:", divErr.Msg)
return
}

fmt.Println("unexpected error:", err)
return
}

fmt.Println(result)

Kết quả:

code: 400
message: cannot divide by zero

Ở đây:

var divErr *DivisionError

khai báo biến dùng để nhận custom error.

Sau đó:

errors.As(err, &divErr)

tìm một *DivisionError trong error hiện tại hoặc trong toàn bộ chuỗi error đã được wrap.

Nếu tìm thấy, errors.As gán error đó vào divErr và trả về true.

Phân biệt errors.Is và errors.As

Hai hàm này phục vụ hai mục đích khác nhau.

errors.Is: kiểm tra một giá trị error cụ thể

errors.Is(err, ErrNotFound)

Câu hỏi mà errors.Is trả lời là:

Error này có phải là, hoặc có wrap, sentinel error ErrNotFound hay không?

Nó thường được sử dụng với sentinel error:

if errors.Is(err, ErrDivideByZero) {
// Xử lý lỗi chia cho 0
}

errors.As: tìm một kiểu error cụ thể

var divErr *DivisionError

if errors.As(err, &divErr) {
fmt.Println(divErr.Code)
}

Câu hỏi mà errors.As trả lời là:

Error này, hoặc một error nằm trong chuỗi wrap của nó, có thuộc kiểu *DivisionError hay không?

Có thể ghi nhớ như sau:

  • errors.Is kiểm tra error nào.
  • errors.As kiểm tra kiểu error gì.

Vì sao không nên chỉ dùng type assertion?

Bạn có thể lấy custom error bằng type assertion:

divErr, ok := err.(*DivisionError)
if ok {
fmt.Println(divErr.Code)
}

Cách này hoạt động khi err trực tiếp chứa một *DivisionError.

Tuy nhiên, nó sẽ không hoạt động nếu custom error đã được wrap:

err := fmt.Errorf(
"calculate invoice: %w",
&DivisionError{
Code: 400,
Msg: "cannot divide by zero",
},
)

Type assertion chỉ kiểm tra error ngoài cùng:

_, ok := err.(*DivisionError)
fmt.Println(ok) // false

Trong khi errors.As có thể duyệt qua chuỗi wrap:

var divErr *DivisionError

ok := errors.As(err, &divErr)
fmt.Println(ok) // true

Vì vậy, khi error có khả năng được wrap, errors.As là lựa chọn phù hợp hơn.

Error wrapping với %w

Trong một ứng dụng thực tế, error thường đi qua nhiều tầng:

Repository → Service → Handler

Mỗi tầng có thể muốn thêm ngữ cảnh trước khi trả error lên trên.

Ví dụ, tầng thấp nhất mở file cấu hình:

func readConfig() error {
_, err := os.Open("config.yaml")
if err != nil {
return fmt.Errorf("read config: %w", err)
}

return nil
}

Nếu file không tồn tại, kết quả có thể là:

read config: open config.yaml: no such file or directory

Trong đoạn code trên:

fmt.Errorf("read config: %w", err)

%w thực hiện hai việc:

  1. Thêm ngữ cảnh "read config" vào thông báo.
  2. Giữ lại error gốc bên trong error mới.

Điểm thứ hai rất quan trọng. Error gốc không bị chuyển thành một chuỗi rồi mất đi. Nó vẫn có thể được tìm thấy bằng:

errors.Is
errors.As
errors.Unwrap

%w khác gì %v?

Hãy xem hai cách viết:

fmt.Errorf("read config: %v", err)

và:

fmt.Errorf("read config: %w", err)

Cả hai có thể tạo ra thông báo giống nhau khi được in.

Tuy nhiên, %v chỉ chèn nội dung của error vào chuỗi:

wrapped := fmt.Errorf("read config: %v", os.ErrNotExist)

fmt.Println(errors.Is(wrapped, os.ErrNotExist)) // false

Trong khi %w giữ lại quan hệ wrapping:

wrapped := fmt.Errorf("read config: %w", os.ErrNotExist)

fmt.Println(errors.Is(wrapped, os.ErrNotExist)) // true

Vì vậy:

  • Dùng %v khi bạn chỉ muốn định dạng error như văn bản.
  • Dùng %w khi muốn giữ lại error gốc để bên gọi tiếp tục kiểm tra.

Trong hầu hết trường hợp trả error lên tầng trên, %w là lựa chọn phù hợp hơn.

Unwrap error bằng errors.Unwrap

Một error được tạo bằng %w có thể cung cấp error bên trong thông qua method:

Unwrap() error

Package errors cung cấp hàm errors.Unwrap để truy cập error đó:

err := fmt.Errorf("read config: %w", os.ErrNotExist)

inner := errors.Unwrap(err)

fmt.Println(inner)

Kết quả:

file does not exist

Bạn hiếm khi cần gọi errors.Unwrap trực tiếp.

Trong phần lớn trường hợp, nên sử dụng:

errors.Is

hoặc:

errors.As

Hai hàm này tự động duyệt qua toàn bộ chuỗi error đã được wrap.

Chuỗi error wrapping

Một error có thể được wrap qua nhiều tầng:

var ErrDivideByZero = errors.New("cannot divide by zero")

err := fmt.Errorf(
"calculate report: %w",
fmt.Errorf(
"calculate average: %w",
ErrDivideByZero,
),
)

Chuỗi error lúc này có dạng:

calculate report
└── calculate average
└── ErrDivideByZero

Dù sentinel nằm ở tầng sâu nhất, errors.Is vẫn tìm thấy nó:

fmt.Println(errors.Is(err, ErrDivideByZero))

Kết quả:

true

Đây là lý do không nên kiểm tra bằng ==:

fmt.Println(err == ErrDivideByZero) // false

Phép == chỉ so sánh error ngoài cùng. Error ngoài cùng là một giá trị mới được tạo bởi fmt.Errorf, không phải chính sentinel ban đầu.

Trong khi đó:

errors.Is(err, ErrDivideByZero)

duyệt qua chuỗi wrapping và tìm thấy sentinel ở bên trong.

errors.Join: kết hợp nhiều error

Thông thường, một thao tác chỉ trả về một error. Tuy nhiên, đôi khi nhiều thao tác độc lập có thể cùng thất bại.

Ví dụ:

  • Đóng nhiều file.
  • Dừng nhiều worker.
  • Giải phóng nhiều resource.
  • Validate nhiều thành phần độc lập.
  • Cleanup nhiều kết nối.

Từ Go 1.20, package errors cung cấp hàm errors.Join để kết hợp nhiều error thành một:

err1 := errors.New("disk full")
err2 := errors.New("network timeout")

err := errors.Join(err1, err2)

fmt.Println(err)

Kết quả:

disk full
network timeout

Error được tạo bởi errors.Join vẫn hỗ trợ errors.Is:

fmt.Println(errors.Is(err, err1)) // true
fmt.Println(errors.Is(err, err2)) // true

Nó cũng hỗ trợ errors.As khi một trong các error thành phần là custom error.

Ví dụ:

func closeResources() error {
fileErr := errors.New("cannot close file")
connErr := errors.New("cannot close connection")

return errors.Join(fileErr, connErr)
}

Khi một error truyền vào là nil, errors.Join sẽ bỏ qua error đó:

err := errors.Join(nil, errors.New("network timeout"))

fmt.Println(err)

Nếu tất cả error đều là nil, kết quả cũng là nil:

err := errors.Join(nil, nil)

fmt.Println(err == nil) // true

Quy ước viết error message

Error message trong Go thường được viết:

  • Bắt đầu bằng chữ thường.
  • Không có dấu chấm hoặc dấu câu ở cuối.
  • Mô tả ngắn gọn điều gì đã thất bại.

Nên viết:

errors.New("cannot divide by zero")

Không nên viết:

errors.New("Cannot divide by zero.")

Lý do là error thường được wrap vào một thông báo lớn hơn:

return fmt.Errorf("calculate invoice: %w", err)

Nếu error bên trong viết hoa và có dấu chấm:

calculate invoice: Cannot divide by zero.

Thông báo sẽ trông giống hai câu bị ghép lại không tự nhiên.

Trong khi error viết theo convention tạo ra kết quả rõ ràng hơn:

calculate invoice: cannot divide by zero

Tránh lặp lại từ “failed”

Hãy cẩn thận khi mọi tầng đều thêm từ failed.

Ví dụ:

return fmt.Errorf("failed to read config: %w", err)

Tầng trên tiếp tục wrap:

return fmt.Errorf("failed to start application: %w", err)

Kết quả:

failed to start application: failed to read config: failed to open file: file does not exist

Thông báo này không sai, nhưng khá dài và lặp lại.

Có thể viết gọn hơn:

return fmt.Errorf("read config: %w", err)

và:

return fmt.Errorf("start application: %w", err)

Kết quả:

start application: read config: open config.yaml: file does not exist

Tham số target của errors.As

Tham số thứ hai của errors.As phải là một con trỏ khác nil.

Ví dụ đúng:

var divErr *DivisionError

if errors.As(err, &divErr) {
fmt.Println(divErr.Code)
}

Ở đây, &divErr là con trỏ đến biến target.

errors.As cần con trỏ vì khi tìm thấy error phù hợp, nó phải ghi giá trị đó vào biến của bạn.

Ví dụ sai:

var divErr *DivisionError

errors.As(err, divErr)

Đoạn code này có thể gây panic:

errors: target must be a non-nil pointer

Có thể hiểu cơ chế theo cách đơn giản:

errors.As(sourceError, nơiNhậnKếtQuả)

“Nơi nhận kết quả” phải là một địa chỉ có thể ghi vào, vì vậy chúng ta truyền con trỏ.

Value error và pointer error

Nếu custom error được trả về dưới dạng value:

type DivisionError struct {
Code int
}

func (e DivisionError) Error() string {
return "division error"
}

Bạn có thể khai báo target như sau:

var divErr DivisionError
errors.As(err, &divErr)

Nếu custom error được trả về dưới dạng pointer:

func (e *DivisionError) Error() string {
return "division error"
}

Target thường được khai báo:

var divErr *DivisionError
errors.As(err, &divErr)

Kiểu target phải tương ứng với kiểu error thực sự được trả về.

Khi nào dùng panic, khi nào dùng error?

Go có panic và recover, nhưng chúng không nên được sử dụng như một hệ thống exception cho các lỗi nghiệp vụ thông thường.

Dùng error cho thất bại có thể dự đoán

Hãy dùng error khi bên gọi có thể hợp lý xử lý tình huống và tiếp tục chạy.

Ví dụ:

  • File không tồn tại.
  • Request bị timeout.
  • Người dùng nhập dữ liệu không hợp lệ.
  • Không tìm thấy record.
  • Không có quyền truy cập.
  • Kết nối database thất bại.
  • Một phép chia nhận mẫu số bằng 0.
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}

return a / b, nil
}

Bên gọi có thể xử lý lỗi và tiếp tục:

result, err := Divide(10, 0)
if errors.Is(err, ErrDivideByZero) {
fmt.Println("please provide a non-zero divisor")
return
}

fmt.Println(result)

Dùng panic cho trạng thái không thể tiếp tục an toàn

panic phù hợp hơn với các trường hợp như:

  • Vi phạm invariant nội bộ.
  • Trạng thái chương trình lẽ ra không thể xảy ra.
  • Lỗi lập trình nghiêm trọng.
  • Một dependency bắt buộc không thể khởi tạo và ứng dụng không thể chạy.

Ví dụ:

func MustLoadConfig() Config {
config, err := LoadConfig()
if err != nil {
panic(err)
}

return config
}

Tên MustLoadConfig thể hiện rõ rằng hàm sẽ panic nếu không thể tải cấu hình.

Quy tắc thực tế có thể ghi nhớ là:

Nếu bên gọi có thể xử lý thất bại và tiếp tục, hãy trả về error. Nếu chương trình đã rơi vào trạng thái không thể tiếp tục an toàn, panic có thể phù hợp.

Trong application code, error gần như luôn là lựa chọn mặc định.

Không nên bỏ qua error bằng _

Go cho phép bỏ qua một giá trị trả về bằng blank identifier _:

result, _ := Divide(4, 0)
fmt.Println(result)

Đoạn code trên in ra:

0

Nhưng nó không cho biết vì sao kết quả lại là 0.

Error duy nhất báo hiệu phép chia thất bại đã bị bỏ qua.

Một ví dụ nguy hiểm hơn:

data, _ := os.ReadFile("config.yaml")

Nếu file không tồn tại, data có thể là nil hoặc không chứa dữ liệu mong đợi, trong khi chương trình tiếp tục chạy như thể việc đọc file đã thành công.

Cách đúng:

data, err := os.ReadFile("config.yaml")
if err != nil {
return fmt.Errorf("read config: %w", err)
}

Chỉ nên bỏ qua error khi bạn thực sự hiểu contract của hàm và có lý do rõ ràng rằng error đó không ảnh hưởng đến chương trình.

Ngay cả khi chủ động bỏ qua, nên ghi chú lý do:

// Ignore the cleanup error because the primary operation has already failed.
_ = tempFile.Close()

Tổng kết

Trong Go, error không phải là một cơ chế điều khiển luồng ẩn. Error là một giá trị được trả về một cách tường minh.

Các nguyên tắc quan trọng cần ghi nhớ:

  • Một kiểu thỏa mãn interface error khi có method Error() string.
  • Một hàm có thể thất bại thường trả error ở vị trí cuối cùng.
  • Luôn kiểm tra err != nil trước khi sử dụng kết quả.
  • Dùng errors.New cho thông báo cố định.
  • Dùng fmt.Errorf khi cần định dạng hoặc thêm ngữ cảnh.
  • Dùng %w để giữ lại error gốc khi wrapping.
  • Dùng sentinel error khi cần nhận biết một trạng thái lỗi cụ thể.
  • Dùng custom error khi cần mang theo dữ liệu có cấu trúc.
  • Dùng errors.Is để kiểm tra một error cụ thể.
  • Dùng errors.As để tìm một kiểu error cụ thể.
  • Dùng errors.Join khi cần kết hợp nhiều error độc lập.
  • Tránh so sánh error message bằng chuỗi.
  • Tránh bỏ qua error bằng _.
  • Cẩn thận với shadowing và typed nil.
  • Dùng panic cho trạng thái thực sự không thể tiếp tục, không dùng nó thay cho error thông thường.

Phong cách xử lý lỗi này có thể trông dài dòng khi mới tiếp cận Go. Tuy nhiên, khi codebase lớn dần, tính tường minh của nó giúp luồng thất bại dễ quan sát, dễ kiểm soát và dễ debug hơn.

Quiz

Câu 4


#Backend
DUKE
Written byDUKEView all stories by DUKE

Bình luận