Trong bài “Lập trình giải bài toán đố năm ngôi nhà của Albert Einstein” tôi đã giới thiệu nội dung, thuật toán cũng như trình bày cách lập trình theo phương pháp vét cạn để tìm tất cả lời giải của bài toán này. Bài viết này đề cập đến một khía cạnh khác của bài toán: áp dụng Dependency Injection để phân rã nhỏ mã lệnh chương trình giải bài toán đố ở trên.
Việc cải tiến trên nhằm hai mục đích: 1- giúp mã nguồn chương trình rõ ràng, sáng sủa hơn; 2- giới thiệu về việc áp dụng Dependency Injection như thế nào để phân rã chương trình thành các thành phần và lắp ghép các thành phần với nhau lại thành chương trình hoàn chỉnh.
Phần mã nguồn nào rắc rối và khó hiểu?
Phần mã nguồn chương trình (lưu ý toàn bộ mã nguồn nằm trong tệp Main.java) rối và khó hiểu chính là các vòng lặp sinh hoán vị và kiểm tra các ràng buộc của bài toán trong hàm main(). Các vòng lặp lồng nhau cùng với các câu lệnh điều kiện kiểm tra ràng buộc được rải vào trong các vòng lặp khiến cho đoạn mã lệnh trở nên phức tạp, khó đọc. Một điều quan trọng nữa là toàn bộ khối lệnh gắn kết chặt chẽ với nhau, nên không thể viết unit testing để kiểm tra tính đúng đắn của toàn bộ khối lệnh này.
Phân rã đoạn mã thành các hàm
Mã lệnh của mỗi vòng lặp sinh các bộ hoán vị cho một đặc điểm (quốc tịch - nationalities, màu sắc - colors, thú nuôi - pets, thuốc lá - cigarettes, đồ uống - drinks) được tách và khai báo thành từng hàm, các hàm gọi lồng nhau.
Đến đây, chúng ta thấy mã lệnh các vòng lặp không còn lồng trực tiếp vào nhau nữa, chỉ còn các lời gọi hàm lồng nhau thôi. Tuy nhiên, do các hàm gọi nhau trực tiếp thông qua tên hàm nên giữa các hàm này vẫn còn sự kết nối chặt chẽ (tight coupling).
Tách các hàm thành các đối tượng rời
Để tách rời các đối tượng, chúng ta định nghĩa các Interface và cho phép các đối tượng trỏ đến nhau bằng biến tham chiếu để khi cần có thể gọi hàm của nhau thông qua biến tham chiếu đó. May mắn thay các hàm kiểm tra điều kiện ràng buộc trong bài toán chúng ta đều có tham số giống nhau, do đó chúng ta chỉ cần định nghĩa một Interface chung cho tất cả các lớp đối tượng chứa hàm kiểm tra điều kiện ràng buộc trên. Để thuận tiện, chúng ta gọi các lớp đối tượng này là các lớp đối tượng kiểm tra ràng buộc.
public interface RuleChecker {
public void check(int[][] permutation);
}
Ngoài hàm kiểm tra điều kiện ràng buộc giống nhau, các lớp đối tượng kiểm tra ràng buộc còn có một số thuộc tính giống nhau chẳng hạn như các hằng số, tham chiếu đến đối tượng Helper, tham chiếu đến đối tượng kiểm tra ràng buộc tiếp theo. Chính vì vậy, chúng ta thay Interface bằng lớp đối tượng trừu tượng (abstract class) để bổ sung thêm các thuộc tính và phương thức chung này:
public abstract class RuleChecker {
public static final int N_NA = 0;
public static final int N_CO = 1;
public static final int N_DR = 2;
public static final int N_PE = 3;
public static final int N_CI = 4;
protected PuzzleHelper helper;
public void setHelper(PuzzleHelper puzzleHelper) {
this.helper = puzzleHelper;
}
protected RuleChecker nextChecker;
public void setNextChecker(RuleChecker nextChecker) {
this.nextChecker = nextChecker;
}
public abstract void check(int[][] permutation);
}
Các lớp đối tượng kiểm tra ràng buộc được kế thừa từ lớp trừu tượng RuleChecker, mỗi lớp kiểm tra ràng buộc của một đặc điểm tương ứng với một hàm kiểm tra. Chẳng hạn, tương ứng với hàm kiểm tra checkColorsRule() ta có lớp đối tượng ColorsRuleChecker khai báo như sau:
public class ColorsRuleChecker extends RuleChecker {
@Override
public void check(int[][] p) {
p[N_CO] = new int[]{0, 1, 2, 3, 4};
do {
//13. The Norwegian lives next to a blue house
//09. The Norwegian lives in the first house
int co_blue = helper.findIndexOf("Blue", N_CO, p);
if (co_blue != 1) continue;
//04. The green house is to the left of the white house
int co_green = helper.findIndexOf("Green", N_CO, p);
int co_white = helper.findIndexOf("White", N_CO, p);
if (co_green > co_white) continue;
if (this.nextChecker != null) {
this.nextChecker.check(p);
}
} while (helper.genNextPermutation(p[N_CO]));
}
}
Dễ thấy hàm check() trong lớp đối tượng này không gọi trực tiếp đến hàm/đối tượng kiểm tra ràng buộc khác, mà gọi thông qua biến tham chiếu nextChecker. Các lớp đối tượng kiểm tra ràng buộc kết nối với nhau lỏng lẻo (loose coupling) thông qua tệp cấu hình application-context.xml như sau:
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="puzzleHelper" class="net.acegik.einsteinpuzzle.PuzzleHelper" />
<bean id="colorsRuleChecker"
class="net.acegik.einsteinpuzzle.checkers.ColorsRuleChecker">
<property name="helper" ref="puzzleHelper" />
<property name="nextChecker" ref="drinksRuleChecker" />
</bean>
<bean id="drinksRuleChecker"
class="net.acegik.einsteinpuzzle.checkers.DrinksRuleChecker">
<property name="helper" ref="puzzleHelper" />
<property name="nextChecker" ref="cigarettesRuleChecker" />
</bean>
<!-- ..... các khai báo khác ..... -->
</beans>
Đến đây, lớp đối tượng chính của chương trình chỉ còn lại hàm main() với nhiệm vụ đơn giản: yêu cầu IoC Container nạp tệp cấu hình, tạo các đối tượng bean, móc nối các đối tượng này theo như khai báo trong cấu hình, và kích hoạt thực thi chương trình.
public class MainWithSpring {
public static void main(String[] args) {
int[][] combination = new int[5][5];
ApplicationContext ctx =
new ClassPathXmlApplicationContext("/application-context.xml");
RuleChecker checker = (RuleChecker) ctx.getBean("ruleCheckerChain");
checker.check(combination);
}
}
Những lợi ích thu được
Rất dễ nhận thấy việc tách rời quá trình kiểm tra ràng buộc bài toán thành các đối tượng kết nối lỏng lẻo dẫn đến tạo thêm nhiều tệp mã nguồn cũng như phải bổ sung thêm mã lệnh để móc nối các đối tượng với nhau. Tuy nhiên, bù lại, chúng ta thu được những lợi ích sau:
- Lợi ích đầu tiên đó là chương trình được tổ chức lại rõ ràng và mạch lạc hơn rất nhiều, các đoạn mã lệnh được phân tách nhỏ thành các lớp đối tượng chức năng cụ thể nên dễ đọc, dễ hiểu.
- Các lớp đối tượng kiểm tra ràng buộc được phát triển riêng lẻ, độc lập nhau và có thể áp dụng Unit Testing để đảm bảo tính đúng đắn trước khi được ghép nối với nhau thành chương trình hoàn chỉnh. Việc điều chỉnh thứ tự kiểm tra ràng buộc cũng được thực hiện dễ dàng, chúng ta chỉ việc “tháo khớp” và lắp ghép lại theo trật tự mới trong tệp cấu hình application-context.xml.
- Dễ dàng cải tiến, tùy chỉnh. Một ví dụ đơn giản ở đây là chúng ta có thể lập trình tạo thêm lớp đối tượng ResultToFileWriter để ghi kết quả ra tệp thay thế cho lớp đối tượng ResultToConsoleWriter mà không phải lo lắng sẽ làm thay đổi nội dung tệp hoặc sai lệch những phần mã nguồn khác. Chúng ta có thể phát triển và test đối tượng ResultToFileWriter đến khi hoàn chỉnh rồi mới sử dụng. Để thay thế đối tượng ResultToConsoleWriter bằng ResultToFileWriter, chúng ta chỉ việc chỉnh sửa khai báo trong tệp application-context.xml là xong.
Vài lời kết
Theo tôi, chương trình giải bài toán đố này đủ nhỏ nhưng không quá ngắn để minh họa cho những ưu điểm của việc áp dụng Dependency Injection trong quá trình phát triển ứng dụng. Hy vọng các bạn tìm thấy được những điều thú vị và bổ ích trong bài viết này.