Ba báo cáo trong hệ thống bán hàng: xuất CSV, xuất HTML, và xuất CSV chỉ cho đơn từ một triệu đồng trở lên. Cả ba mở kết nối, lọc, in tiêu đề, lặp từng dòng, in phần chân, rồi đóng. Chỉ có vài bước khác nhau. Nếu mỗi báo cáo là một class riêng chép lại cả quy trình, một thay đổi như "thêm log trước khi xuất" phải sửa ba nơi. Template Method giải quyết đúng chỗ đó: lớp cha giữ khung thuật toán, lớp con chỉ điền những bước khác nhau. Bài này dựng một ví dụ hoàn chỉnh, chỉ ra ba lỗi thiết kế hay gặp, tìm Template Method trong JDK (InputStream, AbstractList) và Spring (JdbcTemplate), rồi so sánh với Strategy. Code chạy trên JDK 21 (mức ngôn ngữ 17), output được dán nguyên văn.
Bài trước, Observer pattern: từ listener tới event bus và Flow API, cho phép gắn hành vi từ bên ngoài. Template Method làm ngược lại: khung nằm cố định ở trong, điểm mở rộng được khai báo sẵn.
Template Method là gì và gồm những phần nào
Theo refactoring.guru, Template Method định nghĩa phần khung của một thuật toán ở lớp cha, cho lớp con ghi đè các bước cụ thể mà không đổi cấu trúc chung. Nhóm GoF xếp nó vào nhóm pattern hành vi, dựa trên kế thừa. Các thành phần:
| Thành phần | Vai trò | Trong ví dụ |
|---|---|---|
| Template method | Gọi các bước theo thứ tự cố định, nên khai báo final | export() |
| Bước trừu tượng | Lớp con bắt buộc cài | header(), row() |
| Bước có mặc định | Lớp con có thể ghi đè | filter(), footer() |
| Hook | Bước rỗng làm điểm chèn, thuật toán chạy bình thường nếu không ghi đè | beforeExport(), afterExport() |
Điều khiến Template Method khác với kế thừa thông thường là đảo chiều điều khiển: lớp con không gọi lớp cha, mà lớp cha gọi lớp con. Người ta hay gọi là "Hollywood principle": đừng gọi chúng tôi, chúng tôi sẽ gọi bạn.
Cài đặt Template Method từng bước
Bước đầu tiên là viết khung. Lớp trừu tượng OrderExporter có phương thức export() khai báo final để lớp con không phá thứ tự. Mảng log chỉ phục vụ việc kiểm chứng thứ tự các bước trong demo:
static abstract class OrderExporter {
protected final List<String> log = new ArrayList<>();
public final String export(List<Order> orders) { // template method
StringBuilder sb = new StringBuilder();
log.add("begin");
beforeExport(sb); // hook
log.add("header");
sb.append(header()); // bước bắt buộc
for (Order o : filter(orders)) { // bước có mặc định
sb.append(row(o)); // bước bắt buộc
}
log.add("footer");
sb.append(footer(orders.size())); // bước có mặc định
afterExport(sb); // hook
return sb.toString();
}
protected abstract String header();
protected abstract String row(Order o);
protected List<Order> filter(List<Order> orders) { return orders; }
protected String footer(int total) { return ""; }
protected void beforeExport(StringBuilder sb) {}
protected void afterExport(StringBuilder sb) {}
}Lớp con chỉ viết phần khác biệt. CsvExporter cài hai bước bắt buộc. HtmlExporter dùng thêm hai hook để bọc thẻ <table> và ghi đè footer. BigOrderCsvExporter kế thừa CsvExporter và chỉ ghi đè filter:
static class CsvExporter extends OrderExporter {
@Override protected String header() { return "id,customer,amount\n"; }
@Override protected String row(Order o) { return o.id() + "," + o.customer() + "," + o.amount() + "\n"; }
}
static class BigOrderCsvExporter extends CsvExporter {
@Override protected List<Order> filter(List<Order> orders) {
return orders.stream().filter(o -> o.amount() >= 1_000_000).toList();
}
}Chạy với ba đơn hàng mẫu (ReportDemo.java):
== CsvExporter
id,customer,amount
DH-1,An,450000
DH-2,Bình,2300000
DH-3,Chi,1000000
thứ tự bước: [begin, header, footer]
== HtmlExporter
<table>
<tr><th>Mã</th><th>Khách</th><th>Tiền</th></tr>
<tr><td>DH-1</td><td>An</td><td>450000</td></tr>
<tr><td>DH-2</td><td>Bình</td><td>2300000</td></tr>
<tr><td>DH-3</td><td>Chi</td><td>1000000</td></tr>
<tr><td colspan=3>Tổng 3 đơn</td></tr>
</table>
thứ tự bước: [begin, header, footer]
== BigOrderCsvExporter
id,customer,amount
DH-2,Bình,2300000
DH-3,Chi,1000000
thứ tự bước: [begin, header, footer]Ba kết quả khác nhau, nhưng thứ tự bước giống hệt nhau. Có một chi tiết nên đọc kỹ: HtmlExporter in "Tổng 3 đơn" dựa trên orders.size(), tức số đơn trước khi lọc. Nếu một lớp con lọc bớt đơn thì chân trang sẽ báo sai. Đó là ví dụ cho việc khung và các bước phải thống nhất về dữ liệu truyền giữa chúng: tham số footer(int total) nên là số dòng đã in, không phải số đầu vào. Khi viết khung, hãy tự hỏi mỗi bước nhận gì và hứa trả về gì. Mã mẫu cố ý giữ nguyên điểm yếu này để minh hoạ; BigOrderCsvExporter không lộ lỗi vì chân trang CSV rỗng.
Ba lỗi thiết kế thường gặp với Template Method
Template Method dùng kế thừa, nên mang theo mọi rủi ro của kế thừa. Chương trình Pitfalls.java tái hiện ba lỗi, mỗi lỗi đều có output thật.
Lỗi 1: gọi bước ghi đè từ constructor của lớp cha
static abstract class Report {
Report() { System.out.println(" Report(): title = " + title()); }
abstract String title();
}
static class SalesReport extends Report {
private final String name;
SalesReport(String name) { this.name = name; }
@Override String title() { return name; }
}--- 1. Constructor gọi bước ghi đè ---
Report(): title = nullConstructor lớp cha chạy trước khi trường của lớp con được gán, nên title() trả về null dù trường khai báo final. Đây là cảnh báo nằm trong sách Effective Java của Joshua Bloch: constructor không được gọi phương thức có thể bị ghi đè. Trong Template Method, đừng gọi template method (hay bất kỳ bước nào) từ constructor; hãy tách ra phương thức run() hoặc init() gọi sau khi đối tượng dựng xong.
Lỗi 2: lớp con bỏ qua bước mặc định và phá hợp đồng
static abstract class Notifier {
final void send(String msg) {
String text = format(msg);
System.out.println(" gửi: \"" + text + "\" (độ dài " + text.length() + ")");
}
String format(String msg) { return msg.strip(); } // bước mặc định
}
static class SloppyNotifier extends Notifier {
@Override String format(String msg) { return msg; } // quên strip()
}--- 2. Bước mặc định bị bỏ qua ---
gửi: "xin chào" (độ dài 8)
gửi: " xin chào " (độ dài 12)Lớp cha Notifier mặc định gọi strip() trong bước format. SloppyNotifier ghi đè và quên dòng đó, nên khoảng trắng lọt qua. Refactoring.guru cảnh báo đây là điểm yếu: ghi đè một bước mặc định theo cách bỏ hành vi gốc có thể vi phạm nguyên lý thay thế Liskov, vì lớp con không còn cư xử như lớp cha. Biện pháp: ghi rõ trong Javadoc hợp đồng của từng bước (đầu vào, đầu ra, những gì nhất định phải giữ), hoặc nếu hành vi mặc định là bắt buộc thì đưa nó vào template method thay vì để trong bước có thể ghi đè.
Lỗi 3: quên khai báo template method là final
Đoạn dưới đây thiếu final ở run() (phương thức khung), nên lớp con ghi đè được toàn bộ:
static class Loose {
void run() { System.out.println(" Loose.run: bước 1, 2, 3"); } // thiếu final
}
static class Hijack extends Loose {
@Override void run() { System.out.println(" Hijack.run: bỏ qua khung, chỉ làm bước 2"); }
}
// Loose l = new Hijack(); l.run();--- 3. Template method không final ---
Hijack.run: bỏ qua khung, chỉ làm bước 2Không có final, lớp con ghi đè luôn phương thức khung và toàn bộ lời hứa về thứ tự biến mất. Đây là lỗi dễ nhất để tránh: template method là public final (hoặc package-private final nếu chỉ dùng trong gói), còn các bước là protected, không public, để người ngoài không gọi lẻ từng bước.
Template Method trong JDK: InputStream và AbstractList
JDK dùng Template Method rất nhiều, và hai ví dụ dưới đây dễ kiểm chứng. Theo Javadoc của InputStream, phương thức duy nhất còn trừu tượng là read(). Theo Javadoc, read(byte[], int, int) được định nghĩa bằng cách gọi lặp lại read() (các phương thức khác như skip, readAllBytes hay available có cách hoạt động riêng), và Javadoc khuyến khích lớp con cài đè để hiệu quả hơn. AbstractList tương tự: chỉ cần get(int) và size() là có một danh sách chỉ đọc đầy đủ; các thao tác sửa đổi (set, add, remove) là bước tuỳ chọn, mặc định ném UnsupportedOperationException. Ví dụ JdkDemo.java:
static class CountingStream extends InputStream {
private int next = 0;
private final int limit;
int readCalls = 0;
CountingStream(int limit) { this.limit = limit; }
@Override public int read() {
readCalls++;
return next < limit ? 'a' + (next++ % 26) : -1;
}
}
static class Range extends AbstractList<Integer> {
private final int from, to;
Range(int from, int to) { this.from = from; this.to = to; }
@Override public Integer get(int i) { return from + i; }
@Override public int size() { return to - from; }
}read(byte[]) đọc 10 byte: abcdefghij, số lần gọi read() = 11
readAllBytes -> abcde, số lần gọi read() = 7
Range: [10, 11, 12, 13, 14], contains(12) = true, indexOf(14) = 4, subList(1,3) = [11, 12]
add() -> UnsupportedOperationException (bước tuỳ chọn chưa được ghi đè)Con số 11 ở dòng đầu là 10 byte cộng một lần read() trả -1 để biết hết luồng. Dòng thứ hai là 7 lần với chỉ 5 byte dữ liệu: readAllBytes() cũng dựng trên read(byte[], int, int), và phải gọi thêm một vòng để xác nhận đã hết. Ta không cài gì ngoài read() mà vẫn có đủ các phương thức đọc khối, đúng tinh thần Template Method: lớp cha sở hữu thuật toán, lớp con cung cấp nguyên liệu. Đổi lại, cách mặc định đọc từng byte một nên chậm. Đó là lý do Javadoc khuyến khích ghi đè read(byte[], int, int) khi có thể đọc cả khối từ nguồn.
Template Method trong Spring: JdbcTemplate và callback
Spring dùng một biến thể: thay vì kế thừa, khung được truyền hành vi qua callback. Theo tài liệu JdbcTemplate, lớp này lo toàn bộ phần lặp đi lặp lại của JDBC (lấy kết nối, tạo statement, chạy, duyệt kết quả, giải phóng tài nguyên, dịch exception), còn mã ứng dụng chỉ cung cấp câu SQL, tham số và cách xử lý kết quả qua các callback như RowMapper. Để thấy rõ cấu trúc, ví dụ JdbcDemo.java viết một MiniJdbc chỉ vài dòng rồi so với JdbcTemplate thật (Spring 6.0.12, H2 2.2.224 trong bộ nhớ):
interface RowMapper<T> { T map(ResultSet rs) throws SQLException; }
static class MiniJdbc {
private final DataSource ds;
MiniJdbc(DataSource ds) { this.ds = ds; }
<T> List<T> query(String sql, RowMapper<T> mapper) {
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
List<T> out = new ArrayList<>();
while (rs.next()) out.add(mapper.map(rs));
return out;
} catch (SQLException e) {
throw new IllegalStateException("Truy vấn lỗi: " + e.getMessage(), e);
}
}
}MiniJdbc (tự viết):
Product[id=1, name=Áo thun, price=180000]
Product[id=2, name=Giày chạy bộ, price=1450000]
JdbcTemplate (Spring):
Product[id=1, name=Áo thun, price=180000]
Product[id=2, name=Giày chạy bộ, price=1450000]
Lỗi SQL được dịch sang DataAccessException:
org.springframework.jdbc.BadSqlGrammarExceptionHai bên cho cùng kết quả. Điểm đáng giá của template là bạn không thể quên đóng kết nối: try-with-resources nằm trong khung, không nằm trong code của bạn. Spring còn thêm phần mà bản mini bỏ qua là dịch SQLException sang hệ DataAccessException; câu SQL sai tên bảng ở trên trả về BadSqlGrammarException, một lớp con của nó. Lợi ích của cách dùng callback thay vì kế thừa, theo cách nhìn của chúng tôi (tài liệu Spring không nêu đây là lý do thiết kế chính thức), là bạn không phải tạo một lớp con mới cho mỗi truy vấn, và lambda biến mỗi bước thành một dòng.
Template Method và Strategy: kế thừa hay thành phần
Hai pattern giải bài toán gần nhau: tách phần cố định khỏi phần thay đổi. Refactoring.guru tóm tắt: Template Method dựa trên kế thừa, hoạt động ở cấp lớp và cố định lúc biên dịch; Strategy dựa trên thành phần, hoạt động ở cấp đối tượng và có thể hoán đổi lúc chạy. Bài Strategy pattern với lambda và functional interface đã đi chi tiết hướng thứ hai.
| Tiêu chí | Template Method | Strategy |
|---|---|---|
| Cơ chế | Kế thừa lớp trừu tượng | Truyền đối tượng hoặc lambda vào |
| Thời điểm chọn biến thể | Khi viết hoặc dựng đối tượng | Có thể đổi lúc đang chạy |
| Số điểm thay đổi | Nhiều bước trong một khung | Thường một thuật toán thay thế được |
| Rủi ro chính | Phụ thuộc chặt vào lớp cha, lỗi ở mục trên | Nhiều đối tượng nhỏ cần nối lại |
| Phù hợp khi | Khung ổn định, nhiều lớp con cùng khuôn | Cần đổi hành vi theo cấu hình hoặc theo ngữ cảnh |
Cách chọn thực dụng: nếu chỉ có một hoặc hai bước biến đổi và bạn muốn kiểm thử riêng từng bước, hãy truyền lambda (Strategy hoặc callback kiểu JdbcTemplate). Nếu có nhiều bước ràng buộc nhau theo một trình tự bất biến và hệ thống tự nhiên có một họ lớp con (các loại báo cáo, các loại import file), Template Method đọc gọn hơn. Nhiều codebase kết hợp cả hai: khung Template Method, còn bước lọc nhận một Predicate.
Khi nào không nên dùng Template Method
- Khung chưa ổn định. Mỗi lần đổi chữ ký một bước là sửa mọi lớp con. Hãy chờ tới khi có ít nhất hai ba biến thể thật rồi mới trích khung.
- Số bước phình ra. Refactoring.guru lưu ý Template Method khó bảo trì khi số bước tăng. Khi lớp cha có tám, chín điểm mở rộng thì nên chuyển sang thành phần.
- Lớp con đã có cha khác. Java chỉ cho kế thừa một lớp. Nếu
CsvExporterbuộc phải kế thừa một lớp khung khác thì pattern này bế tắc, còn callback thì không. - Cần đổi hành vi lúc chạy. Kế thừa cố định từ lúc dựng đối tượng; muốn đổi theo cấu hình người dùng hãy dùng Strategy.
- Chỉ một biến thể. Một lớp cha trừu tượng với một lớp con duy nhất là phức tạp không cần thiết.
Checklist khi viết Template Method
- Khai báo template method
final; các bước làprotected. - Phân loại rõ bước bắt buộc (
abstract), bước có mặc định và hook (thân rỗng). - Không gọi bước ghi đè từ constructor.
- Ghi hợp đồng từng bước trong Javadoc: nhận gì, trả gì, phải giữ gì khi ghi đè.
- Kiểm tra dữ liệu truyền giữa các bước nhất quán, như ví dụ
footer(total)ở trên. - Giữ số bước ít; cân nhắc callback hoặc Strategy khi nhiều điểm biến đổi độc lập.
- Viết test cho khung bằng một lớp con giả ghi lại thứ tự gọi, như mảng
logtrong demo.
Tóm tắt
Template Method đặt thuật toán ở lớp cha và để lớp con điền các bước khác nhau, nên loại được code trùng lặp và giữ thứ tự thực thi nhất quán. Giá phải trả là sự ràng buộc chặt qua kế thừa: ba lỗi đã chạy thử cho thấy constructor, bước mặc định và final là những chỗ dễ sai nhất. JDK (InputStream, AbstractList) và Spring (JdbcTemplate) đều dùng ý tưởng này, nhưng Spring nghiêng về callback để tránh gánh nặng kế thừa. Mã nguồn đầy đủ nằm trong code/s6-14/.
Bài tiếp theo
Khi cần đóng gói cả một yêu cầu thành đối tượng để xếp hàng, ghi log hay hoàn tác, ta chuyển sang pattern khác: Command pattern: hàng đợi lệnh, undo và redo. Bạn cũng có thể đọc lại Bridge và Flyweight pattern: tách trừu tượng và chia sẻ trạng thái để so sánh các cách tách phần cố định khỏi phần thay đổi.



