zoukankan      html  css  js  c++  java
  • 无码系列5.1 代码重构 消除重复代码

    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:
    a.txt
    b.txt
    c.txt
    "
    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
    "))
             }
      case "ls":
             {
                conn.Write([]byte("a.txt
    b.txt
    c.txt
    "))
             }
          case "get":
             {
                conn.Write([]byte("a.txt
    "))
             }
      
          case "close":
             {
                conn.Write([]byte("close....
    "))
                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:
    a.txt
    b.txt
    c.txt
    "
    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:
    a.txt
    b.txt
    c.txt
    "
    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:
    a.txt
    b.txt
    c.txt
    "
    TcpSendMessage(space.TcpCon, sendMsg)
      
       return gworker.TaskWaitMore
    }
  • 相关阅读:
    mysql面试知识点
    计算机网络
    BFS
    拓扑排序
    双指针
    回溯算法
    hash表 算法模板和相关题目
    桶排序及其应用
    滑动窗口
    贪心算法
  • 原文地址:https://www.cnblogs.com/2020-zhy-jzoj/p/13165557.html
Copyright © 2011-2022 走看看