矫捷开发松结对编程系列:L型代码结构案例StatusFiltersDropdownList(下)

敏捷开发松结对编程系列:L型代码结构案例StatusFiltersDropdownList(下)

这是松结对编程的第23篇(专栏目录)。

接上文,45分钟后……

新的筛选效果

现在需要在下拉框上加上两排新的筛选项(更早和更晚):
矫捷开发松结对编程系列:L型代码结构案例StatusFiltersDropdownList(下)
师傅本人可以在45分钟完成(实测),但如果直接交给徒弟维护(或师傅离开后维护),可能会发生以下问题:
1. 以前的下拉框里边只有两排,现在要显示三排了,怎么办?
2. 三排了,框框变宽,显示不下了怎么办?
3. 以前的筛选条件是怎么写的?(希望改改就能写出新的来)

在非L型代码结构下,可能会寄希望与阅读DropdownListHtml接口:
        public static MvcHtmlString DropdownListHtml(WebViewPage page, MvcHtmlString title, IEnumerable<MvcHtmlString> linksLeft,
                                                    IEnumerable<MvcHtmlString> linksRight1, IEnumerable<MvcHtmlString> linksRight2,
                                                    string width = "400px", string viewName = "~/Views/MFCControls/_HtmlDropdownList.cshtml", MvcHtmlString valueSpecial = null)
还好,接口算是明白,原来有个LinksRight2来处理右边的第二排;还有个width可以设置宽度。

即使看不太懂,在L型代码结构中,还有一些办法。(这也使得大家平时可以写成“看不太懂”的情况。虽然大家都希望能看懂,但要维护一套一看就懂的代码,代价很高)
如果师傅在,徒弟会问师傅该怎么办(如果他没看太懂);如果师傅不在,他会搜索全部DropdownListHtml以前使用的情况,并看到所有使用情况:
矫捷开发松结对编程系列:L型代码结构案例StatusFiltersDropdownList(下)
由于L型代码结构写底层的人同时也写上层(所以才叫“L”型,而不是“三”型或“川”型),所以所有底层技术都是随需开发的。因而如果支持三列链接显示,一定能找到一个以前使用过的例子。这些例子可以大大缓解注释、文档缺少的问题,甚至可以说比注释和文档更有效。
本人两年前学习C#以来,只买过1本C#的图书(后来因为几乎每看,就送人了),完全是按照一个大约130页的电子书上的案例完整开发了一遍,也就完成了入门工作。之后基本上遇到问题再搜Google或者*。L型代码结构其实想做的就是在企业内部完成类似的学习和使用过程。

完成后代码

新增或改动代码手工在前面加了//比较醒目,因为代码太长了,所以又补充了几句注释。

        public static MvcHtmlString StatusFiltersDropdownList(WebViewPage page)
        {
            var allStatuses = Status.AllStatuses().ToList();
            const string key = "statusIds";
            var currentStatusIds = page.ParameterOf(key);

            //Status groups filters on the top.
            var linksLeft = new Dictionary<string, IEnumerable<Status>>
            {
                {"所有状态", allStatuses},
                {"所有正常", allStatuses.Where(i => i.IsNormal)},
                {"所有开放", allStatuses.Where(i => !i.IsClosed)}, 
                {"所有故事板", allStatuses.Where(i => i.IsDisplayedOnKanban)},
            };
            var linksListLeft = AddToList(page, key, currentStatusIds, linksLeft);

            var linksRight1 = new Dictionary<string, IEnumerable<Status>>
            {
                {"0", null},
                {"所有已放弃或推迟", allStatuses.Where(i => !i.IsNormal)},
                {"所有关闭", allStatuses.Where(i => i.IsClosed)},
                {"所有非故事板", allStatuses.Where(i => !i.IsDisplayedOnKanban)}
            };
            var linksListRight1 = AddToList(page, key, currentStatusIds, linksRight1);

            //Single status filters on the bottom.
            linksListLeft.Add(new MvcHtmlString("<hr/>"));
            linksListLeft.AddRange(allStatuses.Where(i => i.IsNormal)
                .Select(status => status.Link(outerLink: page.MergeParameter(key, "_" + status.ID + "_"), title: status.Value.ToString(),
                    displayAsBoldText: "_" + status.ID + "_" == currentStatusIds)));
            linksListLeft.Add(new MvcHtmlString("<hr/>"));
            linksListLeft.AddRange(allStatuses.Where(i => !i.IsNormal)
                .Select(status => status.Link(outerLink: page.MergeParameter(key, "_" + status.ID + "_"), title: status.Value.ToString(),
                    displayAsBoldText: "_" + status.ID + "_" == currentStatusIds)));

            /*linksListRight1.Add(null);
            linksListRight1.Add(MFCUI.Link("及更早", "#", textColor: "#EEE"));
            inksListRight1.AddRange(allStatuses.Where(i => i.IsNormal).Skip(1)
                .Select(status => MFCUI.Link("及更早", page.MergeParameter(key, 
                    allStatuses.Where(s => s.IsNormal && s.Value <= status.Value).Aggregate<Status, string>(null, (current, s) => current + "_" + s.ID + "_")), title: status.Value.ToString(),
                    displayAsBoldText: currentStatusIds == allStatuses.Where(i => i.IsNormal && i.Value <= status.Value).Aggregate<Status, string>(null, (current, i) => current + "_" + i.ID + "_"))));
            var linksListRight2 = new List<MvcHtmlString>();

            linksListRight2.AddRange(new List<MvcHtmlString> { null, null, null, null, null});
            linksListRight2.AddRange(allStatuses.Where(i => i.IsNormal).Take(allStatuses.Count(i => i.IsNormal) - 1)
                .Select(status => MFCUI.Link("及更晚", page.MergeParameter(key,
                    allStatuses.Where(s => s.IsNormal && s.Value >= status.Value).Aggregate<Status, string>(null, (current, s) => current + "_" + s.ID + "_")), title: status.Value.ToString(),
                    displayAsBoldText: currentStatusIds == allStatuses.Where(i => i.IsNormal && i.Value >= status.Value).Aggregate<Status, string>(null, (current, i) => current + "_" + i.ID + "_"))));
            linksListRight2.Add(MFCUI.Link("及更晚", "#", textColor: "#EEE"));*/

            //Current value text
            var currentValueText = "";
            MvcHtmlString currentValue = null;
            currentValueText = linksLeft.SingleOrDefault(i => i.Value != null && currentStatusIds == i.Value.Aggregate<Status, string>(null, (current, status) => current + "_" + status.ID + "_"))
                .Key ?? currentValueText; //Is a group filter in left column.
            currentValueText = linksRight1.SingleOrDefault(i => i.Value != null && currentStatusIds == i.Value.Aggregate<Status, string>(null, (current, status) => current + "_" + status.ID + "_"))
                .Key ?? currentValueText; //Is a group filter in right column.
            if (!string.IsNullOrEmpty(currentValueText))
            {
                currentValue = new MvcHtmlString("<b>" + currentValueText + "</b>");
            }
            else
            {
                var currentStatus = allStatuses.SingleOrDefault(i => currentStatusIds == "_" + i.ID + "_"); //Is a single status filter. 
                if (currentStatus != null)
                    currentValue = MFCUI.Image(currentStatus.Title, "/MFC/Items/STAT/STAT16.png", showText: true, cssClassOfText: "bold", textColor: currentStatus.Color);
                else
                {/*
                    currentStatus = allStatuses.SingleOrDefault(status => currentStatusIds == allStatuses.Where(i => i.IsNormal && i.Value <= status.Value)
                        .Aggregate<Status, string>(null, (current, i) => current + "_" + i.ID + "_")); //Is a equal-to or early-than filter.
                    if (currentStatus != null)
                        currentValue = new MvcHtmlString(MFCUI.Image(currentStatus.Title, "/MFC/Items/STAT/STAT16.png", showText: true, cssClassOfText: "bold", textColor: currentStatus.Color)
                            + "<b>  及更早</b>");
                    else
                    {
                        currentStatus = allStatuses.SingleOrDefault(status => currentStatusIds == allStatuses.Where(i => i.IsNormal && i.Value >= status.Value)
                            .Aggregate<Status, string>(null, (current, i) => current + "_" + i.ID + "_")); //Is a equal-to or later-than filter.
                        if (currentStatus != null)
                            currentValue = new MvcHtmlString(MFCUI.Image(currentStatus.Title, "/MFC/Items/STAT/STAT16.png", showText: true, cssClassOfText: "bold", textColor: currentStatus.Color) + 
                                "<b>  及更晚</b>");*/
                        else
                            currentValue = new MvcHtmlString("<b style=\"color: #169; \">请选择</b>");
                    }
                }
            }

            var ddl = MFCUI.DropdownListHtml(page, currentValue, linksListLeft, linksListRight1, /*linksListRight2, "300px"*/);
            return new MvcHtmlString("状态:" + ddl);
        }

这个代码已经有点太长了,等下改成三个函数。因为三个函数都不是要被复用的(只在这里有用),会以private形式写在主函数下面并折叠收起来,有人看的时候才打开。

时间统计与L型代码结构的效率

整个代码编写大约花了一天左右;其中本文中的维护45分钟(编写者,也是师傅本人维护)。
如果师傅本人1个月后维护,估计要花费1小时;如果徒弟一个月后在师傅指导下维护,估计要花费2小时;如果很久以后徒弟自己维护,估计要4小时。但这都是第一次维护所需的时间,在维护过一次之后,速度很快就能向师傅靠拢了。

或许有人会说“如果从头写一堆SQL语句,也未必用的了4小时”,的确如此。但所需的代码行数会大大超过现在的代码行数,对专用的代码的测试也会更消耗时间;而日后一旦更改业务或技术,成本就更不用说了。

更大范围的复用框架

刚才只是一个小的控件的复用,如果能在更大框架下实现L型代码结构,那么编写和维护软件会更加容易。
比如本文及之前两篇文章是关于“筛选用户故事”这个功能的,由于用户故事和测试用例在火星人中是基于共同基类实现的,因此只要加上下面代码中的注释文字(未使之醒目而注释),立刻就能实现测试用例的筛选了,一共用了2分钟(菜单是共享的不用动):
        public ActionResult IndexTree(int rootID, string whats, string whattypes/*, string statusIds*/)
        {
            if (string.IsNullOrEmpty(whats) || string.IsNullOrEmpty(whattypes))
                return RedirectToAction("IndexTree", new
                {
                    rootID, 
                    whats = SystemItemWhat.Story, 
                    whattypes = ItemWhattype.AllItemWhattypeString(SystemItemWhat.Story),
                    /*statusIds = Status.AllStatuses().Where(i => i.Value >= Status.FirstStatusOnboard.Value)*/
                });

            var root = _repository.ReadItemAt(rootID);
            return IndexTreeView("用例树[" + root.Title + "]",
                new ItemTreeRightPadViewModel(_repository, null,
                    rootID, whats, whattypes, /*statusIds,*/ Product.ProductsAccessibleToUserIDs(WebSecurity.CurrentUserId),
                    showOpenAsTreeRoot: true,
                    subItemsTreeColumnWidth: 350),
                comments: root.WhatType == ItemWhattype.ProductProductline
                    ? new MvcHtmlString("悬停并点击下面的产品名称后的" + MFCUI.Image("", "/MFC/ItemTrees/OpenAsTreeRoot16.png") + "以查看详细的产品树。")
                    : new MvcHtmlString("下面是首级子目录水平放置的故事树;点击各级故事目录后的“打开子树”图标 "
                                        + MFCUI.Image("打开子树", "/MFC/ItemTrees/OpenAsTreeRoot16.png") + " 以展开详细故事树。"
                                        + "首级目录排序和跨产品拖拽只能在"
                                        + MFCUI.ImageLink("故事树首页", "/ProductManagement/StoryTrees/Index")
                                        + " 进行"));
        }
下面是筛选“所有故事板上的用户故事的测试用例”:
矫捷开发松结对编程系列:L型代码结构案例StatusFiltersDropdownList(下)

如果之前的筛选是单独编码的,那么这2分钟就又变成4小时了——如果没有遇到什么Bug的话。

总结

1. 业务代码是那些上层的可能因业务升级而变更的代码。
2. 业务代码中包含“做什么”而不包含“怎么做”。
由于业务升级只涉及“做什么”,因此只需要修改业务代码。
3. L型代码结构利用公共的技术底层来搭建业务代码。
由于L型代码结构的底层是因需产生的,因此在重用时有很多现成的“例子”来代替或因此可以减少文档和注释的数量。
3. 新手/新人/徒弟看完业务代码后,应该能够进行“业务维护”以实现新的业务要求,这是业务代码编写的第一原则;能进行“技术维护”是更高水平的人的事情。
实际上,很多代码因为为了让新手/新人/徒弟看懂怎么实现的,而令他们很难维护,是舍本逐末的做法。

如何让新手学习、练习以得以进步的内容不在本文范围内(在同系列中有大量描述)。