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

使用动态装饰器为对象添加方面

starIconstarIconstarIconstarIconstarIcon

5.00/5 (9投票s)

2010年9月30日

CPOL

15分钟阅读

viewsIcon

56813

downloadIcon

596

讨论如何在运行时为对象添加方面,并使用动态装饰器增强它们。

引言

在文章 动态装饰器模式 中,我介绍了一种动态装饰器,它可以在运行时向对象添加额外功能,而无需修改其类或在设计时编写装饰代码。它提供了一种方便的方式来向对象添加切面(横切关注点)。在本文中,我将讨论动态装饰器的一些重要特性以及如何在对象切面中使用它们。

背景

正如你在文章 动态装饰器模式 中看到的,向对象添加切面就像编写一个方法一样简单。通过将横切关注点分离到单独的方法(切面)中,然后使用它们来装饰现有对象,可以实现更优越的设计。虽然这在架构上是合理的,但这种方法的有用性取决于你可以在切面中做什么。这正是本文的目的。在本文中,我将讨论动态装饰器的一些重要特性以及如何利用这些特性来增强你的切面。讨论的特性包括:

  1. 添加预处理和后处理切面
  2. 将多个切面链接在一起
  3. 向切面传递参数
  4. 处理从切面抛出的异常
  5. 抑制从切面抛出的异常
  6. 在切面中访问目标对象
  7. 在切面中访问方法调用上下文
  8. 向 .NET Framework 对象添加切面

提供了这些特性的代码示例,以演示如何在切面中使用它们。还讨论了动态装饰器的一个限制。

阅读本文后,你应该能够使用动态装饰器进行严肃的编程,为你的对象编写切面。

Using the Code

*注意:本文中的代码已更新。此处的一些示例可能无法与 动态装饰器模式 中的代码一起使用。最新代码可从本文下载。

在接下来的讨论中,大多数示例都使用了 EmployeeEmployee 类的代码如下所示:

public interface IEmployee
{
    System.Int32? EmployeeID { get; set; }
    System.String FirstName { get; set; }
    System.String LastName { get; set; }
    System.DateTime DateOfBirth { get; set; }
    System.String FullName(bool bCommaSeparate);
    System.Single Salary();
}
public class Employee : IEmployee
{
    #region Properties

    public System.Int32? EmployeeID { get; set; }
    public System.String FirstName { get; set; }
    public System.String LastName { get; set; }
    public System.DateTime DateOfBirth { get; set; }

    #endregion

    public Employee(
        System.Int32? employeeid
        , System.String firstname
        , System.String lastname
        , System.DateTime bDay
    )
    {
        this.EmployeeID = employeeid;
        this.FirstName = firstname;
        this.LastName = lastname;
        this.DateOfBirth = bDay;
    }

    public Employee() { }

    public System.String FullName(bool bCommaSeparate)
    {
        if (bCommaSeparate)
        {
            System.String s = LastName + ", " + FirstName;
            Console.WriteLine("Full Name: " + s);
            return s;
        }
        else
        {
            System.String s = FirstName + " " + LastName;
            Console.WriteLine("Full Name: " + s);
            return s;
        }
    }

    public System.Single Salary()
    {
        System.Single i = 10000.12f;
        Console.WriteLine("Salary: " + i);
        return i;
    }
}

Employee 类也用于 动态装饰器模式,它实现了一个 IEmployee 接口。它实现了最基本的业务逻辑。

对于当今的大多数应用程序来说,仅实现业务功能是不够的。为了使应用程序正常工作,还需要满足安全性、日志记录等系统要求。这些系统要求不是业务特定的,并且往往会贯穿应用程序的多个抽象。

假设你想在调用 EmployeeSalary 方法之前进行安全检查,并且还想在调用 EmployeeFullNameSalary 方法之前和之后进行一些日志记录。这些需求是横切关注点的绝佳示例。将它们放入一些切面并用它们来装饰你的对象是理想的做法。

在以下代码示例中,我使用匿名方法作为切面,以使示例简洁且独立。但是,我也可以使用命名方法代替匿名方法,就像我在 动态装饰器模式 的示例中所做的那样。命名方法的优点在于它们可以在多个地方使用。如果设计得当,通过命名方法定义的切面可以用来装饰不同的对象,无论是相同类型还是不同类型的对象。

1. 添加预处理和后处理切面

预处理切面在目标方法执行之前执行,而后处理切面在目标方法执行之后执行。使用动态装饰器,你可以为预处理提供一个切面,为后处理提供第二个切面。

以下代码将一个包装在匿名方法中的预处理切面((x, y) => { Console.WriteLine("Do enter log here"); })和一个包装在第二个匿名方法中的后处理切面((x, y) => { Console.WriteLine("Do exit log here"); })添加到 emSalaryFullName 方法中。

IEmployee em = new Employee(1, "John", "Smith", new DateTime(1990, 4, 1));
IEmployee tpem = (IEmployee)ObjectProxyFactory.CreateProxy(
    em,
    new String[] { "Salary", "FullName" },
    new Decoration((x, y) => { Console.WriteLine("Do enter log here"); }, null),
    new Decoration((x, y) => { Console.WriteLine("Do exit log here"); }, null));

tpem.FullName(false);
Console.WriteLine("");
tpem.Salary();

执行上述代码后,你将看到以下结果:

Do enter log here
Full Name: John Smith
Do exit log here

Do enter log here
Salary: 10000.12
Do exit log here

如你所见,在调用 SalaryFullName 之前执行了预处理切面,而在调用 SalaryFullName 之后执行了后处理切面。

2. 将多个切面链接在一起

如果你有多个预处理切面或后处理切面,你可以通过多次调用 ObjectProxyFactory.CreateProxy,将前一个代理作为下一个代理的目标,来创建代理链以连接所有预处理切面或所有后处理切面。这样就可以构建一个强大的预处理或后处理链。例如,你希望在调用对象的某个方法之前同时添加进入日志记录和安全检查。与其将进入日志记录和安全检查逻辑放在一个切面中,不如为进入日志记录创建一个切面,为安全检查创建一个单独的切面,并将它们一个接一个地链接起来。这样,你就可以将不同的关注点保留在不同的切面中。

以下代码将一个安全检查预处理切面添加到 emSalary 方法中,然后,将一个进入日志记录预处理切面和一个退出日志记录后处理切面添加到 tpCheckRightSalaryFullName 方法中。请注意,tpCheckRightem 的代理,而 tpLogCheckRighttpCheckRight 的代理。当使用 tpLogCheckRight 访问 IEmployee 的方法时,执行路径为 tpLogCheckRight -> tpCheckRight -> em

IEmployee em = new Employee(1, "John", "Smith", new DateTime(1990, 4, 1));
IEmployee tpCheckRight = (IEmployee)ObjectProxyFactory.CreateProxy(
    em,
    new String[] { "Salary" },
    new Decoration((x, y) => { Console.WriteLine("Do security check here"); }, null),
    null);

IEmployee tpLogCheckRight = (IEmployee)ObjectProxyFactory.CreateProxy(
    tpCheckRight,
    new String[] { "Salary", "FullName" },
    new Decoration((x, y) => { Console.WriteLine("Do enter log here"); }, null),
    new Decoration((x, y) => { Console.WriteLine("Do exit log here"); }, null));

tpLogCheckRight.FullName(false);
Console.WriteLine("");
tpLogCheckRight.Salary();

执行上述代码后,你将看到以下结果:

Do enter log here
Full Name: John Smith
Do exit log here

Do enter log here
Do security check here
Salary: 10000.12
Do exit log here

tpLogCheckRight.FullName(false) 执行时,它会查找 tpCheckRight 的预处理切面并找到一个(匿名方法 (x, y) => { Console.WriteLine("Do enter log here"); }),因此会执行预处理切面。然后,它调用 tpCheckRightFullName(请注意,在这种情况下,tpCheckRighttpLogCheckRight 的目标)。在执行 tpCheckRightFullName 时,它会查找 em 的预处理切面但未找到。因此,它继续调用 emFullName。然后,它查找 em 的后处理切面但未找到。因此,它继续查找 tpCheckRight 的后处理切面并找到一个(匿名方法 (x, y) => { Console.WriteLine("Do exit log here"); }),因此会执行后处理切面。下面的图表展示了 tpLogCheckRight.FullName(false) 的执行顺序。

tpLogCheckRight.Salary() 执行时,它会查找 tpCheckRight 的预处理切面并找到一个(匿名方法 (x, y) => { Console.WriteLine("Do enter log here"); }),因此会执行预处理切面。然后,它调用 tpCheckRightSalary(请注意,在这种情况下,tpCheckRighttpLogCheckRight 的目标)。在执行 tpCheckRightSalary 时,它会查找 em 的预处理切面并找到一个(匿名方法 (x, y) => { Console.WriteLine("Do security check here"); }),因此会执行预处理切面。然后,它调用 emSalary。然后,它查找 em 的后处理切面但未找到。因此,它继续查找 tpCheckRight 的后处理切面并找到一个(匿名方法 (x, y) => { Console.WriteLine("Do exit log here"); }),因此会执行后处理切面。tpLogCheckRight.Salary() 的执行顺序与上面图表中显示的 tpLogCheckRight.FullName(false) 相同。

请注意,在代理链执行过程中,最后添加的预处理切面首先执行,然后是倒数第二个添加的预处理切面,依此类推,最后是第一个添加的预处理切面;在此之后,执行原始对象的方法;在此之后,第一个添加的后处理切面执行,然后是第二个添加的后处理切面,依此类推,最后是最后一个添加的后处理切面。

3. 向切面传递参数

你可以将一个对象数组传递给切面。通过在调用 ObjectProxyFactory.CreateProxy 时将对象数组作为 Decoration 构造函数的第二个参数来达到此目的。然后,当调用相应的切面时,这个数组将作为第二个参数传递给切面。在切面内部,你可以获取数组中每个对象的运行时类型并使用它。

在下面的示例中,一个实现 IPrincipal 接口的单个对象的数组被作为预处理切面的 Decoration 构造函数的第二个参数传递。当切面调用时,这个数组将作为第二个参数传递给切面(Decoration 构造函数的第一个参数)。在这种情况下,当切面执行时,y 代表数组。请注意,数组的类型是 object[]。在使用它之前,你需要将数组的第一个元素转换为 WindowsPrincipal。然后,你就可以访问特定类型的方法了。

Thread.GetDomain().SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal);

IEmployee em = new Employee(1, "John", "Smith", new DateTime(1990, 4, 1));
IEmployee tpem = (IEmployee)ObjectProxyFactory.CreateProxy(
    em,
    new String[] { "Salary" },
    new Decoration((x, y) =>
    {
        if (y != null && ((WindowsPrincipal)y[0]).IsInRole
			("BUILTIN\\" + "Administrators"))
        {
            Console.WriteLine("Has right to call");
            return;
        }

        throw new Exception("No right to call!");
    }
        , new object[] { Thread.CurrentPrincipal }),
    null);

tpem.Salary();

假设你已登录为 Windows 管理员,执行上述代码后,你将看到以下结果:

Has right to call
Salary: 10000.12

如果你没有登录为 Windows 管理员,在执行上述代码时,你将收到一个异常,并且 Salary 方法将不会被执行。

在此示例中,我使用匿名方法进行安全检查。当然,你可以将匿名方法的代码移到一个命名方法中,并在调用 ObjectProxyFactory.CreateProxy 时用它来代替匿名方法(参见 动态装饰器模式 中的示例)。此外,你还可以将此方法用作另一个对象(无论是相同类型还是不同类型)的切面。你只需要将此对象和命名方法传递给另一个 ObjectProxyFactory.CreateProxy 调用。现在,返回的代理对象将为你进行安全检查。

4. 处理从切面抛出的异常

当切面内部发生异常时,正常的代码执行会被中断。由于切面是在代理调用中执行的,因此实际的异常在上下文中会丢失。动态装饰器解决了这个问题,使应用程序能够看到实际的异常,而不是一个通用的调用异常。

在下面的代码中,预处理切面抛出了一个异常。方法调用 tpem.Salary() 被包装在一个 try...catch 子句中。

IEmployee em = new Employee(1, "John", "Smith", new DateTime(1990, 4, 1));
IEmployee tpem = (IEmployee)ObjectProxyFactory.CreateProxy(
    em,
    new String[] { "Salary" },
    new Decoration((x, y) => { throw new Exception
		("An exception has occurred!"); }, null),
    null);

try
{
    tpem.Salary();
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}

当上述代码执行时,你将看到以下结果:

An exception has occurred!

正如你所见,处理从切面抛出的异常与处理任何其他 .NET 异常没有区别。

5. 抑制从切面抛出的异常

大多数情况下,当发生异常时,正常的代码执行会被中断。然后,异常会在调用堆栈的更高级别进行处理。然而,有时你可能希望抑制从切面抛出的异常,并继续执行切面之后的代码。例如,当由于远程 Web 服务不可用而导致进入日志记录切面抛出异常时,你可能希望继续调用目标方法。Decoration 有一个重载的构造函数,可以接受第三个参数来启用抑制从切面抛出的异常。当此参数设置为 true 并且切面内部发生异常时,异常将被抑制,并且执行将继续,就像没有发生异常一样。

在下面的示例中,Decoration 的第三个参数设置为 true,这意味着如果在预处理切面中发生异常,它将在代理调用内部处理,并且程序的执行将继续,就像没有发生异常一样。

IEmployee em = new Employee(1, "John", "Smith", new DateTime(1990, 4, 1));
IEmployee tpem = (IEmployee)ObjectProxyFactory.CreateProxy(
    em,
    new String[] { "Salary" },
    new Decoration((x, y) => { throw new Exception
		("An exception has occurred!"); }, null, true),
    null);

try
{
    tpem.Salary();
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}

当上述代码执行时,你将看到以下结果:

Salary: 10000.12

正如你所见,即使在预处理切面中抛出了异常,程序的执行也会继续,并且 Salary 方法会被调用,就像从未发生过异常一样。如果第三个参数设置为 false 或未设置(通过使用带有两个参数的 Decoration 构造函数),你将看到异常,并且 Salary 方法不会被执行。

6. 在切面中访问目标对象

你可以在切面中访问目标对象。目标对象作为 ObjectProxyFactory.CreateProxy 的第一个参数传递。然后,当调用预处理和后处理切面时,它作为第一个参数传递给它们。有趣之处在于:一方面,你通过使用切面增强目标对象,为其添加额外功能;另一方面,在切面内部,你可以访问目标对象并使用它来增强你的切面。

在下面的代码中,目标对象 em 作为 ObjectProxyFactory.CreateProxy 的第一个参数传递。当执行 tpem.Salary() 时,会调用预处理切面,并将目标对象作为第一个参数 x 传递。在切面内部,使用了目标对象的运行时类型。

IEmployee em = new Employee(1, "John", "Smith", new DateTime(1990, 4, 1));
IEmployee tpem = (IEmployee)ObjectProxyFactory.CreateProxy(
    em,
    new String[] { "Salary" },
    new Decoration((x, y) =>
    {
        Console.WriteLine("Entering " + x.GetType().ToString());
    }
        , null),
    null);

tpem.Salary();

当上述代码执行时,你将看到以下结果:

Entering ThirdPartyHR.Employee
Salary: 10000.12

正如你所见,预处理切面能够访问目标对象并使用其运行时类型 ThirdPartyHR.Employee。如果你将相同的匿名方法用作另一个不同类型对象的预处理切面,你将获得不同的运行时类型,而不是 ThirdPartyHR.Employee

如果你将匿名方法的代码移到一个命名方法中,你可以将它用作应用程序中 em 以及其他对象(无论是相同类型还是不同类型)的切面。通过在切面中访问目标对象的能力,可以创建一个极其强大的场景:你编写一个命名方法,将其用作应用程序中各种对象的切面,然后该切面会自动呈现目标对象的运行时类型。

7. 在切面中访问方法调用上下文

你可以从切面内部访问方法调用上下文。方法调用上下文包括方法的运行时信息,如方法名称、参数类型和值等。方法调用上下文与目标对象一起提供了对象的完整运行时执行上下文。

下面的代码演示了如何通过调用 CallContext.GetData("Method Context") 来访问预处理切面内部的方法调用上下文,该调用返回一个 IMethodCallMessage 接口。从那里,你可以检索方法的各种运行时信息,包括方法名称、方法参数类型和值等。

IEmployee em = new Employee(1, "John", "Smith", new DateTime(1990, 4, 1));
IEmployee tpem = (IEmployee)ObjectProxyFactory.CreateProxy(
    em,
    new String[] { "Salary", "FullName" },
    new Decoration((x, y) =>
    {
        IMethodCallMessage method = 
	(IMethodCallMessage)CallContext.GetData("Method Context");
        string str = "Entering " + x.GetType().ToString() + "." + method.MethodName +
            "(";
        int i = 0;
        foreach (object o in method.Args)
        {
            if (i > 0)
                str = str + ", ";
            str = str + o.ToString();
        }
        str = str + ")";

        Console.WriteLine(str);
    }
        , null),
    null);

tpem.FullName(false);
Console.WriteLine("");
tpem.Salary();

当上述代码执行时,你将看到以下结果:

Entering ThirdPartyHR.Employee.FullName(False)
Full Name: John Smith

Entering ThirdPartyHR.Employee.Salary()
Salary: 10000.12

正如你所见,预处理切面不仅能够访问目标对象,还能访问正在调用目标对象的哪个方法以及其参数的值。当 tpem.FullName(false) 执行时,预处理切面获取目标类型 ThirdPartyHR.Employee,方法名称 FullName,以及方法参数的值 False。当 tpem.Salary() 执行时,预处理切面获取目标类型 ThirdPartyHR.Employee,方法名称 Salary,以及方法参数的值(在此情况下为空)。请注意,预处理切面装饰了目标对象的 FullNameSalary 方法。根据调用的是哪个方法,切面会访问实际的方法调用上下文并相应地输出它们。

如果你将预处理切面的代码移到一个命名方法中,你可以将其用作应用程序中其他对象(无论是相同类型还是不同类型)的切面。这里有趣的一点是:由命名方法定义的切面会自动检索目标对象的运行时类型和方法调用上下文。通过访问目标对象的运行时类型和方法调用上下文的能力,切面几乎可以做任何你在修改源代码时可以做的事情,以向对象添加额外功能。

8. 向 .NET Framework 对象添加切面

最后一个示例演示了如何向 .NET Framework 对象添加切面。这是一种简单而强大的增强 .NET 类库或其他第三方库的方式。

假设你想在事务完成(无论成功还是失败)后编写一些日志。我需要做的是在事务的 CommitRollback 方法之后添加一些日志记录逻辑。在下面的代码中,我将 ADO.NET 的 SqlTransaction 类的对象传递给 ObjectProxyFactory.CreateProxy,指定 CommitRollback 方法,并提供一个后处理切面。现在,事务对象具有日志记录功能。InsertOrder 包含两个 insert SQL 语句,它们被放入一个事务中。

string connStr = ConfigurationManager.ConnectionStrings["ConnStr"].ConnectionString;
IDbConnection conn = new SqlConnection(connStr);
IDbTransaction transaction = null;

conn.Open();
transaction = conn.BeginTransaction();

transaction = (IDbTransaction)ObjectProxyFactory.CreateProxy(
    transaction,
    new String[] { "Commit", "Rollback" },
    null,
    new Decoration((x, y) =>
    {
        IMethodCallMessage method = (IMethodCallMessage)CallContext.GetData
		("Method Context");
        Console.WriteLine(x.GetType().ToString() + "." + method.MethodName + " exited.");
    }
        , null));

try
{
    InsertOrder(conn, transaction);

    //Both insert commands succeeded. Commit the transaction.
    transaction.Commit();
}
catch (Exception e)
{
    //Exception occurred during the execution of insert commands. 
    //Rollback the transaction.
    if (transaction != null)
        transaction.Rollback();

    Console.WriteLine(e.Message);
}

当上述代码执行时,如果事务成功完成,你将看到以下结果:

System.Data.SqlClient.SqlTransaction.Commit exited.

如果事务失败,你将看到以下结果:

System.Data.SqlClient.SqlTransaction.Rollback exited.

正如你所见,向 .NET Framework 类对象添加切面与向自己的对象添加切面相同。此外,你可以将同一个切面用于 .NET Framework 类对象或自己的对象。例如,如果你将后处理切面代码移到一个命名方法中,你可以将其用作 SqlTransaction 对象和你的 Employee 对象的切面。

理想情况下,你可以设计多个切面(横切关注点,例如,进入日志记录、退出日志记录和安全检查等),并在应用程序的对象之间共享它们,无论它们是你的自定义类型对象、.NET Framework 类对象还是第三方类型对象。

动态装饰器的限制

动态装饰器的一个限制是它只能转换为接口(例如 IEmployeeIDbTransaction),而不能转换为类(例如 EmployeeSqlTransaction)。原因是 ObjectProxyFactory.CreateProxy 返回一个实现该接口的透明代理类的实例。尝试将透明代理类的实例转换为另一个类类型(如 EmployeeSqlTransaction)是错误的。

这意味着只有由类实现的接口方法才能用预处理切面或后处理切面进行装饰。考虑到接口编程的流行性及其优于基类继承的优势,这一限制微不足道。毕竟,方法必须是接口方法而不是类方法并没有什么吸引力。

关注点

使用动态装饰器,你可以通过为每个切面提供一个方法来为你的应用程序添加切面。然后,现有的对象(无论是相同类型还是不同类型)都可以通过这些切面在运行时进行装饰。编写切面时,请牢记以下几点:

  • 可以通过传递参数、访问目标对象和方法调用上下文来增强切面。
  • 一个对象可以有多个预处理切面和/或多个后处理切面。
  • 切面的异常处理是透明的。
  • 向 .NET Framework 类或第三方类的对象添加切面与向自己的类对象添加切面相同。

历史

  • 2010 年 9 月 30 日:初次发布
© . All rights reserved.