Mã nguồn kế thừa, hay “legacy codebase”, là một thách thức không mấy dễ chịu đối với bất kỳ nhà phát triển nào. Nó giống như một cuốn sách cổ viết bằng thứ ngôn ngữ bạn không quen, mà không có chú giải hay mục lục. Tuy nhiên, thay vì ghét bỏ, chúng ta có thể tiếp cận nó với tư duy của một nhà khảo cổ học: khai quật, phân tích và phục hồi giá trị.
Hãy tưởng tượng một dự án phi thường: một nhà phát triển có tên xavxav đang tái tạo lại PP-BESM, trình biên dịch ngôn ngữ cấp cao đầu tiên của Liên Xô từ những năm 1950, được thiết kế bởi Andrey Ershov. Không phải mô phỏng, mà là tái xây dựng từng dòng mã từ những bản scan giấy cũ, độ phân giải thấp. Đây là ví dụ cực đoan nhất cho vấn đề “tôi không thể đọc hiểu mã nguồn này” mà bạn có thể gặp phải. Cùng một hình dáng, chỉ là bụi thời gian dày đặc hơn. Chính tác giả của dự án PP-BESM đã xuất bản một bài viết mà, khi gạt bỏ bối cảnh Chiến tranh Lạnh, nó lại chính là cẩm nang sạch sẽ nhất về khảo cổ học mã nguồn kế thừa mà tôi từng đọc trong nhiều năm qua.
Bài viết này là phiên bản tổng quát hóa của cẩm nang đó, cung cấp các kỹ thuật bạn có thể áp dụng ngay trong tuần này cho bất kỳ kho mã nguồn PHP, COBOL, Perl, hay Java 6 nào mà bạn đang “thừa kế”.
Mục lục
Tóm Tắt Nhanh: 7 Bước Phục Hồi Mã Nguồn Cũ
Không cần đọc hết toàn bộ bài viết, bạn có thể nắm bắt nhanh lộ trình khảo cổ mã nguồn kế thừa qua 7 giai đoạn thiết yếu sau đây. Thứ tự các bước này rất quan trọng và không nên bỏ qua:
- Xác Định Ranh Giới (Boundaries): Phác thảo các đầu vào, đầu ra, và tác động phụ của hệ thống. Bạn không thể hiểu bên trong nếu chưa biết bên ngoài.
- Xây Dựng Môi Trường Chạy Độc Lập (Harness): Tạo ra một cách để chạy mã nguồn trong môi trường biệt lập. Vòng lặp chạy code là trò chơi cốt lõi.
- Phương Pháp Phân Đôi (Bisection): Thu hẹp tìm kiếm vào 10% phần mã chịu tải chính. Hầu hết mã nguồn chỉ là “chất kết dính”.
- Đặt Tên Rõ Ràng (Naming): Đổi tên có hệ thống ngay khi bạn hiểu được chức năng. Đây là những ghi chú bạn để lại cho chính mình trong tương lai.
- Thêm Kiểu Dữ Liệu (Types): Bổ sung kiểu dữ liệu nếu thiếu, dù chỉ là những kiểu lỏng lẻo. Kiểu dữ liệu là tài liệu có thể chạy được.
- Kiểm Thử Là Sự Thật Khách Quan (Tests as Ground Truth): Viết các bài kiểm thử để khóa chặt hành vi hiện có, kể cả những hành vi bạn không thích. Tái cấu trúc mà không có kiểm thử là một câu chuyện viễn tưởng.
- Bình Luận Về Quyết Định (Document Negotiations): Ghi chú lý do đằng sau một quyết định, không phải cái gì mà đoạn mã đang làm. Lý do là thứ thời gian dễ dàng xóa nhòa nhất.
Việc bỏ qua bất kỳ bước nào có thể khiến các nhóm phát triển mất sáu tháng vào việc “hiện đại hóa” và kết thúc với một phiên bản tồi tệ hơn của hệ thống ban đầu.
1. Xác Định Ranh Giới Trước Khi Đi Sâu Vào Nội Bộ
Động thái đầu tiên khi tiếp cận một mã nguồn xa lạ không phải là đọc code. Mà là vẽ ra ranh giới của nó. Đây là bước khảo sát địa hình ban đầu, giúp bạn hình dung bức tranh tổng thể trước khi đào sâu vào từng ngóc ngách.
* Đối với một dịch vụ web: những tuyến đường HTTP nào tồn tại, mỗi tuyến trả về gì, những bảng cơ sở dữ liệu nào bị ảnh hưởng, những API bên ngoài nào được gọi, những gì được ghi vào ổ đĩa, những sự kiện nào được kích hoạt.
* Đối với một CLI (giao diện dòng lệnh): nó chấp nhận những đối số nào, đọc những tệp nào, ghi những gì, và ma trận mã thoát (exit code) là gì.
* Đối với một thư viện: API công khai là gì, nó phụ thuộc vào cái gì, và nó có “monkey-patch” (thay đổi hành vi của các đối tượng hoặc lớp trong runtime) bất kỳ thành phần nào không.
Bạn hoàn toàn có thể thực hiện bước này mà không cần hiểu một hàm nào bên trong mã. Các công cụ hỗ trợ:
# Các tuyến đường HTTP cho dịch vụ Node.js
grep -rE "router\.(get|post|put|delete)|app\.(get|post)" --include="*.{js,ts}" src/
# Các bảng cơ sở dữ liệu bị ảnh hưởng
grep -rE "FROM|UPDATE|INSERT INTO|DELETE FROM" --include="*.{sql,js,ts,py}" .
# Các cuộc gọi API bên ngoài
grep -rE "axios|fetch\(|http\.request" --include="*.{js,ts}" src/
# Các tệp được đọc hoặc ghi
grep -rE "fs\.(read|write)|open\(" --include="*.{js,ts,py}" .
Hãy ghi lại các câu trả lời này. Đây là bản đồ của bạn. Bạn không thể hiểu được bên trong cho đến khi bạn biết “những cánh cửa” dẫn vào hệ thống ở đâu. Trong dự án PP-BESM, ranh giới chính là mô hình máy BESM. Không thể đọc một trình biên dịch năm 1955 mà không biết tập lệnh của máy mà nó nhắm đến. xavxav đã tái tạo lại điều đó từ một bộ tài liệu riêng biệt trước khi chạm vào mã nguồn trình biên dịch.
2. Xây Dựng Môi Trường Chạy Độc Lập (Harness) – Dù Đơn Giản Nhất
Động thái mang lại lợi ích cao nhất trên một mã nguồn kế thừa, với một khoảng cách lớn, là làm cho bất kỳ phiên bản nào của mã chạy được trong môi trường biệt lập, với một đầu vào và một đầu ra có thể quan sát được, trước khi bạn cố gắng hiểu bất kỳ điều gì về nó.
* Đối với một dịch vụ web, điều đó có nghĩa là một tệp `docker-compose` có thể khởi động ứng dụng và cơ sở dữ liệu của nó bằng một lệnh duy nhất, kèm theo một lệnh `curl` để kiểm tra một tuyến đường cụ thể.
* Đối với một CLI, điều đó có nghĩa là một dòng lệnh chạy thực thi với một đầu vào đại diện và chuyển hướng đầu ra đến nơi bạn có thể đọc được.
* Đối với một thư viện, điều đó có nghĩa là một chương trình nhỏ chỉ năm dòng nhập thư viện và gọi hàm bạn quan tâm.
Nếu bước này không thể thực hiện được, phần còn lại của quá trình kiểm toán cũng sẽ không thể. Hãy dành một ngày để xây dựng môi trường chạy thử (harness) này. Nó chính là “vòng lặp” của bạn.
# Một môi trường chạy thử tối thiểu cho một script Python cũ
mkdir -p harness
cat > harness/run.sh <<'EOF'
#!/bin/bash
cd "$(dirname "$0")/.."
python3 ./scary_script.py --input fixtures/sample.csv > /tmp/out.txt
diff /tmp/out.txt fixtures/expected.txt
EOF
chmod +x harness/run.sh
Giờ đây, bạn có một vòng lặp chỉ với một lệnh. Mọi thay đổi bạn thực hiện từ đây có thể được kiểm tra bằng `harness/run.sh`. Môi trường chạy thử này chính là lưới an toàn của bạn. Đối với PP-BESM, xavxav đã xây dựng máy ảo BESM. Mọi thay đổi đối với trình biên dịch có thể được kiểm tra bằng cách chạy một chương trình nhỏ thời Liên Xô bên trong máy ảo và quan sát kết quả. Máy ảo đó còn quan trọng hơn bất kỳ phần nào của mã nguồn trình biên dịch.
3. Phương Pháp Phân Đôi (Bisection): Định Vị Lõi Của Hệ Thống
Bản năng khi tiếp cận một mã nguồn mới là đọc từ điểm khởi đầu và theo dõi biểu đồ cuộc gọi. Điều này gần như sai trong mọi trường hợp. Hầu hết mã nguồn kế thừa chỉ là “chất kết dính” (glue code). Logic thú vị, phần thực sự thực hiện công việc, nằm trong khoảng 10 đến 20 phần trăm các tệp. 80 đến 90 phần trăm còn lại chỉ đơn thuần là chuyển dữ liệu giữa các phần thú vị đó.
Cách nhanh nhất để tìm ra các phần thú vị là sử dụng phương pháp phân đôi (bisection). Bạn sẽ dần dần loại bỏ các phần ít quan trọng hơn.
# Những gì đã tác động đến cơ sở dữ liệu trong năm qua?
git log --since="1 year ago" --name-only --pretty=format: \
| grep -E "schema|migration|model" | sort -u
# Các tệp dài nhất nằm ở đâu? (Dài thường có nghĩa là thú vị)
find . -name "*.py" -not -path "*/node_modules/*" \
-exec wc -l {} \; | sort -rn | head -20
# Module nào được import nhiều nhất? (Import nhiều thường có nghĩa là chịu tải chính)
grep -rE "^import|^from" --include="*.py" . | awk '{print $2}' \
| sort | uniq -c | sort -rn | head -20
Mỗi lệnh trên đều thu hẹp phạm vi tìm kiếm. Tệp dài nhất thường là nơi “đổ rác” cho nhiều logic. Module được import nhiều nhất thường là bộ não thực sự của hệ thống. Các tệp xuất hiện trong mọi migration là những tệp mà schema không thể thiếu. Đối với PP-BESM, mục tiêu phân đôi là PP-3, pha biên dịch cuối cùng. xavxav biết các pha đầu đã được ghi lại tốt hơn trong tài liệu hiện có. Điều thú vị chưa biết là pha cuối cùng, và anh ấy tập trung vào đó trước tiên.
4. Đổi Tên Rõ Ràng Ngay Khi Bạn Hiểu
Mỗi khi bạn hiểu một hàm, hãy đổi tên nó. Mỗi khi bạn hiểu một biến, hãy đổi tên nó. Hãy thực hiện điều này trong một nhánh riêng và commit thường xuyên.
Cám dỗ là đọc toàn bộ mã nguồn trước và đổi tên sau. Điều này là sai lầm. Bạn sẽ quên những gì mình đã hiểu. Bạn sẽ mất hàng giờ ngữ cảnh. Việc đổi tên chính là ghi chú bạn để lại cho chính mình trong tương lai và cho người tiếp theo.
// Trước khi hiểu
function process(x, y) {
const r = x.filter(z => z.s > y).map(z => z.id)
return db.query(r)
}
// Sau khi hiểu, đây là việc lấy ID người dùng hoạt động trên ngưỡng điểm
function fetchActiveUserIdsAboveScore(users, threshold) {
const qualifyingIds = users
.filter(user => user.score > threshold)
.map(user => user.id)
return db.query(qualifyingIds)
}
Một quy tắc tốt là: nếu bạn không thể đổi tên một hàm một cách có ý nghĩa, bạn chưa thực sự hiểu nó. Hãy tiếp tục đọc. Một khi bạn có thể đổi tên nó, hãy làm ngay lập tức, sau đó commit với một thông điệp ghi lại những gì bạn đã học. Việc đổi tên trong dự án PP-BESM của xavxav là một quá trình dịch thuật, nhưng nguyên tắc thì tương tự. Các định danh tiếng Nga trở thành định danh tiếng Anh. Các từ viết tắt ba chữ cái khó hiểu trở thành những từ có nghĩa. Mã nguồn trở nên dễ đọc hơn vì ai đó đã dành thời gian để làm cho nó dễ đọc.
5. Kiểu Dữ Liệu: Tài Liệu Sống Của Mã Nguồn
Nếu mã nguồn được viết bằng ngôn ngữ có kiểu động (dynamically typed), hãy thêm kiểu dữ liệu. Nếu các kiểu dữ liệu sai, hãy sửa chúng. Ngay cả các kiểu dữ liệu lỏng lẻo cũng tốt hơn là không có kiểu, bởi vì kiểu dữ liệu chính là tài liệu có thể chạy được.
// Trước đó, không có kiểu dữ liệu
function calculate(data, config) {
return data.items.reduce((acc, item) => {
return acc + item.price * (config.taxRate + 1)
}, 0)
}
// Sau đó, có kiểu dữ liệu để tái cấu trúc
type LineItem = { price: number; quantity: number; }
type TaxConfig = { taxRate: number; }
type Order = { items: LineItem[]; }
function calculateTotalWithTax(order: Order, config: TaxConfig): number {
return order.items.reduce((acc, item) => {
return acc + item.price * (config.taxRate + 1)
}, 0)
}
Đối với Python, hãy thêm “type hints”. Đối với PHP, sử dụng PHPStan hoặc Psalm. Đối với JavaScript cũ, hãy di chuyển từng tệp sang TypeScript với `allowJs: true`. Các kiểu dữ liệu không cần phải hoàn hảo ngay từ ngày đầu. Chúng cần phải tồn tại.
Lý do điều này quan trọng hơn mọi người nghĩ: kiểu dữ liệu được biên dịch. Bình luận thì không. Một bình luận sai có thể tồn tại mãi mãi. Một kiểu dữ liệu sai sẽ làm hỏng quá trình biên dịch (build). Kiểu dữ liệu là định dạng tài liệu duy nhất mà trình biên dịch giữ cho trung thực.
6. Kiểm Thử Là Sự Thật Khách Quan: Ghi Lại Hành Vi Hiện Tại
Trước khi bạn tái cấu trúc bất cứ điều gì, hãy viết các bài kiểm thử để khóa chặt hành vi hiện có, bao gồm cả những phần trông giống như lỗi.
Đây là quy tắc phản trực giác nhất trong danh sách. Các kỹ sư mới thường muốn sửa lỗi ngay lập tức. Động thái đúng đắn là viết một bài kiểm thử chứng minh lỗi tồn tại trước, sau đó giữ cho bài kiểm thử đó luôn vượt qua trong khi bạn tái cấu trúc, rồi sau cùng mới thay đổi bài kiểm thử một cách có chủ đích nếu lỗi cần được sửa.
# Khóa chặt hành vi hiện tại, ngay cả khi nó sai
def test_calculate_returns_negative_for_empty_orders():
"""
LỖI TIỀM ẨN: Các đơn hàng trống hiện trả về -1 thay vì 0.
Một hệ thống hạ nguồn nào đó phụ thuộc vào điều này. Không thay đổi nếu
chưa phối hợp với đội thanh toán.
"""
result = calculate([], TaxConfig(rate=0.1))
assert result == -1
Bài kiểm thử thực hiện hai điều. Nó cho chính bạn trong tương lai biết rằng hành vi đó là có chủ ý, không phải ngẫu nhiên. Nó cũng hoạt động như một chuông báo động nếu một “tái cấu trúc nhỏ” làm hỏng hợp đồng. Các bài kiểm thử của xavxav cho PP-BESM không phải là unit test theo nghĩa hiện đại. Chúng là những chương trình nhỏ thời Liên Xô được chạy qua máy ảo với đầu ra mong đợi được ghi lại. Cùng một ý tưởng, phạm vi nhỏ hơn. Khóa chặt hành vi, tái cấu trúc dựa trên hành vi đã khóa, thay đổi hành vi đã khóa một cách có chủ đích.
7. Bình Luận Về Quyết Định (Tại Sao), Không Phải Điều Hiển Nhiên (Cái Gì)
Người bảo trì mã nguồn trong tương lai có thể đọc được mã. Họ không thể đọc được cây quyết định của bạn. Những bình luận tồn tại qua một thập kỷ là những bình luận ghi lại lý do một lựa chọn cụ thể được đưa ra, đặc biệt khi lựa chọn đó trông có vẻ kỳ lạ.
Bình luận tệ: `// Tăng bộ đếm`. Mã đã nói rõ điều đó rồi.
Bình luận tốt: `// Chúng ta làm tròn xuống vì đội thanh toán chỉ mong đợi số cent là số nguyên. // Lịch sử: số cent dạng float đã gây ra sự cố đối chiếu vào tháng 5 năm 2023.`
Bình luận tốt là một ghi chú từ kỹ sư này sang kỹ sư khác về một ràng buộc không hiển thị trong mã. Ràng buộc đó là thật. Ràng buộc đó sẽ tồn tại lâu hơn kỹ sư đã giới thiệu nó. Bình luận là nơi duy nhất nó tồn tại.
Hãy thực hiện bài tập này trên mã nguồn kế thừa của bạn: tìm mọi nơi mà mã trông hơi kỳ lạ. Một “số ma thuật”, một kiểm tra được mã hóa cứng, một `try/except` nuốt một ngoại lệ cụ thể, một trường hợp đặc biệt cho một ID khách hàng. Mỗi điều đó là một “cuộc đàm phán” mà ai đó đã thực hiện với thực tế. Nếu bình luận bị thiếu, hãy thêm nó sau khi bạn tìm ra lý do đằng sau “cuộc đàm phán” đó.
# Tệ
TIMEOUT = 47
# Tốt
# Đặt thành 47 giây vì cổng xác thực của họ có giới hạn cứng là 50 giây
# và chúng tôi đã quan sát thấy độ trễ 1-2 giây từ bộ cân bằng tải của chúng tôi.
# Xem sự cố 2024-03-15. Không tăng giá trị này nếu chưa phối hợp với đội đối tác.
TIMEOUT = 47
Tổng Hợp Lộ Trình: Một Quy Trình Liên Tục Và Phát Triển
Bảy giai đoạn không song song. Chúng xây dựng lẫn nhau. Công việc xác định ranh giới cho bạn biết nơi để đặt môi trường chạy thử. Môi trường chạy thử cho phép bạn phân đôi. Phân đôi cho bạn biết cái gì cần được đặt tên. Tên cho bạn biết cái gì cần thêm kiểu. Kiểu cho bạn biết cái gì cần được kiểm thử. Các bài kiểm thử cung cấp cho bạn sự an toàn để bình luận một cách tự tin.
Cùng một vòng lặp chạy ở mọi quy mô. xavxav đang thực hiện nó trên một trình biên dịch 70 năm tuổi với mã nguồn trên giấy. Bạn có thể thực hiện nó trên một ứng dụng Rails 12 năm tuổi với mã nguồn trên GitHub. Hình thức là giống hệt nhau.
Một tuần đầu tiên thực tế, nếu bạn kế thừa một mã nguồn kế thừa vào ngày mai:
- Ngày 1: Ranh giới. Vẽ bản đồ. Đừng đọc phần nội bộ.
- Ngày 2: Môi trường chạy thử. Làm cho bất kỳ phiên bản nào chạy được với một lệnh.
- Ngày 3: Phân đôi. Tìm 10 phần trăm mã thực hiện công việc chính.
- Ngày 4: Đặt tên + kiểu dữ liệu. Làm cho 10 phần trăm đó dễ đọc.
- Ngày 5: Kiểm thử. Khóa chặt hành vi đã quan sát được trước khi tái cấu trúc.
Từ Tuần 2 trở đi: tái cấu trúc dựa trên các hành vi đã khóa, bình luận về các quyết định và ràng buộc.
Đến cuối tuần đầu tiên, bạn sẽ biết nhiều hơn về mã nguồn đó so với kỹ sư đã viết nó, bởi vì kỹ sư đã viết nó chưa bao giờ có bản đồ. Họ xây dựng hệ thống từng phòng một. Bạn đang đọc kiến trúc trong hai tuần vì bản đồ là một phần của công việc.
Cái Nhìn Chân Thực Về Mã Nguồn Kế Thừa
Hầu hết các kỹ sư sẽ nói rằng họ ghét mã nguồn kế thừa. Họ nói điều này bởi vì những mã nguồn kế thừa duy nhất họ từng thấy là những cái không ai thèm đọc. Một mã nguồn mà ai đó đã thực sự hiểu, lập bản đồ, có môi trường chạy thử và các hành vi được khóa chặt, là một nơi làm việc hoàn toàn dễ chịu. Sự khó chịu không nằm ở tuổi đời của mã, mà nằm ở sự thiếu vắng của “khảo cổ học”.
Dự án PP-BESM có lẽ sẽ không bao giờ có hàng triệu người dùng. Nó sẽ không xuất hiện trong cây phụ thuộc của bạn. Nó sẽ không gọi vốn Series A. Tuy nhiên, dự án này vẫn nằm trong số những tác phẩm phần mềm thú vị nhất đang diễn ra vào năm 2026, bởi vì mục tiêu là bảo tồn thay vì tăng trưởng, và bởi vì kỹ thuật này có thể tổng quát hóa. Đầu ra không phải là một sản phẩm. Đầu ra là một cẩm nang.
Cẩm nang đó hoạt động trên mã nguồn đang nằm trong kho của riêng bạn ngay bây giờ, cái mã nguồn có thư mục `legacy/` mà không ai dám chạm vào. Hãy dành một tuần để làm việc với nó. Thư mục legacy đó sẽ trở thành một tài sản thay vì một gánh nặng.
Câu Hỏi Dành Cho Bạn
Mảnh mã nguồn cũ nhất bạn từng đọc một cách nghiêm túc là gì, và bạn đã bỏ qua giai đoạn nào trong số bảy giai đoạn trên?



