Vietnamese (Tiếng Việt) translation by Brian Vu (you can also view the original English article)
Các framework cung cấp 1 công cụ giúp phát triển ứng dụng nhanh chóng, nhưng lại thường để lại các món "nợ kỹ thuật" nhanh như cách chúng giúp bạn tạo ra các tính năng. "Nợ kỹ thuật" được tạo ra khi việc bảo trì không được coi như 1 mục tiêu trọng tâm đối với các lập trình viên. Những thay đổi và việc gỡ lỗi trong tương lai sẽ trở nên tốn kém, do thiếu kiểm thử unit và cấu trúc mã lệnh cho ứng dụng.
Sau đây, chúng ta sẽ tìm hiểu cách tái cấu trúc mã lệnh giúp chúng có khả năng kiểm thử và bảo trì - cũng như tiết kiệm thời gian cho bạn.
Chủ đề của chúng ta sẽ bao gồm (ở mức độ nhẹ nhàng):
- DRY
- Dependency Injection
- Interfaces
- Containers
- Unit Tests với PHPUnit
Nào, cùng bắt đầu với một vài đoạn mã lệnh ví dụ điển hình. Chúng có thể là 1 model class trong bất kỳ framework nào.
class User { public function getCurrentUser() { $user_id = $_SESSION['user_id']; $user = App::db->select('id, username') ->where('id', $user_id) ->limit(1) ->get(); if ( $user->num_results() > 0 ) { return $user->row(); } return false; } }
Đoạn mã trên sẽ chạy, nhưng như vậy là chưa đủ bởi:
- Nó không có khả năng kiểm thử
- Chúng ta đang dựa vào biến toàn cục
$_SESSION
. Các unit-test framework, như PHPUnit, chạy trên trên môi trường command-line, nơi rất nhiều biến toàn cục không tồn tại. - Chúng ta đang dựa vào việc kết nối cơ sở dữ liệu. Lý tưởng nhất là chúng ta nên tránh việc kết nối cơ sở dữ liệu thật trong unit-test. Kiểm thử là việc kiểm tra mã lệnh, chứ không phải dữ liệu.
- Chúng ta đang dựa vào biến toàn cục
- Đoạn mã này cũng không có tính bảo trì. Ví dụ, nếu chúng ta thay đổi nguồn dữ liệu, chúng ta sẽ cần thay đổi nhân cơ sở dữ liệu trong mỗi thể hiện của
App::db
được sử dụng trong ứng dụng của chúng ta. Ngoài ra, có trường hợp chúng ta không chỉ muốn mỗi thông tin user hiện tại thì sao?
Thử Xây Dựng Unit Test
Dưới đây là việc cố gắng tạo ra 1 unit test cho chức năng trên.
class UserModelTest extends PHPUnit_Framework_TestCase { public function testGetUser() { $user = new User(); $currentUser = $user->getCurrentUser(); $this->assertEquals(1, $currentUser->id); } }
Cùng phân tích nó, bạn sẽ thấy. Đầu tiên, việc kiểm thử sẽ thất bại. Biến $_SESSION
được sử dụng trong đối tượng User
không tồn tại trong unit test, cũng như trong PHP command line.
Thứ hai, không có kết nối cơ sở dữ liệu nào được thiết lập. Điều này có nghĩa là, để nó có thể hoạt động, chúng ta sẽ cần can thiệp vào quá trình nạp mồi bootstrap của ứng dụng để lấy đối tượng App
và đối tượng ob
của nó. Nhớ là chúng ta cũng sẽ cần một kết nối cơ sở dữ liệu hoạt động được.
Để làm đoạn unit test trên chạy được, chúng ta sẽ cần:
- Cấu hình 1 PHPUnit CLI chạy trong ứng dụng của chúng ta
- Lệ thuộc vào kết nối cơ sở dữ liệu. Việc này đồng nghĩa với lệ thuộc vào 1 nguồn dữ liệu riêng biệt với unit test của chúng ta. Điều gì sẽ xảy ra nếu cơ sở dữ liệu dùng để kiểm thử không có dữ liệu chúng ta mong muốn? Sẽ ra sao nếu kết nối cơ sở dữ liệu của chúng ta chậm chạp?
- Dựa vào việc nạp mồi bootstrap chương trình sẽ làm tăng chi phí cho quá trình kiểm thử, làm chậm unit test một cách đáng kể. Lý tưởng nhất, hầu hết mã lệnh của chúng ta nên được kiểm thử độc lập với framework đang sử dụng.
Do đó, hãy kéo xuống để tìm hiểu làm thế nào chúng ta có thể cải thiện việc này.
Giữ cho mã DRY
Hàm getCurrentUser không thực sự cần thiết trong trường hợp đơn giản này. Dưới đây là 1 ví dụ thể hiện tinh thần nguyên tắc DRY, sự tối ưu hoá đầu tiên tôi chọn để thực hiện là khái quát hoá phương thức này.
class User { public function getUser($user_id) { $user = App::db->select('user') ->where('id', $user_id) ->limit(1) ->get(); if ( $user->num_results() > 0 ) { return $user->row(); } return false; } }
Điều này cung cấp cho ta một phương thức có thể sử dụng xuyên suốt ứng dụng. Chúng ta có thể truyền current user vào tại thời điểm gọi hàm, hơn là gán cứng 1 phương thức trong model. Mã lệnh trở nên module hoá và khả dụng cho việc bảo trì hơn khi nó không phụ thuộc vào các tính năng khác (vd như biến cục bộ session).
Tuy nhiên, việc này vẫn chưa làm cho mã lệnh có thể kiểm thử và bảo trì được. Chúng vẫn còn lệ thuộc vào việc kết nối cơ sở dữ liệu.
Dependency Injection
Để cải thiện tình hình này chúng ta thêm vào một vài Dependency Injection. Chúng ta sẽ truyền kết nối cơ sở dữ liệu vào class, khi đó model của chúng ta có thể trông như sau.
class User { protected $_db; public function __construct($db_connection) { $this->_db = $db_connection; } public function getUser($user_id) { $user = $this->_db->select('user') ->where('id', $user_id) ->limit(1) ->get(); if ( $user->num_results() > 0 ) { return $user->row(); } return false; } }
Tại thời điểm này, các dependency của model User
đã được cung cấp. Class của chúng ta không còn sử dụng 1 kết nối cơ sở dữ liệu cụ thể, cũng như không lệ thuộc vào bất kỳ đối tượng toàn cục nào.
Bây giờ, class của chúng ta cơ bản đã khả dụng cho kiểm thử. Chúng ta có thể truyền vào 1 CSDL tuỳ chọn và 1 user id, và kiểm tra kết quả trả về của phương thức được gọi. Chúng ta cũng có thể dễ dàng chuyển đổi các kết nối cơ sở dữ liệu riêng biệt (giả định rằng cả 2 csdl đều thực thi cùng các phương thức nhận dữ liệu). Tuyệt vời phải không nào.
Cùng xem unit test cho đoạn mã trên trông như thế nào nhé.
<?php use Mockery as m; use Fideloper\User; class SecondUserTest extends PHPUnit_Framework_TestCase { public function testGetCurrentUserMock() { $db_connection = $this->_mockDb(); $user = new User( $db_connection ); $result = $user->getUser( 1 ); $expected = new StdClass(); $expected->id = 1; $expected->username = 'fideloper'; $this->assertEquals( $result->id, $expected->id, 'User ID set correctly' ); $this->assertEquals( $result->username, $expected->username, 'Username set correctly' ); } protected function _mockDb() { // "Mock" (stub) database row result object $returnResult = new StdClass(); $returnResult->id = 1; $returnResult->username = 'fideloper'; // Mock database result object $result = m::mock('DbResult'); $result->shouldReceive('num_results')->once()->andReturn( 1 ); $result->shouldReceive('row')->once()->andReturn( $returnResult ); // Mock database connection object $db = m::mock('DbConnection'); $db->shouldReceive('select')->once()->andReturn( $db ); $db->shouldReceive('where')->once()->andReturn( $db ); $db->shouldReceive('limit')->once()->andReturn( $db ); $db->shouldReceive('get')->once()->andReturn( $result ); return $db; } }
Tôi đã thêm vào một vài điều mới mẻ trong unit test này: Mockey. Mockey cho phét bạn "mô phỏng" (mock) các đối tượng PHP (giả). Ở trường hợp này, chúng ta đang giả lập kết nối cơ sở dữ liệu. Với việc giả lập này, chúng ta có thể bỏ qua việc kiểm tra kết nối cơ sở dữ liệu và chỉ việc đơn giản thực hiện kiểm thử model của chúng ta.
Bạn có ý định tìm hiểu thêm về Mockey?
Ở đây, chúng ta đang mô phỏng 1 kết nối SQL. Chúng ta đang nói với đối tượng mô phỏng (mock object) rằng chúng ta muốn có các phương thức select
, where
, limit
và get
có thể gọi được bên trong nó. Tôi đang trả về thực thể Mock, để ánh xạ cách 1 đối tượng 'kết nối SQL' trả về chính nó ($this
), từ đó làm cho phương thức của nó có thể 'kết nối'. Chú ý rằng, đối với phương thức get
, tôi trả về kết quả dữ liệu được gọi - là một đối tượng stdClass
với dữ liệu user được gắn vào.
Việc này giải quyết một số vấn đề sau:
- Chúng ta chỉ kiểm tra model class. Chúng ta không bao gồm kiểm tra kết nối cơ sở dữ liệu.
- Chúng ta có thể kiểm soát đầu vào và đầu ra của kết nối cơ sở dữ liệu giả lập, và, vì thế, có thể tin cậy vào kết quả của cơ sở dữ liệu được gọi khi thực hiện kiểm thử. Tôi biết tôi sẽ nhận được kết quả user ID là "1" từ việc truy vấn dữ liệu mô phỏng.
- Chúng ta không cần gọi ứng dụng nào trong bootstrap, không cần có bất kỳ cấu hình nào hoặc phải sử dụng cơ sở dữ liệu hiện hành, để phục vụ việc kiểm thử.
Thậm chí có thể làm tốt hơn. Dưới đây bạn sẽ thấy sự thú vị này.
Interfaces
Để cải tiến hơn nữa, chúng ta có thể định nghĩa và thực thi một interface. Cùng xem đoạn mã dưới đây.
interface UserRepositoryInterface { public function getUser($user_id); } class MysqlUserRepository implements UserRepositoryInterface { protected $_db; public function __construct($db_conn) { $this->_db = $db_conn; } public function getUser($user_id) { $user = $this->_db->select('user') ->where('id', $user_id) ->limit(1) ->get(); if ( $user->num_results() > 0 ) { return $user->row(); } return false; } } class User { protected $userStore; public function __construct(UserRepositoryInterface $user) { $this->userStore = $user; } public function getUser($user_id) { return $this->userStore->getUser($user_id); } }
Có một vài thứ xảy ra ở đây.
- Đầu tiên, chúng ta định nghĩa một interface cho nguồn dữ liệu user của chúng ta. Interface này định nghĩa phương thức
getUser()
. - Tiếp theo, chúng ta thực thi interface này. Ở đây, chúng ta tạo ra 1 hệ thống thực thi MySQL. Chúng ta chấp nhận một đối tượng kết nối cơ sở dữ liệu, và sử dụng nó để lấy user từ cơ sở dữ liệu.
- Cuối cùng, chúng ta ràng buộc việc sử dụng class thực thi
UserInterface
trong modelUser
của chúng ta. Việc này đảm bảo rằng nguồn dữ liệu sẽ luôn có 1 phương thứcgetUser()
tồn tại, không cần biết nguồn dữ liệu nào thực thiUserInterface
.
Chú ý rằng model
User
sử dụng object type-hintsUserInterface
trong constructor của nó. Điều này có nghĩa là bắt buộc PHẢI truyền vào đối tượngUser
1 class thực thiUserInterface
. Việc này bảo đảm cho cái chúng ta đang dựa vào - chúng ta cần phương thứcgetUser
luôn tồn tại.
Kết quả của của việc này là gì ?
- Mã lệnh của chúng ta giờ đây hoàn toàn có thể kiểm thử. Đối với class
User
, chúng ta có thể dẽ dàng mô phỏng nguồn dữ liệu. (Kiểm tra hệ thống thực thi của nguồn dữ liệu sẽ là công việc tách biệt với unit test) - Mã của chúng ta mang tính bảo trì mạnh mẽ hơn. Chúng ta có thể chuyển đổi các nguồn dữ liệu khác nhau mà không phải thay đổi mã lệnh, xuyên suốt ứng dụng của chúng ta.
- Chúng ta có thể tạo BẤT KỲ nguồn dữ liệu nào. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser, v...v...
- Chúng ta có thể dễ dàng truyền bất kỳ nguồn dữ liệu nào vào đối tượng
User
khi chúng ta cần. Nếu bạn không muốn dùng SQL, bạn chỉ cần tạo 1 hệ thống thực thi khác (ví dụ nhưMongoDbUser
) và truyền nó vào trong modelUser
.
Chúng ta đã đơn giản hoá việc kiểm thử unit!
<?php use Mockery as m; use Fideloper\User; class ThirdUserTest extends PHPUnit_Framework_TestCase { public function testGetCurrentUserMock() { $userRepo = $this->_mockUserRepo(); $user = new User( $userRepo ); $result = $user->getUser( 1 ); $expected = new StdClass(); $expected->id = 1; $expected->username = 'fideloper'; $this->assertEquals( $result->id, $expected->id, 'User ID set correctly' ); $this->assertEquals( $result->username, $expected->username, 'Username set correctly' ); } protected function _mockUserRepo() { // Mock expected result $result = new StdClass(); $result->id = 1; $result->username = 'fideloper'; // Mock any user repository $userRepo = m::mock('Fideloper\Third\Repository\UserRepositoryInterface'); $userRepo->shouldReceive('getUser')->once()->andReturn( $result ); return $userRepo; } }
Chúng ta đã tách công đoạn mô phỏng việc kết nối dữ liệu ra riêng hoàn toàn. Thay vào đó, chúng ta chỉ đơn giản mô phỏng nguồn dữ liêu, và giao việc cho nó khi getUser
được gọi.
Nhưng, chúng ta thậm chí có thể làm tuyệt vời hơn nữa!
Containers
Cùng xem xét việc sử dụng đoạn mã bên dưới:
// In some controller $user = new User( new MysqlUser( App:db->getConnection("mysql") ) ); $user->id = App::session("user->id"); $currentUser = $user->getUser($user_id);
Cuối cùng, tôi muốn giới thiệu với các bạn về containers. Trong đoạn mã trên, chúng ta phải tạo và dùng nhiều đối tượng chỉ để lấy current user. Và mã lệnh này có thể được sử dụng lại nhiều lần trong ứng dụng của bạn. Nếu bạn muốn chuyển từ MySQL qua MongoDB, bạn sẽ cần chỉnh sửa ở nhiều nơi có đoạn mã trên. Điều này phá vỡ nguyên lý DRY. Containers có thể giải quyết vấn đề này.
Một container sẽ "chứa" một object hoặc một tính năng. Nó tương tự như 1 registry trong ứng dụng của bạn. Chúng ta có thể sử dụng 1 container để tự động khóa việc khởi tạo đối tượng User
với mọi dependencies cần thiết. Dưới đây, tôi sử dụng Pimple, một container class khá phổ biến.
// Somewhere in a configuration file $container = new Pimple(); $container["user"] = function() { return new User( new MysqlUser( App:db->getConnection('mysql') ) ); } // Now, in all of our controllers, we can simply write: $currentUser = $container['user']->getUser( App::session('user_id') );
Tôi đã chuyển tiến trình tạo model User
vào trong khu vực cấu hình của ứng dụng. Và kết qủa là:
- Chúng ta đã giữ cho mã lệnh DRY. Đối tượng
User
và tùy chọn lưu trữ dữ liệu được định nghĩa trong cùng 1 nơi trong ứng dụng của chúng ta. - Chúng ta có thể chuyển đổi việc sử dụng nguồn dữ liệu như MySQL hay bất kỳ nguồn nào khác cho model
User
trong cùng MỘT nơi. Việc này tăng tính bảo trì cho mã lệnh rất nhiều.
Kết Luận
Xuyên suốt toàn bộ bài viết này, chúng ta đã gặt hái được:
- Việc giữ cho mã DRY và khả năng sử dụng lại cao
- Xây dựng các đoạn mã có khả năng bảo trì - Chúng ta có thể chuyển đổi qua lại các nguồn dữ liệu cho các đối tượng trong cùng một nơi cho toàn bộ ứng dụng nếu cần.
- Làm cho mã lệnh của chúng ta có khả năng kiểm thử - Chúng ta có thể dễ dàng mô phỏng các đối tượng mà không cần dựa vào việc nạp mồi bootstrap cho ứng dụng hoặc tạo ra 1 cơ sở dữ liệu kiểm thử.
- Học cách sử dụng Dependency Injection và Interfaces, cho phép chúng ta tạo ra các đoạn mã mang tính khả dụng cho việc kiểm thử và bảo trì.
- Thấy được cách containers có thể giúp đỡ việc xây dựng 1 ứng dụng dễ bảo trì hơn.
Chắc bạn cũng để ý thấy là chúng ta đã thêm vào nhiều mã lệnh hơn để mang lại khả năng bảo trì và kiểm thử. Một lập luận mạnh mẽ có thể chống lại quan điểm này là: chúng ta đang làm tăng sự phức tạp lên. Thực tế thì những vấn đề này sẽ đòi hỏi sự hiểu biết chuyên sâu về lập trình, đối với cả người nắm chính dự án và các thành viên làm việc trong cùng 1 dự án.
Tuy nhiên, cái giá cho sự nghiên cứu và hiểu biết thì không đáng kể bởi nó giúp chúng ta giảm đi món "nợ kỹ thuật" rất nhiều.
- Mã lệnh cực kỳ dễ bảo trì, việc chỉnh sửa các thay đổi chỉ cần làm ở 1 nơi, thay vì phải thực hiện ở nhiều chỗ.
- Có thể nhanh chóng xây dựng unit test, sẽ giảm lỗi trong mã lệnh - đặc biệt là về lâu dài hoặc các dự án mang tính cộng đồng (open-source).
- Kỹ lưỡng tại thời điểm ban đầu sẽ có nhiều việc phải làm, nhưng sẽ giúp chúng ta tiết kiệm thời gian và những vấn đề nan giải, nhức đầu về sau này.
Nguồn tài nguyên
Bận có thể dễ dàng đưa Mockey và PHPUnit vào trong ứng dụng của bạn bằng cách sử dụng Composer. Thêm chúng vào mục "require-dev" trong file composer.json
:
"require-dev": { "mockery/mockery": "0.8.*", "phpunit/phpunit": "3.7.*" }
Sau đó, bạn sẽ có thể cài đặt các dependencies của Composer với tùy chọn yêu cầu "dev" như bên dưới.
$ php composer.phar install --dev
Bạn có thể tìm hiểu thêm về Mockey, Composer và PHPUnit trên Nettuts+ tại đây.
Đối với PHP, bạn nên cân nhắc sử dụng Laravel 4, vì nó có sử dụng containers, cũng như các ý tưởng khác được viết ở trên.
Cám ơn bạn đã đọc bài viết này!