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
    }
  • 相关阅读:
    Discuz X 2.5 点点(伪静态)
    jq 、xml 省市级联动
    php memcache 初级使用(2)
    关于windows虚拟内存管理的页目录自映射
    SharePoint 2010 网络上的开发经验和资源
    SharePoint 2010 Reporting Services 报表服务器正在内置 NT AUTHORITY\SYSTEM 账户下运行 解决方法
    SharePoint 2010 Reporting Services 报表服务器无法解密用于访问报表服务器数据库中的敏感数据或加密数据的对称密钥 解决方法
    Active Directory Rights Management Services (AD RMS)无法检索证书层次结构。 解决方法
    SharePoint 2010 Reporting Services 报表服务器实例没有正确配置 解决方法
    SharePoint 2010 页面引用 Reporting Services 展现 List 报表
  • 原文地址:https://www.cnblogs.com/2020-zhy-jzoj/p/13165557.html
Copyright © 2011-2022 走看看