Viết ASP.NET bằng MVP và NHibernate phần 4 - Castle Windsor
Posted On Friday, October 31, 2008 at at 9:20 PM by UnknownBài viết này được dịch, tóm tắt và bổ sung dựa vào bài viết NHibernate Best Practices with ASP.NET, 1.2nd Ed trên code Project. Các code sample trong bài dựa vào database Northwind của Microsoft và tham khảo 99,99% từ code mẫu của tác giả Billy McCafferty.
I. Giới thiệu Inversion of Control và Castle Windsor
I.1 Giới thiệu
- Có rất nhiều website và blog nói về Inversion of Control và Dependency Injection. Có nguồn cho rằng đây là hai khái niệm khác nhau (MSDN) nhưng sự phụ Martin Fowler thì cho rằng Dependency Injection là tên gọi khác của Inversion of Control để khỏi confused. Tui thì thấy giải thích của bác Martin Fowler khá ổn, dù gì cũng là người lớn tuổi nên chắc không lừa con nít tụi mình
Inversion of Control (IoC) có nghĩa rằng các object không phải tự nó tạo ra các objects mà nó phụ thuộc. Thay vào đó, nó sẽ có những objects nó cần nhờ vào một bên thứ ba.
- Còn Inversion of Control Container là một library dựa trên nguyên tắc của IoC để hỗ trợ chúng ta trong việc tạo và hủy các đối tượng trong chương trình. Khi nhắc đến Dependency Injection, ngoài Spring.NET có lẽ người ta sẽ nghĩ đến Castle Windsor. Castle Windsor là một Container mà phần chính của nó là Castle MicroKernel (cũng là 1 container). Theo như Castle Prolect thì Windsor Container là một library dựa trên library MicroKernel nhưng có hỗ trợ thêm khả năng config uyển chuyển và một số tính năng khác. Nói cách khác, Windsor Container tiện dụng hơn MicroKernel và đối với những ai đã sử dụng Spring.NET thì sẽ thấy rằng Windsor cách dùng đơn giản như đang giỡn
Hình : Lâu đài Windsor ở Anh Quốc
I.2 Cách dùng Windsor
- Trước tiên hãy nghĩ xem Castle Windsor giúp chúng ta giải quyết problem gì. Người ta thường phân chia chương trình thành các module, các layer hay các tầng, tầng này khai báo Interface và tầng kia sẽ có những lớp implement các interface này. Như vậy với cách làm thông thường chắc chắn bằng cách nào đó sẽ có sự phụ thuộc, sẽ có các dòng code đại loại như : IOrderDao orderDao = new NHibernateOrderDao(). Và như vậy rõ ràng nơi nào sử dụng dòng code này đã có sự phụ thuộc vào module/layer chứa lớp NHibernateOrderDao. Người ta muốn tránh sự phụ thuộc này bằng cách thay dòng code có chữ new bằng một cách khác và cách người ta sử dụng chính là Dependency Injection. Có 3 khái niệm Dependency Injection: Constructor injection, Setter Injection và Interface Injection nhưng cái chúng ta thường gặp nhất là Interface Injection. Trong phần này ta sẽ tìm hiểu cách sử dụng Windsor Conainer và Interface Injection.
- Để khỏi phải new 1 object NHibernateOrderDao như trên, ta sẽ sử dụng Castle Windsor. Ta tạo 1 file config tên là Windsor.config rồi add vào project, nội dung của file này như sau:
<configuration>
service="MyProjectName.Core.DataInterfaces.IOrderDao, MyProjectName.Core">
<parameters>
<nameOfParameter2>100</nameOfParameter2>
</parameters>
</component>
</components>
</configuration>
Code 1: Nội dung file config cho Castle Windsor
- Bước thứ hai, mình mở mở file config và thêm vào như sau:
<configuration>
</configSections>
<castle>
</castle>
</configuration>
Code 2: Thêm config cho Windsor vào file app/web.config
- Theo như config trên thì file app/web config và file Windsor.config phải ở cùng thư mục với nhau. Trong Code1, ta khai báo một component với id là orderDao (có thể đặt tên gì cũng được), trong đó type và service lần lượt là Interface và class implement tương ứng. Trong phần parameters, ta khai báo các value cần thiết cho constructor của lớp NHibernateOrderDao. Và cuối cùng trong chương trình, ta sẽ dùng code sau đây để lấy được instance của class NHibernateOrderDao như mong muốn:
IOrderDao = (IOrderDao)windsorContainer.Resolve("orderDao");
// Or : IOrderDao = (IOrderDao)windsorContainer.Resolve(typeof(IOrderDao));
Code 3: Lấy instance của class NHibernateOrderDao như mong muốn
II. Tạo Web project và cấu hình Windsor, NHibernate
II.1 Load config cho Windsor trong global.asax
- Sau khi tạo một project Web, add file Windsor.config và sửa file web.config như đã nói ở trên xong thì điều tiếp theo ta sẽ nghĩ xem nên load cái config ấy như thế nào. Rõ ràng không nên cứ load config này mỗi lần muốn Resolve 1 object. Tương tự như khi sử dụng log4net cho web application, chúng ta phải load config log4net 1 lần duy nhất và code cho hành động này được đặt ở file global.asax. Nhưng khác với log4net, chúng ta cần một cách nào đó để giữ lại instance của WindsorContainer nên không thể thực hiện trong Global.asax được. Cách của bác Billy rất hay, đó là viết 1 lớp CustomHttpApplication, đặt tất cả các cấu hình cần thiết vào Application_Start, kế đến khai báo một public property để giữ instance của Windsor Container trong suốt thời gian sống của Application và vì thế instance của Windsor Container có thể được sử dụng bất cứ nơi nào trong tầng Web, cụ thể là các Page và các UserControl:
public static IWindsorContainer WindsorContainer
{
get { return windsorContainer; }
}
public void Application_Start(object sender, EventArgs e) {
// Initialize log4net
XmlConfigurator.Configure();
// Create the Windsor Container for IoC.
// Supplying "XmlInterpreter" as the parameter tells Windsor
// to look at web.config for any necessary configuration.
windsorContainer = new WindsorContainer(new XmlInterpreter());
}
public void Application_End(object sender, EventArgs e) {
windsorContainer.Dispose();
}
Code 4: Giữ instance của WindsorContainer trong HttpApplication
II.2 Open Session In View Principle
- Nếu google cụm từ trên thì các bạn sẽ thấy rất nhiều topic viết về chủ đề này trong đó tui thấy nên đọc nhất là trang của Hibernate.org. Vấn đề chúng ta mong muốn giải quyết ở đây là hạn chế số lần NHibernate mở connection đến database. Trong một view bất kì (page/usercontrol) có thể ta sẽ cần load nhiều dữ liệu, gọi nhiều query để load nhiều thông tin khác nhau để hiển thị trên form. Nếu với cách làm bình thường mỗi lần cần 1 Dao object nào đó select dữ liệu ta lại để NHibernate mở một connection thì performance của chương trình sẽ rất thấp, bởi vậy người ta nghĩ ra ý tưởng chỉ mở 1 connection ứng với mỗi View. Kĩ thuật của tác giả là khi có một request đến một view, một connection hay còn gọi là NHibernate session sẽ được tạo ra nếu chưa có, sau khi được sử dụng session NHibernate này sẽ được lưu trong 1 Hashtable, và nếu trong view đó tiếp tục còn Request khác thì session NHibernate này sẽ được lấy ra để tiếp tục sử dụng. Cuối cùng khi không còn Request nào nữa, ứng với sự kiện EndRequest của 1 view thì NHibernate session này sẽ được lấy ra lần cuối để close đi, sau đó empty Hashtable. Vậy nếu trên 1 trang aspx của ta có 3 usercontrol tương ứng 3 view sẽ có 3 lần mở connection đến database mà thôi thay vì mỗi usercontrol bản thân nó lại mở cả đống connection. Vậy làm sao để NHibernate Session chỉ mở một lần ứng với 1 View? Người ta có thể dùng HttpModule để thực hiện mục đích này.
- Như chúng ta biết HttpModule được khai báo trong file web.config và cho phép chúng ta implement những function bổ xung cho web application. Một HttpModule sẽ được gọi ngay vào đầu và sau khi có request đến 1 View bất kì. Thông thường người ta sử dụng HttpModule để phục vụ việc Logging hay check Security, trong trường hợp này ta đã implement lớp NHibernateSessionModule(Xem bài 3) để quản lý việc đóng và commit transaction. Lớp này được đặt trong project ProjectBase.Data và Pattern này có thể được sử dụng trong nhiều prolect khác nhau của bạn:
/// Commits and closes the NHibernate session provided by the supplied <see cref="NHibernateSessionManager"/>.
/// Assumes a transaction was begun at the beginning of the request; but a transaction or session does
/// not *have* to be opened for this to operate successfully.
/// </summary>
private void CommitAndCloseSession(object sender, EventArgs e) {
OpenSessionInViewSection openSessionInViewSection = GetOpenSessionInViewSection();
try {
// Commit every session factory that's holding a transactional session
foreach (SessionFactoryElement sessionFactorySettings in openSessionInViewSection.SessionFactories) {
if (sessionFactorySettings.IsTransactional) {
NHibernateSessionManager.Instance.CommitTransactionOn(sessionFactorySettings.FactoryConfigPath);
}
}
}
finally {
// No matter what happens, make sure all the sessions get closed
foreach (SessionFactoryElement sessionFactorySettings in openSessionInViewSection.SessionFactories) {
NHibernateSessionManager.Instance.CloseSessionOn(sessionFactorySettings.FactoryConfigPath);
}
}
}
Code 5: Implement 1 lớp HttpModule để apply Open In View Principle
- Và đây là một phần của file web.config để khai báo HttpModule này:
<httpModules>
</httpModules>
</system.web>
Code 6: Khai báo HttpModule trong web.config
III. Cải tiến DaoFactory bằng Generic và Castle Windsor
- Hiện tại có một vấn đề với DaoFactory của chúng ta. Khi cần tạo mới một class XXXDao nào đó cho một entity mới, chúng ta phải mở code, sửa lại interface IDaoFactory và thêm vào một dòng IXXXDao GetXXXDao(). Tất nhiên chúng ta cũng phải sửa lại lớp NHibernateDaoFactory, implement thêm method GetXXXDao(). Như vậy khá là bất tiện. Bác huynguyen_fisherman (đồng nghiệp, PM của project NHAU
1/ Refactoring lại interface IDao, thay tên của nó thành IGenericDao:
Hình : Refactor lại interface IDao
2/ Thêm vào một interface rỗng IDao và để IGeneric Dao inherit từ interface IDao này
3/ Sửa lại interface IDaoFactory như sau:
Code 7: Sửa lại interface IDaoFactory
4/ Implement lại class NHibernateDaoFactory:
{
public NHibernateDaoFactory()
{
}
// id is "component id" that we "declare" in the CastleComponents.config
public T GetDao
{
Check.Require(string.IsNullOrEmpty(id) == false, "component id cannot be null or empty");
return (T)_windsorContainer.Resolve(typeof(T));
}
private IWindsorContainer _windsorContainer = new WindsorContainer(new XmlInterpreter());
}
Code 8: Sửa lại class NHibernateDaoFactory
5/ Và đây là cách sử dụng để lấy 1 Dao object như ý muốn
ICustomerDao customerDao = daoFactory.GetDao<ICustomerDao>("CustomerDao");
Code 9: Cách lấy một Dao bằng NHibernateDaoFactory
IV. Tóm tắt & Kết luận
- Castle Windsor là một IOC Container rất được ưa thích, nếu các bạn xem ở blog này http://www.hanselman.com/blog/ListOfNETDependencyInjectionContainersIOC.aspx sẽ thấy rằng Windsor xếp đầu bảng và ngoài nó ra còn rất nhiều library Dependcy Injection nữa. Trong số đó thì có Unity và NInjection là hai IoC Container tui muốn tìm hiểu khi có thời gian :D.
- Trong bài này ta đã tìm hiểu cách áp dụng Generic và IoC Container để làm cho code gọn gàng, dễ sửa đổi. Chúng ta chỉ mới tạo project web và chuẩn bị trước một số thứ như config NHibernate và Windsor cũng như làm quen với Open In View Principle. Trong phần tiếp theo chúng ta sẽ tìm hiểu cách áp dụng pattern MVP để làm các trang aspx. Cách làm mới có gì khác so với cách làm truyền thống? Hãy chờ hồi sau sẽ rõ
Code phần 4: http://nthoaiblog.googlepages.com/EnterpriseSample-part4.zip
Các đoạn code minh họa trong bài viết được tui rút gọn cho dễ hiểu, code được implement cuối cùng trong demo sẽ có nhiều điểm khác biệt...
(Còn tiếp)
Tham khảo:
http://martinfowler.com/articles/injection.html
http://msdn.microsoft.com/en-us/library/aa973811.aspx
http://www.castleproject.org/container/documentation/v1rc3/concepts/ioc.html
http://en.wikipedia.org/wiki/Inversion_of_control
http://sourceforge.net/forum/message.php?msg_id=2847509
http://www.hibernate.org/43.html
http://www.builderau.com.au/program/dotnet/print.htm?TYPE=story&AT=339284537-339028399t-320002019c
http://dotnetslackers.com/articles/designpatterns/InversionOfControlAndDependencyInjectionWithCastleWindsorContainerPart1.aspx
http://dotnetslackers.com/articles/designpatterns/InversionOfControlAndDependencyInjectionWithCastleWindsorContainerPart2.aspx
Viết ASP.NET bằng MVP và NHibernate phần 2 - Implement Data Layer
Posted On Monday, October 20, 2008 at at 6:10 PM by UnknownBài viết này được dịch, tóm tắt và bổ sung dựa vào bài viết NHibernate Best Practices with ASP.NET, 1.2nd Ed trên code Project. Các code sample trong bài dựa vào database Northwind của Microsoft và tham khảo 99,99% từ code mẫu của tác giả Billy McCafferty.
I. Nhắc lại kĩ thuật Separated Interface
- Separated Interface là một kĩ thuật trong lập trình nhằm đạt được mục tiêu phân chia rạch ròi sự phụ thuộc giữa các tầng (tiers) trong chương trình. Kĩ thuật này được giới thiệu lần đầu bởi sư phụ Martin Fowler, nó cũng là một trong những nguyên tắc lập trình nổi tiếng được nhắc đến trong cuốn sách Agile Software Development của Robert Martin. Trong thực tế, kĩ thuật này thường được áp dụng khi người ta implement Domain Layer và Data Access Layer. Có một ví dụ thế này, trong domain layer ta tạo một lớp Customer. Lớp Customer này sẽ có nhiều method, một trong số đó là method GetAllCustomer(). Vậy lớp Customer cần phải sử dụng một data access object để xử lý yêu cầu đó. Kết quả là đối tượng Customer ít nhất đã có một sự phụ thuộc nhất định đến CustomerDao (nằm trong Data Access Layer). Mặt khác đối với lớp CustomerDao, nó sẽ trực tiếp truy xuất vào database và trả về kết quả là một mảng/list các đối tượng kiểu Customer, vì thế nó phải phụ thuộc ngược lại Domain Layer vì như chúng ta đã thống nhất với nhau mình sẽ khai báo các class Entity trong Domain Layer (Project EnterpriseSample.Core). Nếu chưa/không biết kĩ thuật này, có thể chúng ta có thể dùng cheat code bằng cách tách tất cả những lớp định nghĩa Entity ra một project khác ví dụ như EnterpriseSampe.Entities. Còn nếu đã biết Dependency Inversion Priciple rồi thì ta sẽ làm một cách sạch sẽ và trong sáng hơn nhiều
- Có một giải pháp còn cheat ghê hơn nữa
+ Domain layer và Data layer sẽ phụ thuộc hai chiều vào nhau.
+ Ở chung một nhà lại phụ thuộc nhiều thế này thì lâu ngày … sẽ có con chung
+ Nếu trong code của Entity object sử dụng trực tiếp một instance của class DAO thì sẽ rất khó Unit Test nó mà không cần database thực. Vì khó test nên có thể người ta sẽ lười test, mà lười rồi thì khỏi viết Unit Test
- Thực ra cách mà tác giả đề nghị là domain và data layers nên được đặt trong các assembly độc lập. Chẳng hạn như XXXX.Core và XXXX.Data. Domain layer (assembly XXXX.Core) sẽ chứa những lớp domain và DAO Interface. Còn Data Layer (assembly XXXX.Data) chỉ chứa những lớp DAO, những lớp DAO này là những lớp sẽ implement các Interface nói trên trong domain layer. Các bạn hãy xem lại hình vẽ cấu trúc này lần nữa:
Hình 1: Quan hệ giữa project Core và project Data
- Cách làm trong sáng này có một số lợi ích sau:
+Domain Layer sẽ hoàn toàn độc lập với các assembly liên quan đến database như NHibernate hay System.Data.SqlClient
+Domain Layer không cần quan tâm xem data layer hoạt động thế nào, truy xuất loại database gì… Vì thế việc chuyển đổi các loại database khác nhau cũng như thay đổi chi tiết implementation của tầng data layer rất dễ dàng và không hề ảnh hưởng đến code của domain layer.
+ Vì hai layer này quan hệ một chiều với nhau như vậy nên những fan của Unit testing có thể sử dụng các kĩ thuật “Mocked” data-access layer trong domain layer để test các lớp Entity mà không cần có database thực.
II. Khai báo các Interface cần thiết
- Bây giờ chúng ta sẽ từng bước khai báo các interface này. Trước hết là interface IDao, đây là một interface chung nhất khai báo các function cơ bản nhất mà bất cứ một data access object nào cũng có, nội dung interface này như sau:
Code 1: Khai báo các function chung trong interface IDao
- Đây là 1 generic interface với hai kiểu generic là T và IdT. Nếu ta nhìn phần khai báo các function thì cũng đoán ra nếu một lớp DAO nào implement interface này hoặc một interface nào inherit từ interface này sẽ phải khai báo kiểu của Entity và kiểu Id của Entity đó. Ví dụ như hai interface ICustomerDao và ISupplierDao như sau:
Code 2: Các interface kế thừa từ Generic interface IDao
- Vậy đến khi implement thực sự các Data Access Object, ta sẽ implement các interface như ICustomerDao, ISupplierDao. Ở phần 1, chúng ta đã tạo 1 lớp là HistoricalOrderSummaryDao để giữ giá trị khi gọi Stored Procedure. Vì đây là một “value class“ đặc biệt, không phải là một Entity chính thức nên nó không cần Id; do đó interface DAO tương ứng cũng không cần phải kế thừa từ interface IDao:
Code 3: Interface IHistoricalOrderSummaryDao không cần kế thừa từ IDao
- Cuối cùng chúng ta sẽ tạo một interface IDaoFactory, nếu các bạn đã đọc về Factory Pattern thì trong trường hợp này chúng ta đang sử dụng Abstract Factory. Sẽ có 1 lớp Factory implement interface này và tùy chúng ta sử dụng ORM nào thì các lớp DAO tương ứng sẽ được tạo ra. Cho nên trong phần sau chúng ta sẽ viết 1 lớp NHibernateDaoFactory với mục đích tạo ra các NHibernate Dao Object:
Code 4: Nội dung của interface IDaoFactory
III. Làm việc với NHibernate Session
- Khi viết chương trình có sử dụng database, người ta thường tìm cách giới hạn số lần mở connection đến database server sao cho càng ít kết nối càng tốt. Nếu các bạn quan tâm đến vẫn đề này hẵn các bạn sẽ biết đến khái niệm Transaction. Trong NHibernate cũng có một khái niệm như vậy và chúng ta hãy tạm chấp nhận một NHibernate Session là một connection đến database server bằng NHibernate. Khi viết một ứng dụng web, việc giảm thiểu các connection đến database là điều rất đáng để quan tâm. Giả sử chúng ta viết 1 trang aspx trên trang đó có sử dụng 10 usercontrol, mỗi usercontrol lại cần kết nối với database để load dữ liệu riêng của nó. Như vậy một lần mở trang web ta đã mở 10+ connection đến database server. Chúng ta có thể giải quyết vấn đề nhức đầu này bằng cách đưa tất cả các request đó vào cùng một transtaction. Cụ thể thế nào thì chúng ta sẽ bàn vào bài sau, còn trong phạm vi bài này chúng ta hãy chấp nhận rằng mỗi một Dao object khi cần kết nối đến database để đọc/ghi dữ liệu nó sẽ cần đến một NHibernate.ISession. Và tác giả đã implement một lớp NHibernateSessionManager cho chúng ta sử dụng, đối số cần truyền cho method GetSesssionFrom() là một giá trị string chứa đường dẫn đến file config của NHibernate. Cách làm này của tác giả có rất nhiều mục đích như: hỗ trợ truy xuất nhiều database một lúc và sử dụng một kĩ thuật gọi là OpenSessionInView để quản lý connection đến database server,.. Tác giả đã implement các lớp Configuration để hỗ trợ đọc các file config NHibernate vì các config này được đặt vào một file xml riêng và không còn nằm chung trong file web/app.config. Các lớp này được viết một lần nhưng ta có thể sử dụng chúng cho nhiều project khác nên chúng được đặt trong một assembly riêng tên là ProjectBase.Data. Chúng được đặt trong thư mục NhibernateSessionMgmt; nếu các bạn quan tâm có thể tìm hiểu khái niệm Configuration của .NET Framework 2.0
Sau đây là một ví dụ có sử dụng NHibernate session:
Code 9: Sử dụng NHibernate Session
IV. Implement DaoFactory và các lớp Dao tương ứng
- Vậy là cơ bản chúng ta đã hoàn thành xong Domain Layer. Xin nhắc lại project EnterpriseSample.Core chỉ chứa các lớp Entity và các interface Dao, không chứa bất kì một lớp Dao nào. Ngay sau đây chúng ta sẽ tạo các lớp Dao này trong project EnterpriseSample.Data:
IV.1 Generic DAO
- Các lớp Dao là những lớp trực tiếp sử dụng NHibernate để thực hiện các lệnh thêm xóa, sửa, update vào database. Ví dụ ta có CustomerDao sẽ implement các function như UpdateCustomer, GetAllCustomers. Nhìn chung thì mỗi một Entity trong chương trình đều có các method tương tự nhau như: GetById, Save, Delete, Update. Thử tưởng tượng bạn có 20 tables, ứng với 20 class Entity trong chương trình và mỗi một Entity như vậy phải implement ít nhất 4 function GetById, Save, Delete, Update thì đúng là một cực hình. Người ta sẽ giải quyết vấn đề trùng lặp code này bằng cách sử dụng Generic của .NET framework 2.0, tất cả những hàm tương tự nhau trong các lớp Entity sẽ được implement trong một class Generic:
Code 5: Lớp Generic Dao AbstractNHibernateDao
- Các lớp Dao trong chương trình ngoài implement interface IxxxDao sẽ inherit từ lớp abstract này:
Code 6: Cách sử dụng lớp Generic AbstractNHibernateDao
IV.2 DAO Factory
- Dao Factory là gì và tại sao ta lại cần một lớp Factory? Nếu bạn có quan tâm đến Design Pattern chắc là bạn đã nghe đến các pattern như Abstract Factory, Factory Method. Một Dao Factory có khả năng tạo ra các Dao Object theo một tiêu chuẩn nào đó. Trong project này ta sử dụng NHibernate để thao tác với cơ sở dữ liệu, vậy thì Dao Factory của ta sẽ tạo ra các NHibernate Dao Object để tương tác với NHibernate. Ta sẽ tạo ra một lớp là NHibernateDaoFactory với tiêu chí đó, code như sau:
Code 7: Lớp NHibernateDaoFactory
IV.3 Nhận kết quả từ Stored Procedured như thế nào?
- Trong bài trước chúng ta có implement lớp HistoricalOrderSumary để giữ giá trị trả về khi gọi một stored procedure. Ta cũng đã làm một file mapping xml tương ứng cho nó. Nhưng để thực hiện các bước cụ thể nhằm map các kết quả từ database, ta phải làm quen một khái niệm trong NHibernate là IQuery và Transform. Chúng ta sẽ tạo một instance kiểu IQuery bằng static method GetNamedQuery với parameter là tên query được khai báo trong file mapping XML, từ đó hướng dẫn NHibernate map kết quả trả về bằng constructor của lớp HistoricalOrderSumary:
Code 8: Cách map kết quả từ stored procedure
V. Kết luận
- Qua phần hai này, chúng ta đã làm quen với khái niệm Session của NHibernate một cách rất khái quát
Code phần 2: http://nthoaiblog.googlepages.com/EnterpriseSample-part2.zip
Các đoạn code minh họa trong bài viết được rút gọn cho dễ hiểu, code được implement cuối cùng trong project sẽ có nhiều điểm khác biệt với hình...
(Còn tiếp)
The Open-Closed Principle
Posted On Wednesday, August 13, 2008 at at 4:50 PM by Unknown- Có rất nhiều kinh nghiệm quý được rút ra trong lĩnh vực thiết kế hướng đối tượng. Ví dụ như “tất cả biến member nên được khai báo private”, hay “không nên sử dụng biến toàn cục”, hay “sử dụng chức năng kiểm tra kiểu lúc runtime (run time type identification - RTTI) rất nguy hiểm và nên hạn chế”. Vậy các kinh nghiệm quý đó bắt đầu từ đâu? Ai đã kiểm chứng tính đúng đắn của những kinh nghiệm này và liệu chúng luôn luôn đúng? Bài viết này sẽ đề cập đến một nguyên tắc cơ bản, nguyên tắc nền tảng cho các kinh nghiệm quý trên. Nguyên tắc Open-Closed (Open – Closed principle).
- Ivar Jacobson nói rằng: “Tất cả những hệ thống phần mềm thay đổi suốt quá trình hoạt động của chúng. Phải tâm niệm rằng phần mềm luôn được khách hàng mong đợi sẽ hoạt động hoàn hảo hơn so với phiên bản đầu tiên của nó“
-Làm sao để có thể thiết kế được một sản phẩm có tính ổn định cao trong khi vẫn cho phép sửa đổi suốt quá trình hoạt động? Bertrand Meyer đưa ra một nguyên tắc rất nổi tiếng vào năm 1988 :
NÊN ĐƯỢC OPEN ĐỂ MỞ RỘNG, NHƯNG CLOSED ĐỐI VỚI NHỮNG SỬA ĐỔI.
- Khi có một sửa đổi nào đó vào chương trình mà dẫn đến nhiều thay đổi ở những nơi khác, chương trình đó được xem là một thiết kế tồi (bad design) và nó trở nên khó sửa đổi cũng như khó sử dụng lại. Nguyên tắc open-closed sẽ giúp khắc phục điểm yếu này của phần mềm theo một cách rất đơn giản. Nó nói rằng bạn nên thiết kế những modules mà không bao giờ phải sửa đổi. Khi yêu cầu (requirement) có thay đổi, bạn mở rộng những behavior của những module này bằng cách thêm code mới thay vì sửa những dòng code cũ đang chạy tốt.
Mô tả nguyên tắc open-closed:
- Những modules được xem tuân theo nguyên tắc open-closed khi nó có hai tính chất sau:
1. Chúng “Open For Extension”.
+ Điều này có nghĩa là những behavior của module có thể được mở rộng. Chúng ta có thể làm cho module chạy theo một cách khác khi yêu cầu của ứng dụng thay đổi.
2. Chúng “Closed for Modifictation”.
+ Source code của module không được sửa đổi trong mọi trường hợp.
- Dường như hai điều trên mâu thuẫn với nhau. Cách thông thường để mở rộng một behavior của module là sửa lại code của module đó. Vậy làm cách nào để giải quyết mâu thuẫn này?
Sử dụng Abstraction – Sự trừu tượng hóa.
- Trong một số bài viết trước, đã có lần chúng ta nói đến abstraction, trong các bài viết/dịch của tôi, các Abstraction sẽ được tạm hiểu như các Interface hoặc Abstract class trong C#.
- Khi sử dụng các nguyên tắc của ngôn ngữ lập trình hướng đối tượng (3 NGUYÊN TẮC CƠ BẢN CHẮC AI CŨNG BIẾT) như C++, C#, chúng ta có thể thể tạo ra những abstractions để tượng trưng cho một nhóm vô tận những behavior có thể được thay đổi về sau. Abstractions ở đây có thể là các abstract base classes trong C++ hay C#, có thể là một Interface như trong C# và Java, và một nhóm các behavior có thể thay đổi được đề cập ở trên chính là các lớp con. Một module của bạn chỉ cần biết đến abstraction này và sử dụng nó. Module loại này sẽ đạt được tính “closed for modification” bởi vì nó chỉ phụ thuộc chặt chẽ vào một abstraction nào đó đã cố định. Còn những behavior của module có thể được mở rộng bằng cách thêm các lớp mới, mở rộng từ abstraction của bạn.
- Hình 1 dưới đây là ví dụ về một thiết kế không theo nguyên tắc open-closed. Cả lớp Client và lớp Server đều là các lớp cụ thể. Không có gì đảm bảo rằng các member functions của lớp Server là virtual. Bởi vì lớp Client với lớp Server có quan hệ “uses”, nên khi chúng ta muốn một đối tượng thuộc kiểu Client sử dụng một đối tượng thuộc kiểu Server, chúng ta phải sửa tên lớp khai báo trong lớp Client thành tên mới của lớp server mong muốn.
Hình 1: Closed Client
Hình 2: Open Client
Vài ví dụ
The Shape Abstraction
- Các bạn hãy xem một ví dụ kinh điển sau. Ta cần làm một chương trình có thể vẽ các hình hình học. Lúc đầu người ta yêu cầu chỉ cần vẽ hình tròn và hình vuông và các hình này phải được vẽ theo một trình tự nhất định. Một danh sách các hình tròn và hình vuông sẽ được tạo ra theo một thứ tự nào đó và chương trình phải duyệt qua danh sách đó để vẽ ra hết các hình trong danh sách.
Trong thủơ mới học lập trình, chúng ta thường được dạy lập trình kiểu thủ tục và tất nhiên cách tiếp cận ấy hoàn toàn không tuân theo nguyên tắc open-closed. Chúng ta sẽ giải quyết vấn đề này trong đoạn code dưới đây. Ở đây chúng ta thấy một tập các cấu trúc dữ liệu được khai báo để mô tả một đối tượng hình học. Thành phần thứ nhất của struct dùng để xác định một đối tượng là hình tròn hay hình vuông. Function DrawAllShapes sẽ duyệt qua danh sách các hình và xác định xem từng hình là hình gì sau đó gọi function vẽ tương ứng (hoặc DrawCircle hoặc DrawSquare).
enum SharpType
{
Circle,
Square
};
class Sharp
{
SharpType Type;
}
class Circle : Sharp
{
double Radius;
Point Center;
}
class Square : Sharp
{
double Side;
Point TopLeft;
}
//
// These functions are implemented elsewhere
//
void DrawSquare(Square obj)
{
}
void DrawCircle(Circle obj)
{
}
void DrawAllShapes(Sharp[] list, int n)
{
int i;
for (i = 0; i < n; i++)
{
Sharp s = list[i];
switch (s.Type)
{
case SharpType.Square:
DrawSquare((Square)s);
break;
case SharpType.Circle:
DrawCircle((Circle)s);
break;
}
}
}
Code 1
- Function DrawAllShapes trên không tuân theo nguyên tắc open-closed bởi vì rõ ràng nó không thể “closed” khi muốn thêm khả năng vẽ hình tam giác, hình chữ nhật, hình cô gái.
- Tất nhiên chương trình này chỉ là một ví dụ nhỏ. Trong thực tế, câu lệnh switch trong function DrawAllShapes có thể phải lặp lại nhiều lần trong những function khác. Nếu muốn thêm một hình mới vào chương trình kiểu này bạn phải tìm mọi chỗ có đoạn switch hoặc if/else như trên để sửa.
- Đoạn code dưới đây sẽ giải quyết vấn đề ta đang gặp phải với open-closed. Trong trường hợp này, ta sẽ dùng 1 lớp abstract class Shape. Bên trong lớp này sẽ khai báo một hàm virtual tên là Draw. Các lớp hình học cụ thể kế thừa từ lớp này sẽ implement lại hàm virtual Draw cho chính nó.
class Sharp
{
public virtual void Draw()
{
// Implement default behavior, or do nothing here
}
}
class Circle : Sharp
{
public override void Draw()
{
// Implement function draw a Circle
}
}
class Square : Sharp
{
public override void Draw()
{
// Implement function draw a Square
}
}
void DrawAllShapes(Sharp[] list)
{
foreach(Sharp s in list)
s.Draw();
}
Code 2
- Rất dễ thấy rằng nếu muốn mở rộng behavior của function DrawAllShapes trong hình trên, ta chỉ cần thêm một lớp mới kế thừa từ lớp abstract Shape. Function DrawAllShapes không cần thiết phải sửa lại, vì thế DrawAllShapes đã thỏa mãn yêu cầu của nguyên tắc open-closed.
- Trong thực tế, lớp abstract Shape có thể có thêm những method khác. Còn việc thêm một lớp con của lớp Shape vào chương trình thì khá đơn giản vì ta chỉ cần implement tất cả các method abstract/virtual cần thiết. Và hiển nhiên làm theo cách này ta sẽ không cần phải tìm trong code cũ của chương trình những chỗ có switch, if/else để sửa đổi.
Các phương án để đạt được tính "closed" của chương trình:
- Thực sự thì không có một chương trình nào có thể 100% “closed”. Có một ví dụ như sau, bạn hãy nghĩ xem sẽ phải làm như thế nào nếu ta muốn function DrawAllShapes phải vẽ những hình tròn trước các hình vuông. Rõ ràng hàm DrawAllShapes hiện tại không thể “closed” với những yêu cầu như thế này. Nói chung, cho dù module của bạn có “closed” như thế nào thì vẫn có những thay đổi nhỏ mình phải chấp nhận.
- Bởi vì tính đóng không hoàn toàn trọn vẹn 100%, chúng ta cần có chiến thuật hợp lý cho nó. Có nghĩa là một người thiết kế hướng đối tượng nên xem xét những gì có thể sẽ thay đổi và những gì nên “closed” trong thiết kế của anh ta và điều này đòi hỏi nhiều kinh nghiệm trong thiết kế phần mềm. Một designer giàu kinh nghiệm biết tường tận về người sử dụng hoặc thị trường phần mềm của mình, từ đó có thể xác định những thay đổi có thể có trong chương trình. Và nhờ đó anh ta có thể chắc chắn rằng nguyên tắc open-closed sẽ được dùng trong hầu hết những nơi có thay đổi trong tương lai.
Using Abstraction to Gain Explicit Closure
- Vậy làm thế nào để “close” function DrawAllShapes trong bài toán vẽ theo thứ tự? Xin nhắc lại là tính đóng của chương trình dựa trên abstraction. Vì thế, để làm cho function DrawAllShapes “closed” đối với thứ tự vẽ, có lẽ ta nên nghĩ đến làm thế nào để trừu tượng hóa cái thứ tự vẽ đó. Trường hợp vẽ theo thứ tự nói trên thực ra là thao tác vẽ một số loại hình trước các loại hình khác.
- Nếu có một cách để xác định thứ tự của các đối tượng cùng một kiểu. Nếu cho hai đối tượng nào đó, ta phải tìm ra đối tượng nào nên được vẽ trước. Vì thế, chúng ta có thể viết một method của lớp Shape tên là Precedes chẳng hạn để so sánh chính nó với một đối tượng thuộc kiểu Shape khác và trả về giá trị bool. Giá trị bool trả về là true thì đối tượng ban đầu nên được vẽ trước đối tượng được dùng để so sánh.
- Trong các ngôn ngữ hướng đối tượng như C++, C#, chúng ta có thể implement ý tưởng của function Precedes trên bằng cách sử dụng overloaded operator <. Hình 3 sẽ hiện thực phần code này. Như vậy, chúng ta đã có cách để xác định thứ tự trước sau của hai đối tượng thuộc kiểu Shape, chúng ta có thể sort chúng và sau đó chỉ việc vẽ ra theo thứ tự mong muốn. - Nhưng chúng ta vẫn chưa hoành chỉnh abstraction behavior so sánh của đối tượng Shape. Với khai báo abstract như trong lớp Shape, mỗi đối tượng thuộc kiểu Shape phải override lại method Precede. Nhưng họ đã làm điều đó như thế nào?
public class Sharp
{
public virtual void Draw()
{
}
public virtual bool Precedes(Sharp otherSharp)
{
return true;
}
public static bool operator <(Sharp sharp1, Sharp sharp2)
{
return sharp1.Precedes(sharp2);
}
}
Code 3
public class Circle : Sharp
{
public override bool Precedes(Sharp otherSharp)
{
try
{
Square temp = (Square)otherSharp;
}
catch
{
return false;
}
return true;
}
}
Code 4
- Rõ ràng với việc ép kiểu như vậy chúng ta đã vi phạm nguyên tắc open-closed. Và dường như không có cách nào hàm so sánh này có thể “closed” khi muốn kiểm tra Circle với một hình học loại khác. Và mỗi khi một lớp con của lớp Shape được tạo ra, hàm compare này của chúng ta buộc lòng phải được sửa lại không chỉ ở lớp Circle mà ở toàn bộ những lớp con đã có của lớp Shape.
Using a “Data Driven” Approach to Achieve Closure
- Tính “closed” ở các lớp con của lớp Shape có thể đạt được bằng cách sử dụng một cheat code như sau. Ta sẽ tạo ra một bảng các tên của lớp theo thứ tự mong muốn, từng lớp con của lớp Shape sẽ dựa vào các giá trị trong bảng này và tên của chính nó để đưa ra kết quả so sánh. Hãy xem hình 6 để hiểu rõ thêm cách thực hiện.
- Theo cách tiếp cận này chúng ta đã thành công trong việc “closed” function DrawAllShapes trước những vấn đề nảy sinh, và tính “closed” của mỗi lớp con Shapes khi có một lớp mới thêm vào. Thậm chí khi có yêu cầu thay đổi quy luật vẽ của các hình thì các lớp con Shapes cũng không cần sửa đổi gì cả.
- Tuy nhiên vẫn có một thành phần vẫn chưa “closed” với những loại khác nhau của Shapes, đó chính là bản thân bảng danh sách tên đang sử dụng. Nhưng ta vẫn có thể đưa phần code định nghĩa bảng này sang một module độc lập với những module còn lại trong chương trình, vì thế bất cứ sự thay đổi nào cũng không ảnh hưởng đến những module khác.
- Có nói mãi cũng không hết chuyện về các vấn đề trong ví dụ Shapes. Tuy nhiên nếu hàm vẽ các hình theo một thứ tự khác không xét instance Shapes thuộc loại gì thì lại nảy sinh nhiều vấn đề khác nữa. Có vẽ như chúng ta muốn sắp xếp thứ tự vẽ của các hình theo nhiều kiểu khác nhau và ta sẽ không bàn tiếp nữa trong bài viết này. Và như các bạn thấy, không thể 100% “closed” trong thiết kế phần mềm được.
Heuristic and Conventions
- Như đã đề cập ở phần đầu của bài viết, nguyên tắc open-closed là nền tảng chính cho rất nhiều các kinh nghiệm và quy ước khác được công bố liên quan đến OOD trong nhiều năm. Dưới đây là một vài điều quan trọng nhất trong số đó.
Make all Member Variables Private.
- Đây là điều quan trọng nhất trong các conventions của thiết kế hướng đối tượng. Các member variable bên trong class nên được biết đến bởi các methods của lớp định nghĩa chúng. Các member variables không nên được truy xuất bởi lớp khác, bao gồm cả các lớp con. Vì thế chúng nên được khai báo private thay vì public hoặc protected.
- Khi một member variables của một lớp thay đổi, mỗi function có sử dụng biến này sẽ phải thay đổi theo. Vì thế không có một function nào của lớp có thể “closed” khi cứ phụ thuộc vào member variable kiểu này.
- Trong thiết kế hướng đối tượng, chúng ta mong muốn các method của class không nhất thiết phải “closed” đối với những thay đổi của member variables bên trong class. Tuy nhiên chúng ta muốn bất cứ những lớp khác, bao gồm cả lớp con phải “closed” đối với những thay đổi của các variables này. Chúng ta có tên cho điều này, đó chính là “Encapsulation”.
- Bây giờ, nếu ta có một member variable mà ta biết sẽ không bao giờ cần thay đổi? Vậy liệu có lí do nào để khai báo nó private? Ví dụ, hình 5 thể hiện một lớp Device có một member variable kiểu bool tên là status. Biến variable này giữ trạng thái của lần hoạt động cuối cùng của object kiểu Device. Nếu lần chạy cuối thành công thì biến status này giữ giá trị true và ngược lại.
class Device
{
public bool Status;
}
Code 5
- Chúng ta biết rằng kiểu(bool) và ý nghĩa (meaning) của variable này sẽ không bao giờ đổi. Vậy tại sao lại không khai báo nó là public và để chương trình (client code) có thể đọc giá trị của nó? Nếu biến variable này thật sự không thay đổi, và nếu tất cả những phần trong chương trình chỉ đọc giá trị status thì khi đó biến public này sẽ không gây tác hại gì cả. Tuy nhiên, có thể ở một nơi nào, bởi một người khác trong nhóm của bạn, có thay đổi giá trị status và điều này có thể dẫn đến chương trình chạy sai mà bạn không ngờ tới. Vì vậy có thể không đáng để chúng ta mạo hiểm khai báo như trên.
No Global Variables -- Ever
- Vấn đề tranh cãi về biến toàn cục cũng tương tự với biến member public. Không một module nào có sử dụng một biến toàn cục lại có thể “closed” đối với những module tương tự nhưng lại có khả năng ghi vào biến toàn cục này. Bất kì module nào có sử dụng biến dùng chung nhưng thay đổi nó tùy tiện có thể ảnh hưởng đến những nơi khác trong chương trình.
- Tuy nhiên, nếu có những biến toàn cục được sử dụng rất ít bởi một số nơi, hoặc chắc chắn rằng nó sẽ được sử dụng một cách nhất quán, thì nó sẽ không gây tác hại gì với chương trình. Designer phải xem xét xem mức độ “closed” của ứng dụng so với lợi ích có được khi sử dụng biến toàn cục trong một số trường hợp.
- Như vậy, tương tự như public member variables, vấn đề sử dụng biến toàn cục sẽ tùy vào phong cách, nhu cầu thiết kế của designer. Trong một số trường hợp, sử dụng biến toàn cục sẽ rất tiện lợi và có thể tăng performance cho ứng dụng. Trong các trường hợp như vậy, không nên máy móc theo open-closed mà bỏ đi biến toàn cục trong khi sử dụng nó cũng chẳng gây ra lỗi gì.
RTTI is Dangerous
- Một nguyên tắc khác cũng rất phỏ biến đó là sử dụng dynamic_cast. Người ta thường khuyên nên tránh sử dụng các chức năng ép kiểu trong các ngôn ngữ lập trình, rằng nó nguy hiểm và nên tránh sử dụng càng nhiều càng tốt. Hãy xem ví dụ 6 đã vi phạm nguyên tắc open-closed khi sử dụng dinamic_cast như thế nào:
class Sharp
{
}
class Circle : Sharp
{
public void DrawCircle()
{
}
}
class Square : Sharp
{
public void DrawSquare()
{
}
}
void DrawAllShapes(Sharp[] list)
{
foreach(Sharp s in list)
{
Square square;
Circle circle;
try { square = (Square)s; } catch {}
try { circle = (Circle)s; } catch {}
if (square != null)
square.DrawSquare();
else if (circle != null)
circle.DrawCircle();
}
}
Code 6
- Tuy nhiên hình 7 cho ta thấy trong một trường hợp khác, vẫn sử dụng dynamic_cast nhưng không vi phạm nguyên tắc open-closed. Đoạn code chỉ là ví dụ minh họa vì có nhiều cách khác tốt hơn để implement method DrawOnlyCircles.
class Sharp
{
public virtual void Draw()
{
}
}
class Circle : Sharp
{
}
void DrawOnlyCircles(Sharp[] list)
{
foreach(Sharp s in list)
{
Circle circle;
try { circle = (Circle)s; } catch {}
if (circle != null)
circle.Draw();
}
}
Code 7
- Điểm khác nhau giữa hai cách trên là trong hình 6, code bắt buộc phải được sửa lại khi có một lớp con mới của lớp Shape được thêm vào. Trong khi đó, hình 7 cho ta thấy rằng vẫn có sử dụng dynamic_cast nhưng vẫn không vi phạm open-closed và lớp mới được thêm vào sẽ không làm đoạn code này chạy sai. Nói chung, nếu RTTI không ảnh hưởng nguyên tắc open-closed thì nó an toàn để sử dụng.
Kết luận
- Có nhiều điều để bàn tiếp về nguyên tắc open-closed nhưng nhiều người đồng ý rằng nguyên tắc này là nền tảng của thiết kế hướng đối tượng. Tuân theo nguyên tắc này sẽ giúp ta đạt được những lợi ích của hướng đối tượng: đó là khả năng sử dụng lại và dễ bảo trì. Tuy nhiên, để đạt được điều đó không đơn giản là viết phần mềm = ngôn ngữ lập trình hướng đối tượng. Mà hơn thế, nó đòi hỏi người thiết kế phải apply các abstraction và những phần của chương trình mà anh ta cho rằng nó có thể được thay đổi/mở rộng trong tương lai.
Dịch và bịa lại từ: The Open-Closed Principle
Tham khảo: http://objectmentor.com/resources/publishedArticles.html