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