Go语言高效率Web开发五:改造面向过程代码,步步惊心(步一)

学习方式自下而上:提出问题 -> 分析问题 -> 解决问题 -> 总结

需求场景

WechatIMG1484.jpeg

如图所示,返回当前周签到记录:
{
  "data": {
    "2022-01-02": true,
    "2022-01-03": false,
    "2022-01-04": false,
    "2022-01-05": false,
    "2022-01-06": false,
    "2022-01-07": false,
    "2022-01-08": false
  }
}
true代表签到 false代表未签到
复制代码

面向过程代码实现

源码链接

func (ctr *User) Signs(c *gin.Context) {

   now := time.Now()
   index := int(now.Weekday())
   dates := make([]string, 7)
   for i := 0; i < 7; i++ {
      day := now.AddDate(0, 0, i - index).Format("2006-01-02")
      dates[i] = day
   }
   // dates [2022-01-02 2022-01-03 2022-01-04 2022-01-05 2022-01-06 2022-01-07 2022-01-08]

   signs := ctr.repo.Signs(c.Request.Context(), dates)
   // signs [&{Id:1 UserId:1 Date:2022-01-02 00:00:00 +0800 CST}]

   response := make(map[string]bool)
   for _, s := range signs {
      k := s.Date.Format("2006-01-02")
      response[k] = true
   }
   // response [2022-01-02:true]

   for i := 0; i < 7; i++ {
      k := dates[i]
      if _, ok := response[k]; ok {
         continue
      }
      response[k] = false
   }

   c.JSON(http.StatusOK, gin.H{"data": response})
}
复制代码

思考:如果现在是codeReview阶段,需要代码评审,这段代码都有什么问题?

检视代码

在检视代码之前,先和大家讲个故事,前段时间家里跳闸停电了,家里电器较多,不太可能一个个地试,幸运的是现在的总闸旁边还有一系列的“功能电闸”,我先把所有的电闸关了,再将大厅的推上去,发现没问题,然后把主卧、侧卧一一推上去,都没问题,推到厨房时,又跳闸了,最后找到是厨房里的电热水壶漏电了,整个过程就只需要几分钟时间,而如果家里只有一个总闸,只能把所有的电器插头先拔了,然后把家里的电器接通电源挨个试,想想都头大。

听完故事,我们回头再检视一下我们的代码,首先,这段代码因为将参数整理、数据获取、数据转换、格式调整、输出等功能全部耦合在一起,一但出现生产问题,我们要紧急排查时,我们将不得不把整个方法都看一遍,也就像我们在检查电路时,将整个房子里的电器全部检查一遍,这个就是可阅读性差

此外,如果我们希望对厨房的线路进行改造时我们只需要关闭厨房的电闸即可,但若只有一个总电闸,我们将不得不将总闸关闭,整个屋子断电了才能整改,例如需要现在的“年月天”日期修改成只需要“天”,我们需要从头阅读代码,定位所有跟日期有关的变量和日期key代码片段,理清代码片段上下文逻辑,然后才能确定是否需要修改,这个就是可维护性差

另一方面,当我们需要在此基础上再增加一个新的日期范围,比如月签到,季度签到时,成本也会越来越高,每次修改都要改动原有的代码(所有功能都在一个方法中)。此外,当我们增加或修改功能时,至少得将原方法读一遍才能动手吧,方法越长就越难以理解,也就越难以修改,而因为原有代码难以理解,大多数情况下只能小修小补,或增加一个分支,以期望尽量不影响原有功能,增加一个新功能速度越来越慢,并且形成恶性循环,这就是可扩展性差

func (ctr *User) Signs(c *gin.Context) {

   // 参数整理开始
   now := time.Now()
   index := int(now.Weekday())
   dates := make([]string, 7) // 问题:通篇数字7,7代表什么含义
   for i := 0; i < 7; i++ {
      day := now.AddDate(0, 0, i - index).Format("2006-01-02") // 问题:“2006-01-02”字符串出现多次
      dates[i] = day
   }
   // dates [2022-01-02 2022-01-03 2022-01-04 2022-01-05 2022-01-06 2022-01-07 2022-01-08]
   // 参数整理结束

   // 获取用户数据
   signs := ctr.repo.Signs(c.Request.Context(), dates)
   // signs [&{Id:1 UserId:1 Date:2022-01-02 00:00:00 +0800 CST}]

   // 数据转换开始
   response := make(map[string]bool)
   for _, s := range signs {
      k := s.Date.Format("2006-01-02")
      response[k] = true  // 问题:布尔值true代表什么含义
   }
   // response [2022-01-02:true]

   for i := 0; i < 7; i++ {
      k := dates[i]
      if _, ok := response[k]; ok {
         continue
      }
      response[k] = false // 问题:布尔值false代表什么含义
   }
   // 数据转换结束

   // 数据输出
   c.JSON(http.StatusOK, gin.H{"data": response})
   
   // 问题:dates、signs变量贯穿函数,加大阅读难度,增加调试难度
}
复制代码

单一职责原则

单一职责原则:引发某个对象修改的原因有且只有一个。

职责单一化,使代码易于理解,也就是说一个类或方法就应该被设计用于完成一类或一件事情,如果不是,那么当它所完成的若干个功能中的任意一个发生变化时,我们将不得不对这个类或方法进行修改,这就是违反了单一职责原则。

单一职责原则不但适用于类和方法,而且适用于属性,数据库字段等。一种非常不好的设计是在一个字段中将多个信息揉在一起,然后通过字符串分隔、匹配的方式做出条件判断。

违反单一职责原则的一般表现,大体如下:

1、过长的方法,方法内部包含多个功能实现,整体难以复用
2、发散式变化,其中任意相关功能发生变化,都需要修改此方法
3、问题难定位,出现问题时,方法过长,内部数据抽取、转换、加工都耦合,难以快速定位问题
4、方法中代码缩进超过3层,出现if...else与switch...case或for...循环等的多层嵌套,方法难以理解
5、一个或多个变量贯穿整个方法被引用、被当作条件判断,或根据不同条件在多处被赋值修改
复制代码

如果不及时重构,任其发展,那么代码会持续腐化,变得非常复杂而难以理解,当没有人愿意对此方法或类进行修改,需求发生变更需要在原基本上新增功能时,大家会倾向于将原方法复制一份,改一个名字,然后在新增加的功能中指向此新方法,此时就入了违反单一职责原则的高级阶段——重复的代码。

单一职责原则 --> 降低复杂度 --> 简化代码 --> 提升可维护性

诊断一下这份示例代码:
方法接近30行,尤其是数据抽取、转换、赋值散落在方法各处,维护成本较高,dates、signs这些变量因为不同的原因被创建,在方法的不同逻辑块当中穿梭使用,有时根据条件、场景赋值,有时又被其他变量引用,被当成参数调用,代码耦合很重要的一类原因就是这个,当我们需要维护或扩展此方法时,需要理解这类变量在方法的每一处引用、赋值,而大脑的容量是有限的,会造成阅读障碍。

此外,在此基础上再增加一个新的时间段查询或需求发生变更时修改代价较大,所有的这些问题,都是之前欠下的技术债务,影响的不是当下,而是未来。

It's payback time!

确定重构目标

1、消除隐性知识,隐性知识显性化
2、消除穿梭的变量
3、变量方法命名自解释
复制代码

隐性知识:知识是需要一段学习和理解的过程,比如代码中的数字7,在这段代码的上下文中,程序员需要思考一下才能理解数字7是星期的天数,这就增加了阅读难度,隐性知识需要消除!(隐性知识又称魔术值)

穿梭的变量:一个变量在整个方法中贯彻始终,哪里都会出现,作用域特别广,当排查bug或修改功能时不知道哪里使用这个变量的时候会对其进行改变,必然导致阅读难度和维护难度,穿梭的变量需要消除!

命名自解释:一个易理解没有歧义的方法名或变量名可以降低代码理解难度,编码5分钟,命名2小时!

func (ctr *User) SignsV1(c *gin.Context) {

   dates := ctr.getCurrentWeekDates()

   signs := ctr.repo.Signs(c.Request.Context(), dates)

   response := ctr.transformSigns(signs, dates)

   c.JSON(http.StatusOK, gin.H{"data": response})
}

func (ctr *User) getCurrentWeekDates() []string {
   now := time.Now()
   index := int(now.Weekday())

   weekdays := 7 // 每周七天 用变量名自解释,消除隐性知识
   dates := make([]string, weekdays)
   for i := 0; i < weekdays; i++ {
      dates[i] = now.AddDate(0, 0, i - index).Format(ctr.getDateFormatLayout())
   }

   return dates
}

func (ctr *User) transformSigns(signs []*domain.UserSign, dates []string) map[string]bool {

   signed := true //消除隐性知识
   unSign := false

   signsMap := make(map[string]bool)
   for _, s := range signs {
      k := s.Date.Format(ctr.getDateFormatLayout())
      signsMap[k] = signed
   }

   len := len(dates)
   for i := 0; i < len; i++ {
      if _, ok := signsMap[dates[i]]; ok {
         continue
      }
      signsMap[dates[i]] = unSign
   }
   return signsMap
}

// 函数名自解释,消除隐性知识
func (ctr *User) getDateFormatLayout() string {
   return "2006-01-02"
}
复制代码

至此,我们将变量限制在了各自的子函数中,通过函数命名,主体方法变成了四句"话"

ctr.getCurrentWeekdays:获取当前周日期列表
repo.Signs:  通过待签到日期列表,换取签到日历数据
ctr.transformSigns:通过签到数据,换取所需要的数据格式
c.JSON:    给定签到日历数据,输出业务格式,完成功能
复制代码

每一个子函数功能都是单一的,有问题就改相应的子函数,而不需要动到其他的方法,在方法层级,我们达成了“单一职责”的目标。

注意:此时是一个面向过程的方式完成功能,面向过程的方式其实也并没有什么不好,通过命名与变量隔离,面向过程的方式也能写出相对易于理解的代码,想像一下,如果此时发生生产问题,我们可通过功能电闸的方式可以快速排查问题,如果需要增加新的时间段查询,修改起来范围也不会太大。

但是,这还不够,现在只是在方法层级上实现了“单一职责”,类的层面,User Controller类负责了输出功能、获取数据功能、格式转化功能,例如当扩展其他时间段时,在类的层面,我们仍然需要进行修改,所以让我们继续往下看...

思考:ctr.getCurrentWeekDates、ctr.transformSigns、ctr.getDateFormatLayout属于User Controller类的类方法吗?再换个思路思考,如果有另外的Controller类需要获取当前周日期列表怎么办?复制一份?

解析:
获取当前周日期列表是Week类的行为
签到数据数据格式转换是UserSigns类的行为
日期格式转换是Date类的属性



package date

var LayoutYmd = "2006-01-02"

var LayoutYmdHis = "2006-01-02 15:04:05"


// 用变量名自解释,消除隐性知识
var Weekdays = 7

type Week struct {
}

func (w *Week) CurrentDates() []string {
   now := time.Now()
   index := int(now.Weekday())

   dates := make([]string, Weekdays)
   for i := 0; i < Weekdays; i++ {
      dates[i] = now.AddDate(0, 0, i - index).Format(LayoutYmd)
   }

   return dates
}

type UserSigns []*UserSign

func (dom UserSigns) TransformMap() map[string]*UserSign {
   signsMap := make(map[string]*UserSign)
   for _, s := range dom {
      k := s.Date.Format(date.LayoutYmd)
      signsMap[k] = s
   }

   return signsMap
}


func (ctr *User) SignsV2(c *gin.Context) {

   var week date.Week
   dates := week.CurrentDates()

   var signs domain.UserSigns = ctr.repo.Signs(c.Request.Context(), dates)

   response := ctr.transformSignsV2(signs.TransformMap(), dates)

   c.JSON(http.StatusOK, gin.H{"data": response})
}

func (ctr *User) transformSignsV2(signs map[string]*domain.UserSign, dates []string) map[string]bool {

   signed := true //消除隐性知识
   unSign := false

   signsMap := make(map[string]bool)
   len := len(dates)
   for i := 0; i < len; i++ {
      k := dates[i]
      if _, ok := signs[k]; ok {
         signsMap[k] = signed
      }
      signsMap[k] = unSign
   }
   return signsMap
}
复制代码

至此我们实现了类与类之间的隔离,大家好好体会

新增需求:增加当前月签到记录

func (ctr *User) SignsV3(c *gin.Context) {

   var days []string
   if periodType, _ := c.GetQuery("periodType"); periodType == "week" {
      var week date.Week
      days = week.AllDays()
   } else {
      var month date.Month
      days = month.AllDays()
   }

   var signs domain.UserSigns = ctr.repo.Signs(c.Request.Context(), days)

   response := ctr.transformSignsV3(signs.TransformMap(), days)

   c.JSON(http.StatusOK, gin.H{"data": response})
}
复制代码

看完后什么感觉呢?我们在日常开发代码中,是否看到过类似的代码呢?我们往老代码中添加新功能时,是不是在原代码基础上,通过扩展若干个参数,增加一个if...else或者switch...case分支来实现新功能的呢?

开放封闭原则

开放封闭原则:对象对扩展是开放的,对修改是封闭的。

利用多态,通过增加新类的方式来扩展新功能需求,以达到将变化封闭在仅需要变化的类的内部。

使用开放封闭原则 --> 降低耦合度 --> 控制变更的范围 --> 增加可扩展性

确定重构目标

1、利用多态在类层级隔离变化
2、重命名与消除冗余代码
复制代码
type Period interface {
   AllDays() []string
}

func NewPeriod(tp string) Period {
   switch tp {
   case "week":
      return new(Week)
   case "month":
      return new(Month)
   }

   return nil
}

func (ctr *User) SignsV3(c *gin.Context) {
   
   periodType, _ := c.GetQuery("periodType")
   period := date.NewPeriod(periodType)
   days := period.AllDays()

   var signs domain.UserSigns = ctr.repo.Signs(c.Request.Context(), days)

   response := ctr.transformSignsV3(signs.TransformMap(), days)

   c.JSON(http.StatusOK, gin.H{"data": response})
}
复制代码

至此,我们参照单一职责、开放封闭两个原则,将变化在类与方法两个层级进行了隔离,也就是说当需求变更时,如果是周相关的,只需要调整Week类,反之亦然,如果是数据抽取和转换相关的,只需要调整基类中的方法即可,同时,如果需要增加一个比如季签到记录,我们对于现有代码不需要调整,只需要扩展一个Season类即可。

思考:当项目开始只需要周签到记录的时候,需不需要抽象Period接口?

开放封闭原则要求在设计时,尽量让类足够好,写好了就不去修改,新需求通过增加类来解决,原来的代码能不动就不动,但是绝对的对修改关闭是不可能,所以使用开放封闭原则时,需要警惕:拒绝不成熟的抽象和抽象本身同样重要,也就是说不要因为对需求或未来的猜测而进行抽象,过度设计不如无设计,超过3层的业务类继承所带来的维护成本很可能超过它所带来的好处,最好的设计就是随需求而演化出来的刚刚好的实现。在实际开发中,很有可能这个项目整个生命周期都只需要周签到功能,只当增加月签到功能的时候,通过重构增加Period接口,这是个好的时机。

为什么要设计软件?

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

需求不断在变化

一个项目从生到死的整个过程中软件功能不可能一直保持不变,中间存在大量的新增需求和老旧功能的迭代更新,所以代码必须要拥抱变化、适应变化,能快速的适应需求变化的代码就是好代码。

实例分析重构后的代码如何应对需求的变化:

修改年月天格式 -> 天格式 {"30":true,"31":false,"1":false,"2":false,"3":false,"4":false,"5":false}

分析:数据格式的变化需求,跟参数整理、数据查询无关,可以快速定位到transformSigns方法,修改map的key值生成和赋值逻辑。
复制代码
签到可以获得不同数量的京豆,返回数据需要增加京豆数据,需要兼容老版本
1.0版本返回:{"2021-05-30":0,"2021-05-31":0,"2021-06-01":1,"2021-06-02":1,"2021-06-03":0,"2021-06-04":0,"2021-06-05":0}
2.0版本返回:{"2021-05-30":{"sign":0,"bean":0},"2021-05-31":{"sign":1,"bean":1},"2021-06-01":{"sign":1,"bean":2},"2021-06-02":{"sign":0,"bean":0},"2021-06-03":{"sign":0,"bean":0},"2021-06-04":{"sign":0,"bean":0},"2021-06-05":{"sign":0,"bean":0}}
分析:UserSign增加bean属性,数据转换transformSigns方法抽取为单独类,
     新建Transformer接口,接口定义Transform方法等等,参照Week类重构。
复制代码

总结

单体应用 -> 微服务 -> 分层结构 -> MVC -> OOP -> 单独类 -> 单独类的不同方法

设计软件本质是管理复杂性,解决复杂业务需求,没有什么好办法,只能做到分而治之,把复杂的业务拆分成多个合适的子业务,子业务再拆分成多个合适的子模块,子模块再拆分成多个合适的类,类再拆分成多个合适的方法...
如何才能合适?想明白了,你就是软件大师。

猜你喜欢

转载自juejin.im/post/7050371891382452255