Try   HackMD

當實作時發現某個物件可以同時符合兩個不同的情境,只需要做出一些調整,我們應該修改當下的物件,還是進行拆分呢?

SRP

# 單一(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

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 的看法

// 規格 -(形成)-> 約定 interface Counter { count: number } // 實作細節 class VisitorCounter { public count: number = 0 } const SimpleCounter: Counter = { count: 0 } const ClassCounter: Counter = new VisitorCounter()
// TypeScript 對「資料結構」的看法 interface Counter { count: number } // 等同於(定義資料結構) type Counter = { count: number }
// 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 } }
// 常態 -> 把 interface 的概念隱藏或忽視 class VisitorCounter { increment(amount: number): number { return this.count += amount } }
# 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
# RBS 定義介面 interface Counter def increment: (Integer) -> Integer end # 使用方 def open: (Counter) -> void

延伸:跟測試的關係

Clean Architecture

Input Boundary / Output Boundary

# 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
// 某框架 class VisitorController { // Input Boundary -> index() -> 約定了 VisitorIndexInput 類型資料 index(data: VisitorIndexInput): void { // ... } }

Layered Architecture

// 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

# 單一使用者 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
# 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
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