MVC程序中实体框架的连接恢复和命令拦截

这是微软官方教程Getting Started with Entity Framework 6 Code First using MVC 5 系列的翻译,这里是第四篇:MVC程序中实体框架的连接恢复和命令拦截

原文: Connection Resiliency and Command Interception with the Entity Framework in an ASP.NET MVC Application

到目前为止,应用程序已经可以在您本地机器上正常地运行。但如果您想将它发布在互联网上以便更多的人来使用,您需要将程序部署到WEB服务器并将数据库部署到数据库服务器。

在本教程中,您将学习在将实体框架部署到云环境时非常有价值的两个特点:连接回复(瞬时错误的自动重试)和命令拦截(捕捉所有发送到数据库的SQL查询语句,以便将它们记录在日志中或更改它们)。

注意:本节教程是可选的。如果您跳过本节,我们会在后续的教程中做一些细微的调整。

启用连接恢复

当您将应用程序部署到Windows Azure时,您会将数据库部署到Windows Azure SQL数据库中——一个云数据库服务。和您将Web服务器和数据库直接连接在同一个数据中心相比,连接一个云数据库服务更容易遇到瞬时连接错误。即使云Web服务器和云数据库服务器在同一数据中心机房中,它们之间在出现大量数据连接时也很容易出现各种问题,比如负载均衡。

另外,云服务器通常是由其他用户共享的,这意味着可能会受到其它用户的影响。您对数据库的存取权限可能受到限制,当您尝试频繁的访问数据库服务器时也可能遇到基于SLA的带宽限制。大多数连接问题都是在您连接到云服务器时瞬时发生的,它们会尝试在短时间内自动解决问题。所以当您尝试连接数据库并遇到一个错误,该错误很可能是瞬时的,当您重复尝试后可能该错误就不再存在。您可以使用自动瞬时错误重试来提高您的客户体验。实体框架6中的连接恢复能自动对错误的SQL查询进行重试。

连接恢复功能只能针对特定的数据库服务进行正确的配置后才可用:

  • 必须知道那些异常有可能是暂时的,您想要重试由于网络连接而造成的错误,而不是编程Bug带来的。

  • 在失败操作的间隔中必须等待适当的时间。批量重试时在线用户可能会需要等待较长时间才能够获得响应。

  • 需要设置一个适当的重试次数。在一个在线的应用程序中,您可能会进行多次重试。

您可以为任何实体框架提供程序支持的数据库环境来手动配置这些设定,但实体框架已经为使用Windows Azure SQL数据库的在线应用程序做了缺省配置。接下来我们将在Contoso大学中实施这些配置。

如果要启用连接恢复,您需要在您的程序集中创建一个从DbConfiguration派生的类,该类将用来配置SQL数据库执行策略,其中包含连接恢复的重试策略。

在DAL文件夹中,添加一个名为SchoolConfiguration.cs的新类。

使用以下代码替换类中的:

 using System.Data.Entity;
 using System.Data.Entity.SqlServer;
 
 namespace ContosoUniversity.DAL
 {
     public class SchoolConfiguration : DbConfiguration
     {
         public SchoolConfiguration()
         {
             SetExecutionStrategy("System.Data.SqlClient", () => new SqlAzureExecutionStrategy());
         }
     }
 }

实体框架会自动运行从DbConfiguration类派生的类中找到的代码,你同样也可以使用Dbconfiguration类来在web.config中进行配置,详细信息请参阅 EntityFramework Code-Based Configuration 。

在学生控制器中,添加引用:

using System.Data.Entity.Infrastructure;

更改所有捕获DataException的异常代码块,使用RetryLimitExcededException:

catch (RetryLimitExceededException)
            {
                ModelState.AddModelError("", "保存数据时出现错误。请重试,如果问题依旧存在请联系系统管理员。");
            }

在之前,你使用了DataException。这样会尝试找出可能包含瞬时错误的异常,然后返回给用户一个友好的重试提示消息,但现在你已经开启自动重试策略,多次重试仍然失败的错误将被包装在RetryLimitExceededException异常中返回。

有关详细信息,请参阅 Entity Framework Connection Resiliency / Retry Logic 。

启用命令拦截

现在你已经打开了重试策略,但你如何进行测试已验证它是否像预期的那样正常工作?强迫发出一个瞬时错误并不容易,尤其是您正在本地运行的时候。而且瞬时错误也难以融入自动化的单元测试中。如果要测试连接恢复功能,您需要一种可以拦截实体框架发送到SQL数据库查询的方法并替代SQL数据库返回响应。

你也可以在一个云应用程序上按照最佳做法: log the latency and success or failure of all calls to external services 来实现查询拦截。实体框架6提供了一个 dedicated logging API 使它易于记录。但在本教程中,您将学习如何直接使用实体框架的 interception feature (拦截功能),包括日志记录和模拟瞬时错误。

创建一个日志记录接口和类

best practice for logging 是通过接口而不是使用硬编码调用System.Diagnostice.Trace或日志记录类。这样可以使得以后在需要时更容易地更改日志记录机制。所以在本节中,我们将创建一个接口并实现它。

在项目中创建一个文件夹并命名为Logging。

在Logging文件夹中,创建一个名为ILogger.cs的接口类,使用下面的代码替换自动生成的:

 using System;
 
 
 namespace ContosoUniversity.Logging
 {
     public interface ILogger
     {
         void Information(string message);
         void Information(string fmt, params object[] vars);
         void Information(Exception exception, string fmt, params object[] vars);
 
         void Warning(string message);
         void Warning(string fmt, params object[] vars);
         void Warning(Exception exception, string fmt, params object[] vars);
 
         void Error(string message);
         void Error(string fmt, params object[] vars);
         void Error(Exception exception, string fmt, params object[] vars);
 
         void TraceApi(string componentName, string method, TimeSpan timespan);
         void TraceApi(string componentName, string method, TimeSpan timespan, string properties);
         void TraceApi(string componentName, string method, TimeSpan timespan, string fmt, params object[] vars);
 
 
     }
 }

该接口提供了三个跟踪级别用来指示日志的相对重要性,并且设计为可以提供外部服务调用(例如数据库查询)的延迟信息。日志方法提供了可以让你传递异常的重载。这样异常信息可以包含在栈中并且内部异常能够可靠地被该接口实现的类记录下来,而不是依靠从应用程序的每个日志方法来调用并记录。

TraceAPI方法使您能够跟踪到外部服务(例如SQL Server)的每次调用的延迟时间。在Logging文件夹中,创建一个名为Logger.cs的类,使用下面的代码替换自动生成的:

 using System;
 using System.Diagnostics;
 using System.Text;
 
 namespace ContosoUniversity.Logging
 {
     public class Logger : ILogger
     {
 
         public void Information(string message)
         {
             Trace.TraceInformation(message);
         }
 
         public void Information(string fmt, params object[] vars)
         {
             Trace.TraceInformation(fmt, vars);
         }
 
         public void Information(Exception exception, string fmt, params object[] vars)
         {
             Trace.TraceInformation(FormatExceptionMessage(exception, fmt, vars));
         }
 
         public void Warning(string message)
         {
             Trace.TraceWarning(message);
         }
 
         public void Warning(string fmt, params object[] vars)
         {
             Trace.TraceWarning(fmt, vars);
         }
 
         public void Warning(Exception exception, string fmt, params object[] vars)
         {
             throw new NotImplementedException();
         }
 
         public void Error(string message)
         {
             Trace.TraceError(message);
         }
 
         public void Error(string fmt, params object[] vars)
         {
             Trace.TraceError(fmt, vars);
         }
 
         public void Error(Exception exception, string fmt, params object[] vars)
         {
             Trace.TraceError(FormatExceptionMessage(exception, fmt, vars));
         }
 
 
 
         public void TraceApi(string componentName, string method, TimeSpan timespan)
         {
             TraceApi(componentName, method, timespan, "");
         }
 
         public void TraceApi(string componentName, string method, TimeSpan timespan, string properties)
         {
             string message = String.Concat("Component:", componentName, ";Method:", method, ";Timespan:", timespan.ToString(), ";Properties:", properties);
             Trace.TraceInformation(message);
         }
 
         public void TraceApi(string componentName, string method, TimeSpan timespan, string fmt, params object[] vars)
         {
             TraceApi(componentName, method, timespan, string.Format(fmt, vars));
         }
         private string FormatExceptionMessage(Exception exception, string fmt, object[] vars)
         {
             var sb = new StringBuilder();
             sb.Append(string.Format(fmt, vars));
             sb.Append(" Exception: ");
             sb.Append(exception.ToString());
             return sb.ToString();
         }
     }
 }

我们使用了System.Diagnostics来进行跟踪。这是.Net的使它易于生成并使用跟踪信息的一个内置功能。你可以使用System.Diagnostics的多种侦听器来进行跟踪并写入日志文件。例如,将它们存入blob storage或存储在Windows Azure。在 Troubleshooting Windows Azure Web Sites in Visual Studio 中你可以找到更多选项及相关信息。在本教程中您将只在VS输出窗口看到日志。 在生产环境中您可能想要使用跟踪包而非System.Diagnostics,并且而当你需要时,ILogger接口能够使它相对容易地切换到不同的跟踪机制下。

创建拦截器类

接下来您将创建几个类,这些类在实体框架在每次查询数据库时都会被调用。其中一个模拟瞬时错误而另一个进行日志记录。这些拦截器类必须从DbCommandInterceptor类派生。你需要重写方法使得查询执行时会自动调用。在这些方法中您可以检查或记录被发往数据库中的查询,并且可以再查询发送到数据库之前对它们进行修改,甚至不将它们发送到数据库进行查询而直接返回结果给实体框架。

在DAL文件夹中创建一个名为SchoolInterceptorLogging.cs的类,并使用下面的代码替换自动生成的:

 using System;
 using System.Data.Common;
 using System.Data.Entity;
 using System.Data.Entity.Infrastructure.Interception;
 using System.Data.Entity.SqlServer;
 using System.Data.SqlClient;
 using System.Diagnostics;
 using System.Reflection;
 using System.Linq;
 using ContosoUniversity.Logging;
 
 namespace ContosoUniversity.DAL
 {
     public class SchoolInterceptorLogging : DbCommandInterceptor
     {
         private ILogger _logger = new Logger();
         private readonly Stopwatch _stopwatch = new Stopwatch();
 
         public override void ScalarExecuting(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
         {
             base.ScalarExecuting(command, interceptionContext);
             _stopwatch.Restart();
         }
 
         public override void ScalarExecuted(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
         {
             _stopwatch.Stop();
             if (interceptionContext.Exception != null)
             {
                 _logger.Error(interceptionContext.Exception, "Error executing command: {0}", command.CommandText);
             }
             else
             {
                 _logger.TraceApi("SQL Database", "SchoolInterceptor.ScalarExecuted", _stopwatch.Elapsed, "Command: {0}: ", command.CommandText);
             }
             base.ScalarExecuted(command, interceptionContext);
         }
         public override void NonQueryExecuting(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
         {
             base.NonQueryExecuting(command, interceptionContext);
             _stopwatch.Restart();
         }
 
         public override void NonQueryExecuted(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
         {
             _stopwatch.Stop();
             if (interceptionContext.Exception != null)
             {
                 _logger.Error(interceptionContext.Exception, "Error executing command: {0}", command.CommandText);
             }
             else
             {
                 _logger.TraceApi("SQL Database", "SchoolInterceptor.NonQueryExecuted", _stopwatch.Elapsed, "Command: {0}: ", command.CommandText);
             }
             base.NonQueryExecuted(command, interceptionContext);
         }
 
         public override void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
         {
             base.ReaderExecuting(command, interceptionContext);
             _stopwatch.Restart();
         }
         public override void ReaderExecuted(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
         {
             _stopwatch.Stop();
             if (interceptionContext.Exception != null)
             {
                 _logger.Error(interceptionContext.Exception, "Error executing command: {0}", command.CommandText);
             }
             else
             {
                 _logger.TraceApi("SQL Database", "SchoolInterceptor.ReaderExecuted", _stopwatch.Elapsed, "Command: {0}: ", command.CommandText);
             }
             base.ReaderExecuted(command, interceptionContext);
         }
     }
 }

对于成功查询的命令,这段代码将相关信息及延时信息写入日志中,对于异常,它将创建错误日志。

在DAL文件夹中创建一个名为SchoolInterceptorTransientErrors.cs的类,该类在当您输入"Throw"到搜索框并进行查询时生成虚拟的瞬时错误。使用以下代码替换自动生成的:

 using System;
 using System.Data.Common;
 using System.Data.Entity;
 using System.Data.Entity.Infrastructure.Interception;
 using System.Data.Entity.SqlServer;
 using System.Data.SqlClient;
 using System.Diagnostics;
 using System.Reflection;
 using System.Linq;
 using ContosoUniversity.Logging;
 
 namespace ContosoUniversity.DAL
 {
     public class SchoolInterceptorTransientErrors : DbCommandInterceptor
     {
         private int _counter = ;
         private ILogger _logger = new Logger();
 
         public override void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
         {
             bool throwTransientErrors = false;
             if (command.Parameters.Count >  && command.Parameters[].Value.ToString() == "Throw")
             {
                 throwTransientErrors = true;
                 command.Parameters[].Value = "an";
                 command.Parameters[].Value = "an";
             }
 
             if (throwTransientErrors && _counter < )
             {
                 _logger.Information("Returning transient error for command: {0}", command.CommandText);
                 _counter++;
                 interceptionContext.Exception = CreateDummySqlException();
             }
         }
 
         private SqlException CreateDummySqlException()
         {
             // The instance of SQL Server you attempted to connect to does not support encryption
             var sqlErrorNumber = ;
 
             var sqlErrorCtor = typeof(SqlError).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic).Where(c => c.GetParameters().Count() == ).Single();
             var sqlError = sqlErrorCtor.Invoke(new object[] { sqlErrorNumber, (byte), (byte), "", "", "",  });
 
             var errorCollection = Activator.CreateInstance(typeof(SqlErrorCollection), true);
             var addMethod = typeof(SqlErrorCollection).GetMethod("Add", BindingFlags.Instance | BindingFlags.NonPublic);
             addMethod.Invoke(errorCollection, new[] { sqlError });
 
             var sqlExceptionCtor = typeof(SqlException).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic).Where(c => c.GetParameters().Count() == ).Single();
             var sqlException = (SqlException)sqlExceptionCtor.Invoke(new object[] { "Dummy", errorCollection, null, Guid.NewGuid() });
 
             return sqlException;
         }
     }
 }

这段代码仅重写了用来返回多行查询结果数据的ReaderExcuting方法。如果你想要检查其他类型的连接恢复,你可以重写如NonQueryExecuting和ScalarExecuting方法就像在日志拦截器中所做的那样。 当您运行学生页面并输入"Throw"作为搜索字符串时,代码将创建一个虚拟的SQL数据库错误数20,被当做瞬时错误类型。目前公认的瞬时错误号码有64,233,10053,10060,10928,10929,40197,40501及40613等,你可以检查新版的SQL 数据库来确认这些信息。

这段代码返回异常给实体框架而不是运行查询并返回查询结果。瞬时异常将返回4次然后代码将正常运行并将查询结果返回。 由于我们有全部的日志记录,你可以看到实体框架进行了4次查询才执行成功,而在应用程序中,唯一的区别是呈现页面所花费的事件变长了。 实体框架的重试次数是可以配置的,在本代码中我们设定了4,因为这是SQL数据库执行策略的缺省值。如果您更改执行策略,你同样需要更改现有的代码来指定生成瞬时错误的次数。您同样可以更改代码来生成更多的异常来引发实体框架的RetryLimitExceededException异常。 您在搜索框中输入的值将保存在command.Parameters[0]和command.Parameters[1]中(一个用于姓而另一个用于名)。当发现输入值为"Throw"时,参数被替换为"an"从而查询到一些学生并返回。 这仅仅只是一种通过应用程序的UI来对连接恢复进行测试的方法。您也可以针对更新来编写代码生成瞬时错误。

在Global.asax,添加下面的using语句:

using ContosoUniversity.DAL;
using System.Data.Entity.Infrastructure.Interception;

将高亮的行添加到Application_Start方法中:

        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
            DbInterception.Add(new SchoolInterceptorTransientErrors());
            DbInterception.Add(new SchoolInterceptorLogging());
        }

这些代码会在实体框架将查询发送给数据库时启动拦截器。请注意,因为你分别单独创建了 拦截器类的瞬时错误及日志记录,您可以独立的禁用和启用它们。

你可以在应用程序的任何地方使用DbInterception.Add方法添加拦截器,并不一定要在Applicetion_Start中来做。另一个选择是将这段代码放进之前你创建执行策略的DbConfiguration类中。

public class SchoolConfiguration : DbConfiguration
{
    public SchoolConfiguration()
    {
        SetExecutionStrategy("System.Data.SqlClient", () => new SqlAzureExecutionStrategy());
        DbInterception.Add(new SchoolInterceptorTransientErrors());
        DbInterception.Add(new SchoolInterceptorLogging());
    }
}

不管你在何处放置这些代码,要小心不要超过一次。对于相同的拦截器执行DbInterception.Add可能会使你得到额外的拦截器实例。例如,如果添加两次日志记录拦截器,您将看到查询被记录在两个日志中。 拦截器是按照Add方法的注册顺序执行的。根据你所要进行的操作,顺序可能很重要。例如,第一个拦截器可能会更改CommandText属性,而下一个拦截器获取到的会是更改过的该属性。

您已经编写完成了模拟瞬时错误的代码,现在可以在用户界面通过输入一个不同的值来进行测试了。作为替代方法,您可以在拦截器中编写不检查特定参数值而直接生成瞬时错误的代码,记得仅仅当你想要测试瞬时错误时才添加拦截器。

测试日志记录和连接恢复

按下F5在调试模式下运行该程序,然后点击学生选项卡。

检查VS输出窗口,查看跟踪输出,您可能要向上滚动窗口内容。

您可以看到实际被发送到数据库的SQL查询。 在学生索引页中,输入"Throw"进行查询。 你会注意到浏览器会挂起几秒钟,显然实体框架正在进行重试查询。第一次重试发生速度很快,然后每次重试查询都会增加一点等待事件。 当页面执行完成后,检查输出窗口,你会看到相同的查询尝试了5次,前4次都返回了一个瞬时错误异常。对于每个瞬时错误,你在日志中看到异常的信息。 返回学生数据的查询是参数化的:

SELECT TOP (3) 
    [Project1].[ID] AS [ID], 
    [Project1].[LastName] AS [LastName], 
    [Project1].[FirstMidName] AS [FirstMidName], 
    [Project1].[EnrollmentDate] AS [EnrollmentDate]
    FROM ( SELECT [Project1].[ID] AS [ID], [Project1].[LastName] AS [LastName], [Project1].[FirstMidName] AS [FirstMidName], [Project1].[EnrollmentDate] AS [EnrollmentDate], row_number() OVER (ORDER BY [Project1].[LastName] ASC) AS [row_number]
        FROM ( SELECT 
            [Extent1].[ID] AS [ID], 
            [Extent1].[LastName] AS [LastName], 
            [Extent1].[FirstMidName] AS [FirstMidName], 
            [Extent1].[EnrollmentDate] AS [EnrollmentDate]
            FROM [dbo].[Student] AS [Extent1]
            WHERE (( CAST(CHARINDEX(UPPER(@p__linq__0), UPPER([Extent1].[LastName])) AS int)) > 0) OR (( CAST(CHARINDEX(UPPER(@p__linq__1), UPPER([Extent1].[FirstMidName])) AS int)) > 0)
        )  AS [Project1]
    )  AS [Project1]
    WHERE [Project1].[row_number] > 0
    ORDER BY [Project1].[LastName] ASC

你没有在日志中记录值的参数,当然你也可以选择记录。你可以在拦截器的方法中通过从DbCommand对象的参数属性中获取到属性值。

请注意您不能重复该测试,除非你停止整个应用程序并重新启动它。如果你想要能够在单个应用程序的运行中进行多次测试,您可以编写代码来重置SchoolInterceptorTransientErrors中的错误计数器。要查看执行策略的区别,注释掉SchoolConfiguration.cs,然后关闭应用程序并重新启动调试,运行学生索引页面并输入"Throw"进行搜索。

        public SchoolConfiguration()
        {
            //SetExecutionStrategy("System.Data.SqlClient", () => new SqlAzureExecutionStrategy());
        }

这一次在尝试第一次查询时,调试器会立即停止并弹出异常。

取消注释并再试一次,了解之间的不同。

总结

在本节中你看到了如何启用实体框架的连接恢复,记录发送到数据库的SQL查询命令,在下一节中你会使用Code First Migrations来将其部署该应用程序到互联网中。

作者信息

Tom Dykstra - Tom Dykstra 是微软Web平台及工具团队的高级程序员,作家。