在如何至始至终保持代码的可维护性方面我给.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项目,将会越来越受欢迎。
// 避免命名空间依赖环
warnif count > 0
// 这个查询列出了应用程序的所有命名空间依赖环。
// 每一行显示一个不同的环,并以缠在环中的命名空间作为前缀。
//
// 想要在依赖图或依赖矩阵中查看某个环,右键点击
// 该环然后将相应的命名空间导出为依赖图或依赖矩阵即可!
//
// 在矩阵中,依赖环以红色方块或黑色单元格表示。
// 为了能够方便地浏览依赖环,依赖矩阵需有该选项:
// --> 显示直接和间接依赖
//
// 请阅读我们关于分解代码的白皮书,
// 以更深入地了解命名空间依赖环,以及弄明白为什么
// 避免出现依赖环是组织代码结构的简单而有效的解决方案。
// http://www.ndepend.com/WhiteBooks.aspx
// 优化:限定程序集范围
// 如果命名空间是相互依赖的
// - 则它们必定在同一个程序集中被声明
// - 父程序集必定ContainsNamespaceDependencyCycle
from assembly in Application.Assemblies
.Where(a => a.ContainsNamespaceDependencyCycle != null &&
a.ContainsNamespaceDependencyCycle.Value)
// 优化:限定命名空间范围
// 依赖环中命名空间的Level值必须为null。
let namespacesSuspect = assembly.ChildNamespaces.Where(n => n.Level == null)
// hashset用来避免再次遍历环中已经被捕获的命名空间。
let hashset = new HashSet()
from suspect in namespacesSuspect
// 若注释掉这一行,则将查询环中的所有命名空间。
where !hashset.Contains(suspect)
// 定义2个代码矩阵
// - 非直接使用嫌疑命名空间的命名空间的深度。
// - 被嫌疑命名空间非直接使用的命名空间的深度。
// 注意:直接使用的深度等于1。
let namespacesUserDepth = namespacesSuspect.DepthOfIsUsing(suspect)
let namespacesUsedDepth = namespacesSuspect.DepthOfIsUsedBy(suspect)
// 选择使用namespaceSuspect或者被namespaceSuspect使用的命名空间
let usersAndUsed = from n in namespacesSuspect where
namespacesUserDepth[n] > 0 &&
namespacesUsedDepth[n] > 0
select n
where usersAndUsed.Count() > 0
// 这里我们找到了使用嫌疑命名空间或者被嫌疑命名空间使用的命名空间。
// 找到了包含嫌疑命名空间的环!
let cycle = usersAndUsed.Append(suspect)
// 将环中的命名空间填充到hashset。
// 需要使用.ToArray() 来推进迭代过程。
let unused1 = (from n in cycle let unused2 = hashset.Add(n) select n).ToArray()
select new { suspect, cycle } |
代码规范体包括若干区域:
- 首先,利用属性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(C)[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#语法,则看懂这条规范的相关代码是件很容易的事情。
// 避免命名空间相互依赖
warnif count > 0
// 这条规则列出所有相互依赖的命名空间对。
// 命名空间对格式{ first, second }表明第一个命名空间不应该使用第二个命名空间。
// 格式中的first/second顺序是由被彼此使用的类型的个数推到出来的。
// 如果第一个命名空间使用第二个命名空间的类型的个数比相反的少,
// 则表明第一个命名空间相对于第二个来说在组织结构中处于更低层级。
//
// 找出相互依赖的两个命名空间的耦合点:
// 1) 将第一个命名空间导出到依赖矩阵的垂直方向头部。
// 2) 将第二个命名空间导出到依赖矩阵的水平方向头部。
// 3) 双击黑色单元格。
// 4) 在矩阵命令工具条中,点击按钮:Remove empty Row(s) en Column(s)。
// 到这里,依赖矩阵就显示出了导致耦合的类型。
//
// 遵循这条规则能有效地避免出现命名空间依赖环。
// 可以在我们的关于分解代码的白皮书中找到这方面的更多内容。
// http://www.ndepend.com/WhiteBooks.aspx
// 优化:限定程序集的范围
// 如果命名空间是相互依赖的
// - 则它们必定在同一个程序集中被声明
// - 父程序集必定ContainsNamespaceDependencyCycle
from assembly in Application.Assemblies.Where(a => a.ContainsNamespaceDependencyCycle !=
null && a.ContainsNamespaceDependencyCycle.Value)
// hashset用来避免重复报告 A <-> B and B <-> A
let hashset = new HashSet<INamespace>()
// 优化:限定命名空间集合
// 如果一个命名空间没有Level值,则它必定在依赖环中,
// 或者直接或间接地使用了某个依赖环。
let namespacesSuspect = assembly.ChildNamespaces.Where(n => n.Level == null)
from nA in namespacesSuspect
// 使用nA选择相互依赖的命名空间
let unused = hashset.Add(nA) // Populate hashset
let namespacesMutuallyDependentWith_nA = nA.NamespacesUsed.Using(nA)
.Except(hashset) // <-- 避免重复报告 A <-> B and B <-> A
where namespacesMutuallyDependentWith_nA.Count() > 0
from nB in namespacesMutuallyDependentWith_nA
// nA和nB是相互依赖的。
// 首先选择不应该使用另一个的那个。
// 第一个命名空间是由它使用的第二个命名空间的类型的个数更少这个事实推导出来的。
let typesOfBUsedByA = nB.ChildTypes.UsedBy(nA)
let typesOfAUsedByB = nA.ChildTypes.UsedBy(nB)
let first = (typesOfBUsedByA.Count() > typesOfAUsedByB.Count()) ? nB : nA
let second = (first == nA) ? nB : nA
let typesOfFirstUsedBySecond = (first == nA) ? typesOfAUsedByB : typesOfBUsedByA
let typesOfSecondUsedByFirst = (first == nA) ? typesOfBUsedByA : typesOfAUsedByB
select new { first, shouldntUse = second, typesOfFirstUsedBySecond, typesOfSecondUsedByFirst } |
当你解除了所有相互依赖的命名空间对之后,第一条代码规范可能仍然会报告存在依赖环。这是因为你可能会遇到由至少三个命名空间组成的依赖环,即A依赖于B,B依赖于C,C依赖于A
。这看起来很令人抓狂,但在实践中,这样的环通常是容易解除的。事实上,当3个或者更多的组件形成了这样的环形关系时,确定哪个处于最低一级是件微不足道的事情,你很容易就可以确定应该从环中的哪个地方裁剪。
结论
- 很让人兴奋,现在我们能使用这两条强大的代码规范来检测命名空间依赖环以及指示怎样解除依赖环。
- 另外,令我特别喜悦的是,我们通过两个单一的文本式C#代码摘录添加了这些强大特性,有利于阅读、编写、分享和推敲。NDepend做了将它们编译和即时执行的工作,并以可浏览和交互
的方式发布。从技术上讲, 现在我们可以在几分钟之内添加完成用户要求的全新特性(我们已经推出了200个CQLinq代码规范)。同时,更为优越的是,用户甚至可以自己开发出新特性!
|