Ví dụ áp dụng Định luật Hyrum trong Golang
(abenezer.org)- Trong
net/httpcủa codebase Go có một chú thích nói rằng chuỗi lỗi"http: request body too large"màMaxBytesError.Error()trả về không thể thay đổi vì Hyrum's Law - Định luật Hyrum là nguyên tắc cho rằng khi có đủ nhiều người dùng API, sẽ luôn có ai đó phụ thuộc cả vào những hành vi có thể quan sát được nhưng không nằm trong hợp đồng chính thức
- Ngay cả một chuỗi có vẻ nhỏ nhặt như thông báo lỗi cũng có thể làm mã bên ngoài vận hành dựa trên đúng câu chữ đó, và khi thay đổi thì mã hiện có có thể bị hỏng
- Bên trong Go,
crypto/rsavàinternal/weakcũng có các chú thích tương tự, đề cập đến rủi ro cố định hóa hành vi của luồng ngẫu nhiên hoặc ngữ nghĩa chưa được chốt - Đây không phải vấn đề chỉ riêng Go, nên các API hay thư viện công khai cần được thiết kế để hành vi ngoài ý muốn không vô tình bị đóng đinh như một tiêu chuẩn trên thực tế
Định luật Hyrum được xác nhận trong mã Go
MaxBytesError.Error()trongnet/http/request.gotrả về chuỗi sau"http: request body too large"- Chú thích tại đó ghi: “Due to Hyrum's law, this text cannot be changed.”
- Định luật Hyrum là nguyên tắc được đặt theo tên Hyrum Wright, và định nghĩa trên hyrumslaw.com như sau
- Nếu có đủ nhiều người dùng API, bất kể hợp đồng hứa hẹn điều gì, sẽ luôn có ai đó phụ thuộc vào mọi hành vi có thể quan sát được của hệ thống
- Điểm cốt lõi của trường hợp
MaxBytesErrorlà câu chữ chính xác của thông báo lỗi có thể được mã bên ngoài sử dụng- Một thay đổi nhỏ trong câu chữ cũng có thể làm hỏng mã hiện có
- Kết quả tìm kiếm
http: request body too largecho thấy có mã nguồn mở Go đang dùng chính chuỗi này
Các gói Go khác và ví dụ từ codebase bên ngoài
-
Sự phụ thuộc vào luồng ngẫu nhiên trong
crypto/rsaEncryptOAEPtrongcrypto/rsa/rsa.gocó chú thích liên quan đến Hyrum's Law- Hàm này không hứa hẹn việc thực thi mang tính quyết định đối với luồng ngẫu nhiên, nhưng vì không áp dụng
MaybeReadByte, có khả năng ai đó đang phụ thuộc vào hành vi hiện tại SignPSStrongcrypto/rsa/pss.gocũng có chú thích trong cùng ngữ cảnh- Trong cả hai trường hợp, một số lượng byte ngẫu nhiên được xác định rõ sẽ được đưa vào bản mã hoặc chữ ký theo cách xác định rõ, nên được xem là một cam kết có thể chấp nhận
-
Rủi ro cố định hóa ngữ nghĩa của
internal/weakinternal/weakghi rằng toolchain cấm rõ ràng việc truy cập gói này và các hàm tham chiếu quago:linkname- Ngữ nghĩa của gói này chưa đi qua quy trình đề xuất, và nếu lộ chức năng ra ngoài thì ngữ nghĩa hiện có có thể bị cố định bởi Hyrum's Law
-
Mẫu lặp lại cả bên ngoài Go
- Việc nhắc đến Hyrum's Law không chỉ giới hạn trong Go
- Có thể xem các ví dụ đa ngôn ngữ trong kết quả tìm kiếm đa ngôn ngữ trên grep.app
urllib.parsecủa Python vàarray.hcủa Pixar OpenUSD cũng là những ví dụ codebase liên quan- Sự tiến hóa của JavaScript cũng dẫn tới các trường hợp mà sự phụ thuộc rộng rãi vào nhiều hành vi kỳ quặc và ngoài ý muốn đã trở thành tiêu chuẩn trên thực tế
Những điều cần kiểm tra trước khi thay đổi
- Khi thay đổi mã, cần cân nhắc không chỉ API đã được tài liệu hóa mà cả hành vi có thể quan sát được mà mã bên ngoài có thể đang phụ thuộc
- Cần thiết kế hệ thống ngay từ đầu để giảm khả năng phát sinh phụ thuộc vào những hành vi ngoài ý muốn
1 bình luận
Các ý kiến trên Hacker News
Định luật Hyrum là một quan sát hữu ích, nhưng không nên bám chặt vào đó rồi rút ra kết luận sai
Tổng thời gian chạy của một hàm cũng là một thuộc tính có thể quan sát được, nên ngay cả việc tối ưu để hàm chạy nhanh hơn cũng có thể bị xem là thay đổi phá vỡ tương thích. Vì hàng đợi đột nhiên có thể bị làm trống quá nhanh và gây ra deadlock. Dù vậy, 99,99999999% người dùng vẫn sẽ thích việc mã của họ chạy nhanh hơn mà không phải tốn công sức gì
Rốt cuộc, cái gì là thay đổi phá vỡ tương thích không thể chỉ là một hợp đồng kỹ thuật, mà phải là một hợp đồng xã hội. Nếu không thì theo nghĩa đen sẽ chẳng thể thay đổi bất cứ thứ gì. Tác giả thư viện cần ghi tài liệu những phần không thay đổi trong API, hành xử hợp lý và đồng cảm với người dùng; còn người dùng thư viện cần hiểu rằng việc lấy các giao diện không được ghi tài liệu làm phụ thuộc cốt lõi là trách nhiệm của chính họ, đồng thời cũng cần đồng cảm với tác giả
Tuy nhiên, nhìn từ góc độ khác, định luật Hyrum không phải là hợp đồng kỹ thuật hay hợp đồng xã hội, mà là một thuộc tính kỹ thuật phát sinh xuất hiện trong các hệ thống được sử dụng đủ rộng rãi
Cách ứng phó với thuộc tính đó phụ thuộc vào bối cảnh xã hội. Nếu là maintainer FOSS, khi một tối ưu hóa làm 99,99% nhanh hơn và chỉ 0,01% phải sửa mã hoặc chuyển sang API mới, họ sẽ phát hành. Nếu là công ty công nghệ lớn, họ vừa phải tối ưu, vừa không được làm hỏng dù 0% trong nội bộ công ty, nên phải hợp tác với nhiều nhóm để tìm điểm thỏa hiệp. Nếu là công ty phần mềm doanh nghiệp, dù chỉ 0,1% bị ảnh hưởng nhưng người dùng đó nằm trong 5 hợp đồng lớn nhất thì họ sẽ không phát hành
Lý do là tác giả ban đầu gọi nhiều hàm bất đồng bộ rồi giả định rằng đến lúc routine chậm trước đây kết thúc thì tất cả các hàm đó cũng đã hoàn tất. Mất rất lâu mới tìm ra chính xác chuyện gì đã xảy ra
Vì thế PC có nút turbo để giảm tốc độ, còn các máy tính 8-bit dù có CPU nhanh hơn vẫn không tăng tốc trong suốt 10 năm. Ngày nay hầu như mọi thứ đều chạy trên hai CPU trở lên, nên trừ việc có đủ nhanh hay không, hiếm khi có trường hợp phụ thuộc vào thời gian chạy của hàm. Ngay cả trong nhúng, sau khi từng trải qua chuyện một CPU đơn bị ngừng sản xuất, người ta cũng cố tránh các phụ thuộc như vậy
Đó là câu chuyện về lý do vì sao HTTP Status 418 từng bị biến thành phụ thuộc cốt lõi trong một API nội bộ, và vì sao dưới những ràng buộc đã cho, đó lại là lựa chọn ít tệ nhất
Môi trường vận hành, tải hệ thống tại thời điểm đó, việc GC chạy, v.v. đều có thể ảnh hưởng
Tóm lại, tôi không xem hành vi phát sinh từ máy móc là giao diện có chủ ý hay một dạng hợp đồng nào đó. Vì vậy, dù ai đó phụ thuộc vào một hành vi không chủ ý, cũng giống như việc sửa một lỗi tinh vi không bị coi là thay đổi phá vỡ tương thích, trường hợp này tôi cũng không xem là thay đổi phá vỡ tương thích
Trường hợp này có vẻ giống một bằng chứng cho thấy Go cam kết rất mạnh với khả năng tương thích ngược hơn bất cứ điều gì khác
Haha, phần chú thích trong
crypto/rsalà do tôi viết. Trong Go, định luật Hyrum và tương thích ngược https://go.dev/doc/go1compat được xem xét cực kỳ nghiêm túcVí dụ, trong nhiều hàm
GenerateKey, Go đọc thêm một byte từ luồng ngẫu nhiên bằngMaybeReadBytehttps://pkg.go.dev/crypto/internal/randutil#MaybeReadByte để thuật toán không bị cố định. Mới hôm qua cũng có báo cáo rằng một private ECDSA key có public key nil trước đây vẫn hoạt động nhưng giờ thì không, nên có lẽ sẽ phải làm cho nó hoạt động lại https://go.dev/issue/70468Việc duyệt map dùng thứ tự ngẫu nhiên để không làm lộ chi tiết triển khai nội bộ. Đầu ra của
rand.Randđược xem là một phần của cam kết tương thích, nên đã phải bỏ ra khá nhiều công sức để cải tiến nó https://go.dev/blog/randv2 https://go.dev/blog/chacha8randChúng tôi luôn thảo luận xem nên ghi cam kết nào vào tài liệu, và hành vi nào cần ghi rõ là “có thể thay đổi”. Vì chúng tôi biết rằng những gì đã được tài liệu hóa thì tuyệt đối không thể đổi, và cả những gì không ghi rõ là “có thể thay đổi” thì có lẽ cũng khó đổi https://go-review.googlesource.com/c/go/+/598336/comment/5d6...
Dù vậy, tôi xem đó là một đánh đổi đáng giá. Tôi dùng Go rất nhiều và thích khả năng tương thích ngược mạnh, nhưng nếu các nhà phát triển Go có nhiều tự do hơn để cải thiện hiệu năng và thêm tính năng, tôi sẵn sàng chấp nhận tỷ lệ thay đổi gây vỡ cao hơn một chút
Nhìn vào địa ngục mà người dùng các hệ sinh thái khác phải chịu, chẳng hạn Python, tôi nghĩ ý này không chỉ của riêng mình tôi
MaybeReadByteđược dùng trong nhiều hàmGenerateKey, nhưng có vẻ ed25519 thì không làm vậyTrước khi có
ed25519.NewKeyFromSeed(), đó là cách duy nhất để suy ra public Ed25519 key từ private key, và tôi gần như chắc chắn từng viết mã phụ thuộc vào chuyện đó. Tôi không thích lắm, nhưng vì chỉ có cách đó nên cũng dễ nhớTuy nhiên, việc tài liệu của
ed25519.GenerateKeyghi rõ đầu ra là xác định là điều tốt. Có vẻ Go đã làm rất tốt việc khảo sát và duy trì các hành vi đã bị “đóng đinh” trong API mật mã, đồng thời ngăn các hành vi mới bị đóng đinhGiống như A20 line khét tiếng (https://en.wikipedia.org/wiki/A20_line), cuối cùng ta sẽ phải mang theo hành vi hỏng này mãi mãi
Cụ thể, lời giải cho vấn đề được nhắc tới là đừng dùng lỗi dựa trên chuỗi, mà hãy dùng sentinel error https://thomas-guettler.de/go/wrapping-and-sentinel-errors
Nói tổng quát hơn, đừng tạo ra mã khiến người dùng API dù chỉ hơi muốn phụ thuộc vào các chuỗi phi kỹ thuật. Nếu dùng các thành phần hạng nhất của ngôn ngữ như giá trị lỗi được định nghĩa sẵn, kiểu, hoặc hằng chứa chuỗi phi kỹ thuật, người dùng API có thể so sánh giá trị trả về với hằng thay vì tự hard-code chuỗi
Định luật Hyrum chắc chắn tồn tại, nhưng ta có thể giảm tác động của nó
Grafana, có vẻ là nguyên nhân hàng đầu trong kết quả tìm kiếm được liên kết, lẽ ra phải dùng
errors.As(&http.MaxBytesError{})thay vì so sánh chuỗiĐiểm cốt lõi của định luật Hyrum là dù bạn thiết kế API tốt đến đâu cũng không quan trọng. Người ta sẽ phụ thuộc vào hành vi, chứ không phải hợp đồng
Bạn vẫn có thể viết mã kiểm tra
err.String() == "no more tea available.". Tôi đồng ý là không nên làm vậy, nhưng không có gì ngăn bạn làm thếHơn nữa,
errors.Ismới được thêm vào Go tương đối gần đây, nên vào thời điểm mọi người kiểm tra lỗi theo kiểu này, kiểm tra literal string là cách dễ hơn. Trong Go, nhà cung cấp API không thể ngăn người dùng kiểm tra giá trị trả về của.String()Đặc biệt trong standard library thì gần như không có lý do bào chữa
Khi cố tình phớt lờ lịch sử của các ngôn ngữ lập trình rồi chọn cách tiếp cận “vừa làm vừa thiết kế”, kết quả sẽ là như vậy
Cách đối phó với định luật Hyrum cũng là một chủ đề thú vị
Một khả năng là đưa tính ngẫu nhiên vào những phần mà bạn không muốn mọi người phụ thuộc vào
Nếu tôi nhớ không nhầm thì giao thức QUIC làm như vậy. Trong phiên bản hiện tại có một trường không được sử dụng, nhưng để ngăn router bắt đầu nhận diện gói tin bằng trường đó, đặc tả yêu cầu đặt nó thành giá trị ngẫu nhiên thay vì byte null
Nguồn có lẽ là ở đây: https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
“Giá trị của trường Unused được máy chủ đặt thành một giá trị tùy ý. Máy khách phải bỏ qua giá trị của trường này. [...] Lưu ý rằng các phiên bản QUIC khác có thể không đưa ra khuyến nghị tương tự”
Tôi hiểu rằng việc này được gọi là greasing, nhằm ngăn ossification
https://www.rfc-editor.org/rfc/rfc8701.html
Bản nháp sớm nhất của RFC này có từ giữa năm 2016, có lẽ là thời điểm thuật ngữ này xuất hiện công khai lần đầu: https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...
Không gì kinh khủng bằng việc 10 năm sau tỉnh dậy và phát hiện những bit đó thật sự cần dùng, nhưng 20 mẫu router của 10 thương hiệu đã quyết định rằng các bit đó nhất định phải theo một cách cụ thể
Nếu phía bên kia còn có checksum hoặc mã hóa khiến việc đụng vào bit sẽ làm hỏng mọi thứ thì càng “được điểm cộng”. Những “mánh hack thông minh” của middlebox thật sự là một cơn đau đầu
Đây là một ví dụ hay về phần mềm stringly typed
Các nhà thiết kế Go không muốn có exception, nhưng với
panic/recoverthì vẫn có thứ tương tự, và lỗi không có kiểu là có hại. Ngược lại, làm sao xử lý lỗi có kiểu mà không có pattern matching? Vìcatchtrong hầu hết ngôn ngữ chính là một dạng pattern matching sơ khaihttps://learn.microsoft.com/en-us/dotnet/csharp/language-ref...
Trước đây ở một nơi tôi từng làm, tôi phát hiện một lỗi chính tả trong thông báo lỗi và sửa nó, rồi nhận ra mạng lưới phụ thuộc vào đoạn văn bản có lỗi chính tả đó sâu đến mức thực tế không thể sửa được, nên cuối cùng phải đổi lại về đoạn văn bản có lỗi chính tả
Đến giờ vẫn còn làm tôi bận tâm
https://en.wikipedia.org/wiki/HTTP_referer
Đây là một dạng định luật Hyrum, nhưng thực ra về bản chất chỉ là Go kiểu Go
Nếu lỗi là kiểu enum, người dùng chỉ có thể thay đổi bằng cách thay thế chuỗi. Thay vào đó, vì dùng chuỗi như một kiểu, nên không thể biết người dùng có thể đang phụ thuộc vào nó theo cách nào. Họ có thể chỉ kiểm tra 6 ký tự ở giữa chuỗi lỗi, và nếu đổi thì sẽ bị hỏng
Đây lại là một quyết định thiết kế tệ hại và lỗi thời nữa, dù các ngôn ngữ khác đã có những lựa chọn tốt hơn từ hàng chục năm trước. Khi sai lầm ban đầu kết hợp với việc không thể thay đổi, bạn sẽ bị trói buộc mãi mãi
Điều thú vị là định luật này đối lập trực tiếp với nguyên tắc vững chắc, tức định luật Postel
“Bảo thủ khi gửi, khoan dung khi nhận”
Nếu chấp nhận đầu vào một cách khoan dung, bạn phải hiểu mình đã khoan dung theo những cách nào và ít nhất phải tài liệu hóa nội bộ. Vì định luật Hyrum, sau các thay đổi trong codebase lớn, bạn sẽ phải hỗ trợ tất cả những cách đó mãi mãi
Chính vì lý do đó mà tôi không muốn tạo API “khoan dung với những gì nhận vào”
Nếu đặt tiêu chuẩn dữ liệu nhận qua API quá lỏng, cuối cùng bạn sẽ phải quyết định sẽ nhào nặn dữ liệu đó thành định dạng chuẩn nào. Và quyết định đó dường như gần như luôn dẫn đến một hành vi gây bất ngờ cho người dùng theo cách nào đó
Có vẻ mỗi tác giả package chấp nhận vấn đề này ở mức độ khác nhau. Vài ngày trước tôi thấy chú thích như thế này trong package
jsonisValidNumberbáo cáo liệuscó phải là một literal số JSON hợp lệ hay khôngisValidNumberlẽ ra phải là chi tiết triển khai nội bộ, nhưng các package được dùng rộng rãi đang truy cập nó bằnglinknameThành viên tiêu biểu của hall of shame bao gồm
github.com/bytedance/sonicĐây là những điều tôi học được khi phát hành API
Client sẽ làm bất cứ điều gì cần thiết để hoàn thành việc của họ, dù đó không phải là cách nhà phát hành dự định. Client không đọc tài liệu. Khi đủ nhiều client phụ thuộc vào một hành vi nào đó, bug cũng trở thành một phần của API. Số lượng lệnh gọi API không nhất thiết tương ứng với mức độ quan trọng
Vì vậy khi phát triển API, tôi cố gắng tung beta API càng sớm càng tốt và quan sát cách nó được dùng để giảm bất ngờ. Trong hầu hết trường hợp, tôi tăng phiên bản chính trong khi vẫn hỗ trợ phiên bản cũ. Để làm được vậy, cần định nghĩa SLA của API