1 前言

本文可以视为对ThoughtWorks高级顾问yuanyingjie关于“正交四原则”策略“消除重复”的“个人解读”。 如有谬误,欢迎指正。首先感谢 ThoughtWorks高级顾问 yuanyingjie。 本文档大量“复制”了顾问的观点, 实在是太多,恕笔者不一一标注。

“实战篇” 章节是近期笔者代码练习而写, 是一个真实的代码演进过程。 这份代码的复杂度刚好切合了“重构”思路的表达, 以解决软件工程培训中“缺乏实际项目参考”的问题。

本文案例代码是一个练习副产品,其设计思路最早源自十年前笔者开发的一个模块。同时也是对本系列博文《无码系列-2 代码架构师的空想》的思路的一次实践检验。因此设计风格会与 TransDSL(yuanyingjie设计) 有所不同。 但这不妨碍我们学习探讨关于“代码重构”的思路。

2 关于消除重复代码的思考

2.1 为什么要消除重复代码?

消除重复代码是一种触发走向极简代码设计的“驱动力”。 它把架构设计思想转变成了一种可以实际操作的实践方法。

它不是架构设计的本质, 也不作为写代码的终极目标, 而是触发人们思考:我的代码还能简单一些吗? 还能再简单一些吗? 从而促使我们设计出一个高内聚低耦合的代码形态。

备注:高内聚低耦合的代码形态, 是为了大型软件、长生命周期软件能够更好地适应变化。 它使得代码的变更(功能特性修改/新增/删除)集中、有序, 避免牵一发而动全身。 其代码的可读性被放在了第二位。 我们反过来思考: 如果一个今天开发,明天上线后天就作废的软件, 有必要这么考究么? 这个情况下面向过程的快速编码, 才是最好的选择。

2.2 什么是重复代码?

代码架构设计是为了“代码能更好地表达业务”。 一个优秀的架构能让业务代码“仅关注业务逻辑的本质”, 而付出尽可能少的附加成本。 这些附加成本包括(仅列举,不限于此):

1) 为了描述业务逻辑而做的准备工作: 例如收集整理数据;

2) 因语言表达能力弱而额外补充的代码; 例如多线程及其线程池管理、异步临界数据和锁。

3) 为适应特定运行环境适配:适配虚拟机、物理机差异,通信协议差异、操作系统差异。

4) 商业成本、产品规格考虑:内存消耗、IO能力、功耗…

高内聚低耦合科普:

高内聚就是要把重复、分散的代码逻辑进行抽象整合。 表面上看是把重复的代码收编。 重复的代码背后隐藏着一个共同的逻辑。 这个逻辑可以通过抽象整合, 成为业务不直接感知的逻辑单元。 这个逻辑单元服务于业务,但不是业务逻辑关注的本质。 正是由于它“不是业务逻辑关注的本质”, 我们才有可能把这样的逻辑单元独立出来, 并使得业务逻辑从“不关注”上升到“更低依赖”的程度。 从而达成了我们的“低耦合”目的。 这里要特别注意一个概念: 低耦合不等于“一点都不耦合”。 业务逻辑的关联性是客观存在的, 解耦程度的极限是“足够低的耦合度”, 但不可能是“完全解耦合”。

发现重复的代码:

如果一个业务被定义为“不同于其他业务的特征”, 那么共同的逻辑就不是业务, 可以理解为一种基础设施。 消除重复的一种实现手段, 就是把共同的逻辑变成基础设施。 最终的目的是我们得到一个高内聚低耦合的代码。 注意:这里仅是其中一种手段, 还有更多手段等待您去设计。

通常我们把代码相同的,或者一个代码有bug另一个代码也必然有bug的, 认为是重复代码。 或者一段代码有需求要修改, 另一段代码也必然会有需求要跟着做相似修改的。

为了节约时间,恕笔者假定读者已经做过一些代码微重构,并积累了常规的经验。 我们不讨论那些“显而易见”的重复代码。 也不讨论“宏定义”、“函数封装”等已经在“教科书”上明确定义的解决方案。除了“显而易见”的代码重复之外, 我们应当如何寻找“重复的代码”?

用逆向的思维方式去观察两个事物 A、B。 当我们向一个非常熟悉 A 业务的朋友介绍B业务时, 我们会说: B跟A非常相似,它只是在X方面和A有点不一样。 从某种意义上说, 如果用代码去实现A、B两个业务, 是否代码上也仅在X方面略有不同?

在已有A业务代码的情况下, 开发B业务代码。 我们可爱的程序员在为B业务写代码的时候会发现事情远没有“说得那么轻巧”。 要共用A业务代码,并且修改其X方面的差异点,会非常费劲。 还不如独立写一个B业务代码, 并且能重用A的函数就尽量重用, 不能重用的就再写一段代码。 我们日常观察到的代码大致如此: 重用的代码也不少, 但往往局限在一个函数、一个小片段。 代码的表现形式上, 却远没达到日常语言表达 “说得那么轻巧”的程度。

一句“仅在X方面略有不同”在代码上应当如何表示? 如果“X方面”是散落在各消息流程中的信元,它们对应不同场景、不同的代码块…。 总而言之,A、B两个业务之间的逻辑重复, 在代码层面不一定“重复”。 退一步地说, 如果您已经找到了收编“逻辑重复”的方法(工具), 那它就是重复代码。 如果您还没有找到方法, 那就暂且认为“代码不重复”吧。 从某种意义上说,代码是否重复, 和我们使用的语言及其特性、掌握的技能都是有关系的。我们有更多的工具、方法简洁地表达业务逻辑,就能发现业务里面更多的共同点。 它就是我们要找的重复代码。

2.3 消除重复代码的手段

消除重复有很多种手段。 例如使用宏定义、封装成函数、 使用java的注解。 某些重复是“逻辑上重复,代码不同”。 不一定适用常规的方法消除重复。 如果能设计出一个良好的架构,从代码逻辑抽象的角度能找到通用的逻辑表达方案, 是可以消除重复的。 如果缺乏架构支撑, 这些逻辑就无法被合理地整合起来。 甚至我们由于“知识能力的局限”而认为“这不是重复代码”。

程序语言本身不具备某种特性, 例如C++ 不具备java的反射机制。 这会使得一些代码逻辑的表达显得很困难。 以至于为了消除一个重复代码所支付的代价过高, 没有任何收益。 这时候给人的直观判断是“这段代码不重复”。 如果我们能够从语言特性中寻找到解决问题的方法, 即把“代价”降到足够低, 它就成为“消除重复代码”的一种手段。 因此手段是人想出来的, 并不局限于宏定义、封装成函数等常规方式。

代码架构师应当懂很多门高级语言, 而且理解高级语言“特性”背后的原因。 任何一门语言再提供某个特性时,都是有其背景和意图的。 它一定是为了解决某个问题, 使得在解决问题时的代价更低。 只有这样,我们遇到一个问题时,才能很快地闪现一个解决方案:这个问题在XX语言中是怎么解决的? 用在当前产品的语言中, 我们有什么模拟/替代的解决方案?

2.4 “消除重复代码”成为代码架构设计的驱动力

判断重复的标准不是代码完全相同或者相似。 相同一定重复了。 如果代码不同而逻辑相同,甚至逻辑也不同,只是神似而形不似, 是不是重复?

例如A发消息并接收响应。B发消息但不接收响应。 无论从消息编码格式还是消息行为上, A、B都是不一样的。 在transDSL(参见ThoughtWorks高级顾问yuanyingjie的《Transaction DSL - Yuan Ying Jie.pdf》)中它们都被封装为action(一个步骤)。 即被收编为统一的action。 甚至action可以是既不发送消息也不接收消息的单元、 或者只收不发。 这些逻辑迥异的代码和行为, 如果找到一种模型去表达他们的共性, 并把他们的特征不断“精炼”, 最终的结果也是消除重复。

所以消除重复的根本含义是提炼代码的共性, 保留代码的特性。 从数学意义上描述, 任意事务都能分离出一个通解空间和一个特解空间。如果我们觉得代码没有重复, 也许我们只是暂时没有找到一种更精炼的特征表达方式。 消除重复的结果就是把问题的通解架构化, 把特解的表达形式压缩到最简、最本质的方式。

问题总有其本质,我们几乎不可能让代码表达的复杂度低于问题的本质。 大部分时候代码表达的形式要比问题的本质要复杂很多。 这种复杂是代码表达能力带来的额外负担。 我们需要找到一种工具去降低这种额外负担。 消除重复代码是一种驱动力,它试图提醒我们“该去寻找这样一种工具了”。 这个工具就变成了我们的代码架构。

3 消除重复代码 ---实战篇

3.1 从业务特例中提取流程框架

附录“代码演进第一版”中采用了面向过程的方式实现了一个 FTP服务端玩具(仅为了说明问题, 不要在意细节)。 面向过程的业务处理代码通常具有比较好的可读性。 对于任意一个流程环节, 你都能找到与之对应的一段代码。

备注:所以笔者并不鄙视面向过程,某些场景下面向过程更占优势。 面向过程和面向对象,就如一个螺丝刀VS瑞士军刀, 很难说谁能替代谁。笔者也不会去争论PHP是不是最好的语言。

第一版的代码存在较严重的一个问题:所有代码都是“专用的”,它不存在公共逻辑可供第三方使用。

例如 Listen是一个长期执行的任务, Login、UserName、Password行为是串行环节, Get、Put、Ls、Cd 等行为是并行case。 它们都属于“执行单元”。 为了协调这些“执行单元”的顺序关系、出错处理, 代码出现了不少重复。

3.1.1 代码的问题

§ 重复的出错处理:

 

 

备注: 一个好的架构需要能很“优雅”地进行异常处理。 出错处理要求“集中、统一”, 避免异常场景处理花样多, 引发其它问题。 这也是可信软件的一项要求。

§ 重复的代码case形式:

 

 

备注: 大量switch-case是面向过程代码经常出现的形式。 由于其不具有对象注入的能力, 不得不在一个函数中通过大量case 来列举。 这种代码导致了散弹式的修改(一个内聚的功能, 需要分拆到不同的代码块实现)。

§ 业务流程代码不能复制:

代码中通过面向过程的方式描述了一个FTP使用过程。 但这个过程难以被其它业务重用。 例如对FTP使用场景进行一个变异, 获得新的业务流程。 这段代码就完全无法重用。

3.1.2 重构后的代码

§ 提取了业务流程描述机制, 使得业务流程描述可以被其它业务共用。

§ 统一的出错处理机制: OnErrorGoto

§ 消除了大量switch-case 嵌入同一个函数, 使得业务单元之间耦合降低

§ 业务流程直观描述:把分散在代码各处的流程描述代码, 抽象集中到一起。直观显示了整个业务的概貌

a href="">§ 代码重构的逻辑对应关系:

 

 

§ 把面向过程的“流程逻辑”显式提取出来

这个流程逻辑,在逻辑意义上是一个状态机。 传统的软件设计是从“状态变迁图”翻译成代码,其对应的代码实现就是状态机。 在transDSL实现思路中, 是把状态机隐藏在trans描述的流程中。 在这份代码中, 则把状态机隐藏在“执行计划”中。

 

 

备注: 上述代码风格和重构, 引用了更多重构知识点。 后续文档逐步展开说明。

由于本代码案例与 transDSL略有差异, 部分读者已经熟知transDSL。 这里提供一个逻辑对照。

 

href="">3.2 整合零散代码,提高内聚性

规整代码形式,发现宏观上的重复代码。 一个业务逻辑单元的代码,应当尽可能放在一起。 通过语言提供的一些特性,我们尽可能地把一个业务的代码放进“一个积木”里面。 避免这个积木的代码被拆解、分散在多个位置。 这样我们就更方便拿两个积木进行对比分析, 发现共同点。 并把积木进一步精简。

备注:Java语言提供了很好的注解/注入方式去实现这一点。 Go 语言实现没有那么优雅,也勉强能做到。 C++ 利用全局变量进行信息交换, 也可以模拟注入。 C语言就难免要主动“注册”信息到全局了。

§ 把归属一个业务单元的信息规整到一起:

 

 

不同的业务单元之间, 逻辑存在一定程度的相似。 但从代码角度看, 又是很不一样的。 这是因为一个完整的逻辑单元被我们拆解成多个“更小的颗粒”。 这些小颗粒又被归类存放。 以至于在“大颗粒”的业务单元上对比本该相似的代码, 在小颗粒对比上反而不相似了。 简单一点地说, 就是宏观上本该相似的, 到了微观对不时却又不相似了。

§ 再看是否有更多的相似点:

 

 

上述代码中, 存在几个明显的代码重复点:

1、注册事件监听的机制相同, 参数不同。

2、for循环相同

3、stop控制退出的机制相同

4、有一个NameMap的共同机制。 这个机制后续被用来承载“消除重复”的重任

3.3 用逆推导方式, 提炼业务的本质

通过逆向思维,我们先寻找业务的本质。 找出“哪些代码是绝不会重复的”。

看下图的框中,才是两个业务单元本质上的差异。 其它代码都是为了能正确执行它而付出的额外代价。

§ 发现业务的本质差异:

把“非本质”的代码收编掉:

1) 注册事件监听的代码, 参数化

2)for循环控制,转变成 return 指示

3)stop 控制信号处理, 提取出来集成到框架中

去除重复代码后的效果:

如下黄色标记的代码, 是业务逻辑“最本质”的表达形式。 经过重构,已经把其它辅助的代码逻辑削减到最小。

注意:这里的关键是“把代码逻辑削减到最小”, 而不是“把代码行数削减到最小”。 因为重构的目的是“降低代码逻辑复杂度”, 而不是降低代码行数。 尽管大多数重构的结果也导致了代码行数的大幅度降低。

var _ = gworker.NameMap("CmdLs", &CmdLs{},"ls")
  func (t *CmdLs)OnRequest(wkSpace interface{}, data string) gworker.TaskCtrl {
   space := wkSpace.(*SpaceA)
   fmt.Println("rcv cmd:", data)
   sendMsg := "list:\r\na.txt\r\nb.txt\r\nc.txt\r\n"
TcpSendMessage(space.TcpCon, sendMsg)
   return gworker.TaskWaitMore
}

4 附录代码片段

备注:本附录中的代码是笔者练习的副产品。 可供大家学习、软件工程方法论验证使用。 如果需要放在产品、工具中运行本代码框架, 请自行进行加固改进。 这是GO语言版的代码。 另有C++语言版的相似代码架构, 如有需要,可向笔者索取。

4.1 代码演进第一版

func main() {
addr := "127.0.0.1:8080"
fmt.Println("listen :" + addr)
fmt.Println("input command: cd ls get close")
listenOnPort(addr)
return
}

业务单元的实现案例:

流程、业务逻辑混合在一起。 流程控制逻辑不能重用。 异常处理机制不能汇聚。

func FtpServer(conn net.Conn) {
   defer conn.Close()
  
   var ftpInfo FtpInfo
  
     user, _, isOk1 := GetUserName(conn)
if isOk1 == false {
      fmt.Println("get user name error")
      return
}
  
   ftpInfo.UserName = user
  
   pwd, _, isOk2 := GetPassword(conn)
   if isOk2 == false {
      fmt.Println("get password error")
      return
}
  
   ftpInfo.Pwd = pwd
  
   leftBuf := []byte{}
   cmd := ""
isOk := false

isClode := false

for {
      if isClode == true {
         break
}
  
      cmd, leftBuf, isOk = AskAndAnser(conn, "", leftBuf)
      if isOk == false {
         continue
}
  
      switch cmd {
      case "cd":
         {
            conn.Write([]byte("change dir\r\n"))
         }
  case "ls":
         {
            conn.Write([]byte("a.txt\r\nb.txt\r\nc.txt\r\n"))
         }
      case "get":
         {
            conn.Write([]byte("a.txt\r\n"))
         }
  
      case "close":
         {
            conn.Write([]byte("close....\r\n"))
            time.Sleep(time.Duration(1) * time.Second)
            isClode = true
}
      }
   }
}

4.2 代码演进第二版

第二版实现了基于逻辑描述的“语言”, 实现对业务过程的描述。 代码架构通过文本名称动态装配业务逻辑, 并解释执行。

func ServiceA() gworker.SpaceIf {
   var plan SpaceA
   plan.Init()
  
plan.OnErrorGoto("StepEnd")  // 出错后跳转到 StepEnd ,执行终结处理

plan.S("Listen 127.0.0.1:8080")  // 监听端口,死循环调用。 这里收到一个连接后,要fork出一个新任务
plan.B("ReadLine", "DispatchCmd")      // 后台执行 全生命周期任务
plan.S("CmdLogin", "CmdUser", "CmdPwd")  // 顺序执行任务,验证密码
plan.P("CmdPut", "CmdLs", "CmdCd", "CmdGet","CmdClose")  //并发执行任务
plan.S("StepEnd")  //进入终结任务操作, 回收资源
plan.S("SayBye")
  
   return &plan
}

需要有一个全局注册表信息:

func init(){
   fmt.Println("gworker init...")
  
   AddNameMap("Listen", &Listen{})
   AddNameMap("ReadLine", &ReadLine{})
   AddNameMap("DispatchCmd", &DispatchCmd{})
   AddNameMap("CmdLogin", &CmdLogin{})
   AddNameMap("CmdUser", &CmdUser{})
   AddNameMap("CmdPwd", &CmdPwd{})
   AddNameMap("CmdPut", &CmdPut{})
   AddNameMap("CmdLs", &CmdLs{})
   AddNameMap("CmdCd", &CmdCd{})
   AddNameMap("CmdGet", &CmdGet{})
   AddNameMap("CmdClose", &CmdClose{})
   AddNameMap("StepEnd", &StepEnd{})
   AddNameMap("SayBye", &SayBye{})
}

一个业务单元的实现案例:

type CmdLs struct {
   gworker.TaskBase
}
  
  func (t *CmdLs)OnRequest(wkSpace interface{}) int {
   fmt.Println("CmdLs finish OnRequest" )
  
   space := wkSpace.(*SpaceA)
  
myChn := make(chan interface{}, 5)
   space.EventListen("ls", myChn)
   defer space.EventListen("ls", nil)
  
   inRun := true
for ;inRun; {
      cmd, ok := <- myChn
      if ok != true {
         break
}
      switch msg := cmd.(type) {
      case gworker.EventStop:
         inRun = false
case string:
         fmt.Println("rcv cmd:", msg)
         sendMsg := "list:\r\na.txt\r\nb.txt\r\nc.txt\r\n"
TcpSendMessage(space.TcpCon, sendMsg)
      }
   }
  
   return 0
  }

4.3 代码演进第三版

func ServiceA() gworker.SpaceIf {
   var plan SpaceA
   plan.Init()
  
plan.OnErrorGoto("StepEnd")  // 出错后跳转到 StepEnd ,执行终结处理

plan.S("Listen 127.0.0.1:8080")  // 监听端口,死循环调用。 这里收到一个连接后,要fork出一个新任务
plan.S("Echo receive a tcp connect")
   plan.B("ReadLine", "DispatchCmd")      // 后台执行 全生命周期任务
plan.S("Echo login first:", "CmdLogin", "CmdUser", "CmdPwd")  // 顺序执行任务,验证密码
plan.S("Echo and then input command: put cd ls get close")
   plan.P("CmdPut", "CmdLs", "CmdCd", "CmdGet","CmdClose")  //并发执行任务
plan.S("StepEnd")  //进入终结任务操作, 回收资源
plan.S("SayBye")
  
   return &plan
}

一个业务单元的实现案例:

第三版对业务单元进行了格式整理, 利用go语言特性实现了类似java注解的机制。 使得对象注入比较优雅。 同时把原来拆散的业务逻辑单元代码物理上聚集在一起。 成为真正的积木。

var _ = gworker.NameMap("CmdLs", &CmdLs{})
  type CmdLs struct {
   gworker.TaskBase
}
  
  func (t *CmdLs)OnRequest(wkSpace interface{}) int {
   fmt.Println("CmdLs enter OnRequest" )
  
   space := wkSpace.(*SpaceA)
  
myChn := make(chan interface{}, 5)
   space.EventListen("ls", myChn)
   defer space.EventListen("ls", nil)
  
   inRun := true
for ;inRun; {
      cmd, ok := <- myChn
      if ok != true {
         break
}
      switch msg := cmd.(type) {
      case gworker.EventStop:
         inRun = false
case string:
         fmt.Println("rcv cmd:", msg)
         sendMsg := "list:\r\na.txt\r\nb.txt\r\nc.txt\r\n"
TcpSendMessage(space.TcpCon, sendMsg)
      }
   }
  
   return 0
  }

4.4 代码演进第四版

func ServiceA() gworker.SpaceIf {
   var plan SpaceA
   plan.Init()
  
plan.OnErrorGoto("StepEnd")  // 出错后跳转到 StepEnd ,执行终结处理

plan.S("Listen 127.0.0.1:8080")  // 监听端口,死循环调用。 这里收到一个连接后,要fork出一个新任务
plan.S("Echo receive a tcp connect")
   plan.B("ReadLine", "DispatchCmd")      // 后台执行 全生命周期任务
plan.S("Echo login first:", "CmdLogin", "CmdUser", "CmdPwd")  // 顺序执行任务,验证密码
plan.S("Echo and then input command: put cd ls get close")
   plan.P("CmdPut", "CmdLs", "CmdCd", "CmdGet","CmdClose")  //并发执行任务
plan.S("StepEnd")  //进入终结任务操作, 回收资源
plan.S("SayBye")
  
   return &plan
}

一个业务单元的实现案例:

第四版代码,把业务逻辑单元中相似的代码进行了提取。 事件监听机制参数化, 控制流程能力下沉到架构中实现。

var _ = gworker.NameMap("CmdLs", &CmdLs{},"ls")
  type CmdLs struct {
   gworker.TaskBase
}
  
  func (t *CmdLs)OnRequest(wkSpace interface{}, data string) gworker.TaskCtrl {
   fmt.Println("CmdLs enter OnRequest" )
   space := wkSpace.(*SpaceA)
  
   fmt.Println("rcv cmd:", data)
   sendMsg := "list:\r\na.txt\r\nb.txt\r\nc.txt\r\n"
TcpSendMessage(space.TcpCon, sendMsg)
  
   return gworker.TaskWaitMore
}

 

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐