[注:本节教程可以选择性学习]
本节教程将学习EF6的两个重要特性,这两个特性在我们将程序部署在云环境时特别有用:
1.启用弹性连接:
当我们将程序部署在Windows Azure上时,我们会将数据库部署在Windows Azure SQL数据库(一种云数据库服务)上。瞬时的云数据库连接错误会比程序服务器和数据库部署在同一个数据中心时更加常见。即使云程序服务器和云数据库服务托管在同一个数据中心,它们之间也会有有更多的网络连接问题,比如负载平衡。
同时,云服务是与其他用户共享的,这就意味着它的响应能力会受其他用户的影响。并且我们访问数据库也可能会受到限制即当我们超出访服务水平协议(SLA)允许的频率访问数据库时数据库服务将会抛出异常。
当我们访问云服务的许多或者大部分的连接错误都是瞬时的,它们会在短时间内自己解决。因此,当我们尝试数据库操作返回错误时,这种错误一般是比较短暂的,我们可以在短暂的等待后再次尝试该操作,这样或许就会操作成功。如果我们使用自动重试来处理瞬时的错误,这样对用户来说是不可见的,会带来比较好的用户体验。EF6的弹性连接特性会自动重试执行失败的SQL查询。
弹性连接特性必须为一个特定的数据库服务做适当的配置:
我们可以为EF提供者支持的任何数据库环境手动配置这些设置,但是一个对使用Windows Azure SQL数据库的在线程序支持良好的设置已经为我们配置好。我们将会在我们的Contoso University程序中使用这些设置。
1.1.我们需要做的是在我们的程序集中新建一个继承自DbConfiguration的类来启用弹性连接,并在这个类中设置SQL数据库的执行策略(这是EF重试连接的另一个术语)。在DAL文件夹新建SchoolConfiguration.cs.:
public class SchoolConfiguration : DbConfiguration { public SchoolConfiguration() { SetExecutionStrategy("System.Data.SqlClient", () => new SqlAzureExecutionStrategy()); } }
EF自动运行继承自DbConfiguration
的类中的代码。除了可以在DbConfiguration
继承类中配置,我们也可以在Web.config中配置。更多信息请查看 EntityFramework Code-Based Configuration
1.2.在StudentController.cs添加引用System.Data.Entity.Infrastructure
:
using System.Data.Entity.Infrastructure;
1.3.修改StudentController.cs中的所有catch代码块:
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
是为了识别瞬时的错误,然后给出一个友好的“try again”信息。但是现在我们已经启用重试策略,瞬时的错误会被重试,如果在重试几次后依然失败,将会返回真正的异常信息即RetryLimitExceededException
异常。更多信息请查看Entity Framework Connection Resiliency / Retry Logic
2.启用命令拦截:
现在我们已经启用重试策略,但是我们如何通过测试来验证它是否按照我们所期望的工作呢?使一个瞬时错误再现并不容易,特别是在本地运行的时候。而将实际的瞬时错误融入一个自动化的单元测试会更加会难。为了测试弹性连接特性,我们需要一种方法来拦截EF发送给SQL Server的命令,并将SQL Server的响应替换为一种异常类型(这种替换通常是暂时的)。
为了实现云应用程序的最佳实践,我们可以使用查询拦截: log the latency and success or failure of all calls to external services。EF6提供了专用的日志API来简化日志记录,但是在本教程我们将会学习如何直接使用EF的命令拦截属性,用来记录日志和模拟瞬时错误。
2.1.新建日志接口和类:
2.1.1.新增Logging文件夹,并添加ILogger.cs:
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数据库)的延迟。
2.1.2.在Logging文件夹添加Logger.cs:
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跟踪,有很多我们可以使用的“监听器”。例如,我们可以把记录写入文件或者把它们写入Azure的云端文件系统(blob storage)。更多信息请查看: Troubleshooting Azure Web Sites in Visual Studio。本教程中,我们仅在Visual Studio的Output窗口查看日志。
在应用程序产品中我们可能想要使用不同于System.Diagnostics的跟踪包,ILogger接口使得切换到不同的跟踪机制时相对容易。
2.2.新建拦截类:
接下来我们将新建供EF每次向数据库发送查询命令调用的类,一个用来模拟瞬时错误,一个用来记录日志。这些类必须继承自DbCommandInterceptor
。在这些类中我们重写查询即将执行时自动调用的方法。在这些方法中,我们可以检查或者记录被发送到数据库的查询命令,并且我们可以在它被发送到数据库之前改变查询或者通过EF自己返回一些结果而不是把查询传递给数据库。
2.2.1.在文件夹DAL新建记录每个发送给数据库的SQL查询的拦截类SchoolInterceptorLogging.cs:
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); } }
对于成功的查询或命令,上面的代码会写一个有延迟信息的Inormation日志;对于异常会写一个Error日志。
2.2.2.在DAL文件夹新建SchoolInterceptorTransientErrors.cs,它作用是当我们在搜索框输入“Throw”时产生模拟的瞬时错误:
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 = "%an%"; command.Parameters[1].Value = "%an%"; } if (throwTransientErrors && _counter < 4) { _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 = 20; var sqlErrorCtor = typeof(SqlError).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic).Where(c => c.GetParameters().Count() == 7).Single(); var sqlError = sqlErrorCtor.Invoke(new object[] { sqlErrorNumber, (byte)0, (byte)0, "", "", "", 1 }); 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() == 4).Single(); var sqlException = (SqlException)sqlExceptionCtor.Invoke(new object[] { "Dummy", errorCollection, null, Guid.NewGuid() }); return sqlException; } }
该类中仅重写了ReaderExecuting
方法,该方法被查询调用,然后返回多行数据。如果我们想要查看其他类型查询的弹性连接,我们可以像SchoolInterceptorLogging.cs一样重写NonQueryExecuting
和ScalarExecuting
方法。
当我们返回Student页面,并在搜索框输入“Throw”搜索时,代码会产生一个模拟的异常代码为20的SQL数据库异常,20是一个典型的瞬时错误代码。其他被认为是瞬时异常的代码是:64, 233, 10053, 10054, 10060, 10928, 10929, 40197, 40501及 40613,但这些都是新版本SQL数据库的变化。
代码并不执行查询并返回查询结果,而是返回异常给EF。瞬时异常返回四次,然后代码恢复正常即把查询传递给数据库。
因为所有的操作都会被记录,我们可以看到EF在操作执行成功之前重试了4次操作,在应用程序中与之前唯一的区别是它需要更长的时间来呈现一个页面查询结果。
EF重试的次数是可以配置的,代码默认指定4次,是因为SQL数据库的执行策略。如果我们改变执行策略,我们也要修改这里的代码来指定瞬时错误重试的次数。我们也可以修改代码来产生更多的异常,这样EF将会抛出更多的RetryLimitExceededException
异常。
我们在搜索框输入的值会保存在command.Parameters[0]和command.Parameters[1]
中(一个用于first name,一个用于last name)。当值”%Throw%“被发现时,”Thorw“将会被”an“替换,这样将会查询first name或last name中含有”an“的Student信息。
这种实现只是一种改变应用程序UI输入值来测试连接弹性的简便方式。我们也可以编写代码为所有的查询和更新产生瞬时错误,稍后将会介绍DbInterception.Add方法。
2.2.3.在Global.asax的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()); }
这样当EF向数据库发送查询命令时,拦截代码将会运行。像上面一样我们为瞬时错误模拟和日志记录创建单独的拦截类,这样我们可以分别单独的启用和禁用它们。
拦截器不是必须需要添加在Application_Start
中,我们可以使用DbInterception.Add方法在代码的任何地方添加。另一个选择是把添加代码放在之前创建的执行策略文件SchoolConfiguration.cs中。
public SchoolConfiguration() { SetExecutionStrategy("System.Data.SqlClient", () => new SqlAzureExecutionStrategy()); DbInterception.Add(new SchoolInterceptorTransientErrors()); DbInterception.Add(new SchoolInterceptorLogging()); }
不管将拦截器代码放在哪里,我们需要注意的是不要使用DbInterception.Add
多次添加同一个拦截器,不然将会产生其他的拦截器实例。例如我们添加了2次SchoolInterceptorLogging,那么每个SQL查询都会产生两天记录。
拦截器按照注册的顺序(即DbInterception.Add
调用的顺序)执行。注册的顺序可能依赖于你在拦截器中做什么。例如,一个获取CommandText属性并改变SQL命令的拦截器,如果它先执行,那么将会改变SQL命令,那么下一个拦截器获取的将会是被改变后的SQL命令,而不是原始的SQL命令。
我们已经编写了瞬时错误的模拟代码,当我们在UI中输入不同值的时候将会引发瞬时错误。另外,我们可以修改拦截器的代码,让其总是产生瞬时错误而不用检查特殊的值。当我们想要产生瞬时错误时,我们可以添加这个拦截器。如果我们这样做,不要在数据库初始化完成之前添加拦截器。换句话说,在产生瞬时错误之前要至少执行一个数据操作(比如查询)。EF在数据库初始化的过程中会执行一些查询,而这些查询不是事务的,所以在初始化期间的错误可能导致上下文进入一个不一致的状态。
3.测试日志和弹性连接:
3.1.运行程序,点击Student标签,查看Output窗口:
我们可以看到被发送到数据库的真实的SQL查询语句。我们也会看到EF启动时的初始化查询和命令,来检查数据库版本和迁移历史表(我们将会在下节教程中学习迁移)。然后我们可以看到分页查询,查找有多少student记录,最后获取student数据。
3.2.在Student页面,在搜索框输入”Throw“,然后搜索:
我们注意到当EF重试查询时浏览器似乎挂了几秒钟。第一次重试很快发生,然后每个额外的重试等待会增加。每个重试等待时间增加的过程称为指数退避(exponential backoff)。
当页面加载完成时,显示的是名字中含有”an“的student,查看output窗口,我们看到相同的查询执行了5次,前4次返回瞬时异常。每个瞬时异常都可以在日志中看到。
当我们输入一个查询字符串,查询返回的student数据是参数化的:
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 ([Extent1].[LastName] LIKE @p__linq__0 ESCAPE N'~') OR ([Extent1].[FirstMidName] LIKE @p__linq__1 ESCAPE N'~') ) AS [Project1] ) AS [Project1] WHERE [Project1].[row_number] > 0 ORDER BY [Project1].[LastName] ASC:
我们没有记录参数的值,但是我们可以这样做。如果我们想要看到参数的值,我们可以编写日志代码,从拦截器方法的DbCommand
对象的Parameters
属性获取参数值。
我们不能重复这个测试,除非我们停止并重新启动应用程序。如果我们希望能够在一个运行的应用程序多次测试弹性连接,我们可以在SchoolInterceptorTransientErrors
中编写代码重置错误计数器。
3.3.为了查看执行策略(重试策略)产生的不同,我们在SchoolConfiguration.cs中注释掉SetExecutionStrategy
行,然后运行程序,在搜索框输入”Throw”。
这次调试器在第一次试图执行查询产生异常时停止。
3.4.取消SchoolConfiguration.cs对SetExecutionStrategy
行的注释。