iT邦幫忙

2024 iThome 鐵人賽

DAY 10
0
Software Development

一個好的系統之好維護基本篇 ( 馬克版 )系列 第 10

Day-10: 實務時 Code Review 看 Class 地方 1 ( 基本 )

  • 分享至 

  • xImage
  •  

同步至 medium

https://ithelp.ithome.com.tw/upload/images/20240924/20089358sHImTe8ujV.png


雖然上面 AI 產的圖有些文字重複,但有時還是會讓我笑一下。

看點 11. 有沒有儘量符合單一職責

記不記得之前在提到 SRP 時,有說到 :

每個元件都應該僅有一種且唯一種被『 修改的理由 』

其中一個 class 的兩個可能被修改理由 :

  • 使用的 context 變了,或是另一個 context 也要用他 ( 就是外面用它的地方 )
  • 它內部使用的依賴關係。

所以這裡我比較會看幾點 :

  1. 這個 class 是不是支援太多 context,如果是的話建議拆,例如 userService 裡面要支援 auth 與 learning 情境。
  2. 這個 class 依賴了多少東西,如果依賴太多 class,那是不是他根本不適合當一個 class 呢 ? 該合併到最常一起用的呢 ?

第 1 點我覺得比較需要拿來說說的是『 context 情境 』,這個事實上有點模糊,有時後我也很難說用啥來判斷,但通常我有以下的選項 :

  • 角色 :《 Clean Architecture 》有提到,例如 courseService 中前台老師可以編輯課程,後台工作人員也可以幫老師編輯課程 )
  • domain : 這個比較抽象點,簡單的說就是 userService 中可能會有 auth 相關與 learning 相關的東西。
  • 狀態 : 例如 course 在不同的狀態有不同的操作,例如草稿、募資中。

但要用那種呢 ? 我也說不準呢 ~

然後第 2 種就是如下程式碼,一看就知道依賴很多對吧 ?

class OrderService {
  constructor(
    private paymentService: PaymentService,
    private notificationService: NotificationService,
    private inventoryService: InventoryService,
    private shippingService: ShippingService,
    private discountService: DiscountService,
    private taxService: TaxService,
    private customerService: CustomerService,
    private loggingService: LoggingService,
  ) {}

  processOrder(orderId: string, productId: string) {
    this.inventoryService; // 檢查庫存
    this.paymentService;   // 處理付款
    this.shippingService;  // 處理運輸
    this.discountService;  // 計算折扣
    this.taxService;       // 計算稅額
    this.customerService;  // 確認客戶資料
    this.notificationService; // 發送通知
    this.loggingService;   // 記錄操作
  }
}

~小備註~
通常這個東西,我都是用建議的情況下,如果有時間就做,然後這時沒時間我覺得合理的話開張 task 留存之後在重構就好。


看點 12. 這個 Class 完不完整

簡單的說就是這個 class 是不是只支援到半殘的功能,不論是沒開發,還是放在另一個 class 都算是。

不過認真的說,如果半殘的情況下,還符合業務與 PM 需求,我自已還算可以接受。

下面這個是購物車的範例,事實上有用過購物車的應該都知道,他應該還缺了不少支援購物車行為,例如 removeProduct 。

class ShoppingCart {
  private products: Product[] = [];

  addProduct(product: Product): void {
    this.products.push(product);
  }

  checkout(){
    ... 
  }
}

這裡有幾個方法可以避免或是發現這種情況 :

  1. 如果是 domain layer 的情況下,事實上 event storming 的 command 就是可以用來當這個的檢查清單。
  2. 互補方法,例如上面的範例有 add 的話,那這時也要直覺的思考一下,會有需要 remove 嗎 ? 就算是 feature,但有時後 pm 可能也會忘了反向。
  3. 如果你在開發時,發現這個每次要完成某個 class 要做的事情,但是發現他總是需要呼叫其它 class 來協助完成,那這個時後,就可以想想他是不是有這個臭味。

其中第 3 點就是 :

我們前面提到的 CRP Common Reuse Principle)相同,就是經常被一起使用的,就應該放在一起,不要分開它。


看點 13. 有沒有已經重複的 Class

這裡除了指 class 名稱相同的,還有就是名稱不同,但是主職責幾乎是很相同的。

這題如果是在 1 ~ 2 個人開發與維護的小專案,我是覺得很容易發現,因為範圍小,但是如果是同時有 4 個團隊一起在某個 repo 開發,那就會真的每個人都會建出很接近的東西,這題我真的覺得比較麻煩。

還有一種情況就是,有一些比較喜歡自幹的人,不太喜歡去看舊 code 有沒有人有寫過的,通常三不五十就直接重新建個新的。這一題我也只有以下幾個建議 :

  • 如果四個團隊沒有地盤之分 ( 就想成 4 個團隊都是專案型團隊 ),那就先想辦法畫好地盤吧,因為會讓人不知道有沒有相同的,或是去追舊 code,有一個很大的原因就是太大了,每個人最後都變懶了,所以分地盤後,因為 Cognitive load 下降,所以通常比較容易展握到自已地盤的東西,這也代表比較容易在 code review 就發現。
  • 自幹類型的人喔,如果你說了他還是覺得寫新的比較好,而且如果你也覺得不錯,記得要開個 issue、task 重構舊的,不然一定沒人會去改,然後接下來你就看到 3、4 個版本…

但是團隊有沒有時間重構又是一回事了……


看點 14. 有沒有依賴具體實現

例如最常見的 database 範例,UserService 是依賴於抽象 interface Database 而不是依賴 MySQLDatabase 。

如果依賴具體實現,會依賴源變動,可能修改它整個 UserService 都需要修改。像我在最近一次的經驗上,我們有直接依賴整個 logger service,然後要修改就發現每一個地方都要改,變動非常大。

// 定義一個穩定的抽象接口,不會頻繁變動
interface Database {
  connect(): void;
  query(queryString: string): any;
  disconnect(): void;
}

// MySQL 的具體實現
class MySQLDatabase implements Database {
  connect() {
    console.log("Connected to MySQL");
  }

  query(queryString: string) {
    console.log(`Executing MySQL query: ${queryString}`);
    return {};
  }

  disconnect() {
    console.log("Disconnected from MySQL");
  }
}

// 使用者只依賴於抽象,不依賴於具體實現
class UserService {
  constructor(private db: Database) {}

  getUser(id: string) {
    this.db.connect();
    const result = this.db.query(`SELECT * FROM users WHERE id = ${id}`);
    this.db.disconnect();
    return result;
  }
}

-------------------------------------------
  

const userService = new UserService(new MySQLDatabase());
userService.getUser("123");

看點 15. 小心使用繼承

這裡主要會看這幾個點 :

  1. 你的子類別與母類別的行為是否是相同的。
  2. 你的繼承會不會太深,就是子類上還有個 2 層以上,通常 3 層我就覺得有問題了。
  3. 繼承會不會過寬,就是有一堆子類別。

首先第 1 個,還記得 LSP 嗎 ? 如果違反了,然後通常在 class 中的方法直接不做任何事,或是 throw error,通常時都是會給後人留坑。

 1. 子型態 (subtype) 必須能夠替換掉它們的基底型態 ( base type )
> 2. IS-A 是關於行為的

~備註~
我自已覺得 Refused Bequest 這個 Bad Smell 本質上和違反 LSP 概念是一樣的。

Refused Bequest

然後由於繼承事實上有不少缺點如下 :

  • 父類別修改,可能會讓子類別全炸,更有可能讓子孫們都炸了。
  • 有些語言有支援多種繼承,但這種就讓這個類別的地位是什麼就更難理解了。
  • 繼承深度問題。
  • 難以維護。

所以不少本書都有提到一件事 :

用組合來代替繼承

例如下面的範例

// 定義行為接口
interface Flyable {
  fly(): void;
}

interface Swimmable {
  swim(): void;
}

// 飛行行為的實現
class CanFly implements Flyable {
  fly() {
    console.log('I can fly');
  }
}

class CannotFly implements Flyable {
  fly() {
    console.log('I cannot fly');
  }
}

// 游泳行為的實現
class CanSwim implements Swimmable {
  swim() {
    console.log('I can swim');
  }
}

class CannotSwim implements Swimmable {
  swim() {
    console.log('I cannot swim');
  }
}

// 動物類別,不使用繼承,而是組合具體的行為
class Animal {
  private flyBehavior: Flyable;
  private swimBehavior: Swimmable;

  constructor(flyBehavior: Flyable, swimBehavior: Swimmable) {
    this.flyBehavior = flyBehavior;
    this.swimBehavior = swimBehavior;
  }

  performFly() {
    this.flyBehavior.fly();
  }

  performSwim() {
    this.swimBehavior.swim();
  }
}

// 創建不同的動物,根據具體情況組合行為
const eagle = new Animal(new CanFly(), new CannotSwim());
const penguin = new Animal(new CannotFly(), new CanSwim());
const dolphin = new Animal(new CannotFly(), new CanSwim());

然後這裡還有第二種方法來實現組合,那就是 Mixin 這個特性在不少語言都有,它們允許將功能增加到類別中。

// 定義一個基礎類
class Animal {
  name: string

  constructor(name: string) {
    this.name = name
  }
}

// 定義一個混入行為,要求基類必須有 name 屬性
function Flyable<T extends { new (...args: any[]): { name: string } }>(
  Base: T
) {
  return class extends Base {
    fly() {
      console.log(`${this.name} is flying!`)
    }
  }
}

// 定義另一個混入行為,要求基類必須有 name 屬性
function Swimmable<T extends { new (...args: any[]): { name: string } }>(
  Base: T
) {
  return class extends Base {
    swim() {
      console.log(`${this.name} is swimming!`)
    }
  }
}

// 使用 Mixin 創建具有多重行為的類
class Duck extends Flyable(Swimmable(Animal)) {
  constructor(name: string) {
    super(name)
  }
}

const duck = new Duck('Daffy')
duck.fly() // Output: Daffy is flying!
duck.swim() // Output: Daffy is swimming!

小結

在這篇文章中,我們探討了許多與 class 設計 有關的常見檢查點,涵蓋了單一職責原則、具體實現的依賴、繼承問題、class 重複等方面的問題,然後列出了以下幾點看點。

總結來說,良好的 class 設計 應該:

  • 看點 11. 有沒有『 儘量 』符合單一職責
  • 看點 12. 不完整的 Class
  • 看點 13. 有沒有已經重複的 Class
  • 看點 14. 有沒有依賴具體實現
  • 看點 15. 小心使用繼承

這些原則不僅能提升代碼的可讀性和可擴展性,還能使團隊在長期開發和維護中更加高效。但是團隊有沒有時間做這些事情,又是另一個世界了……


上一篇
Day-09: 實務時 Code Review 看的地方之 2 ( 好理解 )
下一篇
Day-11: 實務時 Code Review 看 Class 地方 2 ( 封裝 )
系列文
一個好的系統之好維護基本篇 ( 馬克版 )30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言