GO面向对象(做CRUD专家)六 :库存检查逻辑实现

203 阅读8分钟

业务需求:折扣、试用、返利三种商品类型的商品库存检查

package controller

type Item struct {
   repo *repository.Item
}

func (ctr *Item) Order(c *gin.Context) {
   item := ctr.repo.Get()
   number := 2
   if item.OutOfStock(number) {
      c.JSON(http.StatusOK, "库存不足")
   }
}
---------------------------------------------------------------
package domain

type Item struct {
   ID       int
   Category int
   Title    string
   Stock    int
   PriceMarket int
}

func (bo *Item) OutOfStock(number int) bool {
   if bo.Stock < number {
      return true
   }

   return false
}

代码分析:
1、Item基类实现库存检查函数OutOfStock;
2、ItemDiscount、ItemTrial、ItemRebate三种实现类继承Item基类;
源码链接

目前很完美...

新增需求:
增加虚拟商品类型,例如电子书,无库存限制

代码实现:

package domain

// 虚拟商品
type ItemVirtual struct {
   *Item
}
-----------------------------------------------------------------------
package controller

type Item struct {
   repo *repository.Item
}

func (ctr *Item) Order(c *gin.Context) {
   item := ctr.repo.Get()
   number := 2

    // 断言是否为虚拟商品类型
    _, isVirtual := item.Instance.(*bo.ItemVirtual)
    if !isVirtual && item.OutOfStock(number) {
       c.JSON(http.StatusOK, "库存不足")
    }
}

代码分析:
1、虚拟商品ItemVirtual实现类不应该包含stock属性和OutOfStock方法,但因为继承导致拥有了不属于自己的属性和行为;
2、再增加一个不需要库存检查的商品类型,调用方必须再增加一个断言,违反了开闭原则的"对修改关闭";
源码链接

继承优缺点

优点:
1、子类拥有父类的所有方法和属性,从而可以减少创建类的工作量。
2、提高了代码的重用性。
3、提高了代码的扩展性,子类不但拥有了父类的所有功能,还可以添加自己的功能。

缺点:
1、继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法,破坏了类的封装属性。
2、降低了代码的灵活性。因为继承时,父类会对子类有一种约束。
3、增强了耦合性。当需要对父类的代码进行修改时,必须考虑到对子类产生的影响。有时修改了一点点代码都有可能需要对打断程序进行重构。

如何扬长避短在代码用好继承呢?方法是遵守里氏替换原则

里氏替换原则

定义:只要有父类出现的地方,都可以用子类来替代,而且不会出现任何错误和异常。但是反过来则不行,有子类出现的地方,不能用其父类替代。

func (ctr *Item) Order(c *gin.Context) {
   item := ctr.repo.Get()
   number := 2
   // 1、当还未增加虚拟商品类型,这里的item变量用折扣、试用、返利三种的任意一个类型来代替都不会出现错误和异常
   // 2、当增加了虚拟商品类型,虚拟类型替代不了item变量,因为虚拟商品不需要检查库存
   if item.OutOfStock(number) {      
      c.JSON(http.StatusOK, "库存不足")
   }
}

通过断言具体某个实现类来判断商品类型确定是否需要检查库存,这就是前文遗留的问题,什么是面向实现编程?

面向实现编程

针对某一具体实现类进行代码编程,类似于面向细节编程,调用方对实现类有强烈的依赖关系,调用方必须清楚的知道具体哪些商品类型不需要进行商品库存检查,调用代码与实现类的具体类型耦合而难以扩展和维护,这种强烈的依赖关系将会大大地抑制编程的灵活性和可复用性。

我们现在面对三个问题:
1、如何消除不合理的继承
2、避免面向实现编程
3、遵守里氏替换原则

代码实现步骤:
1、新建ItemStockHandler接口;
2、封装库存属性为ItemStock结构体,实现ItemStockHandler接口;
3、ItemDiscount、ItemTrial、ItemRebate包含ItemStock;
4、response模型转换断言ItemStockHandler获取库存;
5、下单时断言ItemStockHandler,检查库存;
源码链接

package controller

func (ctr *Item) Order(c *gin.Context) {
   item := ctr.repo.Get()
   number := 2

   // 这里的item变量用折扣、试用、返利、虚拟四种的任意一个来代替都不会出现错误和异常
   stockHandler, ok := item.Instance.(bo.ItemStockHandler)
   if ok && stockHandler.OutOfStock(number) {
      c.JSON(http.StatusOK, "库存不足")
   }
}

代码分析:
1、Go语言的解决方式使用组合代替继承,消除了不合理继承,"组合优于继承"也是现在的主流观点;
2、新增商品类型是否支持库存检查,原调用方代码不变,新增商品类型只需要通过包含ItemStock结构体实现库存检查功能,这就是符合开闭原则,对修改关闭,对扩展开放;
3、调用方面向接口ItemStockHandler,而不是具体的某个商品实现类,这就是"面向接口编程,而不要面向实现编程",也就是依赖倒置原则;

依赖倒置原则

1、高层模块不应该依赖低层模块,两者都应该依赖其抽象;
2、抽象不应该依赖细节,细节应该依赖抽象;

高层模块不应该依赖低层模块,即高层模块应该持有抽象类或接口的引用,而不应该持有某一具体实现类的引用。

1、调用方controller.Item是高层,折扣、试用、返利、虚拟等商品类型是底层;
2、controller.Item判断ItemVirtual类型时,属于高层模块依赖低层模块;
3、controller.Item判断ItemStockHandler接口时,属于高层模块依赖低层的抽象;
4、商品类型实现ItemStockHandler接口,属于底层模块依赖抽象;

细节应该依赖抽象,即实现类也应该持有抽象类或接口的引用,而不应该持有某一具体实现类的引用。

// 折扣商品
type ItemDiscount struct {
   *Item
   ItemStockHandler // 细节(具体实现类)依赖抽象
}

// 折扣商品
type ItemDiscount struct {
   *Item
   *ItemStock      // 细节依赖细节(具体实现类)
}

抽象不应该依赖细节,即接口或抽象类不应该持有某一具体实现类的引用,而应该持有此类所继承抽象类或所实现接口的引用。

// 代码里没有,这里举例说明
type ItemLimiter interface {
   CheckStock(handler ItemStockHandler) error  // ItemLimiter接口依赖了ItemStockHandler接口,这就是抽象依赖于抽象
}

type ItemLimiter interface {
   CheckStock(stock *ItemStock) error   // ItemLimiter接口依赖了ItemStock具体实现类,这就是抽象依赖于细节
}

思考:还未出现虚拟奖品类型的开发阶段是否需要把ItemStockHandler抽象出来?

还未出现虚拟奖品类型的时候,代码是符合里氏替换原则,子类替换父类没有任何副作用,未来也有可能虚拟奖品类型永远都不会出现,过度设计也是一种浪费,代码应该随着需求的变更而进行不断的重构,不同的需求阶段重构出适合的代码逻辑,当出现虚拟奖品的需求时,就是合适的重构时机。

为什么要设计软件?

软件设计是为了「长期」更加容易地适应未来需求的变化。 正确的软件设计方法是为了长期地、更好更快、更容易地实现软件需求的变更。

业务需求在整个项目生命周期中不断改变和演进,软件设计的最大障碍就是变化,设计原则和设计模式的想解决的根本问题就是隔离变化,复用不变逻辑,隔离易变逻辑,所以识别出变化与不变,是区分程序员水平的一大标准。

两种可能的变化点:
1、变化点:在当前系统或者当前需求中已经存在了
如:商品价格计算

2、演化点:推测的类型变化可能发生在今后,但在当前的需求中不存在
如:判断库存是否充足,未来的商品类型有可能是无限库存,比如电子书类型

需求的可预测性: 实际开发中一个需求到达程序员手里,程序员第一时间需要评估未来的需求变化,例如登陆验证码种类功能项目后期需不需要增加重置密码种类,根据以往的项目经验和实际生活中的项目需求判断验证码大概率是需要多个种类,这时我们就可以用上文中的步骤来适应变化,这就是需求变化的可预测性。

需求的不可预测性: 还有很多需求未来不确定会有什么新的需求和改动,需求方也判断不了,很多需求变化是跟着项目的不断推进用户的不断反馈来进行演进,这种情况程序员判断不了代码哪些逻辑需要隔离变化,千万不要过多猜测,做很多无用功,增加代码难度,可以采取的策略是以不变应万变,根据需求的变化来重构代码。

开放封闭原则
设计的时候,尽量让这个类是足够好,写好了就不要去修改了,如果新需求来,增加类就完事了,原来的代码能不动则不动,但绝对对修改关闭时不可能的,拒绝不成熟的抽象和抽象本身同样重样,设计不足和过度设计同样需要避免,项目的需求是跟着市场方向和用户反馈不断演进的,让代码去适应变化,随着需求而不断演化的代码的好的选择。