接上篇继续,本文的完整源代码也在上篇文章中。
枚举数组和普通枚举性能差异
有些人可能知道,.net在处理枚举时,对于数组有特别的优化,所以,当枚举的集合是一个数组时,性能会好些。例如下面的测试代码:
1 class C1 { 2 3 public void Do1() { 4 int[] array = { 1, 2, 3, 4 }; 5 for (int i = 0; i < int.MaxValue/100; i++) { 6 DoIt1(array); 7 } 8 } 9 10 private void DoIt1<T>(IEnumerable<T> array) { 11 foreach (var item in array) { 12 13 } 14 } 15 16 public void Do2() { 17 int[] array = { 1, 2, 3, 4 }; 18 for (int i = 0; i < int.MaxValue/100; i++) { 19 DoIt2(array); 20 } 21 } 22 23 private void DoIt2(int[] array) { 24 foreach (var item in array) { 25 26 } 27 } 28 }
第23行的方法中,编译器提前已知是一个数组的枚举,所以会优化指令。那么,到底这种优化差距有多大呢?我需要试验一下。

第一个是Do1的结果,第二个是Do2的结果,显而易见,差距还是相当大的,为什么呢?从反编译的IL代码来看,第一个方法使用标准的GetEnumerator机制,需要创建实例,而且要调用Current和MoveNext两个方法,更何况,Array的GetValue实现实在不够快。而第二个方法使用了for的机制,无需创建实例,不断累加和一个判断语句即可,性能当然高了。
在Linq to Object中,其实是有这样考虑的代码的。例如:
public static IEnumerable<TResult> Select<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector) { if (source == null) { throw Error.ArgumentNull("source"); } if (selector == null) { throw Error.ArgumentNull("selector"); } if (source is Enumerable.Iterator<TSource>) { return ((Enumerable.Iterator<TSource>)source).Select<TResult>(selector); } if (source is TSource[]) { return new Enumerable.WhereSelectArrayIterator<TSource, TResult>((TSource[])source, null, selector); } if (source is List<TSource>) { return new Enumerable.WhereSelectListIterator<TSource, TResult>((List<TSource>)source, null, selector); } return new Enumerable.WhereSelectEnumerableIterator<TSource, TResult>(source, null, selector); }
创建类和结构的性能差异以及属性和字段的性能差异
下面我还要试验创建类实例和结构类型,其性能差异到底有多大?会不会.net的垃圾回收超级厉害,基本上差异不大呢?当然,我也顺手测试了访问属性和访问字段的差别。
1 class C1 { 2 public void Do1() { 3 for (int i = 0; i < int.MaxValue/10; i++) { 4 var p = new PointClass() { X = 1, Y = 2 }; 5 } 6 } 7 public void Do2() { 8 for (int i = 0; i < int.MaxValue/10 ; i++) { 9 var p = new PointClass2() { X = 1, Y = 2 }; 10 } 11 } 12 public void Do3() { 13 for (int i = 0; i < int.MaxValue/10; i++) { 14 var p = new Point() { X = 1, Y = 2 }; 15 } 16 } 17 public void Do4() { 18 for (int i = 0; i < int.MaxValue / 10; i++) { 19 var p = new Point() { XP = 1, YP = 2 }; 20 } 21 } 22 } 23 24 25 class PointClass { 26 public int X { get; set; } 27 public int Y { get; set; } 28 } 29 30 class PointClass2 { 31 public int X; 32 public int Y; 33 } 34 35 struct Point { 36 public int X; 37 public int Y; 38 39 public int XP { get { return X; } set { X = value; } } 40 public int YP { get { return Y; } set { Y = value; } } 41 }
测试结果如下:

实验结果表明:在计算密集型的程序中,结构的创建仍然比类要高效的多,另外,属性和字段的访问其性能基本相当。
今天,我想跟大家聊一聊C#的性能优化,当然,这里并不谈基本的原则,这些都假设你已经非常精通了,本文聊的是要争取几个毫秒的程序。关于基本的性能优化,可以参考园子里的文章。比如:
先说说我的测试环境:

一台典型的笔记本电脑,Windows 7中文版,.net Framework用的是4.5版本,VS是现在VS11 beta版。我也是用VS2008这样的环境测试了下面的所有场景,发现没有任何区别,所以就以VS11为基准了。
所有测试数据都是编译为Relase,且不包含PDB,直接双击运行而非在VS环境下执行。点击这里下载源代码。
言归正传,先测试第一点:
静态方法比实例方法快吗?
我们总是从各个渠道听说:静态方法比实例方法要快,所以,我想亲自试试。测试方法很简单,循环调用实例方法和静态方法。
/// <summary>
/// 这是一个普通类,调用实例的方法
/// </summary>
public class C1 {
public void DoLoop() {
for (int i = 0; i < int.MaxValue; i++) {
DoIt();
}
}
private void DoIt() {
}
}
/// <summary>
/// 使用静态方法调用。
/// </summary>
public static class C2 {
public static void DoLoop() {
for (int i = 0; i < int.MaxValue; i++) {
DoIt();
}
}
private static void DoIt() {
}
}
测试结果如下:

测试多次,基本偏差不大,只能说,静态方法比实例方法快那么可怜的一点点,鉴于实例方法的灵活性远大于静态方法,所以还是一般使用实例方法吧。
也实验过,在方法中访问实例字段和静态字段,发现也没有区别,所以不再单独罗列代码。
避免方法内创建实例的情况
这个要讨论的问题有点难说明,我们还是先看一看.net内部的代码吧,下面是一段Collection<T>.Add的方法:
public void Add(T item)
{
if (this.items.IsReadOnly)
{
ThrowHelper.ThrowNotSupportedException(ExceptionResource.NotSupported_ReadOnlyCollection);
}
int count = this.items.Count;
this.InsertItem(count, item);
}
注意ThrowHelper类,如果换成我们自己写,一句话就搞定了:throw new NotSupportedException。为什么微软要这么写呢?
老外有解释:Why does SortedList implementation use ThrowHelper instead of throwing directly?
其实,我也是信奉此真理,而且就在前一段时间,一位同事还找我问,两段几乎一样的代码,为什么测试性能有差距,结果我按照此原理,将异常抛出放在外面,结果真的变好了。
现在,我还要再次测试一下,我相信的是数据:
class C1 {
private Dictionary<int, int> _dict = new Dictionary<int, int>() ;
public C1() {
_dict.Add(1, 1);
_dict.Add(2, 2);
}
public void Do1() {
object obj = new object();
for (int i = 0; i < int.MaxValue/100; i++) {
GetItOne(1);
}
}
//这个方法,在内部可能创建实例
private int GetItOne(int key) {
int value;
if (!_dict.TryGetValue(key,out value)) {
throw new ArgumentOutOfRangeException("key");
}
return value;
}
public void Do2() {
for (int i = 0; i < int.MaxValue/100; i++) {
GetItTwo(1);
}
}
//这个方法,将创建实例的代码移动到外部
private int GetItTwo(int key) {
int value;
if (!_dict.TryGetValue(key, out value)) {
ThrowArgumentOutOfRangeException();
}
return value;
}
private static void ThrowArgumentOutOfRangeException() {
throw new ArgumentOutOfRangeException("key");
}
}
测试结果是:

基本上,会快0.06秒左右,但是如此大的循环得到的好处并不是那么的明显,但有作用。这种写法还是比较舒服的,所以还是建议大家用吧。
下篇我将实验:数组的枚举,类和结构创建的成本。
测试
现有问题
“测试”这个章节被安排到最后才说,并不是他有多么的难,而是领导给我出了一个难题:
在之前,程序员提交的代码未经过严格测试就提交到TFS服务器,所以我们很难时时刻刻可以获得一个稳定的版本。因此,领导希望程序员提交的代码必须由测试人员测试通过,才能真正提交到TFS。
当前,我们是已经有一套流程来处理这样的需求的,做法是:
- 程序员通过”项目工具“签出代码,此部分的代码会被锁定,其他人不能修改,签出的代码以某个业务单元为最小单位,例如一张销售订单;
- 修改完毕并本机测试通过后,通过“项目工具”提交代码,而“项目工具”的内部实际做法是将本地的源代码搁置到TFS,然后回滚本地代码。
- 业务设计师使用”项目工具“进行第一轮测试,工具实际做法是取消搁置集,并本地编译一个版本,完成测试后,进入下一轮测试;
- 测试人员完成第二轮测试,方法同上,当测试通过后,执行”入库“动作,即将搁置的源代码真正签入TFS代码库,并解除锁定。
此流程的确可以让测试完毕的代码才能进入代码库,但问题也很多:
- 最显而易见的问题是,锁定时间太长了。从程序员签出开始加锁,到测试完成才能解锁,程序员经常需要费力的调整任务,以便防止等待他人解锁造成的等待。甚至我询问程序员,他们告诉我,如果我锁定了代码,那么另外一个人如果也需要改这个代码又不愿意等,那么他会拷贝代码给我,让我一并处理,晕死。
- 另外一个问题是,以前的测试是基于同一个时间点的DLL,而现在测试基于不同时间点的源代码。程序员假设是1号完成的测试并提交(实际是搁置),他的环境是1号的总体代码+自己源代码。等到5号的时候测试开始工作时,测试获取的总体代码是5号的,但搁置集又是1号的,编译和测试的环境都不同了。
- 编译不通过,还会引发责任不明确的问题,如果上面的例子中5号代码未编译通过了,程序员会认为我1号的时候测试通过的啊,而后续代码签入者更觉得与我无关。
因此,我总想这个有个”万能钥匙“可以解开这个问题,而我,… … 没能找到。
经典的持续集成步骤
既然找不到新的方法,那么我们就来回顾经典的方法。下图展示了持续集成中开发人员级别的测试过程:

(图片摘自网络: http://www.slideshare.net/sagittatius/ss-8672114)
持续集成的步骤2中,在修改完毕后会自己增加测试,但如果你们的团队没有完全的自动化测试用例,那么可能会手动测试。步骤3和4都是完成自动化测试,通过快速而简单的测试,”立即“完成代码的签入过程。
可以看出,持续集成并不追求绝对的“稳定”后才提交的原则,而是相对稳定就提交代码。测试工作主要通过测试用例快速的完成,当测试已经完成后,代码实际已经签入到代码库。为解决这个问题,测试驱动的开发就要求开发人员自己编写BUG测试用例,非常严谨,但在一个还很不成熟的团队来说,有些困难。
在未进入最后发版前,我们都进行一种滚动式的测试,意思是,每天测试人员都获取最新的编译环境(如果有成功编译的环境的话),在此环境下进行测试,因为BUG总是很多,昨天发现的BUG很可能开发人员很快就修复了,测试人员熟悉这个BUG有利于快速再次测试,如果不幸这个BUG没有修复(我们当然不希望发生),再次要求修改,开发人员也很熟悉此BUG的情况,而不是一个长达一个月的测试完毕后再次测试时,程序员和测试人员都要费力的回忆这个BUG的情况(虽然BUG有完整的描述)。
这种测试方法显然也是不是“绝对”正确的,很可能昨天已经修复的BUG,在今天的新版本下又重新出现,而我们因为已经“测试通过”,所以不会再测试他了,而遗漏了这个BUG。对于这个问题,最常见的办法是,为每个BUG编写测试用例,这个就可以在后面的编译版本中不断的进行测试,从而避免问题的复发。
这种方式如果没有足够的测试用例,也不能防止某个“不负责任”的开发人员提交一个糟糕的代码,造成这个版本无法使用。当然,如果有足够的测试用例,会好些,至少能保证已测试的功能正常使用。所以,我给的答案是:无法完全的做到这个需求,只能通过建立大量的测试用例来尽力避免。
项目工具总结
我们在上面的文章中都提到项目工具,在这里总结一下,在VS的集成环境下,共包括以下功能:

这些功能,其“下载最新环境”、“依赖环境更新”和选项可以在独立的程序中使用,例如测试人员可以用他获取特定时间的环境,用于测试。
一个完整的产品单元其\src\目录下应包含以下文件,用于支持上面的功能:
- all.sln 包含所有项目定义的解决方案文件;
- downloadEnv.proj 下载最新的环境;
- debug.proj 启动调试;
- build.proj 编译指定的解决方案并部署到环境;
- updateRuntime.proj 部署最新组件到运行环境;
- integration_One.proj 持续集成的第一个步骤;
- integration_two.proj 持续集成的第二个步骤;
通过这些一系列的MSBuild过程,配合软件的集成,完成对项目的管理。对于此项目工具有兴趣的话,可以留言是否建立一个开源项目。
版本
大版分支
在发布一个稳定的版本后,我们会创建一个分支,这是因为我们的人力还需要马不停蹄的继续开发大量的新版本功能(修改代码),而客户使用的是稳定版本,但很难说不会有BUG,这个时候我们就可以在这个分支修改BUG,立即交付给客户。创建一个分支是TFS和很多源代码管理工具都自带的功能,可惜很多人不知道,我就啰嗦一下。
在TFS的“源代码管理资源管理器”中,找到你的产品单元目录,例如MyProduct,右键,选择“Branch 分支”,出现下面的画面:
在“Target 目标”的地方键入MyProduct-1.0,这样,就在与MyProduct平级的目录,建立了一个新目录:MyProduct-1.0。由于之前已经在1.0发版时打了标签,所以我这里选择了这个标签。点击OK后,你还需要实际的签入才能生效。
建立一个平级的目录的好处是,你可以像以前的工作方式一样,把这个1.0看成一个新的产品单元,这样之前定义的流程在分支情况下仍然适用。
当真的需要修改BUG时,你需要到MyProduct-1.0下修订这个BUG,测试并签入代码,与往常没有什么不一样,唯一的另外就是,通常这个BUG在当前开发版本中(在这里例子就是MyProduct)也有,TFS提供了简便的方法帮助你快速完成MyProduct上此BUG的修订,可以在MyProduct-1.0/src目录上点击右键,选择“Merge 合并”,出现下图:

在“Source branch 源分支”中选择稳定版本(已经修订BUG的版本),在“Target branch 目标分支”上选择开发版本(未修订Bug的版本),注意,一般我们选择“Selected changesets 选择变更集”,而不是“All changes up to a specific version 所有变更”,这样就仅针对你这个bug修改进行合并,点击“Next 下一步”。

这里你能看见所有未合并的变更集,选择你的变更集,然后下一步,最后完成。重新回到MyProduct对应的MyCode所在目录,你会发现TFS自动签出了这个文件:
如果你打开这个文件,可以看见你修订BUG的代码也合并进去,你需要的是继续在当前MyProduct上完成编译和测试,最后签入即可。
这里仅仅是一个演示,分支与合并其实是一门蛮高深的学问,我这里就不细说,仅作为一个引子。
补丁
当我们已经修订BUG并测试通过后,必然已经更新了TFS上的bin目录,我们可以根据比对历史版本,来知道到底修改了哪些组件。
在bin目录上点击右键,选择“View History 查看历史”,在出现的历史列表中,选择两个版本,然后点击“Compare Folders 比较目录”。最后出现这个清单:
这是手工操作的方法,我是不会这样干的,我还是使用“项目工具”,首先看看目录结构:

你看见多了一个hotfixs目录,下面的目录名,其01是产品单元的编号,而119就是在TFS上的“Changeset 变更集”编号,“项目工具”总是检测最后一个补丁的编号,并与最新的编号比较,就跟你上图中手工处理一样,获取到清单后,工具就下载这些文件到一个临时目录,并使用安装包制作工具创建一个补丁安装程序。
可能你会说,并不是把最新的dll复制到客户就能修正某个bug的,可能还需要修复数据库的表结构,甚至修复数据。是的,你说的没有错,但这个流程我们不会把他设计到补丁工具上,而是
这些最新的组件其包含这些功能,这样补丁的流程就简单很多。
如果你的某个版本时间跨度很长,很可能积累了很多的补丁,要知道这些补丁是向前依赖的,不能说单独安装一个补丁。我建议你的产品设计一个在线更新的功能,这样就不会那么痛苦了。另外一个常见的实践是制作SP1这样的较大补丁包,他实际上就是把一堆的补丁合并成一个大补丁,方便新客户的安装。
有几点要切记,切记:
- 补丁尽量只包含BUG的修订,不要包含新功能;
- 给新客户的安装包,是原版+SP的形式,而不是直接一个最新版本。
这些忠告直接关系你的管理成本,如果你要问我为什么?怎么说呢,好复杂,你看Windows都是这么干的,O(∩_∩)O哈哈~算是解释吗。
中版分支
我知道,你在看到上面的第一点忠告时已经暴跳起来,
天啊,客户怎么可能等上一两年没有一个新版本?
做,当然做,不过我们换一种方法来做。
让我们设想一下,如果你将新特性放在MyProduct_1.0源代码中,就会出现一个问题,新特性的开发毕竟需要一个较长的编码和测试周期,当客户想你反映一个BUG时,你虽然可以很快修订这个BUG,但是你无法发布给客户,因为你的组件包含了很多尚未测试完毕的新特性,这个非常糟糕。
我的方法是,创建一个特性包分支,参加下图

在完成1.0的发版后,会产生分支,这个我们上面已经讲过,并且在修正1.0的BUG后合并到开发版中,形成1.0.1版本。
在完成1.0分支后我们就开展了2.0的大规模开发,但我们会优先开发R1的功能,在完成R1的开发和测试后,即1.1.1后,我们再次产生MyProduct-1.0 R1的分支,因为这个版本还包含了R1不需要的一些代码(开发版不可能完全遵照R1的要求开发,可能更进一步),所以我们需要花一些时间调整和测试。
在此期间,如果在正式版发现的BUG仍然需要合并到开发版,但是无需合并到R1版本,因为我们很快就已经将就绪的R1合并到正式版,然后封存R1这个分支,这样我们就仅维护两个版本。在日后的开发中,如果再次修订BUG,你应该选择合并对应的变更集,这是因为正式版已经包含很多的R1代码,如果选择全部合并,会把R1的代码也合并过来,而此功能在开发版已经包括了。
特性包
事实上,中版的分支方式我是不喜欢的,因为不管TFS的分支与合并功能再好,我们也还是需要维护多套源码,一个BUG在多处修订容易,每处都要测试就不轻松了,这都是“成本”。
我更喜欢“特性包”这样的方式。特性包的案例你可以想象是Word这样的软件,当Word开发完毕后,二次开发商利用提供的VSTO开发新的特性,二次开发商并没有修改Word的源代码,他们的代码也没有重叠,什么地方出现BUG就在什么产品单元上修改即可。回过来看我们的中版开发,你可以认为这个二次开发商就是自己。
特性包的做法前提是你的产品具有较强的二次开发能力,当开发完毕1.0后,并不立即产生分支,所有的中版开发放在一个独立的产品单元,注意这个产品单元并不是一个分支,更不是一份拷贝的副本,而是基于1.0 SDK的二次开发。

从上图可以看出,所谓特性版本就是一个独立的产品单元,各自维护自己的BUG。但是这样做对基础产品的要求就比较高,要能在不改动基础产品的情况下,完成很多的增强特性。当然,人无完人,产品也是,通常遇到不能支撑的时候,我们还是适当的修改底层,但要保持兼容。
开发2.0产品的方法是,首先从1.0产生分支出MyProduct_2.0,将各个特性版的二次开发代码整合到这个分支,最终形成2.0版本。但这个时候显然还是要维护2套版本,当发布3.0时,需要维护3套版本。
另外一种比较极端的做法是,开发2.0仍然看成已有产品单元的再次二次开发,3.0也是。这种方法对底层平台的要求更高,即特性版也能支撑二次开发。为提高性能,可以在特性版或各个补丁包安装完毕后,执行自动整合任务,以便将分散的二次开发整合成一个产品,有点像编译的过程。
日常工作
更新组件
每个程序员在开始新的工作前,都应该先更新最新的组件,如果更新所有的源代码其实是非常耗时的动作,所以我们会仅更新最新的成果(即当前产品单元下bin目录文件),以及你当前已经打开的项目源代码。如果通过“项目工具”添加一个现有项目到解决方案,也会询问是否获取最新版本。
由于之前做了大量的准备工作,所以更新非常容易,但我们仍然认为此动作太常用,所以在工具栏上包含了此按钮,他会通过TFS强制下载最新的bin目录文件。然后会将所有文件的只读属性去掉,为什么要这么做呢?不然你编译就无法覆盖这些只读文件了,这也是我为什么强调是“强制下载”的原因。
有些时候,服务器上发布的版本有些问题,所以你可能希望获得昨天的版本,TFS正好提供了这个特性,因此此按钮还允许你选择一个特定的版本。
编译和调试
我们每天都需要做很多次的源代码修改,然后编译他,调试,最后成功后发布或者定期自动发布。
调试,或者说让程序跑起来(Run),你可能认为直接按F5不就结了(F5是VS调试的快捷键)。如果你是项目只有一个exe和一堆dll,那么可能的确是这样。但我们现在讨论的是大型项目,所以就稍微复杂点点了,比如平台组的程序员,他编译更新的只是部分组件,即其bin目录下也只是部分dll,要跑起来需要有完整的运行环境。再比如,平台的组件可能作为很多的产品底层,因此运行需要决定运行哪个产品,这就又需要“项目工具”了。
第一个按钮是运行,第二个是编译,大家都知道的我就不罗嗦了。关键是第三个文本框,他可以录入一个路径,他就是你运行时目录。当编译时,工具先编译解决方案(跟你点击VS的Build效果一样),然后再执行一段自定义的MSBuild过程,他的任务是将编译更新的成果(例如dll)按照自定义的规则复制到运行目录下。这个自定义的MSBuild过程其实是解决方案相同目录下的一个UpdateRuntime.proj的文件,每个产品单元会手工编写这个文件完成这个任务。
这种方式有个小小的问题,如果你的bin目录下的文件和子目录太多太多,这个自定义过程可能时间稍长。一方面,可以默认仅复制上次更新之后的文件,减少复制时间。另外一方面可以通过减少目录的文件和层次达到,例如将大量琐碎的图片资源文件打包成一个zip文件,这样减少了文件的数量也就减少了复制的时间。
这个运行按钮也有一点点不一样,由于有些产品单元根本没有exe可以运行,例如平台的代码就没有,所以他通过让VS启动某个特定的Exe来启动调试,附加的好处是仍然可以使用VS的“编辑并立即运行”功能。
发布
发布包括内部发布和依赖成果更新两种,内部发布是指产品单元内部始终保持尽可能新的组件,通常的,内部发布的工作都是定时执行的,只是偶尔某个版本发布的有问题,我们才会手工执行一次发布的过程。他就是一个MSBuild的自定义文件,存放在特定的目录下,让TFS定时执行,他获取此产品单元的所有最新文件,签出bin目录,然后编译,确认无编译错误后签入最新组件。
依赖成果更新相对基础产品单元来说就是外部发布,对于上层产品单元来说是一次依赖成果的更新。当依赖的基础产品单元完成内部发布并确认无误后,向本产品单元更新最新成果的过程,过程是:
- 下载依赖产品单元的最新bin成果,例如下载平台的最新组件;
- 签出本产品单元bin目录,然后复制,例如具体ERP产品签出bin目录,再将平台的最新组件覆盖ERP的bin目录;
- 签入最新组件。
由于这种更新必须是谨慎的,所以我们都是手工启动任务,他一样是一个MSBuild文件。
代码项目文件规划
这里特别使用“代码项目文件”规划,我怕大家误解成在讲大型项目的项目规划,这里讲的是代码项目文件的规划,例如你使用C#开发,就是指那个*.csproj文件。
项目的输出
首选,你需要保证project输出到此产品单元的bin目录下,而不是默认的bin\debug这样的目录。打开项目的属性,下图是C#项目的设置:
1、选择“Build 生成”;
2、选择“All Configurations 所有配置”,保证Debug和Release都使用此配置;
3、选择“Output path 输出路径”,指向产品单元的bin目录。
4、如果你还有xml帮助输出,可以选择“XML documentation file XML文档文件”,也指向产品单元的bin目录对应的文件名。
Virtual Basic大同小异,我这里仅给出一幅图,注意Basic不需要专门设置XML文档文件名。
这些设置我强烈不建议手工操作,项目居多时,费时费力又容易有疏漏,建议开发小工具自动完成。
引用组件
毫无疑问,项目都应该引用bin目录下的dll,那么还有哪些注意的事项呢。下图演示了在一个C#项目中,一个引用的属性(他通过在项目引用上点击右键,选择属性获得)。
这里我将“复制本地”设置为false,因为我们从此目录引用文件,然后又复制到这个目录,是没有必要的。
我还喜欢把“特定版本”设置为false,这样只是为了方便,虽然不严谨,但很实用。
如果是引用产品单元内部的组件,是引用dll还是引用项目文件?
应该引用DLL,而不是项目文件。
我们知道,如果引用项目文件的话,好处是Visual Studio会自动分析项目的引用关系,并决定编译的先后顺序,这是一个非常好的功能。但请注意,我们这里讲的是大型.net项目,他们动辄几百个项目,如果放在一个解决方案中,显然太大了。我们总是仅加载几个我们需要的项目来调试,而这个时候Visual Studio就会在你的项目引用图标上显示一个感叹号,告诉你他找不到这个项目。虽然实践证明,VS仍然可以正常编译,但由于没有加载被引用的项目,所以VS也就不能自动调整编译顺序了。另外一个已知的问题是,如果你在解决方案中删除一个项目,那么在其他项目引用了他,VS会自作聪明的将引用删除掉。所以引用项目形同虚设。
回到引用DLL,首先引用DLL的行为变得一致。关键是在自定义的解决方案文件中,我们可以随时删除一个项目,或增加一个项目,VS不会自作聪明的删除引用了。那么如何定义编译的顺序呢?总不能程序员每次自己维护吧,当然不是,这就不得不使用文章开头提到的“项目工具”了,你必须使用此工具添加和删除项目,他会自动设置项目的编译顺序。
下图演示了“项目工具”中,引用功能的样子。
此工具的工作原理是,他事先分析此产品单元的所有引用,这样就知道先后顺序,当你在VS中添加个别项目时,他设置项目的依赖性达到此功能。还是那句话,你需要一个工具而不是手工操作。
解决方案文件
程序员在调试时,是自己建立一个解决方案文件,当他需要调试某个项目时,把他加入进来,如果调试完毕,从解决方案中移除即可。这样做显而易见的好处是内存消耗更小,编译和运行更加的快速。
此解决方案并不需要上传到TFS服务器上,那如果要编译所有的解决方案该怎么办呢?通常,我们的“项目工具”,提供一个功能,扫描产品单元的源代码目录,将所有的项目集合到一个解决方案中,分析依赖关系,最后自动创建一个all.sln文件,我们就是编译这个项目文件,TFS也是编译这个解决方案。
在VS中,新增一个Project也是通过这个工具完成的,而不是使用VS自带的新增项目功能,他主要是完成以下功能:
- 自动设置项目的输出等信息;
- 包含产品统一的版权申明,版本等信息;
- 签出all.sln文件,并加入这个项目到解决方案;
这个工具的界面看起来这个样子的,这仅是一个示例,你可以为了易用性,增加诸如搜索这样的功能。
在大型项目中,Project文件非常的多,如果没有很好的规划,会造成诸如dll版本不一致,打开的Project太多编译缓慢的问题,这都直接影响了工作效率。
下面我将简要介绍我们在开发大型项目中,Visual Studio Project是如何规划的,从而规避很多的问题,提高工作效率。(注:此文仅适用适用Visual Studio开发的项目,对于其他项目可能需要修改)
下面的所有实践都需要一个小工具,我叫他“项目工具”,这里我没有办法提供此工具,但你们的团队完全可以按照文章中的实践方案编写出自己的软件。
规律
在任何软件项目的活动中,均遵循下面的规律:

这里的产品单元你可以认为是一个较大的,独立管理的单元,在一个产品单元中,我们总是引用一些已有成果,例如C#项目中引用第三方组件。产品单元最重要的部分是源代码,我们会不断修改他,然后通过编译的动作,又输出成新成果。当确定无误后,我们通过“发布”的动作又发布给其他的产品单元。例如产品单元1是我们的平台组件,而产品单元2是我们具体的产品。
下图演示了一个典型的产品单元活动流程(图中每个椭圆就是一个产品单元),注意,后面我们讨论到版本等活动时,会变的更复杂一些。

平台时时刻刻都在编译,但你可以选择某个稳定的时候发版给平台测试,当平台测试OK后,他就又可以发布到产品中,产品开始使用新版本的平台,同样的,你可以自由选择什么时候打包,然后交付给产品测试。当然,你可以修改为产品到产品测试,然后才是打包。这个就随你自愿了。
目录规划
在某个产品单元下,其目录的规划非常简单,根目录来看,仅包含bin和src目录。

这么简单吗?是的,为什么要搞那么复杂。我想你会又一堆的问题出现,好吧,我一一解释。
外部已有成果(例如第三方组件)和新成果(当前产品单元编译的结果)都混在在bin目录下吗?
是的,这样做有几个好处,首先当使用者获取此目录后,即可获取所有最新的组件,要知道,往往编译一个大的产品单元是很长时间的,我们一般每天两次在服务器上编译完毕后更新TFS上的此目录,这样程序员就可以免去很多编译的时间。其次,外部组件和内部组件都放在bin的话,程序员引用dll就很方便,不必费力的在多个目录去引用,这时我最喜欢这样设计的目的。
bin下为什么没有debug和release目录
是的,正如我第一点所说,我不希望C# project在引用dll时还要如此复杂。当我真的需要一个release版本时,我通常只是调整编译参数,编译,覆盖现有组件,最后上传tfs,通过某个标签我就知道哪个是release版本。
(这里的tfs指的是Microsoft Team Foundation Server,如果你使用SVN这样的工具,本文也同样适用)
当然,如果你是一个偏执狂,我可以告诉你一个秘诀,使用记事本打开你的project源代码,然后像我这样修改,就可以动态引用一个dll了。

好吧,我有些扯远了,在下面的项目规划上我会再详细说的。
src目录存放所有源代码,我们现有的方式是src下一级存放大模块,例如薪资、供应链、财务等,然后在此目录下再建各个项目,这样不至于上千的项目放在一个目录下难于查找。 你可能会问,为什么不将这些模块作为不同的产品单元,我当然想了,可是我不得不承认一件事情,这些模块都是互相引用,很难说谁更底层一些,虽然我们可以将这些模块的接口和实体部分提取出来作为独立的产品单元,可是管理成本也是我们在乎的,所以我们将他们放在一个产品单元,只不过通过建立两级目录来隔离一下。
那么,我的设计文档、帮助文档放在哪里?
可以放在src平级,也可以放在src的子目录,这个要看个人喜好了,我没有发现这两种方式有什么优劣势。我自己喜欢src平级的目录,因为我不喜欢目录太深。
当多个产品单元需要维护时,建立多个平齐的根目录即可(就是上例的Demo Solution)。
本人属马,性格当然也就比较像了,昨天突然和老婆聊起住过多少地方,数了一下,哇,怎么住了那么多的地方,真是浪费钱财。
先看一副城市迁移图:

在安徽宁国,我的老家住过两个地方,就不画图了。下图是广州的。

最初住在公司安排的宿舍,条件非常的好,可惜不知天高地厚,没有珍惜离开了公司。就开始了城中村的不断迁徙,算算应该是8个地方。
后来跑到上海,也办了很多次家

刚开始在浦东,最后跑到昆山花桥,跨度很大,所以不得不搞个放大图,得益于老婆最终决定买个房子,在宏图国际算是稳定了很长一段时间。
在后来,我又折腾到深圳。

刚到深圳住在公司宿舍桃源村,算是打头阵,老婆来了之后搬到南头,没过几个月就把昆山的房子买了,在龙华买了一个小产权房,算是安定下来。
本来一切安好,我们也生活的无比幸福。结果,我又开始折腾了,这次是去南京。第19个地方... 前一段时间已经买好房,第20个地方。
写完这些,我能说什么呢?
我好能折腾。
第二,我老婆真好,陪我折腾。但我不能再折腾了,扎实做一件事情吧。









