1 điểm bởi GN⁺ 5 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • 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 đó RateLimiter có field *redis.Client và kiểm tra r.redis != nil bên trong Allow thoạ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 Allow chạ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 khi client == nil thì 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)
  • Làm như vậy thì constructor của RateLimiter cũ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 NULL hoặ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 == nil bên trong RateLimiter.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ằng http.StatusBadRequest và trả về
  • Sau khi xác thực xong, req là 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
  • Allow cuối cùng tập trung vào logic thực tế mà không kiểm tra nil
    • userID := 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 userID rỗ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

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    Khi call stack được tháo ngược, ngữ cảnh về lỗi nên được tích lũy dần

    • Một ví dụ idiomatic hơn sẽ trông như sau
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      Sau đó, mỗi tầng chỉ bổ sung lỗi xảy ra ở đâu, còn err trong cùng cho biết điều gì đã xảy ra thì sẽ tốt hơn
    • Đáng tiếc là không có stack trace thống nhất, trên thực tế là chuẩn, cho lỗi
      Trên thực tế, “wrap” rất dễ trở thành việc grep chuỗ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ất
    • Cũng có người phàn nàn rằng stack lỗi quá dài, nhưng đa số cho rằng những thông báo như vậy có thể hành động được và hữu ích
      Trướ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
    • Cách làm hiện nay, theo trí nhớ của tôi, là dùng errors.Join
  • Tôi cho rằng Go tạo ra hai vấn đề ở đây

    1. Nếu Go có khả năng null (nullability) rõ ràng, bản thân vấn đề này gần như đã biến mất
    2. Dường như không có cách nào ngăn zero initialization đối với các kiểu có thể đặt tên, nên sai sót có thể lẩn vào bất cứ lúc nào
    • Tôi cảm thấy câu này trong bài viết chỉ ra rất rõ vấn đề gốc rễ
      Đó 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 nil tạ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ận
      Vấ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 nullable
    Tô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

    • Nếu nhìn từ phía ngược lại, khả năng diễn đạt “không có” hoặc “bị thiếu” một cách gọn gàng rất hữu ích, đặc biệt khi xử lý các cấu trúc dữ liệu tùy ý như JSON
      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.baz dễ hơn nhiều so với foo.unwrap().bar.unwrap().baz của Rust
      Ngay 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ận
    • Không dùng con trỏ thì cũng không có null, hoan hô… 😭
  • Tô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ằng T*, nhưng T* 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++

    • Dereference nil của Go tốt hơn dereference con trỏ null của C ở chỗ nó panic một cách xác định, nhưng vì lỗi chỉ xuất hiện khi con trỏ thực sự bị dereference, nên cũng không tuyệt vời đến vậy
      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ạp
      Vì vậy, assert ngay trong NewRateLimiter rõ ràng có lợi. Trong code ví dụ, tức là đổi
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      thành
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      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ộ runtime
    • Tôi cho rằng null check và assert là hai thứ hoàn toàn khác nhau
      assert 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 nil xuấ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ến
    Trong 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ì

    • Assert các bất biến là việc tuyệt vời nếu bắt đầu như vậy ngay từ đầu và tiếp tục duy 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 tra err != 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 != nil mộ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?

    • Tôi không phải fan của Go, nhưng cách đóng khung như thế này làm tôi khó chịu
      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 đó