Lập trình hướng đối tượng nâng cao DI bằng "Minimal Cake Pattern"
Như ở bài trước đã trình bày, Dependency Injection (từ đây sẽ gọi là DI) có thể thực hiện bằng 3 cách. Cùng Bizfly Cloud tìm hiểu 3 cách này ngay tại bài viết này nhé:
1. Dùng DI Container trong các Framework hỗ trợ DI như Spring DI Container hay Google Guice
2. Áp dụng Cake Pattern bằng pure code
3. Dùng kỹ thuật Reader Monad của lập trình hàm
Lần này mình sẽ trình bày lại một cách làm gọn gàng và nhẹ nhàng hơn của cách 2 tên đơn giản là Minimal Cake Pattern.
Hiện tại chúng ta có UserService phụ thuộc vào UserRepository, ApplicationLive phụ thuộc vào UserService. UserRepository là một class nguyên thủy rất đơn giản.
class User(val name: String)
class UserRepository {
def save(user: User) = {
print("Saved " user.name)
}
}
Cake Pattern hoàn chỉnh của Jonas Boner gói userRepository vào trong một trait mới, và gói tiếp userService vào một trait khác, sử dụng kiểu tự nhận thức và cuối cùng Extends tất cả ở ApplicationLive. Logic của phương pháp này khá phức tạp và vẫn là rào cản lớn đối với những bạn chưa quen lập trình hướng đối tượng nâng cao. Trong thực tế, áp dụng Cake Pattern ở những dự án lớn cũng rất tốn cost vận hành và phát triển. Khi đó một anh engineer người Nhật đã "phát minh" ra một phương pháp giản lược hơn, khi tách riêng ý nghĩa về interface và implement đối với từng class.
Interface vs implement
Mỗi class sẽ luôn được định nghĩa đi kèm với 2 trait, 1 trait dùng để làm interface và một trait dùng để implement. Giả sử class tên là XXX, ta sẽ định nghĩa UsesXXX làm interface và MixInXXX làm implement
abtract class XXX {}
// Interface
trait UsesXXX {
val x: XXX
}
// Implement
trait MixInXXX {
val x = new XXX
}
Interface dùng cho định nghĩa và kế thừa, còn Implement dùng để dùng nghiệp vụ trực tiếp.
Khi này, khi nào xuất hiện class YYY mới phụ thuộc vào XXX, chúng ta sẽ định nghĩa bằng cách extend interface trait UsesXXX, và viết implement bằng cách sử dụng implement trait của MixInXXX
abstract class YYY extends UsesXXX {}
// Interface
trait UsesYYY {
val x: YYY
}
// Implement
trait MixInYYY {
val x = new YYY with MixInXXX
}
Bạn có thể thấy YYY cũng có tiếp 2 trait chuẩn bị sẵn, đến khi xuất hiện class nào mới muốn dùng thì lại lấy 2 trait dùng tương tự. Cứ thế class có thể kế thừa đến khi nào tùy thích, và luôn đảm bảo có 2 trait đi kèm. 2 trait này sẽ giúp cho DI dễ dàng hơn mà sẽ được trình bày ở phần sau.
trait UserRepository {
def save(user: User): Unit
}
object UserRepository extends UserRepository {
def save(user: User) = {
print("Saved " user.name)
}
}
// Interface
trait UsesUserRepository {
val userRepository: UserRepository
}
// Implement
trait MixInUserRepository {
val userRepository = UserRepository
}
// userService
abstract class UserService extends UsesUserRepository {
def register(name: String) = {
val someone = new User(name)
userRepository.save(someone)
}
}
// Interface
trait UsesUserService {
val userService: UserService
}
// Implementv
trait MixInUserService {
val userService = new UserService with MixInUserRepository
}
cuối cùng là ApplicationLive
object ApplicationLive extends MixInUserService
ApplicationLive.userService.register("dtvd")
DI for testing
Thực tế và hay gặp nhất là yêu cầu DI trong UnitTest. Chúng ta muốn test hàm Register của UserService mà không muốn tạo ra một UserRepository thực tế, hay nói cách khác, chúng ta chỉ muốn test logic mà không muốn phát sinh kết nối đến Database, để test chạy nhanh và không bị phụ thuộc tài nguyên Server.
Việc cần làm rất đơn giản: sử dụng nhánh interface (UsesXXX) của các class bên trên. Mình sẽ tạo mock cho UserRepository và nhúng vào một class kế thừa UserService, do đặc tính UserService chỉ kế thừa UsesUserRepository nên nhúng là một chuyện dễ dàng.
DI bằng cách nhúng Mock Object vào Interface:
class MockUserRepository extends UserRepository {
override def save(user: User) = print("Do nothing !!!")
}
class UserServiceForTest extends UserService {
val userRepository = new MockUserRepository
}
val test = new UserServiceForTest
test.register("hehe") // Do nothing !!!
Tại sao Minimal Cake Pattern lại là một phát kiến lớn
Khi đối mặt với một hệ thống khổng lồ, khoảng 400 module, trước hết không có DI thì dev sẽ dẹo, và DI bằng Cake Pattern thì dev cũng dẹo luôn. Lúc này bắt buộc phải có một phương pháp đơn giản hết mức, loại bỏ các tầng trừu tượng và lồng chéo không cần thiết.
Nếu so sánh với các thư viện DI Container thì bản thân Trait trong Scala là một phát minh của ngôn ngữ này, đại diện luôn cho khả năng "Inject" của các class kế thừa. Martin Flower từng nói, nếu có phép thay thế bằng những đối tượng đơn giản hơn thì không có lý gì lại không sử dụng. Khi ngôn ngữ đã tiến hóa lên thì phần xây dựng sẵn từ xưa của thư viện hay design pattern trở thành thừa và không cần thiết cũng là điều dễ hiểu.
Khi một hệ thống không chỉ lớn và còn khổng lồ về số lượng dòng code, khả năng check lỗi bởi theo compile time chứ không phải theo run time là điều tối cần thiết, và check lỗi bằng compiler của Scala chắc chắn sẽ dễ hiểu hơn check lỗi của các thư viện DI Container.
Lời kết
Qua 2 bài mình đã điểm qua 3 cách DI phổ biến trong Scala
• Áp dụng Cake Pattern bằng pure code
• Dùng kỹ thuật Reader Monad của lập trình hàm
• Sử dụng Minimal Cake Pattern bằng pure code
Trong thực tế thì Reader Monad và Minimal Cake Pattern là hữu dụng hơn cả, tuy nhiên Reader Monad đòi hỏi phải có tư duy về lập trình hàm, và nếu đã quyết định dùng thì phải chấp chận dùng lập trình hàm ở mọi nơi. Trong khi đó Minimal Cake Pattern là sáng sủa và gọn gàng, có thể áp dụng trong cả những hệ thống lớn.
Hi vọng các bạn đang làm Scala sẽ chọn được phương pháp phù hợp cho mình và tốt cho cả tương lai hậu bối của các bạn
Nguồn: Kipalog
>> Tìm hiểu thêm: Những nguyên tắc, định luật cần biết trong lập trình