[原]复用你的代码--从构建类库到构建框架

Don't repeat yourself是圈内开发耳熟能详的概念。随着系统功能拓展,你会遇到很多功能有或多或少的相似点,如何复用功能,是一个问题。

莽荒时代:粘贴代码

 

我们以最常见的订单系统为例,假设系统有一个功能是在客人下单的时候做一个实时的校验,伪代码如下:

//PC版本
    if($bookingPrice != $realTimePrice){
        throw PriceExcetion('价格已更新,请刷新页面后重新下单');
    }
    if($bookingCount >= $realTimeStock){
        throw StockExcetion('库存不足,请刷新页面后重新下单');
    }
    if(($bookingPrice-$basePrice)<$minProfit){
        updateSellPrice();//刷新售价
        throw BasePriceExcetion('价格异常,请刷新页面后重新下单');
    }
    //剩余88道工序略...

随着移动端近年来蓬勃发展,除了原有的PC端,你们公司打算再搞一个微信版的客户端。微信版本和PC版本的业务大多数是相同的,但由于我们现在在猛推微信版,老板放话可以不受利润调控的约束,这块功能应该在微信端屏蔽掉。

//微信版本
    if($bookingPrice != $realTimePrice){
        throw PriceExcetion('价格已更新,请刷新页面后重新下单');
    }
    if($bookingCount >= $realTimeStock){
        throw StockExcetion('库存不足,请刷新页面后重新下单');
    }
    if($wechatNeedCheckProfit  && 
      (($bookingPrice-$basePrice)<$minProfit)){
        updateSellPrice();//刷新售价
        throw BasePriceExcetion('价格异常,请刷新页面后重新下单');
    }
    //剩余88道工序略...

你果断的拿起了Ctrl键和C/V键,通过粘贴,裁剪和修改代码快速的完成新的功能。你的开发效率猛如虎,并且由于你的代码是完全面向过程的,你的代码简单易懂,BUG比别人少,修得比别人快,深受老板的喜爱,后面又陆陆续续帮老板做了很多个类似的客户端。

上古时代:构建类库

好景不长,复制黏贴的问题很快就浮现了。原有的功能要升级:以后刷新售价后,如果仍然无法通过利润校验,就下架产品并通知运营人员。

//PC版本
    if($bookingPrice != $realTimePrice){
        throw PriceExcetion('价格已更新,请刷新页面后重新下单');
    }
    if($bookingCount >= $realTimeStock){
        throw StockExcetion('库存不足,请刷新页面后重新下单');
    }
    if(($bookingPrice-$basePrice)<$minProfit){
        updateSellPrice();
        if(($bookingPrice-$basePrice)<$minProfit)){
            updateProductSoldOut();//设置产品下架
            noticeOperationalPeople();//通知运营人员
        }else{
            throw BasePriceExcetion('价格异常,请刷新页面后重新下单');
        }
    }
    //剩余88道工序略...

代码很快就改完了,但被测试打回来几十个BUG。你才突然想起,你在微信等众多版本上忘记增加这个功能。你蒙圈了,因为你需要review每个可能复制过这块代码的地方,逐个补上这个新功能。
Copy and paste programming是最简单粗暴的代码复用手段,也是程序员圈里面最著名的反模式之一。他的问题很明显,随着你的粘贴,你会创造出越来越多的类似的冗余代码。它会导致你在你需要新增和修改功能时,需要在每一个补上上打上同样的补丁。这大大加大了开发的成本和制造BUG的可能性。除此以外,冗余代码会成为项目里面的噪音代码,浪费代码管理资源和干扰维护者的阅读。

而这一阶段的解决方案是,即是构建类库:我们可以通过经验的预判,在开发时将代码封装成具体的方法或设计成易于重构的结构,当有复用的需要时,只需要通过简单的重构或者方法调用,即可完成新业务对旧业务的处理。各个语言现代的IDE基本上都有成熟的重构功能去处理这种情况。
处理后的伪代码如下:

//抽离出的公共类库
function checkBeforeBooking($terminal){
    checkPriceBeforeBooking();
    checkStockBeforeBooking();
    $needCheckProfit=($terminal==TERMINAL_WECHAT)?$wechatNeedCheckProfit:true;
    if($needCheckProfit){
        checkProfitAndUpdateBeforeBooking();
    }
    //剩余88道工序略...
}
//PC版本
checkBeforeBooking(TERMINAL_PC);
//微信版本
checkBeforeBooking(TERMINAL_WECHAT);

PC版本和微信版本的相关代码被浓缩成1行通用方法调用,对该业务的任何改动都只需要改动类库代码这一处地方。

近代:面向对象式的类库

前文提到构造类库是一种完全面向过程的处理手法,其原理是通过共享相同的执行路径去清除冗余代码,而近十几年普及的面向对象程序设计方式则为复用代码提供了很多的可能性。由于不同业务之间使用的类库代码是相同,过程式的类库如果需要为不同的场景提供差异化的功能就需要在所有人都引用到的公共类库中添加分支流程,并且可能需要添加提供该分支流程对应的执行参数。
随着系统功能的复杂化,内部需要提供大量的分支流程来处理这些差异化的功能点。一方面这些在多个地方出现的case TERMINAL_PC,case TERMINAL_WECHAT条件语句本身也会逐渐成为不可忽视的冗余代码,另一方面各种if...else..,switch的分支执行代码块堆积在一起也再次使代码变得冗长混乱而难以阅读。
为了说明问题,我们把我们的下单校验功能搞得更复杂一点。我们要再增加一个终端系统,不同系统要记录不同的日志信息并且使用不同的利润调控算法,可以看到,我们的类库方法马上就膨胀起来了。

function checkBeforeBooking($terminal){
    checkPriceBeforeBooking();
    checkStockBeforeBooking();
    //微信和M版都可能跳过利润调控
    if(TERMINAL_WECHAT==$terminal){
        $needCheckProfit=$wechatNeedCheckProfit;
    }else if(TERMINAL_APP==$terminal){
        $needCheckProfit=$appNeedCheckProfit;
    }else{
        $needCheckProfit=true;
    }
    if($needCheckProfit){
        checkProfitAndUpdateBeforeBooking($terminal);
    }
    //M版本和微信版本需要记录不同的日志
    switch ($terminal){
        case TERMINAL_WECHAT:
            logSthForWechat();
            break;
        case TERMINAL_APP:
            logSthForMobile();
            break;
        case TERMINAL_PC:
            logSthForPc();
            break;
    }
    //剩余86道工序略...
}
function checkProfitAndUpdateBeforeBooking($terminal){
    switch ($terminal){
        case TERMINAL_WECHAT:
            $minProfit=algorithmA();
            break;
        case TERMINAL_APP:
            $minProfit=algorithmB();
            break;
        case TERMINAL_PC:
            $minProfit=algorithmC();
            break;
        //other thing
    }

面向对象赋予了代码一个非常重要的特性--抽象。得益于面向对象代码的抽象性和封装性,我们可以通过"继承--多态"或"组合--动态绑定",屏蔽分支条件和分支语句块。
以"继承--多态"方式为例,我们可以尝试重构一下之前的代码。


class BookingServiceFactory {
    /**
     * 获取BookingService实例
     * @param Enum $terminal
     * @return BookingService
     */
    public function getBookingService($terminal){
        switch ($terminal){
            case TERMINAL_WECHAT:
                return new WechatBookingService();
            case TERMINAL_APP:
                logSthForMobile();
                return new MobileBookingService();
            case TERMINAL_PC:
                return new PcBookingService();
            default:
                return null;
        }
    }
}

abstract class BookingService{
    /**
     * 下单前校验
     */
    abstract public function checkBeforeBooking();
}
class PcBookingService extends BookingService{

    /**
     * @inheritdoc
     */
    public function checkBeforeBooking()
    {
        checkPriceBeforeBooking();
        checkStockBeforeBooking();
        $needCheckProfit=true;
        if($needCheckProfit){
           $this->checkProfitAndUpdateBeforeBooking();
        }
        logSthForPc();
        //剩余86道工序略...
    }



    /**
     * @param String $terminal 下单终端
     */
    protected function checkProfitAndUpdateBeforeBooking($terminal){
        $minProfit=$this->algorithmA();
        //other thing
    }
}
//类库的客户端代码
$factoty=new BookingServiceFactory();
//PC版本
$pcBookingService=$factoty->getBookingService(TERMINAL_PC);
$pcBookingService->checkBeforeBooking();
//微信版本
$wcBookingService=$factoty->getBookingService(TERMINAL_WECHAT);
$wcBookingService->checkBeforeBooking();

效果立竿见影,随着条件分支被封装性进了工厂类,分支代码块被划分到了不同的类当中,代码再次变得简短清晰,不同的BookingService类专注于不同的分支逻辑,条件分支本身的代码冗余也清除了。

类库复用仍潜在的问题

上文封装了一个:checkPriceBeforeBooking()方法
然而其他同事可能因为不够熟悉项目不知悉这个方法,又或者他觉得原有方法的参数和返回值用起来不方便,又或者这个方法差一点就满足他的需求,而他却因为种种原因不敢/不愿意去改造你的方法,于是他造了一批新的轮子,代码再次出现了冗余。

checkStockBeforeBooking()//库存检查;
checkPriceBeforeBooking()//售价检查;

再换一个开发问题场景,我们假设这两个方法其实是有副作用的,而有业务上的耦合:在库存检查时在发现满足实时库存某些条件时会触发售价的调整,因此checkPriceBeforeBooking()需要放在checkStockBeforeBooking()之后执行,否则价格校验可能会存在缺漏,提供类库的你要如何确认用户以你预期的方式调用呢?

类库控制了被复用的代码本身,却忽略了用户对类库的调用方式。

参与过如批量复杂系统对接等复用性极高系统的开发开发人员可能会有这种困扰。随着团队的发展和人员更替问题,如何保证业务的稳定性?换句话说,如果当前项目部全部人都换了,项目还能按现在的路线走下去吗?

文档注释?先不论现代还有多少互联网企业有企图去维护一份完善及时的文档,即使有文档了如何确保每一个进入项目的开发对文档有足够的熟悉度,清楚特定类库的大大小小方法?
CodeReview?如果要对所有代码都做此类深度的review,不仅耗费人力成本高,而且要求reviewer对项目类库熟悉程度很高。
人永远是项目中最不可控的环节,由人导致的问题,往往不能依赖人去解决。
CI+自动化测试?这是检查类库的调用方式的一个办法,但是高覆盖率的测试用例维护起来也很麻烦,有没有更好的办法?

让我们可以回到代码本身,IOC(控制反转)就是一个在代码设计层面的解决方案。(熟悉现代框架如Spring或者DI(依赖注入)的朋友可能对IOC已经有一些了解,但是DI其实只是IOC在依赖管理领域或容器领域下的特定实现,不完全等同于IOC,有兴趣了解的朋友可以自行阅读一下马丁大叔的《InversionOfControl》 )
一个类库和一个框架最重要区别在于程序的控制权。
一个类库提供的是API,你通过调用API,类库执行完特定的代码实现对应功能后将控制权交还给你,程序流掌握在你的手上。
一个框架提供的是骨架,框架本身决定了功能整个生命周期的流程,并在需要具体实现的流程处留下Hook,让你实现特定的业务逻辑,直到你被动或主动的交还控制权,框架继续执行下个阶段的流程。在这种情况下,程序的控制权和一般的程序是刚好反过来的,握在了框架手里,你的代码反而成了被调用方。

算法模板(Template method)就是一个典型实践IOC的设计模式,其定义是在父类中定义流程的骨架,并将部分步骤的实现延迟到子类。
使用算法模板后的相关代码如下:

abstract class BookingService{
    /**
     * @inheritdoc
     */
    final public function checkBeforeBooking()
    {
        //父类定义了校验方法的骨架
        //公共流程
        checkPriceBeforeBooking();
        checkStockBeforeBooking();
        //在可能的分歧点暂时交出控制权,调用子类具体的方法执行特异性的具体逻辑
        $needCheckProfit=$this->needCheckProfitBeforeBooking();
        //控制权回到框架(此处为父类)处,流程控制权始终在框架手上        
if($needCheckProfit){
            $this->checkProfitAndUpdateBeforeBooking();
        }
        $this->logSth();
        //剩余86道工序略...
    }

    /**
     * 利润调控算法
     * @return float 最小利润
     */
    abstract protected function profitCheckAlgorithm();

    /**
     * @return boolean 是否需要在下单前检查利润
     */
    abstract protected function needCheckProfitBeforeBooking();

    /**
     * @param String $terminal 下单终端
     */
    protected function checkProfitAndUpdateBeforeBooking($terminal){
        $minProfit=$this->profitCheckAlgorithm();
        //other thing
    }

    /**
     * 日志记录
     */
    abstract protected function logSth();
}
class PcBookingService extends BookingService{
   
    /**
     * @inheritdoc
     */
    protected function profitCheckAlgorithm(){
            return algorithmA();
    }

    /**
     * @inheritdoc
     */
    protected function needCheckProfitBeforeBooking()
    {
        return true;
    }

    /**
     * @inheritdoc
     */
    protected function logSth(){
        logSthForPc();
    }
}

与只能复用具体实现的类库不一样,模板算法复用了一个含有抽象的流程,他构造了一个小型的框架,让开发去填充内容。这解决了上面提到的流程的稳定性问题了,因为这里不仅提供了复用的代码,还决定了代码的使用方式。如果你后面需要实现新的版本,只需要实现一个最小化的之类。

总结一下四个阶段的处理方式:
1.复制粘贴是最原始的的复用手段,其优点是简单,开发成本低,但是会带来大量的冗余代码造成系统难以维护。
2.过程式的类库解决了大多数的冗余代码问题,但是条件相关代码带来了新的冗余代码和代码噪音。
3.面向对象式的类库更进一步解决了代码冗余的问题。
4.框架解决了类库无法控制类库的使用方式的问题。
复杂的方案尽管更加强大,也带来了更多的项目复杂度。不同解决方案的使用需要根据具体的场景取舍。




参考文献:https://www.jianshu.com/p/9a9be6e91c62
 

Algorithms   本次README修订为算法仓Algorithms的第100次commit,首先我们庆祝自2016年8月4日本仓建立以来Dev-XYS在算法学习方面取得的显著进步!   这里有各种算法的C++代码,任何人可以在自己的任何程序中使用,欢迎大家指出代码中的错误以及有待改进的地方。   本仓内所有代码的授权方式为Unlicense,大家如果使用我的代码开发自己的软件挣了大钱,或是参考我的代码在NOI中得了金牌,我都会很高兴的。使用这里的代码之后,你可以自主选择是否公开源代码。总而言之,你可以把这里的代码当作你自己写的一样,无论怎样使用都是被允许的。但是,我不对本仓代码的正确性负责。大家要是使用我的代码开发软件而导致程序崩溃,或是参考我的代码在考试时出错,请不要向我抱怨。如果你愿意,遇到问题可以在Issues中提出来,我们共同解决。我们不赞成Pull Request,因为本仓主要储存作者已经学习的算法,全部代码均由作者本人负责维护与更新。   以下索引提供了本仓内算法的中文名,方便大家查找。更新可能有很长时间的延迟,不保证所有算法的名称都在列表中出现。 Index --------------------------Contents-------------------------- --------------------------FileName-------------------------- AC自动机 Aho-Corasick-Automation 单源最短路径(SPFA) Bellman-Ford(Queue-Optimised) 单源最短路径(Bellman-Ford) Bellman-Ford 使用Edmonds-Karp进行二分图匹配 Bigrpah-Matching(Edmonds-Karp) 普通的二叉搜索树 Binary-Search-Tree 广度优先搜索 Breadth-First-Search 冒泡排序 Bubble-Sort 桶排序 Bucket-Sort 组合数的递推求解 Combination(Recursion) 枚举组合 Combination 基本的复数类 Complex-Number 割点 Cut-Vertex 深度优先搜索 Depth-First-Search 堆优化的Dijkstra算法 Dijkstra(Heap-Optimised) 并查集 Disjoint-Set-Union 最大流Edmonds-Karp算法 Edmonds-Karp 欧拉函数 Euler's-Totient-Function 有向图的欧拉回路 Eulerian-Tour(Digraph) 拓展欧几里得算法 Extended-Euclid 简单的快速幂 Fast-Exponentiation 树状数组 Fenwick-Tree 所有结点对之间的最短路径(Floyd) Floyd-Warshall 凸包算法(Graham扫描法) Graham-Scan 辗转相除法求最大公约数 Greatest-Common-Divisor 堆排序 Heap-Sort ISAP算法 Improved-Shortest-Augmenting-Path(Naive) 插入排序 Insertion-Sort 字符串匹配(KMP) Knuth-Morris-Pratt 最小生成树(Kruskal) Kruskal 最近公共祖先(Tarjan) Least-Common-Ancestor(Tarjan) 使用后缀数组求解最长公共子串 Longest-Common-Substring 最长上升子序列(n·log(n)) Longest-Increasing-Subsequence(n·log(n)) 倍增法求最近公共祖先 Lowest-Common-Ancestor(Doubling) 朴素的矩阵乘法 Matrix-Multiplication(Naive) 归并排序 Merge-Sort 最小堆 Min-Heap 乘法逆元 Modular-Multiplicative-Inverse 仅支持单点修改的可持久化线段树(维护区间和值) Persistent-Segment-Tree(Sum) 试除法素数测试 Prime-Check(Naive) 线性的素数筛法 Prime-Sieve(Linear) 队列的基本操作 Queue 快速排序的优化版本 Quick-Sort(Extra-Optimised) 快速排序的随机化版本 Quick-Sort(Randomized) 快速排序 Quick-Sort 使用向量叉积判断两个有向线段的时针关系 Segment-Direction 线段树维护区间最大值 Segment-Tree(Maximum) 线段树维护区间最小值 Segment-Tree(Minimum) 线段树维护区间和值 Segment-Tree(Sum) 普通的选择算法 Selection Eratosthenes素数筛法 Sieve-of-Erotosthenes 指针版的单向链表 Singly-Linked-List(Pointer) 跳表 Skip-List ST表 Sparse-Table 伸展树 Splay 博弈论SG函数 Sprague-Grundy 栈的基本操作 Stack 递推法求解无符号第一类斯特林数 Stirling-Number(Cycle,Unsigned,Recursion) 递推法求解第二类斯特林数 Stirling-Number(Subset,Recursion) 倍增法求解后缀数组 Suffix-Array(Doubling) 倍增法求解后缀数组(附带Height数组) Suffix-Array-with-Height(Doubling) 使用Tarjan算法求解强连通分量 Tarjan(Strongly-Connected-Components) 数组版的字典树 Trie(Array) 指针版的字典树 Trie(Pointer)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值