Kiểm tra con trỏ nil quá mức trong Go
(konradreiche.com)- Kiểm tra nil trong Go có thể ngăn panic, nhưng nếu việc kiểm tra lặp lại sai chỗ, mã sẽ không tự giải thích được “điều gì có thể là nil”
- Nếu kiểm tra phụ thuộc bắt buộc như Redis client bên trong các phương thức nội bộ, ta sẽ đối xử với lỗi khởi tạo như một luồng thực thi bình thường
- Chỉ lọc nil trong constructor là chưa đủ; cần xử lý lỗi ngay tại điểm khởi tạo như
NewRedisClient(addr) - Các giá trị đi vào từ bên ngoài, như object request, nên được xác thực ở lớp ranh giới như HTTP handler, RPC dispatch, queue consumer; logic nội bộ nên tin vào bảo đảm đó
- Nếu âm thầm cho phép những trạng thái vốn phải là không thể xảy ra, lỗi sẽ trở nên im lặng, trì hoãn và mơ hồ, rồi về sau phát sinh chi phí khôi phục tín hiệu đã mất bằng metric, dashboard và alert
Kiểm tra nil không phải lúc nào cũng là lập trình phòng thủ
- Để ngăn panic trong production, cần lập trình phòng thủ bằng cách kiểm tra input, phạm vi và con trỏ trước cả
deferred recover - Kiểm tra nil đúng chỗ tạo ra mã an toàn, nhưng kiểm tra sai chỗ là tín hiệu cho thấy ta không theo dõi được giá trị nào có thể là nil
- Mẫu này xuất hiện thường xuyên hơn trong mã được sinh ra, nhưng không phải hiện tượng mới và cũng không chỉ giới hạn ở AI
- Kiểm tra nil trông rẻ và an toàn, nhưng để lại cho người đọc tiếp theo thông điệp “giá trị này có thể là nil”, và thường truyền đạt sai ý nghĩa
Vấn đề của việc kiểm tra nil ở phụ thuộc
- Đoạn mã trong đó
RateLimitercó field*redis.Clientvà kiểm trar.redis != nilbên trongAllowthoạt nhìn có vẻ an toàn - Nếu Redis client là nil, vấn đề đã xảy ra từ thời điểm tạo, chứ không phải lúc
Allowchạy - Kiểm tra nil trong phương thức nội bộ khiến việc tiếp tục hoạt động ở trạng thái khởi tạo thất bại trông như một trạng thái chấp nhận được
- Kiểm tra như vậy là tín hiệu cho thấy mã đã đánh mất nguồn gốc của object, trách nhiệm khởi tạo và bất biến rằng nil lẽ ra không thể xảy ra
Chỉ kiểm tra nil trong constructor là chưa đủ
- Cách
NewRateLimiter(client *redis.Client)trả về lỗi khiclient == nilthì tốt hơn, nhưng chưa phải giải pháp hoàn chỉnh - Việc một con trỏ nil đã được truyền tới hàm tự nó đã có nghĩa là một trạng thái sai đã đi vào hệ thống
- Lỗi thực sự phải được xử lý tại điểm khởi tạo nơi tạo Redis client
- Nếu lỗi xảy ra tại
redisClient, err := NewRedisClient(addr), cần trả về ngay - Sau đó, chỉ client hợp lệ mới được truyền vào
NewRateLimiter(redisClient)
- Nếu lỗi xảy ra tại
- Làm như vậy thì constructor của
RateLimitercũng không còn cần trả về lỗi - Nếu phải cho phép trạng thái nơi kho lưu trữ tạm thời không dùng được, đừng lan truyền nil; hãy bọc nó trong một kiểu bên ngoài luôn non-nil và đóng gói logic retry hoặc xử lý suy giảm hiệu năng bên trong
- Điều này tương tự ràng buộc
NOT NULLhoặc khóa ngoại trong cơ sở dữ liệu- Nếu một hàng sai không thể tồn tại ngay từ đầu, mọi truy vấn sẽ không cần kiểm tra lại dữ liệu
- Với giá trị runtime cũng vậy: một khi đã thiết lập bất biến, phần mã còn lại có thể tránh kiểm tra lặp lại
Chi phí của lỗi im lặng
- Việc chỉ kiểm tra nil hoặc ghi log vì không muốn dừng chương trình do một thay đổi nhỏ có thể tạo cảm giác ổn định
- Lựa chọn thực tế gần với thất bại ồn ào hay thất bại im lặng hơn là “crash hay tiếp tục chạy”
- Lỗi được trả về một cách tường minh có ba đặc tính
- Rõ ràng: biết được rằng lỗi đã xảy ra
- Tức thời: biết được lỗi gần với nguyên nhân
- Quy thuộc: caller có thể liên kết lỗi với thao tác tương ứng
- Lỗi bị nuốt hoạt động ngược lại
- Lỗi âm thầm biến mất
- Nhiều mã hơn được chạy rồi sau đó mới biểu hiện thành triệu chứng
- Khi triệu chứng xuất hiện, rất khó xác định nguyên nhân
- Càng nhiều lời gọi sống sót trong trạng thái sai, khoảng cách giữa nguyên nhân và triệu chứng càng lớn
- Cách sửa đúng không phải là che giấu lỗi cục bộ, mà là hiểu lỗi sẽ được truyền đi đâu và ở đâu nó biến thành từ chối request, thất bại job, retry, alert hoặc thoát chương trình
- Nếu việc trả về lỗi dừng nhiều hơn mức hệ thống cần, vấn đề nằm ở ranh giới xử lý lỗi, không phải ở hàm đó
Chi phí thứ cấp của việc tạo lại tín hiệu đã mất
- Khi lỗi trở nên im lặng, bạn không thể biết điều gì thực sự đã xảy ra, nên bug có thể bị che giấu
- Khi đó cần xây dựng hạ tầng quan sát như metric, dashboard và alert để phát hiện sự vắng mặt của hành vi
- Mỗi lần cho phép một trạng thái không thể xảy ra hoặc chưa được xử lý, bạn sẽ phải trả chi phí kỹ thuật để về sau khôi phục tín hiệu đã vứt bỏ bằng quan sát
Vai trò của lớp bên ngoài và lớp bên trong
- Nơi quá trình thực thi bắt đầu và dữ liệu bên ngoài đi vào là lớp bên ngoài; mã sâu hơn mà lời gọi đó chạm tới là lớp bên trong
- Ở giai đoạn đầu của thực thi, chưa có gì được bảo đảm, nhưng cũng chưa có công việc nào được thực hiện
- Trong quá trình khởi tạo, chương trình phải thiết lập các thành phần mà nó phụ thuộc vào, và quyết định từng thành phần là bắt buộc hay có thể tạm thời vắng mặt
- Thiết kế nên luôn nghiêng về các phụ thuộc luôn khả dụng, và giảm thiểu các phụ thuộc có thể biến mất giữa chừng
Dữ liệu trong phạm vi request nên được xác thực ở ranh giới
- Object request, các field của request và giá trị dẫn xuất từ request khác với phụ thuộc cố định
- Request đi vào ở mỗi lần gọi từ bên ngoài như HTTP handler, RPC, queue, test helper, package khác
- Kiểm tra
req == nilbên trongRateLimiter.Allow(ctx, req)cũng là sai lầm giống kiểm tra nil ở phụ thuộc - Request không lần đầu đi vào tại
Allow; nó đã đi vào ở ranh giới truyền tải phía trước rồi di chuyển trong mã nội bộ - Nếu xác thực lại trong một hàm nội bộ như
Allow, một hàm sâu sẽ tái kiểm tra điều mà lớp bên ngoài phải bảo đảm, khiến sự bất định lan rộng
Sau khi xác thực ranh giới, logic nội bộ tin vào bất biến
- Kiểm tra nil nên nằm tại điểm ranh giới nơi các byte không đáng tin cậy được chuyển thành kiểu nội bộ như
*Request - Trong ví dụ HTTP handler, nếu
DecodeRequest(r)thất bại, nó phản hồi bằnghttp.StatusBadRequestvà trả về - Sau khi xác thực xong,
reqlà một giá trị hợp lệ, và sau đóh.limiter.Allow(r.Context(), req)có thể tin vào giá trị đó - Vì không thể kiểm soát dữ liệu nhận từ bên ngoài, việc kiểm tra nil và các ràng buộc cần thiết ở ranh giới là hợp lý
- Dữ liệu đi qua ranh giới được ánh xạ thành kiểu nội bộ và logic nghiệp vụ, và sau đó trở thành bất biến của hệ thống
Allowcuối cùng tập trung vào logic thực tế mà không kiểm tra niluserID := GetUserID(req)- Nếu
userID == ""thì trả vềfalse, nil - Nếu không thì gọi
r.checkLimit(ctx, userID)
- Việc kiểm tra
userIDrỗng cũng có thể được chuyển lên lớp HTTP, nhưng trong ví dụ này bộ giới hạn tốc độ được để sở hữu chính sách đó
Kiểm tra nil lặp lại tạo ra nhánh mới và hành vi mới
- Hệ thống có cấu trúc như vậy dễ suy luận và dễ thay đổi
- Ngược lại, trong hệ thống không có bất biến, sau khi thêm kiểm tra ở khắp nơi, bạn phải quyết định mỗi kiểm tra nên làm gì
- Mỗi kiểm tra nil là một nhánh mới, và mỗi nhánh buộc bạn định nghĩa một hành vi mới cho trạng thái lẽ ra không nên tồn tại
- Kiểm tra nil hữu ích khi cưỡng chế các ranh giới đã được tài liệu hóa hoặc mô hình hóa trạng thái tùy chọn có chủ ý
- Nên hoài nghi những kiểm tra nil âm thầm xử lý các trạng thái mà chương trình xem là không thể xảy ra
- Nếu kiểm tra nil xuất hiện khắp nơi, đó là một trong hai trường hợp
- Mã bình thường để bảo vệ input ranh giới không đáng tin cậy
- Vấn đề thiết kế trong đó codebase không thiết lập được bất biến
- Trong một hệ thống không thể tin cậy bất kỳ tham số nào, có thể cần thêm kiểm tra ngay lập tức, nhưng công việc thực sự là thiết lập bất biến mà kiểm tra đó đang thay thế và biến nó thành một bảo đảm đáng tin cậy
1 bình luận
Các ý kiến trên Lobste.rs
Xin nhắc lại với các lập trình viên Go khác: hãy wrap lỗi
Khi call stack được tháo ngược, ngữ cảnh về lỗi nên được tích lũy dần
errtrong cùng cho biết điều gì đã xảy ra thì sẽ tốt hơnTrên thực tế, “wrap” rất dễ trở thành việc
grepchuỗi lỗi, hy vọng chuỗi đó là duy nhất, rồi phải gượng ép sáng tạo để làm cho chuỗi đó trở nên duy nhấtTrước đây, trong một sản phẩm networking, có một kỹ sư đã dành một tháng để sửa hàng trăm thông báo lỗi, vì việc log in ra “What the f-ck?” không giúp ích gì cho người dùng cuối
Cần phải biến các thông báo đó thành hữu ích, và vì những lý do như trên, cũng phải thêm stack lỗi
Tôi cho rằng Go tạo ra hai vấn đề ở đây
Đó là phần nói rằng “vì bạn không thể kiểm soát thứ được truyền vào, nên kiểm tra
niltại ranh giới đó là hợp lý”Điều này đúng với input bên ngoài, nhưng nếu mọi con trỏ đều có thể là
nil, thì việc theo dõi các ranh giới an toàn trong codebase đòi hỏi phải suy luậnVấn đề của Go là nó buộc mọi lập trình viên phải thực hiện suy luận này trong đầu, thay vì để compiler làm
Rust có
Option<T>, còn C# có kiểu nullableTôi cho rằng đến năm 2026 thì ta không còn cần phải gặp những vấn đề như thế này nữa
Trong ngôn ngữ, cú pháp thường là yếu tố kém thú vị hơn, nhưng trong ngôn ngữ scripting yêu thích, viết
foo.bar.bazdễ hơn nhiều so vớifoo.unwrap().bar.unwrap().bazcủa RustNgay cả với tư cách người thích Rust cũng vậy; dù Go và Rust thường được xếp chung một nhóm, tôi thấy Go gần với một ngôn ngữ scripting do lập trình viên C tạo lại hơn nhiều
Dù vậy, nếu một ngôn ngữ dùng null thì mặc định không nullable sẽ tốt hơn. Đặc biệt nếu có cú pháp ngắn như
?hoặc.?, thì trong các dự án lớn, gánh nặng cú pháp đó đáng để chấp nhậnTôi hiểu rằng Go không phải là ngôn ngữ mô hình hóa tốt đối tượng không nullable
Ở điểm này nó giống C, và
Option<T>có thể được biểu diễn bằngT*, nhưngT*không nhất thiết có nghĩa làOption<T>Nhìn chung tôi đồng ý với bài viết. Khi làm ở một công ty firmware nhúng, tôi cũng từng thuyết phục mọi người đừng rải null check khắp code C++ mà hãy dùng assert
assert dễ debug, không bị tính là nhánh xét theo coverage, và truyền đạt rõ điều kiện kỳ vọng cho người đọc. Trong release build nó bị loại bỏ, nên cũng hiệu quả hơn
Tuy nhiên, tôi hiểu rằng trong Go, dereference nil vốn đã cho thông tin debug tốt, nên lợi ích của assert không lớn như trong C++
Với ví dụ trong bài, nó sẽ nổ sâu trong
checkLimit, rồi từ đó phải truy ngược nguồn gốc của nil. Tùy hệ thống hoặc kiến trúc, việc này có thể khá phức tạpVì vậy, assert ngay trong
NewRateLimiterrõ ràng có lợi. Trong code ví dụ, tức là đổi thành Tuy nhiên, đội ngũ Go phản đối mạnh mẽ assertion, và panic cũng không lý tưởng vì nếu không được bắt, nó sẽ làm crash toàn bộ runtimeassert có nghĩa là “trạng thái này không hợp lệ”, và macro assert có thể biến null check đó thành no-op trong release build
Tùy cách định nghĩa macro assert, các tối ưu hóa liên quan đến hành vi không xác định có thể xảy ra, khiến các check phía sau bị loại bỏ và dẫn đến những crash khó hiểu
Ví dụ, tôi từng thấy một định nghĩa assert khiến check phía sau bị loại bỏ trong trường hợp
assert(p); if (!p) { ... }Nói bừa “đừng null check, hãy dùng assert” có thể đúng với bất biến trạng thái, nhưng không đúng với việc kiểm tra lỗi
Phần kết luận có lời khuyên hay
Nếu các kiểm tra
nilxuất hiện khắp nơi thì chỉ có một trong hai khả năng: đó là mã bình thường để phòng vệ trước đầu vào ở ranh giới không đáng tin cậy, hoặc là vấn đề thiết kế khi codebase không thiết lập được bất biếnTrong một hệ thống nơi không thể tin cậy bất kỳ tham số nào, giải pháp không phải là thêm nhiều kiểm tra hơn. Trước mắt có thể phải làm vậy, nhưng việc thật sự cần làm là thiết lập các bất biến mà những kiểm tra đó đang thay thế, và dần biến nhiễu sinh ra từ nỗi sợ thành những bảo đảm mà hệ thống có thể dựa vào
Tôi nghĩ điều này vượt ra ngoài kiểm tra nil. Việc thêm kiểm tra hay mã phòng vệ ở phần “lá” của hệ thống thường là cách xử lý triệu chứng của việc thiếu bất biến hoặc bất biến không được cưỡng chế đúng cách
“Thêm một kiểm tra nữa” rất dễ trở thành mặc định, nhưng có giới hạn mở rộng. Đến một lúc nào đó logic kiểm tra sẽ nhiều hơn logic chức năng, và độ phức tạp tổng thể phình ra mất kiểm soát
Các kiểm tra bổ sung để chặn một hai lỗi thường không gây hại, nhưng khi cảm thấy số lượng và độ phức tạp của kiểm tra tăng quá nhiều, thì thay vì cứ sửa ở phần lá, lùi lại một bước để tìm nguyên nhân gốc rễ về lâu dài sẽ tốt hơn cho hệ thống và cho cuộc sống của người bảo trì
Tuy nhiên, việc huấn luyện các lập trình viên ngừng lập trình phòng vệ là vấn đề khó hơn
Những bất biến như thế này, ở đây là tính không-null, có thể được mô hình hóa tốt hơn nhiều trong các hệ thống kiểu biểu đạt phong phú hơn Go
Bài tôi thích nhất về chủ đề này là bài năm 2019 của Alexis King, Parse, don't validate
Nguyên tắc này áp dụng được ở mọi nơi, nhưng trong hệ thống kiểu của Haskell thì trông thật sự dễ. Tôi đã cố làm theo lời khuyên của Alexis trong TypeScript suốt vài năm, nhưng không hề dễ
Tóm lại, vấn đề không phải là có quá nhiều kiểm tra, mà là bọc nil như một giá trị
Vấn đề này đã xuất hiện lặp đi lặp lại, và tôi cho rằng đó là hệ quả của một ngôn ngữ không coi xử lý lỗi là tính năng hạng nhất
Như tôi nhớ thì ở các luồng thảo luận khác cũng từng nói, trên thực tế các linter tiêu chuẩn sẽ buộc dùng cấu trúc này
Tôi không biết các kiểm tra nil này có tệ về mặt logic hay không. Nhiều ngôn ngữ tích hợp sẵn xử lý lỗi, và khác biệt chủ yếu là mức độ nhất quán và đơn giản khi lan truyền lỗi
Các lựa chọn khi đối mặt với một interface có thể trả lỗi đại khái có bốn loại: xử lý và phục hồi, bỏ qua, lan truyền lỗi, hoặc bỏ lỗi đó và lan truyền lỗi của chính mình; trường hợp cuối cũng có thể bọc lỗi hiện có
Những ngôn ngữ có xử lý lỗi là tính năng hạng nhất thường làm cho lựa chọn 2 và 3 trở nên dễ dàng, nhất là các ngôn ngữ hiện đại. Vì vậy lựa chọn 4 cũng có thể khá gọn gàng tùy ngôn ngữ
Với lựa chọn 1, hỗ trợ hạng nhất cũng không giúp được nhiều ngoài việc làm rõ hơn rằng kiểu xử lý đó là cần thiết
Về cơ bản, nếu một hàm có thể phát sinh lỗi, thì bất kể có triển khai như thế nào, mọi ngôn ngữ đều tương đương với việc làm
{error,result} = functioncall()rồi sau đóif (error) { ... }Vì Go không coi xử lý lỗi là tính năng hạng nhất, nhiều hàm chủ động trả về tuple
(result, err), và khi linter gần như buộc phải kiểm traerr != nil, mã tạo cảm giác bị phủ kín bởi mẫu đóTôi xem việc ngôn ngữ không trực tiếp xử lý cơ chế xử lý lỗi đúng đắn là một khiếm khuyết thiết kế ngôn ngữ, nhưng một khi đã ở vị trí đó thì mô hình này có lẽ gần với phương án tốt nhất
Tôi không rõ mã Go có theo thông lệ dùng kiểu trả về tùy chọn để phân biệt giữa lỗi có thể bỏ qua về mặt chức năng và lỗi “cần quan tâm” hay không. Nếu trong trường hợp như vậy mà việc luôn trả về kiểu lỗi vẫn là thông lệ, thì linter có vẻ sẽ luôn cưỡng ép mẫu này
Tôi không ghét Go, chỉ là không đồng ý với một lựa chọn thiết kế. Gần như ngôn ngữ nào cũng có những lựa chọn thiết kế đáng để phàn nàn
Tôi nghĩ sai lầm lớn nhất của Go là ở chỗ gần như khắp nơi, kiểm tra
err != nilmột cách tường minh là bắt buộc về mặt chức năng, nên các linter cũng yêu cầu điều đóNgay khi Go mới xuất hiện, hàng trăm người đã chỉ ra toàn bộ cấu trúc này nực cười đến mức nào
Nhưng ngôn ngữ này trở nên rất phổ biến, và trong bầu không khí rằng Rob Pike biết rõ hơn, các phê bình bị gạt đi
Giờ mới thấy mọi người thảo luận bình thường bằng lý lẽ logic thì thật tốt
Không phải như thể đây chưa từng được biết là một ý tưởng tồi từ hàng chục năm trước, nhưng nếu Google làm thì chắc là tốt thôi… đúng không?
Vì khi gọi nó là “mớ nhảm nhí nực cười”, ta rất dễ bóp nghẹt chính tư duy logic mà mình nói là muốn thấy thêm
Tôi quên đó là tập podcast Oxide nào, nhưng Bryan Cantrill từng nói kiểu như “tôi muốn nghiên cứu thứ này để ghét nó tốt hơn”
Theo nghĩa đó, tôi muốn hiểu vì sao vào thập niên 2010 người ta lại hào hứng với Go đến vậy. Một phần chắc chắn là hype, và tôi đã tận mắt thấy ở nơi làm việc khi đó các lập trình viên không giải thích được vì sao nó tốt nhưng vẫn hào hứng
Nhưng hẳn không thể chỉ là hype thuần túy. Tôi tò mò đâu là steel-man argument mạnh nhất cho việc dùng Go vào thời đó