codecamp

利用JS职能链模式进行小步重构

思考越多,收获越多。

背景: 业务场景和问题

先前,我在进行H5页面开发时,曾经有一个业务场景,根据不同的逻辑会在三种弹窗中至多显示一种弹窗。为了以示区分,假设这三种弹窗,分别是:文字弹窗、图片弹窗、视频弹窗。并且,假设优先级是:文字弹窗 > 图片弹窗 > 视频弹窗(产品同学为了照顾用户可贵的流量,先用低流量的文字,到最后高流量的视频)。

经抽离关键代码后,一开始的代码实现大概如下:

var h5 = {
    init: function() {


        // 弹出优先级 : 文字 - 图片 - 视频
        h5.popupText();
    },


    /*
     * 文字弹窗
     */
    popupText: function() {
        api.getText().done(function(re) {
            if (re.code == 1) {
                // 显示文字弹窗 ...
            }
        }).always(function(re = {}) {
            if (re.code != 1) {
                h5.popupImg(); // 继续图片弹窗
            }
        });


    },


    /*
     * 图片弹窗
     */
    popupImg: function() {
        api.getImg().done(function(re) {
            if (re.code == 1) {
                // 显示图片弹窗 ...
            }
        }).always(function(re = {}) {
            if (re.code != 1) {
                h5.popupVideo(); // 继续视频弹窗
            }
        });
    },


    /*
     * 视频弹窗
     */
    popupVideo: function() {
        // 显示视频弹窗 ...
    }
}

上面的代码虽然有点长,但还是不难看出其中的业务逻辑的。

这里的问题是,重要的业务规则得不到很好的体现,并且复杂的业务场景实现没有使用通用的、既有的模式得到恰当的解决。

职能链简介

这里打算使用职能链模式进行重构优化,关于职能链模式的静态UML结构如下:

这里不过多讲述职能链模式的说明,感兴趣的同学可自行百度。举一个员工的问题为例子,假设有一名基层员工遇到问题,他解决不了,然后去找他的老大;如果他老大也解决不了的话,就会继续再往上一级找老大的老大,依次类推,直到问题被解决。这就是职能链模式。

重构后的代码

经过一番改造后,使用职能链模式重构后的代码,主要如下。

var h5 = {
    init: function() {


        // 弹窗职能链调用
        var chain = new MiniChain();
        chain.next(h5.popupText).next(h5.popupImg).next(h5.popupVideo).go();
    },


    /*
     * 文字弹窗
     */
    popupText: function() {
        var isShow = false;
        api.getText().done(function(re) {
            if (re.code == 1) {
                // 显示文字弹窗 ...
                isShow = true;
            } else {
                // 其他场景处理 ...
            }
            return re;
        });
        return isShow;
    }
},


    /*
     * 图片弹窗
     */
popupImg: function() {
    var isShow = false;
    api.getImg().done(function(re) {
        if (re.
        switch == 1) {
            isShow = true;
            // 图片弹窗显示
        }
    });
    return isShow;
},


    /*
     * 视频弹窗
     */
popupVideo: function() {
    // 显示视频弹窗 ...
}
}

主要的改动,首先,是职能链的按优先级的配置和调度:

        // 弹窗职能链调用
        var chain = new MiniChain();
        chain.next(h5.popupText).next(h5.popupImg).next(h5.popupVideo).go();

其次,是各职能链(在这里是各弹窗功能)的具体实现,主要添加了处理标识返回,但内部实现已经更专注于自身功能的实现,而非外部的判断和处理。

重构后的好处

这样重构后,明显我们看到核心关键的业务得到了很好的突显,并且把业务规则通过恰当的模式组合最终恰如其分地表达了出来。下面再细说下重构后的好处。

好处1:关注点分离,调度与实现分离

调度的策略,应该和具体的弹窗功能实现分开。这是两个不同的关注点,如何调度,是高层的概念;而具体的实现则是技术细节的范畴,不应把这两者混淆。一旦混淆了,就会容易产生代码异味。

重构前,调度的入口是由文字弹窗的调用而开始的。这样很容易会给人一种误导,或者是隐藏了重要的信息。因为只有深入到细节,才知道当没有文字弹窗时才会继续尝试弹图片弹窗。更让人“惊讶”的是,文字弹窗没显示时会再尝试视频弹窗。。。 这里没有很好地在高层表达重要的信息,而且也没有很好的抽象高层的业务概念。这个入口,就如同一个黑黑的洞穴,只有你拿着火把,一步步深入其中,走到底,才知道还有没有路可走。

而重构后,我们把调度分离了出来,并用贴切的职能链模式表现了出来。高层的业务概念不仅得到了体现,而各自的弹窗功能实现也变得了更内聚(因为不需要再关注要不要再走下一步),这也是符合我们常常说的“高内聚、低耦合”。

好处2:对业务友好、对开发友好、对测试友好

对业务友好

回顾前面弹窗优先级的配置代码:

        // 弹窗职能链调用
        var chain = new MiniChain();
        chain.next(h5.popupText).next(h5.popupImg).next(h5.popupVideo).go();

可以看到,对于弹窗功能的优先级顺序编排非常简单明了,因此当产品需要增加弹窗、删除弹窗、调整顺序时都非常简、快速,快速即友好,因为能快速满足产品同学的需求,快速交付有价值的功能。

对开发友好

除了快速外,它也是容易维护,即维护成本低。

因为重构后,把原来的三层嵌套调用简化了只有一层嵌套,我把这种调整称之为嵌套结构扁平化。扁平化的各个弹窗功能模块,可以独立重用、互不影响,通用职能链组合复用起来,而不是像之前那样“暗地里”耦合在一起。维护成本变低的原因还有技术层面的,因为通过配置的弹窗优先级,我们不仅可以看到当前已有多少弹窗以便评估性能方面对用户体验的影响,还可以方便判断是否存在死循环、消除复杂的耦合关系。在这里,又让我再一次想起了那段话:

“设计软件有两种方法:一种是简单到明显没有缺陷,另一种复杂到缺陷不那么明显。”

举个假设的需求以对比重构前后的维护成本。

假设,产品希望原来把弹窗优先级,从原来的:文字弹窗 -> 图片弹窗 -> 视频弹窗。调整成(全部逆转):视频弹窗 -> 图片弹窗 -> 文字弹窗 。

试想一下,如果是原来的方式,需要开发的时间是多少?1分钟、5分钟、10分钟、半小时?其中还需要进行开发自测。这里可能维护时间因人而异,但我觉得,负责维护的开发工程师至少也要想一下

但如果是重构后,我觉得,即便是刚毕业的实习生,也能够在短短的10秒钟内完成开发(并且可以不用自测!)。因为需要修改的代码就是这么简单:

        // 弹窗职能链调用
        var chain = new MiniChain();
        chain.next(h5.popupVideo).next(h5.popupText).next(h5.popupImg).go();

在这里,对于开发工程师,U维护成本低即友好**。

对测试友好

很多因素,虽然微弱,虽然看似无关联,实际上相互联系、相互作用的。从代码上的修改,反映到对产品需求的响应、再到维护成本的影响,最后也自然会影响到测试。重构后的代码,质量上得到了提升,也就得到了保障,对于测试人员,不必要担心修改后有问题而花过多时间去回归功能或者改出问题后进行故障的登记和跟进。另外,对比原来的测试路径,可以有 222=8 种组合场景;而扁平化嵌套结构后,测试只需要测试 2+2+2 = 6种独立的场景即可,可以少测3种场景。因为原来排列组合的方式,现在只需要单独测试每个模块即可。

即,重构前需要测试 222=8 种组合场景:

重构后,只需要测试 2+2+2 = 6种独立的场景:

在这里,对于测试工程师,减轻测试工作量即友好

职能链的考虑: 明确成功后终止,抑或明确失败后继续?

在实现职能链模式时,需要考虑的一个小细节就是:到底是明确成功后终止,抑或明确失败后继续?

这里涉及到何时停止的裁定,所以在JS实现时是需要仔细考虑的。一开始是返回false才会继续的,但由于对于弹窗的业务功能实现时,开发工程师有可能返回undefine、或者null、或者0等其他类似false的场景,但我们使用了全等判断,故而也就会对开发工程师严格要求,否则就会容易产生bug。为了继续体现对开发者的友好性(帮助开发工程师减少出错的概率),我决定最后调整为宽松的约定,即你明确告诉我成功了才会停止调度,否则当作失败继续调度下一个处理器。

小游戏:谁是内奸

假设有这么一个游戏:找出警队里的内奸。某警队里有3个中队,分别有3人、4人、5人,其中有一个是内奸,需要FBI联邦总局命令你找出内奸。请设计合适的算法,找出内奸。

这里的问题,如果用图论中的算法结构,我们可以得到这样的树模型(假设内奸位置已按算法分配好):

利用上面刚学到的职能链,我发现可以这样来处理。

// 小游戏:谁是内奸
QUnit.test("little game: WHO IS SPY", function (assert) {
    var i_am_police = function () {
        console.log("I am police.");
        return false;
    }


    var i_am_spy = function () {
        console.log("I am spy!");
        return true;
    }


    var team_1 = new MiniChain();
    team_1.next(i_am_police).next(i_am_police).next(i_am_police);


    var team_2 = new MiniChain();
    team_2.next(i_am_police).next(i_am_police).next(i_am_spy).next(i_am_police); // 内奸在这!


    var team_3 = new MiniChain();
    team_3.next(i_am_police).next(i_am_police).next(i_am_police).next(i_am_police).next(i_am_police);


    var chain = new MiniChain();
    chain.next(team_1.go).next(team_2.go).next(team_3.go);
    var rs = chain.go();


    assert.deepEqual(rs, true); // 找到内奸
});

运行的效果如下(期望效果):

LOG: 'I am police.'
LOG: 'I am police.'
LOG: 'I am police.'
LOG: 'I am police.'
LOG: 'I am police.'
LOG: 'I am spy!'

很遗憾的是,目前上面的代码运行还是有问题的。看来暂时还不宜找出内奸。 这里明显虽然不一定要用职能链模式,而职能链模式也不是构建树最好的解决方案,但通过这种的分解后,我们可以得到的是可重用、可复用的独立模块,进而组合产生出你所想要的功能。再强调一次:复用而非耦合。

不适应性场景: 适合于操作类功能,不适宜返回结果

任何负责任的技术或者思想,都应该告诉你其不适应性。同样,就这里的JS职能链,也有其不适应性。它不适合用于返回结果的功能,因为我们约定了返回true作为成功授理的标识,否则会再进行下一步。

当然,也可以根据场景需要,改造成带返回结果的职能链。但我觉得,模式也应该是专注的,即职能链本质就适合于操作类不带结果返回的实现。

小结

最后,模式不要滥用,仅当需要时才用。实现细节不要过于生搬硬套。重要是思想的体现。

惯例优于配置,配置优于实现
快车道:流,重构,tdd与设计模式
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }