Trong hơn một thập kỷ miệt mài với nghề lập trình, tôi đã từng viết ra những dòng code mà bản thân cho là tuyệt vời, chỉ để rồi sáu tháng sau nhận ra chúng là một tượng đài cho sự bất an của chính mình. Bạn biết loại code đó mà, một cơ sở mã yêu cầu bạn phải có bằng tiến sĩ để hiểu tại sao một tính năng đơn giản lại cần ba lớp trừu tượng, hai mẫu thiết kế học được từ một bài blog nào đó, và một tệp cấu hình dài hơn cả phần triển khai thực tế.
Điều mà không ai nói cho bạn biết khi bạn mới học lập trình là: mục tiêu không phải là viết ra một hệ thống thanh lịch, hoàn hảo về mặt lý thuyết nhất. Mục tiêu là giải quyết một vấn đề cho người dùng thực, triển khai nó trước đối thủ cạnh tranh, và tạo ra đủ doanh thu để duy trì hoạt động trong khi bạn tìm ra thứ gì sẽ xây dựng tiếp theo. Mọi thứ khác chỉ là sự tự thỏa mãn trí tuệ được khoác lên tấm áo của “kỹ thuật xuất sắc”.
Tôi đã học được bài học này một cách khó khăn, tự nhiên thôi. Ai trong chúng ta cũng vậy. Có một điều trớ trêu sâu sắc là những dòng code tồi tệ nhất tôi từng viết lại là những dòng tôi dành nhiều thời gian nhất để cố gắng làm cho nó “sạch”. Còn những dòng code tốt nhất tôi từng viết? Đó thường là những thứ tôi hoàn thành trong một buổi chiều vì có khách hàng đang chờ và tôi không có thời gian để “thông minh” một cách không cần thiết.
Đây không phải là một bài blog khác nói bạn nên viết unit test hay sử dụng tên biến có ý nghĩa. Bạn đã biết những điều đó rồi. Bài viết này nói về một vấn đề khó hơn nhiều: khi nào thì nên dừng lại – khi nào code của bạn đủ tốt để triển khai, và khi nào bạn chỉ đang trì hoãn vì sợ hãi điều sẽ xảy ra khi người dùng thực chạm vào hệ thống quý giá của bạn.

Mục lục
“Overengineering” Là Gì và Biểu Hiện Thực Tế Của Nó?
Để hiểu rõ hơn về overengineering, hãy cùng tôi nhìn vào một số ví dụ thực tế. Nó không phải là viết code tệ hay lười biếng. Nó là việc giải quyết những vấn đề bạn không có, xây dựng cho những tương lai có thể không bao giờ đến, và ưu tiên sự thanh lịch lý thuyết hơn là việc triển khai thực tiễn.
Ví dụ 1: Tính năng xuất dữ liệu phức tạp hóa không cần thiết
Ba năm trước, chúng tôi xây dựng một tính năng cho phép người dùng xuất dữ liệu dưới dạng tệp CSV. Đơn giản phải không? Tải một số hàng từ cơ sở dữ liệu, định dạng chúng thành các giá trị phân tách bằng dấu phẩy, gửi tới trình duyệt. Bất kỳ lập trình viên junior nào cũng có thể hoàn thành việc đó trong một buổi chiều.
Nhưng chúng tôi là một “đội ngũ kỹ thuật nghiêm túc” tại một “startup tăng trưởng cao”, điều đó có nghĩa là chúng tôi không thể chỉ viết một hàm đơn giản xuất dữ liệu ra tệp. Không, chúng tôi cần phải nghĩ về tương lai. Sẽ thế nào nếu chúng tôi muốn hỗ trợ xuất JSON? Hay XML? Còn về tệp Excel với nhiều sheet? Sẽ thế nào nếu chúng tôi cần hỗ trợ xuất hàng tỷ hàng dữ liệu?
Vì vậy, chúng tôi đã xây dựng một lớp trừu tượng (abstraction layer). Chúng tôi tạo ra một ExportStrategyFactory trả về các triển khai khác nhau của một giao diện IExportStrategy. Chúng tôi có một hệ thống cấu hình ánh xạ các loại tệp tới các lớp chiến lược. Chúng tôi viết một framework streaming tùy chỉnh để xử lý các tập dữ liệu lớn, mặc dù khách hàng lớn nhất của chúng tôi có lẽ chỉ có năm mươi nghìn hàng dữ liệu. Chúng tôi đã dành ba tuần cho tính năng này.
Điều đáng tiếc nhất? Hai năm sau, không ai yêu cầu bất cứ thứ gì ngoài xuất CSV. Một lần cũng không. Toàn bộ cơ sở hạ tầng đó, kiến trúc “thông minh” đó, tất cả các unit test cho các trường hợp biên không bao giờ xảy ra—tất cả đều là lãng phí. Lãng phí hoàn toàn. Và khi cuối cùng chúng tôi cần thêm xuất PDF cho một khách hàng doanh nghiệp lớn, lớp trừu tượng mà chúng tôi đã xây dựng lại quá đặc thù cho các giả định ban đầu của chúng tôi đến nỗi chúng tôi vẫn phải “hack” xung quanh nó.
Ví dụ 2: Kiến trúc Microservices không phù hợp quy mô
Một người bạn của tôi đang xây dựng một ứng dụng web cho một tiệm bánh địa phương. Họ cần theo dõi đơn hàng, quản lý kho, và gửi biên lai qua email. Những thứ cơ bản. Nhưng bạn tôi vừa đọc một cuốn sách về microservices và anh ấy tin rằng đây là thời điểm để áp dụng mọi thứ mình đã học.
Anh ấy chia ứng dụng thành bảy dịch vụ khác nhau: một cho xác thực người dùng, một cho quản lý đơn hàng, một cho theo dõi kho, một cho thông báo email, một cho xử lý thanh toán, một cho báo cáo, và một dịch vụ điều phối để phối hợp tất cả. Mỗi dịch vụ có cơ sở dữ liệu riêng, pipeline triển khai riêng, thiết lập giám sát riêng. Toàn bộ hệ thống giao tiếp thông qua một message queue bởi vì, bạn biết đấy, “loose coupling” và tất cả những thứ tương tự.
Tiệm bánh đó không bao giờ mở cửa. Bạn tôi đã dành chín tháng để xây dựng cơ sở hạ tầng và không bao giờ thực hiện được các tính năng thực tế mà tiệm bánh cần. Lần cuối tôi nghe, chủ tiệm đã chọn một đối thủ cạnh tranh có sản phẩm hoạt động trong ba tuần. Code của đối thủ có lẽ là một mớ hỗn độn—một Rails monolith lớn với tất cả logic nghiệp vụ nhồi nhét vào các controller “fat”. Nhưng bạn biết không? Nó hoạt động. Nó được triển khai. Nó kiếm tiền.
Sự thật đau lòng là overengineering bề ngoài không giống code tệ. Trên thực tế, nó thường trông giống code thực sự tốt—các trừu tượng rõ ràng, phân tách mối quan tâm rõ ràng, tất cả các mẫu thiết kế được đặt đúng chỗ. Vấn đề là tất cả những điều tốt đẹp đó lại phục vụ cho những vấn đề chưa tồn tại và có thể không bao giờ tồn tại.
Vì Sao Các Kỹ Sư Giỏi Lại Xây Dựng Những Thứ Không Cần Thiết?
Chúng ta cần thành thật về lý do tại sao chúng ta lại tự gây ra điều này. Overengineering thường không phải do sự thiếu năng lực hay lười biếng. Nó bắt nguồn từ nỗi sợ hãi, cái tôi, và sự hiểu lầm căn bản về công việc của chúng ta.
1. Nỗi sợ hãi và sự tối ưu hóa sớm cho cái tôi
Khi bạn đối mặt với một màn hình trống và cần triển khai một tính năng vào thứ Sáu, có một giọng nói trong đầu bạn rằng: “Sẽ thế nào nếu đoạn code này không đủ tốt? Sẽ thế nào nếu người khác nhìn vào và nghĩ mình là một lập trình viên tồi? Sẽ thế nào nếu thiết kế này không thể mở rộng? Sẽ thế nào nếu mình phải viết lại nó trong sáu tháng nữa?” Giọng nói đó rất lớn, rất thuyết phục, và nó nói với bạn rằng cách duy nhất để an toàn là xây dựng một cái gì đó thật mạnh mẽ, thật linh hoạt, thật hoàn hảo về mặt lý thuyết để không ai có thể chỉ trích.
Vì vậy, bạn thêm một lớp trừu tượng nữa. Bạn làm cho mọi thứ có thể cấu hình. Bạn xây dựng các điểm mở rộng cho các tính năng chưa tồn tại. Bạn viết code test nhiều gấp đôi code sản phẩm. Và với mỗi sự bổ sung, bạn cảm thấy an toàn hơn một chút, chuyên nghiệp hơn một chút, giống như bạn đang làm “Kỹ thuật Thực sự™” hơn một chút.
Vấn đề là tất cả những điều này là một hình thức tối ưu hóa sớm, nhưng là cho cái tôi của bạn thay vì hiệu suất. Bạn không xây dựng cho người dùng—bạn đang xây dựng cho người đánh giá code tưởng tượng trong đầu, người sẽ phán xét mọi quyết định của bạn. Và người đánh giá đó khắc nghiệt hơn bất kỳ người thật nào.
2. Cái tôi cá nhân
Hãy nhìn thẳng vào vấn đề này. Chúng ta trở thành lập trình viên vì chúng ta thông minh, và chúng ta thích giải quyết các vấn đề khó, và chúng ta muốn người khác biết chúng ta thông minh trong việc giải quyết các vấn đề khó. Viết một hàm đơn giản chỉ làm một việc tốt không có vẻ gì là ấn tượng. Nó không thể hiện kỹ năng của bạn. Nó không chứng minh rằng bạn đã đọc tất cả các cuốn sách và blog phù hợp và hiểu tất cả các mẫu thiết kế.
Nhưng xây dựng một hệ thống thanh lịch với nhiều lớp, mỗi lớp thể hiện một nguyên tắc thiết kế khác nhau? Điều đó mới giống kỹ thuật thực sự. Điều đó mới giống loại công việc phân biệt giữa senior và junior. Mặc kệ việc các junior đang triển khai tính năng nhanh gấp đôi vì họ không bị sa lầy vào địa ngục trừu tượng.
Tôi đã thấy điều này diễn ra trong các buổi review code hàng chục lần. Ai đó gửi một giải pháp đơn giản, thẳng thắn cho một vấn đề, và ngay lập tức các bình luận tràn vào: “Bạn đã xem xét sử dụng mẫu Strategy ở đây chưa?” “Cái này thực sự nên là một dịch vụ riêng.” “Chúng ta nên làm cái này tổng quát hơn để có thể tái sử dụng.” Và chín trong mười lần, những bình luận đó thực sự không cải thiện code—chúng chỉ là ai đó đang cố gắng chứng minh họ biết nhiều thứ.
3. Hiểu lầm về các đánh đổi (Trade-offs)
Lý do thứ ba chúng ta overengineer là vì chúng ta thực sự không hiểu những đánh đổi mà chúng ta đang thực hiện. Khi bạn đọc các bài blog, tài liệu và các buổi nói chuyện tại hội nghị, chúng hầu như luôn nói về các công ty lớn giải quyết các vấn đề lớn. Google nói về cách họ mở rộng quy mô cho hàng tỷ người dùng. Netflix giải thích kiến trúc microservices của họ. Amazon mô tả các pipeline triển khai của họ. Và tất cả những điều đó thực sự ấn tượng và nghe rất thông minh, vì vậy chúng ta giả định rằng đó là cách chúng ta cũng nên xây dựng phần mềm.
Nhưng đây là điều mà những bài blog đó không nói với bạn: những công ty đó không bắt đầu với kiến trúc đó. Họ đã phát triển thành nó, một cách đau đớn, qua nhiều năm, sau khi họ đã có hàng triệu người dùng và hàng trăm kỹ sư và các vấn đề thực tế yêu cầu những giải pháp đó. Google không bắt đầu với Kubernetes. Netflix không ra mắt với microservices. Amazon không bắt đầu với kiến trúc hướng dịch vụ. Họ bắt đầu với code đủ đơn giản để triển khai, và sau đó họ tái cấu trúc khi các vấn đề thực sự phát sinh.
Khi bạn sao chép kiến trúc của các công ty ở quy mô lớn mà không có các vấn đề quy mô lớn, bạn không học hỏi từ thành công của họ—bạn đang “cargo-culting” trạng thái hiện tại của họ mà bỏ qua hành trình đã đưa họ đến đó. Nó giống như việc xem ai đó đã chạy mười cuộc marathon và quyết định rằng bạn nên tập luyện chính xác theo cách họ làm bây giờ, bỏ qua thực tế rằng họ đã bắt đầu bằng việc chạy bộ quanh khu nhà.
Code Sạch Hay Code Khôn Ngoan: Đâu Là Sự Khác Biệt Quan Trọng?
Đây là một sự phân biệt mà tôi đã mất quá nhiều thời gian để hiểu: code sạch và code khôn ngoan không phải là cùng một thứ, và trên thực tế chúng thường là đối lập. Code sạch là code mà người khác có thể đọc, hiểu và sửa đổi mà không muốn khóc. Code khôn ngoan là code khiến bạn cảm thấy thông minh vì đã viết ra nó.
Từ trải nghiệm cá nhân
Tôi từng viết rất nhiều code “khôn ngoan”. Tôi sẽ tìm cách hoàn thành một điều gì đó chỉ trong một dòng thay vì năm dòng. Tôi sẽ sử dụng các tính năng ngôn ngữ mà hầu hết mọi người không biết. Tôi sẽ cấu trúc mọi thứ theo những cách mà về mặt kỹ thuật thì thanh lịch nhưng lại yêu cầu bạn phải giữ rất nhiều ngữ cảnh trong đầu để hiểu điều gì đang xảy ra. Và tôi cảm thấy thực sự hài lòng về đoạn code này. Tôi sẽ cho các lập trình viên khác xem và xem họ bối rối, và tôi sẽ giải thích sự bối rối của họ là sự ngưỡng mộ kỹ năng của tôi.
Rồi một ngày tôi phải sửa một lỗi trong đoạn code mình đã viết sáu tháng trước. Tôi nhìn vào nó và hoàn toàn không biết mình đã nghĩ gì. Sự khôn ngoan tưởng chừng rất rõ ràng vào thời điểm đó giờ đây hoàn toàn khó hiểu. Tôi đã dành hai giờ để gỡ rối các trừu tượng của chính mình chỉ để thay đổi một điều kiện. Đó là lúc tôi cuối cùng cũng nhận ra: code không chỉ được viết một lần, nó được đọc hàng trăm lần. Và mỗi phút ai đó dành để cố gắng hiểu giải pháp “khôn ngoan” của bạn là một phút họ không dành cho việc thực sự giải quyết vấn đề.
Code sạch, mặt khác, gần như nhàm chán trong sự rõ ràng của nó. Nó làm những gì nó nói sẽ làm. Các hàm có tên cho bạn biết chính xác chúng hoàn thành điều gì. Cấu trúc phản ánh miền vấn đề một cách hợp lý. Khi bạn đọc nó, bạn không ấn tượng bởi sự tài tình kỹ thuật của tác giả—bạn hầu như không nghĩ về tác giả chút nào. Bạn chỉ nghĩ về vấn đề đang được giải quyết.
Ví dụ cụ thể: Xử lý người dùng
Đây là một số code “khôn ngoan” tôi đã viết vài năm trước:
const processUsers = users => users
.filter(u => u.active && !u.deleted && u.email)
.map(u => ({...u, normalized: u.email.toLowerCase().trim()}))
.reduce((acc, u) => ({...acc, [u.id]: u}), {});
Đây có phải code tệ không? Không hẳn. Nó súc tích, nó sử dụng các nguyên tắc lập trình hàm, nó không có tác dụng phụ. Tôi đã khá tự hào về nó vào thời điểm đó. Nhưng đây là vấn đề: khi người khác cần thêm một phép biến đổi mới vào pipeline này, họ phải hiểu toàn bộ chuỗi hoạt động. Và khi chúng tôi cần gỡ lỗi tại sao một số người dùng nhất định không hiển thị, chúng tôi phải tách nó ra để tìm ra bước nào đang lọc họ.
Đây là phiên bản code sạch:
function processUsers(users) {
const activeUsers = users.filter(user => {
return user.active && !user.deleted && user.email;
});
const normalizedUsers = activeUsers.map(user => {
return {
...user,
normalized: user.email.toLowerCase().trim()
};
});
const usersById = {};
for (const user of normalizedUsers) {
usersById[user.id] = user;
}
return usersById;
}
Nó có dài hơn không? Có. Nó có sử dụng nhiều dòng hơn không? Chắc chắn rồi. Nhưng bạn biết không? Khi ai đó cần sửa đổi đoạn code này vào lúc 11 giờ đêm vì có lỗi sản phẩm, họ có thể hiểu từng bước một cách độc lập. Họ có thể thêm log giữa các bước. Họ có thể sửa đổi một phần mà không cần phải suy luận về toàn bộ pipeline. Và điều đó đáng giá hơn nhiều so với việc tiết kiệm vài dòng code.
Đặt tên trong Code Sạch và Code Khôn Ngoan
Sự khác biệt giữa sạch và khôn ngoan cũng thể hiện rõ trong cách đặt tên. Code khôn ngoan có tên biến như d cho data hoặc ctx cho context. Code sạch viết rõ ràng mọi thứ: userAccountData, validationContext. Code khôn ngoan sử dụng các từ viết tắt và những trò đùa nội bộ. Code sạch sử dụng cùng thuật ngữ mà doanh nghiệp sử dụng, ngay cả khi điều đó có nghĩa là tên dài hơn.
Tôi từng làm việc với một lập trình viên ám ảnh với việc làm cho mọi thứ ngắn gọn nhất có thể. Anh ấy sẽ viết các hàm như pUsr(u) thay vì processUser(user). Khi tôi hỏi anh ấy về điều đó, anh ấy nói việc gõ thêm ký tự là lãng phí thời gian. Nhưng bạn biết điều gì lãng phí thời gian hơn không? Ba mươi phút tôi đã dành để cố gắng hiểu pUsr làm gì, nhân với mỗi lập trình viên từng chạm vào đoạn code đó.
Điều thú vị về code sạch là nó thường trông đủ đơn giản đến nỗi mọi người nghĩ rằng nó dễ viết. Và đôi khi đúng là vậy. Nhưng thường thì, viết code thực sự sạch khó hơn viết code “khôn ngoan” bởi vì nó yêu cầu bạn phải hiểu sâu sắc vấn đề và tìm ra giải pháp đơn giản nhất có thể. Bất kỳ ai cũng có thể làm cho một thứ trở nên phức tạp—đó là trạng thái mặc định của phần mềm. Làm cho một thứ đơn giản đòi hỏi kỷ luật và gu thẩm mỹ.
Overengineering “Giết Chết” Đà Phát Triển và Phá Hủy Đội Ngũ
Overengineering gây ra một chi phí vượt xa thời gian bạn dành để viết code không cần thiết. Nó “giết chết” đà phát triển, và trong lĩnh vực phần mềm, đà phát triển là tất cả. Khi một đội ngũ di chuyển nhanh chóng, triển khai tính năng, nhận phản hồi từ người dùng, lặp lại nhanh chóng—đó là lúc điều kỳ diệu xảy ra. Đó là lúc bạn tìm ra thứ bạn thực sự đang xây dựng và đối tượng của nó. Overengineering giống như ném cát vào bánh răng của quá trình đó.
Làm tê liệt đội ngũ và đổi hướng mục tiêu
Tôi đã thấy điều này phá hủy nhiều đội ngũ. Bạn bắt đầu với một nhóm nhỏ các lập trình viên hào hứng xây dựng một cái gì đó mới. Mọi người đều tràn đầy năng lượng, ý tưởng tuôn trào, bạn thấy tiến độ rõ rệt mỗi ngày. Rồi ai đó quyết định rằng bạn cần “làm điều này cho đúng” trước khi có thể triển khai. Có thể là tech lead muốn xây dựng một nền tảng vững chắc. Có thể là CTO lo lắng về khả năng mở rộng. Có thể chỉ là một lập trình viên đã đọc quá nhiều bài Medium về kiến trúc.
Vì vậy, bạn ngừng triển khai tính năng và bắt đầu xây dựng cơ sở hạ tầng. Bạn cần một pipeline triển khai phù hợp. Bạn cần dịch vụ phát hiện. Bạn cần theo dõi phân tán. Bạn cần một chiến lược kiểm thử toàn diện. Bạn cần chuẩn hóa các mẫu API của mình. Và tất cả những điều này nghe có vẻ rất có trách nhiệm và chuyên nghiệp, vì vậy mọi người đều đồng ý rằng đó là cần thiết.
Ba tháng trôi qua. Bạn đã xây dựng rất nhiều cơ sở hạ tầng. Bạn có các dịch vụ và pipeline, quản lý cấu hình và tất cả các dấu hiệu của một tổ chức kỹ thuật trưởng thành. Nhưng bạn chưa triển khai bất cứ thứ gì người dùng có thể thấy. Nhóm marketing đang sốt ruột vì họ không có gì để trình bày cho các nhà đầu tư. Nhóm bán hàng thất vọng vì họ đang mất hợp đồng vào tay các đối thủ cạnh tranh có sản phẩm hoạt động. Và nhóm phát triển đang bắt đầu cảm thấy gánh nặng của tất cả cơ sở hạ tầng mà họ đã xây dựng—mỗi tính năng mới giờ đây yêu cầu thay đổi nhiều dịch vụ, cập nhật cấu hình, phối hợp giữa các nhóm.
Những gì bắt đầu như một nỗ lực để xây dựng mọi thứ “đúng cách” đã trở thành một bộ máy quan liêu. Năng lượng cạn kiệt. Các lập trình viên từng hào hứng xây dựng sản phẩm giờ đây chỉ đang duy trì cơ sở hạ tầng. Tốc độ triển khai tính năng mới đã chậm lại đến mức gần như đứng yên vì mỗi thay đổi chạm vào quá nhiều hệ thống. Và tệ nhất là, bạn vẫn không thực sự biết liệu có ai muốn thứ bạn đang xây dựng hay không vì bạn chưa đưa nó ra trước người dùng.
Đây là chi phí ẩn của overengineering: nó không chỉ làm bạn chậm lại, nó thay đổi điều bạn đang tối ưu hóa. Thay vì tối ưu hóa cho việc học hỏi—để tìm ra người dùng thực sự cần gì—bạn đang tối ưu hóa cho sự chính xác về mặt lý thuyết. Thay vì tối ưu hóa cho sự linh hoạt—để có thể nhanh chóng xoay trục khi bạn học được điều gì đó mới—bạn đang tối ưu hóa cho sự nhất quán trong hệ thống quá phức tạp của mình.
Bài học từ các Startup
Tôi đã chứng kiến một startup thất bại vì điều này. Họ dành một năm xây dựng một nền tảng được cho là sẽ cách mạng hóa cách mọi người thực hiện X. (Tôi đang nói chung chung một cách cố ý.) Kiến trúc đẹp mắt. Họ có sự phân tách rõ ràng giữa frontend, lớp API, các dịch vụ logic nghiệp vụ, và lớp dữ liệu. Họ có kiểm thử toàn diện ở mọi cấp độ. Họ có giám sát và cảnh báo, feature flags và cơ sở hạ tầng A/B testing. Họ có mọi thứ bạn có thể muốn trong một hệ thống phần mềm hiện đại.
Họ cũng chỉ có đúng ba khách hàng khi hết tiền. Hóa ra vấn đề họ đang giải quyết thực sự không phải là vấn đề mà người dùng mục tiêu của họ gặp phải. Nhưng họ đã đầu tư quá nhiều vào kiến trúc của mình, và họ quá tự hào về việc họ đã xây dựng mọi thứ “đúng cách” đến nỗi việc xoay trục sẽ đồng nghĩa với việc bỏ đi hầu hết công việc của họ. Vì vậy, họ đã gấp đôi nỗ lực thay vì thích nghi, và họ đã thất bại với một cơ sở code đẹp mắt giải quyết sai vấn đề.
So sánh điều đó với một startup khác mà tôi biết. Họ xây dựng phiên bản đầu tiên trong hai tuần. Đó là một mớ hỗn độn—một tệp khổng lồ, không có test, cấu hình được hard-code, tất cả các best practice đều bị vi phạm. Nhưng nó hoạt động, và họ đưa nó đến tay người dùng ngay lập tức. Những người dùng đó nói cho họ biết điều gì sai, điều gì thiếu, điều gì họ thực sự cần. Vì vậy, họ đã sửa những điều đó. Sau đó họ có thêm người dùng, và những người dùng đó nói cho họ những điều khác. Và họ tiếp tục lặp lại, triển khai các phiên bản mới sau vài ngày, học hỏi liên tục.
Sáu tháng sau, họ có hàng nghìn người dùng hoạt động và hiểu rõ ràng về những gì họ đang xây dựng. Vào thời điểm đó, với doanh thu thực tế đang đến và các vấn đề thực tế cần giải quyết, họ bắt đầu tái cấu trúc. Họ chia nhỏ monolith khi cần thiết. Họ thêm test cho những phần thường xuyên bị lỗi. Họ xây dựng cơ sở hạ tầng cho các vấn đề mở rộng cụ thể mà họ thực sự gặp phải. Và bởi vì họ làm tất cả những điều này để đáp ứng các vấn đề thực tế, mỗi mảnh phức tạp mà họ thêm vào đều có lý do chính đáng.
Vấn đề về đà phát triển cũng hoạt động ở cấp độ cá nhân. Khi bạn đang xây dựng một cái gì đó và bạn đang trong trạng thái “flow”—bạn biết chính xác những gì bạn cần xây dựng tiếp theo, bạn đang tạo ra tiến bộ rõ rệt, bạn có thể thấy vạch đích—đó là lúc bạn làm việc tốt nhất. Nhưng khi bạn bị mắc kẹt trong địa ngục trừu tượng, cố gắng thiết kế hệ thống hoàn hảo trước khi bạn viết một dòng code nào thực sự làm được gì, trạng thái flow đó biến mất. Bạn dành cả ngày để thảo luận kiến trúc và tài liệu thiết kế, và cuối tuần bạn không triển khai được gì. Và cảm giác không triển khai được gì, tuần này qua tuần khác, thật sự rất tồi tệ.
Viết Code Cho Vấn Đề Hiện Tại, Không Phải Mơ Tưởng Về Tương Lai
Đây có lẽ là bài học quan trọng nhất tôi học được trong sự nghiệp của mình: bạn không thể dự đoán tương lai, vì vậy hãy ngừng cố gắng xây dựng cho nó. Mỗi giờ bạn dành để thêm tính linh hoạt cho các yêu cầu chưa tồn tại là một giờ bạn không dành cho các yêu cầu đang tồn tại. Và khi những yêu cầu tương lai đó cuối cùng đến—nếu chúng có đến—chúng không bao giờ giống như bạn đã tưởng tượng anyway.
Chi phí của sự linh hoạt không cần thiết
Tôi từng rất tệ về điều này. Bất cứ khi nào tôi xây dựng một tính năng, tôi sẽ nghĩ về tất cả những cách mà nó có thể cần phải phát triển trong tương lai. Sẽ thế nào nếu chúng ta cần hỗ trợ nhiều loại tiền tệ? Sẽ thế nào nếu chúng ta cần bản địa hóa cho các khu vực khác nhau? Sẽ thế nào nếu chúng ta cần tùy chỉnh điều này cho mỗi khách hàng? Và tôi sẽ xây dựng tất cả sự linh hoạt đó ngay từ đầu, phòng trường hợp.
Kết quả là đoạn code phức tạp hơn rất nhiều so với những gì nó cần cho các yêu cầu hiện tại thực tế. Những thứ đơn giản lại yêu cầu cấu hình. Logic thẳng thắn được bao bọc trong các trừu tượng. Và khi tương lai mà tôi đã tưởng tượng không bao giờ xảy ra—hoặc xảy ra theo một cách hoàn toàn khác so với dự đoán của tôi—tất cả sự phức tạp bổ sung đó chỉ là một gánh nặng làm cho những thay đổi thực sự trở nên khó khăn hơn.
Đây là một ví dụ cụ thể vẫn khiến tôi phải nhăn mặt. Tôi đang xây dựng một hệ thống xử lý thanh toán cho một sản phẩm SaaS. Vào thời điểm đó, chúng tôi chỉ chấp nhận thẻ tín dụng qua Stripe. Nhưng tôi nghĩ, “Chúng ta có thể muốn hỗ trợ các nhà cung cấp thanh toán khác trong tương lai. Tốt hơn nên làm điều này tổng quát!” Vì vậy, tôi đã xây dựng toàn bộ một lớp trừu tượng cổng thanh toán. Tôi có các giao diện cho PaymentProcessor, PaymentMethod, TransactionResult, tất cả đều vậy. Tôi có một factory chọn đúng bộ xử lý dựa trên cấu hình. Tôi có các adapter và mapper và tất cả các mẫu thiết kế.
Code tích hợp Stripe thực tế có lẽ chỉ khoảng 100 dòng. Lớp trừu tượng bao quanh nó là 500 dòng. Và bạn biết không? Chúng tôi không bao giờ thêm một bộ xử lý thanh toán nào khác. Không phải trong ba năm tôi làm việc trên sản phẩm đó. Lần duy nhất chúng tôi cần sửa đổi luồng thanh toán là để thêm hỗ trợ tạm dừng đăng ký, và lớp trừu tượng tuyệt đẹp của tôi không giúp ích gì cho việc đó cả vì tôi đã không dự đoán yêu cầu đó. Vì vậy, cuối cùng chúng tôi vẫn phải “hack” xung quanh nó.
Nếu tôi có thể quay lại và làm lại, tôi sẽ chỉ viết tích hợp Stripe trực tiếp. Không trừu tượng, không giao diện, chỉ là code thẳng thắn để tính phí thẻ tín dụng. Và sau đó khi—nếu—chúng tôi cần thêm PayPal hoặc Apple Pay hoặc bất cứ thứ gì, tôi sẽ tái cấu trúc vào thời điểm đó. Với lợi ích của việc biết bộ xử lý thanh toán thứ hai thực sự trông như thế nào, tôi sẽ có thể xây dựng một trừu tượng thực sự có ý nghĩa thay vì một cái dựa trên trí tưởng tượng của mình.
Nguyên tắc áp dụng chung
Nguyên tắc này áp dụng cho mọi thứ. Đừng xây dựng một hệ thống cấu hình cho đến khi bạn cần cấu hình một cái gì đó và việc thay đổi code thực sự gây khó khăn. Đừng xây dựng kiến trúc plugin cho đến khi bạn có các plugin thực tế mà bạn muốn hỗ trợ. Đừng xây dựng để mở rộng quy mô cho đến khi bạn có một cái gì đó đáng để mở rộng quy mô. Đừng xây dựng cho việc quốc tế hóa cho đến khi bạn có người dùng quốc tế.
Có một lập luận phản bác mà tôi nghe rất nhiều: “Nhưng nếu chúng ta không xây dựng sự linh hoạt này ngay bây giờ, sẽ khó hơn rất nhiều để thêm sau này! Chúng ta sẽ phải viết lại mọi thứ!” Và được thôi, đúng là đôi khi tái cấu trúc khó hơn là làm đúng ngay từ đầu. Nhưng đây là điều mà lập luận đó bỏ lỡ: có thể bạn không bao giờ cần sự linh hoạt đó. Có thể các giả định của bạn về tương lai là sai. Có thể công ty xoay trục. Có thể tính năng bị hủy bỏ. Có thể yêu cầu thay đổi theo cách khiến trừu tượng của bạn trở nên vô dụng.
Và ngay cả khi bạn cuối cùng cần tái cấu trúc, thì sao? Tái cấu trúc là một phần bình thường của quá trình phát triển phần mềm. Đó không phải là một thất bại—đó là dấu hiệu cho thấy bạn đã học được điều gì đó mà bạn không biết trước đây. Thay vào đó là xây dựng sự linh hoạt mà bạn không cần, duy trì nó mãi mãi, và khiến mọi lập trình viên chạm vào code đều tự hỏi tại sao điều đơn giản này lại phức tạp đến vậy.
Hãy nói theo cách này: chi phí xây dựng một thứ bạn không cần được trả ngay từ đầu và sau đó mỗi ngày sau đó khi mọi người duy trì đoạn code không cần thiết đó. Chi phí tái cấu trúc khi bạn cần nó được trả một lần, tại thời điểm bạn có tối đa thông tin về những gì bạn thực sự cần. Cái nào nghe có vẻ là một thỏa thuận tốt hơn?
Cũng có một điều gì đó giải phóng khi chỉ giải quyết vấn đề của ngày hôm nay. Bạn không phải tưởng tượng mọi tương lai có thể. Bạn không phải dự đoán các yêu cầu có thể phát triển như thế nào. Bạn chỉ cần nhìn vào những gì trước mắt và hỏi: điều đơn giản nhất có thể hoạt động là gì? Và sau đó bạn xây dựng điều đó. Và nếu hóa ra bạn cần một cái gì đó khác sau này, bạn sẽ xây dựng điều đó sau, với lợi ích của mọi thứ bạn đã học được trong thời gian đó.
Chi Phí Thực Sự Của Việc Trừu Tượng Hóa Sớm (Premature Abstraction)
Các trừu tượng (abstractions) được cho là sẽ làm cho cuộc sống của chúng ta dễ dàng hơn. Chúng được cho là để che giấu sự phức tạp, cung cấp các giao diện sạch, làm cho code dễ tái sử dụng hơn. Và khi bạn có các trừu tượng đúng, chúng làm được tất cả những điều đó. Vấn đề là tìm ra các trừu tượng đúng thực sự khó, và bạn gần như không bao giờ biết đủ để làm cho chúng đúng ngay từ lần đầu.
Hậu quả của trừu tượng hóa sai hoặc quá sớm
Trừu tượng hóa sớm—xây dựng các trừu tượng trước khi bạn hiểu vấn đề đủ rõ để biết cái gì nên được trừu tượng hóa—là một trong những sai lầm tốn kém nhất bạn có thể mắc phải trong phát triển phần mềm. Nó tốn kém vì các trừu tượng thêm sự gián tiếp (indirection), và sự gián tiếp làm cho code khó hiểu hơn. Nó tốn kém vì các trừu tượng sai khó làm việc hơn là không có trừu tượng nào cả. Và nó tốn kém vì một khi bạn đã xây dựng một trừu tượng, có áp lực phải tiếp tục sử dụng nó ngay cả khi nó không thực sự phù hợp.
Tôi đã học được bài học này từ một dự án nơi chúng tôi đang xây dựng một hệ thống CRM. Ngay từ đầu, một người trong nhóm nhận thấy rằng chúng tôi sẽ có nhiều loại thực thể khác nhau: Công ty, Liên hệ, Giao dịch và Nhiệm vụ. Và họ lập luận rằng tất cả các thực thể này đều có một số điểm chung—tất cả đều có tên, tất cả đều có dấu thời gian, tất cả đều cần các hoạt động CRUD. Vì vậy, họ đã xây dựng một lớp cơ sở Entity tổng quát mà tất cả các lớp này sẽ kế thừa từ.
Trên giấy tờ, điều này có vẻ thông minh. Chúng tôi sẽ tái sử dụng code, duy trì tính nhất quán, làm cho mọi thứ dễ mở rộng hơn. Trong thực tế, đó là một cơn ác mộng. Công ty và Liên hệ có một số điểm trùng lặp, nhưng Giao dịch hoạt động khác—chúng có các giai đoạn, xác suất và giá trị không khớp với trừu tượng Entity. Nhiệm vụ còn tệ hơn—chúng có ngày đáo hạn, phân công và trạng thái hoàn thành hoàn toàn độc đáo.
Vì vậy, chúng tôi bắt đầu thêm ngày càng nhiều trường vào lớp cơ sở Entity, hầu hết trong số đó chỉ áp dụng cho một số loại thực thể. Chúng tôi thêm kiểm tra kiểu dữ liệu để bỏ qua xác thực cho các trường không áp dụng. Chúng tôi thêm các trường hợp đặc biệt trong code UI để ẩn các trường không liên quan. Lớp trừu tượng được cho là để đơn giản hóa mọi thứ giờ đây lại là nguồn gốc của hầu hết sự phức tạp của chúng tôi.
Cuối cùng, chúng tôi đã loại bỏ tất cả và chỉ làm cho mỗi loại thực thể là một thực thể riêng biệt. Công ty có code riêng cho công ty. Liên hệ có code riêng cho liên hệ. Vâng, có một số sự trùng lặp. Vâng, mỗi loại thực hiện các hoạt động CRUD riêng của nó. Nhưng bạn biết không? Đoạn code dễ hiểu và sửa đổi hơn rất nhiều. Khi ai đó cần thêm một trường vào Công ty, họ không phải lo lắng về việc làm hỏng Giao dịch. Khi chúng tôi cần thay đổi cách hiển thị Nhiệm vụ, chúng tôi không phải thêm các trường hợp đặc biệt vào một trình kết xuất chung.
Nguyên tắc “Quy tắc Ba Lần”
Điều khó khăn về trừu tượng hóa sớm là nó thường có vẻ là kỹ thuật tốt vào thời điểm đó. Bạn đang tuân theo DRY (Don’t Repeat Yourself). Bạn đang suy nghĩ trước. Bạn đang xây dựng các thành phần có thể tái sử dụng. Tất cả các cuốn sách lập trình đều nói với bạn rằng đây là điều bạn nên làm. Nhưng DRY là về việc loại bỏ sự trùng lặp kiến thức, không phải trùng lặp code. Nếu hai thứ tình cờ trông giống nhau ngay bây giờ nhưng đại diện cho các khái niệm cơ bản khác nhau, việc trừu tượng hóa chúng lại với nhau là sai.
Có một quy tắc mà tôi cố gắng tuân theo bây giờ: Tôi không xây dựng trừu tượng cho đến khi tôi có ít nhất ba ví dụ cụ thể sẽ hưởng lợi từ chúng. Một ví dụ là một trường hợp đặc biệt. Hai ví dụ có thể là sự trùng hợp. Ba ví dụ là một mẫu đáng để trích xuất. Điều này buộc tôi phải đợi cho đến khi tôi thực sự hiểu miền vấn đề đủ tốt để trừu tượng hóa một cách đúng đắn.
Và ngay cả khi đó, tôi cố gắng xây dựng trừu tượng đơn giản nhất có thể hoạt động. Không có mẫu thiết kế phức tạp. Không có hệ thống phân cấp kiểu dữ liệu thông minh. Chỉ là một hàm hoặc một lớp nắm bắt thứ thực sự được lặp lại, với một giao diện rõ ràng và một mục đích rõ ràng. Nếu tôi cần nhiều hơn sau này, tôi luôn có thể tái cấu trúc. Nhưng bắt đầu đơn giản có nghĩa là tôi ít có khả năng xây dựng trừu tượng sai.
Gánh nặng nhận thức của các lớp trừu tượng
Một điều khác về các trừu tượng là chúng không miễn phí. Mỗi lớp trừu tượng là thứ mà các lập trình viên phải hiểu và ghi nhớ. Khi ai đó muốn tìm hiểu cách một tính năng hoạt động, họ phải theo dõi qua tất cả các lớp trừu tượng để tìm ra đoạn code thực tế làm điều đó. Và nếu các trừu tượng của bạn sâu và sự gián tiếp của bạn phức tạp, việc theo dõi đó trở thành một gánh nặng nhận thức đáng kể.
Tôi từng làm việc trên một cơ sở code nơi một thao tác “gửi email” đơn giản đi qua bảy lớp trừu tượng. Có một EmailService sử dụng một EmailProvider bao bọc một EmailClient gọi một EmailAdapter sử dụng một MessageSender gọi một TransportLayer cuối cùng gọi API email thực tế. Mỗi lớp thêm một số chức năng—logging, xử lý lỗi, thử lại, v.v.—nhưng hiệu ứng cuối cùng là không ai có thể hiểu email thực sự được gửi như thế nào. Khi việc gửi email bắt đầu thất bại, chúng tôi mất hàng giờ để tìm ra lớp nào gặp vấn đề.
So sánh điều đó với một dự án khác nơi việc gửi email chỉ là một hàm gọi trực tiếp API email. Mọi thứ nó làm—định dạng tin nhắn, xử lý lỗi, ghi lại kết quả—đều ở ngay đó, ở một nơi. Khi có gì đó sai, bạn biết chính xác phải tìm ở đâu. Và khi chúng tôi cần thêm chức năng như logic thử lại, chúng tôi thêm nó ngay tại đó nơi chúng tôi có thể thấy nó, thay vì trong một lớp trừu tượng nào đó ở ba cấp độ trên.
Các Nguyên Tắc Thiết Kế Đơn Giản, Hiệu Quả Trong Thế Giới Thực
Sau nhiều năm xây dựng mọi thứ một cách khó khăn, tôi đã đúc kết được một vài nguyên tắc thực sự hiệu quả trong thực tế. Đây không phải là những nguyên tắc bạn tìm thấy trong sách kiến trúc—chúng giống như các quy tắc ngón tay cái để duy trì sự tỉnh táo và triển khai những thứ không sụp đổ dưới sức nặng của chính chúng.
1. Làm cho nó hoạt động, sau đó làm cho nó đúng, sau đó làm cho nó nhanh
Đây là lời khuyên cũ, nhưng hầu hết mọi người bỏ qua thứ tự. Họ cố gắng làm cho nó đúng và nhanh ngay từ đầu, điều đó có nghĩa là họ không bao giờ đến được phần làm cho nó hoạt động. Bắt đầu với điều đơn giản nhất có thể giải quyết vấn đề. Làm cho nó hoạt động. Triển khai nó nếu bạn có thể. Sau đó, với lợi ích của việc thấy nó thực sự hoạt động, hãy cải thiện nó. Và chỉ tối ưu hóa hiệu suất khi bạn có các vấn đề hiệu suất thực tế, không phải những vấn đề tưởng tượng.
Tôi không thể nói cho bạn biết bao nhiêu lần tôi đã thấy các đội dành hàng tuần để tối ưu hóa code chạy mỗi ngày một lần và mất năm giây. Trong khi đó, con đường quan trọng mà người dùng truy cập hàng nghìn lần mỗi phút lại chậm như mật đường vì không ai bận tâm đo lường những điểm nghẽn thực tế ở đâu. Khi bạn làm cho nó hoạt động trước, bạn có thể thực sự đo lường thời gian của mình đang được dành ở đâu, và bạn có thể tối ưu hóa những thứ quan trọng thay vì những thứ bạn tưởng tượng có thể quan trọng.
2. Bắt đầu từ dữ liệu
Trước khi bạn viết bất kỳ code nào, trước khi bạn nghĩ về các trừu tượng, hãy tìm hiểu dữ liệu bạn thực sự đang xử lý là gì. Cái gì đi vào? Cái gì đi ra? Cái gì cần được lưu trữ? Cái gì cần được biến đổi? Một khi bạn hiểu luồng dữ liệu, code gần như tự viết ra. Nhưng nếu bạn bắt đầu với các trừu tượng và mẫu thiết kế và cố gắng tìm hiểu dữ liệu sau, bạn sẽ có kiến trúc không khớp với vấn đề.
Tôi từng làm việc trên một dự án nơi chúng tôi dành hai tuần để thiết kế một hệ thống các dịch vụ, hàng đợi và worker trước khi thực sự xem xét dữ liệu chúng tôi sẽ xử lý. Khi cuối cùng chúng tôi xem xét nó, chúng tôi nhận ra rằng 90% các bản ghi là một loại duy nhất có thể được xử lý một cách hoàn toàn thẳng thắn, và chỉ 10% cần xử lý đặc biệt. Nếu chúng tôi bắt đầu từ dữ liệu, chúng tôi đã xây dựng một bộ xử lý đơn giản với một trình xử lý trường hợp đặc biệt. Thay vào đó, chúng tôi đã xây dựng kiến trúc hướng sự kiện phức tạp này quá mức cần thiết cho những gì chúng tôi thực sự cần.
3. Lặp lại code tốt hơn là một trừu tượng sai
Tôi đã đề cập điều này trước đây, nhưng nó đáng lặp lại vì nó rất phản trực giác đối với các lập trình viên. Chúng ta được dạy rằng sự trùng lặp là xấu, rằng chúng ta nên luôn DRY code của mình. Nhưng code bị trùng lặp mà dễ hiểu thì tốt hơn nhiều so với một trừu tượng khó sửa đổi. Nếu bạn thấy sự trùng lặp, hãy chống lại sự thôi thúc ngay lập tức trừu tượng hóa nó. Đợi cho đến khi bạn có đủ ví dụ để trừu tượng đúng trở nên rõ ràng. Và nếu bạn không chắc chắn, hãy để nó trùng lặp.
4. Giữ những thứ liên quan lại với nhau
Điều này nghe có vẻ hiển nhiên, nhưng bạn sẽ ngạc nhiên khi thấy các cơ sở code thường vi phạm nguyên tắc này như thế nào nhân danh “phân tách mối quan tâm”. Họ sẽ đặt tất cả các controller vào một thư mục, tất cả các model vào một thư mục khác, tất cả các view vào thư mục thứ ba, và sau đó khi bạn muốn hiểu cách một tính năng hoạt động, bạn phải nhảy giữa năm thư mục khác nhau. Thay vào đó, hãy tổ chức code của bạn xung quanh các tính năng hoặc miền. Đặt mọi thứ liên quan đến xác thực người dùng ở một nơi. Đặt mọi thứ liên quan đến thanh toán ở một nơi khác. Điều này giúp dễ dàng hiểu cách mọi thứ hoạt động và sửa đổi chúng một cách an toàn hơn rất nhiều.
5. Viết các hàm chỉ làm một việc
Không phải “một việc ở một cấp độ trừu tượng” hay “một việc tuân theo Nguyên tắc Trách nhiệm Duy nhất”—chỉ đơn giản là một việc. Một hàm xác thực email và gửi tin nhắn chào mừng đang làm hai việc. Chia nó thành hai hàm. Một hàm lấy dữ liệu, biến đổi nó và lưu nó đang làm ba việc. Chia nó thành ba hàm. Khi các hàm chỉ làm một việc, chúng dễ đặt tên, dễ kiểm thử và dễ tái sử dụng. Khi chúng làm nhiều việc, chúng khó đặt tên (vì tên phải nắm bắt nhiều khái niệm), khó kiểm thử (vì bạn phải kiểm thử nhiều đường dẫn), và khó tái sử dụng (vì bạn hiếm khi cần tất cả những gì hàm làm).
6. Tránh code “khôn ngoan”
Tôi đã nói điều này trước đây, nhưng nó đáng lặp lại. Code “khôn ngoan” là code khiến bạn cảm thấy thông minh. Code rõ ràng là code khiến người khác cảm thấy thông minh. Hãy chọn code rõ ràng mọi lúc. Sử dụng các mẫu chuẩn thay vì tự tạo ra. Sử dụng các tính năng ngôn ngữ quen thuộc thay vì những tính năng khó hiểu. Viết code trông giống code mà người khác đã thấy trước đây. Có rất ít tình huống mà việc trở nên “khôn ngoan” thực sự đáng giá chi phí về sự hiểu biết.
7. Xây dựng ứng dụng, không phải framework
Tôi đã thấy rất nhiều đội bị mắc kẹt vào cái bẫy tự xây dựng framework của riêng mình vì các framework hiện có không hoàn toàn làm được điều họ muốn. Và có thể những framework đó không hoàn toàn phù hợp, nhưng bạn biết không? Chúng đủ tốt, và chúng được duy trì bởi những người làm việc toàn thời gian với chúng, và việc sử dụng chúng có nghĩa là bạn có thể tập trung vào việc xây dựng ứng dụng thực tế của mình thay vì xây dựng lại Rails, Django hay React từ đầu.
Sự Đánh Đổi Giữa Khả Năng Mở Rộng và Sự Đơn Giản
Một trong những lý do phổ biến nhất để overengineering là khả năng mở rộng (scalability). “Chúng ta cần xây dựng điều này đúng đắn vì nếu chúng ta có một triệu người dùng thì sao?” Và được thôi, đúng vậy, khả năng mở rộng rất quan trọng. Nhưng đây là vấn đề: hầu hết các sản phẩm không bao giờ đạt đến điểm mà khả năng mở rộng là vấn đề chính của họ. Hầu hết các sản phẩm thất bại vì họ không thể triển khai đủ nhanh để tìm thấy sự phù hợp sản phẩm-thị trường, chứ không phải vì họ không thể mở rộng quy mô để xử lý lượng người dùng khổng lồ.
Không cần thiết kế cho quy mô từ đầu
Tôi đã xây dựng các hệ thống xử lý hàng triệu yêu cầu mỗi ngày, và tôi cũng đã xây dựng các hệ thống xử lý hàng chục yêu cầu mỗi ngày. Và tôi có thể nói với bạn rằng kiến trúc có ý nghĩa cho hàng triệu yêu cầu khác biệt rất nhiều so với kiến trúc có ý nghĩa cho hàng chục. Nhưng quan trọng hơn, bạn không thể dự đoán kiến trúc nào bạn sẽ cần cho đến khi bạn biết loại tải bạn thực sự đang xử lý.
Có một giả định rằng khả năng mở rộng phải được xây dựng ngay từ đầu, rằng nếu bạn không thiết kế cho quy mô ngay từ đầu, bạn sẽ không bao giờ có thể mở rộng quy mô sau này. Nhưng điều đó không đúng. Twitter bắt đầu như một monolith Rails. Facebook bắt đầu như một ứng dụng PHP. Instagram là một ứng dụng Django chạy trên một vài máy chủ khi họ được mua lại với giá một tỷ đô la. Những công ty này đã mở rộng quy mô bằng cách thêm cơ sở hạ tầng và tái cấu trúc khi họ phát triển, chứ không phải bằng cách dự đoán các vấn đề quy mô của họ trước.
Điều giết chết các công ty không phải là bắt đầu với một kiến trúc đơn giản. Điều giết chết các công ty là không bao giờ triển khai vì họ quá bận xây dựng một kiến trúc có khả năng mở rộng cho người dùng mà họ chưa có. Hoặc triển khai muộn vì mỗi tính năng yêu cầu cập nhật nhiều dịch vụ và điều phối việc di chuyển cơ sở dữ liệu trên các hệ thống phân tán. Hoặc làm kiệt sức đội ngũ vì sự phức tạp của kiến trúc khiến mỗi thay đổi mất thời gian gấp ba lần so với bình thường.
Vì vậy, đây là lời khuyên của tôi về khả năng mở rộng: đừng thiết kế cho quy mô cho đến khi bạn có các vấn đề về quy mô. Bắt đầu với điều đơn giản nhất có thể hoạt động. Một máy chủ, một cơ sở dữ liệu, một ứng dụng. Khi điều đó không còn đủ—khi bạn thực sự có vấn đề về hiệu suất hoặc vấn đề về độ tin cậy hoặc bất cứ điều gì—thì bạn mới mở rộng quy mô. Và bạn mở rộng quy mô để phản ứng với các vấn đề cụ thể, có thể đo lường được mà bạn thực sự đang trải nghiệm.
Khi cơ sở dữ liệu của bạn bắt đầu chậm, hãy thêm chỉ mục. Khi máy chủ của bạn bắt đầu quá tải, hãy thêm một lớp bộ đệm. Khi monolith của bạn quá lớn để triển khai dễ dàng, hãy trích xuất một hoặc hai dịch vụ cho những phần cần mở rộng độc lập. Nhưng hãy làm tất cả những điều này để phản ứng với các vấn đề thực tế, với dữ liệu thực tế về nơi các điểm nghẽn của bạn thực sự nằm.
Điều tuyệt vời về việc mở rộng quy mô để phản ứng với các vấn đề thực tế là bạn có nhiều khả năng mở rộng đúng thứ theo đúng cách hơn. Khi bạn xây dựng cho các vấn đề quy mô tưởng tượng, bạn chỉ đang đoán. Bạn có thể xây dựng một hệ thống phân tán trong khi cái bạn thực sự cần là các chỉ mục cơ sở dữ liệu tốt hơn. Bạn có thể chia mọi thứ thành microservices trong khi cái bạn thực sự cần là một pipeline triển khai tốt hơn cho monolith của mình. Bạn có thể xây dựng một lớp bộ đệm phức tạp trong khi cái bạn thực sự cần là sửa một truy vấn chậm.
Điều này không có nghĩa là bạn nên viết code hoàn toàn không thân thiện với việc mở rộng quy mô. Đừng hard-code URL máy chủ. Đừng đặt trạng thái vào bộ nhớ nếu nó cần được chia sẻ giữa các yêu cầu. Đừng xây dựng các giả định rõ ràng sẽ sai ở quy mô lớn. Nhưng có một sự khác biệt rất lớn giữa việc không ngớ ngẩn và cố gắng xây dựng cho quy mô tương lai về mặt lý thuyết.
Sự đánh đổi giữa khả năng mở rộng và sự đơn giản là có thật, và đó là điều bạn phải thực hiện một cách có ý thức. Mọi quyết định kiến trúc làm cho hệ thống của bạn có khả năng mở rộng hơn cũng làm cho nó phức tạp hơn. Các hệ thống phân tán có khả năng mở rộng hơn monolith, nhưng chúng cũng khó gỡ lỗi và kiểm thử hơn nhiều. Microservices mang lại cho bạn khả năng triển khai độc lập, nhưng chúng cũng mang lại cho bạn các cuộc gọi mạng và sự nhất quán cuối cùng và các giao dịch phân tán. Hàng đợi tin nhắn mang lại cho bạn sự tách rời và khả năng phục hồi, nhưng chúng cũng mang lại cho bạn những cơn ác mộng gỡ lỗi và các vấn đề về nhất quán cuối cùng.
Câu hỏi bạn phải tự hỏi mình là: lợi ích về khả năng mở rộng có đáng giá chi phí về sự phức tạp ngay bây giờ, đối với các vấn đề tôi thực sự có không? Thường thì câu trả lời là không. Và khi câu trả lời là không, hãy chọn sự đơn giản. Bạn luôn có thể thêm sự phức tạp sau này khi bạn cần. Nhưng loại bỏ sự phức tạp thì khó hơn nhiều so với việc thêm nó.
Khi Nào Việc Trừu Tượng Hóa Thực Sự Có Cơ Sở?
Tôi đã dành rất nhiều thời gian nói về những nguy hiểm của việc trừu tượng hóa sớm, nhưng tôi không muốn tạo ấn tượng rằng trừu tượng hóa luôn tệ. Không phải vậy. Các trừu tượng tốt là một trong những công cụ mạnh mẽ nhất chúng ta có để quản lý sự phức tạp. Mấu chốt là biết khi nào bạn đã tìm thấy một trừu tượng tốt so với khi bạn chỉ đang làm cho mọi thứ trở nên phức tạp.
Các dấu hiệu cho thấy trừu tượng hóa là cần thiết
Vậy khi nào việc trừu tượng hóa thực sự có cơ sở? Dấu hiệu rõ ràng nhất là khi bạn có cùng một khái niệm xuất hiện ở nhiều nơi, và bạn có thể thấy rằng đó thực sự là cùng một thứ, không chỉ là code tình cờ tương tự. Tôi đang nói về sự trùng lặp thực sự về kiến thức và hành vi, không chỉ là code trông tương tự trên bề mặt.
Đây là một ví dụ từ một dự án tôi đã làm. Chúng tôi đang xây dựng một hệ thống cần xác thực các loại đầu vào người dùng khác nhau—địa chỉ email, số điện thoại, số thẻ tín dụng, mã bưu điện. Ban đầu, mỗi xác thực chỉ được viết trực tiếp trong code nơi nó cần thiết. Nhưng sau một thời gian, chúng tôi nhận thấy một mẫu: mỗi xác thực đều có cùng cấu trúc. Kiểm tra xem đầu vào có trống không, kiểm tra xem nó có khớp với định dạng không, kiểm tra xem nó có nằm trong danh sách các giá trị xấu đã biết không, trả về một thông báo lỗi có cấu trúc nếu có gì đó sai.
Vào thời điểm đó, việc trích xuất một trừu tượng xác thực có ý nghĩa. Không phải vì chúng tôi đang tuân theo một nguyên tắc nào đó về trừu tượng, mà vì chúng tôi có các ví dụ cụ thể cho chúng tôi thấy trừu tượng nên trông như thế nào. Chúng tôi đã xây dựng một lớp Validator đơn giản nhận một giá trị và một tập hợp các quy tắc, chạy các quy tắc theo thứ tự và trả về kết quả thành công hoặc một lỗi chi tiết. Nó không thông minh, nó không bị overengineer, nó chỉ là một cách thẳng thắn để nắm bắt mẫu mà chúng tôi đã thấy lặp lại hàng chục lần.
Trừu tượng đó đã giúp chúng tôi tiết kiệm thời gian. Khi chúng tôi cần thêm một loại xác thực mới, chúng tôi chỉ định nghĩa các quy tắc—chúng tôi không phải xây dựng lại logic xác thực. Khi chúng tôi cần thay đổi cách hiển thị lỗi xác thực, chúng tôi thay đổi nó ở một nơi thay vì tìm kiếm trong cơ sở code. Và khi một người mới tham gia nhóm và cần hiểu cách xác thực hoạt động, họ có thể nhìn vào lớp Validator và hiểu ngay lập tức.
So sánh điều đó với một trừu tượng tôi đã xây dựng trong một dự án khác nơi chúng tôi có hai biểu mẫu khác nhau đều thu thập thông tin người dùng. Tôi nghĩ, “Những cái này tương tự, mình nên trừu tượng hóa chúng!” Vì vậy, tôi đã xây dựng một FormBuilder tổng quát có thể xử lý các loại trường khác nhau và các quy tắc xác thực và các trình xử lý gửi. Nó có thể cấu hình và linh hoạt và có vẻ thực sự thông minh vào thời điểm đó.
Vấn đề là hai biểu mẫu đó chỉ tương tự bề ngoài. Một là để đăng ký người dùng, cái kia là để cập nhật thông tin hồ sơ. Chúng có các trường khác nhau, các yêu cầu xác thực khác nhau, các luồng gửi khác nhau, xử lý lỗi khác nhau. Mỗi khi chúng tôi cần sửa đổi một biểu mẫu, chúng tôi phải thêm các tùy chọn cấu hình vào FormBuilder để hỗ trợ thay đổi mà không làm hỏng biểu mẫu kia. Cuối cùng, FormBuilder quá phức tạp đến nỗi việc xây dựng các biểu mẫu từ đầu dễ dàng hơn là tìm cách cấu hình nó đúng cách.
Sự khác biệt giữa hai trường hợp này? Trong trường hợp đầu tiên, tôi có bằng chứng rõ ràng rằng tôi đang xử lý cùng một khái niệm. Nhiều ví dụ về cùng một mẫu, tất cả đều tuân theo cùng một cấu trúc. Trong trường hợp thứ hai, tôi đang trừu tượng hóa dựa trên sự tương đồng bề mặt mà không hiểu liệu các khái niệm cơ bản có thực sự giống nhau hay không.
Một trường hợp khác mà trừu tượng hóa có cơ sở là khi bạn cần che giấu sự phức tạp vốn có của vấn đề, không phải sự phức tạp mà bạn đã tạo ra. Nếu bạn đang làm việc với một API bên thứ ba có luồng xác thực phức tạp hoặc định dạng dữ liệu kỳ lạ, việc bao bọc nó trong một giao diện đơn giản có ý nghĩa. Bạn không thêm sự phức tạp—bạn đang cô lập sự phức tạp hiện có để nó không lan truyền khắp cơ sở code của bạn.
Tôi đã làm việc trên một dự án tích hợp với một API SOAP cũ kỹ yêu cầu các lược đồ XML và tệp WSDL và tất cả các loại rắc rối doanh nghiệp từ năm 2005. Chúng tôi đã bao bọc nó trong một adapter đơn giản để hiển thị các đối tượng JavaScript sạch và các hàm async. Khi bạn sử dụng nó, bạn không phải nghĩ về XML hay SOAP hay bất cứ điều gì—bạn chỉ gọi các hàm và nhận lại dữ liệu. Wrapper đó đã thêm một lớp gián tiếp, nhưng nó đáng giá vì nó ngăn chặn sự phức tạp của API cũ kỹ đó làm nhiễm độc toàn bộ cơ sở code của chúng tôi.
Sự khác biệt chính là chúng tôi đang che giấu sự phức tạp cần thiết, không phải tạo ra sự phức tạp không cần thiết. Các thứ liên quan đến SOAP phải tồn tại vì đó là cách API hoạt động. Wrapper chỉ đảm bảo rằng nhóm của chúng tôi không phải nghĩ về nó trừ ở một nơi chúng tôi thực sự nói chuyện với API.
Các trừu tượng cũng có cơ sở khi bạn cần hỗ trợ nhiều triển khai của những thứ thực sự khác nhau. Không phải các triển khai tương lai giả định—các triển khai thực tế, có thật ngay bây giờ. Nếu bạn đang xây dựng một hệ thống thanh toán và bạn cần hỗ trợ cả Stripe và PayPal, thì vâng, bạn có thể muốn một trừu tượng thanh toán. Nếu bạn đang xây dựng một hệ thống lưu trữ và bạn cần hỗ trợ cả S3 và hệ thống tệp cục bộ, thì vâng, bạn muốn một trừu tượng lưu trữ.
Nhưng hãy chú ý từ khóa ở đó: cần. Không phải “có thể cần vào một ngày nào đó” hay “có thể cần về mặt lý thuyết” hay “sẽ tuyệt nếu hỗ trợ”. Bạn cần hỗ trợ nó ngay bây giờ, bạn có người dùng thực tế yêu cầu nó, và việc xây dựng nó là không thể thương lượng. Trong những trường hợp đó, trừu tượng là bạn của bạn. Nhưng ngay cả khi đó, tôi vẫn sẽ lập luận cho việc xây dựng triển khai đầu tiên trực tiếp, sau đó xây dựng triển khai thứ hai trực tiếp, và chỉ sau đó mới trích xuất trừu tượng một khi bạn có thể thấy chúng thực sự có điểm chung gì.
Quy tắc Ba Lần trong Thực tế
Mẫu mà tôi đã định hình là cái tôi gọi là Quy tắc Ba Lần. Khi tôi thấy một cái gì đó một lần, tôi viết nó trực tiếp. Khi tôi thấy nó hai lần, tôi quan sát cẩn thận để xem nó có thực sự là cùng một thứ hay chỉ là sự trùng hợp. Khi tôi thấy nó ba lần, và tôi tự tin rằng đó là cùng một khái niệm, thì tôi trích xuất một trừu tượng. Điều này buộc tôi phải đợi cho đến khi tôi có đủ thông tin để xây dựng trừu tượng đúng, và nó ngăn tôi trừu tượng hóa những thứ tình cờ trông tương tự.
Một dấu hiệu nữa cho thấy trừu tượng hóa có cơ sở: khi chi phí của việc không trừu tượng hóa cao hơn chi phí của việc xây dựng trừu tượng sai. Nếu việc trùng lặp code có nghĩa là trùng lặp logic nghiệp vụ phức tạp có khả năng thay đổi, và các thay đổi cần phải xảy ra nhất quán ở mọi nơi, thì trừu tượng hóa có thể đáng giá ngay cả khi bạn không chắc chắn 100% mình có trừu tượng đúng. Nhưng điều này hiếm khi xảy ra. Hầu hết thời gian, việc trùng lặp code an toàn hơn là trừu tượng hóa sớm.
Cách Các Kỹ Sư Giỏi Thực Sự Tư Duy Về Các Đánh Đổi
Điều làm nên sự khác biệt giữa các kỹ sư giỏi và các kỹ sư bình thường không phải là kiến thức kỹ thuật hay kinh nghiệm nhiều năm hay thậm chí là trí thông minh bẩm sinh. Đó là khả năng tư duy rõ ràng về các đánh đổi. Mọi quyết định kỹ thuật bạn đưa ra đều có chi phí và lợi ích, và phần khó không phải là biết best practice là gì—mà là biết khi nào nên tuân theo best practice và khi nào nên cố tình vi phạm chúng vì chi phí vượt quá lợi ích.
Vượt qua tư duy “đúng-sai”
Tôi thấy các kỹ sư junior tiếp cận các vấn đề như thể có một câu trả lời đúng và một câu trả lời sai. Tôi có nên sử dụng kiến trúc microservices không? Tôi có nên viết unit test không? Tôi có nên sử dụng TypeScript hay JavaScript? Và họ đang tìm kiếm ai đó để nói cho họ câu trả lời đúng để họ có thể làm điều đúng. Nhưng đó không phải là cách nó hoạt động. Câu trả lời luôn là “tùy thuộc”, và điều nó tùy thuộc vào là ngữ cảnh cụ thể bạn đang làm việc.
Các kỹ sư giỏi đặt những câu hỏi khác. Thay vì “Tôi có nên xây dựng cái này như một dịch vụ không?” họ hỏi “Việc xây dựng cái này như một dịch vụ sẽ giải quyết những vấn đề gì? Nó sẽ tạo ra những vấn đề gì? Những vấn đề nó giải quyết có quan trọng hơn những vấn đề nó tạo ra không, xét theo tình hình hiện tại của chúng ta?” Họ suy nghĩ theo các thuật ngữ chi phí và lợi ích, không phải quy tắc và best practice.
Hãy cho bạn một ví dụ cụ thể. Vài năm trước, tôi đang làm việc trên một tính năng cần gửi thông báo email khi các sự kiện nhất định xảy ra trong hệ thống. Cách “đúng” để xây dựng điều này sẽ là sử dụng một message queue. Xuất bản các sự kiện vào hàng đợi, có một tiến trình worker tiêu thụ chúng và gửi email, đảm bảo giao hàng và logic thử lại và tất cả những điều tốt đẹp đó.
Nhưng chúng tôi chưa có một message queue nào được thiết lập. Việc thiết lập một cái sẽ có nghĩa là chọn một công nghệ, triển khai nó, học cách vận hành nó, và xây dựng tất cả cơ sở hạ tầng xung quanh nó. Điều đó sẽ mất ít nhất một tuần, có lẽ hai tuần. Trong khi đó, tính năng cần được triển khai vào cuối sprint, và việc trì hoãn nó có nghĩa là trì hoãn doanh thu.
Vì vậy, thay vào đó, tôi chỉ gửi email trực tiếp trong request handler. Tôi bao bọc việc gửi email trong một try-catch và ghi lại các lỗi để chúng tôi có thể thử lại chúng thủ công nếu cần. Nó không mạnh mẽ, nó không có khả năng mở rộng, nó không phải là thứ bạn sẽ đọc trong một bài blog về kiến trúc hướng sự kiện. Nhưng nó hoạt động, nó được triển khai đúng thời hạn, và chúng tôi có thể giám sát xem email có bị lỗi không để xem chúng tôi có cần đầu tư vào một cái gì đó mạnh mẽ hơn không.
Sáu tháng sau, chúng tôi vẫn không gặp vấn đề gì với việc gửi email. Chúng tôi đã gửi hàng chục nghìn email, và có lẽ một vài cái đã thất bại do lỗi API tạm thời, mà chúng tôi đã bắt được trong log và thử lại thủ công. Cuối cùng chúng tôi đã triển khai một hệ thống hàng đợi phù hợp, nhưng vào thời điểm đó chúng tôi có những lý do tốt hơn cho nó—chúng tôi cần đảm bảo giao hàng cho các thông báo thanh toán, không chỉ các email marketing “nice-to-have”.
Đó là tư duy đánh đổi. Kiến trúc “đúng” sẽ trì hoãn việc triển khai và sử dụng thời gian kỹ thuật mà chúng tôi không có. Kiến trúc “sai” đã được triển khai đúng thời hạn và đủ tốt cho vấn đề thực tế mà chúng tôi đang giải quyết. Một kỹ sư junior có thể cảm thấy tội lỗi về technical debt. Một kỹ sư giỏi nhận ra rằng technical debt là một công cụ—bạn có thể cố tình chấp nhận nó khi lợi ích vượt quá chi phí, và trả nó sau khi nó bắt đầu gây ra vấn đề.
Quản lý sự không chắc chắn và ưu tiên linh hoạt
Tư duy đánh đổi cũng có nghĩa là trung thực về những gì bạn không biết. Khi bạn đưa ra các quyết định kiến trúc, bạn đang đưa ra dự đoán về tương lai. Bạn đang dự đoán cách hệ thống sẽ được sử dụng, cách nó sẽ cần mở rộng quy mô, những tính năng nào sẽ được thêm vào. Và các dự đoán thường sai, đặc biệt là ở giai đoạn đầu đời của sản phẩm khi bạn vẫn đang tìm hiểu những gì bạn đang xây dựng.
Bạn càng không chắc chắn về tương lai, bạn càng nên tối ưu hóa cho sự linh hoạt hơn là sự hoàn hảo. Đừng khóa mình vào những quyết định khó thay đổi. Đừng xây dựng các trừu tượng giả định bạn biết mọi thứ sẽ phát triển như thế nào. Xây dựng những thứ dễ sửa đổi khi bạn học được thông tin mới.
Tôi đã làm việc với các kỹ sư quá tập trung vào việc xây dựng kiến trúc “đúng” đến nỗi họ không thể triển khai bất cứ thứ gì vì họ bị tê liệt bởi sự không chắc chắn về các yêu cầu trong tương lai. Tôi cũng đã làm việc với các kỹ sư quá tập trung vào việc triển khai đến nỗi họ không bao giờ nghĩ về ngày mai và tạo ra một mớ hỗn độn đến nỗi mỗi tính năng mới mất nhiều thời gian hơn để xây dựng. Các kỹ sư giỏi tìm thấy sự cân bằng. Họ triển khai nhanh chóng, nhưng họ triển khai những thứ có thể sửa đổi khi cần thiết.
Phân loại mức độ quan trọng
Một khía cạnh khác của tư duy đánh đổi là hiểu rằng các phần khác nhau của hệ thống của bạn có các yêu cầu khác nhau. Code xác thực của bạn cần phải cực kỳ vững chắc và được kiểm thử kỹ lưỡng vì nếu nó bị lỗi, đó là một vấn đề bảo mật. Bảng điều khiển quản trị nội bộ mà ba người sử dụng có thể hơi “hacky” và thô sơ vì chi phí nếu nó bị lỗi là tối thiểu. Đối xử với tất cả code như nhau về mức độ quan trọng là một sai lầm của người mới.
Tôi từng làm việc trên một hệ thống nơi chúng tôi có cùng tiêu chuẩn review code cho mọi thứ. Logic nghiệp vụ cốt lõi xử lý tiền phải trải qua cùng một quy trình review nghiêm ngặt như các công cụ nội bộ tạo báo cáo. Chúng tôi có test cho mọi thứ, tài liệu cho mọi thứ, tối ưu hóa hiệu suất cho mọi thứ. Điều đó thật mệt mỏi, và nó làm chúng tôi chậm lại một cách đáng kể.
Cuối cùng chúng tôi đã sáng suốt hơn và bắt đầu đối xử với các phần khác nhau của hệ thống một cách khác nhau. Code quan trọng được review kỹ lưỡng và kiểm thử toàn diện. Các công cụ nội bộ được xem qua nhanh và có thể có một số smoke test. Code prototype không được review chút nào—chúng tôi chỉ triển khai nó và sửa lỗi khi chúng phát sinh. Điều này cho phép chúng tôi di chuyển nhanh hơn rất nhiều mà không thực sự tăng tỷ lệ lỗi, bởi vì chúng tôi đang đặt nỗ lực vào nơi nó quan trọng nhất.
Sẵn sàng thay đổi quyết định
Tư duy đánh đổi cũng có nghĩa là sẵn lòng thay đổi suy nghĩ. Quyết định đúng hôm nay có thể là quyết định sai trong sáu tháng tới khi ngữ cảnh đã thay đổi. Và điều đó không sao cả. Các kỹ sư giỏi không gắn bó với các quyết định kiến trúc của họ—họ gắn bó với việc giải quyết vấn đề. Nếu một quyết định ngừng hoạt động, họ sẽ thay đổi nó.
Tôi từng xây dựng một kiến trúc hướng dịch vụ cho một dự án, và nó có ý nghĩa vào thời điểm đó—chúng tôi có nhiều đội làm việc trên các phần khác nhau của sản phẩm, và các dịch vụ mang lại cho chúng tôi ranh giới quyền sở hữu rõ ràng. Nhưng sau đó đội ngũ nhỏ lại, sản phẩm trưởng thành, và đột nhiên chi phí duy trì nhiều dịch vụ không còn đáng giá nữa. Vì vậy, chúng tôi đã hợp nhất một số dịch vụ trở lại thành một monolith. Một số người có thể coi đó là thừa nhận thất bại, nhưng tôi coi đó là phản ứng thích hợp với những thay đổi về hoàn cảnh.
“Overengineering” Biểu Hiện Khác Nhau Theo Quy Mô Đội Ngũ
Lời khuyên tôi đã đưa ra cho đến nay là chung chung, nhưng thực tế là điều có ý nghĩa đối với một lập trình viên độc lập khác với điều có ý nghĩa đối với một đội mười người, điều này lại khác với điều có ý nghĩa đối với một công ty một trăm người. Hãy cùng tìm hiểu những gì tôi đã học được về từng quy mô đội ngũ.
Kỹ sư độc lập hoặc nhóm rất nhỏ (1-3 người)
Đây là nơi bạn có nhiều tự do nhất để di chuyển nhanh và cần tránh overengineering nhất. Bạn không có thời gian để xây dựng cơ sở hạ tầng vì bạn đang làm mọi thứ—viết code, nói chuyện với người dùng, sửa lỗi, triển khai, marketing, mọi thứ. Điều duy nhất quan trọng là triển khai các tính năng giải quyết vấn đề cho người dùng.
- Giữ mọi thứ trong một monolith.
- Sử dụng công nghệ “nhàm chán” mà bạn đã biết.
- Đừng xây dựng trừu tượng cho đến khi bạn đã viết cùng một code ít nhất ba lần.
- Đừng viết test trừ khi có gì đó thường xuyên bị lỗi hoặc bạn đang thực hiện những thay đổi khiến bạn sợ hãi.
- Đừng nghĩ về quy mô cho đến khi bạn có người dùng.
- Đừng xây dựng bảng quản trị cho đến khi bạn cần—hãy sử dụng truy vấn cơ sở dữ liệu thay thế.
- Đừng xây dựng tự động hóa triển khai cho đến khi bạn triển khai đủ thường xuyên đến mức gây khó chịu.
- Làm mọi thứ theo cách đơn giản nhất có thể.
Tôi đã triển khai toàn bộ sản phẩm dưới dạng các tệp đơn lẻ, với tất cả HTML, CSS, JavaScript và logic backend trộn lẫn, bởi vì tôi là người duy nhất làm việc trên đó và tôi có thể di chuyển nhanh hơn rất nhiều mà không bị gánh nặng của sự phân tách thích hợp. Nó có xấu không? Chắc chắn rồi. Nó có hoạt động không? Có. Nó có kiếm tiền không? Cũng có. Và một khi nó kiếm tiền và tôi có bằng chứng rằng ý tưởng hoạt động, thì tôi có thể dành thời gian để tái cấu trúc nó thành một cái gì đó có thể duy trì được.
Cái bẫy mà các lập trình viên độc lập mắc phải là nghĩ rằng họ cần xây dựng như một công ty lớn. Họ đọc về cách Google làm mọi thứ và cố gắng sao chép nó, mặc dù Google có hàng nghìn kỹ sư và họ chỉ có một. Đừng làm điều này. Xây dựng thứ đơn giản nhất có thể hoạt động, triển khai nó, và lặp lại dựa trên những gì bạn học được.
Nhóm nhỏ (4-10 người)
Đây là nơi bạn bắt đầu cần một số cấu trúc, nhưng không quá nhiều. Bạn không thể giữ mọi thứ trong đầu nữa vì có những người khác cần hiểu code. Bạn cần một số quy ước để không phải tranh cãi liên tục về phong cách code. Bạn cần một số test để không phải liên tục phá hỏng công việc của nhau.
- Bạn vẫn đủ nhỏ để không cần quy trình nặng nề.
- Bạn không cần microservices—một monolith được tổ chức tốt với các module rõ ràng dễ làm việc hơn rất nhiều.
- Bạn không cần pipeline triển khai phức tạp—một script chạy test và đẩy lên production là ổn.
- Bạn không cần review kiến trúc chính thức—chỉ cần nhờ người khác xem code của bạn trước khi merge.
Đây là giai đoạn bạn nên bắt đầu suy nghĩ về tổ chức code và khả năng duy trì, nhưng bạn vẫn nên ưu tiên sự đơn giản. Trích xuất các hàm khi bạn thấy sự trùng lặp. Viết test cho những thứ thường xuyên bị lỗi. Tài liệu hóa những thứ không rõ ràng. Nhưng đừng xây dựng cơ sở hạ tầng cho những vấn đề bạn chưa có.
Cái bẫy mà các đội nhỏ mắc phải là mở rộng quy mô sớm. Họ đang phát triển, họ đang tuyển dụng, và ai đó đọc về cách Netflix làm mọi thứ và quyết định họ cần tổ chức lại mọi thứ thành microservices trước khi họ mở rộng quy mô. Nhưng họ không phải Netflix, họ chỉ có mười người, và việc chia thành microservices sẽ làm họ chậm lại gấp ba lần. Đợi cho đến khi monolith thực sự gây ra vấn đề—cho đến khi việc điều phối triển khai trở thành một mớ hỗn độn, cho đến khi các phần khác nhau của hệ thống cần các đặc điểm mở rộng khác nhau, cho đến khi các đội giẫm chân lên nhau. Sau đó hãy chia nhỏ một cách cẩn trọng, không phải phòng ngừa.
Công ty đang phát triển (10-50 người)
Đây là nơi mọi thứ trở nên thú vị. Bạn đủ lớn để việc phối hợp trở thành một vấn đề thực sự. Bạn không thể có mọi người trong cùng một cơ sở code nữa vì xung đột merge là liên tục và việc điều phối triển khai là một cơn ác mộng. Bạn cần suy nghĩ về ranh giới đội ngũ và quyền sở hữu.
- Đây là nơi các quyết định kiến trúc bắt đầu quan trọng hơn.
- Bạn có thể thực sự cần chia monolith của mình thành các dịch vụ, nhưng hãy làm điều đó dựa trên ranh giới đội ngũ, không phải ranh giới kỹ thuật.
- Nếu bạn có một đội làm việc trên thanh toán và một đội làm việc trên sản phẩm cốt lõi, hãy cấp cho mỗi đội dịch vụ riêng của họ.
- Nếu bạn có một thành phần cần mở rộng khác với mọi thứ khác, hãy tách nó ra.
Nhưng ngay cả ở quy mô này, đơn giản vẫn tốt hơn. Đừng xây dựng service mesh trừ khi bạn thực sự gặp vấn đề mạng. Đừng xây dựng pipeline CI/CD phức tạp trừ khi việc triển khai đơn giản đang gây ra khó khăn thực sự. Đừng chuẩn hóa mọi thứ giữa các đội—hãy để các đội đưa ra quyết định cục bộ về những thứ của riêng họ miễn là chúng tích hợp sạch sẽ với mọi người khác.
Cái bẫy mà các công ty đang phát triển mắc phải là áp dụng các thực hành của công ty lớn quá sớm. Họ bắt đầu yêu cầu tài liệu thiết kế cho mọi thay đổi, triển khai các quy trình RFC chính thức, chuẩn hóa các công nghệ cụ thể trên tất cả các đội. Điều này tạo ra gánh nặng làm chậm mọi thứ mà không thực sự giải quyết các vấn đề họ chưa có. Mục tiêu ở giai đoạn này nên là tạo điều kiện cho các đội di chuyển độc lập, không phải tạo ra sự nhất quán vì mục đích riêng của nó.
Tổ chức lớn (50+ người)
Tôi có ít kinh nghiệm hơn ở đây, nhưng theo những gì tôi đã thấy, đây là nơi bạn thực sự cần các thực hành mà các đội nhỏ hơn nghĩ rằng họ cần. Bạn cần review kiến trúc vì những quyết định tồi ảnh hưởng đến quá nhiều đội. Bạn cần chuẩn hóa vì việc hỗ trợ năm mươi lựa chọn công nghệ khác nhau là không thể. Bạn cần các quy trình chính thức vì giao tiếp không chính thức không mở rộng quy mô đến mức này.
Nhưng ngay cả ở đây, nguyên tắc vẫn áp dụng: xây dựng cho những vấn đề bạn thực sự có. Nếu các dịch vụ của bạn ổn định và hiếm khi thay đổi, bạn không cần tự động hóa triển khai phức tạp. Nếu lưu lượng truy cập của bạn có thể dự đoán được, bạn không cần tự động mở rộng quy mô phức tạp. Nếu các đội của bạn không giẫm chân lên nhau, bạn không cần gánh nặng phối hợp nặng nề.
Tái Cấu Trúc Sau Khi Triển Khai, Không Phải Trước
Đây có lẽ là sự thay đổi tư duy quan trọng nhất: hãy ngừng cố gắng làm cho mọi thứ hoàn hảo trước khi bạn triển khai, và hãy thoải mái với việc tái cấu trúc sau khi bạn triển khai. Tôi đã lãng phí nhiều năm trong sự nghiệp của mình để cố gắng xây dựng các hệ thống hoàn hảo ngay từ đầu, và tôi ước ai đó đã nói với tôi sớm hơn rằng tái cấu trúc là một phần bình thường, lành mạnh của quá trình phát triển.
Thông tin quý giá sau khi triển khai
Đây là vấn đề về tái cấu trúc sau khi triển khai: bạn có nhiều thông tin hơn rất nhiều. Bạn biết người dùng thực sự làm gì với tính năng của bạn. Bạn biết phần nào của code thay đổi thường xuyên và phần nào ổn định. Bạn biết các điểm nghẽn hiệu suất thực sự ở đâu. Bạn biết những trừu tượng nào thực sự hữu ích thay vì những trừu tượng bạn tưởng tượng có thể hữu ích.
Tôi từng triển khai một tính năng mà tôi nghĩ sẽ được sử dụng chủ yếu bởi những người dùng thành thạo thực hiện các quy trình làm việc phức tạp. Vì vậy, tôi đã xây dựng một hệ thống cấu hình linh hoạt và rất nhiều tùy chọn và phím tắt và tất cả những thứ này. Hóa ra, 90% người dùng chỉ muốn nhấp vào một nút để làm điều phổ biến nhất. Tất cả sự linh hoạt của tôi chỉ làm họ bối rối. Nếu tôi đợi tái cấu trúc cho đến sau khi triển khai, tôi sẽ thấy điều này ngay lập tức và xây dựng một cái gì đó đơn giản hơn nhiều.
Nỗi sợ hãi, tất nhiên, là nếu bạn triển khai một cái gì đó đơn giản và lộn xộn, bạn sẽ không bao giờ có thời gian quay lại và dọn dẹp nó. Và đôi khi điều đó đúng. Nhưng bạn biết không? Nếu bạn không bao giờ cần quay lại và sửa đổi đoạn code đó, thì sự lộn xộn không quan trọng. Nó hoạt động, nó đang giải quyết một vấn đề, và bạn được tự do làm những việc khác. Đoạn code duy nhất cần sạch là đoạn code bạn sẽ thay đổi.
Tôi hiện có code đang chạy trên production mà tôi đã viết ba năm trước và chưa bao giờ chạm vào kể từ đó. Nó xấu xí. Nó có các giá trị hard-code. Nó có các điều kiện lồng nhau sâu sáu cấp. Nhưng bạn biết không? Nó hoạt động hoàn hảo, nó chưa có lỗi nào trong ba năm, và không ai cần hiểu hay sửa đổi nó. Đoạn code xấu xí đó đã thành công hơn rất nhiều code đẹp mắt mà tôi đã viết nhưng phải viết lại ba lần vì tôi đang cố gắng trừu tượng hóa những thứ tôi chưa hiểu.
Thực hiện tái cấu trúc một cách thông minh
Chìa khóa để tái cấu trúc sau khi triển khai hoạt động là có độ bao phủ test tốt cho những thứ bạn sẽ thay đổi. Lưu ý tôi nói “những thứ bạn sẽ thay đổi”, không phải “mọi thứ”. Bạn không cần độ bao phủ test 100% cho toàn bộ cơ sở code của mình. Bạn cần độ bao phủ tốt cho những phần thay đổi thường xuyên hoặc rất quan trọng đối với doanh nghiệp của bạn. Đối với mọi thứ khác, kiểm thử thủ công là ổn.
Khi bạn tái cấu trúc, hãy làm từng bước một. Đừng viết lại toàn bộ hệ thống—hãy tái cấu trúc từng phần một, triển khai nó, đảm bảo nó hoạt động, sau đó chuyển sang phần tiếp theo. Các bản viết lại “big-bang” hầu như luôn thất bại vì bạn đang thay đổi quá nhiều thứ cùng một lúc và bạn mất khả năng cô lập vấn đề. Tái cấu trúc nhỏ, tăng dần an toàn hơn và bạn có thể dừng lại bất cứ lúc nào nếu các ưu tiên thay đổi.
Tôi từng tái cấu trúc một phần code xác thực phức tạp bằng cách viết một phiên bản mới song song với phiên bản cũ, dần dần chuyển người dùng sang với một feature flag, giám sát lỗi, và chỉ loại bỏ phiên bản cũ sau khi tôi tự tin rằng phiên bản mới hoạt động. Nó mất ba tuần thay vì ba ngày mà một bản viết lại trực tiếp sẽ mất, nhưng tôi không bao giờ gặp sự cố sản xuất và tôi có thể quay lại bất cứ lúc nào.
Lợi thế khác của việc tái cấu trúc sau khi triển khai là bạn có thể đo lường xem việc tái cấu trúc thực sự có giúp ích hay không. Nếu bạn tái cấu trúc để cải thiện hiệu suất, bạn có thể đo lường sự cải thiện hiệu suất. Nếu bạn tái cấu trúc để làm cho code dễ thay đổi hơn, bạn có thể đo lường xem các tính năng mới có thực sự được xây dựng nhanh hơn không. Nếu việc tái cấu trúc không giúp ích, bạn đã học được điều gì đó về những gì không hoạt động. Nếu bạn tái cấu trúc trước khi triển khai, bạn không có cơ sở để so sánh.
Điều này không có nghĩa là bạn nên triển khai rác rưởi hoàn toàn và sửa nó sau. Bạn vẫn nên viết code hoạt động, mà bạn hiểu, mà bạn có thể gỡ lỗi. Nhưng có một sự khác biệt rất lớn giữa “code hoạt động và hơi lộn xộn” và “code được kiến trúc cho mọi yêu cầu tương lai có thể”. Hãy triển khai cái trước, tái cấu trúc sang cái sau chỉ khi bạn biết những yêu cầu tương lai nào thực sự quan trọng.
Dũng Khí Để Triển Khai Một Giải Pháp Đơn Giản
Tôi muốn kết thúc với có lẽ là phần khó nhất trong tất cả những điều này: tìm thấy dũng khí để triển khai một cái gì đó đơn giản khi bản năng của bạn đang nói với bạn rằng hãy làm cho nó hoàn hảo trước. Điều này là về tâm lý, không phải kỹ thuật. Đó là về việc vượt qua nỗi sợ hãi rằng nếu bạn triển khai một cái gì đó không hoàn hảo, mọi người sẽ nghĩ bạn là một kỹ sư tồi.
Ai thực sự quan tâm đến code của bạn?
Đây là điều tôi đã học được: không ai quan tâm code của bạn thanh lịch đến mức nào ngoại trừ các lập trình viên khác, và hầu hết họ quá bận với công việc của riêng họ để phán xét bạn. Người dùng quan tâm liệu thứ đó có hoạt động và giải quyết vấn đề của họ không. Sếp của bạn quan tâm liệu bạn có đang triển khai các tính năng giúp ích cho doanh nghiệp không. Bản thân bạn trong tương lai quan tâm liệu code có dễ sửa đổi khi các yêu cầu thay đổi không.
Không ai trong số các bên liên quan này được hưởng lợi từ việc bạn dành thêm một tuần để xây dựng các trừu tượng cho các yêu cầu chưa tồn tại. Không ai trong số họ được hưởng lợi từ việc bạn kiến trúc một hệ thống có thể về mặt lý thuyết mở rộng quy mô cho hàng triệu người dùng khi bạn chỉ có hàng trăm. Không ai trong số họ được hưởng lợi từ việc bạn triển khai mọi mẫu thiết kế bạn đã học trong cuốn sách đó.
Tôi từng nghĩ rằng việc triển khai code đơn giản là đi tắt, cắt giảm, không chuyên nghiệp. Bây giờ tôi hiểu rằng việc triển khai code đơn giản là thể hiện sự phán đoán tốt. Đó là hiểu các ràng buộc bạn đang làm việc và đưa ra các đánh đổi thông minh. Đó là có kỷ luật để giải quyết vấn đề trước mắt bạn thay vì những vấn đề tưởng tượng bạn có thể phải đối mặt vào một ngày nào đó.
Code tốt nhất tôi từng viết là code mà tôi cảm thấy gần như quá đơn giản khi tôi viết nó. Code mà tôi nghĩ, “Cái này không thể đúng, nó quá thẳng thắn.” Code mà tôi phải chống lại sự thôi thúc làm cho nó phức tạp hơn vì chắc chắn vấn đề phức tạp hơn thế này. Và sau đó tôi triển khai nó, và nó hoạt động, và tôi chuyển sang việc tiếp theo.
Đó là sự thay đổi tư duy đã thay đổi sự nghiệp của tôi: ngừng tối ưu hóa cho việc code của bạn trông ấn tượng như thế nào và bắt đầu tối ưu hóa cho tốc độ bạn có thể học hỏi liệu bạn có đang xây dựng đúng thứ hay không. Bởi vì cuối cùng, code duy nhất quan trọng là code giải quyết các vấn đề thực tế cho người thật. Mọi thứ khác chỉ là sự tự thỏa mãn.
Vì vậy, hãy triển khai các giải pháp đơn giản của bạn. Triển khai các monolith của bạn. Triển khai các giá trị hard-code của bạn và code trực tiếp và các trừu tượng còn thiếu của bạn. Triển khai chúng, học hỏi từ chúng, và tái cấu trúc khi bạn thực sự cần. Bởi vì hệ thống hoàn hảo bạn đang tưởng tượng không tồn tại, tương lai bạn đang xây dựng sẽ không đến theo cách bạn mong đợi, và cách duy nhất để thực sự tìm ra thứ gì cần xây dựng là xây dựng một cái gì đó và xem điều gì xảy ra.
Và khi ai đó nói với bạn rằng code của bạn không sẵn sàng cho doanh nghiệp hoặc không tuân theo best practice hoặc sẽ không mở rộng quy mô hoặc cần nhiều trừu tượng hơn—hãy mỉm cười, gật đầu, và nhớ rằng code của họ có lẽ cũng chưa được triển khai.



