65.9K
CodeProject 正在变化。 阅读更多。
Home

Layers Pattern 实践

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (57投票s)

2010年4月22日

CPOL

25分钟阅读

viewsIcon

158501

downloadIcon

8201

通过WPF项目实现分层模式。

目录

引言

本文的主要任务是介绍一个通用应用程序的生命周期,以及程序员日常面临的常见问题和解决方案。一旦你开始构建一个应用程序,你必须考虑很多事情,主要来自编程的不同理论领域。当然,作为软件产品的架构师,你可能会在规范设计之初就遇到各种问题,主要与理解项目的整体复杂性有关。下面文字的目的是强调在应用程序构思之初需要考虑的关键点。本文将以一个简单的WPF软件产品为例,从其规范设计开始,经过三层类层次结构(用户界面设计 - GUI;业务逻辑 - 下文简称BL;以及数据访问层 - DAL)的开发,最终以一个安装项目和发布后调试结束。有人可能会争辩说“编程是一门艺术”,没有必要事先定义通用规则,从而限制架构师的思维。确实如此,本文将尝试展示Windows应用程序设计中常见问题的解决方案,将生命周期方法论的选择留给架构师。

背景

“架构告诉发生了什么,实现者告诉如何实现” Blaauw

下面编写的教程将通过展示问题如何一起解决以及如何解决来尝试兼顾架构师和实现者的思维。将要展示的软件示例是一个 **BillsManager**。该应用程序的最终目标是允许客户管理其账单(简单明了)。

使用技术

我决定将 BillsManager 构建为一个 Windows 应用程序,它基于日益流行的 Windows Presentation Foundation 技术。我发现它非常突出,确实是 Windows 开发向前迈出的一大步。数据访问层将围绕 XML 文件构建,这些文件将存储用户的账单。因此,可以推断,最低要求是 .NET 3.5 SP.1。

概念完整性

“概念完整性是系统设计中最重要的考虑因素” Frederick Brooks

确实,正如实践所示,为了成功构建任何软件,必须明确定义书面规范(一个必要的工具,尽管不是充分的工具)。从理论到实践——下面列出了 BillsManager 将要实现的主要目标。

目标

  • 显示一个人本月需要花费多少钱
  • 归档付款
  • 显示最近的截止日期
  • 绘制支出图表
  • 按时间间隔绘制支出图表
  • 显示每张账单的信息
  • 提供将存档导出到文件进行备份的可能性
  • 假设目标受众不是英语使用者

BillsManager 将尝试实现这些相当直接的目标。尽管有人可能会争辩说这些规范不足以构建一个成功的应用程序,但我将它们保持原样,以免增加所有解释部分所基于的示例的复杂性。

三层应用程序

“软件架构包含关于软件系统组织的一系列重要决策,包括结构元素的选择及其组成系统的接口;这些元素之间协作中指定的行为;将这些结构和行为元素组合成更大的子系统;以及指导这种组织的架构风格。软件架构还涉及功能性、可用性、弹性、性能、重用、可理解性、经济和技术限制、权衡以及美学考量” P.Kruchten, G. Booch, K. Bittner, R.Reitman

正如上面自述段落所述,在设计应用程序时,软件架构团队应该对需求将如何投射到真实环境中,以及该环境如何尽可能通用有一个强烈的看法。接口、业务逻辑和数据访问层的分离是软件开发中非常重要且常见的需求。本质上,如今,可重用性在面向对象范式中扮演着至关重要的角色。BillsManager 应用程序将使用分层模式指南实现三层结构。图1展示了将要使用的系统化的总体视图。

三层模式用于构建组织更好的软件组件。它提供了一种定义可重用业务组件的机制,同时提供了部署灵活性和智能资源连接管理。所有负责数据可视化(例如 `DataGrid`)的组件都将放置在表示层。所有业务逻辑规则都将封装在 BL 层内的业务组件中。最后,所有与数据相关的代码都需要在数据访问层中定义。通常,DAL 层负责数据库访问。在本文提供的示例中,数据库将是一个 XML 文件(但这并没有区别,只要 GUI 和 BL 不知道底层数据源)。有关此模式的更高级解释,请参阅 使用 Microsoft .NET 的企业解决方案模式

数据访问层

为了拥有一个灵活的架构组件,我将从每个 DAL 连接管理器应实现的契约(接口)开始,以满足领域层的要求。以下是 `IDalBillsManager` 接口的代码。

/// <summary>
/// Interface for Bills' data source manager
/// </summary>
public interface IDalBillsManager
{
    /// <summary>
    /// Read bills
    /// </summary>
    Bills Read(DateTime fromDate, DateTime toDate);
    /// <summary>
    /// Read a bill from datasource by its ID
    /// </summary>
    Bill ReadById(Guid id);
    /// <summary>
    /// Insert bills into the datasource
    /// </summary>
    void Insert(Bills bill);
    /// <summary>
    /// Delete a bill from datasource
    /// </summary>
    int Delete(Guid guid);
    /// <summary>
    /// Delete a enumeration of bills from the datasource
    /// </summary>
    int Delete(IEnumerable<Guid> guids);
    /// <summary>
    /// Update the bill from database
    /// </summary>
    int Update(Guid billId, Bill newBill);
    /// <summary>
    /// Set settings for DAL Provider
    /// </summary>
    object[] Settings
    {
        get;
        set;
    }
}

从需要实现的方法可以看出,它们定义了一个非常通用的契约,可以总结为以下职责

  • 从数据源读取账单(可以是任何数据源:XML、Excel、SQL Server、网络资源等)。
  • 从数据源删除账单。
  • 更新数据源中的账单。
  • 在数据源中插入账单。
  • 获取或设置数据源的设置(例如,连接字符串、存档文件路径等)。

需要指出的是,契约应该放弃 [IN] 和 [OUT] 参数中的以下规则。它应该具有

  • 输入 [IN] 处最通用的参数(例如,`IEnumerable` 接口)
  • 最具体的返回类型 [OUT](例如,`Bills`)

在接口定义之后,值得一提的是,业务逻辑程序员将仅通过接口实例访问 DAL 管理器的方法。

private IDalBillsManager _dalManager = XmlDalBillsManager.GetInstance();

如果在未来的开发周期中,您选择将 DAL 管理器切换到另一个版本或全新的库,您只需更改一行代码,即上面编写的赋值操作。完成此操作后,整个业务逻辑库将按预期工作,无需对代码进行任何其他更改。`IDalBillsManager` 接口中定义的职责将由数据访问管理器承担。在我们的例子中,它将是一个基于 XML 的 DAL 管理器,这意味着所有账单信息将存储在 XML 存档文件中。下面,您可以看到我们的存档将放弃的 XML 模式(此模式位于 *BillEntityLib* 项目文件夹下的 *BillSchema.xsd* 中)。非常重要的是,我们的 XML 将使用严格建立的组合规则。

<?xml version="1.0" encoding="utf-8"?>
<xs:schema id="billschema"
    targetNamespace="http://tempuri.org/billschema.xsd"
    elementFormDefault="qualified"
    xmlns="http://tempuri.org/billschema.xsd"
    xmlns:mstns="http://tempuri.org/billschema.xsd"
    xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <xs:element name="Bills">
    <xs:complexType>
      <xs:choice minOccurs="1" maxOccurs="unbounded">
        <xs:element name="Bill" type="Bill"/>
      </xs:choice>
    </xs:complexType>
  </xs:element>
  <xs:complexType name="Bill">
    <xs:sequence>
      <xs:element name="Name" type="xs:string"
                  minOccurs="1" maxOccurs="1"/>
      <xs:element name="DueDate" type="xs:dateTime"
                  minOccurs="1" maxOccurs="1"/>
      <xs:element name="Amount" type="xs:decimal"
                  minOccurs="1" maxOccurs="1"/>
      <xs:element name="AddedOn" type="xs:dateTime"
                  minOccurs="1" maxOccurs="1"/>
      <xs:element name="Status" type="xs:string"
                  minOccurs="1" maxOccurs="1"/>
    </xs:sequence>
    <xs:attribute name="ID" form="unqualified" type="xs:string"/>
  </xs:complexType>
</xs:schema>

XSD 文件描述了 XML 文档中允许的内容,以便后一个文档被认为是有效的(即,在定义的约束范围内)。如果您不熟悉 XML 模式及其规则,请查阅有关此 主题 的任何可用材料。我们的 XML 模式由以下元素组成

  • `Bills` (根元素)
    • `Bill` (将代表每张账单)
      • `Name` (账单名称)
      • `DueDate` (账单到期日)
      • `Amount` (账单金额)
      • `AddedOn` (账单添加日期)
      • `Status` (账单状态)

我们基于 XML 的数据访问管理器将是一个单例对象(这意味着在整个应用程序生命周期中将只有一个对象实例)。如果您不熟悉单例,请参阅 单例模式文章 以获得更好的解释。下图展示了 `XmlDalBillsManager` 类的类图。(请注意,它实现了 `IDalBillsManager` 接口。)

因此,我们基于 XML 的管理器应该支持*线程安全*操作(这样,如果多个线程同时尝试写入数据库,存档就不会损坏——这可能导致数据丢失或不可预测的行为)。因此,用户可以调用 `Insert`、`Read`、`Update` 和 `Delete` 操作,而无需知道存档如何防止损坏。不难看出,这里我们可以实现读/写线程算法来解决上述问题。在此,管理器将实现以下策略

  • 如果有人从存档中读取数据,其他读取者被允许执行相同的操作(禁止写入者访问)。
  • 如果有人将数据写入存档,则任何其他线程(读取或写入)都不能对上述数据源执行任何操作。

如需更详细的审查,请查阅与 读写者 线程算法相关的任何可用材料。

实体对象

定义了 XML 模式后,我们现在可以生成实体类。这些是不承担任何责任(没有任何方法)的类。它们只是 XML 中数据的二进制表示。`Bills` 和 `Bill` 类在业务逻辑域中只执行存储功能(除了其属性的 getter/setter 之外,它们不会有任何额外的方法)。分层模式中存在不同的方法,无论实体和业务逻辑元素如何。您可以在实体库中的相同类中定义业务逻辑方法(`Insert`、`Read`、`Delete`、`Update`)(稍后我们将看到业务域中实际将实现哪些方法)。其他的可以分成两个不同的类。我使用了以下业务组件分离方式

  • 实体类
  • 管理器类

如果您不理解整个概念,请不要担心;一旦您查看实际实现,您就会明白。我决定进行分离,因为它更自然地在计算机内存中拥有与数据源中完全相同的 `Bill` 项副本(无论数据源是什么:XML、文本、Excel、关系数据库等)。同时,这些实体对象实际上将是可序列化的。此功能将允许任何 .NET 程序员通过在业务逻辑和数据层中添加新层,并将对象从业务域以序列化状态传递到数据域来扩展程序功能。序列化允许将对象的状​​态(`Bills` 和 `Bill`)保存到任何流(内存、网络、文件等)中,并在应用程序域或远程处理服务中传递。

从 *BillSchema.xsd* 文件可以看出,将只有两个实体类:`Bills` 和 `Bill`。 .NET 提供了一种从 *.xsd* 模式生成 *.cs* 文件的简便机制。这种机制通过 XML 架构定义 工具实现。可以通过 Visual Studio 的命令提示符访问 *xsd.exe*,或者通过打开命令行并导航到 *c:\Program Files\Microsoft Visual Studio 9.0\VC>*。需要应用的命令是 *xsd [xml 架构名称].xsd /c*。通过应用此命令,XSD 工具将生成与架构定义对应的部分类。

//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//     Runtime Version:2.0.50727.4926
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

using System.Xml.Serialization;

// 
// This source code was auto-generated by xsd, Version=2.0.50727.3038.
// 


/// <remarks/>
[System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "2.0.50727.303")]
[System.SerializableAttribute()]
[System.Diagnostics.DebuggerStepThroughAttribute()]
[System.ComponentModel.DesignerCategoryAttribute("code")]
[System.Xml.Serialization.XmlTypeAttribute(AnonymousType=true, 
        Namespace="http://tempuri.org/billschema.xsd")]
[System.Xml.Serialization.XmlRootAttribute(
        Namespace="http://tempuri.org/billschema.xsd", 
        IsNullable=false)]
public partial class Bills { /*Bills collection from the datasource*/
    
    private Bill[] itemsField;
    
    /// <remarks/>
    [System.Xml.Serialization.XmlElementAttribute("Bill")]
    public Bill[] Items {
        get {
            return this.itemsField;
        }
        set {
            this.itemsField = value;
        }
    }
}

/// <remarks/>
[System.CodeDom.Compiler.GeneratedCodeAttribute("xsd", "2.0.50727.3038")]
[System.SerializableAttribute()]
[System.Diagnostics.DebuggerStepThroughAttribute()]
[System.ComponentModel.DesignerCategoryAttribute("code")]
[System.Xml.Serialization.XmlTypeAttribute(
  Namespace="http://tempuri.org/billschema.xsd")]
public partial class Bill {
/*Each bill from the datasource, has exactly the same properties*/
    
    private string nameField;
    
    private System.DateTime dueDateField;
    
    private decimal amountField;
    
    private System.DateTime addedOnField;
    
    private string statusField;
    
    private string idField;
    
    /// <remarks/>
    public string Name {
        get {
            return this.nameField;
        }
        set {
            this.nameField = value;
        }
    }
    
    /// <remarks/>
    public System.DateTime DueDate {
        get {
            return this.dueDateField;
        }
        set {
            this.dueDateField = value;
        }
    }
    
    /// <remarks/>
    public decimal Amount {
        get {
            return this.amountField;
        }
        set {
            this.amountField = value;
        }
    }
    
    /// <remarks/>
    public System.DateTime AddedOn {
        get {
            return this.addedOnField;
        }
        set {
            this.addedOnField = value;
        }
    }
    
    /// <remarks/>
    public string Status {
        get {
            return this.statusField;
        }
        set {
            this.statusField = value;
        }
    }
    
    /// <remarks/>
    [System.Xml.Serialization.XmlAttributeAttribute()]
    public string ID {
        get {
            return this.idField;
        }
        set {
            this.idField = value;
        }
    }
}

以下是实体对象的类图。请注意,我添加了一些额外的类成员,这些成员是在生成的基础上添加的。(我稍后会解释为什么 `Bill` 类需要实现 `INotifyPropertyChanged` 接口)。

业务逻辑层

现在我将讨论业务逻辑的职责以及它们在实际代码中是如何实现的。与数据域的情况一样,我决定定义一个 `IBillsManager` 接口,它将向所有实现该接口的类提供契约。以下是 `IBillsManager` 接口的定义

/// <summary>
/// Interface for Bill manager business logic
/// </summary>
public interface IBillsManager
{
    /// <summary>
    /// Read from data source
    /// </summary>
    Bills Read(DateTime fromDate, DateTime toDate);

    /// <summary>
    /// Reads a bill by its ID
    /// </summary>
    Bill ReadById(Guid id);

    /// <summary>
    /// Insert a bill into the database
    /// </summary>
    void Insert(Bills bill);

    /// <summary>
    /// Delete a bill from database
    /// </summary>
    int Delete(Guid bill);

    /// <summary>
    /// Delete a list of bills with the specified guids
    /// </summary>
    int Delete(IEnumerable<Guid> guids);

    /// <summary>
    /// Update a bill in the database
    /// </summary>
    int Update(Guid billId, Bill newBill);

    /// <summary>
    /// Settings
    /// </summary>
    object[] Settings
    {
        get;
        set;
    }
}

您可以看到,上述接口定义的 方法与数据层接口 `IDalBillsManager` 完全相同。是的,这些方法确实相同,因为实际的职责也相同。唯一的区别是,每个管理器(无论是来自业务域还是数据层)都负责对通过的数据执行不同的逻辑操作。例如,请看下图

正如所见,尽管业务领域和数据领域的 方法签名相同,但在内部,它们负责对象之间不同的逻辑交互。在上面的示例中,业务领域的插入操作负责

  • 验证
  • 向数据层发送请求
  • 检查插入操作是否发生
  • 适当时抛出异常

同时,数据层负责

  • 向数据源执行实际的插入操作

有人可能会争辩说,没有必要为业务领域和数据领域定义两个具有相同签名但不同的接口;相反,我应该为两者使用相同的接口。我明确认为这个论点是一个错误,因为客户端程序员能够将业务和数据逻辑对象强制转换为相同的接口,而不知道他访问的是哪个领域的方法。这种方法打破了分层分离的整个理念,绝不应该使用。

表示层

模式中最后但并非最不重要的一层是表示层。它实际上负责用户与机器之间的交互。在开发 GUI(图形用户界面)时,可能会发现许多主题很有用。如果您正在构建一个 WPF 应用程序,您可能会发现在 CodeProject 上查看 Sacha Barber 的文章 很有用。无论如何,我将介绍其中几个我认为最通用的。我们将从 `DataGrid` 开始,这是一个负责向最终用户呈现数据的组件。然后我们将转向一个更具体的问题,如数据图表和字符串资源本地化。

数据网格

默认情况下,.NET 3.5 不附带 WPF 数据网格组件。关于它好不好,有不同的观点。我只想指出,我(和许多其他开发人员一样)决定使用 WPFToolkit,它可以从 CodePlex 网站免费下载(点击此处),以拥有一个内置的 `DataGrid`,功能齐全。我个人认为这些组件非常有用,所以我真的鼓励大家去探索它们。

数据网格中需要定义的核心操作是它与数据源的绑定。绑定是建立 UI(用户界面)组件与业务逻辑之间连接的概念。MSDN 网站上有一篇非常好的文章解释了绑定在 WPF 组件中的工作原理(点击此处)。通常,每个绑定都有这四个组件

  • 绑定目标对象
  • 目标属性
  • 绑定源
  • 绑定源中要使用的值的路径

例如,如果您想将 `TextBox` 的内容绑定到 `Employee` 对象的 `Name` 属性,那么您的目标对象是 `TextBox`,目标属性是 `Text` 属性,要使用的值是 `Name`,源对象是 `Employee` 对象。以下是声明和定义 `BillsManager` 的 `DataGrid` 以及必要绑定的 XAML 代码。

<WPFToolkit:DataGrid x:Uid="_dgBills" 
        Style="{StaticResource DataGrid}" 
        ItemContainerStyle="{StaticResource ItemContStyle}" 
        x:Name="_dgBills" Margin="8,42.96,8,167.08" 
        IsReadOnly="False" AutoGenerateColumns="False" 
        CanUserAddRows="False">
    <WPFToolkit:DataGrid.Columns>
        <WPFToolkit:DataGridTextColumn Header="Bill" 
            Width="100" Binding="{Binding Name}"/>
        <WPFToolkit:DataGridTextColumn Header="Due Date" 
            Width="100" 
            Binding="{Binding DueDate, Converter={StaticResource DateConverter}}"/>
        <WPFToolkit:DataGridTextColumn Header="Amount" 
            Width="75" Binding="{Binding Amount}"/>
        <WPFToolkit:DataGridTextColumn Header="Added On" 
           Width="100" 
           Binding="{Binding AddedOn, Converter={StaticResource DateConverter}}"/>
        <WPFToolkit:DataGridComboBoxColumn Header="Status" 
           Width="75" 
           SelectedItemBinding="{Binding BillStatus}" 
           ItemsSource="{Binding Source={StaticResource myEnum}}"/>
    </WPFToolkit:DataGrid.Columns>
</WPFToolkit:DataGrid>

如您所见,每个 `DataGrid` 列都绑定到我们实体库 `Bill` 类中的特定属性。

  • Name - `Binding="{Binding Name}"`
  • DueDate - `Binding DueDate, Converter={StaticResource DateConverter}`
  • Amount - `Binding="{Binding Amount}"`
  • AddedOn - `Binding="{Binding AddedOn, Converter={StaticResource DateConverter}}"`
  • Status - `SelectedItemBinding="{Binding BillStatus}" ItemsSource="{Binding Source={StaticResource myEnum}}"`

值得一提的是,`DataGrid` 列定义中的 `Converter` 项将执行 `DataTime` 值的格式化操作(它将以短格式 DD.MM.YY 显示日期)。下面显示了转换器项的实际代码

[ValueConversion(typeof(DateTime), typeof(String))]
public class DateConverter : IValueConverter
{
    /*
     * Convert each data item from the data grid into short format DD/MM/YY 
     */
    public object Convert(object value, Type targetType, 
                  object parameter, CultureInfo culture)
    {
        DateTime date = (DateTime)value;
        return date.ToShortDateString();
        /*Show the date in the datagrid in Short format*/
    }
    /*Convert back*/
    public object ConvertBack(object value, Type targetType, 
           object parameter, CultureInfo culture)
    {
        string strValue = value as string;
        DateTime resultDateTime;
        if (DateTime.TryParse(strValue, out resultDateTime))
        {
            return resultDateTime;
        }
        return DependencyProperty.UnsetValue;
    }
}

以上代码按照以下逻辑将数据绑定到 `Bills`

“要将数据网格绑定到数据,请将 `ItemsSource` 属性设置为 `IEnumerable` 实现。数据网格中的每一行都绑定到数据源中的一个对象,数据网格中的每一列都绑定到数据对象的一个属性。为了在向源数据添加或删除项时自动更新 `DataGrid` 用户界面,`DataGrid` 必须绑定到实现 `INotifyCollectionChanged` 的集合,例如 `ObservableCollection`。为了自动反映属性更改,源集合中的对象必须实现 `INotifyPropertyChanged` 接口”。点击 此处 获取更多详细信息。

数据源中的 `Bills` 通过 `Bills` 类在计算机内存中表示。然后,这些项嵌入到 `ObservableCollection` 集合中。这样,我们可以添加、删除、编辑底层账单列表的元素,而无需实际关心更新用户界面以反映更改(.NET 将自动执行此操作)。点击 此处 获取更多详细信息。

如上所述,我们将数据网格项源属性绑定到 `ObservableCollection`。

_collection = new ObservableCollection<Bill>(bills.Items);
//define the ObservableCollection<T> class

this._datagrid.ItemsSource = _collection;
//bind the data to the datasource

非连接数据环境

现在我将讨论在我们的 BillsManager 应用程序中实现的非连接数据环境方法。这种环境为我们提供了关于账单对象在数据网格控件中的更新、删除和插入如何反映在实际数据源中的答案。通常,有两种基本机制

  • 连接数据环境 - 一旦业务领域中的实体(`Bill`)被修改,实际的修改也将在数据领域中执行,这意味着在两个层中都定义了强大的双向关系。
  • 非连接数据环境 - 在业务领域中修改的实体(`Bills`)被标记为 *updated*,但在数据领域中不执行任何修改。一旦应用程序中执行了关键操作(例如,应用程序关闭),所有被标记为 *updated* 的字段都会根据其新值在数据领域中修改。

第二种方法在使用上具有更多优点。这些优点主要与更大的灵活性有关。想象一下用户将账单状态从 `Unpaid` 更改为 `Paid`。此外,他可能希望修改同一账单的 `Amount` 字段。在连接数据环境中,这些操作将导致以下方法调用

  • 将账单的状态属性从 `Unpaid` 更改为 `Paid`
  • 更改账单金额属性

在断开连接的数据环境中,上述操作将导致以下结果

  • 将账单标记为 `Updated`(请注意,此操作不会调用数据域中的任何方法)。
  • 一旦调用 `Commit` 操作,立即更新账单的两个属性。

上述机制是通过使用发布/订阅事件模型实现的。每个账单实体都允许任何客户端订阅其 `PropertyChangedEventHandler PropertyChanged` 事件。一旦您订阅了此事件,您将收到账单属性是否更改的通知。

网格样式

网格的样式位于资源字典(*DataGrid.Generic.xaml*)中。网格样式有趣的地方是条件格式化。一旦账单的到期日逾期且账单状态为 `Unpaid`,数据行会将其颜色从透明变为黄色。这是通过条件格式化完成的。开发人员可以将网格的 `ItemContainerStyle` 属性设置为一个元素,该元素将根据代码中定义的某些逻辑动态更改数据行的背景。因此,我们将定义一个新的资源字典元素并将其命名为 `ItemContStyle`

<Style x:Key="ItemContStyle" TargetType="{x:Type WPFToolkit:DataGridRow}">
    <Style.Resources>
        <billPayManager:DataRowBackgroundConverter x:Key="BackgroundConverter" />
    </Style.Resources>
    <Setter Property="WPFToolkit:DataGrid.Background">
        <Setter.Value>
            <MultiBinding Converter="{StaticResource BackgroundConverter}">
                <MultiBinding.Bindings>
                    <Binding Path="DueDate"/>
                    <Binding Path="Status"/>
                </MultiBinding.Bindings>
            </MultiBinding>
        </Setter.Value>
    </Setter>
</Style>

`DataRowBackgroundConverter` 类的 `Convert` 方法将被调用。根据数据行中设置的数据,它将返回 `Yellow` 或无颜色。

[ValueConversion(typeof(object), typeof(int))]
public class DataRowBackgroundConverter : IMultiValueConverter
{
    #region IMultiValueConverter Members
    /*
     * Check each element for the corresponding background color
     */
    public object Convert(object[] values, Type targetType, 
           object parameter, CultureInfo culture)
    {
        if (values[0] is DateTime)
        {
            DateTime dueDate = (DateTime)values[0];
            string status = values[1] as string;
            if (dueDate < DateTime.Now && 
                BillHelper.Convert(status) == BillStatus.Unpaid)
            {
                return new SolidColorBrush(Colors.Yellow);
                /*Return Yellow if DueDate is overdue and BillStatus is unpaid*/
            }
        }
        return null;
    }

    public object[] ConvertBack(object value, Type[] targetTypes, 
           object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }

    #endregion
}

如您所见,WPF 在数据绑定和数据格式化方面提供了极大的灵活性。

图表

图表是编程论坛上经常讨论的话题。许多人觉得它具有挑战性,因为需要考虑很多事情。我决定使用第三方图表工具,而不是 WPFToolkit 中定义的工具。它可以从 Visifire 网站 免费下载。它是一个提供开源数据可视化组件的框架。我发现它非常漂亮且易于使用。我想指出的是,图表本身根据一组不同的参数绘制数据,这些参数可以通过多种方式设置(`DataSeries` - 实际表示 X, Y 对的数据点系列,其中 F(x) = Y;`Axis` - 饼图、折线图、条形图、2D、3D 等,`Title` - 实际标题文本)。为了拥有一个可扩展的机制,可以使用不同的 `Axis` 绘制图表,我决定使用策略模式。策略实际上是轴的表示方式(这样做是为了拥有三种不同类型的图表:折线图、条形图、饼图)。下图显示了这些项目的类图

因此,图表属性根据其中一种绘制方法进行设置

//chartType object of type ICharting
public static void SetChartValuesAsync(Chart chart, IEnumerable<bill> list, 
       ICharting chartType, IntervalTypes interval,
       ChartValueTypes chartValueType, bool scrollingEnabled, bool erasePrevious)
{
    if (chart != null)
    {
        chart.Dispatcher.BeginInvoke(new Action(delegate()
        {
            if (erasePrevious)
            {
                chart.AxesX.Clear();
                chart.AxesY.Clear();
                chart.Titles.Clear();
                chart.Series.Clear();
            }

            // Add title to Titles collection
            chart.Titles.Add(chartType.GetTitle(list.First().Name));
            /*Set Axis via interface instance*/
            chart.AxesX.Add(chartType.GetAxis(interval, list));
            chart.ScrollingEnabled = scrollingEnabled;
            chart.Series.Add(chartType.GetDataSeries(list, chartValueType));
        }), null);
    }
}

正如我们所见,该机制是可维护和可扩展的,因为即使我们在应用程序的后续版本中添加新类型的图表,我们只需实现相应的 `ICharting` 接口,并将其类型的一个对象传递给 `SetChartValuesAsync` 方法。在下图中,我们可以看到上述设置调整的示例

本地化

本地化是准备应用程序在多个位置运行的过程。.NET Framework 为本地化所有类型的客户端应用程序提供了非常全面的支持。 .NET Framework 中的文化信息和操作通过 `CultureInfo` 类的一个实例公开,该实例可用于读取给定区域设置的文化设置,以及在应用程序中设置特定区域设置。每个线程都维护一组文化信息,这些信息作为 `CultureInfo` 设置存储在 `CurrentCulture` 和 `CurrentUICulture` 属性中。

WPF 应用程序有两种设置不同区域状态的可能性

  • LocBaml 工具
  • Resx 方法

在我的应用程序中,我将使用 Resx 资源文件方法(主要是因为这是在 Windows 应用程序中设置不同区域设置的经典方式)。Resx 文件是基于 XML 的文件,它们被编译成 *resource.dll* 库,可以通过 `ResourceManager` 类加载到应用程序中。以下代码片段显示了执行实际加载的代码

CultureInfo currentCulture = Thread.CurrentThread.CurrentCulture;
string culture = ConfigurationManager.AppSettings["Culture"];
if (!String.IsNullOrEmpty(culture))
   if(culture != "Default")
       currentCulture = new CultureInfo(culture);
ResourceManager resx = new ResourceManager(
  "BillPayManager.Properties.Resources", typeof(App).Assembly);

`currentCulture` 对象将保存线程的文化描述。`resx` 对象将允许我们查询包含实际文化特定信息的 *.dll* 文件。BillsManager 应用程序从应用程序配置文件 `(Key = "Culture")` 读取默认文化。重要的是,如果 *App.config* 文件没有明确设置文化信息,则将使用 `en-US` 文化。以下是查询示例

this._lTotalAmount.Content = resx.GetString("TotalAmount", currentCulture);
this._lNearestDeadline.Content=resx.GetString("NearestDeadline",currentCulture);

资源文件存储在与文化信息同名的文件夹中(`en-US`、`ro-RO`)。接下来,我们可以可视化 `MainAssembly` 如何从相应目录获取资源(见图)。

要更改文化,请更改 *BillPayManager.exe.config* 文件或通过 *Miscellaneous* 选项卡进行更改

<appSettings>
    <add key="Culture" value="ro-RO"/>
</appSettings>

更改 Culture 键时请注意。您只能明确设置对应子文件夹中定义的文化(`en-US` 或 `ro-RO`)。

发布后调试

发布后应用程序调试的重要性不可低估。尽管应用程序测试最终可能只发现少量未检测到的错误,但一旦安装到用户机器上,应用程序的行为就无法预测。这可能由于以下几个原因:用户没有管理员权限,操作系统内部配置与预期不同,或者您的应用程序需要对关键区域进行读/写操作,例如系统驱动器(通常是 *C:\*)的根目录、注册表中的 *HKLM*(本地机器)键等。然而,开发人员有一种机制,可以让他们找出客户端机器上出现的问题,而无需亲自前往客户办公室或家中。这种机制称为 **追踪**。

尽管我们无法预测应用程序何时何地会崩溃,但我们可以假设在某些关键点,它可能会在执行某些操作时遇到问题。这些关键点可以标记为*可追踪的*。一旦应用程序到达这些点,我们应该将当前状态的信息(变量、堆栈跟踪等)写入一个文件,如果需要,该文件可以发送给开发人员以解决问题。拥有应用程序的日志文件将允许他们回忆导致应用程序崩溃的步骤。这种简单的机制可以节省大量时间和金钱。.NET Framework 随附 `System.Diagnostics` 命名空间,它具有许多功能,可以帮助我们追踪、调试和监控应用程序的性能。为了满足安装后调试目的,我们感兴趣的类是 `Trace` 类。接下来,我将从 此处 引用一段文字,它最好地描述了上述类的目的和用法。

您可以使用 `Trace` 类中的属性和方法来检测发布版本。检测允许您监控应用程序在实际运行环境中的健康状况。追踪有助于您隔离问题并修复它们,而不会干扰正在运行的系统。此类提供了显示 `Assert` 对话框的方法,以及发出始终失败的断言的方法。此类提供了以下写入方法的变体:`Write`、`WriteLine`、`WriteIf` 和 `WriteLineIf`。`BooleanSwitch` 和 `TraceSwitch` 类提供了动态控制追踪输出的方法。您可以在不重新编译应用程序的情况下修改这些开关的值。您可以通过向 `Listeners` 集合添加或从中删除 `TraceListener` 实例来自定义追踪输出的目标。`Listeners` 集合由 `Debug` 和 `Trace` 类共享;向任一类添加追踪侦听器会将侦听器添加到两者。默认情况下,追踪输出使用 `DefaultTraceListener` 类发出。

Microsoft 最佳实践团队建议开发人员使用 *App.config* 文件调整跟踪侦听器(这很明显,因为应用程序配置文件中的更改不需要重新编译)。无论如何,我决定在代码中调整诊断对象,因为存在几个问题。例如,考虑您想使用 `DelimiterTraceListener` 作为您的主要侦听器。一旦您在 *App.config* 中指定了实际存储输出消息的文件路径,您就无法检查用户是否对该位置具有写入权限。这意味着如果用户没有所需的权限,一旦 CLR 尝试实例化上述对象,应用程序将在启动时抛出异常。因此,BillsManager 应用程序在下面编写的方法中调整 `Trace` 对象。

/// <summary>
/// Initialize Delimited Trace listener
/// </summary>
private void InitializeDelimitedTraceListener()
{
    bool enabled;
    string tracepath = "";
    try
    {
        enabled = Convert.ToBoolean(
          ConfigurationManager.AppSettings["BooleanSwitch"], 
          _currentCulture);
        tracepath = System.IO.Path.GetFullPath(
          ConfigurationManager.AppSettings["PathToTraceFile"]);
    }
    catch (FormatException)
    {
        enabled = false; /*Disable Boolean switch*/
    }
    this._chbTrace.IsChecked = enabled;
    string path = ProbeFilePermissions(tracepath, FileIOPermissionAccess.Write);
    /*Check if user has enough privileges to Write in the corresponding folder */     

    if (path != tracepath)
    {

        try
        {
            ModifyAppConfig("PathToTraceFile", path);
            /*Modify App.Config if another path is chosen*/
        }
        catch(ConfigurationErrorsException)
        {
            Debugger.Break();
        }
    }
    long maxSize = 1045680; /*1 MB*/
    try
    {
        maxSize = Convert.ToInt32(
          ConfigurationManager.AppSettings["SizeOfTrace"]);
          /*Max Size of trace file*/
    }
    catch (FormatException)
    {
        Debugger.Break();
    }
    finally
    {
        try
        {
            FileInfo fileInfo = new FileInfo(path);
            if(fileInfo.Length > maxSize)
                File.Delete(path);
            
        }
        catch(Exception)
        {
            Debugger.Break();
        }
    }
    DelimitedListTraceListener listener = new DelimitedListTraceListener(path)
                                              {
                                                  Delimiter = "__",
                                                  IndentSize = 4,
                                                  TraceOutputOptions = TraceOptions.DateTime
                                              };
    Trace.Listeners.Add(listener); /*Adding listener to the collection*/
    Trace.AutoFlush = true;
    this._bSwitch = new BooleanSwitch("Switch", "Switch");
    _bSwitch.Enabled = enabled;
    this.TraceSystemSettings(); /*Tracing system settings*/
}

`ProbeFilePermissions()` 方法探测该位置是否具有 `FileIOPermissionAccess.Write` 权限,如果用户缺少指定的权限,则返回用户具有 `Write` 权限的备用路径。

测试

测试是开发应用程序中最不愉快的环节之一。测试的执行方式有不同的方法(手动测试、自动化测试、单元测试)。它们主要取决于软件开发中使用的什么方法论。我更喜欢使用单元测试,因为它可以很好地可视化数据流、异常、断言等。为了编写单元测试,我们需要安装 NUnit 框架,可以从其官方 网站 免费下载。安装后,您可以使用 `NUnit.Framework` 命名空间中可用的功能来测试任何公共方法。有关编写单元测试的更多信息,请单击 此处

源代码

我使用 Visual Studio 2008 TS 编写了该应用程序。源代码结构如下:

  • *BillManagerUninstallAction* 项目。实现了自定义卸载操作,以便在应用程序从用户机器卸载后删除所有相关文件(存档和跟踪输出)。
  • *BillPayManager* 项目。这是实际的 WPF 应用程序。GUI 是使用 Expression Blend 3 IDE 开发的。
  • *BillsBusinessLogicLib* 项目。该库包含业务层管理所需的所有类(BL)。主类 - `BillsManager`。
  • *BillsDalLib* 项目。所有 XML 数据访问相关代码所在的库。DAL 的主类是 - `XmlDalBillsManager`。
  • *BillsEntityLib* 项目。实体库。
  • *TestBillPayManager* 项目。此项目中编写了几个单元测试夹具。我已将此项目从解决方案配置中删除,以便没有安装 NUnit 的用户可以无错误地编译解决方案。如果您想构建它,请将其添加到项目中。
  • *TestBillPayManagerManual* 项目。为 GUI 测试编写的手动测试。该项目也已卸载。
  • *SetupBillPayManager* 项目。BillsManager 的安装程序。

为了成功编译项目,我们需要在 `BillPayManagement` 项目中引用额外的库。这些库是

结论

在我学生的生涯中,我总是有足够的软件设计书籍。即使它们中的每一本都提供了非常好的软件架构分析,但没有一本谈到与实际实现相关的问题。这是因为作者很可能用抽象的术语来表达,以免将自己束缚在特定的技术上(在我的例子中是 .NET Framework)。这当然很棒,但我一直想要一个指南,它将以一个完整的产品为例,从规范设计开始,到安装项目结束。本文的目的是讨论与 .NET Windows 应用程序设计相关的主要问题,不是从理论角度,而是从实践角度。您可能会争辩说,架构设计是一个因产品而异的主题,而且几乎不可能谈论在应用程序生命周期中遇到的每一个微小问题。这当然是真的,但本文的目标受众是“计算机科学学生”,而不是已经了解所有内容的资深开发人员。这就是为什么我设计了一个简单的 WPF 应用程序,以便为希望构建此类应用程序的学生(或未入门的 WPF 开发人员)提供一个通用指南。我试图将文章的篇幅保持在尽可能小的范围内,但不知何故,它超出了最初的预期。感谢您的阅读。

谢谢

  • Alex Railean - http://railean.net/ 感谢他帮助我撰写本文。
  • Victoria - 感谢她的友善。
© . All rights reserved.