12/08/2018, 13:50

The Quality of Software Design - Part 1

The Quality of Software Design ~Kỳ 1~ Part 0. Preface Hãy cùng suy nghĩ về chất lượng của thiết kế phần mềm. “Thiết kế phần mềm” cụ thể nói đến công việc như thế nào nhỉ? Xác định Requirement Definition, tạo Functional Specification, tạo Architecture, quyết định Class Structure, ...

The Quality of Software Design ~Kỳ 1~

Part 0. Preface

Hãy cùng suy nghĩ về chất lượng của thiết kế phần mềm.

“Thiết kế phần mềm” cụ thể nói đến công việc như thế nào nhỉ? Xác định Requirement Definition, tạo Functional Specification, tạo Architecture, quyết định Class Structure, thực hiện coding, theo bạn giai đoạn "thiết kế" nằm ở đâu trong các bước trên? Chắc hẳn hầu hết mọi người đều áng chừng rằng trong những công đoạn nói trên, “thiết kế” được tính từ “xác định Requirement Definition” đến “quyết định Class Structure”. Vậy “thực hiện coding” không phải là thiết kế ư? Thực ra, có rất nhiều ý kiến về vấn đề này. Nếu như giao phó việc thực hiện chương trình cho người thực hiện code trong trạng thái khi các method đã được định nghĩa, thì người thực hiện code đó sẽ phải lựa chọn data structure, xác định thuật toán chứ không phải lắp ghép một cách tùy tiện các thành phần nào đó lại là được. Nếu như vậy thì chẳng lẽ lại không phải là "thiết kế" hay sao?

Có thể có người tin vào lý thuyết “Nếu thiết kế là vấn đề trừu tượng hóa (hay nói cách khác là ký hiệu hóa) thì coding là vấn đề cụ thể hóa.” Nếu suy nghĩ trên quan điểm đó thì việc xác định class structure phải được đưa vào phase coding mới hợp lý. Khi đó, người làm Functional Specification = người thiết kế? Vậy việc tạo architecture là thiết kế hay thực hiện coding?

Thực ra, bản thân tôi cho rằng định nghĩa như thế nào cũng được. Khi đã bỏ qua định nghĩa, tôi nghĩ hầu như tất cả mọi người đều sẽ đồng ý rằng “quá trình thiết kế” từ thiết kế kiến trúc phần mềm đến thiết kế data của class hay thiết kế thuật toán dùng trong method đều có ảnh hưởng rất lớn tới chất lượng phần mềm. Tôi nghĩ từ lần này trở đi, chúng ta hãy cùng thay đổi từ khóa về phương pháp nâng cao chất lượng phần mềm theo ý nghĩa này.

Ở đây, tôi muốn nói trước về một việc hết sức quan trọng.

Trong bài báo này tôi sẽ dùng những ví dụ vô cùng đơn giản để giải thích. Tuy nhiên, trong thực tế, tôi muốn các bạn hiểu được rằng “Chúng là những ví dụ tuy đơn giản nhưng có thể làm phát sinh những vấn đề hết sức phức tạp”. Đó chính là những vấn đề có thể xảy ra trong phần mềm mà các bạn phát triển thực tế. Đây là những ví dụ đơn giản nên các bạn có thể dễ dàng thấy được vấn đề. Nhưng trong thực tế, requirement thường có nội dung phức tạp hơn, do đó có thể vấn đề sẽ bị che lấp, khó nhận ra được. Tôi thường nghe thấy các bạn mải tập trung vào thiết kế logic cho các class, method hay bận code mà không có thời gian rảnh để nghĩ đến những nội dung không cần thiết khác. Nhưng, tôi muốn các bạn bắt đầu nghĩ xa hơn. Khi gặp khó khăn với một lượng bug lớn trong giai đoạn nửa sau của dự án thì đó không chỉ là vấn đề với logic chính của method. Khi các bạn nghĩ: “những nội dung (là) không cần thiết khác” thì đó chính là mầm mống của những vấn đề lớn gây ảnh hưởng đển chất lượng phần mềm. Ngoài ra, chính “ý thức về vấn đề” như vậy là thứ vô cùng quan trọng trong việc tạo ra phần mềm có chất lượng tốt.

Vậy, từ phần tiếp theo, chúng ta hãy cùng xem lần lượt từng “từ khóa” nhé.

Part 1. Correctness and Robustness

Nếu các bạn mở bất kỳ cuốn sách giáo khoa nào về thiết kế phần mềm ra, đầu tiên, hẳn các bạn sẽ thấy đập vào mắt mình là từ khóa về chất lượng “Correctness”. Có lẽ có cách giải thích từ khóa ấy là “khả năng thực hiện các hoạt động được định nghĩa trong bản đặc tả đúng theo ý muốn” v.v...Tuy nhiên, đó chỉ là những lời trong sách. Bản thân các bạn đã từng thử suy nghĩ một cách thấu đáo về định nghĩa này bao giờ chưa? Nếu chưa thì tôi muốn các bạn hãy thử suy ngẫm về nó nhé.

Đầu tiên, các bạn hình dung về “bản đặc tả/spec” như thế nào? Có thể có bạn nghĩ đó là “Requirement Specification”? có bạn cho là “Design Specification”? mỗi người đều có câu trả lời khác nhau cho vấn đề này. Nhưng nếu thảo luận về Correctness thì tôi cho rằng “spec” mà các bạn nghĩ đến hầu hết sẽ là một trong hai đáp án trên. Chúng ta sẽ bàn về Correctness của “Requirement Specification” vào dịp khác. Lần này hãy cùng bàn về Correctness của “Detail Design Specification”. Bản thân cụm từ “Detail Design Specification” trong những năm gần đây có lẽ không được quen thuộc lắm với quy trình Agile. Nhưng có thể giải thích theo một cách khác về cụm từ này là thiết kế (giá trị mong muốn) của method được định nghĩa ở Class diagram hoặc CRC card (Class - Responsibility - Collaboration card).

Hãy cùng thử suy nghĩ về method sau đây như một ví dụ đơn giản nhất.

  • method double getProduct(double, double)
  • Giá trị trả về là tích của 2 số thực có dấu chấm động được truyền với tư cách là đối số.

Do ví dụ này quá đơn giản so với nội dung của method nên các bạn khó có thể thấy rõ. Nhưng tôi nghĩ đây là mô tả thích hợp trong thực tế về “Detail Design Specification” hay Responsibility của method. Vậy khi được yêu cầu code method này, bạn sẽ xử lý như thế nào? Có lẽ các bạn sẽ tạo method lấy 2 biến double a, b là đối số, trả về a * b. Kết quả là có thực là chỉ cần như vậy là xong không?

1: double getProduct(double a, double b) { 2: return a*b; 3: }

Code kiểu như trên ư?

Hãy cùng thay đổi nội dung một chút.

  • method double getDivision(double, double)
  • Trong 2 số thực có dấu chấm động được truyền với tư cách là đối số, trả về giá trị bằng kết quả phép chia đối số thứ nhất cho đối số thứ 2.

Nếu làm như vậy thì có vẻ vấn đề sẽ dễ hiểu hơn một chút.

1: double getDivision(double a, double b) { 2: return a/b; 3: }  Code như trên có được không?

Không, code như thế này thì có chút vấn đề. Nếu để nguyên đoạn code như trên, trong trường hợp b=0, phép tính trở thành “phép chia cho 0”. Khi đó, có lẽ điều băn khoăn nhất là làm thế nào để xử lý được trường hợp “đối số thứ 2 là 0”. Nếu các bạn chưa từng bao giờ băn khoăn về vấn đề này thì từ nay về sau tôi muốn các bạn hãy chú ý đến vấn đề này nhé. Còn nếu các bạn đã băn khoăn về vấn đề này thì tiếp theo, để giải quyết nó, hãy cùng điều tra “phía gọi method mong muốn điều gì” để có thể định nghĩa một cách rõ ràng. “Mong muốn điều gì” ở đây có nghĩa là “method của phía gọi mong muốn điều gì” và cũng có nghĩa là “người code phía gọi mong muốn điều gì”. Cho nên cần phải xác nhận với người phụ trách phía gọi, định nghĩa cho rõ ràng rồi mô tả rõ spec của method.

Trường hợp các bạn thực hiện phép chia mà không hề suy nghĩ hay cân nhắc gì thì hệ quả của nó sẽ tùy thuộc theo xử lý hay môi trường thực hiện nên chúng ta không bàn đến điều này ở đây. Song, nếu nói về đối sách cho trường hợp này, ta có thể nghĩ đến việc trước khi thực hiện phép chia nên check xem đối số thứ 2 có phải là 0 hay không. Nếu là 0 thì ném exception hoặc trả về NaN. Những hoạt động này rất khác biệt và do đó có khả năng thành nguyên nhân của những bug tiềm ẩn tùy theo phương pháp thiết kế của phía gọi. Khi phía gọi mong muốn exception mà phía method lại trả về NaN, có thể NaN sẽ vẫn tồn tại ở biến số và phép chia vô hiệu vẫn tiếp tục được thực hiện. Ngược lại nếu phía gọi mong muốn trả về NaN và có thực hiện check nhưng phía method lại ném exception thì sẽ rơi vào tình trạng: exception không được phía gọi catch mà sẽ bị giữ lại ở exception catch của stack ở tầng trên khiến cho xử lý lỗi đúng không được thực hiện. Hoặc không có chỗ nào catch exception cả và phát sinh hiện tượng ứng dụng bị kết thúc.

Như tôi đã nói ở phần đầu, “Correctness” thường được định nghĩa là “khả năng thực hiện các hoạt động được định nghĩa trong bản đặc tả đúng theo ý muốn”. Nhưng trong thực tế, kể cả khi spec không định nghĩa rõ ràng thì những xử lý ảnh hưởng đến behavior của toàn bộ phần mềm mà tôi nêu ở đây như xử lý exception, xử lý luân phiên v.v.. cũng rất cần thiết. Ở giai đoạn coding, những nội dung này không được code “tùy tiện” mà quan trọng là cần phải điều chỉnh spec với các bên liên quan để có thể định nghĩa rõ ràng. Điều này sẽ đóng góp rất nhiều vào chất lượng của phần mềm cuối cùng. Thêm nữa, việc này không chỉ là “thực hiện coding” mà chính là thiết kế phần mềm. Correctness mà người phụ trách coding cần phải chú ý không chỉ là “coding theo những gì được yêu cầu”.

Chúng ta hãy cùng quay lại ví dụ đầu tiên. Trong trường hợp “trả về giá trị là tích của 2 số thực có dấu chấm động được truyền với tư cách là đối số” sẽ không tồn tại trường hợp “không có yêu cầu nhưng phải định nghĩa” như trường hợp phép chia ở phần trước nhỉ? Nếu suy nghĩ cẩn thận thì sẽ để ý thấy có khả năng bị overflow. Có thể kết quả phép tính nhân sẽ vượt quá giá trị lớn nhất có thể thể hiện bằng dấu chấm động. Nếu so sánh trường hợp này với phép chia cho 0 ở phần trước ta có thể nhận thấy khả năng hard code để ném exception là thấp (tất nhiên, điều này cũng tùy thuộc vào luồng xử lý và hệ thống) nên hầu như những nội dung này đều không được nêu rõ trong requirement. Tuy nhiên, khả năng phát sinh vấn đề này không phải là 0. Ngoài ra, có thể khó nhận ra nhưng ở trong ví dụ thứ 2, method mà “trong 2 số thực có dấu chấm động được truyền như 2 đối số, trả về giá trị là kết quả của phép chia đối số thứ nhất cho đối số thứ 2” cũng có khả năng phát sinh overflow. Các bạn có nhận ra không?

Hãy suy nghĩ thêm một method giống như thế nữa.

  • method long getProduct(long, long)
  • Trả về giá trị là số nguyên có dấu là tích của 2 số nguyên có dấu được truyền với tư cách là đối số. Trường hợp “giá trị trả về là số có dấu chấm động”, do sẽ trả về NaN nếu giá trị trả về bị overflow nên tuy phía gọi có thể check lỗi nhưng tại thời điểm kiểu của biến số giá trị trả về trở thành “số nguyên có dấu” thì không thể sử dụng phương pháp trả về NaN nữa. Chỉ vì kiểu biến số thay đổi mà không thể truyền lỗi sang phía gọi! Các bạn có thể sẽ phải đối mặt với vấn đề này khi dùng template class v.v... Hãy chú ý tới trường hợp kiểu có thể lưu giá trị lỗi như giá trị và kiểu không thể lưu giá trị lỗi bị lẫn lộn với nhau.

    Việc “phải thao tác để không thất bại khi có lỗi hoặc exception không được định nghĩa nhưng có khả năng phát sinh” được gọi là ”Robustness”. Ví dụ: chúng ta có thể nghĩ đến rất nhiều tình huống “ngoài giả định của spec” như “trường hợp có NaN sẵn ở biến số có dấu chấm động của đối số” hay “kết quả tính toán bị underflow, phát sinh phép chia cho 0” hoặc là “trường hợp được giao cho thiết kế data ngoài dự định và thiếu dung lượng bộ nhớ”. Thiết kế đã cân nhắc đầy đủ để có thể hoạt động mà không bị fail trong những tình huống này phải là “thiết kế ý thức được về Robustness”. Những phần mềm được tạo ra mà người tạo không có ý thức về Robustness sẽ dễ dàng bị crash trên những môi trường hơi cũ một chút. Gần đây, hầu như không xảy ra hiện tượng crash nữa nhưng thay vào đó, nhiều khi phần mềm xuất hiện những hoạt động không đúng.

    Thực tế, rất khó để phân chia ranh giới giữa ”Correctness” và ”Robustness” nêu ở trên và việc phân chia này cũng không có ý nghĩa lắm. Các bạn có thể định nghĩa “việc thực hiện đúng như những gì được mô tả rõ trong spec là Correctness, khả năng xử lý thích hợp cho những tình huống ngoại lệ không được mô tả rõ là Robustness” cũng được. Nhưng, theo ý của tôi thì các bạn nên nghĩ là “việc thực hiện đúng những chức năng “phải” được ghi rõ trong spec là Correctness”. Tóm lại, những thứ có ảnh hưởng đến interface của phía gọi như trong ví dụ về phép chia cho 0 đã nêu ở phần trước cần phải được ghi rõ trong spec. Nếu những nội dung này không có trong requirement thì phải điều chỉnh với các bên liên quan để định nghĩa rõ. Tuy nhiên, không cần thiết phải phân biệt rõ “cái này là Correctness” “cái kia là Robutness”, chỉ cần có ý niệm về “Correctness và Robutness” là được. Thêm nữa, tôi muốn các bạn nhớ rằng Robustness có đặc điểm là “dù là những nội dung không được viết rõ trong yêu cầu nhưng khi coding thì cần thường xuyên chú ý đến những nội dung này.”

Hãy suy nghĩ về một ví dụ khác.

  • method unsigned long getAverage(vector<unsigned long>)
  • Trả về giá trị trung bình của các yếu tố trong mảng số nguyên được truyền với tư cách là đối số

Tôi xin nêu ra ví dụ về đoạn code không đúng.

1: unsigned long getAverage(vector<unsigned long> vec) { 2: vector<unsigned long>::iterator itr; 3: unsigned long sum=0; 4: for (itr = vec.begin(); 5: itr != vec.end(); 6: itr++ ) { 7: sum += *itr; 8: } 9: unsigned long average = sum / vec.size(); 10: return average; 11: }

Có 2 chỗ bị sai. Đó là ở đâu nhỉ?  Chỗ đầu tiên có lẽ dễ nhận ra. Trong trường hợp vec là empty thì ở dòng 9 sẽ xảy ra điều gì? Đúng vậy, sẽ phát sinh phép chia cho 0. Trong trường hợp như vậy, nếu size() là 0 thì đoạn xử lý từ dòng 3 đến dòng 9 sẽ trở nên vô nghĩa. Do đó thông thường ta nên check size() đầu tiên.

Ngoài ra, còn một chỗ sai nữa là chỗ nào nhỉ? Gợi ý là “sum”.

Đúng vậy, “sum += *itr;” có thể bị overflow. Vec là mảng unsigned long nên không được tính tổng bằng giá trị đưa vào có độ lớn tương đương. Ở đây dùng C++ nên cần phải định nghĩa long là 32 bit, sum là long long 64 bit.

Tất nhiên, trong môi trường mà method đó được sử dụng tuyệt đối không phát sinh overflow thì để nguyên long cũng không sao. Bằng việc thiết đặt sum là long long thì khi cộng thêm *itr, bắt buộc phải thực hiện chuyển đổi kiểu. Do đó, nếu trong hệ thống mà chi phí để thực hiện việc chuyển đổi kiểu này là lớn thì có lẽ việc giữ nguyên sum là long sẽ hợp lý trên căn cứ là “tuyệt đối sẽ không phát sinh overflow”. Tuy nhiên, đó cũng chỉ là trường hợp đặc biệt, thông thường thì không cần làm như vậy.

Điều tôi muốn các bạn lưu ý ở đây là vấn đề về kiểu của sum này tuy là có rắc rối về overflow nhưng chắc chắn không phải là vấn đề về Robustness. Đây là vấn đề hoạt động có theo như mong muốn hay không nên đây là vấn đề về Correctness. Trong yêu cầu “trả về giá trị trung bình các yếu tố của mảng số nguyên được truyền với tư cách là đối số” thì điều kiện “giá trị lớn nhất của tổng các yếu tố của mảng số nguyên” không được ghi rõ nên việc dù mảng số nguyên được truyền là mảng như thế nào, do phát sinh overflow hay underflow nên không thể lấy được giá trị trung bình đúng thì không thể coi đó là đã thỏa mãn Correctness được.

Thực ra trong các dự án tôi tham gia từ trước đến nay, tôi đã thấy rất nhiều ví dụ về việc chưa xem xét đầy đủ về “Correctness có thể xảy ra nhưng không được mô tả rõ trong requirement hay spec” mà đã thiết kế/ coding. Hơn nữa, hệ quả là ở giai đoạn nửa sau của dự án, có rất nhiều bug phát sinh. Tuy đã thực hiện chỉnh sửa nhưng vẫn phát sinh nhiều bug khó. Nguyên nhân là do khi thực hiện code, người xử lý chỉ sử dụng một vài data đơn giản để kiểm tra, do đó trong nhiều trường hợp đã không phát hiện được sai lầm kịp thời khi commit. Kể cả khi phát hiện được bug ở giai đoạn UT, dù là bug có thể dễ dàng sửa, nhưng nếu sau khi đã đưa đoạn code lỗi đó vào hệ thống lớn mới phát hiện ra thì chi phí để điều tra nguyên nhân của bug là rất tốn kém. Các bạn cần nhớ rằng quan trọng là phải phát hiện được bug càng sớm càng tốt, tốt nhất là trong giai đoạn thiết kế hay coding cần được xử lý để không phát sinh bug.

Cuối cùng, nhân dịp bàn về Robustness, tôi không thể không bàn về Secure Programming mà đã kết thúc bài viết này. Đặc biệt, ngày nay, khi hầu hết các phần mềm đều có kết nối với Internet, xử lý những nội dung (contents) hay những file của người khác hoặc của ai đó mà chúng ta không biết. Việc thiết kế và thực hiện code phần mềm đảm bảo secure trước những cuộc tấn công từ bên ngoài là một vấn đề vô cùng quan trọng. Tuy nhiên, riêng Secure Programming đã là một chủ đề lớn, phải viết cả một quyển sách mới đủ nên những nội dung được nêu ở bài báo này là không thể đầy đủ. Nhưng, may thay những bài báo về Secure Programming có rất nhiều trên mạng. Các bạn hãy thử đọc những bài báo ấy và tìm hiểu xem “thực hiện tấn công như thế nào”, “làm thế nào để bảo vệ mình trước những cuộc tấn công đó”. Secure Programming không chỉ nhằm bảo đảm cấu trúc server mà còn phải đảm bảo việc thiết kế, thực hiện code cho toàn bộ các ứng dụng trên mobile, phần mềm của PC, phần mềm nhúng của các thiết bị hình ảnh của TV nữa.

0