-
.Net 开发规范
一、C# 编码规范
1. 代码组织与风格
1.1. Tab
- 要使一个Tab为4个空格长。
1.2. 缩进
- 要使一个代码块内的代码都统一缩进一个Tab长度。
1.3. 空行
建议适当的增加空行,来增加代码的可读性。
在在类,接口以及彼此之间要有两行空行:
在下列情况之间要有一行空行:
方法之间;
局部变量和它后边的语句之间;
方法内的功能逻辑部分之间;
1.4. 函数长度
- 每个函数有效代码(不包括注释和空行)长度不要超过50行。
1.5. 大括号
开括号“{”要放在块的所有者的下一行,单起一行;
闭括号“}”要单独放在代码块的最后一行,单起一行。
1.6. 行宽
- 每行代码和注释不要超过70个字符或屏幕的宽度,如超过则应换行,换行后的代码应该缩进一个Tab。
1.7. 空格
括号和它里面的字符之间不要出现空格。括号应该和它前边的关键词留有空格,如:while (true) {};
但是方法名和左括号之间不要有空格。
参数之间的逗号后要加一空格。如:method1(int i1, int i2)
for语句里的表达式之间要加一空格。如:for (expr1; expr2; expr3)
二元操作符和操作数之间要用空格隔开。如:i + c;
强制类型转换时,在类型和变量之间要加一空格。如:(int) i ;
2. 注释
2.1. 注释的基本约定
注释应该增加代码的清晰度;
保持注释的简洁,不是任何代码都需要注释的,过多的注释反而会影响代码的可读性。
注释不要包括其他的特殊字符。
建议先写注释,后写代码,注释和代码一起完成
如果语句块(比如循环和条件分枝的代码块)代码太长,嵌套太多,则在其结束“}”要加上注释,标志对应的开始语句。如果分支条件逻辑比较复杂,也要加上注释。
在VS环境中通过配置工程编译时输出XML文档文件可以检查注释的完整情况,如果注释不完整会报告编译警告;
2.2. 注释类型
2.2.1. 块注释
- 主要用来描述文件,类,方法,算法等,放在所描述对象的前边。具体格式以IDE编辑器输入“///”自动生成的格式为准,另外再附加我们自定义的格式,如下所列:
/// <Remark作者,创建日期,修改日期</ Remark
对类和接口的注释必须加上上述标记,对方法可以视情况考虑
2.2.2. 行注释
- 主要用在方法内部,对代码,变量,流程等进行说明。整个注释占据一行。
2.2.3. 尾随注释
- 与行注释功能相似,放在代码的同行,但是要与代码之间有足够的空间,便于分清。例:
int m = 4 ; // 注释
- 如果一个程序块内有多个尾随注释,每个注释的缩进要保持一致。
2.3. 注释哪些部分
项目 注释哪些部分 参数**** 参数用来做什么任何约束或前提条件**** 字段/属性 字段描述 类 类的目的已知的问题类的开发/维护历史 接口 目的它应如何被使用以及如何不被使用 局部变量 用处/目的 成员函数注释 成员函数做什么以及它为什么做这个哪些参数必须传递给一个成员函数成员函数返回什么已知的问题任何由某个成员函数抛出的异常成员函数是如何改变对象的包含任何修改代码的历史如何在适当情况下调用成员函数的例子适用的前提条件和后置条件 成员函数内部注释 控制结构代码做了些什么以及为什么这样做局部变量难或复杂的代码处理顺序 2.4. 程序修改注释
新增代码行的前后要有注释行说明,对具体格式不作要求,但必须包含作者,新增时间,新增目的。在新增代码的最后必须加上结束标志;
删除代码行的前后要用注释行说明,删除代码用注释原有代码的方法。注释方法和内容同新增;删除的代码行建议用#region XXX #endregion 代码段折叠,保持代码文件干净整洁
修改代码行建议以删除代码行后再新增代码行的方式进行(针对别人的代码进行修改时,必须标明,对于自己的代码进行修改时,酌情进行)。注释方法和内容同新增;
3. 命名
3.1. 命名的基本约定
要使用可以准确说明变量/字段/类的完整的英文描述符,如firstName。对一些作用显而易见的变量可以采用简单的命名,如在循环里的递增(减)变量就可以被命名为 ” i ”。
要尽量采用项目所涉及领域的术语。
要采用大小写混合,提高名字的可读性。为区分一个标识符中的多个单词,把标识符中的每个单词的首字母大写。不采用下划线作分隔字符的写法。有两种适合的书写方法,适应于不同类型的标识符:
PasalCasing:标识符的第一个单词的字母大写;
camelCasing:标识符的第一个单词的字母小写。
下表描述了不同类型标识符的大小写规则:
标识符 大小写 示例 命名空间 Pascal namespace Com.Techstar.ProductionCenter 类型 Pascal public class DevsList 接口 Pascal public interface ITableModel 方法 Pascal public void UpdateData() 属性 Pascal Public int Length{…} 事件 Pascal public event EventHandler Changed; 私有字段 Camel private string fieldName; 非私有字段 Pascal public string FieldName; 枚举值 Pascal FileMode{Append} 参数 Camel public void UpdateData(string fieldName) 局部变量 Camel string fieldName; 避免使用缩写,如果一定要使用,就谨慎使用。同时,应该保留一个标准缩写的列表,并且在使用时保持一致。
对常见缩略词,两个字母的缩写要采用统一大小写的方式(示例:ioStream,getIOStream);多字母缩写采用首字母大写,其他字母小写的方式(示例:getHtmlTag);
避免使用长名字(最好不超过 15 个字母)。
避免使用相似或者仅在大小写上有区别的名字。
3.2. 各种标示符类型的命名约定
3.2.1. 程序集命名
- 公司域名(Techstar)+ 项目名称 + 模块名称(可选),例如:
中心系统程序集:Techstar.ProductionCenter;
中心系统业务逻辑程序集:Techstar. ProductionCenter.Business;
3.2.2. 命名空间命名
- 采用和程序集命名相同的方式:公司域名(Techstar)+ 项目名称 + 模块名称。 另外,一般情况下建议命名空间和目录结构相同。例如:
中心系统:Techstar.ProductionCenter;
中心系统下的用户控件:Techstar.ProductionCenter.UserControl;
中心系统业务逻辑:Techstar. ProductionCenter.Business;
中心系统数据访问:Techstar. ProductionCenter.Data;
3.2.3. 类和接口命名
类的名字要用名词;
避免使用单词的缩写,除非它的缩写已经广为人知,如HTTP。
接口的名字要以字母I开头。保证对接口的标准实现名字只相差一个“I”前缀,例如对IComponent的标准实现为Component;
泛型类型参数的命名:命名要为T或者以T开头的描述性名字,例如:
public class List<T public class MyClass<TSession
- 对同一项目的不同命名空间中的类,命名避免重复。避免引用时的冲突和混淆;
3.2.4. 方法命名
第一个单词一般是动词
如果方法返回一个成员变量的值,方法名一般为Get+成员变量名,如若返回的值 是bool变量,一般以Is作为前缀。另外,如果必要,考虑用属性来替代方法,具 体建议见10.1.2节;
如果方法修改一个成员变量的值,方法名一般为:Set + 成员变量名。同上,考虑 用属性来替代方法;
3.2.5. 变量命名
- 按照使用范围来分,我们代码中的变量的基本上有以下几种类型,类的公有变量;类的私有变量(受保护同公有);方法的参数变量;方法内部使用的局部变量。这些变量的命名规则基本相同,见标识符大小写对照表。区别如下:
i. 类的公有变量按通常的方式命名,无特殊要求;
ii. 类的私有变量采用两种方式均可:采用加“m”前缀,例如mWorkerName;
iii. 方法的参数变量采用camalString,例如workerName;
iv. 方法内部的局部变量采用camalString,例如workerName;
不要用_或&作为第一个字母;
尽量要使用短而且具有意义的单词;
单字符的变量名一般只用于生命期非常短暂的变量。i,j,k,m,n一般用于integer;c,d,e 一般用于characters;s用于string
如果变量是集合,则变量名要用复数。例如表格的行数,命名应为:RowsCount;
命名组件要采用匈牙利命名法,所有前缀均应遵循同一个组件名称缩写列表
3.3.组件名称缩写列表
缩写的基本原则是取组件类名各单词的第一个字母,如果只有一个单词,则去掉其中的元音,留下辅音。缩写全部为小写。
组件类型 缩写 例子 Label Lbl lblNote TextBox Txt txtName Button Btn btnOK ImageButton Ib ibOK LinkButton Lb lbJump HyperLink Hl hlJump DropDownList Ddl ddlList CheckBox Cb cbChoice CheckBoxList Cbl cblGroup RadioButton Rb rbChoice RadioButtonList Rbl rblGroup Image Img imgBeauty Panel Pnl pnlTree TreeView Tv tvUnit WebComTable Wct wctBasic ImageDateTimeInput Dti dtiStart ComboBox Cb cbList MyImageButton Mib mibOK WebComm.TreeView Tv tvUnit PageBar Pb pbMaster 4. 声明
每行要只有一个声明,如果是声明i,j,k之类的简单变量可以放在一行;
除了for循环外,声明要放在块的最开始部分。for循环中的变量声明可以放在for语句中。如:
for(int i = 0; I < 10; i++) { }
- 避免块内部的变量与它外部的变量名相同。****
5. 表达式和语句
每行建议只有一条语句。
if-else,if-elseif语句,任何情况下,都应该有“{”,“}”,格式如下:
if (*condition*) { *statements*; } else if (*condition*) { *statements*; } else { *statements*; }
- for语句格式如下:
for (*initialization*; *condition*; *update*) { *statements*; }
- while语句格式如下:
while (*condition*) { *statements*; }
- do-while语句格式如下:
do { *statements*; }while (*condition*);
- switch语句,每个switch里都应包含default子语句,格式如下:
switch (*condition*) { case ABC: *statements*; /* 不含有break 错误 */ case DEF: *statements*; break; case XYZ: *statements*; break; default: *statements*; break; }
- try-catch语句格式如下:
try { *statements*; } catch (ExceptionClass e) { *statements*; } finally { *statements;* }
6. 类型设计规范
- 要确保每个类型由一组定义明确,相互关联的成员组成,而不仅仅是一些无关功能的随 机集合;
6.1. 类型和命名空间
- 要用命名空间把类型组织成相关域的层次结构。例如:
界面层:Techstar.ProductionCenter;
业务逻辑层:Techstar.ProductionCenter.Business;
数据访问层:Techstar.ProductionCenter.Data;
避免过深的命名空间;
避免太多的命名空间;
6.2. 类型和接口的选择
- 要优先采用类而不是接口。
接口的缺点在于语义变化时改变困难。注意接口并不是协定,把协定和实现分开并非一 定用接口实现,用基类和抽象类同样可以表达;
建议使用抽象类而不是接口来解除协定与实现间的偶合;
要定义接口,来实现类似多重继承的效果;
精心定义接口的标志是一个接口只做一件事情。关键是接口的协定需要保持不变, 如果一个接口包含太多功能,那么这个胖接口产生变化的机会就会大得多。
6.3. 抽象类设计:
不要在抽象类中定义公有的或内部受保护的构造函数。因为抽象类无法实例化,所以这 种设计会误导用户;
要为抽象类定义受保护的构造函数或内部构造函数;
6.4. 静态类设计
静态类是一个只包含静态成员的类,它提供了一种纯面向对象设计和简单性之间的一个权衡,广泛用来提供类似于全局变量或一些通用功能。
要少用静态类。静态类应该仅用作辅助类;
避免把静态类当作杂物箱。每个静态类都应该有其明确目的;
不要在静态类中声明或覆盖实例成员;
6.5. 枚举设计
要用枚举来加强那些表示值的集合的参数,属性以及返回值的类型性;
要优先使用枚举而不是静态常量。例如:
//不好的写法 public static class Color { public static int Red = 0; public static int Green = 1; public static int Blue = 2; } //好的写法 public enum Color { Red, Green, Blue }
不要把枚举用于开放的场合,例如操作系统的版本,朋友的名字等;
枚举最后一个值不要加逗号;
枚举中不要提供为了今后使用而保留的枚举值;
7. 成员设计规范
方法,属性,事件,构造函数以及字段等统称为成员。
7.1. 成员设计的一般规范
7.2. 方法的重载规范;
- 避免在重载中随意的给参数命名。如果两个重载中的某个参数表示相同的输入,那么该参数的名字应该相同。例如:
public class String { //好的写法 public int IndexOf(string value) { ...} public int IndexOf(string value, int startIndex) { ...} //不好的写法 public int IndexOf(string value) { ...} public int IndexOf(string str, int startIndex) { ...} }
- 避免使重载成员的参数顺序不一致。在所有的重载中,同名参数应该出现在相同的位置。 例如:
public class EventLog
{
public EventLog();
public EventLog(string logName);
public EventLog(string logName, string machineName);
public EventLog(string logName, string machineName, string source);
}
- 较短的重载应该仅仅调用较长的来实现。另外,重载如果需要扩展性,把最长重载 做成虚函数。例如:
public class String
{
public int IndexOf(string s)
{
//调用
return IndexOf(s, 0);
}
public int IndexOf(string s, int startIndex)
{
//调用
return IndexOf(s, startIndex, s.Length);
}
public virtual int IndexOf(string s, int startIndex, int Count)
{
//实际的代码
}
}
- 要允许可选参选为null。这样做是为了避免调用者调用之前需要检查参数是否null。例 如:
//允许为null时的调用
DrawGeometry(brush, pen, geometry);
//不允许为null时的调用
if (geometry == null) {
DrawGeometry(brush, pen);
}
else{
DrawGeometry(brush, pen, geometry);
}
7.3. 属性和方法的选择
基本原则是方法表示操作,属性表示数据。如果其他各方面都一样,优先使用属性而不 是方法。
要使用属性,如果该成员表示类型的逻辑attribue
如果属性的值存储在内存中,而提供属性的目的仅仅是为了访问该值,要使用属性而不 要使用方法
如果该操作每次返回的结果不同,那么要使用方法。例如来自于.net framework的例子:
//好的写法
Guid.NewGuid();
//不好的写法
DateTime.Now;
如果该操作比访问字段慢一个或多个数量级,要使用方法。
如果该操作有严重的副作用,要使用方法。
7.4. 属性的设计规范:
如果不应该让调用方法改变属性值,要创建只读属性;
不要提供只写属性;
要为所有的属性提供合理的默认值,这样可以确保默认值不会导致漏洞或效率低的代 码;
要允许用户以任何顺序来设置属性的值;
避免在属性的获取方法抛出异常。
属性的获取方法应该是个简单的操作,不应该有任何的条件。如果一个获取方法会抛出 异常,按么可能它更应该设计为方法。
7.5. 构造函数的设计规范
建议提供简单的构造函数,最好是默认构造函数。简单的构造函数增强易用性;
考虑扩展性,如果构造函数设计的不自然,建议用静态的工厂方法来替代构造函数;
要把构造函数的参数用作设置主要属性的便捷方法。如果构造函数参数仅用来设置属 性,应和属性名称相同。仅有大小写的区别;
要在构造函数中做最少的工作。任何其他处理应该推迟到需要的时候;
要在类中显示的声明公用的默认构造函数,如果这样的构造函数是必须的。
如果没有显示默认构造函数,填加有参数构造函数时往往会破坏已有使用默认构造函数 的代码;
- 避免在对象的构造函数内部调用虚成员。这样在扩展设计的时候会导致难以理解的现 象;
7.6. 字段设计规范
不要提供公有的或受保护的字段。代之以属性来访问字段;
要只用常量字段来表示永远不会改变的量。否则会导致兼容性问题。下面是正确的例子:
public struct Int32
{
public const int MaxValue = 0x7fffffff;
public const int MinValue = unchecked((int)0x80000000);
}
- 要用公有的静态只读字段来定义预定义的对象实例。例如:
public struct Color
{
public static readonly Color Red = new Color(0x0000FF);
}
7.7. 参数的设计规范
- 要用类结构层次中最接近基类类型来作为参数的类型,同时要保证该类型能够提供成员 所需的功能。例如:
要设计一个集合遍历的方法,那么参数应该是IEnbumerable为参数,而不应该是IList, 这样方法具有更强的适应性。
- 不要使用保留参数。如果将来需要更多的参数,那么可以增加重载成员。例如:
//不好的写法
public void Method(string reserved, SomeOption option);
//好的写法
public void Method(SomeOption option);
//将来填加
public void Method(SomeOption option, string path);
7.7.1. 参数设计中枚举和布尔参数的选择规范
- 要用枚举。在代码阅读,书写中,枚举都比布尔的可读性好很多。例如:
//使用布尔型,阅读的时候不会轻易了解参数的含义
FileStream f = File.Open(“1.txt”, true, false);
//使用枚举型
FileStream f = File.Open(“1.txt”,CasingOptions.CaseSenstive, FileMode.Open);
不要使用布尔参数,除非百分之百肯定绝对不需要两个以上的值。即使此时,采用枚举 往往也可以提供更好的可读性,如上例。
考虑在构造函数中,对确实只有两种状态值的参数以及用来初始化布尔属性的参数使用 布尔类型;
7.7.2. 参数验证的规范:
要验证传给公有的,受保护的或显示成员的参数是否合法。如果验证失败,应该抛出 System.ArgutmentException或其子类;
要抛出System.ArgutmentNullException,如果传入的null,而该成员不支持null;
7.7.3. 参数传递的规范:
- 避免使用输出参数或引用参数;
8. 扩展性设计规范
- 如果没有恰当理由,不要把类密封起来。这些理由包括:
A)类为静态类;
B)类的受保护成员保存了高度机密信息;
C)类继承了许多虚成员,逐个密封的代价太高,不如密封整个类;
D)不要在密封类中声明保护成员或虚成员,因为无法覆盖其实现;
建议用保护成员用于高级定制。它提供了扩展性,同时也避免了公用接口过于复杂;
不要使用虚成员,除非有合适的理由;
建议只有在绝对必须的时候才用虚成员提供扩展性,并使用Template Method模式;
要优先使用受保护的虚成员,而不是公有虚成员。公有成员通用调用受保护的虚成员的方式来提供扩展性;
9. 异常处理规范
异常的思想是只对错误采用异常处理:逻辑和编程错误,设置错误,被破坏的数据,资源耗尽,等等。通常的法则是系统在正常状态下以及无重载和硬件失效状态下,不应产生任何异常。异常处理时可以采用适当的日志机制来报告异常,包括异常发生的时刻;
一般情况下不要使用异常实现来控制程序流程结构;
使用异常而不要用错误代码来报告错误;
要通过抛出异常的方式来报告操作失败。如果成员无法成功地完成它应该做的任务,那么应该抛出异常;
9.1. 异常类型选择规范
优先考虑使用System命名空间中已有的异常,而不是自己创建新的异常类型;
要使用最合理,最具针对性的异常。例如,对参数为空,应抛出 System.ArgutmentNullException,而不是System.ArgutmentException
9.2. 异常处理规范
不是百分之百确定的情况,不要吞掉异常;
建议捕获特定类型的异常,如果理解该异常在具体环境当中产生的原因;
不要捕获不应该捕获的异常,通常应该允许异常沿着调用栈传递;
进行清理工作时要用try-finally,避免使用try-catch;
要在捕获并重新抛出异常时使用空的throw语句,这是保持调用栈的最好方法
9.3.标准异常类的使用**
9.3.1. Exception与SystemException
不要抛出这两种类型的异常;
避免捕获这两种异常,除非是在顶层的异常处理器中;
9.3.2. InvalidOperationException
- 对象处于不正确状态时抛出;
9.3.3. ArgumentException,ArgumentNullException,ArgumentOutOfRangeException
如果传入的是无效参数,要抛出参数异常,尽可能使用位于继承层次末尾的类型;
要在抛出异常时设置ParaName属性;
9.3.4. NullRefernceException,IndexOutOfRangeException,AccessViolationException
- 不要显示抛出或捕获;
9.3.5. StackOverflowException
- 不要显示抛出或捕获;
9.3.6. OutOfMemoryException
- 不要显示抛出或捕获;
9.4. 自定义异常类型设计规则
避免太深的继承层次;
要从已有的异常基类继承;
异常类要以“Exception”做为后缀;
要使异常可序列化,使其能跨应用程序域和远程边界仍能正常使用;
要把与安全性有关的信息保存在私有的异常状态中
9.5. 异常与性能
- 如果在普通场景都会抛出异常,要采用先效验合法性的方式来避免抛出异常引起的性能 问题;
10. 其他规定
为避免频繁改动代码,代码中只写比较简单的和不会经常发生变化的SQL,如果SQL 经常发生变化或是比较复杂,存到SysMisc中,比如统计用到的SQL;
在VS开发环境中,采用代码分析工具来做自动化的代码分析,以保证代码质量, 具体的使用建议如下:
A)启用代码分析,并设置当风格不符合要求时为错误而不是警告;
B)如果不是做代码审核,此开关应关闭。加上了这个选项的时候编译很慢;
C)详设的时候打开开关,检查详设是否符合编程规范;
D)所有的选项都应当打开。以下内容需要单独设置:
编码 | 名称 | 大类 | 建议 | 使用等级 |
---|---|---|---|---|
CA2209 | 程序集应声明最小安全性 | 用法规则 | 不建议使用 | 警告 |
CA1814 | 与多维数组相比,首选使用交错的数组 | 性能规则 | 使用,但降低等级 | 警告 |
CA1822 | 将成员标记为 static | 性能规则 | 较繁锁,且影响代码质量 | 禁用 |
CA2210 | 程序集应具有有效的强名称 | 设计规则 | 影响Xcopy部署 | 禁用 |
CA1302 | 不要对区域设置特定的字符串进行硬编码 | 全球化规则 | 很繁琐,并且工具支持的不好。全球化规则全部禁用 | 禁用 |
CA2100 | 检查 Sql 查询中是否有安全漏洞 | 安全性规则 | 都采用参数化查询,有可能会参数过长;如果是内部参数,也不会有安全问题 | 警告 |
二、项目系统多层结构设计使用规范
1. 层的实现基础
层是通过多组软件模块之间经过规划的调用来实现的。
层就是一群负有特定使命,提供特定服务的软件模块。
2. 多层结构设计原则
2.1 可重用性
由于业务层的独立存在,那么业务层中的各种服务可以被不同的应用程序使用。
将组件的重用范围从以前的的单个项目扩展到企业范围内的多个项目。
2.2 配置的灵活性
由于软件系统被分成了独立的三层或多层,可以按照企业业务的功能需求和
性能需求灵活配置各层次的物理位置,功能划分,计算机数量等,为需求迅速增长的分布式
应用提供了实现基础。例如不同的部门的不同应用可以连接到不同的应用服务器上。各个层
次还可单独选择最恰当的开发工具。
2.3 配置的灵活性
开发并行性:由于层与层之间是采用基于服务的存取(接口调用),并且是独立存在的,所
以各层可以在约定好的接口下并行开发。
2.4 系统进化的容易度
当软件需求变更时或技术进步时,只需要更改相应层中的组件,在保证
接口不变的情况下,不会影响系统的其他部分,也不需重新测试系统的其他部分。而且层与
层之间采用基于服务的调用方式,所以,当业务规则变化时,只要更新单一的业务层,运行
表示层的各个客户端便自动地获得按照新的业务规则处理的能力。在基于业务层服务的基础
上,可以采用渐增的方式增加客户端应用种类和接入媒体,因而系统可扩展性很好。
3. 分层结构使用原则
上层使用下层提供的服务,且仅通过调用层次间的特定接口获取下层服务,下层暴露特定接口为上层提供特定服务,且不依赖于上层,也不知道上层的存在。下层与相邻上层之间为一对多的关系,即同一个下层可能为不同的上层提供服务。
三、单元测试编写原则
1.保持单元测试小巧, 快速
理论上, 任何代码提交前都应该完整跑一遍所有测试套件. 保持测试代码执行迅捷能够缩短迭代开发周期.
2.单元测试应该是全自动且无交互
测试套件通常是定期执行的, 执行过程必须完全自动化才有意义. 需要人工检查输出结果的测试不是一个好的单元测试.
3.让单元测试很容易跑起来
对开发环境进行配置, 最好是敲条命令或是点个按钮就能把单个测试用例或测试套件跑起来.
4.对测试进行评估
对执行的测试进行覆盖率分析, 得到精确的代码执行覆盖率, 并调查哪些代码未被执行.
5.立即修正失败的测试
每个开发人员在提交前都应该保证新的测试用例执行成功, 当有代码提交时, 现有测试用例也都能跑通.
如果一个定期执行的测试用例执行失败, 整个团队应该放下手上的工作优先解决这个问题.
6.把测试维持在单元级别
单元测试即类 (Class) 的测试. 一个 "测试类" 应该只对应于一个 "被测类", 并且 "被测类" 的行为应该被隔离测试. 必须谨慎避免使用单元测试框架来测试整个程序的工作流, 这样的测试既低效又难维护. 工作流测试 (译注: 指跨模块/类的数据流测试) 有它自己的地盘, 但它绝不是单元测试, 必须单独建立和执行.
7.由简入繁
最简单的测试也远远胜过完全没有测试. 一个简单的 "测试类" 会促使建立 "被测类" 基本的测试骨架, 可以对构建环境, 单元测试环境, 执行环境以及覆盖率分析工具等有效性进行检查, 同时也可以证明 "被测类" 能够被整合和调用.
下面便是单元测试版的 Hello, world! :
void testDefaultConstruction()
{
Foo foo = new Foo();
assertNotNull(foo);
}
8.保持测试的独立性
为了保证测试稳定可靠且便于维护, 测试用例之间决不能有相互依赖, 也不能依赖执行的先后次序.
9.合理的命名测试用例
确保每个方法只测试 "被测类" 的一个明确特性, 并相应的命名测试方法. 典型的命名俗定是 test[what]
, 比如 testSaveAs()
, testAddListener()
, testDeleteProperty()
等.
10.只测公有接口
单元测试可以被定义为 通过类的公有 API 对类进行测试. 一些测试工具允许测试一个类的私有成员, 但这种做法应该避免, 它让测试变得繁琐而且更难维护. 如果有私有成员确实需要进行直接测试, 可以考虑把它重构到工具类的公有方法中. 但要注意这么做是为了改善设计, 而不是帮助测试.
11.看成是黑盒
站在第三方使用者的角度, 测试一个类是否满足规定的需求. 并设法让它出问题.
12.看成是白盒
毕竟被测试类是程序员自写自测的, 应该在最复杂的逻辑部分多花些精力测试.
13.芝麻函数也要测试
通常建议所有重要的函数都应该被测试到, 一些芝麻方法比如简单的 setter
和 getter
都可以忽略. 但是仍然有充分的理由支持测试芝麻函数:
芝麻 很难定义. 对于不同的人有不同的理解.
从黑盒测试的观点看, 是无法知道哪些代码是芝麻级别的.
-
即便是再芝麻的函数, 也可能包含错误, 通常是 "复制粘贴" 代码的后果:
private double weight_; private double x_, y_; public void setWeight(int weight) { weight = weight_; // error } public double getX() { return x_; } public double getY() { return x_; // error }
因此建议测试所有方法. 毕竟芝麻用例也容易测试.
14.先关注执行覆盖率
区别对待 执行覆盖率 和 实际测试覆盖率. 测试的最初目标应该是确保较高的执行覆盖率. 这样能保证代码在 少量 参数值输入时能执行成功. 一旦执行覆盖率就绪, 就应该开始改进测试覆盖率了. 注意, 实际的测试覆盖率很难衡量 (而且往往趋近于 0%).
思考以下公有方法:
void setLength(double length);
调用 setLength(1.0)
你可能会得到 100% 的执行覆盖率. 但要达到 100% 的实际测试覆盖率, 有多少个 double
浮点数这个方法就必须被调用多少次, 并且要一一验证行为的正确性. 这无疑是不可能的任务.
15.覆盖边界值
确保参数边界值均被覆盖. 对于数字, 测试负数, 0, 正数, 最小值, 最大值, NaN (非数字), 无穷大等. 对于字符串, 测试空字符串, 单字符, 非 ASCII 字符串, 多字节字符串等. 对于集合类型, 测试空, 1, 第一个, 最后一个等. 对于日期, 测试 1月1号, 2月29号, 12月31号等. 被测试的类本身也会暗示一些特定情况下的边界值. 要点是尽可能彻底的测试这些边界值, 因为它们都是主要 "疑犯".
16.提供一个随机值生成器
当边界值都覆盖了, 另一个能进一步改善测试覆盖率的简单方法就是生成随机参数, 这样每次执行测试都会有不同的输入.
想要做到这点, 需要提供一个用来生成基本类型 (如: 浮点数, 整型, 字符串, 日期等) 随机值的工具类. 生成器应该覆盖各种类型的所有取值范围.
如果测试时间比较短, 可以考虑再裹上一层循环, 覆盖尽可能多的输入组合. 下面的例子是验证两次转换 little endian 和 big endian 字节序后是否返回原值. 由于测试过程很快, 可以让它跑上个一百万次.
void testByteSwapper()
{
for (int i = 0; i < 1000000; i++) {
double v0 = Random.getDouble();
double v1 = ByteSwapper.swap(v0);
double v2 = ByteSwapper.swap(v1);
assertEquals(v0, v2);
}
}
17.每个特性只测一次
在测试模式下, 有时会情不自禁的滥用断言. 这种做法会导致维护更困难, 需要极力避免. 仅对测试方法名指示的特性进行明确测试.
因为对于一般性代码而言, 保证测试代码尽可能少是一个重要目标.
18.使用显式断言
应该总是优先使用 assertEquals(a, b)
而不是 assertTrue(a == b)
, 因为前者会给出更有意义的测试失败信息. 在事先不确定输入值的情况下, 这条规则尤为重要, 比如之前使用随机参数值组合的例子.
19.提供反向测试
反向测试是指刻意编写问题代码, 来验证鲁棒性和能否正确的处理错误.
假设如下方法的参数如果传进去的是负数, 会立马抛出异常:
void setLength(double length) throws IllegalArgumentExcepti
可以用下面的方法来测试这个特例是否被正确处理:
try {
setLength(-1.0);
fail(); // If we get here, something went wrong
}
catch (IllegalArgumentException exception) {
// If we get here, all is fine
}
20.代码设计时谨记测试
编写和维护单元测试的代价是很高的, 减少代码中的公有接口和循环复杂度是降低成本, 使高覆盖率测试代码更易于编写和维护的有效方法.
一些建议:
使类成员常量化, 在构造函数中进行初始化. 减少
setter
方法的数量.限制过度使用继承和公有虚函数.
通过使用友元类 (C++) 或包作用域 (Java) 来减少公有接口.
避免不必要的逻辑分支.
在逻辑分支中编写尽可能少的代码.
在公有和私有接口中尽量多用异常和断言验证参数参数的有效性.
限制使用快捷函数. 对于黑箱而言, 所有方法都必须一视同仁的进行测试. 思考以下简短的例子:
public void scale(double x0, double y0, double scaleFactor)
{
// scaling logic
}
public void scale(double x0, double y0)
{
scale(x0, y0, 1.0);
}
删除后者可以简化测试, 但用户代码的工作量也将略微增加.
21.不要访问预设的外部资源
单元测试代码不应该假定外部的执行环境, 以便在任何时候/任何地方都能执行. 为了向测试提供必需的资源, 这些资源应该由测试本身提供.
比如一个解析某类型文件的类, 可以把文件内容嵌入到测试代码里, 在测试的时候写入到临时文件, 测试结束再删除, 而不是从预定的地址直接读取.
22.权衡测试成本
不写单元测试的代价很高, 但是写单元测试的代价同样很高. 要在这两者之间做适当的权衡, 如果用执行覆盖率来衡量, 业界标准通常在 80% 左右.
很典型的, 读写外部资源的错误处理和异常处理就很难达到百分百的执行覆盖率. 模拟数据库在事务处理到一半时发生故障并不是办不到, 但相对于进行大范围的代码审查, 代价可能太大了.
23.安排测试优先次序
单元测试是典型的自底向上过程, 如果没有足够的资源测试一个系统的所有模块, 就应该先把重点放在较底层的模块.
24.测试代码要考虑错误处理
考虑下面的这个例子:
Handle handle = manager.getHandle();
assertNotNull(handle);
String handleName = handle.getName();
assertEquals(handleName, "handle-01");
如果第一个断言失败, 后续语句会导致代码崩溃, 剩下的测试都无法执行. 任何时候都要为测试失败做好准备, 避免单个失败的测试项中断整个测试套件的执行. 上面的例子可以重写成:
Handle handle = manager.getHandle();
assertNotNull(handle);
if (handle == null) return;
String handleName = handle.getName();
assertEquals(handleName, "handle-01");
25.写测试用例重现 bug
每上报一个 bug, 都要写一个测试用例来重现这个 bug (即无法通过测试), 并用它作为成功修正代码的检验标准.
26.了解局限
单元测试永远无法证明代码的正确性!!
一个跑失败的测试可能表明代码有错误, 但一个跑成功的测试什么也证明不了.
单元测试最有效的使用场合是在一个较低的层级验证并文档化需求, 以及 回归测试: 开发或重构代码时,不会破坏已有功能的正确性.
四、RESTful API开发规范
1. 基本设计参考准则
RESTful是API接口一种设计思想指南, 一种风格; 过度纠结如何遵守规范只是徒增烦恼,也违背了使用它的初衷. 但是也应该参考几个基本准则, e.g:
- 当标准合理的时候遵守标准。
- API应该对程序员友好,并且在浏览器地址栏容易输入。
- API应该简单,直观,容易使用的同时优雅。
- API应该具有足够的灵活性来支持上层UI。
- API设计权衡上述几个原则。
我们在写API的时候, 要把API当做我们自己的UI来写, 那么
你的API越容易使用,那么就会有越多的人去用它
2. HTTP Methods
HTTP 四个常用方法:
GET: 获取某个资源。
POST: 创建一个新的资源。
PUT: 更新某个已有的资源。
DELETE:删除某个资源。
3. 命名规则建议
由于我们目前后端用C#, 命名就采用camelCase(骆驼命名) ,这样存在有些接口语义性不够强, 针对这种可以采用
-
符号来连接单词. (不要用_
符号)。URI 片段中尽量使用名词、避免使用动词,动作应该通过 get/post/put/delete... 来表述。
URI 片段中始终使用复数形式。
4. URI 设计指南建议
4.1 简单的单一对象CRUD:
GET /users
- 获取用户列表GET /users/1
- 获取 Id 为 1 的用户POST /users
- 创建一个用户PUT /users/1
- 更新 Id 为 1 的用户-
DELETE /users/1
- 删除 Id 为 1 的用户
4.2 关联/级联对象的:
GET /users/1/products
- 获取 Id 为 1 用户下的产品列表GET /users/1/products/2
- 获取 Id 为 1 用户下 Id 为 2 的产品POST /users/1/products
- 在 Id 为 1 用户下,创建一个产品PUT /users/1/products/2
- 在 Id 为 1 用户下,更新 Id 为 2 的产品-
DELETE /users/1/products/2
- 删除 Id 为 1 的用户下 Id 为 2 的产品
4.3 复杂参数查询的:
针对这种有比较多(一般建议3个以上)参数的接口, 应该考虑使用一个viewModel来接收参数
[ResponseType(typeof(PagedResult<ServiceOrderDto))]
public IHttpActionResult Get([FromUri]OwnerOrderQuery query)
{
var result = _ownerOrderSvc.GetOwnerOrderPage(query);
return Ok(result);
}
5. 接口返回类型声明
以.Net Web Api来说, 直接声明IHttpActionResult
, .Net Core的话IActionResult
, 由于我们要提api文档, 接口应该还要声明ResponseType
返回对象类型.
[Route("{id}/detail")]
[ResponseType(typeof(ServiceOrderDto))]
public IHttpActionResult Get(int id)
{
var result = _ownerOrderSvc.GetOwnerOrderDetail(id);
return Ok(result);
}
有一种不推荐的做法就是对接口返回值类型再包装一层, 这样违背了RESTful设计风格, response
的 body
就是数据数据. 不推荐做法示例 e.g:
{
"code":200,
"success":true,
"data":{"id":1,"name":"uoko"},
}
6.版本号
- 直接在URI加版本号, 比如
https://api.example.com/v1/
- 将版本号放在HTTP头信息中,Github采用这种做法。
7.常用状态码
- 200 OK - [GET]:服务器成功返回用户请求的数据,该操作是幂等的(Idempotent)。
- 201 CREATED - [POST/PUT]:用户新建或修改数据成功。
- 202 Accepted - [*]:表示一个请求已经进入后台排队(异步任务)
- 204 NO CONTENT - [DELETE]:用户删除数据成功。
- 400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作,该操作是幂等的。
- 401 Unauthorized - [*]:表示用户没有权限(令牌、用户名、密码错误)。
- 403 Forbidden - [*] 表示用户得到授权(与401错误相对),但是访问是被禁止的。
- 404 NOT FOUND - [*]:用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的。
- 406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)。
- 410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的。
- 422 Unprocesable entity - [POST/PUT] 当创建一个对象时,发生一个验证错误。
- 500 INTERNAL SERVER ERROR - [*]:服务器发生错误,用户将无法判断发出的请求是否成功。
对于我们来说需要知道 200, 400,401,403,404,500这几种的意思 状态码的完全列表见。
8. 文档&测试
目前我们使用MVC
的help doc
已经能满足自动生成文档, 同时也能做在线的接口测试, 后面可以考虑接入Swagger
更具体的编写api
接口单元测试, 见单元测试准则
9. Hypermedia API
Restful API 的设计最好做到 Hypermedia:在返回结果中提供相关资源的链接。这种设计也被称为 HATEOAS。这样做的好处是,调用者可以根据返回结果就能得到后续操作需要访问的地址。
比如访问 api.github.com,就可以看到 Github API 支持的资源操作。
10. 接口安全性
采用 OAuth2协议 提供身份认证