Vài triệu dòng Haskell: Kỹ thuật vận hành production tại Mercury
(blog.haskell.org)- Mercury vận hành codebase Haskell khoảng 2 triệu dòng không tính chú thích và cung cấp dịch vụ ngân hàng cho hơn 300.000 doanh nghiệp, xử lý khối lượng giao dịch 248 tỷ USD và doanh thu quy đổi theo năm 650 triệu USD trong năm 2025
- Giá trị của việc Mercury sử dụng Haskell không nằm ở tính thuần túy tự thân mà ở chỗ đóng gói tri thức vận hành vào API và kiểu dữ liệu, đặt các hành vi rủi ro sau những ranh giới hẹp và biến con đường an toàn thành con đường dễ đi nhất
- Độ tin cậy không được xem là ngăn chặn mọi thất bại mà là khả năng hệ thống hấp thụ biến động, còn hệ thống kiểu giúp loại trừ các lớp lỗi và lưu lại tri thức tổ chức như một dạng tài liệu mà compiler có thể cưỡng chế
- Mercury dùng Temporal làm framework durable execution cho retry, timeout, hủy và khôi phục sau crash trong các luồng công việc tài chính, đồng thời công bố mã nguồn mở Haskell SDK
hs-temporal-sdk - Giá trị production của Haskell không nằm ở việc đưa mọi thứ vào kiểu dữ liệu, mà là bảo vệ bằng kiểu các bất biến có thể dẫn tới mất dữ liệu, lỗi tài chính hoặc vấn đề tuân thủ, đồng thời đóng gói độ phức tạp và vận hành cùng với test, tài liệu và code review
Quy mô vận hành Haskell và góc nhìn về độ tin cậy tại Mercury
- Mercury vận hành một codebase Haskell quy mô khoảng 2 triệu dòng không tính chú thích
- Mercury là công ty fintech cung cấp dịch vụ ngân hàng cho hơn 300.000 doanh nghiệp, và trong năm 2025 đã xử lý khối lượng giao dịch 248 tỷ USD cùng doanh thu quy đổi theo năm 650 triệu USD
- Công ty có khoảng 1.500 nhân viên, và tổ chức kỹ thuật chủ yếu tuyển các lập trình viên đa dụng; phần lớn chưa từng dùng Haskell trước khi gia nhập
- Hệ thống này đã vận hành trong nhiều năm qua các giai đoạn tăng trưởng nhanh, tình huống có 2 tỷ USD tiền gửi mới đổ vào chỉ trong 5 ngày do khủng hoảng SVB, các đợt rà soát quản lý, cùng những tình huống điển hình và không điển hình của hệ thống tài chính quy mô lớn
Độ tin cậy không phải ngăn lỗi mà là khả năng hấp thụ biến động
- Cách tiếp cận độ tin cậy truyền thống tập trung vào việc liệt kê các thất bại, bổ sung kiểm tra và test, rồi tìm bug, nhưng chỉ như vậy là chưa đủ
- Mercury xem độ tin cậy là khả năng hệ thống hấp thụ biến động
- Hệ thống phải có khả năng suy giảm hiệu năng một cách êm ái
- Người vận hành phải có thể hiểu và điều chỉnh hệ thống
- Kiến trúc phải khiến việc đúng trở nên dễ làm, còn việc sai trở nên khó làm
- Trong một tổ chức tăng trưởng nhanh, các câu hỏi vận hành thực tế sẽ là: kỹ sư mới vào có thể đọc và hiểu module hay không, dịch vụ có sụp theo khi database chậm hay không, và compiler có bắt được việc lạm dụng interface hay không
- Hệ thống kiểu gần với một thiết bị hỗ trợ vận hành hơn là chỉ một công cụ chứng minh tính đúng đắn
- Nó loại trừ một số lớp lỗi nhất định
- Nó lưu lại tri thức tổ chức dưới dạng mà compiler có thể đọc được, kể cả sau khi tác giả đã rời đi
- Nó đóng vai trò như tài liệu được cưỡng chế nhất quán hơn wiki
- Kỹ thuật ổn định của Mercury không phải là kiểu “cảnh sát chất lượng” làm chậm phát triển sản phẩm, mà là một cách cộng tác để xử lý tác động khi tính năng bị hỏng ngay từ đầu quá trình thiết kế
- Phạm vi ảnh hưởng khi lỗi xảy ra
- Những tác vụ cần tính idempotent và cách thực hiện
- Hình thức rollback
- Cách xử lý công việc đang dang dở
- Xem xét trước hệ thống nào hấp thụ thất bại và hệ thống nào khuếch đại thất bại
Tính thuần túy không phải thuộc tính của ngôn ngữ mà là ranh giới interface
- Tính thuần túy của Haskell không có nghĩa là bên trong hoàn toàn không có side effect, mà gần hơn với ý rằng interface tạo ra ranh giới ngăn side effect rò rỉ ra ngoài
- Đằng sau các hàm thuần của những thư viện như
bytestring,text,vectorvẫn tồn tại các triển khai nội bộ như cấp phát có thể thay đổi, ghi buffer và unsafe coercion - Monad
STsử dụng thay đổi tại chỗ có thể quan sát được và side effect bên trong phép tính, nhưng kiểu rank-2 củarunSTngăn các tham chiếu mutable được tạo bên trong thoát ra ngoàirunST :: (forall s. ST s a) -> a - Bên trong có thể có hành vi kiểu mệnh lệnh, nhưng bên ngoài chỉ nhận được kết quả, và trạng thái có thể thay đổi sẽ không rò rỉ qua ranh giới
- Nguyên tắc này được áp dụng cho toàn bộ hệ thống vận hành
- Tầng database có thể nội bộ dùng connection pooling, retry và trạng thái mutable
- Cache có thể dùng map mutable đồng thời
- HTTP client có thể có circuit breaker, connection pool và nhiều xử lý bookkeeping
- Cốt lõi là bọc các hành vi rủi ro trong interface hẹp để khiến việc lạm dụng trở nên khó khăn
- Trong hệ thống thực tế, mục tiêu không phải là tránh hoàn toàn thay đổi, mà là làm rõ thay đổi nằm ở đâu và giới hạn ai trong codebase cần phải biết về nó
Biến việc đúng thành việc dễ làm
- Trong codebase lớn, thường xuất hiện các mẫu mà tính đúng đắn phụ thuộc vào một thứ tự cụ thể hoặc các bước bổ sung không nhìn thấy được
- Phải flush audit log sau giao dịch
- Phải kiểm tra feature flag trước khi gọi endpoint
- Phải enqueue thông báo bên trong transaction của database
- Nếu tri thức vận hành này chỉ tồn tại trong wiki, tài liệu onboarding, các buổi review thiết kế cũ, thread Slack hoặc trí nhớ của một vài kỹ sư senior, nó sẽ biến mất rất nhanh
- Haskell có thể mã hóa các quy trình này vào kiểu dữ liệu để không thể bị quên
- Cách làm tệ là chỉ yêu cầu dùng hàm đúng nhưng vẫn để lại đường vòng
-- Please use this one, not the other one writeWithEvents :: Transaction -> [Event] -> IO () -- Don't use this directly (but we can't stop you) writeTransaction :: Transaction -> IO () publishEvents :: [Event] -> IO ()- Cách tốt hơn là tái cấu trúc kiểu để con đường duy nhất thực thi công việc luôn bao gồm việc phát hành event
data Transact a -- opaque; cannot be run directly record :: Transaction -> Transact () emit :: Event -> Transact () -- The *only* way to execute a Transact: commit and publish atomically commit :: Transact a -> IO a - Ở đây, hệ thống kiểu không nhằm chứng minh các định lý sâu về event, mà nhằm biến quy trình vận hành đúng thành con đường dễ nhất
- Khi kỹ sư mới hỏi cách viết transaction, type signature và API công khai sẽ tự đưa ra câu trả lời, và tri thức vẫn còn lại ngay cả khi kỹ sư senior rời đi
Thực thi bền vững và Temporal
- Quy trình công việc trong hệ thống tài chính không dừng lại trong một giao dịch đơn lẻ
- gửi thanh toán
- chờ đối tác phê duyệt
- cập nhật sổ cái
- thông báo cho khách hàng
- xử lý hủy và timeout
- trường hợp đối tác đã thành công nhưng worker chết trước khi ghi lại
- trường hợp không có phản hồi do sự cố mạng
- Những luồng như vậy cần trạng thái, retry, timeout, idempotency và khả năng thực thi tiếp tục bền vững vượt qua crash tiến trình lẫn triển khai
- Trước đây, Mercury điều phối các quy trình này bằng state machine dựa trên cơ sở dữ liệu, tác vụ cron, worker nền, cùng logic retry và timeout rải rác khắp code
- Cách này hoạt động được nhưng mong manh, khó hiểu và là nguyên nhân không cân xứng của các sự cố vận hành
- Temporal là framework durable execution của Mercury, cho phép viết workflow như code tuần tự thông thường và nền tảng sẽ ghi từng bước vào event history
- Nếu worker crash giữa chừng trong workflow, một worker khác sẽ replay prefix mang tính quyết định để khôi phục trạng thái và tiếp tục từ điểm dừng
- Retry, timeout, hủy và xử lý lỗi do nền tảng cung cấp thay vì để từng nhóm tự triển khai lại
- Temporal workflow có tính chất gần giống một hàm thuần đối với event history
- workflow được replay phải tạo ra cùng một chuỗi lệnh như ban đầu
- Yêu cầu về tính quyết định này giống với ràng buộc cùng đầu vào·cùng đầu ra của code thuần
- Tác dụng phụ được cô lập vào activity, tương ứng với
IOcủa workflow
- Mercury đã xây dựng và open source Haskell SDK
hs-temporal-sdk, bọc Core SDK chính thức của Temporal bằng Rust FFI - Mẫu áp dụng Temporal cũng được đề cập trong bài trình bày Temporal Replay conference, và Mercury đã cải thiện vận hành bằng cách thay thế các chuỗi cron·state machine mong manh bằng durable workflow
Thiết kế domain bằng ngôn ngữ nghiệp vụ, không phải lớp truyền tải
- Một sai lầm phổ biến trong các hệ thống đã phát triển là để các khái niệm của hệ thống gọi rò rỉ vào mô hình domain
- Khi code được viết cho HTTP request handler về sau được tái sử dụng trong tác vụ cron, worker nền dựa trên queue, và Temporal workflow, các ngoại lệ HTTP như
StatusCodeException 409 "Conflict"có thể lan sang ngữ cảnh không phải HTTP - Tác vụ cron không có caller nào đang chờ phản hồi 409, và status code kéo ý nghĩa nghiệp vụ xuống sai tầng
- Cách giải quyết là mô hình hóa lỗi domain bằng các kiểu domain
- thiếu số dư phải là
InsufficientFunds - yêu cầu trùng lặp phải là
DuplicateRequest - timeout từ đối tác phải là
PartnerTimeout
- thiếu số dư phải là
- Ở mỗi ranh giới, đặt một lớp chuyển đổi mỏng
data PaymentError = InsufficientFunds | DuplicateRequest RequestId | PartnerTimeout Partner toHttpError :: PaymentError -> HttpResponse toHttpError InsufficientFunds = err402 "Insufficient funds" toHttpError (DuplicateRequest _) = err409 "Duplicate request" toHttpError (PartnerTimeout _) = err502 "Partner unavailable" toWorkerStrategy :: PaymentError -> WorkerAction toWorkerStrategy InsufficientFunds = Fail "Insufficient funds" toWorkerStrategy (DuplicateRequest _) = Skip toWorkerStrategy (PartnerTimeout _) = RetryWithBackoff - Các mối quan tâm của lớp truyền tải nên nằm ở rìa, và mô hình domain không nên mang theo HTTP status code dù được gọi từ web handler, CLI, tác vụ cron, worker nền hay workflow engine
Chi phí và điểm cân bằng của việc mã hóa bằng type
- Đưa bất biến vào type là rất mạnh, nhưng phải trả giá bằng chi phí nhận thức, độ cứng nhắc và khó khăn khi yêu cầu thay đổi
- Nếu vi phạm có thể dẫn tới mất dữ liệu, lỗi tài chính, vấn đề tuân thủ hoặc sự cố khiến caller phải chờ, thì chi phí mã hóa bằng type là hợp lý
- Nếu chỉ vì hiện tại đang làm như vậy, hoặc vì muốn thử kỹ thuật ở cấp type, thì rất dễ khiến codebase trở nên khó thay đổi
-
Phía mã hóa quá nhiều
- trạng thái không hợp lệ không thể biểu diễn và domain được mô hình hóa trung thực bằng type
- thay đổi quy tắc nghiệp vụ dẫn tới thay đổi type xuyên qua 50 module, khiến việc refactor kéo dài
- kỹ sư mới khó hiểu type signature
-
Phía không mã hóa gì cả
- type trở nên gần với
String,IO (), hoặc tệ nhất làDynamic - code dễ thay đổi nhưng không có hợp đồng, và ý nghĩa phụ thuộc vào trí nhớ của người viết trước
- khi người viết rời đi, rất khó hiểu vì sao hệ thống lại hoạt động như vậy
- type trở nên gần với
-
Một số tiêu chí hữu ích
- Bất biến ngăn hỏng hóc âm thầm nên được đưa vào type
- giao dịch đã commit nhưng không có event
- thanh toán được xử lý mà không có audit log
- chuyển trạng thái có vẻ khả thi nhưng về mặt ngữ nghĩa là bất khả
- Bất biến thất bại rõ ràng có thể chỉ cần kiểm tra runtime với thông báo lỗi tốt
- phản hồi 500
- assertion thất bại
- lệch type ở ranh giới JSON
- Cần kiềm chế ham muốn mô hình hóa toàn bộ domain bằng type
- Domain luôn có ngoại lệ, quy tắc tương thích ngược, các quy tắc mâu thuẫn nhau và hành vi đặc biệt cho một số khách hàng
- Type là công cụ không chỉ cho compiler mà còn cho cả nhóm
- Chúng nên tạo thành một lớp phòng thủ cùng với test, tài liệu, code review, ví dụ và playbook
- Bên trong Mercury cũng có các thư viện dùng những cơ chế cấp type phức tạp như GADT, type family và phantom type theo dõi chuyển trạng thái
- Ở những cơ chế mà sai sót có thể khiến tiền chuyển sai hoặc phá vỡ các bất biến tuân thủ, độ phức tạp này là cần thiết
- Điểm cốt lõi là đóng gói độ phức tạp
- Module triển khai state machine ở cấp type nên có một số ít tác giả thực sự hiểu sâu cùng với test đầy đủ
- API ở phía sử dụng nên trông như vài hàm với type thông thường
- product engineer phải có thể gọi chúng một cách an toàn mà không cần biết đến bộ máy chứng minh ở cấp type bên trong
- Nếu trong code review, một PR chạm vào module khác lại đầy những chú thích type được sao chép chỉ để làm compiler hài lòng, đó là dấu hiệu abstraction đang rò rỉ qua ranh giới
- Bất biến ngăn hỏng hóc âm thầm nên được đưa vào type
Thiết kế để có khả năng quan sát nội tại
- Nếu độ tin cậy là khả năng thích ứng, thì khả năng quan sát nội tại là một trong những cách để có được khả năng đó
- Người vận hành không thể vận hành thứ họ không nhìn thấy, và các nhóm cũng khó thích ứng với những hệ thống có nội bộ mờ đục
- Haskell không có monkey patching, nên rất khó thay đổi HTTP client nội bộ của một thư viện khi runtime hoặc thay các lời gọi cơ sở dữ liệu bằng hàm phát ra OpenTelemetry span
- Rust cũng có cùng ràng buộc này, nhưng hệ sinh thái Rust đã hội tụ vào mẫu middleware
tower, trong khi hệ sinh thái Haskell vẫn chia thành nhiều cách tiếp cận - Nếu thư viện chỉ phơi bày một tập các hàm top-level cụ thể, thì để thêm instrument, bạn phải bọc nó trong một module mới và hy vọng mọi người import module đó thay vì module gốc
-
Bản ghi hàm
- Cách giải quyết được dùng thường xuyên nhất là phơi bày bản ghi hàm thay vì các hàm cụ thể
-- A concrete module gives you no leverage: sendRequest :: Request -> IO Response -- A record of functions gives you all of it: data HttpClient = HttpClient { sendRequest :: Request -> IO Response , getManager :: IO Manager } - Với cách này, bạn có thể bọc
sendRequestbằng phần đo thời gian rồi trả về mộtHttpClientmới - Bạn có thể thêm ở runtime các mối quan tâm cắt ngang như fault injection cho test, thay mock, retry, tracing, rewrite request, hay hành vi theo từng tenant
- Mẫu làm cho các phép biến đổi hành vi có thể hợp thành, như
type Middleware = Application -> Applicationcủa WAI, rất hữu ích trong vận hành
- Cách giải quyết được dùng thường xuyên nhất là phơi bày bản ghi hàm thay vì các hàm cụ thể
-
Interceptor được hợp thành bằng
Monoid- Kiểu middleware và interceptor thường có thể có instance
SemigroupvàMonoid Middlewarecủa WAI là một endomorphism, và endomorphism tạo thành monoid dưới phép hợp thành vàid- Bản ghi hook interceptor có thể được hợp thành theo từng field, nên các mối quan tâm như tracing, timeout, rewrite task queue có thể được ghép bằng
mconcatmà không cần plumbing riêngappTemporalInterceptors = mconcat [ retargetingInterceptor , otelInterceptor , sentryInterceptor , sqlApplicationNameInterceptor , loggingContextInterceptor , statementTimeoutInterceptor , teamNameInterceptor , clientExceptionInterceptor , workflowTypeNameInterceptor ] - Mỗi interceptor chỉ xử lý một mối quan tâm trong một module độc lập, override các field cần thiết từ
mempty, và thứ tự được nêu rõ trong danh sách
- Kiểu middleware và interceptor thường có thể có instance
-
Hệ thống effect
- Các hệ thống effect như
effectful,polysemy,fused-effects,cleffcũng cung cấp một con đường khác - Bạn định nghĩa các phép toán khả dụng bằng các kiểu effect, rồi có thể thay interpreter cho production, testing, tracing tại điểm gọi
- Có thể chặn effect để ghi metric hoặc chèn độ trễ, rồi gửi tiếp đến handler thực tế
- Nhược điểm là nó thêm các cơ chế như danh sách effect ở cấp kiểu, handler stack, và các lỗi kiểu khó chịu
- Bản ghi hàm thì đơn giản đến mức một kỹ sư mới có thể hiểu chỉ trong một buổi chiều
- Các hệ thống effect như
-
Ví dụ tích cực từ
persistentSqlBackendcủapersistentlà một bản ghi hàm nhưconnPrepare,connInsertSql,connBegin,connCommit,connRollback- Khi thêm instrument OpenTelemetry, có thể bọc các field liên quan để gắn tracing span cho mọi thao tác cơ sở dữ liệu
- Không cần fork, gần như không cần sửa mã nguồn mà vẫn có được khả năng quan sát ở tầng cơ sở dữ liệu
-
Những thư viện khó vận hành
- Mercury hầu như không dùng các binding web API client công khai trên Hackage
- Khi binding bên thứ ba thực hiện các lời gọi HTTP bằng các hàm cụ thể, sẽ rất khó thêm tracing, timeout theo SLO, mô phỏng lỗi đối tác, hay giải thích khoảng trống 400ms trong trace
- Vì thế họ tự viết client và làm cho nó có thể quan sát được ngay từ đầu
-
Cái giá của một hệ sinh thái nhỏ
- Một số thư viện Haskell không hẳn bị bỏ rơi, nhưng vẫn giống như hạ tầng công cộng không có chủ thể nào rõ ràng chịu trách nhiệm và cải tiến nhanh chóng
- Các giao diện cũ vẫn được duy trì, và tốc độ tiếp nhận các thiết kế mới về khả năng quan sát, thiết kế biên, và khả năng vận hành có thể chậm
http-clienttrực tiếp chỉ hỗ trợ HTTP/1.1; nó đủ dùng, nhưng tại một số thời điểm có thể cần giải pháp vòng tránh
Các yêu cầu vận hành dành cho tác giả package
- Tác giả thư viện nên cung cấp các lối thoát như bản ghi hàm, kiểu effect, callback để người dùng có thể chèn hành vi mà không cần sửa mã nguồn
- Chỉ cần thêm
hs-opentelemetry-apivào dependency và đặt span quanh các thao tácIOcốt lõi cũng đã giúp ích cho những người vận hành thư viện đó trong production- Gói API khá bảo thủ với breaking change, và được thiết kế để hoạt động trơ nếu ứng dụng không khởi tạo OpenTelemetry SDK
- Overhead hiệu năng được giữ ở mức tối thiểu, và không gây ra exception bất ngờ hay logging từ phía ứng dụng người dùng
- Dependency footprint hiện vẫn chưa nhỏ như mong muốn và đang được cải thiện
- Không nên ghi log trực tiếp trong mã thư viện
- Thay vì import logging framework rồi ghi trực tiếp ra
stdouthoặcstderr, nên cung cấp callback, tham số logger, hoặc kiểu dữ liệu thông điệp log để bên gọi có thể tự định tuyến - Việc log sẽ đi đâu là quyết định thuộc về môi trường vận hành của ứng dụng
- Mercury gửi pipeline log có cấu trúc vào observability stack, nên nếu thư viện tự ghi trực tiếp ra
stderrthì sẽ cần plumbing riêng tách biệt với luồng JSON lines
- Thay vì import logging framework rồi ghi trực tiếp ra
- Cũng có thể cân nhắc phơi bày module
.Internal- Lo ngại rằng người dùng sẽ phụ thuộc vào API nội bộ và làm việc refactor trở nên khó khăn hơn là hợp lý
- Nhưng sự tự tin rằng API công khai đã bao phủ mọi use case chỉ hiếm khi là chính đáng
- Module
.Internalcó cảnh báo ổn định rõ ràng có thể tốt hơn việc để người dùng fork package và vendoring nó containers,text,unordered-containerslà những ví dụ tốt về cách làm này trong hệ sinh thái Haskell- Tuy vậy, nếu người dùng âm thầm dùng module nội bộ để tự giải quyết nhu cầu của họ, thì feedback về thiếu sót của API công khai có thể sẽ giảm đi
Những gì không đưa vào type
- Ngay cả Haskell dùng trong production cũng có những phần không đẹp đẽ
unsafePerformIOđược dùng bên trong các thư viện mà chúng ta phụ thuộc hằng ngàybytestringvàtextnội bộ cấp phát buffer có thể thay đổi, ghi vào đó rồi freeze để tạo kết quả- Type không nói điều gì đã xảy ra trong quá trình tạo ra nó
- Ranh giới này được duy trì bằng quy ước, suy luận cẩn trọng và code review
- Khi phương án thay thế an toàn về type khiến chi phí hiệu năng hoặc độ phức tạp trở nên quá lớn, bạn cũng có thể tự viết những thỏa hiệp như vậy
- Cần tài liệu hóa các bất biến mà type không kiểm chứng
- Cần giữ lại sự bất tiện này, và định kỳ xem xét lại liệu phương án thay thế an toàn về type đã trở nên thực tế hơn hay chưa
- Haskell production không phải là không có thỏa hiệp, mà là cô lập các thỏa hiệp một cách có kỷ luật
- Nhiều thư viện Haskell trên Hackage có rất ít hoặc không có test
- Ý nghĩ “biên dịch được thì sẽ chạy” đôi khi có thể đúng với đoạn mã thuần nhỏ và type mạnh
- Nhưng điều đó hầu như không đúng với mã nặng về IO, tích hợp hệ thống bên ngoài, hoặc mã có bug nằm ở ngữ nghĩa thay vì cấu trúc
- Type có thể nói rằng một hàm trả về
Either ParseError Transaction, nhưng không thể nói được:- trường
amountđược parse theo cent hay theo dollar - API đối tác có diễn giải khác nhau giữa trường bị bỏ qua và trường null hay không
- logic retry có gây double charge trong một cửa sổ thời gian cụ thể vào năm nhuận hay không
- trường
- Trong production, chúng ta xây dựng hệ thống trên các thư viện như vậy và kế thừa những giả định chưa được kiểm chứng, nên phải bù lại bằng integration test ở tầng của chính mình
- Những thỏa hiệp như orphan instance, partial function được tin là total trong ngữ cảnh,
errorđược hứa là không thể chạm tới, FFI wrapper gượng ép, hay exception hierarchy viết tay cũng tích lũy dần - Mục tiêu không phải là sự thuần khiết về mặt đạo đức, mà là bảo đảm thông qua code review, tài liệu, ví dụ và test rằng mọi thỏa hiệp đều có thể được nhận biết: nó ở đâu, vì sao được tạo ra, và nếu bỏ đi thì điều gì sẽ hỏng
Haskell có đáng dùng trong production không
- Haskell không phải là lựa chọn nhanh ngay từ ngày đầu
- Hệ sinh thái hiện tại chưa thể ngay lập tức cung cấp môi trường phát triển hot-reloading batteries-included như Next.js hay Rails
- Có thể không có thư viện bạn cần, hoặc nếu có thì nó có thể đang được một người duy trì trong thời gian rảnh
- Đôi khi thông báo lỗi cực kỳ khó hiểu
- Vấn đề tuyển dụng đã bị thổi phồng
- CTO của Mercury là Max Tagher từng công khai nói rằng backend Haskell engineer là vai trò dễ tuyển nhất trong toàn bộ Mercury
- Nhu cầu với công việc Haskell lớn hơn nguồn cung, khiến động lực tuyển dụng thông thường bị đảo ngược
- Mercury tuyển cả người có kinh nghiệm Haskell sâu lẫn người hoàn toàn chưa có, và nhóm sau sẽ đạt năng suất qua chương trình đào tạo 6–8 tuần
- Nếu ngày mai bạn cần 100 chuyên gia Haskell thì bài toán nguồn ứng viên là rất thực tế, nhưng nếu bạn sẵn sàng tuyển các lập trình viên đa năng giỏi rồi đào tạo họ thì vấn đề này kém thực tế hơn
- Rủi ro tuyển dụng lớn hơn không nằm ở quy mô nguồn ứng viên mà ở xu hướng tính cách
- Haskell thu hút những người theo chủ nghĩa lý tưởng, quan tâm đến tính đúng đắn và trừu tượng hóa, thích đọc paper và nghi ngờ các giả định sẵn có
- Nếu không được kiểm soát, điểm mạnh này có thể trở thành gánh nặng cho production
- Cố viết lại tầng cơ sở dữ liệu bằng một cách mã hóa đại số quan hệ ở cấp type hoàn toàn mới, từ chối merge chỉ vì một throwaway script dùng
Stringthay vìText, hoặc kéo mọi thiết kế sang hướng total rewrite theo paper mới nhất đều sẽ làm cả đội chậm lại
- Haskell trong production cần một văn hóa thực dụng
- Type system là công cụ điện, không phải tôn giáo
- Biến một bài toán đã có lời giải tốt thành cơ hội phát minh cơ chế mới là điều không phù hợp với production
- Lợi ích sẽ xuất hiện theo thời gian
- Một đợt refactor mất vài tuần trong codebase dùng kiểu động có thể kết thúc trong vài giờ sau khi đổi type, vì compiler sẽ chỉ ra mọi call site
- Một kỹ sư mới có thể đọc type signature và hiểu hợp đồng của module
- Những trạng thái bất khả thi thực sự không thể được biểu diễn, nên có thể không phát sinh production incident
- Mercury cho rằng thời gian hoàn vốn xuất hiện theo đơn vị tháng, không phải nhiều năm
- Đặc biệt trong dịch vụ tài chính, chi phí của bug toàn vẹn dữ liệu không được đo bằng mức độ người dùng phàn nàn, mà bằng cảnh báo từ cơ quan quản lý và tiền của người khác
- Type system không loại bỏ rủi ro, nhưng nó cung cấp công cụ khiến việc vô tình đưa rủi ro vào một codebase đang tăng trưởng nhanh trở nên khó hơn
- Giá trị của Haskell trong production không nằm ở một viên đạn bạc hay một phong trào đạo đức, mà ở một bộ công cụ mạnh mẽ cho phép ngay cả đội ngũ có trình độ Haskell khác nhau cũng giữ các thiết bị nguy hiểm trong ranh giới, bảo tồn tri thức vận hành, và biến con đường an toàn thành con đường dễ đi nhất
1 bình luận
Ý kiến trên Hacker News
Đúng là Haskell thuộc nhóm ngôn ngữ mạnh nhất trong việc ép buộc những thứ như thế này bằng kiểu, nhưng cùng một mẫu đó cũng hoạt động khá tốt trong Rust và TypeScript
Tôi cũng thích cách ngăn các lỗi phân quyền hiển nhiên lặp đi lặp lại trong ứng dụng web bằng luồng kiểu User -> LoggedInUser -> AccessControlledLoggedInUser
Tôi nghĩ trong ngành, mẫu này đang được dùng ít hơn rất nhiều so với mức nên có
Nếu cần phân biệt chuỗi trước/sau khi escape vì lý do bảo mật, ngay cả trong ngôn ngữ kiểu động bạn cũng có thể bọc nó trong lớp Escaped và có các hàm như
escape(str)->Escaped,dangerouslyAssumeEscaped(str)->EscapedSẽ có chi phí hiệu năng nên cần đánh đổi, nhưng vẫn làm được
Một cách khác là Application Hungarian, chỉ là cách này phụ thuộc vào kỷ luật của lập trình viên hơn là trình biên dịch: https://www.joelonsoftware.com/2005/05/11/making-wrong-code-...
Ví dụ trong C# cũng hoàn toàn làm được, nhưng thường phần nhiễu thị giác lại lớn hơn cả định nghĩa kiểu thực tế
Chỉ là họ thường tránh nói thẳng ra và cũng đặt tên khác đi để né hiệu ứng kiểu “monad đáng sợ quá nên phải viết tutorial”
Có lẽ ảnh hưởng từ type class còn lớn hơn cả monad
Nó không có nominal type, nên để tạo thứ giống newtype bọc primitive thì phải nhớ khá nhiều mẹo hacky
Theo kinh nghiệm của tôi, về mặt ép buộc kiểu an toàn như vậy thì OCaml còn mạnh hơn Rust
Nó có GADT nên biểu đạt tốt hơn, có polymorphic variant và object type/record row type nên tiện hơn, lại còn có module system và functor
Trong những miền mà garbage collection là đủ, bạn cũng tránh được các ràng buộc trừu tượng và khó khăn do borrow checker của Rust gây ra
Tôi đã thực sự rất thích làm việc với Haskell trong vài năm
Không phải thứ tôi chủ động tìm kiếm, nhưng cơ hội tình cờ đến, và nó rất thú vị, kích thích trí tuệ
Tuy vậy, đáng tiếc là ngay cả sau 3 năm chỉ dùng Haskell, năng suất của tôi với Rust vẫn dễ dàng gấp đôi Haskell
Haskell có nhiều cạm bẫy hơn cần biết trước để tránh, và tùy người viết mà đôi khi rất khó tiêu hóa, gần như một ngôn ngữ chỉ để đọc
Toolchain thường đi cùng Nix, mà bản thân Nix cũng là một con quái vật phức tạp, còn các language extension thì có cảm giác xuất hiện khắp nơi
File Cabal cũng không hay ho gì, và phải mất thời gian mới quen được với lỗi từ trình biên dịch
Ở sản phẩm gần nhất, chúng tôi bắt đầu chuyển backend từ Typescript sang Rust vì quá mệt mỏi với crash
Giờ tôi xem đó là một trong những sai lầm kỹ thuật lớn nhất mình từng gây ra, vì năng suất giảm khủng khiếp
Một ví dụ điển hình về thời gian bị lãng phí chỉ trong Rust là việc viết hàm bậc cao kiểu mở kết nối cơ sở dữ liệu, làm gì đó rồi đóng lại; chuyện này trong Haskell, TypeScript, JavaScript, C++, PHP là tầm thường, nhưng trong Rust thì về cơ bản là bất khả thi, kể cả sau khi hỏi bạn bè là chuyên gia Rust, nên cuối cùng tôi đành bỏ cuộc
Tôi cũng đã nhiều lần thử refactor, mất cả ngày sửa lỗi kiểu, rồi đến cuối cùng gặp lỗi ở file top-level và nhận ra rằng do một phần nền tảng của thiết kế nên toàn bộ cuộc refactor là không thể, thế là phải hoàn tác hết
Hơn nữa, Rust là ngôn ngữ hiện đại duy nhất tôi có thể nghĩ ra mà việc dùng giá trị qua interface thay vì kiểu cụ thể nằm đâu đó giữa kỹ thuật nâng cao và bất khả thi, tùy tình huống
Vì thế tôi đi đến kết luận rằng code ứng dụng, tức thứ không phải code hệ thống hay code thư viện, thì gần như không nên viết bằng Rust
Và tôi cũng muốn biết “chỉ để đọc” nghĩa là gì
Trái với nhận thức phổ biến, tôi cho rằng việc Mercury chọn Haskell và việc các lãnh đạo ban đầu có nhiều kinh nghiệm sâu với Haskell có thể đã đóng vai trò không nhỏ trong thành công của họ
Với tư cách khách hàng của Mercury, công ty này là một trong những công ty cốt lõi trong bộ công cụ của tôi, và tôi không thể gạt đi cảm giác rằng việc chọn Haskell đã làm hành trình, quá trình phát triển và toàn bộ tiến trình của họ tốt hơn
Tất nhiên, bạn có thể nói điều tương tự về hầu hết ngôn ngữ, và điều đó không có nghĩa ngôn ngữ hàm như Haskell là công thức thành công
Nhưng việc đưa ra quyết định có chủ đích như vậy trước thời đại “vibe coding” và LLM trông đặc biệt có tầm nhìn, và tôi xem đó là kết quả kết hợp với văn hóa kỹ thuật được bài viết mô tả chi tiết
Tôi cũng thích văn hóa kỹ thuật tốt, nhưng tôi từng thấy những công ty có văn hóa kỹ thuật tuyệt vời chết vì chọn sai trọng tâm kinh doanh
Xa hơn nữa, cũng có thể chính văn hóa fintech kiểu startup đã sinh ra văn hóa kỹ thuật tốt
Vì họ không khởi đầu như một ngân hàng, nên chẳng hạn khác với SVB, họ không cần phải bảo thủ đến thế và cũng không phải tích hợp với một stack công nghệ cổ lỗ đáng sợ
Tôi vui vì Haskell đã thành công, nhưng giống Jane Street với OCaml, tôi nghĩ trái với điều công ty muốn bạn tin, việc chọn ngôn ngữ về mặt kinh doanh gần như chỉ là ngẫu nhiên
Dù vậy, tôi vẫn tò mò không biết frontend của họ dùng gì. Có lẽ Haskell này chủ yếu là backend
Vì bạn có thể truyền văn hóa và phong cách cho người mới ngay từ đầu
Nếu là trước thời vibe coding, thì phần lớn những người đó có lẽ cũng sẽ không tự nhiên lao vào hack mà không có chỉ dẫn
Chuyển từ các dịch vụ khác sang thực sự thấy hài lòng
Bạn thân của tôi làm ở công ty này, và ngay cả nhìn từ bên ngoài thì văn hóa kỹ thuật cũng có vẻ rất tốt
Tôi nghĩ Haskell là công cụ phù hợp cho công việc này và họ khai thác tốt các điểm mạnh của nó, nhưng cũng có cảm giác rằng một phần lớn thành công đơn giản là vì công ty nói chung được vận hành tốt
Tôi nghĩ tác giả này thật ra dùng ngôn ngữ nào cũng sẽ vận hành được một tổ chức kỹ thuật thành công
Tôi đang đọc Real-World OCaml, và dù đã biết sẵn vài thứ, tôi vẫn đang học thêm nhiều về lập trình hàm
Có vẻ bạn có thể xây dựng những mảnh phần mềm chắc chắn đáng kinh ngạc bằng lập trình hàm
Nhưng tôi cũng đang băn khoăn
Backend sản phẩm hiện tại của tôi chạy bằng NiceGUI và đang làm tốt vai trò của nó
Code hợp lý, theo MVVM, và việc quan trọng nhất là kết nối đến websocket theo từng khách hàng để tiêu thụ dữ liệu và hiển thị phân tích
Số khách hàng sẽ không nhiều, và người truy cập website có lẽ chỉ từ vài chục đến cao nhất vài trăm
Tôi cũng muốn có REPL hoặc hot reload, và biết rằng khi tính năng tăng lên, lập trình hàm có thể rất hợp cho việc biến đổi pipeline dữ liệu, như bảng quản lý người dùng, thêm phân tích, v.v.
Chỉ là Haskell hay OCaml đều là ngôn ngữ tĩnh
Nếu muốn thứ gì đó năng động hơn mà vẫn có thể phát triển và mở rộng về sau, tôi nghĩ Clojure hoặc Elixir có thể là lựa chọn tốt
Đồng thời tôi lại sợ rằng khi một ngày nào đó cần refactor thì mọi thứ sẽ vỡ tung
Hiện tại tôi dùng Python với Mypy, còn frontend do NiceGUI sinh ra từ backend
cabal replThành thật mà nói, tôi nghĩ nhiều người dùng Haskell chưa tận dụng tốt chuyện này
Tôi từng làm một hệ thống tương tự bằng Scheme, sau này là Racket, một ngôn ngữ tương đối ít phổ biến; quy mô có lớn lên, nhưng một đội nhỏ vẫn duy trì được trong thời gian dài và giữ được tốc độ cao
Chúng tôi không tạo ra nhiều lỗi, và thường có thể thêm tính năng rất nhanh
Ví dụ, chúng tôi là một trong những bên đầu tiên đạt được một chứng nhận nào đó để host dữ liệu nhạy cảm trên AWS
Đôi khi việc thêm tính năng chậm hơn vì phải tự xây từ đầu những thứ mà ở các nền tảng phổ biến sẽ có sẵn thành phần dựng sẵn
Nhưng một khi đã làm xong thì nó hoạt động tốt, tốc độ lại trở về như cũ, và không bị làm chậm bởi sự cồng kềnh, phức tạp của hàng chục framework dựng sẵn
Vì trực tiếp kiểm soát một nền tảng có thể quản lý được, chúng tôi cũng có thể chuyển sang AWS khá nhanh khi phát sinh nhu cầu
Hệ thống ngay từ đầu đã có cả những bí quyết kiến trúc cho dữ liệu phức tạp và tương tác web, điều này giúp phát triển nhiều tính năng rất nhanh và tiếp tục trao quyền theo hướng thông minh về sau
Khác với công ty fintech dùng Haskell kia là quy mô đội ngũ rất nhỏ
Mỗi thời điểm chỉ có 2~3 kỹ sư phần mềm và một người phụ trách toàn bộ vận hành
Vì vậy không có khó khăn kiểu phải điều phối hàng trăm người mà vẫn giữ được hệ thống nhất quán
Thường thì một người lo các thay đổi code mang tính kỹ thuật và kiến trúc hơn, còn người kia thì nhanh chóng bổ sung các tính năng business logic lớn cho những quy trình phức tạp
Nếu dùng cẩn thận các công cụ AI kiểu LLM hiện tại hoặc tương lai gần, có lẽ ta có thể giành lại phần nào hiệu quả của những đội cực nhỏ nhưng cực kỳ hiệu quả trong phát triển phần mềm
Mô hình hiện lên trong đầu tôi không phải là sản xuất ra sự phình to khổng lồ để xóa sổ story point rồi để tính bền vững thành chuyện của người khác, mà là một số ít người suy nghĩ cực sắc giữ cho hệ thống vừa mạnh mẽ vừa luôn đi trên con đường có thể quản lý được
Đó là con dao hai lưỡi
2 triệu dòng là một thành tựu lớn, nhưng đồng thời cũng là gánh nặng bảo trì đáng kể
Ưu điểm của Haskell về mặt lý thuyết thì rõ ràng, nhưng nhược điểm lại khó trực giác hơn
Sự cám dỗ nằm ở việc mô hình hóa mọi thứ bằng kiểu
Codebase tự nó không còn là ứng dụng nữa mà trở thành đặc tả nghiệp vụ
Mỗi thay đổi chính sách lại biến thành một cuộc refactor lớn, và nhờ tính an toàn của Haskell mà đôi khi việc này ngốn công đáng ngạc nhiên
Cuối cùng bạn không thể có mọi thứ, và sẽ đến lúc bị mắc kẹt trong chính các kiểu
Haskell đặc biệt ấn tượng và mạnh mẽ ở quy mô này, nhưng cũng kéo theo những vấn đề riêng
Sự cám dỗ mô hình hóa business logic bằng kiểu có thể tạo ra cấu trúc cứng nhắc, và sự an toàn mà cấu trúc đó đem lại có thể khiến bạn không nhìn thấy những loại rủi ro khác
Bạn không thể có tất cả, nhưng có thể có rất nhiều
Vài năm trước tôi từng thực tập ở Jane Street; ở đó là OCaml chứ không phải Haskell, nhưng họ có vẻ giữ được sự cân bằng đó rất tốt
Đó là miền có độ phức tạp nội tại cao, nơi độ tin cậy và tính chính xác gắn trực tiếp với sự sống còn của doanh nghiệp, vậy mà họ vẫn di chuyển nhanh đáng kinh ngạc
Nhìn lại thì cốt lõi của Jane Street là họ tuyển những lập trình viên OCaml giàu kinh nghiệm và có gu rất tốt như Stephen Weeks, để họ xây thư viện cốt lõi ngay từ đầu và dẫn dắt toàn bộ codebase
Đáng tiếc là Mercury có vẻ không làm tốt phần này bằng
Thành thật mà nói, nhược điểm lớn nhất của hệ thống kiểu Turing-complete là về mặt lý thuyết bạn có thể hiện thực một ứng dụng mà sau khi biên dịch thì chỉ còn lại bụi
Một câu chuyện thành công tương tự của Haskell ở Bellroy sẽ là chủ đề của buổi gặp Melbourne Compose sắp tới: https://luma.com/uhdgct1v
Vấn đề tôi gặp với lập trình hàm là debugging
Chính xác hơn thì tôi xem đây là một thế mạnh của lập trình mệnh lệnh, đặc biệt là kiểu thủ tục
Trong phong cách hàm/khai báo, bạn thường mô tả trạng thái cần phải là gì chứ không phải nó được tạo ra như thế nào, rồi ngôn ngữ sẽ tự lắp ghép mọi thứ để cho ra kết quả cuối cùng
Nếu mọi thứ đều làm đúng thì rất tuyệt, thậm chí tốt hơn, nhưng nếu không đúng và không ra kết quả như mong đợi thì câu hỏi là làm sao tìm ra bug
Trong ngôn ngữ như C thì tương đối đơn giản
Bạn đi từng dòng một, nhìn trạng thái thực thi giữa các bước, về cơ bản là RAM, và nếu nó khác kỳ vọng thì có gì đó sai ở dòng đó, cứ đi sâu tiếp như vậy
Ngôn ngữ càng cố che giấu trạng thái, như trong lập trình hàm, thì việc này càng khó
Cũng thú vị khi phần dài nhất của bài lại nói đúng về vấn đề này, tức “design for introspection”
Tác giả đã phải cố tình bỏ nhiều công sức để làm cho code có thể debug được, và điều đó đem lại góc nhìn hay về việc sử dụng Haskell trong thực tế, thứ thường bị bỏ qua
Ngay cả code vụn vặt cũng vậy
Các ngôn ngữ chủ lưu khác không đến gần được điều này
Với những tình huống không thể viết như vậy, như đồng thời dùng bộ nhớ chia sẻ, tôi dùng transaction
Điều này cũng là thứ mà các ngôn ngữ chủ lưu khác không đến gần được
Tôi còn chưa tính đến những lợi thế dễ thấy như không có null, không có ép kiểu số nguyên ngầm
Việc debug code Haskell khó hơn các ngôn ngữ khác là hoàn toàn đúng
Nhưng khi bạn đã loại bỏ 90% thứ gây vấp ngã ở tầng thấp thì đương nhiên nó sẽ thành như vậy
Tất nhiên điều này không chỉ riêng lập trình hàm; ngay cả trong các ngôn ngữ phần lớn là mệnh lệnh như Python hay JavaScript, người ta cũng thường dùng Python shell, browser console, Node/Deno/Bun shell, notebook, v.v. như lớp debug đầu tiên
Debug lấy REPL làm trung tâm có những đánh đổi thú vị
Trong ngôn ngữ như C, người ta thường debug toàn bộ chương trình và bắt đầu từ breakpoint, cố đoán đúng điểm chính xác có vấn đề
Trong thế giới lấy REPL làm trung tâm, bạn cố làm cho các thành phần của chương trình có thể được kiểm thử trực tiếp nhiều hơn trong REPL
Vì vậy ranh giới module/API/type dần trở nên giống với khả năng debug
Đôi khi áp lực tạo ra những ranh giới như vậy, sao cho đúng đắn và dễ dùng, còn lớn hơn so với các ngôn ngữ mệnh lệnh như C/C++
Mặt khác, so với kiểu debug toàn chương trình trước tiên, đôi khi lại khó cô lập các vấn đề tích hợp phức tạp giữa các đơn vị trong những kịch bản kỳ quặc của thế giới thực
Tuy nhiên, cách tiếp cận ưu tiên REPL thường thúc đẩy việc giảm thiểu bề mặt tích hợp xuống mức thấp nhất, nên trong ngôn ngữ hàm người ta thường ít thấy các hiệu ứng tích hợp kiểu ngôn ngữ mệnh lệnh hơn
Nói rằng ngôn ngữ hàm che giấu trạng thái là không đúng
Chúng cũng chạy trên phần cứng mệnh lệnh và xử lý trạng thái phần cứng thực
Ở đâu đó vẫn có một lớp chuyển dịch giữa hai thế giới, và có lẽ khác biệt không lớn như bạn nghĩ
Nếu cần, bạn vẫn có thể quay lại breakpoint mệnh lệnh và debugger mệnh lệnh
Vì thế tôi gọi đây là debug “REPL-driven”
Bạn có thể dùng REPL để thu hẹp vấn đề xuống đúng đơn vị đang lỗi, tức module/API/hàm cụ thể và input nào tạo ra output bất ngờ
Nếu chỉ nhìn source mà vẫn chưa thấy bug, bạn có thể chuyển sang debugger mệnh lệnh để có trải nghiệm đi từng dòng gần như y hệt, và có thể lấy thêm ngữ cảnh
Đến lúc đó, nhiều khả năng bạn đã thu hẹp đủ bằng REPL nên bản thân đơn vị đã nhỏ và hẹp, không còn phải chọn breakpoint cho khéo nữa
Tôi nghĩ bạn đã hiểu chưa đúng thông điệp từ phần “design for introspection” trong bài
Phần đó không nói về khả năng debug mà là về observability
Nó nói về việc nối đúng logging/telemetry system, mock các thứ giả trong lúc test, và thêm retry/circuit breaker ở cấp toàn hệ thống thay vì giao cho từng thư viện riêng lẻ
Ngay trong thế giới mệnh lệnh, đây cũng không phải vấn đề debug mà là vấn đề phân rã theo kiểu dependency injection, cài middleware, và dùng abstract interface thay vì concrete class ở ranh giới public API
Những đề xuất thiết kế như vậy là refactor, và ảnh hưởng nhiều hơn đến việc bạn có thể cài middleware quan sát vào public API của người khác dễ đến mức nào, chứ không phải khả năng debug
Tôi khó mà hình dung 2 triệu dòng Haskell rốt cuộc đang làm gì
Đó là rất nhiều code, mà Haskell lại có tiếng là ngôn ngữ “đậm đặc”, làm được nhiều việc với ít code
Tôi tự hỏi có phải là vì có rất nhiều thư viện cho JSON serialization/deserialization, REST API framework, logging và những thứ tương tự không
Nếu binding của bên thứ ba thực hiện gọi HTTP bằng hàm cụ thể, thì không có cách thêm tracing, không có cách tiêm timeout theo SLO, không có cách mô phỏng sự cố đối tác trong test, và cũng không có cách giải thích khoảng trống 400ms trong trace ngoài việc đoán mò bằng lý thuyết
Vì vậy họ tự viết lấy
Ban đầu sẽ nhiều việc hơn, nhưng các client tự viết được làm theo cách đó ngay từ đầu nên được cấu trúc để có thể quan sát được
Nghĩa là có thể diễn đạt những ý tưởng tương đối trừu tượng bằng ít ký tự
Một số người cũng gọi đó là “high-level”
Tuy nhiên tôi không nghĩ 2 triệu dòng thực sự nhiều như lúc đầu nghe có vẻ vậy
Đặc biệt nếu đó là code tích lũy qua nhiều năm ở một công ty hoạt động trong lĩnh vực bị quản lý chặt như tài chính
Số dòng có thể ít hơn phần nào, nhưng số từ thì nhìn chung khá tương đương với các ngôn ngữ hướng đối tượng mang tính mệnh lệnh hơn
Ở những chỗ đó, những biểu thức như
St M -> C Tlà ổn, nhưng trong phần mềm thực tế thì viết nhưTransactionState Debit -> Verified Transactionhữu ích hơn nhiềuMột phần khác là yếu tố văn hóa kéo dài từ LISP
Người ta có xu hướng cố quá thông minh, tiết kiệm số dòng bằng các mánh khó hiểu hay macro
Ở một công ty tài chính như Mercury, có lẽ sự rõ ràng và dễ đọc được khuyến khích hơn kiểu đó
Chẳng hạn linter có thể buộc bạn tách monadic code thành các biểu thức
donhiều dòng cẩn thận, thay vì dồn vào một dòng bằng>>và>>=hơn là dùng>>và>>=trên một dòng duy nhất