在如何至始至终保持代码的可维护性方面我给.NET 开发者团队的最好建议是:将应用程序中的每个命名空间都当作组件看待,同时确保组件之间不存在依赖环。 通过遵守这条简单的原则,大型应用系统的结构就不会陷入大块意大利面式代码的混沌之中——而这种意大利面式代码在专业企业应用开发中往往被视为正常而非异常的现象。
命名空间即组件
从十多年前.NET 技术出现以来,Visual Studio 开发工具一直隐式地将 VS 项目作为组件(也即程序集)。这是不恰当的,因为组件应该是 _ 结构 _ 代码的逻辑部件,而程序集应该是 _ 包 _ 代码的物理部件。这导致了另一个被视为正常而非异常的现象:有些企业应用程序竟由几百个 VS 项目组成。
我为什么鼓励使用命名空间这个轻量级概念来定义组件边界呢?其好处如下:
- 更轻量的组织:多用命名空间而少用程序集意味着所需的 VS 解决方案个数和 VS 项目个数变少了。
- 减少了编译时间:每个 VS 项目都会在编译时产生额外的时间开销。具体点说,项目很多的话会导致编译需要花几分钟时间,但如果大幅减少 VS 项目的数量,则编译仅需花几秒钟时间。
- 更轻量的部署:部署几十个程序集要比部署上千个简单多了。
- 更少的应用程序启动时间:CLR 加载每个程序集时都需要付出一小些额外的性能开销。加载几十或上百个程序集的话,总共的开销就相当明显了,达到了以秒记的级别。
- 方便了组件的层次组织:命名空间能够表达出层次结构,程序集则不能。
- 方便了组件的细颗粒度化:存在 1000 个命名空间不是什么问题,存在 1000 个程序集就是个问题。选择构建一些非常细粒度的组件不应该因为需要专门创建相对应的 VS 项目而令人扫兴。
依赖环危害不小
组件间的依赖环会导致出现人们常说的意大利面式代码(spaghetti code)或者纠缠式代码(tangled code)。假如组件 A 依赖于 B,B 依赖于 C,而 C 依赖于 A,则 A 不能够离开 B 和 C 单独进行开发和测试。A、B 和 C 形成了一个不可见环,一种超级组件。这个超级组件的开销要比 A、B 和 C 三者的开销之和还大,这就是所谓的规模不经济现象(diseconomy of scale phenomenon)(请参见详尽文档 Software Estimation: Demystifying the Black Art by Steve McConnell )。通常,这会导致开发最小单元代码的开销呈指数级增长。这意味着,如果不能将 1000 行代码划分成相互独立的两份 500 行的代码的话,开发和维护 1000 行代码的开销要比 500 行多出三或四倍。如果是碰到意大利面式或者纠缠式代码的话,那就可能无法维护了。为了使组织架构更加合理,人们应该确保组件之间不存在依赖环,同时确保每个组件的大小是合适的(500 至 1000 行之间)。
对战设计侵蚀(design erosion)
五月份发布的 NDepend 版本 4 引入了应对应用程序环的新特性,在这里我想讨论下其所具有实践意义。
现在我们能够按照 LINQ 查询要求来实现编码规范(我们称之为 CQLinq ),我们能够利用 LINQ 的巨大灵活性构建出特定规范。其中一个我参与构建的规范是能够报告命名空间依赖环的代码规范。例如,如果我们来分析 _.NET Framework v4.5_,观察程序集 _System.Core.dll_ 内部,就会发现其存在两个命名空间依赖环,这两个环都由 7 个命名空间组成。代码规范特性可以索引环中的某个命名空间(随机选取)并展现这个环。用鼠标左键点击下图 cycle 字段可以查看依赖环所包括的命名空间:
(点击图片可以放大)
通过鼠标右键点击命名空间列表或者依赖环本身,就会出现将他们导出为依赖图(dependency graph)或者依赖矩阵(dependency matrix)的菜单。下面的截图显示了7 个相互纠缠的命名空间。但这不是循环依赖的典型图示,典型的情况是:假定两个命名空间A 和B,通过B 可以访问到A,并且反之亦然。显然,这样纠缠起来的代码是不容易维护的。
(点击图片可以放大)
让我们来看看CQLinq 的代码规范体 避免命名空间依赖环。我们可以看到开头有很多描述如何使用的注释。这是通过注释和C#代码和读者交流的好机会,感谢即将发布的 Roslyn compiler as services ,我相信所提倡的简短 C#代码摘录(excerpt)而不是 DLL 或者 VS 项目,将会越来越受欢迎。
<i><span color="#a5a5a5">// <Name> 避免命名空间依赖环 </Name></span></i> <span color="#a5a5a5"><span color="#000000">warnif count</span> > </span><span color="#4f81bd">0</span> <i><span color="#a5a5a5">// 这个查询列出了应用程序的所有命名空间依赖环。</span></i> <i><span color="#a5a5a5">// 每一行显示一个不同的环,并以缠在环中的命名空间作为前缀。</span></i> <i><span color="#a5a5a5">//</span></i> <i><span color="#a5a5a5">// 想要在依赖图或依赖矩阵中查看某个环,右键点击 </span></i> <i><span color="#a5a5a5">// 该环然后将相应的命名空间导出为依赖图或依赖矩阵即可!</span></i> <i><span color="#a5a5a5">//</span></i> <i><span color="#a5a5a5">// 在矩阵中,依赖环以红色方块或黑色单元格表示。</span></i> <i><span color="#a5a5a5">// 为了能够方便地浏览依赖环,依赖矩阵需有该选项:</span></i> <i><span color="#a5a5a5">// --> 显示直接和间接依赖 </span></i> <i><span color="#a5a5a5">//</span></i> <i><span color="#a5a5a5">// 请阅读我们关于分解代码的白皮书,</span></i> <i><span color="#a5a5a5">// 以更深入地了解命名空间依赖环,以及弄明白为什么 </span></i> <i><span color="#a5a5a5">// 避免出现依赖环是组织代码结构的简单而有效的解决方案。</span></i> <i><span color="#a5a5a5">// http://www.ndepend.com/WhiteBooks.aspx</span></i> <i><span color="#a5a5a5">// 优化:限定程序集范围 </span></i> <i><span color="#a5a5a5">// 如果命名空间是相互依赖的 </span></i> <i><span color="#a5a5a5">// - 则它们必定在同一个程序集中被声明 </span></i> <i><span color="#a5a5a5">// - 父程序集必定 ContainsNamespaceDependencyCycle</span></i> <span color="#000000"><b>from</b> assembly <b>in</b> Application.Assemblies</span> <span color="#000000"> .Where(a => a.ContainsNamespaceDependencyCycle != <b>null</b> &&</span> <span color="#000000"> a.ContainsNamespaceDependencyCycle.Value)</span> <i><span color="#a5a5a5">// 优化:限定命名空间范围 </span></i> <i><span color="#a5a5a5">// 依赖环中命名空间的 Level 值必须为 null。</span></i> <span color="#000000">let namespacesSuspect = assembly.ChildNamespaces.Where(n => n.Level == <b>null</b>)</span> <i><span color="#a5a5a5">// hashset 用来避免再次遍历环中已经被捕获的命名空间。</span></i> <span color="#000000">let hashset = <b>new</b> HashSet<INamespace>()</span> <span color="#000000"><b>from</b> suspect <b>in</b> namespacesSuspect</span> <i><span color="#a5a5a5"> // 若注释掉这一行,则将查询环中的所有命名空间。</span></i> <span color="#000000"><b> where</b> !hashset.Contains(suspect)</span> <i><span color="#a5a5a5"><br></br> // 定义 2 个代码矩阵 </span></i> <i><span color="#a5a5a5"> // - 非直接使用嫌疑命名空间的命名空间的深度。</span></i> <i><span color="#a5a5a5"> // - 被嫌疑命名空间非直接使用的命名空间的深度。</span></i> <i><span color="#a5a5a5"> // 注意:直接使用的深度等于 1。</span></i> <span color="#000000"> let namespacesUserDepth = namespacesSuspect.DepthOfIsUsing(suspect)</span> <span color="#000000"> let namespacesUsedDepth = namespacesSuspect.DepthOfIsUsedBy(suspect)</span> <i><span color="#a5a5a5"> // 选择使用 namespaceSuspect 或者被 namespaceSuspect 使用的命名空间 </span></i> <span color="#000000"> let usersAndUsed = <b>from</b> n <b>in</b> namespacesSuspect <b>where</b></span> <span color="#000000"> namespacesUserDepth[n] > <span color="#4f81bd">0</span> &&</span> <span color="#000000"> namespacesUsedDepth[n] ></span><span color="#4f81bd"> 0</span> <span color="#000000"><b> select</b> n</span> <span color="#000000"><b> where</b> usersAndUsed.Count() ></span><span color="#4f81bd"> 0</span> <i><span color="#a5a5a5"> // 这里我们找到了使用嫌疑命名空间或者被嫌疑命名空间使用的命名空间。</span></i> <i><span color="#a5a5a5"> // 找到了包含嫌疑命名空间的环!</span></i> <span color="#000000"> let cycle = usersAndUsed.Append(suspect)</span> <i><span color="#a5a5a5"> // 将环中的命名空间填充到 hashset。</span></i> <i><span color="#a5a5a5"> // 需要使用.ToArray() 来推进迭代过程。</span></i> <span color="#000000"> let unused1 = (<b>from</b> n <b>in</b> cycle let unused2 = hashset.Add(n) <b>select</b> n).ToArray()</span> <span color="#000000"><b>select</b> <b>new</b> { suspect, cycle }</span>
代码规范体包括若干区域:
- 首先,利用属性 IAssembly.ContainsNamespaceDependencyCycle 以及属性 IUser.Level ,我们可以尽可能地消除掉多余的程序集和命名空间。因此,对于每个包含命名空间依赖环的程序集 _,_ 我们只保留了被称为 _ 嫌疑命名空间(suspect namespaces)_ 的集合。
- 定义的范围变量(range variable)_hashset_ 被用来避免由 N 个命名空间构成的环被显示 N 次。注释掉这行代码 _where !hashset.Contains(suspect)_ 则会将依赖环显示 N 次。
- 该查询的核心是对两个扩展方法 DepthOfIsUsing() 和 DepthOfIsUsedBy() 的调用。这两个方法非常强大,因为他们各自创建了 ICodeMetric<INamespace,ushort> 对象。通常,如果 A 依赖于 B,B 依赖于 C,则 _DepthOfIsUsing©[A]_ 的值等于 2,DepthdOfIsUsedBy(A)[C]的值也等于 2。** 基本上,如果存在一个或多个嫌疑命名空间 B 使得 _DepthOfIsUsing(A)[B] 和 _DepthOfIsUsedBy(A)[B] 的值同时非 null 且为正数,则包含嫌疑命名空间 A 的依赖环就会被检测到。**
- 接着我们只需构建命名空间 B 的集合,然后将它附加上命名空间 A,从而使整个环包含 A。
裁剪依赖环
虽然我们拥有了检测和可视化命名空间依赖环的强大方法,但当遇到要定义到底哪个依赖必须被裁剪掉以得到层级的代码结构时,我们又一次懵了。让我们来看一看上面的截图,我们可以看到依赖环大多都是由相互依赖的成对命名空间组成的(由图中的 _ 双向箭头 _ 表示)。想要得出层级的代码结构,首先必须解决的问题是确保不存在相互依赖的组件对。
于是我们研发出了 CQLinq 的被称为避免命名空间相互依赖的代码规范。这个代码规范不仅能够陈列出相互依赖对,同时它还能指示双向依赖的哪一方应被裁剪掉 。这个指示是由所使用的类型个数推断出来的。假如A 使用了B 的20 个类型,而B 使用了A 的五个类型,很可能的结论就是B 不应该引用A。B 正在使用A 的五个类型,很可能就是由于开发者不清除代码结构而造成的意外情况。这就是代码结构侵蚀的根源。
凭我们的经验,当A 和B 相互依赖时,我们通常会自然地知道哪一方应该被裁剪掉。这是因为,如我们所想,偶然造成的依赖在个数上通常是较低的。但是如果一直不加以修复,而让这种偶然错误积累,则最终会导致出现我们在大多数企业应用中看到的大面积意大利面式代码。
给个具体的例子,下图是将我们的代码规范应用于程序集_System.Core.dll_ 的结果。我们看到这个程序集包含了16 对相互依赖的命名空间。同时,下图还验证了前面分析的结果:大多数依赖对中双方间的引用类型个数是很不对称的:
(点击图片可以放大)
下面展示了CQLinq 代码规范的主体,其和上面论述的代码规范有相似之处。如果你仔细看了前面解释的代码规范,并且清楚C#语法,则看懂这条规范的相关代码是件很容易的事情。
<i><span color="#a5a5a5">// <Name> 避免命名空间相互依赖 </Name></span></i> <span color="#a5a5a5"><span color="#000000">warnif count</span> > </span><span color="#4f81bd">0</span> <i><span color="#a5a5a5">// 这条规则列出所有相互依赖的命名空间对。</span></i> <i><span color="#a5a5a5">// 命名空间对格式{ first, second }表明第一个命名空间不应该使用第二个命名空间。</span></i> <i><span color="#a5a5a5">// 格式中的 first/second 顺序是由被彼此使用的类型的个数推到出来的。</span></i> <i><span color="#a5a5a5">// 如果第一个命名空间使用第二个命名空间的类型的个数比相反的少,</span></i> <i><span color="#a5a5a5">// 则表明第一个命名空间相对于第二个来说在组织结构中处于更低层级。</span></i> <i><span color="#a5a5a5">//</span></i> <i><span color="#a5a5a5">// 找出相互依赖的两个命名空间的耦合点:</span></i> <i><span color="#a5a5a5">// 1) 将第一个命名空间导出到依赖矩阵的垂直方向头部。</span></i> <i><span color="#a5a5a5">// 2) 将第二个命名空间导出到依赖矩阵的水平方向头部。</span></i> <i><span color="#a5a5a5">// 3) 双击黑色单元格。</span></i> <i><span color="#a5a5a5">// 4) 在矩阵命令工具条中,点击按钮:Remove empty Row(s) en Column(s)。</span></i> <i><span color="#a5a5a5">// 到这里,依赖矩阵就显示出了导致耦合的类型。</span></i> <i><span color="#a5a5a5">//</span></i> <i><span color="#a5a5a5">// 遵循这条规则能有效地避免出现命名空间依赖环。</span></i> <i><span color="#a5a5a5">// 可以在我们的关于分解代码的白皮书中找到这方面的更多内容。</span></i> <i><span color="#a5a5a5">// http://www.ndepend.com/WhiteBooks.aspx</span></i> <i><span color="#a5a5a5">// 优化:限定程序集的范围 </span></i> <i><span color="#a5a5a5">// 如果命名空间是相互依赖的 </span></i> <i><span color="#a5a5a5">// - 则它们必定在同一个程序集中被声明 </span></i> <i><span color="#a5a5a5">// - 父程序集必定 ContainsNamespaceDependencyCycle</span></i> <span color="#000000"><b>from</b> assembly <b>in</b> Application.Assemblies.Where(a => a.ContainsNamespaceDependencyCycle != <b>null</b> && a.ContainsNamespaceDependencyCycle.Value)</span> <i><span color="#a5a5a5">// hashset 用来避免重复报告 A <-> B and B <-> A</span></i> <span color="#000000">let hashset = <b>new</b> HashSet<INamespace>()</span> <i><span color="#a5a5a5">// 优化:限定命名空间集合 </span></i> <i><span color="#a5a5a5">// 如果一个命名空间没有 Level 值,则它必定在依赖环中,</span></i> <i><span color="#a5a5a5">// 或者直接或间接地使用了某个依赖环。</span></i> <span color="#000000">let namespacesSuspect = assembly.ChildNamespaces.Where(n => n.Level == <b>null</b>)</span> <span color="#000000"><b>from</b> nA <b>in</b> namespacesSuspect</span> <i><span color="#a5a5a5">// 使用 nA 选择相互依赖的命名空间 </span></i> <span color="#000000">let unused = hashset.Add(nA) </span><span color="#a5a5a5"><i>// Populate hashset</i></span> <span color="#000000">let namespacesMutuallyDependentWith_nA = nA.NamespacesUsed.Using(nA)</span> <span color="#000000"> .Except(hashset) </span><span color="#a5a5a5"><i>// <-- 避免重复报告 A <-> B and B <-> A </i></span> <span color="#000000"><b>where</b> namespacesMutuallyDependentWith_nA.Count() ></span> <span color="#4f81bd">0</span> <span color="#000000"><b>from</b> nB <b>in</b> namespacesMutuallyDependentWith_nA</span> <i><span color="#a5a5a5">// nA 和 nB 是相互依赖的。</span></i> <i><span color="#a5a5a5">// 首先选择不应该使用另一个的那个。</span></i> <i><span color="#a5a5a5">// 第一个命名空间是由它使用的第二个命名空间的类型的个数更少这个事实推导出来的。</span></i> <span color="#000000">let typesOfBUsedByA = nB.ChildTypes.UsedBy(nA)</span> <span color="#000000">let typesOfAUsedByB = nA.ChildTypes.UsedBy(nB)</span> <span color="#000000">let first = (typesOfBUsedByA.Count() > typesOfAUsedByB.Count()) ? nB : nA</span> <span color="#000000">let second = (first == nA) ? nB : nA</span> <span color="#000000">let typesOfFirstUsedBySecond = (first == nA) ? typesOfAUsedByB : typesOfBUsedByA</span> <span color="#000000">let typesOfSecondUsedByFirst = (first == nA) ? typesOfBUsedByA : typesOfAUsedByB</span> <span color="#000000"><b>select</b> <b>new</b> { first, shouldntUse = second, typesOfFirstUsedBySecond, typesOfSecondUsedByFirst }</span>
当你解除了所有相互依赖的命名空间对之后,第一条代码规范可能仍然会报告存在依赖环。这是因为你可能会遇到由至少三个命名空间组成的依赖环,即 _A 依赖于 B,B 依赖于 C,C 依赖于 A_ 。这看起来很令人抓狂,但在实践中,这样的环通常是容易解除的。事实上,当 3 个或者更多的组件形成了这样的环形关系时,确定哪个处于最低一级是件微不足道的事情,你很容易就可以确定应该从环中的哪个地方裁剪。
结论
- 很让人兴奋,现在我们能使用这两条强大的代码规范来检测命名空间依赖环以及指示怎样解除依赖环。
- 另外,令我特别喜悦的是,我们通过 _ 两个单一的 __ 文本式 C#代码摘录 _ 添加了这些强大特性,有利于阅读、编写、分享和推敲。NDepend 做了将它们编译和 _ 即时 _ 执行的工作,并以 _ 可浏览和交互 _ 的方式发布。从技术上讲, 现在我们可以在几分钟之内添加完成用户要求的全新特性(我们已经推出了 200 个 CQLinq 代码规范)。同时,更为优越的是,用户甚至可以自己开发出新特性!
关于作者
Patrick Smacchia是法国一位 Visual C#方向的微软最有价值专家(MVP),他在软件开发行业打拼了 20 多年。从数学和计算科学专业毕业之后,他从事过多个软件行业领域的工作,包括在 Société Générale 的证券交易系统,在 Amadeus 的航空票务系统,在 Alcatel 的卫星基站。同时他还创作出版了《.NET 2 和 C# 2 实战》一书,这是一本从实际经验出发介绍和探讨.NET 平台的书籍。他从 2004 年 4 月份开始研发 NDepend 工具,以帮助.NET 开发者检测和修复他们的代码中存在的相关问题。他目前是 NDepend 的首席开发人员,百忙之中他还会安排时间享受软件技术的多个领域给他带来的乐趣。
查看英文原文: Cut off wrong dependencies in your .NET code
感谢侯伯薇对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论