Khái niệm thiết kế tốt hay xấu là tương đối. Đồng thời, có một số tiêu chuẩn lập trình, trong hầu hết các trường hợp đảm bảo tính hiệu quả, khả năng bảo trì và khả năng kiểm tra. Ví dụ, trong ngôn ngữ hướng đối tượng, đây là việc sử dụng tính đóng gói, kế thừa và đa hình. Có một tập hợp các mẫu thiết kế mà trong một số trường hợp có ảnh hưởng tích cực hoặc tiêu cực đến thiết kế ứng dụng tùy theo tình huống. Mặt khác, có những mặt đối lập, đôi khi dẫn đến thiết kế có vấn đề.
Thiết kế này thường có các chỉ số sau (một hoặc nhiều chỉ số tại một thời điểm):
- Độ cứng (rất khó sửa đổi mã, vì một thay đổi đơn giản sẽ ảnh hưởng đến nhiều nơi);
- Tính bất động (rất phức tạp khi chia mã thành các mô-đun có thể được sử dụng trong các chương trình khác);
- Độ nhớt (khá khó để phát triển hoặc kiểm tra mã);
- Không cần phức tạp (có một chức năng không được sử dụng trong mã);
- Lặp lại không cần thiết (Sao chép / Dán);
- Khả năng đọc kém (khó hiểu mã được thiết kế để làm gì và để duy trì nó);
- Tính mỏng manh (rất dễ phá vỡ chức năng ngay cả với những thay đổi nhỏ).
Bạn cần có khả năng hiểu và phân biệt các tính năng này để tránh thiết kế có vấn đề hoặc dự đoán các hậu quả có thể xảy ra khi sử dụng nó. Các chỉ số này được mô tả trong cuốn sách «Các nguyên tắc, mô hình và thực hành Agile trong C #» của Robert Martin. Tuy nhiên, không có mô tả ngắn gọn và không có ví dụ mã nào trong bài viết này cũng như trong các bài viết đánh giá khác.
Chúng tôi sẽ loại bỏ nhược điểm này trên từng tính năng.
Độ cứng nhắc
Như nó đã được đề cập, một mã cứng nhắc rất khó được sửa đổi, ngay cả những điều nhỏ nhất. Đây có thể không phải là vấn đề nếu mã không được thay đổi thường xuyên hoặc ít. Vì vậy, mã hóa ra là khá tốt. Tuy nhiên, nếu cần phải sửa đổi mã và khó thực hiện điều này, thì nó sẽ trở thành một vấn đề, ngay cả khi nó hoạt động.
Một trong những trường hợp cứng nhắc phổ biến là chỉ định rõ ràng các kiểu lớp thay vì sử dụng các lớp trừu tượng (giao diện, lớp cơ sở, v.v.). Dưới đây, bạn có thể tìm thấy một ví dụ về mã:
class A { B _b; public A() { _b = new B(); } public void Foo() { // Do some custom logic. _b.DoSomething(); // Do some custom logic. } } class B { public void DoSomething() { // Do something } }
Ở đây hạng A phụ thuộc vào hạng B rất nhiều. Vì vậy, nếu trong tương lai bạn cần sử dụng một lớp khác thay vì lớp B, điều này sẽ yêu cầu thay đổi lớp A và sẽ dẫn đến việc nó được kiểm tra lại. Ngoài ra, nếu lớp B ảnh hưởng đến các lớp khác, tình hình sẽ trở nên phức tạp hơn rất nhiều.
Giải pháp thay thế là một cách trừu tượng là giới thiệu giao diện IComponent thông qua phương thức khởi tạo của lớp A. Trong trường hợp này, nó sẽ không còn phụ thuộc vào lớp cụ thể В và sẽ chỉ phụ thuộc vào giao diện IComponent. Đến lượt nó, Сlass В phải triển khai giao diện IComponent.
interface IComponent { void DoSomething(); } class A { IComponent _component; public A(IComponent component) { _component = component; } void Foo() { // Do some custom logic. _component.DoSomething(); // Do some custom logic. } } class B : IComponent { void DoSomething() { // Do something } }
Hãy cung cấp một ví dụ cụ thể. Giả sử có một tập hợp các lớp ghi lại thông tin - Người quản lý sản phẩm và Người tiêu dùng. Nhiệm vụ của họ là lưu trữ một sản phẩm trong cơ sở dữ liệu và đặt hàng tương ứng. Cả hai lớp đều ghi lại các sự kiện có liên quan. Hãy tưởng tượng rằng lúc đầu có một bản ghi vào một tệp. Để làm điều này, lớp FileLogger đã được sử dụng. Ngoài ra, các lớp được đặt trong các mô-đun (tập hợp) khác nhau.
// Module 1 (Client) static void Main() { var product = new Product("milk"); var productManager = new ProductManager(); productManager.AddProduct(product); var consumer = new Consumer(); consumer.PurchaseProduct(product.Name); } // Module 2 (Business logic) public class ProductManager { private readonly FileLogger _logger = new FileLogger(); public void AddProduct(Product product) { // Add the product to the database. _logger.Log("The product is added."); } } public class Consumer { private readonly FileLogger _logger = new FileLogger(); public void PurchaseProduct(string product) { // Purchase the product. _logger.Log("The product is purchased."); } } public class Product { public string Name { get; private set; } public Product(string name) { Name = name; } } // Module 3 (Logger implementation) public class FileLogger { const string FileName = "log.txt"; public void Log(string message) { // Write the message to the file. } }
Nếu lúc đầu chỉ sử dụng tệp là đủ và sau đó cần đăng nhập vào các kho lưu trữ khác, chẳng hạn như cơ sở dữ liệu hoặc dịch vụ lưu trữ và thu thập dữ liệu dựa trên đám mây, thì chúng ta sẽ cần phải thay đổi tất cả các lớp trong logic nghiệp vụ mô-đun (Mô-đun 2) sử dụng FileLogger. Rốt cuộc, điều này có thể trở nên khó khăn. Để giải quyết vấn đề này, chúng tôi có thể giới thiệu một giao diện trừu tượng để làm việc với trình ghi nhật ký, như được hiển thị bên dưới.
// Module 1 (Client) static void Main() { var logger = new FileLogger(); var product = new Product("milk"); var productManager = new ProductManager(logger); productManager.AddProduct(product); var consumer = new Consumer(logger); consumer.PurchaseProduct(product.Name); } // Module 2 (Business logic) class ProductManager { private readonly ILogger _logger; public ProductManager(ILogger logger) { _logger = logger; } public void AddProduct(Product product) { // Add the product to the database. _logger.Log("The product is added."); } } public class Consumer { private readonly ILogger _logger; public Consumer(ILogger logger) { _logger = logger; } public void PurchaseProduct(string product) { // Purchase the product. _logger.Log("The product is purchased."); } } public class Product { public string Name { get; private set; } public Product(string name) { Name = name; } } // Module 3 (interfaces) public interface ILogger { void Log(string message); } // Module 4 (Logger implementation) public class FileLogger : ILogger { const string FileName = "log.txt"; public virtual void Log(string message) { // Write the message to the file. } }
Trong trường hợp này, khi thay đổi loại trình ghi nhật ký, chỉ cần sửa đổi mã máy khách (Chính), mã này khởi tạo trình ghi nhật ký và thêm nó vào phương thức khởi tạo của ProductManager và Người tiêu dùng. Do đó, chúng tôi đã đóng các lớp logic nghiệp vụ từ việc sửa đổi loại trình ghi nhật ký theo yêu cầu.
Ngoài các liên kết trực tiếp đến các lớp đã sử dụng, chúng tôi có thể giám sát độ cứng trong các biến thể khác có thể dẫn đến khó khăn khi sửa đổi mã. Có thể có một tập hợp chúng vô hạn. Tuy nhiên, chúng tôi sẽ cố gắng cung cấp một ví dụ khác. Giả sử có một mã hiển thị khu vực của một mẫu hình học trên bảng điều khiển.
static void Main() { var rectangle = new Rectangle() { W = 3, H = 5 }; var circle = new Circle() { R = 7 }; var shapes = new Shape[] { rectangle, circle }; ShapeHelper.ReportShapesSize(shapes); } class ShapeHelper { private static double GetShapeArea(Shape shape) { if (shape is Rectangle) { return ((Rectangle)shape).W * ((Rectangle)shape).H; } if (shape is Circle) { return 2 * Math.PI * ((Circle)shape).R * ((Circle)shape).R; } throw new InvalidOperationException("Not supported shape"); } public static void ReportShapesSize(Shape[] shapes) { foreach(Shape shape in shapes) { if (shape is Rectangle) { double area = GetShapeArea(shape); Console.WriteLine($"Rectangle's area is {area}"); } if (shape is Circle) { double area = GetShapeArea(shape); Console.WriteLine($"Circle's area is {area}"); } } } } public class Shape { } public class Rectangle : Shape { public double W { get; set; } public double H { get; set; } } public class Circle : Shape { public double R { get; set; } }
Như bạn thấy, khi thêm một mẫu mới, chúng ta sẽ phải thay đổi các phương thức của lớp ShapeHelper. Một trong những tùy chọn là vượt qua thuật toán kết xuất trong các lớp của các mẫu hình học (Hình chữ nhật và Hình tròn), như được hiển thị bên dưới. Bằng cách này, chúng tôi sẽ cách ly logic có liên quan trong các lớp tương ứng, do đó giảm trách nhiệm của lớp ShapeHelper trước khi hiển thị thông tin trên bảng điều khiển.
static void Main() { var rectangle = new Rectangle() { W = 3, H = 5 }; var circle = new Circle() { R = 7 }; var shapes = new Shape[]() { rectangle, circle }; ShapeHelper.ReportShapesSize(shapes); } class ShapeHelper { public static void ReportShapesSize(Shape[] shapes) { foreach(Shape shape in shapes) { shape.Report(); } } } public abstract class Shape { public abstract void Report(); } public class Rectangle : Shape { public double W { get; set; } public double H { get; set; } public override void Report() { double area = W * H; Console.WriteLine($"Rectangle's area is {area}"); } } public class Circle : Shape { public double R { get; set; } public override void Report() { double area = 2 * Math.PI * R * R; Console.WriteLine($"Circle's area is {area}"); } }
Do đó, chúng tôi thực sự đã đóng lớp ShapeHelper cho các thay đổi bổ sung các kiểu mẫu mới bằng cách sử dụng tính kế thừa và đa hình.
Tính bất động
Chúng tôi có thể giám sát sự bất động khi tách mã thành các mô-đun có thể sử dụng lại. Do đó, dự án có thể ngừng phát triển và bị cạnh tranh.
Ví dụ, chúng ta sẽ xem xét một chương trình máy tính để bàn, toàn bộ mã của chương trình này được triển khai trong tệp ứng dụng thực thi (.exe) và đã được thiết kế để logic nghiệp vụ không được xây dựng trong các mô-đun hoặc lớp riêng biệt. Sau đó, nhà phát triển đã phải đối mặt với các yêu cầu kinh doanh sau:
- Để thay đổi giao diện người dùng bằng cách biến nó thành một ứng dụng Web;
- Để xuất bản chức năng của chương trình dưới dạng một tập hợp các dịch vụ Web có sẵn cho khách hàng bên thứ ba để sử dụng trong các ứng dụng của riêng họ.
Trong trường hợp này, các yêu cầu này khó được đáp ứng vì toàn bộ mã nằm trong mô-đun thực thi.
Hình dưới đây cho thấy một ví dụ về một thiết kế bất động trái ngược với một thiết kế không có chỉ báo này. Chúng được ngăn cách bởi một đường thẳng. Như bạn có thể thấy, việc phân bổ mã trên các mô-đun có thể tái sử dụng (Logic), cũng như việc xuất bản chức năng ở cấp dịch vụ Web, cho phép sử dụng mã trong các ứng dụng khách (App) khác nhau, đó là một lợi ích chắc chắn.
Immobility cũng có thể được gọi là thiết kế nguyên khối. Rất khó để chia nó thành các đơn vị nhỏ hơn và hữu ích của mã. Làm thế nào chúng ta có thể trốn tránh vấn đề này? Ở giai đoạn thiết kế, tốt hơn hết là bạn nên suy nghĩ về khả năng sử dụng tính năng này hoặc tính năng đó trong các hệ thống khác. Mã dự kiến sẽ được sử dụng lại tốt nhất nên được đặt trong các mô-đun và lớp riêng biệt.
Độ nhớt
Có hai loại:
- Độ nhớt phát triển
- Độ nhớt của môi trường
Chúng ta có thể thấy độ nhớt phát triển trong khi cố gắng tuân theo thiết kế ứng dụng đã chọn. Điều này có thể xảy ra khi một lập trình viên cần phải đáp ứng quá nhiều yêu cầu trong khi có một cách phát triển dễ dàng hơn. Ngoài ra, độ nhớt phát triển có thể được nhìn thấy khi quá trình lắp ráp, triển khai và thử nghiệm không hiệu quả.
Như một ví dụ đơn giản, chúng ta có thể coi công việc với các hằng số sẽ được đặt (Theo thiết kế) thành một mô-đun riêng biệt (Mô-đun 1) để được sử dụng bởi các thành phần khác (Mô-đun 2 và Mô-đun 3).
// Module 1 (Constants) static class Constants { public const decimal MaxSalary = 100M; public const int MaxNumberOfProducts = 100; } // Finance Module #using Module1 static class FinanceHelper { public static bool ApproveSalary(decimal salary) { return salary <= Constants.MaxSalary; } } // Marketing Module #using Module1 class ProductManager { public void MakeOrder() { int productsNumber = 0; while(productsNumber++ <= Constants.MaxNumberOfProducts) { // Purchase some product } } }
Nếu vì bất kỳ lý do gì mà quá trình lắp ráp mất nhiều thời gian, các nhà phát triển sẽ khó có thể đợi cho đến khi nó kết thúc. Ngoài ra, cần lưu ý rằng mô-đun không đổi chứa các thực thể hỗn hợp thuộc các phần khác nhau của logic kinh doanh (mô-đun tài chính và tiếp thị). Vì vậy, mô-đun không đổi có thể được thay đổi khá thường xuyên vì những lý do độc lập với nhau, điều này có thể dẫn đến các vấn đề bổ sung, chẳng hạn như đồng bộ hóa các thay đổi.
Tất cả điều này làm chậm quá trình phát triển và có thể gây căng thẳng cho các lập trình viên. Các biến thể của thiết kế ít nhớt hơn sẽ là tạo các mô-đun hằng số riêng biệt - từng mô-đun cho mô-đun logic nghiệp vụ tương ứng - hoặc chuyển các hằng số đến đúng vị trí mà không cần sử dụng mô-đun riêng biệt cho chúng.
Một ví dụ về độ nhớt của môi trường có thể là việc phát triển và thử nghiệm ứng dụng trên máy ảo máy khách từ xa. Đôi khi quy trình làm việc này trở nên khó chịu do kết nối Internet chậm, vì vậy nhà phát triển có thể bỏ qua việc kiểm tra tích hợp mã đã viết một cách có hệ thống, điều này cuối cùng có thể dẫn đến lỗi ở phía máy khách khi sử dụng tính năng này.
Không cần phức tạp
Trong trường hợp này, thiết kế có chức năng thực sự không được sử dụng. Thực tế này có thể làm phức tạp việc hỗ trợ và duy trì chương trình, cũng như tăng thời gian phát triển và thử nghiệm. Ví dụ, hãy xem xét chương trình yêu cầu đọc một số dữ liệu từ cơ sở dữ liệu. Để thực hiện việc này, thành phần DataManager đã được tạo, thành phần này được sử dụng trong một thành phần khác.
class DataManager { object[] GetData() { // Retrieve and return data } }
Nếu nhà phát triển thêm một phương thức mới vào DataManager để ghi dữ liệu vào cơ sở dữ liệu (WriteData), phương thức này không chắc sẽ được sử dụng trong tương lai, thì đó cũng sẽ là một sự phức tạp không cần thiết.
Một ví dụ khác là một giao diện cho tất cả các mục đích. Ví dụ:chúng ta sẽ xem xét một giao diện với phương thức Process duy nhất chấp nhận một đối tượng kiểu chuỗi.
interface IProcessor { void Process(string message); }
Nếu nhiệm vụ là xử lý một loại thông báo nhất định với cấu trúc được xác định rõ ràng, thì việc tạo một giao diện được nhập đúng kiểu sẽ dễ dàng hơn là bắt các nhà phát triển giải mã chuỗi này thành một loại thông báo cụ thể mỗi lần.
Việc lạm dụng các mẫu thiết kế trong những trường hợp không cần thiết cũng có thể dẫn đến thiết kế có độ nhớt.
Tại sao lại lãng phí thời gian của bạn vào việc viết một đoạn mã có khả năng không sử dụng? Đôi khi, QA phải kiểm tra mã này, vì nó thực sự được xuất bản và được mở cho các khách hàng bên thứ ba sử dụng. Điều này cũng đặt ra thời gian phát hành. Việc bao gồm một tính năng cho tương lai chỉ đáng giá nếu lợi ích có thể có của nó vượt quá chi phí phát triển và thử nghiệm.
Lặp lại không cần thiết
Có lẽ, hầu hết các nhà phát triển đã phải đối mặt hoặc sẽ bắt gặp tính năng này, bao gồm nhiều lần sao chép cùng một logic hoặc mã. Mối đe dọa chính là lỗ hổng của mã này trong khi sửa đổi nó - bằng cách sửa một cái gì đó ở một nơi, bạn có thể quên làm điều này ở một nơi khác. Ngoài ra, cần nhiều thời gian hơn để thực hiện các thay đổi so với trường hợp mã không chứa tính năng này.
Việc lặp lại không cần thiết có thể là do sự cẩu thả của các nhà phát triển, cũng như do độ cứng / tính dễ vỡ của thiết kế khi việc không lặp lại mã sẽ khó hơn nhiều và rủi ro hơn là không làm điều này. Tuy nhiên, trong mọi trường hợp, khả năng lặp lại không phải là một ý tưởng hay và cần phải liên tục cải tiến mã, chuyển các phần có thể sử dụng lại cho các phương thức và lớp phổ biến.
Khả năng đọc kém
Bạn có thể theo dõi tính năng này khi khó đọc mã và hiểu nó được tạo ra để làm gì. Các lý do dẫn đến khả năng đọc kém có thể là do không tuân thủ các yêu cầu đối với việc thực thi mã (cú pháp, biến, lớp), logic triển khai phức tạp, v.v.
Dưới đây, bạn có thể tìm thấy ví dụ về mã khó đọc, mã này triển khai phương thức với biến Boolean.
void Process_true_false(string trueorfalsevalue) { if (trueorfalsevalue.ToString().Length == 4) { // That means trueorfalsevalue is probably "true". Do something here. } else if (trueorfalsevalue.ToString().Length == 5) { // That means trueorfalsevalue is probably "false". Do something here. } else { throw new Exception("not true of false. that's not nice. return.") } }
Ở đây, chúng tôi có thể phác thảo một số vấn đề. Thứ nhất, tên của các phương thức và biến không tuân theo các quy ước được chấp nhận chung. Thứ hai, việc thực hiện phương pháp này không phải là tốt nhất.
Có lẽ, nên lấy một giá trị Boolean, hơn là một chuỗi. Tuy nhiên, tốt hơn là chuyển nó thành giá trị Boolean ở đầu phương thức, thay vì sử dụng phương pháp xác định độ dài của chuỗi.
Thứ ba, văn bản ngoại lệ không tương ứng với văn phong chính thức. Đọc những văn bản như vậy, có thể có cảm giác rằng mã được tạo ra bởi một người nghiệp dư (vẫn còn, có thể có một điểm vấn đề). Phương thức có thể được viết lại như sau nếu nó nhận giá trị Boolean:
public void Process(bool value) { if (value) { // Do something. } else { // Do something. } }
Đây là một ví dụ khác về tái cấu trúc nếu bạn vẫn cần lấy một chuỗi:
public void Process(string value) { bool bValue = false; if (!bool.TryParse(value, out bValue)) { throw new ArgumentException($"The {value} is not boolean"); } if (bValue) { // Do something. } else { // Do something. } }
Bạn nên thực hiện tái cấu trúc với mã khó đọc, chẳng hạn như khi việc bảo trì và sao chép mã dẫn đến nhiều lỗi.
Tính mong manh
Tính dễ vỡ của một chương trình có nghĩa là có thể dễ dàng bị hỏng khi được sửa đổi. Có hai loại lỗi:lỗi biên dịch và lỗi thời gian chạy. Những cái đầu tiên có thể là mặt sau của sự cứng nhắc. Những cái sau là nguy hiểm nhất vì chúng xảy ra ở phía khách hàng. Vì vậy, chúng là một chỉ báo về tính mong manh.
Không nghi ngờ gì nữa, chỉ số này là tương đối. Một người nào đó sửa mã rất cẩn thận và khả năng xảy ra sự cố là khá thấp, trong khi những người khác thực hiện việc này một cách vội vàng và bất cẩn. Tuy nhiên, một mã khác với cùng một người dùng có thể gây ra một số lỗi khác nhau. Có thể, chúng ta có thể nói rằng càng khó hiểu mã và dựa vào thời gian thực thi của chương trình hơn là vào giai đoạn biên dịch, thì mã càng mỏng manh.
Ngoài ra, các chức năng sẽ không được sửa đổi thường bị treo. Nó có thể bị kết hợp logic cao của các thành phần khác nhau.
Hãy xem xét ví dụ cụ thể. Ở đây logic ủy quyền người dùng với một vai trò nhất định (được định nghĩa là tham số được cuộn) để truy cập vào một tài nguyên cụ thể (được định nghĩa là resourceUri) nằm trong phương thức tĩnh.
static void Main() { if (Helper.Authorize(1, "/pictures")) { Console.WriteLine("Authorized"); } } class Helper { public static bool Authorize(int roleId, string resourceUri) { if (roleId == 1 || roleId == 10) { if (resourceUri == "/pictures") { return true; } } if (roleId == 1 || roleId == 2 && resourceUri == "/admin") { return true; } return false; } }
Như bạn có thể thấy, logic rất phức tạp. Rõ ràng là việc thêm các vai trò và tài nguyên mới sẽ dễ dàng phá vỡ nó. Do đó, một vai trò nhất định có thể nhận được hoặc mất quyền truy cập vào tài nguyên. Tạo lớp Tài nguyên lưu trữ nội bộ mã định danh tài nguyên và danh sách các vai trò được hỗ trợ, như được hiển thị bên dưới, sẽ làm giảm tính mong manh.
static void Main() { var picturesResource = new Resource() { Uri = "/pictures" }; picturesResource.AddRole(1); if (picturesResource.IsAvailable(1)) { Console.WriteLine("Authorized"); } } class Resource { private List<int> _roles = new List<int>(); public string Uri { get; set; } public void AddRole(int roleId) { _roles.Add(roleId); } public void RemoveRole(int roleId) { _roles.Remove(roleId); } public bool IsAvailable(int roleId) { return _roles.Contains(roleId); } }
Trong trường hợp này, để thêm tài nguyên và vai trò mới, không cần thiết phải sửa đổi mã logic ủy quyền, nghĩa là thực sự không có gì để phá vỡ.
Điều gì có thể giúp bắt lỗi thời gian chạy? Câu trả lời là thử nghiệm thủ công, tự động và đơn vị. Quá trình kiểm tra được tổ chức càng tốt thì càng có nhiều khả năng xảy ra mã dễ vỡ ở phía máy khách.
Thông thường, tính dễ vỡ là mặt sau của các dấu hiệu nhận dạng khác của thiết kế xấu, chẳng hạn như độ cứng, khả năng đọc kém và lặp lại không cần thiết.
Kết luận
Chúng tôi đã cố gắng phác thảo và mô tả các đặc điểm nhận dạng chính của thiết kế xấu. Một số trong số chúng phụ thuộc lẫn nhau. Bạn cần hiểu rằng vấn đề thiết kế không phải lúc nào cũng không tránh khỏi những khó khăn. Nó chỉ chỉ ra rằng chúng có thể xảy ra. Càng ít các số nhận dạng này được giám sát, xác suất này càng thấp.