别再用设计模式来消除 if else 了

4,008 阅读17分钟

TL;DR:

1.恰到好处的实现功能,不要给代码加入过多假设条件。

2.过早使用设计模式可能限制代码的灵活性,虽然是反直觉的,但是现实情况确实如此。

3.状态模式和策略模式不一定总是最佳选择,有时简单的if-else更直观。

4.工厂模式在代码研发过程中不断清理代码的时候可以自然引入,不需要刻意套用


if else 真的不好吗?

如果三个 if 逻辑判断就能解决的问题,我们就没必要用设计模式进行封装。

刚开始编程的时候,觉得尽量减少代码中的 if else 是对代码的优化,但是现在认识到提高扩展性和性能的才叫优化。

当我们学到了设计模式,**总想着拿着这把锤子去敲一敲代码的钉子。**但是,我经常敲完之后重新回来看自己的代码,会在想为什么需要写的这么复杂?三if else 就能解决,为什么要上工厂模式和策略模式?

明明可以直接用 if 直接判断状态机的状态是否符合,而且状态也只有三个以内,为什么要把代码修改成状态模式?

给自己的感觉也是为了封装而封装。

实际上,如果一个功能 if 都满天飞了,正常来说啥设计模式都适配不了了,用设计模式只会让代码更加复杂。

真正想要优化,我们可以画个流程图,重新理一下业务流程把一些多余的逻辑去掉,这才是能从根本上优化 if else

状态模式消除 if else

看一个具体的例子:一个购物订单被创建后会处于等待支付的状态(pending),然后用户支付了之后会变成已支付(paid),只有处于未支付的订单才能够进行取消操作(cancel)。

通过 if else 来实现上面的需求


func CreateOrder() Order {
 // 在数据库中创建订单

 return Order{
  State: Pending,
 }
}

func PaidOrder(order Order) error {
 // 未支付的订单才能支付
 if order.State != Pending {
  return errors.New("order state is not pending")
 }

 // 更新订单状态到数据库
 order.State = Paid
 // ...

 return nil
}

func CancelOrder(order Order) error {
 // 只有订单状态为Pending时才能取消
 if order.State != Pending {
  return errors.New("order state is not pending")
 }

 // 更新订单状态到数据库
 order.State = Cancel
 // ...

 return nil
}

因为状态相对比较少,直接实现看起来简洁和简单,如果这个时候我们硬是希望通过状态机模式进行封装,虽然消灭了 if 判断状态,但是实际上引入了比 if 更复杂的代码.

看看状态机实现后的代码

// OrderState 定义订单状态行为
type OrderState interface {
 Paid(order *Order) error
 Cancel(order *Order) error
 GetState() int
}

// PendingState 待支付状态
type PendingState struct{}

func (s *PendingState) Paid(order *Order) error {
 // 更新订单状态到数据库
 order.OrderState = &PaidState{}
 fmt.Println("订单已支付")
 return nil
}

func (s *PendingState) Cancel(order *Order) error {
 // 更新订单状态到数据库
 order.OrderState = &CanceledState{}
 fmt.Println("订单已取消")
 return nil
}

func (s *PendingState) GetState() int {
 return Pending
}

// PaidState 已支付状态
type PaidState struct{}

func (s *PaidState) Paid(order *Order) error {
 return errors.New("订单已支付,不能再次支付")
}

func (s *PaidState) Cancel(order *Order) error {
 return errors.New("订单已支付,不能取消")
}

func (s *PaidState) GetState() int {
 return Paid
}

// CanceledState 已取消状态
type CanceledState struct{}

func (s *CanceledState) Paid(order *Order) error {
 return errors.New("订单已取消,不能支付")
}

func (s *CanceledState) Cancel(order *Order) error {
 return errors.New("订单已取消,不能重复取消")
}

func (s *CanceledState) GetState() int {
 return Cancel
}

// CreateOrder 创建一个新订单,默认状态为 Pending
func CreateOrder() *Order {
 return &Order{
  OrderState: &PendingState{},
 }
}

// PaidOrder 支付订单
func PaidOrder(order *Order) error {
 return order.OrderState.Paid(order)
}

// CancelOrder 取消订单
func CancelOrder(order *Order) error {
 return order.OrderState.Cancel(order)
}

看起来是“高大上”了很多,我们来逐个分析一下状态机模式会带来的“好处”。

符合单一职责原则(SRP)

每个状态类只负责处理与该状态相关的逻辑。这样使得代码更易于维护和修改。如果需要更改“已支付”状态下的逻辑,只需要修改 PaidState 类,不影响其他状态的实现。

按第一种实现我们需要改动的也只有 PaidOrder 的方法,并没有在改动范围上有隔离,所以这个“好处”并不存在。

代码更具扩展性

如果将来有新的状态或行为(例如:RefundedState 退款状态、ShippedState 已发货状态),可以直接添加新的状态类,无需修改现有的代码。只需新增状态类并实现相关的行为,其他状态不会受到影响。 符合开闭原则(OCP) ,即对扩展开放,对修改关闭。

真的能做到对扩展开放,对修改关闭吗?

我们如果要增加退款的操作,那么我们需要在 OrderState 接口增加 RefundedState 方法。

这个时候我们需要在每个状态的实现上都增加这个方法。

这个时候你可能会说,我们可以增加一个 ErrOrderState 的状态,所有都默认继承这个状态,只有特殊逻辑的才进行特殊处理。

如果我增加一个需求,每种不同的错误状态要有不同的报错,并且不存在规律的文案,这个时候是不是这个通用的实现就不管用了?

你可能会问,在原有的 if-else 实现中,增加新状态会导致多次修改现有代码,所有使用 if-else 的地方都要更新。

但是在实际情况中会改的范围往往比我们想的要小很多。

例如我们增加了支付订单能转成已发货状态,并且确认收货后完成订单。

我们不需要改动之前的代码,只需要增加已发货和完成订单的操作即可

func ShippedOrder(order Order) error {
 // 只有订单状态为Paid时才能发货
 if order.State != Paid {
  return errors.New("order state is not paid")
 }

 // 更新订单状态到数据库
 order.State = Shipped
 // ...

 return nil
}

func CompleteOrder(order Order) error {
 // 只有订单状态为Shipped时才能完成
 if order.State != Shipped {
  return errors.New("order state is not shipped")
 }

 // 更新订单状态到数据库
 order.State = Complete
 // ...

 return nil
}

你可能会说我加的业务场景太取巧,没有涉及改动到原来的逻辑。

那这里我们再深入看一下,如果我们增加支持先发货再支付,最终才完成订单。

这里你可能会说,“你看,这样 if 判断状态是不是就复杂了!”

我们只需要增加一个 map 来维护所有操作的有效状态,然后增加一个验证订单 ValidOrderState 的状态的方法,就可以解决这个问题。

var (
 orderAction2ValidState = map[OrderAction][]State{
  OrderActionPay:      {Pending},
  OrderActionShip:     {Paid},
  OrderActionComplete: {Shipped},
  OrderActionCancel:   {Pending},
 }
)

func ValidOrderState(order Order, action OrderAction) bool {
 validStates, ok := orderAction2ValidState[action]
 if !ok {
  return false
 }

 for _, state := range validStates {
  if order.State == state {
   return true
  }
 }
 
 return false
}

我们把原来的代码进行改造

func ShippedOrder(order Order) error {
 if ValidOrderState(order, OrderActionShip) {
  return errors.New("order state is not paid")
 }

 // 更新订单状态到数据库
 order.State = Shipped
 // ...

 return nil
}

func CompleteOrder(order Order) error {
 if ValidOrderState(order, OrderActionComplete) {
  return errors.New("order state is not shipped")
 }

 // 更新订单状态到数据库
 order.State = Complete
 // ...

 return nil
}

其他操作的修改相同,这里就不全部进行展示

改造完了之后,我们重新来看要实现的需求:支持先发货再付款。

那么我们只需要修改 orderAction2ValidState 即可,增加了在支付的时候允许已发货的状态,完成订单的时候也允许已支付的状态

var (
 orderAction2ValidState = map[OrderAction][]State{
  OrderActionPay:      {Pending, **Shipped**},
  OrderActionShip:     {Paid},
  OrderActionComplete: {Shipped, **Paid**},
  OrderActionCancel:   {Pending},
 }
)

看到这里,状态机的开闭原则也并没有给我们带来代码修改的好处。

避免复杂的条件判断

使用 if-else 判断多个状态时,代码可能变得复杂、难以维护,特别是在状态多或逻辑复杂的情况下。而状态模式通过将每个状态封装成独立类,使得代码更加清晰。 通过使用状态类对象,状态转换逻辑自然地融入了状态行为内部,简化了代码。

这里通过我们上面 map 的改造后其实不攻自破了,状态也只是被维护在 orderAction2ValidState 里面,通过 action 就能对应上正确的状态。

代码可读性更强

状态逻辑清晰、结构化,不同状态的行为是分离的,不会混杂在一起,使得代码的可读性大大提高。在 if-else 实现中,所有状态的逻辑都集中在一个方法中,容易导致代码难以理解和维护。而状态模式下,逻辑更清晰可见。

通过上面的例子我们也可以看到,确实是结构化了,但是代码读起来的简洁程度并不如 if else 来的直接。

我们如果想看支付的逻辑,直接看 PaidOrder 就可以,不需要理解状态机整套的封装逻辑。

易于调试和测试

由于每个状态的行为都封装在各自的类中,可以对每个状态单独进行测试和调试。这样就可以更精细地测试某一个特定状态的行为,而不需要顾及其他状态的逻辑。

这点也不攻自破,我们在测试 ShippedOrder 的时候也不需要关心其他状态,**反而少了很多不必要的构造状态机的代码,**更加方便了我们的测试。

策略模式消除 if else

策略模式和工厂方法同样能够消除 if else ,但会不会像策略模式一样出现鸡肋的情况呢?我们看下面的具体例子。

对于不同的用户,我们要有不同的打折策略:

  • 普通用户不打折
  • VIP 用户打八折
  • SVIP 用户打五折

我们先通过直译的方式来实现

func CalculatePrice(user User, price float64) float64 {
 // 8折
 if user.CustomerType == VIP {
  return price * 0.8
 }
 // 5折
 if user.CustomerType == SVIP {
  return price * 0.5
 }
 return price
}

我们这里通过提前返回的方式,代码看起来也相对比较简洁。

这个时候我们就会想,这不就能用上策略模式和工厂模式的封装吗?

我们定义折扣策略的接口

type DiscountStrategy interface {
 Calculate(price float64float64
}

然后不同的用户实现不同的折扣策略

type RegularUserDiscount struct{}

func (r *RegularUserDiscount) Calculate(price float64float64 {
 return price // 无折扣
}

type VIPUserDiscount struct{}

func (v *VIPUserDiscount) Calculate(price float64float64 {
 return price * 0.8 // 8折
}

type SVIPUserDiscount struct{}

func (s *SVIPUserDiscount) Calculate(price float64float64 {
 return price * 0.5 // 5折
}

通过用户类型调用不同的折扣策略进行计算

var (
 DiscountStrategyMap = map[CustomerType]DiscountStrategy{
  Regular: &RegularUserDiscount{},
  VIP:     &VIPUserDiscount{},
  SVIP:    &SVIPUserDiscount{},
 }
)

func CalculatePrice(user User, price float64) float64 {
 strategy := DiscountStrategyMap[user.CustomerType]
 return strategy.Calculate(price)
}

根据函数是“第一公民”的特性,我们还可以对代码进行进一步简化。

type CalculateHandle func(float64) float64

func (c CalculateHandle) Calculate(price float64float64 {
 return c(price)
}

func VIPDiscountCalculate(price float64) float64 {
 return price * 0.8
}

func RegularDiscountCalculate(price float64) float64 {
 return price * 0.9
}

func SVIPDiscountCalculate(price float64) float64 {
 return price * 0.5
}

这样就不需要每次都创建一个结构体来进行计算,而是通过类型转换来实现 DiscountStrategy 接口

var (
 calculateFunc = map[CustomerType]CalculateHandle{
  VIP:     VIPDiscountCalculate,
  SVIP:    SVIPDiscountCalculate,
  Regular: RegularDiscountCalculate,
 }
)

func CalculatePrice(user User, price float64) float64 {
 handle, ok := calculateFunc[user.CustomerType]
 if !ok {
  return price
 }
 return handle.Calculate(price)
}

这样封装之后我们会觉得整体代码结构化了许多,也在得意说后续如果加入新的用户类型之后,我们只需要增加一种用户类型的 DiscountCalculate 就好了。

然后问题就出现了。

代码存在过多假设

后续产品提出了新的需求,如果SVIP打完折后仍然满 300则会减30,VIP用户则是满500再减30。

这跟我们最开始封装的预想不一样呀,我们是以为是会增加用户类型来增加折扣,现在反而是增加了给相同的用户增加了折扣类型。

这就是因为原先代码做了过多的假设造成的问题,原先的封装策略是跟在用户上的,但是满减策略则只是满减金额配置的不同,这个时候需要增加额外的满减策略来进行复用。

一个是根据用户的类型进行抽象,一个是根据折扣的类型进行抽象,不同的封装角度也带来理解的困难,也给代码的修改带来了困难。

站在“现在”看“现在”,而不是站在“未来”看“现在”

你可能会质疑,这里因为我最开始是以用户类型的维度做了封装,那最开始以折扣配置的方式进行封装,不就没问题了吗?

这东西就跟炒股一样,我们不能站在上帝视角去看问题,而是站在“当前”状态去看。

如果我们预先能知道所有的情况,那我们疯狂做T不就暴富了。

现实需求同样如此,反观很多设计模式都是站在上帝视角去解决问题,对于已有项目进行重构优化来说,固然好用。

但是对于未知如何发展的产品来说,不做过多的假设和抽象,反而能留给代码足够多的灵活性。这是不是反直觉的?

过早的对代码使用设计模式,后续的需求则需要兼容原先的设计角度,反而限制了后期的扩展。

不做超出功能的封装

我们仍然通过第一种 if else 来对代码进行修改,这个时候我们发现其实对于不同类型的用户来说,也只是折扣力度的不同,所以我们可以从折扣的角度进行封装。

type Discount struct {
 // 折扣率
 DiscountRate float64
}

func (d *Discount) Calculate(price float64float64 {
 return price * d.DiscountRate
}

type FullDiscount struct {
 // 满多少钱可以减
 TargetPrice float64
 // 减多少钱
 Discount float64
}

func (f *FullDiscount) Calculate(price float64float64 {
 if price >= f.TargetPrice {
  return price - f.Discount
 }
 return price
}

func getDiscounts(customerType CustomerType) []DiscountStrategy {
 if customerType == VIP {
  return []DiscountStrategy{
   &Discount{DiscountRate: 0.8},
   &FullDiscount{TargetPrice: 500, Discount: 30},
  }
 }

 if customerType == SVIP {
  return []DiscountStrategy{
   &Discount{DiscountRate: 0.5},
   &FullDiscount{TargetPrice: 300, Discount: 30},
  }
 }

 return []DiscountStrategy{
  &Discount{DiscountRate: 1},
 }
}

func Calculate(user User, price float64, discount Discount) float64 {
 // 获取折扣策略,这个可以支持配置
 strategies := getDiscounts(user.CustomerType)

 // 给价格应用折扣策略
 for _, strategy := range strategies {
  price = strategy.Calculate(price)
 }

 return price
}

可以看到我们仍然保留了 if else ,但是构造折扣的方式做了修改,我们这里仍然不做进一步封装。

这样的代码已经足够清晰让阅读的人能够快速知道不同用户类型有哪些折扣,而且也支持了折扣链的构造,完美的实现了当前的需求,也没有引入其他的假设。

封装的技巧很多,但是克制的使用技巧才是进阶。

如果后续这些折扣要支持配置,我们只需要修改 getDiscounts 中的构造即可,也可以在局部进行策略缓存的优化,不会影响到折扣策略本身的计算逻辑。

为什么这里我反而假设了要做缓存呢?因为缓存是技术侧能够控制的,与业务无关的内容,不会被产品的演进方向的改变而有过多的改变,所以我们可以做一些假设,尽管这里的假设不成立,也不会影响需求的迭代,这跟我们一开始对需求的假设是不一样的。

结构化代码能够减少修改带来的错误

另一种情况是业务需求本身比较稳定了,需要做性能上的优化时,代码混乱的结构让我们无从下手,这个时候上述的封装就不会出现问题,反而是需要我们在优化性能的时候去进行重构,这样增加缓存优化的时候也能更好的避免出现BUG。

减少代码的BUG 不能仅仅通过靠人,也要通过技术的手段,我们上面抽象出 getDiscounts 之后,如果要对 getDiscounts 增加缓存,我们就给 getDiscounts 增加单元测试,然后通过改变配置来看获取到的 getDiscounts 是否符合预期,只要符合预期,那么计算出来的金额也是没有问题的。

通过抽象的方式,我们的代码变得更加“可测试”,这样对于性能优化起来也更加的友好,缩小的测试范围让我们编写测试也更加简单。

工厂模式

与策略模式的情况相同,如果产品还没有稳定的方向,我们也没能有确定的抽象,先不要过早的使用。

KubernetesproxyProvider ,最开始 v1.0.0 的版本也是直接依赖 Proixeriptables 的具体实现,直到第一版的功能完整之后,才开始抽象出 Provider

// Provider is the interface provided by proxier implementations.
type Provider interface {
 config.EndpointSliceHandler
 config.ServiceHandler
 config.NodeHandler
 config.ServiceCIDRHandler

 Sync()
 SyncLoop()
}

它也并非一开始上来就创建了抽象,而是先实现了功能,再将上下层进行隔离的时候,总结出了应该如何做抽象。

对于业务代码而言,一开始就创建抽象则是限制了上层业务的变化.

工厂模式通过 OOP 的思想将相同的内容进行内聚,我们平时开发的时候不必刻意去套用,当代码在生长的过程中,自然而然就会用到了。

还是以 KubernetesProvider 为例,当抽象出 Provider 之后,在增加 IPVSnftables 的时候自然就会通过 createProxier 来进行实例化,这里面就用到了工厂模式的思想。

func (s *ProxyServer) createProxier(...) (proxy.Provider, error) {
 var proxier proxy.Provider
 if config.Mode == proxyconfigapi.ProxyModeIPTables {
  if dualStack {
   proxier, err = iptables.NewDualStackProxier()
  } else {
   proxier, err = iptables.NewProxier()
  }
 } else if config.Mode == proxyconfigapi.ProxyModeIPVS {
  if dualStack {
   proxier, err = ipvs.NewDualStackProxier()
  } else {
   proxier, err = ipvs.NewProxier()
  }
 } else if config.Mode == proxyconfigapi.ProxyModeNFTables {
  if dualStack {
   proxier, err = nftables.NewDualStackProxier()
  } else {
   proxier, err = nftables.NewProxier()
  }
 }
 return proxier, nil
}

简化了代码可以看到,虽然不是标准的工厂模式,但是这样写同样能让人非常直观的看懂,这就足够了。

我们在业务开发中同样如此,设计模式的使用都是要秉持着谨慎和克制的态度。

如果恰到好处能完成需求了,那我们没必要为了还未出现的需求去做后续的假设。

小结

我刚开始学完设计模式的时候,也非常想在项目里面去寻找“钉子”,在这个过程中,我锤了很多钉子,但是实际上回过头继续去维护的时候,发现这些钉子其实都因为设计模式的原因,要撬开变得异常困难。

我也因此开始反思,设计模式真的对项目有帮助吗,通过这篇文章,我们知道根据项目的实际情况克制的去使用技巧。

在不断复盘自己写过的代码的时候,我发现我没有办法一开始就写出整洁易读的代码,随着项目不断生长,我在添加代码的时候不断的清理和重构原有的代码,让代码变得更加贴合产品和语义。

这也是重构代码的基础,并不是从头到尾重新开始写代码才是重构。

在敏捷开发的环境下,极少有大量的时间去对已上线的代码进行重构或者优化,只有将重构也以敏捷的方式融入到日常开发中,才能有机会不断的去打扫自己的代码。

我们将哪些东西抽象化,去除哪些特殊性,会带来什么样的好处,这些问题在封装之前要想清楚。

本文使用 markdown.com.cn 排版