6. 三层架构
6.1. 简介
让我们来看看税费计算应用程序的最新版本:
using System;
namespace Chap3 {
class Program {
static void Main() {
// interactive tax calculator
// the user enters three data points on the keyboard: married nbEnfants salary
// the program then displays Tax payable
...
// creation of a IImpot object
IImpot impot = null;
try {
// creation of a IImpot object
impot = new FileImpot("DataImpotInvalide.txt");
} catch (FileImpotException e) {
// error display
...
// program stop
Environment.Exit(1);
}
// infinite loop
while (true) {
// tax calculation parameters are requested
Console.Write("Paramètres du calcul de l'Impot au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :");
string paramètres = Console.ReadLine().Trim();
...
// parameters are correct - Impot is calculated
Console.WriteLine("Impot=" + impot.calculer(marié == "o", nbEnfants, salaire) + " euros");
// next taxpayer
}//while
}
}
}
前面的解决方案包含经典的:
- 从文件、数据库等中检索数据,第12-21行
- 与用户交互,第26行(输入)和第29行(显示)
- 应用业务算法,第29行
实践经验表明,将这些不同的流程分离到不同的类中,可以提高应用程序的可维护性。采用这种结构的应用程序架构如下:
![]() |
这种架构被称为“三层架构”。“三层”一词通常指各层位于不同机器上的架构。当各层位于同一台机器上时,该架构即成为“三层架构”。
- [业务]层包含应用程序的业务规则。对于我们的税务计算应用程序而言,这些规则用于计算纳税人的税款。该层需要数据才能运行:
- 税率表(每年都会更新)
- 子女数量、婚姻状况以及纳税人的年薪
在上图中,数据可来自两个来源:
- 数据访问层(DAO,即数据访问对象),用于获取已存储在文件或数据库中的数据。例如,税率档次的数据就可能来自此处,这与应用程序的上一版本做法一致。
- 用户界面层([ui],即 UI = User Interface),用于处理用户输入或显示给用户的数据。例如,纳税人的子女数量、婚姻状况和年薪即属于此类情况
- 一般而言,[dao] 层负责处理对持久化数据(文件、数据库)或非持久化数据(网络、传感器等)的访问。
- [UI] 层负责处理与用户的交互(如有)。
- 这三个层通过接口的使用实现了相互独立。
我们将以之前多次研究过的应用程序 [Impots] 为例,为其构建一个三层架构。为此,我们将依次研究 [ui、metier、dao] 这三个层,首先从处理持久化数据的 [dao] 层开始。
首先,我们需要定义应用程序 [Impots] 各层的接口。
6.2. 应用程序接口 [Impots]
请记住,接口定义了一组方法签名。实现该接口的类为这些方法提供具体实现。
让我们回到应用程序的三层架构:
![]() |
在此类架构中,通常由用户发起操作。用户在 [1] 发出请求,并在 [8] 收到响应。这被称为请求-响应循环。以纳税人计算税款为例,这将需要几个步骤:
- [ui]层需要向用户询问子女数量、婚姻状况和年薪。这就是上文提到的操作[1]。
- 完成上述步骤后,[ui]层将请求业务层计算税款。为此,它会将从用户处接收到的数据进行传输。这就是操作[2]。
- [业务]层需要特定信息来完成工作:税率表。它将通过路径[3, 4, 5, 6]向[DAO]层请求此信息。[3]是初始请求,[6]是对此请求的响应。
- 获取所需全部数据后,[业务]层计算税额。
- 现在,[业务]层可以响应[UI]层在(b)中发出的请求。这就是路径[7]。
- [ui]层将对这些结果进行格式化处理并呈现给用户。这就是路径[8]。
- 我们可以设想用户进行税费模拟并希望保存结果。此时他将使用路径 [1-8] 来完成此操作。
此描述表明,一个层将使用其右侧层的资源,但绝不会使用其左侧层的资源。考虑两个相邻的层:
![]() |
层 [A] 向层 [B] 发起请求。在最简单的情况下,一个层由单个类实现。应用程序会随着时间推移而演变。因此,层 [B] 可能拥有不同的实现类 [B1, B2, ...]。如果层 [B] 是 [dao] 层,它可能有一个初始实现 [B1],该实现从文件中获取数据。 几年后,你可能希望将数据存入数据库。于是我们构建了第二个实现类 [B2]。如果最初的应用程序中,层 [A] 是直接与类 [B1] 交互的,那么我们不得不部分重写层 [A] 的代码。例如,假设层 [A] 原本是这样编写的:
- 第 1 行:创建了 [B1] 类的实例
- 第 3 行:向该实例请求数据
如果我们假设新的实现类 [B2] 使用的方法与类 [B1] 的方法具有相同的签名,那么我们就必须将所有的 [B1] 替换为 [B2]。这是一种非常理想的情况,如果你没有注意这些方法签名,这种情况发生的可能性非常小。 实际上,类 [B1] 和 [B2] 的方法签名往往并不一致,因此 [A] 层的大部分代码都必须完全重写。
通过在层 [A] 和 [B] 之间创建一个接口,可以改善这种情况。这意味着在接口中固定层 [B] 向层 [A] 提供的方法签名。此时,前面的图示将变为如下所示:
![]() |
层 [A] 不再直接引用层 [B],而是引用其接口 [IB]。因此,在层 [A] 的代码中,层 [B] 的实现类 [Bi] 仅在实现接口 [IB] 时出现一次。一旦完成这一操作,代码中使用的便是 [IB] 接口,而非其实现类。原代码变为如下形式:
- 第 1 行:通过实例化类 [B1] 创建了一个实现接口 [IB] 的实例 [ib]
- 第 3 行:从实例 [ib] 请求数据
现在,如果我们将层 [B] 的 [B1] 实现替换为 [B2] 实现,且这两个实现都遵循相同的 [IB] 接口,那么只需修改层 [A] 的第 1 行代码,其他行无需修改。这是一个巨大的优势,仅此一点就足以证明在两个层之间系统地使用接口是合理的。
我们甚至可以更进一步,使层 [A] 完全独立于层 [B]。在上面的代码中,第 1 行存在问题,因为它引用了 [B1] 类。理想情况下,层 [A] 应该拥有 [IB] 接口的实现,而无需指定类名。这将与我们上面的图示保持一致。 我们可以看到,层 [A] 调用的是接口 [IB],我们不明白它为何需要知道实现该接口的类名。这一细节对层 [A] 毫无用处。
Spring 框架(http://www.springframework.org)实现了这一点。之前的架构演变如下:
![]() |
横切层 [Spring] 将通过配置使某一层能够获取其右侧层的引用,而无需知道该层实现类的名称。该名称将出现在配置文件中,而非 C# 代码中。因此,层 [A] 的 C# 代码将呈现如下形式:
- 第 1 行:一个实现 [B] 层 [IB] 接口的实例 [ib]。该实例由 Spring 根据配置文件中的信息创建。Spring 将创建:
- 实现 [B] 层的 [b] 实例
- 实现 [A] 层的 [a] 实例。该实例将被初始化。上文中的 [ib] 字段将被设置为实现 [B] 层的对象 [b] 的引用
- 第 3 行:从实例 [ib] 请求数据
现在我们可以看到,层 B 的实现类 [B1] 在层 [A] 的代码中完全没有出现。当实现 [B1] 被新的实现 [B2] 替换时,类 [A] 的代码不会发生任何变化。我们只需修改 Spring 配置文件,使其实例化 [B2] 而不是 [B1]。
Spring 与 C# 接口的结合通过使应用程序各层相互隔离,为应用程序维护带来了决定性的改进。这正是我们将用于 [Impots] 应用程序新版本的解决方案。
让我们回到应用程序的三层架构:
![]() |
在简单的情况下,我们可以从[业务]层开始,以此发现应用程序的接口。要使该方法生效,需要:
- 这些数据已存在于文件、数据库中或通过网络获取。它们由[DAO]层提供。
- 尚未可用。这些由 [ui] 层提供,该层从应用程序用户处获取这些信息。
[DAO]层应向[业务]层提供哪些接口?这两个层之间可能存在哪些交互?[DAO]层必须向[业务]层提供以下数据:
- 税率档次
在我们的应用程序中,[DAO]层使用现有数据,但不创建新数据。[DAO]层的接口定义可以如下所示:
using Entites;
namespace Dao {
public interface IImpotDao {
// tax brackets
TrancheImpot[] TranchesImpot{get;}
}
}
- 第 3 行:[dao] 层将位于 [Dao] 命名空间中
- 第 6 行:接口 IImpotDao 定义了属性 TranchesImpot,该属性将向 [business] 层提供税率区间。
- 第 1 行:导入定义结构 TrancheImpot 的命名空间:
namespace Entites {
// a tax bracket
public struct TrancheImpot {
public decimal Limite { get; set; }
public decimal CoeffR { get; set; }
public decimal CoeffN { get; set; }
}
}
让我们回到应用程序的三层架构:
![]() |
[业务]层应该向[UI]层提供什么样的接口?让我们回顾一下这两个层之间的交互:
- [ui]层向用户询问子女数量、婚姻状况和年薪。这就是上文中的操作[1]。
- 完成上述操作后,[ui]层将请求业务层计算座位数。为此,它会将从用户处接收到的数据进行传输。这就是操作[2]。
[业务]层的接口定义可以如下所示:
namespace Metier {
interface IImpotMetier {
int CalculerImpot(bool marié, int nbEnfants, int salaire);
}
}
- 第 1 行:我们将所有与 [metier] 层相关的内容都放在 [Metier] 命名空间中。
- 第 2 行:接口 IImpotMetier 仅定义了一个方法:根据婚姻状况、子女数量和年薪计算纳税人的应纳税额。
我们将研究这种分层架构的一个初始实现。
6.3. 示例应用程序 - 第 4 版
6.3.1. Visual Studio 项目
Visual Studio 项目结构如下:
![]() |
- [1]:[Entities] 文件夹包含跨越 [UI、业务、DAO] 层的对象:结构体 TrancheImpot 以及异常类 FileImpotException。
- [2]:[Dao] 文件夹包含 [dao] 层的类和接口。我们将使用 IImpotDao 的两个实现:第 4.10 节中讨论的 HardwiredImpot 类,以及第 5.8 节中讨论的 FileImpot。
- [3]:[Metier] 文件夹包含 [metier] 层的类和接口
- [4]:[Ui] 文件夹包含 [ui] 层的类
- [5]:文件 [DataImpot.txt] 包含 [dao] 层中 FileImpot 实现所使用的税率区间。该文件已配置 [6] 为自动复制到项目运行目录。
6.3.2. 应用程序实体
让我们回到应用程序的三层架构:
![]() |
我们称之为“实体跨层类”。通常,封装了来自[dao]层数据的类和结构都属于这一类。这些实体通常一直延伸到[ui]层。
该应用程序的实体如下:
结构体 TrancheImpot
namespace Entites {
// a tax bracket
public struct TrancheImpot {
public decimal Limite { get; set; }
public decimal CoeffR { get; set; }
public decimal CoeffN { get; set; }
}
}
FileImportException 异常
using System;
namespace Entites {
public class FileImpotException : Exception {
// error codes
[Flags]
public enum CodeErreurs { Acces = 1, Ligne = 2, Champ1 = 4, Champ2 = 8, Champ3 = 16 };
// error code
public CodeErreurs Code { get; set; }
// manufacturers
public FileImpotException() {
}
public FileImpotException(string message)
: base(message) {
}
public FileImpotException(string message, Exception e)
: base(message, e) {
}
}
}
注意:FileImportException 仅在 [dao] 层由 FileImport 实现时才有用。
6.3.3. [dao] 层
![]() |
回顾 [dao] 层接口:
using Entites;
namespace Dao {
public interface IImpotDao {
// tax brackets
TrancheImpot[] TranchesImpot{get;}
}
}
我们将通过两种不同的方式实现此接口。
首先使用第 4.10 节中讨论的 HardwiredImplement 实现:
using System;
using Entites;
namespace Dao {
public class HardwiredImpot : IImpotDao {
// data tables required to calculate the
decimal[] limites = { 4962M, 8382M, 14753M, 23888M, 38868M, 47932M, 0M };
decimal[] coeffR = { 0M, 0.068M, 0.191M, 0.283M, 0.374M, 0.426M, 0.481M };
decimal[] coeffN = { 0M, 291.09M, 1322.92M, 2668.39M, 4846.98M, 6883.66M, 9505.54M };
// ranges
public TrancheImpot[] TranchesImpot { get; private set; }
// manufacturer
public HardwiredImpot() {
// creation of a table of
TranchesImpot = new TrancheImpot[limites.Length];
// filling
for (int i = 0; i < TranchesImpot.Length; i++) {
TranchesImpot[i] = new TrancheImpot { Limite = limites[i], CoeffR = coeffR[i], CoeffN = coeffN[i] };
}
}
}// class
}// namespace
- 第 5 行:类 HardwiredImpot 实现了 IImpotDao 接口
- 第 12 行:实现了 TranchesImpot 接口 IImpotDao。该属性为自动属性。它实现了 TranchesImpot 接口 IImpotDao 的 get 属性。我们还声明了一个类内部的 set 方法,以便第 15-22 行的构造函数能够初始化税率表。
第 5.8 节中讨论的 FileImpot 类也将实现 IImpotDao 接口:
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Entites;
namespace Dao {
class FileImpot : IImpotDao {
// data file
public string FileName { get; set; }
// tax brackets
public TrancheImpot[] TranchesImpot { get; private set; }
// manufacturer
public FileImpot(string fileName) {
// save the file name
FileName = fileName;
// data
List<TrancheImpot> listTranchesImpot = new List<TrancheImpot>();
int numLigne = 1;
// exception
FileImpotException fe = null;
// read the contents of the fileName file, line by line
Regex pattern = new Regex(@"s*:\s*");
// initially no error
FileImpotException.CodeErreurs code = 0;
try {
using (StreamReader input = new StreamReader(FileName)) {
while (!input.EndOfStream && code == 0) {
// current line
string ligne = input.ReadLine().Trim();
// ignore empty lines
if (ligne == "")
continue;
// line broken down into three fields separated by :
string[] champsLigne = pattern.Split(ligne);
// do we have 3 fields?
if (champsLigne.Length != 3) {
code = FileImpotException.CodeErreurs.Ligne;
}
// 3-field conversions
decimal limite = 0, coeffR = 0, coeffN = 0;
if (code == 0) {
if (!Decimal.TryParse(champsLigne[0], out limite))
code = FileImpotException.CodeErreurs.Champ1;
if (!Decimal.TryParse(champsLigne[1], out coeffR))
code |= FileImpotException.CodeErreurs.Champ2;
if (!Decimal.TryParse(champsLigne[2], out coeffN))
code |= FileImpotException.CodeErreurs.Champ3;
;
}
// mistake?
if (code != 0) {
// on note l'erreur
fe = new FileImpotException(String.Format("Ligne n° {0} incorrecte", numLigne)) { Code = code };
} else {
// the new tax bracket is memorized
listTranchesImpot.Add(new TrancheImpot() { Limite = limite, CoeffR = coeffR, CoeffN = coeffN });
// next line
numLigne++;
}
}
}
} catch (Exception e) {
// on note l'erreur
fe = new FileImpotException(String.Format("Erreur lors de la lecture du fichier {0}", FileName), e) { Code = FileImpotException.CodeErreurs.Acces };
}
// error to report?
if (fe != null) {
// on lance l'exception
throw fe;
} else {
// return the listImpot list in the tranchesImpot array
TranchesImpot = listTranchesImpot.ToArray();
}
}
}
}
- 该代码已在第5.8节中进行过讲解。
- 第14行:TranchesImpot方法,IImpotDao接口
- 第76行:在类构造函数中初始化税率区间,数据来自第17行传递给构造函数的文件。
6.3.4. 尿布 [metier]
![]() |
让我们回顾一下该层的接口:
namespace Metier {
public interface IImpotMetier {
int CalculerImpot(bool marié, int nbEnfants, int salaire);
}
}
该接口的实现类 ImpotMetier 如下所示:
using Entites;
using Dao;
namespace Metier {
public class ImpotMetier : IImpotMetier {
// layer [dao]
private IImpotDao Dao { get; set; }
// tax brackets
private TrancheImpot[] tranchesImpot;
// manufacturer
public ImpotMetier(IImpotDao dao) {
// memorization
Dao = dao;
// tax brackets
tranchesImpot = dao.TranchesImpot;
}
// tAX CALCULATION
public int CalculerImpot(bool marié, int nbEnfants, int salaire) {
// calculating the number of shares
decimal nbParts;
if (marié)
nbParts = (decimal)nbEnfants / 2 + 2;
else
nbParts = (decimal)nbEnfants / 2 + 1;
if (nbEnfants >= 3)
nbParts += 0.5M;
// calculation of taxable income & family quota
decimal revenu = 0.72M * salaire;
decimal QF = revenu / nbParts;
// tAX CALCULATION
tranchesImpot[tranchesImpot.Length - 1].Limite = QF + 1;
int i = 0;
while (QF > tranchesImpot[i].Limite)
i++;
// return result
return (int)(revenu * tranchesImpot[i].CoeffR - nbParts * tranchesImpot[i].CoeffN);
}//calculate
}//class
}
- 第 5 行:[Metier] 类实现了 [IImpotMetier] 接口。
- 第 14-19 行:[metier] 层必须与 [dao] 层协作。因此,它必须持有对实现 IImpotDao 接口的对象的引用。这就是为什么将该引用作为参数传递给构造函数。
- 第 16 行:[dao] 层的引用存储在第 8 行的私有字段中
- 第 18 行:基于此引用,构建器请求税率表,并将引用存储在第 8 行的私有属性中。
- 第 22-41 行:实现 IImpotMetier 接口的 CalculerImpot 方法。该实现使用了由构造函数初始化的税率表。
6.3.5. [ui] 层
![]() |
第 2 版和第 3 版中的用户对话框类非常相似。第 2 版的实现如下:
using System;
namespace Chap2 {
public class Program {
static void Main() {
...
// creation of
IImpot impot = new HardwiredImpot();
// infinite loop
while (true) {
...
}//while
}
}
}
以及第 3 版:
using System;
namespace Chap3 {
public class Program {
static void Main() {
...
// creation of a IImpot object
IImpot impot = null;
try {
// creation of a IImpot object
impot = new FileImpot("DataImpotInvalide.txt");
} catch (FileImpotException e) {
// error display
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// program stop
Environment.Exit(1);
}
// infinite loop
while (true) {
...
}//while
}
}
}
唯一的变化在于实例化 IImpot 的方式,该接口用于计算税款。此对象在此对应于我们的 [业务] 层。
对于使用 HardwiredImpot 类的 [DAO] 实现,对话框类如下所示:
using System;
using Metier;
using Dao;
using Entites;
namespace Ui {
public class Dialogue2 {
static void Main() {
...
// we create the layers [metier and dao]
IImpotMetier metier = new ImpotMetier(new HardwiredImpot());
// infinite loop
while (true) {
...
// the parameters are correct - the
Console.WriteLine("Impot=" + metier.CalculerImpot(marié == "o", nbEnfants, salaire) + " euros");
// next taxpayer
}//while
}
}
}
- 第 12 行:实例化 [dao] 和 [metier] 层。请注意,[metier] 层需要依赖 [dao] 层。
- 第 18 行:使用 [metier] 层计算税款
对于使用 FileImport 类的 [dao] 实现,对话框类如下所示:
using System;
using Metier;
using Dao;
using Entites;
namespace Ui {
public class Dialogue {
static void Main() {
...
// we create the layers [metier and dao]
IImpotMetier metier = null;
try {
// layer creation [job]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (FileImpotException e) {
// error display
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// program stop
Environment.Exit(1);
}
// infinite loop
while (true) {
...
// parameters are correct - Impot is calculated
Console.WriteLine("Impot=" + metier.CalculerImpot(marié == "o", nbEnfants, salaire) + " euros");
// next taxpayer
}//while
}
}
}
- 第 11-21 行:实例化 [dao] 和 [metier] 层。实例化 [dao] 层可能会抛出异常,该异常由
- 第 26 行:使用 [metier] 层计算税额,与前一版本相同
6.3.6. 结论
分层架构和接口的使用为我们的应用程序带来了一定的灵活性。这一点在 [ui] 层实例化 [dao] 和 [business] 层的方式上尤为明显:
// on crée les couches [metier et dao]
IImpotMetier metier = new ImpotMetier(new HardwiredImpot());
在一种情况下是:
// we create the layers [metier and dao]
IImpotMetier metier = null;
try {
// layer creation [job]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (FileImpotException e) {
// error display
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// program stop
Environment.Exit(1);
}
在另一个应用程序中。除了情况2中的异常处理外,两个应用程序中[dao]和[metier]层的实例化方式是相似的。一旦[dao]和[metier]层被实例化,两个情况下的[ui]层代码是完全相同的。 这是因为 [metier] 层是通过其接口 IImpotMetier 进行操作的,而非通过其实现类。在不更改应用程序 [metier] 层或 [dao] 层接口的情况下,修改这些层时,通常只需修改 [ui] 层中前面的几行代码即可。
该架构带来的灵活性还体现在 [business] 层的实现上:
using Entites;
using Dao;
namespace Metier {
public class ImpotMetier : IImpotMetier {
// layer [dao]
private IImpotDao Dao { get; set; }
// tax brackets
private TrancheImpot[] tranchesImpot;
// manufacturer
public ImpotMetier(IImpotDao dao) {
// memorization
Dao = dao;
// tax brackets
tranchesImpot = dao.TranchesImpot;
}
// tAX CALCULATION
public int CalculerImpot(bool marié, int nbEnfants, int salaire) {
...
}//calculate
}//class
}
第 14 行显示,[business] 层是通过引用 [dao] 层接口构建的。因此,更改后者的实现对 [business] 层没有任何影响。这就是为什么我们 [business] 层的单一实现能够与 [dao] 层的两个不同实现无缝配合,且无需任何修改。
6.4. 应用示例—— 第5版
![]() |
此新版本基于上一版本,并包含以下更改:
- [业务]层和[DAO]层分别封装在独立的DLL中,并通过NUnit单元测试框架进行测试。
- 各层之间的集成由 Spring 框架提供
在大型项目中,通常有多名开发人员共同参与同一项目。分层架构有助于这种协作模式:由于各层通过明确定义的接口进行通信,因此负责某一层开发的工程师无需关注其他层开发人员的工作。唯一的要求是所有开发人员都必须遵守这些接口规范。
在上例中,[business] 层的开发人员在测试其层时需要 [dao] 层的实现。在该实现完成之前,只要其符合 [dao] 接口 IImpotDao,他就可以使用 [dao] 层的模拟实现。 这是分层架构的另一个优势:[DAO]层的延迟不会阻碍[业务]层的测试。此外,[DAO]层的模拟实现通常比真正的[DAO]层更容易实现,因为后者可能需要启动关系型数据库管理系统(RDBMS)、建立网络连接等操作。
当 [dao] 层开发完成并经过测试后,将以 DLL 形式(而非源代码)交付给 [business] 层开发人员。最终,应用程序通常以 .exe 可执行文件(用于 [ui] 层)和 .dll 类库(用于其他层)的形式交付。
6.4.1. NUnit
迄今为止,针对各类应用程序所进行的测试均基于视觉验证。我们通过检查屏幕显示是否符合预期来确认结果。当需要执行大量测试时,这种方法便难以奏效。人类容易疲劳,且随着时间推移,其验证测试的能力会逐渐减弱。因此,测试必须实现自动化,并力求完全无需人工干预。
应用程序会随着时间推移而演进。每次演进后,我们需要验证应用程序是否“回归”,即它是否仍能通过最初编写时执行的功能测试。这些测试被称为“非回归”测试。大型应用程序可能需要数百个测试。 应用程序中每个类的每个方法都会被测试。这些被称为单元测试。如果未实现自动化,这些测试可能需要大量开发人员参与。
目前已开发出多种工具来实现测试自动化。其中一种名为 NUnit。您可以在 [http://www.nunit.org] 上获取该工具:
![]() | ![]() |
本文档使用的是上述 2.4.6 版本(2008 年 3 月)。安装后,桌面会生成一个图标 [1]:
![]() |
双击图标 [1] 可启动 NUnit 图形界面 [2]。但这对测试自动化毫无帮助,因为我们又回到了视觉验证的阶段:测试人员需要手动检查图形界面中显示的测试结果。不过,测试也可以通过批处理工具运行,并将结果保存为 XML 文件。这是开发团队常用的方法:测试在夜间运行,开发人员次日早晨即可获得结果。
让我们来看一个 NUnit 测试的示例。首先,创建一个类型为“Application Console”的新 C# 项目:
![]() |
在 [1] 中,我们可以看到该项目的引用。这些引用是包含项目所用类和接口的 DLL。图 [1] 中所示的引用默认包含在每个新的 C# 项目中。为了能够使用 NUnit 框架的类和接口,我们需要在项目中添加 [2] 一个新的引用。
![]() |
在上方的 .NET 选项卡中,我们选择组件 [nunit.framework]。上方的 [nunit.*] 组件并非 .NET 环境中的默认组件,而是由之前安装的 NUnit 框架引入的。添加引用后,它将出现在 [4] 项目引用列表中。
在生成应用程序之前,项目的 [bin/Release] 文件夹是空的。生成(F6)之后,[bin/Release] 文件夹就不再是空的了:
![]() |
在 [6] 中,我们可以看到 DLL [nunit.framework.dll] 的存在。正是添加了 [nunit.framework] 引用,才导致该 DLL 被复制到执行文件夹中。实际上,这是 .NET 的 CLR(通用语言运行时)在查找项目所引用的类和接口时会遍历的文件夹之一。
让我们构建一个最初的测试类 NUnit。为此,我们删除默认类 [Program.cs],并在项目中添加一个新类 [Nunit1.cs]。同时,我们还删除了不必要的引用 [7]。
测试类 NUnit1 的代码如下:
using System;
using NUnit.Framework;
namespace NUnit {
[TestFixture]
public class NUnit1 {
public NUnit1() {
Console.WriteLine("constructeur");
}
[SetUp]
public void avant() {
Console.WriteLine("Setup");
}
[TearDown]
public void après() {
Console.WriteLine("TearDown");
}
[Test]
public void t1() {
Console.WriteLine("test1");
Assert.AreEqual(1, 1);
}
[Test]
public void t2() {
Console.WriteLine("test2");
Assert.AreEqual(1, 2, "1 n'est pas égal à 2");
}
}
}
- 第 6 行:NUnit1 类必须是 public 的。Visual Studio 默认不会生成 public 关键字,必须手动添加。
- 第 5 行:[TestFixture] 是 NUnit 的一个属性。它表示该类是一个测试类。
- 第 7-9 行:构造函数。此处仅用于向屏幕输出消息,以便观察其执行时机。
- 第 10 行:[SetUp] 定义了一个在每次单元测试之前执行的方法。
- 第 14 行:[TearDown] 定义了在每次单元测试后执行的方法。
- 第 18 行:[Test] 属性用于定义测试方法。对于每个标注了 [Test] 的方法,其对应的 [SetUp] 方法将在测试前执行,而 [TearDown] 方法将在测试后执行。
- 第 21 行:NUnit 框架定义的 [Assert.*] 方法之一。可用的 [Assert] 方法包括:
- [Assert.AreEqual(expression1, expression2)]:检查两个表达式的值是否相等。支持多种表达式类型(int、string、float、double、decimal 等)。如果两个表达式不相同,则抛出异常。
- [Assert.AreEqual(real1, real2, delta)]:验证两个实数在 delta 范围内相等,即 |real1 - real2| <= delta。例如,我们可以编写 [Assert.AreEqual(real1, real2, 1E-6)] 来检查两个值是否在 10⁻⁶ 范围内相等。
- [Assert.AreEqual(expression1, expression2, message)] 和 [Assert.AreEqual(real1, real2, delta, message)] 是用于指定当 [Assert.AreEqual] 失败时抛出异常所关联的错误消息的变体。
- [Assert.IsNotNull(object)] 和 [Assert.IsNotNull(object, message)]:验证 object 不为 null。
- [Assert.IsNull(object)] 和 [Assert.IsNull(object, message)] : 验证 object 是否等于 null。
- [Assert.IsTrue(expression)] 和 [Assert.IsTrue(expression, message)]:检查表达式是否等于 true。
- [Assert.IsFalse(表达式)] 和 [Assert.IsFalse(表达式, 消息)] : 检查表达式是否等于 false。
- [Assert.AreSame(object1, object2)] 和 [Assert.AreSame(object1, object2, message)]:检查引用 object1 和 object2 是否指向同一个对象。
- [Assert.AreNotSame(object1, object2)] 和 [Assert.AreNotSame(object1, object2, message)]:检查引用 object1 和 object2 是否不指向同一个对象。
- 第 21 行:断言必须成功
- 第 26 行:断言必须失败
让我们配置该项目,使其生成 DLL 文件而非 .exe 可执行文件:
![]() |
- 在 [1] 中:项目属性
- 在 [2, 3]:选择 [类库] 作为项目类型
- 在 [4] 中:项目生成将产生一个名为 [Nunit.dll] 的 DLL(程序集)
现在让我们使用 NUnit 来执行测试类:
![]() |
- 在 [1] 中:打开一个 NUnit 项目
- 在 [2, 3]:加载由 C# 项目生成生成的 DLL 文件 bin/Release/Nunit.dll
- 在 [4]:DLL 已加载
- 在 [5]:测试树
- 在 [6]:正在执行
![]() |
- 在 [7] 中:结果:t1 通过,t2 失败
- 在 [8] 中:红色条表示测试类整体失败
- 在 [9] 中:失败测试的错误信息
![]() |
- 在 [11] 中:结果窗口中的不同选项卡
- 在 [12] 中:[Console.Out] 选项卡。在此我们可以看到:
- 构建器仅被执行了一次
- 在两次测试之前都执行了 [SetUp] 方法
- [TearDown] 方法在两个测试各自执行之后被调用
您可以指定要测试的方法:
![]() |
- 在 [1] 中:每个测试旁边都会显示一个复选框
- 在 [2] 中:勾选要运行的测试
- 在 [3] 中:执行这些测试
要修正错误,只需修改 C# 项目并重新生成即可。NUnit 会检测到其正在测试的 DLL 已发生更改,并自动加载新版本。您只需重新运行测试即可。
请看以下这个新的测试类:
using System;
using NUnit.Framework;
namespace NUnit {
[TestFixture]
public class NUnit2 : AssertionHelper {
public NUnit2() {
Console.WriteLine("constructeur");
}
[SetUp]
public void avant() {
Console.WriteLine("Setup");
}
[TearDown]
public void après() {
Console.WriteLine("TearDown");
}
[Test]
public void t1() {
Console.WriteLine("test1");
Expect(1, EqualTo(1));
}
[Test]
public void t2() {
Console.WriteLine("test2");
Expect(1, EqualTo(2), "1 n'est pas égal à 2");
}
}
}
从 NUnit 2.4 版本开始,引入了一种新的语法,即第 21 行和第 26 行所示的语法。为此,测试类必须继承自 AssertionHelper(第 6 行)。
新旧语法之间的对应关系(非穷举)如下:
让我们在 NUnit2 类中添加以下测试:
[Test]
public void t3() {
bool vrai = true, faux = false;
Expect(vrai, True);
Expect(faux, False);
Object obj1 = new Object(), obj2 = null, obj3=obj1;
Expect(obj1, Not.Null);
Expect(obj2, Null);
Expect(obj3, SameAs(obj1));
double d1 = 4.1, d2 = 6.4, d3 = d1;
Expect(d1, EqualTo(d3).Within(1e-6));
Expect(d1, Not.EqualTo(d2));
}
如果我们生成 (F6) C# 项目的新 DLL,NUnit 项目将变为如下所示:
![]() |
- 在 [1] 中:已自动检测到新的测试类 [NUnit2]
- 在 [2] 中:运行 NUnit2 的 t3 测试
- 在 [3]:t3 测试通过
如需了解有关 NUnit 的更多信息,请阅读 NUnit 的帮助文档:
![]() | ![]() |
6.4.2. Visual Studio 解决方案
![]() |
我们将逐步构建以下 Visual Studio 解决方案:
![]() |
- 在 [1] 中:ImpotsV5 解决方案由三个项目组成,分别对应应用程序的三个层
- 在 [2] 中:来自 [dao] 层的 [dao] 项目
- 在 [3] 中:[业务] 层的 [metier] 项目
- 在 [4] 中:来自 [ui] 层的 [ui] 项目
ImpotsV5 解决方案的构建方式如下:
1 ![]() | 234 ![]() | 5 ![]() |
- en [1]: 创建新项目
- en [2]: 选择控制台应用程序
- in [3]: 将项目命名为 [dao]
- in [4]: 创建项目
- in [5]: 项目创建完成后,将其保存
![]() |
- 在 [6] 中:将项目名称保留为 [dao]
- 在 [7] 中:指定一个文件夹来保存项目及其解决方案
- 在 [8] 中:为解决方案命名
- 在 [9] 中:指定解决方案必须拥有独立的文件
- 在 [10] 中:保存项目及其解决方案
- 在 [11] 中:[dao] 项目及其所属的 ImpotsV5 解决方案
![]() |
- 在 [12] 中:解决方案文件 ImpotsV5。它包含来自 [dao] 文件夹的 [dao] 文件夹。
- 在 [13] 中:[dao] 文件夹的内容
- 在 [14] 中:向 ImpotsV5 解决方案中添加了一个新项目
![]() |
- [15]:新项目名为 [metier]
- 在 [16] 中:包含两个项目的解决方案
- 在 [17] 中:添加了第三个项目 [ui] 后的解决方案
![]() |
- 在 [18] 中:解决方案文件及三个项目的文件
- 当使用 (Ctrl+F5) 执行解决方案时,实际执行的是当前活动项目。生成 (F6) 解决方案时也是如此。活动项目的名称在解决方案中以粗体显示 [19]。
- 在 [20] 中:更改解决方案的活动项目
- 在 [21] 中:[metier] 项目现已成为解决方案中的活动项目
6.4.3. [层 DAO]
![]() |
![]() |
项目参考文献(参见项目中的[1])
我们添加了 [NUnit] 测试所需的 [nunit.framework] 引用
实体(参见项目中的[2])
[TrancheImport] 类与之前版本相同。将之前版本中的 [FileImportException] 类重命名为 [ImportException],以使其更具通用性,并避免将其与特定的 [dao] 层关联:
using System;
namespace Entites {
public class ImpotException : Exception {
// error code
public int Code { get; set; }
// manufacturers
public ImpotException() {
}
public ImpotException(string message)
: base(message) {
}
public ImpotException(string message, Exception e)
: base(message, e) {
}
}
}
[dao] 层(参见项目中的 [3])
[IImpotDao] 接口与上一版本相同。[HardwiredImpot] 类也是如此。[FileImpot] 类已进行修改,以适应 [FileImpotException] 异常更名为 [ImpotException] 的变更:
...
namespace Dao {
public class FileImpot : IImpotDao {
// error codes
[Flags]
public enum CodeErreurs { Acces = 1, Ligne = 2, Champ1 = 4, Champ2 = 8, Champ3 = 16 };
...
// manufacturer
public FileImpot(string fileName) {
// save the file name
FileName = fileName;
...
// initially no error
CodeErreurs code = 0;
try {
using (StreamReader input = new StreamReader(FileName)) {
while (!input.EndOfStream && code == 0) {
...
// mistake?
if (code != 0) {
// on note l'erreur
fe = new ImpotException(String.Format("Ligne n° {0} incorrecte", numLigne)) { Code = (int)code };
} else {
...
}
}
}
} catch (Exception e) {
// on note l'erreur
fe = new ImpotException(String.Format("Erreur lors de la lecture du fichier {0}", FileName), e) { Code = (int)CodeErreurs.Acces };
}
// error to report?
...
}
}
}
- 第 8 行:原先位于 [FileImpotException] 类中的错误代码已迁移至 [FileImpot] 类。这些是针对 [IImpotDao] 接口此实现的特定错误代码。
- 第 26 行和第 34 行:为封装错误,使用类 [ImpotException] 代替类 [FileImpotException]。
[Test1] 测试(参见项目中的 [4])
[Test1] 类仅在屏幕上显示税率区间:
using System;
using Dao;
using Entites;
namespace Tests {
class Test1 {
static void Main() {
// create the [dao] layer
IImpotDao dao = null;
try {
// layer creation [dao]
dao = new FileImpot("DataImpot.txt");
} catch (ImpotException e) {
// error display
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// program stop
Environment.Exit(1);
}
// display tax brackets
TrancheImpot[] tranchesImpot = dao.TranchesImpot;
foreach (TrancheImpot t in tranchesImpot) {
Console.WriteLine("{0}:{1}:{2}", t.Limite, t.CoeffR, t.CoeffN);
}
}
}
}
- 第 13 行:[dao] 层由 [FileImport] 类实现
- 第 14 行:处理可能发生的 [ImpotException] 异常。
测试所需的 [DataImpot.txt] 文件会自动复制到项目执行文件夹中(参见项目中的 [5])。[dao] 项目中将包含多个带有 [Main] 方法的类。在这种情况下,当用户按 Ctrl-F5 请求执行项目时,必须显式指定要执行的类:
![]() |
- en [1]:访问项目属性
- en [2]:指定此为控制台应用程序
- 在 [3] 中:指定要执行的类
执行前面的 [Test1] 类会得到以下结果:
4962:0:0
8382:0,068:291,09
14753:0,191:1322,92
23888:0,283:2668,39
38868:0,374:4846,98
47932:0,426:6883,66
0:0,481:9505,54
[Test2] 测试(参见项目中的 [4])
[Test2]类与[Test1]类功能相同,通过[HardwiredImpot]类实现[dao]层。将[Test1]的第13行替换为以下内容:
dao = new HardwiredImpot();
项目已修改为运行 [Test2] 类:
![]() |
屏幕显示结果与之前相同。
NUnit [NUnit1] 测试(参见项目中的 [4])
单元测试 [NUnit1] 如下:
using System;
using Dao;
using Entites;
using NUnit.Framework;
namespace Tests {
[TestFixture]
public class NUnit1 : AssertionHelper{
// layer [dao] to be tested
private IImpotDao dao;
// manufacturer
public NUnit1() {
// dao] layer initialization
dao = new FileImpot("DataImpot.txt");
}
// test
[Test]
public void ShowTranchesImpot(){
// display tax brackets
TrancheImpot[] tranchesImpot = dao.TranchesImpot;
foreach (TrancheImpot t in tranchesImpot) {
Console.WriteLine("{0}:{1}:{2}", t.Limite, t.CoeffR, t.CoeffN);
}
// some tests
Expect(tranchesImpot.Length,EqualTo(7));
Expect(tranchesImpot[2].Limite,EqualTo(14753));
Expect(tranchesImpot[2].CoeffR, EqualTo(0.191));
Expect(tranchesImpot[2].CoeffN, EqualTo(1322.92));
}
}
}
- 该测试类继承自 [AssertionHelper] 类,从而能够使用静态方法 Expect(第 27-30 行)。
- 第 10 行:对 [dao] 层的引用
- 第 13-16 行:构造函数使用 [FileImport] 类实例化 [dao] 层
- 第 19-20 行:测试方法
- 第 22 行:从 [dao] 层检索税率表
- 第 23-25 行:显示内容与之前相同。在实际的单元测试中,此处无需显示。此处展示仅出于教学目的。
- 第27行:检查是否有7个税率档次
- 第28至30行:检查第2个税级的数值
要运行此单元测试,项目类型必须为 [类库]:
![]() |
- 在 [1] 中:项目类型已更改
- 在 [2] 中:生成的 DLL 将命名为 [ ImpotsV5-dao.dll]
- 在 [3] 中:生成(F6)项目后,[dao/bin/Release] 文件夹中将包含 DLL 文件 [ImpotsV5-dao.dll]
随后,DLL [ImpotsV5-dao.dll] 会被加载到 NUnit 框架中并执行:
![]() |
- 在 [1] 中:测试通过。现在我们认为 [dao] 层已可正常运行。其 DLL 包含项目中的所有类,包括测试类。这些类已不再需要。我们重新构建 DLL 以排除测试类。
- 在 [2] 中:将 [tests] 文件夹从项目中排除
- 在 [3] 中:新项目。按 F6 生成新 DLL 即可重新生成该项目。
6.4.4. [ 任务]
![]() |
![]() |
- 在 [1] 中,[metier] 项目已成为解决方案的当前项目
- 在 [2] 中:项目引用
- en [3]:[metier] 层
- 在 [4] 中:测试类
- 在 [5] 中:税率表文件 [DataImport.txt] 已配置 [6] 自动复制到项目执行文件夹 [7]
项目引用(参见项目中的 [2])
与 [dao] 项目类似,我们需要添加 [NUnit] 测试所需的 [nunit.framework] 引用。由于 [metier] 层依赖于 [dao] 层,因此需要引用该层的 DLL。操作步骤如下:
![]() |
- 在 [1] 中:向 [metier] 项目引用中添加一个新引用
- 在 [2] 中:选择 [浏览] 选项卡
- 在 [3]:选择文件夹 [dao/bin/Release]
- 在 [4] 中:选择项目 [dao] 中生成的 DLL [ImpotsV5-dao.dll]
- 在 [5] 中:新的引用
[metier] 组件(参见项目中的 [3])
[IImpotMetier] 接口与上一版本相同。[ImpotMetier] 类也是如此。
[Test1] 测试(参见项目中的 [4])
[Test1] 类仅执行一些薪资计算:
using System;
using Dao;
using Entites;
using Metier;
namespace Tests {
class Test1 {
static void Main() {
// we create the [metier] layer
IImpotMetier metier = null;
try {
// layer creation [job]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (ImpotException e) {
// error display
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// program stop
Environment.Exit(1);
}
// on calcule qqs impots
Console.WriteLine(String.Format("Impot(true,2,60000)={0} euros", metier.CalculerImpot(true, 2, 60000)));
Console.WriteLine(String.Format("Impot(false,3,60000)={0} euros", metier.CalculerImpot(false, 3, 60000)));
Console.WriteLine(String.Format("Impot(false,3,60000)={0} euros", metier.CalculerImpot(false, 3, 6000)));
Console.WriteLine(String.Format("Impot(false,3,60000)={0} euros", metier.CalculerImpot(false, 3, 600000)));
}
}
}
- 第 14 行:创建 [metier] 和 [dao] 层。[dao] 层通过 [FileImpot] 类实现
- 第 12-21 行:处理可能抛出的 [ImpotException] 异常
- 第23-26行:多次调用 [IImpotMetier] 接口中的唯一方法 CalculerImpot。
[metier] 项目的配置如下:
![]() |
- [1]:该项目是一个控制台应用程序
- [2]:执行的类是 [Test1]
- [3]: 项目生成后将产生可执行文件 [ImpotsV5-metier.exe]
该项目的成果如下:
[NUnit1] 测试(参见项目中的 [4])
单元测试类 [NUnit1] 重复了前面的四项计算并验证了结果:
using Dao;
using Metier;
using NUnit.Framework;
namespace Tests {
[TestFixture]
public class NUnit1:AssertionHelper {
// layer [metier] to test
private IImpotMetier metier;
// manufacturer
public NUnit1() {
// initialization layer [metier]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
}
// test
[Test]
public void CalculsImpot(){
// display tax brackets
Expect(metier.CalculerImpot(true, 2, 60000), EqualTo(4282));
Expect(metier.CalculerImpot(false, 3, 60000), EqualTo(4282));
Expect(metier.CalculerImpot(false, 3, 6000), EqualTo(0));
Expect(metier.CalculerImpot(false, 3, 600000), EqualTo(179275));
}
}
}
- 第 14 行:创建 [metier] 和 [dao] 层。[dao] 层由 [FileImpot] 类实现
- 第21-24行:多次调用 [IImpotMetier] 接口的 CalculerImpot 方法,并验证结果。
[metier] 项目的配置如下:
![]() |
- [1]:该项目属于“类库”类型
- [2]:生成项目将生成 DLL 文件 [ImpotsV5-metier.dll]
生成项目(F6)。随后,生成的 DLL [ ImpotsV5-metier.dll] 被加载到 NUnit 中并进行测试:
![]() |
上述测试均已通过。现在,我们将 [metier] 层视为已投入运行。其 DLL 包含所有项目类,包括测试类。这些类已不再需要。我们重新构建 DLL 以排除测试类。
![]() |
- 在 [1] 中:将 [tests] 文件夹从项目中排除
- 在 [2] 中:新项目。按 F6 生成新 DLL 即可重新生成该项目。
6.4.5. [ui] 层
![]() |
![]() |
- 在 [1] 中,[ui] 项目已成为该解决方案的当前项目
- 在 [2] 中:项目引用
- 在 [3] 中:[ui] 层
- 在 [4] 中:[DataImport.txt] 税率表文件,已配置 [5] 自动复制到项目执行文件夹 [6]
项目引用(参见项目中的 [2])
[ui] 层需要 [metier] 和 [dao] 层来执行其税务计算。因此,它需要引用这两个层的 DLL。操作步骤与 [metier] 层相同
主类 [Dialogue.cs](参见项目中的 [3])
[Dialogue.cs] 类与上一版本相同。
测试
[ui] 项目的配置如下:
![]() |
- [1]:该项目属于“控制台应用程序”类型
- [2]:项目生成将产生可执行文件 [ImpotsV5-ui.exe]
- [3]:待执行的类
执行示例如下(Ctrl+F5):
Paramètres du calcul de l'Impot au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :o 2 60000
Impot=4282 euros
6.4.6. [图层 Spring]
让我们回到 [Dialogue.cs] 中的代码,该代码创建了 [dao] 和 [metier] 层:
// on crée les couches [metier et dao]
IImpotMetier metier = null;
try {
// création couche [metier]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (ImpotException e) {
// affichage erreur
...
// arrêt programme
Environment.Exit(1);
}
第 5 行创建了 [dao] 和 [metier] 层,并明确指定了这两个层的实现类:[dao] 层为 FileImpot,[metier] 层为 ImpotMetier。如果其中一个层使用新的类进行实现,则第 5 行将相应更改。例如:
metier = new ImpotMetier(new HardwiredImpot());
除了这一变更外,应用程序的其他部分不会发生任何变化,因为每一层都是通过接口与下一层进行通信的。只要接口保持不变,层与层之间的通信方式也就不会改变。Spring框架允许我们将层独立性进一步提升,通过将实现各层的类 的名称外部化到配置文件中。更改一个层的实现等同于修改配置文件,对应用程序代码没有任何影响。
![]() |
在上文中,[ui] 层将请求 [0] Spring 根据配置文件中的信息实例化 [dao] [1] 和 [metier] [2] 层。随后,[ui] 层将向 Spring [3] 请求 [metier] 层的引用:
// we create the layers [metier and dao]
IImpotMetier metier = null;
try {
// spring context
IApplicationContext ctx = ContextRegistry.GetContext();
// a reference is requested on the [metier] layer
metier = (IImpotMetier)ctx.GetObject("metier");
} catch (Exception e1) {
...
}
- 第 5 行:Spring 实例化 [dao] 和 [metier] 层
- 第 7 行:获取 [metier] 层的引用。请注意,[ui] 层在未指定实现 [metier] 层的类名的情况下就获得了该引用。
Spring 框架有两个版本:Java 和 .NET。.NET 版本可通过以下网址获取(2008年3月)[http://www.springframework.net/]:
![]() |
- 在 [1] 中:[Spring.net] 网站
- 在 [2] 中:下载页面
![]() |
- 在 [3] 中:下载 Spring 1.1(2008 年 3 月)
![]() |
- [4]:下载并安装 .exe 版本
- 在 [5] 中:安装生成的文件夹
- in [6]:[bin/net/2.0/release] 文件夹包含适用于 Visual Studio .NET 2.0 或更高版本项目的 Spring DLL 文件。Spring 是一个功能丰富的框架。我们在此将使用 Spring 的一个特性来管理应用程序中各层的集成,该特性被称为 IoC(控制反转)或 DI(依赖注入)。 Spring 提供了用于通过 NHibernate 访问数据库、生成和操作 Web 服务、Web 应用程序等的库……
- 用于管理应用程序中各层集成的 DLL 是 [7] 和 [8]。
我们将这三个 DLL 文件存储在项目中的 [lib] 文件夹内:
![]() |
- [1]:使用 Windows 资源管理器将这三个 DLL 文件放置在 [lib] 文件夹中
- [2]:在 [ui] 项目中,显示所有文件
- [3]:现在可以看到 [ui/lib] 文件夹。我们将它添加到
- [4]:[ui/lib] 文件夹已成为项目的一部分
创建 [lib] 文件夹的操作绝非必需。也可以直接在 [Spring.net] 的 [bin/net/2.0/release] 文件夹中对这三个 DLL 建立引用。但是,通过创建 [lib] 文件夹,可以在没有 [Spring.net] 的工作站上开发应用程序,从而降低对可用开发环境的依赖。
我们将这三个新 DLL 的引用添加到 [ui] 项目中:
![]() |
- [1]:创建对 [lib] 文件夹中三个 DLL 的引用 [2]
- [3]:这三个 DLL 已成为项目引用的一部分
让我们回到应用程序架构的概述:
![]() |
在上文中,[ui] 层将根据配置文件中的信息,请求 [0] Spring 实例化 [dao] [1] 和 [metier] [2] 层。随后,[ui] 层将向 Spring [3] 请求 [metier] 层的引用。这将在 [ui] 层中生成以下代码:
// we create the layers [metier and dao]
IImpotMetier metier = null;
try {
// spring context
IApplicationContext ctx = ContextRegistry.GetContext();
// a reference is requested on the [metier] layer
metier = (IImpotMetier)ctx.GetObject("metier");
} catch (Exception e1) {
...
}
- 第 5 行:Spring 实例化 [dao] 和 [metier] 层
- 第 7 行:获取 [metier] 层的引用。
上文第 [5] 行使用了 Visual Studio 项目中的 [App.config] 配置文件。在 C# 项目中,该文件用于配置应用程序。因此,[App.config] 并非 Spring 的概念,而是 Spring 所利用的 Visual Studio 概念。Spring 还支持使用 [App.config] 以外的其他配置文件。因此,此处介绍的解决方案并非唯一可选方案。
让我们使用 Visual Studio 向导创建 [App.config] 文件:
![]() |
- 在 [1] 中:向项目添加新元素
- 在 [2] 中:选择“应用程序配置文件”
- 在 [3]:[App.config] 是此配置文件的默认名称
- 在 [4] 中:文件 [App.config] 已添加到项目中
文件 [App.config] 的内容如下:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
</configuration>
[ App.config] 是一个 XML 文件。项目配置被包含在 <configuration> 标签中。Spring 所需的配置如下:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<sectionGroup name="spring">
<section name="context" type="Spring.Context.Support.ContextHandler, Spring.Core" />
<section name="objects" type="Spring.Context.Support.DefaultSectionHandler, Spring.Core" />
</sectionGroup>
</configSections>
<spring>
<context>
<resource uri="config://spring/objects" />
</context>
<objects xmlns="http://www.springframework.net">
<object name="dao" type="Dao.FileImpot, ImpotsV5-dao">
<constructor-arg index="0" value="DataImpot.txt"/>
</object>
<object name="metier" type="Metier.ImpotMetier, ImpotsV5-metier">
<constructor-arg index="0" ref="dao"/>
</object>
</objects>
</spring>
</configuration>
- 第 11-23 行:由 <spring> 标签界定的部分称为 <spring> 部分组。您可以在 [App.config] 中创建任意数量的部分组。
- 一个部分组包含若干部分:此处即为该情况:
- 第 12-14 行:<spring/context> 部分
- 第 15-22 行:<spring/objects> 部分
- 第 4-9 行:<configSections> 区域定义了 [App.config] 中存在的节组处理程序列表。
- 第 5-8 行:定义了 <spring> 组(name="spring")中的部分管理器列表。
- 第 6 行:<spring> 组中 <context> 节的处理器:
- name:被管理的部分名称
- type:管理该节的类名,格式为 NomClasse, NomDLL。
- <spring> 组中的 <context> 部分由 [Spring.Context.Support.ContextHandler] 管理,该类位于 DLL [Spring.Core.dll] 中
- 第 7 行:<spring> 组中 <objects> 节的处理器
第 4-9 行是 Spring 项目中 [App.config] 文件的标准配置。我们只需将它们从一个项目复制到另一个项目即可。
- 第 12-14 行:定义 <spring/context> 部分。
- 第 13 行:<resource> 标签指明了 Spring 将要实例化的类所定义的文件位置。这些类可能像这里一样位于 [App.config] 中,但也可能位于另一个配置文件中。这些类的位置在 <resource> 标签的 uri 属性中指定:
- <resource uri="config://spring/objects"> 表示待实例化的类列表位于 [App.config] 文件(配置路径:config://spring/objects)中,即 <spring> 标签下的 <objects> 标签内。
- <resource uri="file://spring-config.xml"> 则表示待实例化的类列表位于 [spring-config.xml] 文件中。该文件应放置在项目的运行时文件夹(bin/Release 或 bin/Debug)中。 最简单的方法是像处理 [DataImport.txt] 文件那样,将其放置在项目根目录下,并设置 [Copy to output directory=always] 属性。
第 12-14 行是 Spring [App.config] 文件中的标准内容。我们只需将它们从一个项目复制到另一个项目即可。
- 第 15-22 行:定义待实例化的类。此处进行应用程序的具体配置。<objects> 标签界定了待实例化类的定义部分。
- 第 16-18 行:定义用于 [dao] 层的实例化类
- 第 16 行:Spring 实例化的每个对象都由 <object> 标签定义。该标签有一个 name 属性,即实例化对象的名称。这就是应用程序向 Spring 请求引用时的方式:“给我一个名为 dao 的对象的引用”。 type 属性定义了待实例化的类,如 NomClasse、NomDLL。第 16 行定义了一个名为 dao 的对象,它是位于 DLL 文件 "ImpotsV5-dao.dll" 中的 "Dao.FileImport" 类的实例。请注意,这里给出了完整的类名(包括命名空间),且 DLL 文件名中未指定 .dll 后缀。
在 Spring 中,可以通过两种方式实例化类:
- 通过传递参数的特殊构造函数:第 16-18 行即采用此方式。
- 通过不带参数的默认构造函数。随后通过其 public 属性对对象进行初始化:<object> 标签下会包含 <property> 子标签来初始化这些属性。此处未提供该情况的示例。
- (待续)
- 第 16 行:实例化的类是 FileImport。它具有以下构建器:
public FileImpot(string fileName);
构造函数参数使用 <constructor-arg> 进行定义。
- 第 17 行:定义第一个也是唯一的构造函数参数。属性的 index 是构造函数参数的序号,value 是其值:<constructor-arg index="i" value="valuei"/>
- 第 19-21 行:定义了 [metier] 层要实例化的类:class [Metier.ImpotMetier],该类位于 DLL [ImpotsV5-metier.dll] 中。
- 第 19 行:实例化的类是 ImpotMetier。它具有以下构建器:
public ImpotMetier(IImpotDao dao);
- (待续)
- 第 20 行:定义了第一个也是唯一的构造函数参数。上文中,构造函数的 dao 参数是一个对象引用。 在此情况下,在 <constructor-arg> 标签中,我们使用 ref 属性代替 [dao] 层所用的 value 属性:<constructor-arg index="i" ref="refi"/>。在上述构造函数中,参数 dao 代表 [dao] 层上的一个实例。该实例已在配置文件的第 16-18 行中定义。因此,在第 20 行中:
<constructor-arg index="0" ref="dao"/>
ref="dao" 代表由第 16-18 行定义的 Spring 对象 "dao"。
综上所述,[App.config] 文件:
- 通过 FileImpot 类实例化 [dao] 层,该类接收 DataImport.txt 作为参数(第 16-18 行)。生成的对象名为 "dao"
- 通过类 ImpotMetier 实例化 [metier] 层,该类将前面的 "dao" 对象作为参数接收(第 19-21 行)。
剩下的就是将此 Spring 配置文件应用于 [ui] 层。为此,我们将 [Dialogue.cs] 类复制为 [Dialogue2.cs],并将后者设为 [ui] 项目的类:
![]() |
- 在 [1] 中:[Dialogue.cs] 的副本
- en [2]: 粘合
- 在 [3] 中:[Dialogue.cs] 的副本
- 在 [4] 中:重命名为 [Dialogue2.cs]
![]() |
- 在 [6] 中:我们将 [Dialogue2.cs] 设为 [ui] 项目的主类。
以下代码来自 [Dialogue.cs]:
// we create the layers [metier and dao]
IImpotMetier metier = null;
try {
// layer creation [job]
metier = new ImpotMetier(new FileImpot("DataImpot.txt"));
} catch (ImpotException e) {
// error display
string msg = e.InnerException == null ? null : String.Format(", Exception d'origine : {0}", e.InnerException.Message);
Console.WriteLine("L'erreur suivante s'est produite : [Code={0},Message={1}{2}]", e.Code, e.Message, msg == null ? "" : msg);
// program stop
Environment.Exit(1);
}
// infinite loop
while (true) {
...
在 [Dialogue2.cs] 中变为:
// we create the layers [metier and dao]
IApplicationContext ctx = null;
try {
// spring context
ctx = ContextRegistry.GetContext();
} catch (Exception e1) {
// error display
Console.WriteLine("Chaîne des exceptions : \n{0}", "".PadLeft(40, '-'));
Exception e = e1;
while (e != null) {
Console.WriteLine("{0}: {1}", e.GetType().FullName, e.Message);
Console.WriteLine("".PadLeft(40, '-'));
e = e.InnerException;
}
// program stop
Environment.Exit(1);
}
// a reference is requested on the [metier] layer
IImpotMetier metier = (IImpotMetier)ctx.GetObject("metier");
// infinite loop
while (true) {
....................................
- 第 2 行:IApplicationContext 提供了访问由 Spring 实例化的一组对象的途径。我们将此对象称为应用程序的 Spring 上下文,或简称为应用程序上下文。目前,该上下文尚未初始化。随后的 try/catch 语句将执行初始化操作。
- 第 5 行:读取并使用 [App.config] 中的 Spring 配置。此操作完成后,若未抛出异常,<objects> 部分中的所有对象都将被读取并实例化:
- Spring "dao" 对象是 [dao] 层上的一个实例
- Spring "metier" 对象是 [metier] 层上的一个实例
- 第 19 行:[Dialogue2.cs] 类需要 [metier] 层的引用。该引用从应用上下文中获取。IApplicationContext 对象可通过名称(Spring 配置中的 <object> 属性标签)访问 Spring 对象。 生成的引用指向泛型类型 Object。我们需要将该引用转换为正确类型,即 [metier] 层接口的类型:IImpotMetier。
如果一切顺利,在第 19 行之后,[Dialogue2.cs] 便拥有了对 [metier] 层的引用。第 21 行及之后的代码即为之前已学习的 [Dialogue.cs] 类的内容。
- 第 6-17 行:处理 Spring 配置文件无法处理时触发的异常。这可能由多种原因导致:配置文件本身语法错误,或无法实例化某个已配置的对象。在本示例中,如果项目执行文件中找不到 [App.config] 第 17 行中的 DataImpot.txt 文件,就会发生后一种情况。
第 6 行出现的异常是一个异常链,其中每个异常都有两个属性:
- Message:异常错误消息
- InnerException:异常链中的前一个异常
第 10-14 行的循环以“异常类及其关联消息”的形式显示链中的所有异常。
当使用有效的配置文件运行 [ui] 项目时,将获得通常的结果:
Paramètres du calcul de l'Impot au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :o 2 60000
Impot=4282 euros
当使用不存在的 [DataImpotInexistant.txt] 文件运行 [ui] 项目时,
<object name="dao" type="Dao.FileImpot, ImpotsV5-dao">
<constructor-arg index="0" value="DataImpotInexistant.txt"/>
</object>
我们得到以下结果:
- 第 17 行:原始异常类型为 [FileNotFoundException]
- 第 15 行:[dao] 层将此异常封装为 [Entities.ImportException] 类型
- 第 9 行:Spring 因无法实例化名为“dao”的对象而抛出的异常。在创建该对象的过程中,此前已发生过两个其他异常:即第 11 行和第 13 行的异常。
- 由于无法创建“dao”对象,因此无法创建应用上下文。这就是第 5 行异常的含义。此前,第 7 行已发生过另一个异常。
- 第 3 行:最高级别的异常,即异常链中的最后一个:报告了一个配置错误。
由此可见,我们应记住,最深层的异常(即第 17 行处的异常)通常也是最重要的。但请注意,Spring 保留了第 17 行处的错误信息,并将其传递给了第 3 行处的最高级别异常,以便在最高级别保留错误的原始原因。
仅 Spring 这一主题就值得写一本书。我们在此仅是略作探讨。您可通过 Spring 安装目录中的文档 [spring-net-reference.pdf] 进行更深入的探索:
![]() |
另请参阅 [http://tahe.developpez.com/dotnet/springioc],这是一篇基于 VB.NET 环境的 Spring 教程。






























































