> 當實作時發現某個物件可以同時符合兩個不同的情境,只需要做出一些調整,我們應該修改當下的物件,還是進行拆分呢? ## SRP ```ruby= # 單一(Render - 算繪)功能 class Rectangle def render; end end class Ellipse def render; end end # --> 預期,只能做 "render" # 單一(Render - 算繪)職責 class Rectangle def render; end # 其他能力,但是跟算繪相關 def color; end end class Ellipse def render; end def color; end end ``` ## Interface ### Design by Contract * API == Contract * Define Interface from Contract * JSON API * Schema * ... * gRPC vs RESTful API * MVC Framework * Interface / Contract * Model & Controller 如何互動 * Controller & View 如何互動 * Rails -> 慣例優於設定 --> 也是一種約定 ### 實作 > Based on Sample ```ruby= class CourseHoursCalculator # ... def remaining_hours(course, user = single_user) # used_hours -> used_hours_data -> DB # Method call chains --> 描述如何做事 # --> call method --> 本身就是一種 API Call # --> Public Method 就是物件對外的 API (initial_hours(course, user) - used_hours(course, user)).round(2) end # ... private def used_hours(course, user) used_hours_data[[user.id, course.id]].to_i end def used_hours_data @used_hours_data ||= UsedCourse.where(user_id:, year:).group('used_courses.user_id', 'used_courses.course_id').sum('used_courses.hours') end end ``` > TypeScript 對 Interface 的看法 ```ts= // 規格 -(形成)-> 約定 interface Counter { count: number } // 實作細節 class VisitorCounter { public count: number = 0 } const SimpleCounter: Counter = { count: 0 } const ClassCounter: Counter = new VisitorCounter() ``` ```ts= // TypeScript 對「資料結構」的看法 interface Counter { count: number } // 等同於(定義資料結構) type Counter = { count: number } ``` ```ts= // User Story 描述行為 ==> 訪客拜訪次數統計 // 行為 -> 規格(需求) ==> 可以計算次數(遞增) // 規格 -> 約定的行為 ==> Object has method "increment" interface Counter { increment(amount: number): number } // 實現約定的行為 -> 實作 class VisitorCounter implements Counter { // Compiler 要求實現方法 increment(amount: number): number { return this.count += amount } } ``` ```ts= // 常態 -> 把 interface 的概念隱藏或忽視 class VisitorCounter { increment(amount: number): number { return this.count += amount } } ``` ```ruby= # Duck Typing # implements "Counter" class VisitorCounter def increment(amount) self.count += amount end end # 使用方 def open(counter) # counter 有 "increment" 方法就可以 -> Duck Typing counter.increment(1) end ``` ```ruby= # RBS 定義介面 interface Counter def increment: (Integer) -> Integer end # 使用方 def open: (Counter) -> void ``` > 延伸:跟測試的關係 ## Clean Architecture ### Input Boundary / Output Boundary ```ruby= # Controller def index # Input Data -> params # Input Boundary -> ActionController::Parameters(不明確) # 對 Rails 來說,Input Boundary (interface) 是一種 Hash-like 的物件(實作跟 Hash 一樣的 Interface) h = {} # Ruby Hash h[:id] # Hash 的 [] 方法 params[:id] # ActionController::Parameters 定義 [] 方法,實作 Hash 介面 # ... # Output Data -> instance variables # Output Boundary -> render end ``` ```ts= // 某框架 class VisitorController { // Input Boundary -> index() -> 約定了 VisitorIndexInput 類型資料 index(data: VisitorIndexInput): void { // ... } } ``` ### Layered Architecture ```go= // implements http.HandlerFunc // Presenter Layer func (ctrl *VisitorController) IndexHandler(w http.ResponseWriter, r *http.Request) { // http.ResponseWriter -> Output Boundary --> Layer Presenter (RESTful/gRPC) // *http.Request -> Input Boundary // -> http === interface // Input Boundary -> increment() // Output Boundary -> res (pointer) res := ctrl.countUseCase.increment(1) // Encoder ==> Presenter // json data ==> View Model encoder := json.NewEncoder() // View -> JSON encoder.Encode(w, res) } ``` ## Domain-Driven Design | | 狀態(instance variable) | 行為 (methods) | |--|----|------| |Entity/Aggregate | Yes | Yes | |Service | No | Yes | > Data Transfer Object -> 有資料沒有行為(資料 != 狀態) > DTO --> Hash / JSON > 狀態 -> 有 Context 的 Data ## 分析 > 分析 Sample Code * Input * UseCase -> 要拆分的部分 * 單一使用者餘剩時數 * 多位使用者餘剩時數 * Ouput ### Use Case ```ruby= # 單一使用者 Use Case class CourseHoursController def index # 準備資料 @available = AvailableCourse.where(user_id:, year:) @used = UsedCourse.where(user_id:, year:) @courses = Course.all @calculator = CourseHoursCalculator.new # 進行計算 # Domain Model @calculator .remaining_hours(@courses, @available, @used) # To View .as_json(only: %i[remaining_hours]) #... end end # 所有使用者 Use Case class Admin::CourseHoursController def index # 準備資料 @user_ids = User.where('extract(year from join_date) <= ?', @year) # User.before_year(@year) # Concern --> included by User @courses = Course.all @availables = AvailableCourse.where(user_id:, year:).group('user_id', 'course_id') @usedes = UsedCourse.where(user_id:, year:).group('used_courses.user_id', 'used_courses.course_id') @calculator = CourseHoursCalculator.new # 進行計算 @user_ids.map do |user_id| { user_id:, courses: @calculator.remaining_hours(@courses, @available[user_id], @used[user_id]) end end end ``` ```ruby= # Service Object class CourseHoursCalculator def remaining_hours(courses, available, used) courses.map do |course| { id: course.id, remaining_hours: remaining_hours(available[course.id], used[course.id]) extra_hours: .... # ... } end end private # ... end ``` ## Sample 這是一個賣線上課程的網站 計算哪些線上課程使用者買了多少時數,用了多少時數,還剩下多少時數 1. 本來只寫了單一使用者的 user_remaining_hours 2. 後來才加上所有當年度(含之前)加入的使用者 manage_statistics_hours ```ruby= class CourseHoursCalculator def initialize(user: nil, year: nil) @single_user = user @year = year || Time.zone.today.year @users = User.where('extract(year from join_date) <= ?', @year) @user_id = user&.id || @users.select(:id) end def user_remaining_hours Course.all.map do |course| { id: course.id, remaining_hours: remaining_hours(course) } end end def remaining_hours?(course, user = single_user) initial_hours(course, user).positive? end def remaining_hours(course, user = single_user) (initial_hours(course, user) - used_hours(course, user)).round(2) end def manage_statistics_hours users.map do |user| { user_id: user.id, courses: course_hours(user) } end end private def used_hours_data @used_hours_data ||= UsedCourse.where(user_id:, year:).group('used_courses.user_id', 'used_courses.course_id').sum('used_courses.hours') end def initial_hours_data @initial_hours_data ||= AvailableCourse.where(user_id:, year:).group('user_id', 'course_id').sum('initial_hours + extra_hours') end def extra_hours_data @extra_hours_data ||= AvailableCourse.where(user_id:, year:).group('user_id', 'course_id').sum('extra_hours') end def used_hours(course, user) used_hours_data[[user.id, course.id]].to_i end def initial_hours(cource, user) initial_hours_data[[user.id, cource.id]] || 0 end def course_hours(user) Course.all.map do |course| { id: course.id, extra_hours: extra_hours_date[[user.id, course.id]] || 0, initial_hours: remaining_hours?(course, user) ? initial_hours(course, user) : 0, used_hours: used_hours(course, user), remaining_hours: remaining_hours?(course, user) ? remaining_hours(course, user) : 0 } end end end ``` ## Reference * [Open-Closed Principle](https://www.youtube.com/watch?v=aSX76noRP2w)