OOP Mastery – Lý thuyết 04: SOLID Principle

Android Architecture Pattern là chuỗi bài viết về các kiến trúc, principal trong quá trình phát triển phần mềm.

Mời bạn đọc qua bài viết trước: Architecture Pattern: MVC, MVP, MVVM

SOLID là một bộ các quy tắc trụ cột trong việc thiết kế và phát triển phần mềm. Mục đích của SOLID được đề ra để phần mềm được viết ra có khả năng bảo trì, phát triển, cập nhật thuận tiện hơn.


Single Responsibility Principle (SRP)

Single Responsiblility Principle – Android Mastery

Phát biểu của SRP: Mỗi 1 thực thể trong phần mềm (e.g variable, function, class, module) chỉ đảm nhiệm một nhiệm vụ duy nhất.

Tham khảo đoạn code dưới đây:

class MainActivity : AppCompatActivity() {
  private val apiService: ApiService by lazy { get<ApiService>() }
  
  private lateinit var binding: MainActivityBinding
  override fun onCreate(savedInstanceState: Bundle) {
    super.onCreate(savedInstanceState)
    binding = MainActivityBinding.inflate(layoutInflater)
    setContent(binding.root)
    
    lifecycleScope.launch {
      val myProfile = withContext(Dispatchers.IO) { apiService.getMyProfile() }
      binding.myProfileUiData = myProfile.mapUiData()
      binding.executePendingBindings()
    }
  }
}

Trong đoạn code trên, class MainActivity mục đích chính để hiển thị giao diện và nhận các sự kiện người dùng nhưng lại có cả logic thực thi gọi network từ biến apiService -> đây là một bad practice trong phát triển phần mềm vì nó đã không follow SRP.

Cách sửa lại cho hợp lý

class MainActivity : AppCompatActivity() {
  private val mainViewModel: MainViewModel by viewModels()
  private lateinit var binding: MainActivityBinding
  
    override fun onCreate(savedInstanceState: Bundle) {
    super.onCreate(savedInstanceState)
    binding = MainActivityBinding.inflate(layoutInflater)
    setContent(binding.root)
    
    lifecycleScope.launch {
      mainViewModel.myProfileStateFlow.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { it ->
        binding.myProfileUiData = it
        binding.executePendingBindings()
      }
    }
  }
}

class MainViewModel : ViewModel() {
    private val apiService: ApiService by lazy { get<ApiService>() }
    
    private val _myProfileStateFlow: MutableStateFlow<MyProfileUiState> = MutableStateFlow(MyProfileUiState.empty)
    private val myProfileStateFlow: StateFlow<MyProfileUiState> = _myProfileStateFlow
    
    init {
      fetchMyProfile()
    }
    
    fun fetchMyProfile() {
      viewModelScope.launch {
        val myProfile = withContext(Dispatchers.IO) { apiService.getMyProfile() }
        _myProfileStateFlow.value = myProfile.mapUiData()
      }
    }
}

Đoạn code sau khi được sửa lại đã tách ra ViewModel riêng, bạn vẫn có thể tiếp tục tách ra thành các Use-Case, hoặc tách cho ApiService nằm ở tầng Data Layer thay vì nằm trực tiếp trong ViewModel. Áp dụng thử nhé!


Open-Closed Principle (OCP)

Open-Closed Principle – Android Mastery

Phát biểu của OCP: Các thực thể (variable, function, class, module) trong phần mềm nên được thiết kế để hỗ trợ tính kế thừa, mở rộng (Open). Không nên thiết kế đóng (Closed)

Hãy tham khảo ví dụ sau đây để hiểu rõ hơn

class ImageLoader {
  private val network: Network by lazy { get<Network>() }
  suspend fun loadImage(url: String): Bitmap? {
    return network.request(url).getBitmap() // -> simulate download image from network and cast it to Bitmap
  }
}

Thiết kế trên mô phỏng một class với nhiệm vụ load một tấm ảnh từ Internet về. Trong quá trình phát triển, chúng ta nảy sinh ra tính năng loadImage từ local file path vậy phải làm thế nào?

Solution 1: Sửa hàm loadImage trong ImageLoader

class ImageLoader {
  private val network: Network by lazy { get<Network>() }
  private val local: Local by lazy { get<Local<() }
  suspend fun loadImage(url: String): Bitmap? {
    if(url.startWith("http"){
      return network.request(url).getBitmap() // -> simulate download image from network and cast it to Bitmap
    } else {
      return local.load(url).getBitmap() // simulate download image from local file path and cast it to Bitmap
    }
  }
}

Về cơ bản đoạn code này có thể chạy được mà vẫn đảm bảo thực thi đúng nhưng nó gặp 1 vấn đề là cùng 1 hàm loadImage trong ImageLoader đang phải đảm nhiệm 2 chức vụ là Load ảnh từ Internet và load ảnh từ Local. Nó vừa vi phạm SRP, đồng thời theo OCP chúng ta vừa học, 1 class nên Open cho mở rộng (extend)Close cho thay đổi (modify). Nên cách tiếp cận này chưa hợp lý!

Solution 2: Thay đổi lại thiết kế Class

interface ImageLoader {
  suspend fun loadImage(url: String): Bitmap?
}

class LoadImageFromNetwork: ImageLoader {
  private val network: Network by lazy { get<Network>() }
  override suspend fun loadImage(url: String): Bitmap? {
    return network.request(url).getBitmap() // -> simulate download image from network and cast it to Bitmap
  }
}

class LoadImageFromLocal: ImageLoader {
  private val local: Local by lazy { get<Local<() }
  override suspend fun loadImage(url: String): Bitmap? {
    return local.load(url).getBitmap() // simulate download image from local file path and cast it to Bitmap
  }
}

Vậy là bạn đã có một thiết kế vừa follow được SRP, vừa follow được OCP.


Liskov Substitution Principle (LSP)

Liskov Substitution Principle – Android Mastery

Liskov -> Tên riêng

Substitution -> thay thế được

Phát biểu của LSP: Trong phát triển phần mềm, khi thiết kế Class, một Object của Parent Class phải replacable bởi các Child Class mà không làm thay đổi tính đúng của chương trình.

Đây là một Principle hàn lâm, và khó hiểu nhất trong bộ 5 nguyên tắc. Đọc thêm đoạn code dưới đây nhé!

open class NotesLocalDataStore {
    val dataStore: LocalDataStore by lazy { get<LocalDataStore>() }
    open fun getNote(id: Long): Note? {
      return dataStore.fetchNoteItem(id)?.mapNote()
    }
}

class NotesSQLite: NotesLocalDataStore {
  val dataBase: SQLiteDatabase by lazy { get<SQLiteDatabase>() }
  override fun getNote(id: Long): Note? {
    return null
  }
  
  fun getNote(id: String): Note? {
    val cursor = dataBase.rawQuery("SELECT * from notes WHERE id = $id")
    return Note(cursor.first())
  }
}

Ở ví dụ trên có thể thấy lớp NotesSQLite đã phá vỡ quy tắc Liskov Substitution khi trả thẳng giá trị null cho hàm getNote(id: Long) và tự thêm một hàm getNote(id: String) để phục vụ việc query SQLite của mình.

Cách khắc phục cho trường hợp này cũng khá dễ. Bạn có thể tham khảo.

open class NotesLocalDataStore {
    val dataStore: LocalDataStore by lazy { get<LocalDataStore>() }
    open fun getNote(id: Long): Note? {
      return dataStore.fetchNoteItem(id)?.mapNote()
    }
}

class NotesSQLite: NotesLocalDataStore {
  val dataBase: SQLiteDatabase by lazy { get<SQLiteDatabase>() }
  override fun getNote(id: Long): Note? {
    val idString = id.toString()
    val cursor = dataBase.rawQuery("SELECT * from notes WHERE id = $idString")
    return Note(cursor.first())
  }
}

Interface Segregation Principle (ISP)

Interface Segregation Principle – Android Mastery

Segregation -> Sự phân chia

Đây cũng là một Principal thú vị, mình sẽ trích dẫn nguyên văn phát biểu tiếng Anh của nó ‘A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.

Phát biểu của ISP: Code trong chương trình không nên có khả năng Reference đến những tính năng mà chúng không có nhu cầu sử dụng.

Phát biểu này ý nói trong một function, class hay module không nên để sự dư thừa reference đến những tính năng mà function, class hay module đó không có nhu cầu sử dụng. Có thể hiểu Principal này cũng có nét tương đồng với Single Responsibility Principal, điều đó cũng không sai.

Để giải quyết cho Interface Segregation Principal này chúng ta thường tiếp cận vấn đề, thiết kế các Interface sao cho chúng nhỏ nhất có thể, độc lập nhất có thể để thuận tiện cho việc triển khai code được gọn và sạch.

Tham khảo ví dụ code sau đây

interface ApiService {
  suspend fun getMyProfile(): Profile
  suspend fun getUserProfile(id: String): Profile
  suspend fun follow(id: String): FollowResult
  suspend fun unfollow(id: String): UnfollowResult
  suspend fun getNewsFeed(timestamp: Long): NewsResult
  suspend fun likePost(id: String): LikeResult
  suspend fun hidePost(id: String): HidePost
}

Cách thiết kế của Interface ApiService đang bao phủ quá nhiều tính năng mà không phải lúc nào cũng cần dùng. Điều này break Interface Segregation Principal. Cách khắc phục là bạn hãy chia nhỏ ApiService ra thành nhiều Interface khác nhau để việc sử dụng, tái sử dụng được rõ ràng hơn.

interface UserApiService {
  suspend fun getMyProfile(): Profile
  suspend fun getUserProfile(id: String): Profile
  suspend fun follow(id: String): FollowResult
  suspend fun unfollow(id: String): UnfollowResult
}

interface NewsFeedApiService {
  suspend fun getNewsFeed(timestamp: Long): NewsResult
  suspend fun likePost(id: String): LikeResult
  suspend fun hidePost(id: String): HidePost
}

Dependency Inversion Principle (DIP)

Dependency Inversion Principle – Android Mastery

Dependency: Sự phụ thuộc

Inversion: đảo nghịch

Phát biểu của DIP: Các class, module khác nhau không phụ thuộc vào nhau. Chúng phụ thuộc vào abstract layer.

Lại là một Principle khá hàn lâm và khó hình dung. Để tui giải thích cho các fen nha. Đến chặng này là cũng sắp thành danh, thành tài rồi cố lên.

class NoteDataSource {
  val database: Database by lazy { get<Database>() }
  suspend fun getNote(id: Long): Note? {
    return database.getNote(id)?.mapNote()
  }
  suspend fun deleteNote(id: Long): Int {
    return database.deleteNote(id)
  }
  suspend fun updateNote(id: Long, content: String): Int {
    return database.updateNote(id, content)
  }
}

class MainViewModel(private val noteDataSource: NoteDataSource) : ViewModel() {
  
}

Trong ví dụ trên có thể nhận thấy lớp NoteDataSource được truyền trực tiếp vào MainViewModel, dẫn đến sự phụ thuộc (dependency) giữa MainViewModelNoteDataSource. Code này vẫn chạy đúng, vẫn follow được Single Responsibility Principle vậy tại sao cần Dependency Inversion Principle để giải quyết điều gì?

Các vấn đề khi không dùng DIP

  • Tính đóng gói riêng biệt: Về mặt methodology khi chỉ phụ thuộc lên abstract bạn có thể mock các dependencies này và test chúng với các kịch bản của phần mềm đã soạn sẵn. Điều tương tự khi bạn thực hiện viết test cho các Implementation của các Interface được truyền vào. Có nghĩa là tất cả điều riêng biệt và chẳng lệ thuộc gì vào nhau. Kiến trúc này tăng tính testable cho hệ thống.
  • Phụ thuộc vào detail of implementation (depends on details implementation) khiến cho source code trở nên cứng nhắc, khó thay thế và phát triển. Trong trường hợp bạn cần thay thế NoteDataSource bằng một cách tiếp cận khác, bạn buộc phải cập nhật lớp NoteDataSource (-> cách này vi phạm Open Closed) hoặc kế thừa lớp NoteDataSource ra một class mới và truyền ngược Object đó vào MainViewModel để sử dụng. Việc thay đổi này có khiến cho source code bị thay đổi nhiều, theo nguyên tắc phần mềm thì code change càng nhiều, càng khó test + verify.
  • Việc phụ thuộc vào detail implementation thay vì abstract còn mang lại trở ngại lớn cho Lập trình viên trong việc quản lý dependency graph trong trường hợp 2 class nằm ở 2 module khác nhau -> gây ra complexity trong quản lý codebase.
interface NoteDataSource {
  suspend fun getNote(id: Long): Note?
  suspend fun deleteNote(id: Long): Int
  suspend fun updateNote(id: Long, content: String): Int
}

class NoteDataSourceImpl : NoteDataSource {
  val database: Database by lazy { get<Database>() }
  override suspend fun getNote(id: Long): Note? {
    return database.getNote(id)?.mapNote()
  }
  override suspend fun deleteNote(id: Long): Int {
    return database.deleteNote(id)
  }
  override suspend fun updateNote(id: Long, content: String): Int {
    return database.updateNote(id, content)
  }
}

class MainViewModel(private val noteDataSource: NoteDataSource) : ViewModel() {
  
}

Vậy nên hãy áp dụng Dependency Inversion Principle triệt để nhé!