Parse, đừng validate (2019)
(lexi-lambda.github.io)- Trong thiết kế hướng kiểu, thay vì validate chỉ để kiểm tra rồi bỏ đi đầu vào, parse giúp giữ lại kết quả kiểm tra dưới một kiểu chính xác hơn, từ đó tăng độ an toàn cho phần mã phía sau
- Với những hàm có thể thất bại trên một số đầu vào như
head :: [a] -> a, ta có thể làm yếu kiểu trả về, nhưng khi đó bên gọi sẽ phải liên tục gánh các nhánh thất bại không cần thiết NonEmpty agiữ trạng thái danh sách không rỗng ngay trong kiểu, giúp giảm kiểm tra trùng lặp và xử lý các lỗi “không bao giờ xảy ra”- Nếu trộn các bước validate tạm thời khắp mã xử lý, sẽ dẫn tới shotgun parsing, khiến lỗi đầu vào chỉ được phát hiện sau khi một phần trạng thái đã bị thay đổi
- Trong thực tế, nên đưa biểu diễn dữ liệu mong muốn vào chữ ký hàm trước, rồi dùng
Map, kiểu trừu tượng, smart constructor, v.v. để đẩy bất biến lên ranh giới kiểu
Điểm khởi đầu của thiết kế hướng kiểu
- “Parse, don’t validate” là câu khẩu hiệu cô đọng thiết kế hướng kiểu trong ba từ
- Hệ thống kiểu tĩnh cho thấy câu hỏi “liệu có thể viết hàm này không” ngay cả trước khi viết mã
- Trong ví dụ Haskell,
foo :: Integer -> Voidthực tế không thể tạo ra giá trị vìVoidkhông có giá trị nào head :: [a] -> acũng không được định nghĩa cho mọi đầu vào vì có thể nhận danh sách rỗng[]- GHC sẽ cảnh báo rằng pattern matching không xử lý
[] - Đây là một hàm bộ phận không được định nghĩa cho mọi đầu vào có thể có
- GHC sẽ cảnh báo rằng pattern matching không xử lý
Hai cách biến hàm bộ phận thành hàm toàn phần
-
Làm yếu kiểu trả về
- Đổi thành
head :: [a] -> Maybe athì khi gặp danh sách rỗng có thể trả vềNothing, nhờ đó thành hàm toàn phần - Cách này dễ triển khai nhưng bên gọi luôn phải xử lý khả năng
Nothing - Bài viết đưa ví dụ dù đã đọc biến môi trường
CONFIG_DIRSvà xác nhận danh sách không rỗng, trongmainvẫn phải xử lý lại nhánhNothingtừ kết quảhead - Kiểm tra lặp lại làm mã trở nên lộn xộn, và trong các trường hợp phức tạp còn có thể tích lũy thành chi phí hiệu năng
- Ngay cả khi bỏ bước kiểm tra phía trước, các lỗi “không bao giờ xảy ra” ở phía sau vẫn không được thể hiện qua kiểu
- Cuối cùng hệ thống kiểu bị thủng lỗ, và việc phát hiện bug phải dựa vào test hoặc rà soát thủ công
- Đổi thành
-
Làm mạnh kiểu đối số
- Nếu không làm yếu kiểu trả về mà làm mạnh kiểu đối số, ta có thể loại bỏ khả năng
headbị gọi với danh sách rỗng NonEmpty atrongData.List.NonEmptybiểu diễn một danh sách không rỗng- Định nghĩa là
data NonEmpty a = a :| [a] - Phần tử đầu
avà phần còn lại[a]được tách riêng, nên dù đuôi rỗng thì phần tử đầu vẫn luôn tồn tại head :: NonEmpty a -> acó thể được triển khai chỉ với một pattern và là hàm toàn phần- Nếu đổi kiểu trả về thành
getConfigurationDirectories :: IO (NonEmpty FilePath), thì факт danh sách không rỗng sẽ được giữ lại trong kiểu nonEmpty :: [a] -> Maybe (NonEmpty a)dùng để chuyển danh sách thường thànhNonEmpty- Việc xử lý
Nothingchỉ cần diễn ra một lần ở ranh giới đầu vào - Trong
main, có thể dùnginitializeCache (head configDirs)mà không cần rẽ nhánh lặp lại - Về sau nếu
getConfigurationDirectorieskhông còn đảm bảo danh sách không rỗng, kiểu trả về cũng phải đổi theo, vàmainsẽ lỗi khi kiểm tra kiểu
- Nếu không làm yếu kiểu trả về mà làm mạnh kiểu đối số, ta có thể loại bỏ khả năng
Khác biệt giữa validate và parse
validateNonEmpty :: [a] -> IO ()vàparseNonEmpty :: [a] -> IO (NonEmpty a)đều kiểm tra danh sách rỗng và báo lỗi nếu thất bại- Khác biệt nằm ở kiểu trả về
validateNonEmptytrả về()không mang thông tin, tức là vứt bỏ kết quả kiểm traparseNonEmptytrả vềNonEmpty a, giữ lại tri thức có được từ bước kiểm tra trong hệ thống kiểu
- Có thể xem parser là hàm tiêu thụ đầu vào kém cấu trúc hơn để tạo ra đầu ra có cấu trúc hơn
- Theo định nghĩa đó,
parseNonEmptylà một parser đơn giản biến danh sách thành danh sách không rỗng - Parse cho phép hoàn tất việc kiểm tra ở ranh giới giữa chương trình và thế giới bên ngoài, để không phải lặp lại cùng một kiểm tra về sau
Ranh giới parse trong hệ sinh thái Haskell
- Ở các điểm tiếp xúc với thế giới bên ngoài, ứng dụng Haskell dùng nhiều loại parser khác nhau
- aeson: cung cấp kiểu
Parserđể parse dữ liệu JSON thành kiểu miền nghiệp vụ - optparse-applicative: cung cấp parser combinator cho đối số dòng lệnh
- persistent, postgresql-simple: cung cấp cơ chế parse giá trị từ kho dữ liệu bên ngoài
- servant: parse kiểu dữ liệu Haskell từ thành phần đường dẫn, tham số truy vấn, HTTP header, v.v.
- aeson: cung cấp kiểu
- Thế giới bên ngoài không giao tiếp bằng product type hay sum type mà bằng luồng byte, nên parse là điều không thể tránh khỏi
- Parse dữ liệu trước khi dùng giúp tránh được nhiều loại bug, một số trong đó thậm chí có thể dẫn tới lỗ hổng bảo mật
- Muốn parse mọi thứ ngay từ đầu thì đôi khi phải parse giá trị sớm hơn rất nhiều so với lúc thực sự sử dụng
- Với hệ thống kiểu tĩnh, nếu logic parse và logic xử lý lệch nhau thì chương trình sẽ không biên dịch được
Rủi ro của cách tiếp cận thiên về validate
- Validate tạm thời dễ dẫn tới shotgun parsing trong lĩnh vực language-theoretic security
- Trong bài báo năm 2016 The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them, shotgun parsing là một anti-pattern khi mã parse và kiểm tra đầu vào bị trộn lẫn, phân tán trong mã xử lý
- Nếu không parse toàn bộ đầu vào ngay từ đầu, chương trình có thể xử lý một phần đầu vào hợp lệ rồi mới phát hiện lỗi ở phần khác vào lúc muộn
- Khi đó phải hoàn tác những thay đổi trạng thái đã thực hiện
- Một số trường hợp như transaction trong RDBMS có thể rollback, nhưng nhìn chung không phải lúc nào cũng làm được
- Cách tiếp cận dựa trên validate khiến việc xác minh rằng mọi kiểm tra thực sự đã hoàn tất từ đầu trở nên khó hoặc bất khả thi
- Parse chia chương trình thành giai đoạn parse và giai đoạn thực thi, giới hạn lỗi do đầu vào sai vào giai đoạn đầu tiên
Cách áp dụng trong thực tế
- Hãy viết biểu diễn dữ liệu mà hàm mong muốn ngay trong type signature trước, rồi thiết kế để lấp đầy khoảng cách với biểu diễn hiện có
- Nếu một hàm nhận danh sách
[(k, v)]nhưng không được phép có khóa trùng, một bước kiểm tra riêng nhưcheckNoDuplicateKeys :: ... => [(k, v)] -> m ()rất dễ bị bỏ sót - Cách tốt hơn là để hàm nhận
Map, vì cấu trúc này về bản chất không cho phép khóa trùng- Điểm gọi hàm có thể sẽ lỗi kiểm tra kiểu
- Điều đó buộc việc chuyển từ danh sách sang
Mapđược đẩy ngược lên trên chuỗi lời gọi - Khi tới nơi giá trị được tạo ra hoặc nơi việc trùng lặp thực sự cần được cho phép, ta sẽ chèn kiểm tra dạng
[(k, v)] -> m (Map k v)
- Khi đó không thể bỏ qua bước kiểm tra, vì kết quả của nó là thứ cần cho phần thực thi phía sau
- Có hai nguyên tắc lặp đi lặp lại
- Dùng cấu trúc dữ liệu khiến trạng thái bất khả thi không thể được biểu diễn
- Đẩy gánh nặng chứng minh lên cao nhất có thể, nhưng không ép đi xa hơn điểm thực sự cần thiết
Hướng dẫn thiết kế bổ sung và giới hạn
- Hãy để kiểu dữ liệu dẫn dắt mã nguồn, và tránh cám dỗ chỉ thêm
Boolvào record vì nhu cầu của hàm đang viết - Những hàm trả về
m ()cần được xem xét cẩn thận- Chúng có thể cần thiết khi chỉ thực hiện hiệu ứng mệnh lệnh mà không có kết quả có ý nghĩa
- Nhưng nếu mục tiêu chính là ném lỗi thì có thể tồn tại cách tốt hơn
- Không cần sợ việc parse dữ liệu theo nhiều bước
- Tránh shotgun parsing không có nghĩa là không được làm gì với dữ liệu đầu vào trước khi parse hoàn toàn
- Ta vẫn có thể dùng một phần đầu vào để quyết định cách parse phần khác
- Nên tránh biểu diễn dữ liệu phi chuẩn hóa, đặc biệt khi dữ liệu có thể thay đổi
- Nếu cùng một dữ liệu bị sao chép ở nhiều nơi, trạng thái lệch nhau rất dễ xuất hiện
- Nếu thực sự cần phi chuẩn hóa, hãy giấu nó sau ranh giới trừu tượng để chỉ một mô-đun đáng tin cậy nhỏ chịu trách nhiệm đồng bộ
- Khi chỉ với công cụ Haskell khó thể hiện chính xác một bất biến nào đó, có thể dùng
newtypetrừu tượng và smart constructor để biến validator thành parser - Không nhất thiết phải đưa singletons vào và refactor toàn bộ ứng dụng chỉ để loại bỏ mọi
error "impossible", nhưng trong những trường hợp như vậy vẫn cần xử lý thật thận trọng, chẳng hạn ghi lại bất biến bằng chú thích
Đọc thêm và những lưu ý thực tế
- Để tận dụng tốt hệ thống kiểu của Haskell, không nhất thiết phải có bằng PhD hay dùng các language extension mới nhất của GHC
- Điểm khởi đầu gần với nguyên tắc đơn giản “hãy viết hàm toàn phần”, nhưng việc áp dụng vào mã thực tế có thể không hề dễ dàng
- Cộng đồng Haskell khá nhỏ nên nhiều pattern và kỹ thuật thiết kế đôi khi tồn tại dưới dạng tri thức truyền miệng hơn là tài liệu hóa đầy đủ
- Tài liệu liên quan có bài của Matt Parson: Type Safety Back and Forth
- Với chủ đề nâng cao hơn, bài báo năm 2018 của Matt Noonan Ghosts of Departed Proofs trình bày các kỹ thuật đưa những bất biến phức tạp hơn vào hệ thống kiểu
- Trong chương trình thực tế, có thể có những bất biến rất khó biểu diễn trong hệ thống kiểu, nên các nguyên tắc này gần với một lý tưởng để hướng tới hơn là các yêu cầu cứng nhắc
1 bình luận
Các ý kiến trên Hacker News
Lời khuyên rất hay và là một bài viết xuất sắc. Có lý do vì sao bài này thỉnh thoảng lại được đưa lên lại trên trang này
Ngay cả với những người không dùng ngôn ngữ hàm có kiểu tĩnh, ý tưởng này cũng vượt ra ngoài phạm vi paradigm. Trong các tài liệu hướng đối tượng thập niên 80–90, chẳng hạn Design by Contract (thiết kế theo hợp đồng), cũng có thể thấy những khái niệm rất tương tự; và có lẽ còn tìm được cả các bài báo, thảo luận, đặc tả xa hơn nữa
Tôi nghĩ TypeScript cũng thường được viết theo kiểu thu hẹp kiểu tại runtime. Design by Contract có lẽ cũng đã ảnh hưởng đến spec của Clojure, một ngôn ngữ động
Về cơ bản, đây là vấn đề của giả định và bảo đảm. Nếu có thể kiểm tra một giả định nào đó để tạo ra một bảo đảm, thì các phần khác của chương trình không cần kiểm tra lại cùng giả định ấy nữa
Khi đọc code, điều khiến tôi bối rối nhất là thấy một thuộc tính đã được bảo đảm rồi lại bị kiểm tra ở nơi khác. Điều đó làm việc suy luận và cải tiến trở nên khó hơn
Về mặt thống kê, chuyện như vậy cuối cùng cũng sẽ xảy ra, và khi đó các process, script, code khác vốn phụ thuộc vào quy trình kiểm chứng “ban đầu” sẽ gặp rắc rối lớn
Nhưng phải thực sự dùng nó. Ví dụ, có thể đặt các lớp
UncheckedEmail,ValidEmail,VerifiedEmail, và bắt buộc phải đi qua quy trình xác minh email mới được chuyển từ giai đoạn này sang giai đoạn tiếp theoKhi đó bạn không cần đoán địa chỉ email là chưa kiểm tra, hợp lệ về mặt định dạng, hay đã xác minh xong; cũng không cần các boolean như
is_email_verifiedvốn dễ bị quên cập nhật hoặc kiểm tra. Nếu dùng sai giá trị ở sai chỗ, type checker sẽ la lên, còn con người có thể tập trung vào việc quan trọngVì vậy có người hiểu rằng tác giả đang nói đừng xác thực gì cả, chỉ parse thôi; nhưng bài viết thực ra nói về việc xác thực dữ liệu ở đâu và làm gì với kết quả đó. Đây không phải là bài kêu gọi loại bỏ mọi việc xác thực
Dù là bài từ năm 2019, đây vẫn là lời khuyên khá ổn. Pattern này cũng rất hợp với C# hiện đại, và vì có thể bỏ qua khai báo biến tường minh nên cũng tiết kiệm chỗ
if(!Whatever.TryParse(input, out var output)) output = some-sane-default;hoặc
if(!Whatever.TryParse(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");Mẹo chuyên gia: đừng làm cách sau trong kernel-mode driver
Xử lý tường minh luôn tốt hơn một giá trị mặc định ngầm định được dùng thay thế khi giá trị mà ta tưởng là đúng hóa ra lại sai
Điều nên làm là báo lỗi sớm và coi đó là lỗi parse, rồi định nghĩa thật rõ quy trình và protocol để xử lý một file không thể load. Khi đó bạn sẽ tự buộc mình đặt ra những câu hỏi khó mà hai lựa chọn trên không xử lý
Vấn đề thực sự trong vụ driver kernel mode gần đây của CrowdStrike không parse được một file def/config nào đó là các developer, product owner, business analyst đã không hỏi “điều gì xảy ra nếu cố load một file không hợp lệ?”
Xử lý tường minh > xử lý ngầm định
if(!Whatever.TryParse(input, out var output)) output = some-sane-default;Tôi thật sự ghét cách này. Tôi cho rằng lỗi input không hợp lệ nên được xử lý bên ngoài hàm parse. Trong F# thì việc đó dễ
type Whatever =static member create input =match input with| ValidWhatever x -> Some x| _ -> Nonematch Whatever.create input with| Some x -> // xử lý dữ liệu đã parse| None -> // xử lý trường hợp không parse đúngHoặc cũng có thể dùng
Option.map/Option.bindđể làm pipeline xử lý nối tiếp thuận tiện hơnNhư vậy, instance chỉ có thể được tạo thông qua phương thức
createdùng để parse inputTuy trong thực tế nhiều khả năng bạn sẽ muốn dùng
resulthơnoption, nhưng đó là chuyện phụif(!Whatever.TryParse(input, out var output)) output = some-sane-default;Nếu input hoàn toàn không được cung cấp, tức tham số là optional, thì dùng một giá trị mặc định hợp lý là có lý
Nhưng nếu input sai đã được cung cấp, thì tôi không muốn giả vờ như chẳng có vấn đề gì
Khi ai đó bước vào cửa hàng hoa và gọi cà phê, câu trả lời đúng không phải là đưa cho họ một bông hồng. Nếu họ cố uống nó thì miệng họ sẽ bị rách toạc
Với tập input đó, method, module hay chương trình không có output được định nghĩa. Cần làm rõ sự thật đó, thay vì âm thầm làm sai hoặc làm điều mơ hồ khiến chương trình nhanh chóng trở nên không thể suy luận được. Tốt hơn cho chính bạn là làm cho vấn đề phát sinh rõ ràng và để lại stack trace dẫn thẳng tới điểm lỗi, thay vì để vài tháng sau nó bị bắt như một bug hành vi kỳ lạ
Đây là lời khuyên hãy tận dụng hệ thống kiểu mạnh để khiến trạng thái lỗi không thể biểu diễn được. Điều này rất hữu ích trong việc giảm lỗi trên toàn bộ phần mềm
Suy nghĩ sâu hơn về vấn đề và thiết kế theo cách này sẽ tốn thêm thời gian, nhưng trong nhiều trường hợp khoảng thời gian đó hoàn toàn xứng đáng
Tất nhiên, nếu là những ngôn ngữ đòi hỏi nhiều quy trình có ý thức để mô hình hóa dữ liệu như C++, Java, C#, Python, Go, JavaScript thì sẽ tốn thời gian hơn
“Giờ đây tôi đã có một khẩu hiệu ngắn gọn và mạnh mẽ thể hiện thiết kế theo kiểu nghĩa là gì với tôi, và hay hơn nữa là nó chỉ có ba từ: Parse, don’t validate.”
Khẩu hiệu của tôi thì đúng hơn là hãy luôn xác thực chỉ trong một constructor duy nhất. Là hàm constructor cũng không sao
Làm vậy thì đối tượng không hợp lệ ngay từ đầu đã không thể tồn tại, và luôn có một nguồn sự thật duy nhất. Nếu muốn sửa đối tượng, có thể triển khai bằng cách gọi lại cùng constructor đó để tạo trạng thái mới
Điểm cốt lõi là nếu chỉ xác thực thì thông tin đó sẽ biến mất về sau
Ví dụ, chỉ xác thực rằng một
intnào đó là số dương thì lợi ích khá hạn chế. Nếu không parse giá trị đó thành một số nguyên dương, thì sau đó ở cấp độ kiểu sẽ không còn thông tin như vậy. Mảng/danh sách không rỗng cũng tương tự, nên bên tiêu thụ phía sau có thể phải kiểm tra lại xem danh sách đó có thật sự không rỗng hay khôngLoại thông tin này không phải lúc nào cũng có thể được mã hóa vào đối tượng hay constructor
Tài liệu liên quan: Making Impossible States Impossible của Richard Feldman
https://www.youtube.com/watch?v=IcgmSRJHu_8
Trước đây cũng đã có những thảo luận hay
https://news.ycombinator.com/item?id=35053118
https://news.ycombinator.com/item?id=21476261
Mỗi khi chủ đề này xuất hiện, tôi lại nhớ đến mục 5 của https://cr.yp.to/qmail/guarantee.html. Trong đó có những câu như “đừng parse” và “trong thế giới điện toán có hai loại giao diện lệnh: giao diện tốt và giao diện người dùng”
Nếu tôi dạy một lớp về lập trình quy mô trung bình, chứ không phải quy mô nhỏ hay lớn, tôi muốn giao cho sinh viên viết bài luận so sánh và đối chiếu các đề xuất này. Mỗi đề xuất đều có điều đáng học, và có thể chúng không mâu thuẫn như thoạt nhìn
Tôi nhớ đến một bình luận từng thấy vào thời cơn sốt XML giữa những năm 2000. Nội dung là lý do nhiều tổ chức triển khai các ngôn ngữ đặc thù miền, kể cả ngôn ngữ cấu hình, bằng XML có lẽ là vì XML cung cấp parser, còn phần lớn tổ chức không muốn tự viết parser cho mình
Tôi không biết vì sao mọi người lại không thích viết parser. Viết parser không khó đến thế và khá thú vị
Đây là một trong những bài viết tôi thích nhất trong sự nghiệp. Tôi thấy nhiều người chỉ đọc tiêu đề rồi cho rằng parsing và validation somehow loại trừ lẫn nhau, nhưng thực tế không phải vậy. Parsing thường bao gồm cả validation
Nội dung này được đề cập trong phần “Use abstract datatypes to make validators ‘look like’ parsers” của bài viết
Nó nằm cùng phạm vi với chuyện tránh ám ảnh kiểu nguyên thủy