Trở lại với cơ bản: OOP, Dependency Injection và Cake Pattern
Version 1: Code kết dính
Trước hết hãy cùng Bizfly Cloud bắt đầu với một class đơn giản. Giả sử hệ thống có một class User, định nghĩa đối tượng người dùng. Đây là một đối tượng quá quen thuộc phải không nào:
class User(val name: String)
Khi lưu thông tin người dùng vào database, mình giả sử lại có một DAO (Data Access Object) class tên là UserRepository và một Service class UserService sử dụng DAO.
class UserRepository {
// Maybe DB initialization here
def save(user: User) = {
// Can use real DB access here
print("Saved " user.name)
}
}
class UserService {
val userRepository = new UserRepository
def register(name: String) = {
val someone = new User(name)
userRepository.save(someone)
}
}
Cuối cùng là đối tượng ApplicationLive là app chính của chúng ta.
object ApplicationLive extends UserService
ApplicationLive.register("dtvd") // Saved dtvd
Trong thực tế UserRepository có thể tạo ra kết nối đến database và thực sự gọi câu query để insert/update, phát sinh rất nhiều overhead. Class UserRepository trên đây chỉ in ra màn hình mang tính chất minh họa.
Code kết dính không tốt như thế nào
Đoạn code trên có gì không ổn ?
val userRepository = new UserRepository
Class UserService mang một biến có kiểu UserRepository, nói cách khác là UserService bị phụ thuộc vào UserRepository.
Phụ thuộc thì có làm sao không?
Phụ thuộc thì ... rất làm sao, rất vấn đề. new UserRepository là một implement của class UserRepository. Khi chúng ta muốn thay đổi implement của UserRepository thì bắt buộc phải động đến UserService, hay tệ hơn nữa là phải duplicate sang một implement mới.
Khi nào mà lại cần phải thay đổi implement của UserRepository thế?
Câu hỏi này rất hay. Hãy tưởng tượng, bây giờ mình không muốn dùng kết nối đến MySQL để lấy data nữa, mà muốn lấy từ ...MongoDB, hay là từ Redis, mình sẽ có một vài MongoUserRepositoryhay RedisUserRepository nữa, nhưng không thể dùng trực tiếp với UserService bên trên.
Một ví dụ dễ thấy hơn là khi muốn test UserService, chúng ta không thể dùng UserRepositorytạo ra kết nối thật đến DB, mà phải mock thành object ảo, và lại không biết làm thế nào để dùng với UserService.
Dependency Injection
Để giải quyết vấn đề phụ thuộc, chúng ta sẽ ứng dụng Dependency Injection (DI).
• Dependency là đối tượng được sử dụng trong một service. dependency ở đây là userRepository, service ở đây là UserService
• Injection có nghĩa là khả năng thay thế dependency nói trên bằng một đối tượng tùy ý khác.
Đến năm 2016 mà nhắc đến Dependency Injection hay Inversion Of Control thì cũng không có gì là mới mẻ nữa. Trong thế giới của Java thì khái niệm đã tồn tại hơn 10 năm tuổi. Martin Fowler đã viết về tư tưởng này từ năm 2004. Hay thậm chí, Inversion Of Control còn được đưa ra từ những năm 1980. Tuy vậy đến gần đây những ngôn ngữ như PHP hay Javascript mới xuất hiện những framework sử dụng DI, và việc nắm rõ khái niệm cùng với áp dụng trong code base vẫn là một kỹ năng quan trọng và không thể thiếu với các senior dev ngay tại thời điểm hiện tại.
Cake Pattern
Để thực hiện DI trong Scala có 3 phương pháp chí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
Phương pháp 3 mình sẽ trình bày ở cuối cùng. Phương pháp 2: Cake pattern là đối lập với phương pháp 1 khi sử dụng Spring DI Container hay Google Guice.
Spring DI Container nhận rất nhiều chỉ trích rằng đã áp dụng DI quá phức tạp. Để sử dụng DI Container bạn phải tạo ra Enterprise JavaBean (EJB). Từ EJB mới xuất hiện một khái niệm hoàn toàn đối lập là Plain Old Java Object (POJO), đề cao tính dễ hiểu và nguyên bản của ngôn ngữ. Đối lập lại, Google Guice đã có những bước tiến vượt bậc và gần đây đã được tích hợp vào Play framework phiên bản 2.4. Tuy vậy đây vẫn là một ngạc nhiên lớn vì từ trước đến nay trong Scala, "dân tình" vẫn dùng Cake Pattern để implement DI và nghiễm nhiên coi là phương pháp phổ biến.
DI Container của Google Guice có khả năng làm biến mất hoàn toàn trong code tham chiếu giữa client và service (bên sử dụng và bên được sử dụng). Mỗi bên có thể compile riêng rẽ, client code rất sạch và đơn giản. Tuy vậy điều này cũng dẫn đến chuyện bug sẽ chỉ xuất hiện tại thời điểm runtime chứ không phải tại thời điểm compile, cùng với việc mở ra khả năng dễ dàng tạo ra "ma trận quan hệ" phức tạp ngoài ý thức của người lập trình. DI Container sử dụng Inversion Of Control(IOC), là khái niệm mà Martin Fowler cũng chùn tay khi sử dụng.
Inversion of control is a common feature of frameworks, but it's something that comes at a price. It tends to be hard to understand and leads to problems when you are trying to debug. So on the whole I prefer to avoid it unless I need it. This isn't to say it's a bad thing, just that I think it needs to justify itself over the more straightforward alternative.
Tạm để Google Guice lại một bên, lần này chúng ta sẽ tìm hiểu thực tiễn về Cake Pattern và ứng dụng vào ví dụ đầu bài. Tư tưởng về Cake Pattern được giới thiệu lần đầu bởi Jonas Boner, CTO của TypeSafe và tác giả của bộ Akka.
Trước hết mỗi class UserRepository và UserService sẽ được gói lại trong một trait mới vói tên là ...Component. Trong mỗi trait này tồn tại một biến là kiểu UserRepository hoặc UserService cũ.
trait UserRepositoryComponent {
val userRepository = new UserRepository // <-- Here
class UserRepository {
def save(user: User) = {
print("Saved " user.name)
}
}
}
trait UserServiceComponent extends UserRepositoryComponent {
val userService = new UserService // <-- Here
class UserService {
def register(name: String) = {
val someone = new User(name)
userRepository.save(someone)
}
}
}
Sau đó là "nỗi tất cả context" lại vào nhau
object ApplicationLive
extends UserServiceComponent
ApplicationLive.userService.register("dtvd")
Bạn có thể thấy trait của Service đã extend trait của Repository.
Trait là cái gì thế?
Trait là abstract class nhưng cho phép một class mới có thể extend nhiều Trait, thay vì chỉ có thể extend 1 superclass - tính chất multiple inheritance (đa kế thừa) của Scala. Bạn có thể dễ dàng bắt gặp trong Scala những đoạn code kiểu như sau
class Child extends Parent with Trait1 with Trait2 with Trait3 with Trait4 with Trait5
Viết thế này đã tốt hơn ở điểm nào?
Bây giờ UserServiceComponent chỉ cần extends UserRepositoryComponent là đã có thể dùng được biến userRepository như bình thường. Và tất nhiên ApplicationLive thì đã sở hữu cả 2 biến userRepository, userService và dùng được luôn.
Kế thừa hay là tự nhận thức ?
Trongbài viết về Cake Pattern của Jonas Boner, anh đã dùng Self Type Annotion (kiểu tự nhận thức) mà trên đây mình mới chỉ viết bằng Inheritance (kế thừa).
// trait UserServiceComponent extends UserRepositoryComponent // Inheritance
trait UserServiceComponent { this: UserRepositoryComponent => } // Self Type Annotion
Ở đây dùng tự nhận thức hay kế thừa cũng sẽ tạo được hiệu quả như nhau. Tuy vậy sẽ là thiếu sót nếu không đề cập đến lý do tại sao tự nhận thức lại tốt hơn kế thừa.
// Self Type Annotion
trait A
trait B { this: A => }
// Inheritance
trait A
trait B extends A
Trong trường hợp này không có gì khác nhau cả, mình có thể nói mình viết kiểu tự nhận thức vì ... mình thích thế. Tuy vậy, khi xuất hiện một trait con nữa thì vấn đề sẽ khác.
// Self Type Annotion
trait A { def foo = "foo" }
trait B { this: A => def foobar = foo "bar" }
trait C { this: B => def fooNope = foo "Nope" } //not found: value foo
// Inheritance
trait A { def foo = "foo" }
trait B extends A { def foobar = foo "bar" }
trait C extends B { def fooNope = foo "Nope" } // fooNope
Như vậy tự nhận thức không cho phép C biết được A mang method gì, nói cách khác, khi C một khi đã tự nhận thức mình là B thì sẽ chỉ biết B mà thôi. Điều này dùng để phòng chống việc để lọt những tính năng không cần thiết từ các tầng cao xuống tầng thấp. Mỗi tầng chỉ cần biết giao tiếp với tầng kế trên là đủ.
Favor 'object composition' over 'class inheritance' - Design Pattern
Ở bài toán cụ thể của chúng ta, sau khi dùng tự nhận thức thì ApplicationLive bắt buộc phải extends cả 2 trait
object ApplicationLive
extends UserServiceComponent
with UserRepositoryComponent
Version 3: Cake Pattern hoàn chỉnh
Nếu bạn để ý thì trong nội dung các trait vừa mới tạo đã khởi tạo biến userRepository và userService tại chỗ bằng từ khoá val. Điều này dẫn đến chuyện khi khởi tạo ApplicationLive thì 2 biến trên là có sẵn và không động vào được nữa. Muốn gọi 2 biến trên ra cần phải
ApplicationLive.userService
ApplicationLive.userRepository
Các câu khởi tạo trải đều trong code base, khởi tạo bị dính chặt với định nghĩa. Để mang tất cả khởi tạo về một chỗ, mình sẽ thay thế bằng định nghĩa kiểu duy nhất
trait UserRepositoryComponent {
val userRepository: UserRepository
//...
}
trait UserServiceComponent { this: UserRepositoryComponent =>
val userService: UserService
//...
}
Và chuyển phần khởi tạo về ApplicationLive
object ApplicationLive extends UserServiceComponent with UserRepositoryComponent {
val userRepository = new UserRepository
val userService = new UserService
}
ApplicationLive.userService.register("dtvd")
Vậy là xong. Đến bây giờ thì inject đã quá dễ dàng. Mình sẽ inject userRepository thành một mock object, không làm xử lý gì trong xử lý mà chỉ in ra "Do nothing!!!"
object Test extends UserServiceComponent with UserRepositoryComponent {
class MockUserRepository extends UserRepository{
override def save(user: User) = {
print("Do nothing!!!")
}
}
val userRepository = new MockUserRepository
val userService = new UserService
}
Test.userService.register("dtvd") // Do nothing!!!
DI bằng Reader Monad
Hàm trong Scala có thể dùng làm kiểu trả về của một hàm khác. Sau đây là một ví dụ đơn giản:
val square = (i: Int) => i * i
square(3) // 9
square mang kiểu hàm (function type), nhận vào giá trị Int và cho ra bình phương của giá trị đó. Kiểu hàm nhận 1 tham số duy nhất gọi là kiểu unary function (hàm đơn phân), mà thực chất là đối tượng của kiểu Function1 trong Scala. Kiểu hàm có thể "nối" bằng andThen.
val z = square andThen ( i => i 3 )
z(3) //12
Reader Monad là monad định nghĩa cho hàm đơn phân, dùng andThen và map. Reader Monad thực tế chỉ là một Function1. Mình sẽ viết lại ví dụ bên trên như sau
class UserRepository {
def save(user: User) = {
print("Saved " user.name)
}
}
class UserService {
def register(name: String) = (r: UserRepository) => r.save(new User(name))
}
object ApplicationLive extends UserService
ApplicationLive.register("dtvd")(new UserRepository)
Nhìn có vẻ hay đấy, nhưng DI thế nào với quả này
class MockUserRepository extends UserRepository{
override def save(user: User) = {
print("Do nothing!!!")
}
}
ApplicationLive.register("dtvd")(new MockUserRepository)
Kết luận
Bài viết đã trình bày cách sử dụng Cake Pattern với pure code để thực hiện DI thông qua một ví dụ đơn giản và gần với thực tế. Khi thực hiện Cake Pattern, bạn nên sử dụng kiểu tự nhận thức thay vì kế thừa trong code của service. Hy vọng sau khi đọc bài này các bạn viết code linh hoạt và dễ thay đổi, dễ test hơn.
VCCloud sưu tầm
Theo Kipalog
>> Tham khảo thêm: 12 thủ thuật vô cùng hữu ích mà lập trình viên JavaScript cần biết