首页 > 代码库 > MVC5 Entity Framework学习之弹性连接和命令拦截

MVC5 Entity Framework学习之弹性连接和命令拦截

到目前为止,应用程序一直在本地IIS Express上运行。如果你想让别人通过互联网访问你的应用程序,你必须将它部署到WEB服务器同时将数据库部署到数据库服务器

本篇文章中将教你如何使用在将你的应用程序部署到云环境时的Entity Framework 6的非常有价值的两个特性:弹性连接(瞬时错误的自动重试)和命令拦截(捕获所有发送到数据库的SQL查询语句并记录至日志中)。

1.启用弹性连接

当你将应用程序部署到Windows Azure时,相应的数据库部也应被部署到Windows Azure SQL数据库中。当连接一个云数据库服务时比将Web服务器和数据库直接连接在同一个数据中心更容易发生瞬时连接错误,即使云Web服务器和云数据库服务器在同一数据中心,它们之间仍会发生网络连接问题,比如负载均衡。

通常云服务器是由用户共享的,这意味着你的应用程序的响应速度会受到其它用户的影响,并且你对数据库的访问可能会受到限制,限制是指如果你越过服务级别协议(Service Level Agreement)的限制频繁的访问数据库,那么数据库服务就会抛出异常。

当你访问云服务器时的大多数连接问题都是瞬时的并且它们会尝试在短时间内自我解决,所以当你尝试执行数据库操作时时遇到的错误一般都是瞬时的,当你等一段时间后重试,操作也许就会被成功执行。你可以使用自动重试来解决瞬时错误以提高用户体验。Entity Framework 6的连接恢复特性会自动重试失败的SQL查询。

对于特定的数据库服务,必须要正确的配置弹性连接功能:

  • 必须知道哪些异常有可能是瞬时的,你应该重试那些由于网络连接而造成的错误,而不是那些程序Bug引起的错误
  • 必须等待合适的时间后再重试失败的操作,当重试批量操作时应该等待更长的时间
  • 必须设置一个适当的重试次数,你可能会在一个在线的应用程序进行多次重试

你可以为Entity Framework 提供程序支持的任何数据库环境手动配置这些设置,但是对于应用程序来说使用Windows Azure SQL 数据库的默认配置已经足够了。

要启用连接恢复,你只需要在你的程序集中新建一个类并继承DbConfiguration类,并在该类中配置SQL数据库的执行策略,也就是EF中的重试策略。

在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());
        }
    }
}

Entity Framework会自动运行从DbConfiguration派生的类,你可以使用Dbconfiguration类来配置相关作业,否则就只能在web.config中进行配置。

打开StudentController.cs,添加System.Data.Entity.Infrastructure命名空间

using System.Data.Entity.Infrastructure;
使用RetryLimitExceededException 异常替换掉DataException
catch (RetryLimitExceededException /* dex */)
{
    //Log the error (uncomment dex variable name and add a line here to write a log.
    ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists see your system administrator.");
}

原来使用DataException来试图捕获那些可能是瞬时错误的异常并向用户发出一个友好的重试信息,但是现在启用重试策略,多次重试仍然失败的错误将会触发RetryLimitExceededException异常。

2.启用命令拦截

现在你已经启用了重试策略,但是如何测试以验证它是否按预期那样工作?强制制造出瞬时错误是不容易的,尤其当应用运行在本地IIS上,即使是使用单元测试。要测试弹性连接功能,你需要拦截Entity Framework发送至SQL数据库的查询命令并使用异常类型也就是瞬时错误来替换掉SQL数据库的相应。

你也可以按照最佳做法( log the latency and success or failure of all calls to external services)例如数据库服务来实现查询拦截,EF6提供了专用日志接口(dedicated logging API)来记录日志。在本文章中,你将学习如何使用Entity Framework的拦截功能,包括日志记录和模拟瞬时错误。

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

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

新建一个文件夹并命名为Logging

在Logging文件夹中,新建一个类并命名为ILogger.cs,使用下面的代码替换

<pre name="code" class="csharp">using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

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数据库)的每次调用的延迟时间

在Logging文件夹中,新建一个类并命名为Logger.cs,使用下面的代码替换

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Web;

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)
        {
            Trace.TraceWarning(FormatExceptionMessage(exception, fmt, vars));
        }

        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 fmt, params object[] vars)
        {
            TraceApi(componentName, method, timespan, string.Format(fmt, vars));
        }
        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);
        }

        private static string FormatExceptionMessage(Exception exception, string fmt, object[] vars)
        {
            // Simple exception formatting: for a more comprehensive version see 
            // http://code.msdn.microsoft.com/windowsazure/Fix-It-app-for-Building-cdd80df4
            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的多种侦听器来进行跟踪并记录日志,例如将它们写入到Windows Azure的blob存储中。

在生产环境中你可能应该考虑跟踪包而不是System.Diagnostics,ILogger接口根据你的需要能够相对容易的切换到不同的跟踪机制。

创建拦截器类

接下来你将新建两个类,一个类用来模拟瞬时错误而另一个用来进行日志记录,这两个类会在Entity Framework 每次向数据库发送查询命令时被调用。这些拦截器类必须派生自DbCommandInterceptor类,你所重写的方法需要在查询命令执行时被自动调用,在这些方法中你可以检查或者记录那些将被发送至数据库的查询命令,并且可以在查询命令被发送至数据库之前修改它们,甚至不将它们发送到数据库进行查询而直接返回结果给Entity Framework。

为了创建能够记录每一个发送至数据库的查询命令的拦截器类,你需要在DAL文件夹中新建一个类并命名为SchoolInterceptorLogging.cs,使用下面的代码替换

using ContosoUniversity.Logging;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Data.Entity.Infrastructure.Interception;
using System.Diagnostics;
using System.Linq;
using System.Web;

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 ContosoUniversity.Logging;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Data.Entity.Infrastructure.Interception;
using System.Data.SqlClient;
using System.Linq;
using System.Reflection;
using System.Web;

namespace ContosoUniversity.DAL
{
    public class SchoolInterceptorTransientErrors : DbCommandInterceptor
    {
        private int _counter = 0;
        private ILogger _logger = new Logger();

        public override void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
        {
            bool throwTransientErrors = false;
            if (command.Parameters.Count > 0 && command.Parameters[0].Value.ToString() == "Throw")
            {
                throwTransientErrors = true;
                command.Parameters[0].Value = http://www.mamicode.com/"an";>

上面的代码仅仅重写了ReaderExecuting 方法,该方法可以通过查询命令返回多行数据。如果你想查看对于其他查询类型的弹性连接功能,你需要重写NonQueryExecuting 和 ScalarExecuting 方法,就像在日志拦截器中所做的那样。

当你运行项目并在Student页面输入” Throw”作为搜索条件时,该方法会创建一条错误代号为20的虚拟SQL数据库异常。目前公认的瞬时连接错误的错误代号有64, 233, 10053, 10054, 10060, 10928, 10929, 40197, 40501, 40613,但是在新版本的数据库中错误代号可能与上面的不一致。

上面的代码将异常返回给Entity Framework而不是执行查询并返回查询结果,瞬时异常会出现4次,接下来程序恢复正常并将查询命令发送至数据库。

因为日志会记录所有的事件,所以你可以看到在最终成功之前Entity Framework共执行了四次查询,在应用程序中,你所能感觉到的唯一不同就是呈现页面时花费的时间变长了。

Entity Framework的重试次数是可以配置的,上面的代码指定重试次数是4次,是因为这是SQL数据库执行策略设定的默认值。如果你修改了执行策略,同时你必须修改上面的代码来指定应该生成多少次瞬时错误。你也可以修改代码来产生更多的异常,这样Entity Framework就会抛出RetryLimitExceededException异常。

你在搜索框中输入的值会被保存在command.Parameters[0] 和 command.Parameters[1] 中(一个用来存储姓,一个用来存储名)。当输入"Throw"时,该值会被替换为”an”并查询相应的学生信息。

这里仅仅是通过修改输入参数来方便的测试弹性连接功能。你也可以通过自己编码来为查询或者更新命令生成瞬时连接错误。

打开Global.asax,添加命名空间

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());
}
上面的代码会在Entity Framework向数据库发送查询命令时启用拦截器,需要注意的是你为瞬时错误模拟和日志记录创建了不同的拦截器类,你可以单独的启用或关闭它们。

你可以在代码的任何地方使用DbInterception.Add方法添加拦截器,并不一定非要在 Application_Start方法中添加。另一种可选的方法是将上面的代码放在你之前创建的DbConfiguration类中并配置执行策略。

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

不管你将这段代码放在什么地方,注意对于同一个拦截器,DbInterception.Add 方法只能有一个,否则会产生多个拦截器实例。举例来说,如果你添加了两次日志记录拦截器,那么对于每一条SQL查询语句,会产生两条相同的日志记录。

拦截器是按照注册顺序执行的(DbInterception.Add 方法被调用的顺序)。根据你所要做的操作,拦截器的执行顺序可能是很重要的。例如,一个拦截器可能修改从CommandText属性得到的SQL命令,另一个拦截器得到的是修改后的SQL命令而不是最初的SQL命令。

你已经完成了模拟瞬时连接错误的代码并可通过在程序中输入特定的值来引发瞬时连接错误。作为另一种替代方法,你可以编写总是产生瞬时连接错误的代码而不是通过检查特定的参数来引发瞬时连接错误,然后在你想要产生瞬时连接错误的时候再添加拦截器。如果这样做的话,请在数据库初始化完成之后再添加拦截器。换句话说,在开始产生瞬时连接错误前至少要执行一次数据库操作比如查询实体集。Entity Framework会在数据库初始化期间执行多个查询操作,但是它们并不在同一个事务中执行,所以在初始化期间出现的错误可能导致数据库上下文进入不一致状态。

3.测试日志记录和弹性连接功能

按F5运行,点击选Students 项卡

打开Visual Studio Output 窗口查看输出

你可以看到实际被发送到数据库的SQL查询语句,在这里可以看出Entity Framework在初始化时执行了一些查询语句和命令来检查数据库版本和迁移历史数据,接下来是查询共有多少学生,最后是查询所有学生数据的SQL语句。


打开Students页面,在搜索框中输入"Throw",点击Search


你可以注意到浏览器似乎有几秒钟的延迟,这是因为Entity Framework正在进行重试查询。第一次重试发生的非常快,然后在每一次重试之前增加等待时间,这种行为被称为指数退避(exponential backoff)

当所搜索的学生数据被显示出来时,查看output窗口,你会发现同一个查询被尝试执行了5次,前4次执行时返回了瞬时错误异常。对于每一个瞬时错误,你可以看到那些你在SchoolInterceptorTransientErrors 类中产生并在SchoolInterceptorLogging 中捕获瞬时错误时所定义的日志信息。


这里使用的是参数化的查询

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对象的Parameters 属性中的值记录下来。

注意你不能重复此测试,除非你重新运行该应用程序。如果你想在应用运行期间一直能够重复测试弹性连接功能,你需要修改SchoolInterceptorTransientErrors类中的代码来重置错误计数器。

要查看使用执行策略(重试策略)和不使用执行策略的区别,请注释掉SchoolConfiguration.cs类中的SetExecutionStrategy行,重新运行项目,并在Student页面输入"Throw"并查询。

当第一次执行查询时,调试器会在第一产生异常时将其捕获并抛出


最后取消对SchoolConfiguration.cs类中的SetExecutionStrategy行的注释。


项目源码:https://github.com/johnsonz/MvcContosoUniversity


THE END

MVC5 Entity Framework学习之弹性连接和命令拦截