[翻译][MVC 5 + EF 6] 4:弹性连接和命令拦截(Command Interception)

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

  [注:本节教程可以选择性学习]

  本节教程将学习EF6的两个重要特性,这两个特性在我们将程序部署在云环境时特别有用:

  • 弹性连接(connection resiliency):遇到瞬时的连接错误时自动重试连接。
  • 命令拦截(command interception):捕获所有发送到数据库的查询SQL,用来记录日志或改变它们。

1.启用弹性连接

  当我们将程序部署在Windows Azure上时,我们会将数据库部署在Windows Azure SQL数据库(一种云数据库服务)上。瞬时的云数据库连接错误会比程序服务器和数据库部署在同一个数据中心时更加常见。即使云程序服务器和云数据库服务托管在同一个数据中心,它们之间也会有有更多的网络连接问题,比如负载平衡。

  同时,云服务是与其他用户共享的,这就意味着它的响应能力会受其他用户的影响。并且我们访问数据库也可能会受到限制即当我们超出访服务水平协议(SLA)允许的频率访问数据库时数据库服务将会抛出异常。

  当我们访问云服务的许多或者大部分的连接错误都是瞬时的,它们会在短时间内自己解决。因此,当我们尝试数据库操作返回错误时,这种错误一般是比较短暂的,我们可以在短暂的等待后再次尝试该操作,这样或许就会操作成功。如果我们使用自动重试来处理瞬时的错误,这样对用户来说是不可见的,会带来比较好的用户体验。EF6的弹性连接特性会自动重试执行失败的SQL查询。

  弹性连接特性必须为一个特定的数据库服务做适当的配置:

  • 它必须知道那些异常可能是瞬时的。例如,我们想重试由于网络连接错误造成的暂时损失,而不是程序bug而引起的错误。
  • 它必须在操作失败和重试之前等待一个适当的时间。一个批处理可以比用户等待响应的在线web页面等待更长的时间。
  • 它必须在放弃重试之前重试一个适当的次数。一个批处理可以比在线应用程序尝试的次数多。

  我们可以为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一样重写NonQueryExecutingScalarExecuting方法。

  当我们返回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.asaxApplication_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.csSetExecutionStrategy行的注释。

时间: 2024-09-29 01:23:55

[翻译][MVC 5 + EF 6] 4:弹性连接和命令拦截(Command Interception)的相关文章

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

到目前为止,应用程序一直在本地IIS Express上运行.如果你想让别人通过互联网访问你的应用程序,你必须将它部署到WEB服务器同时将数据库部署到数据库服务器 本篇文章中将教你如何使用在将你的应用程序部署到云环境时的Entity Framework 6的非常有价值的两个特性:弹性连接(瞬时错误的自动重试)和命令拦截(捕获所有发送到数据库的SQL查询语句并记录至日志中). 1.启用弹性连接 当你将应用程序部署到Windows Azure时,相应的数据库部也应被部署到Windows Azure S

EF6学习笔记(四) 弹性连接及命令拦截调试

EF6学习笔记总目录:ASP.NET MVC5 及 EF6 学习笔记 - (目录整理) 本章原文地址:Connection Resiliency and Command Interception 原文有些地方讲的比较细,个人根据实际理解做些缩减,或者加入一些个人理解: 第1部分 弹性连接 为什么要弹性连接?什么是弹性连接? 在实际的网络应用中,尤其是在Internet上的网络应用,就算Web服务器和数据库服务器在一个数据中心,也不能保证WEB服务器和数据库服务器没有任何延迟或者其他网络问题: 尤

[翻译][MVC 5 + EF 6] 10:处理并发

原文:Handling Concurrency with the Entity Framework 6 in an ASP.NET MVC 5 Application 1.并发冲突: 当一个用户编辑一个实体数据时,另一个用户在第一个用户的改变提交到数据库之前同时也在编辑这个实体数据,这时就会发生冲突.如果不处理这种冲突,最后更新数据库的用户的更改将覆盖其他用户的修改.在许多程序中,这种风险是可以接受的:如果程序具有较少的用户和较少的更新操作,或者不是覆盖关键的变化,这种情况下处理并发的成本可能大

[翻译][MVC 5 + EF 6] 7:加载相关数据

原文:Reading Related Data with the Entity Framework in an ASP.NET MVC Application 1.延迟(Lazy)加载.预先(Eager)加载.显式(Explicit)加载: EF加载相关数据到实体导航属性有以下几种方式: 延迟加载:当实体第一次读取时,相关数据没有加载.当第一次试图访问导航属性时,所需的导航数据自动加载.这导致多条查询语句被发送到数据库:一条查询实体本身,一条查询实体相关数据.DbContext类默认启用延迟加载

[翻译][MVC 5 + EF 6] 6:创建更复杂的数据模型

原文:Creating a More Complex Data Model for an ASP.NET MVC Application 前面的教程中,我们使用的是由三个实体组成的简单的数据模型.在本教程中,我们将添加更多的实体和关系,并通过指定格式.验证和数据库映射规则来自定义数据模型.有两种方式来定义数据模型:一种是给实体类添加属性,另一种是在数据库上下文类添加代码. 当我们完成后,实体类将完成下图所示的数据模型: 1.通过使用属性来自定义数据模型: 1.1.DateType属性: 修改Mo

[翻译][MVC 5 + EF 6] 5:Code First数据库迁移与程序部署

原文:Code First Migrations and Deployment with the Entity Framework in an ASP.NET MVC Application 1.启用Code First迁移: 当我们开发一个新的程序时,数据模型经常会发生改变,每次模型发生改变时,就会变得与数据库不同步.我们之前配置EF在每次数据模型发生改变时自动删除然后重建数据库.当我们增加.删除或者改变实体类或者改变DbContext类时,在程序下次运行时将会自动删除已经存在的数据库,并且创

[翻译][MVC 5 + EF 6] 1:准备工作

原文:Getting Started with Entity Framework 6 Code First using MVC 5 1.新建MVC项目: 2.修改Views\Shared\_Layout.cshtml: <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=d

[翻译][MVC 5 + EF 6] 2:基础的增删改查(CRUD)

原文:Implementing Basic CRUD Functionality with the Entity Framework in ASP.NET MVC Application 1.修改Views\Student\Details.cshtml: @model ContosoUniversity.Models.Student @{ ViewBag.Title = "Details"; } <h2>Details</h2> <div> <

[翻译][MVC 5 + EF 6] 11:实现继承

原文:Implementing Inheritance with the Entity Framework 6 in an ASP.NET MVC 5 Application 1.选择继承映射到数据库表: 在School数据模型里面,Instructor和Student类有几个属性是相同的: 假设我们想要消除Instructor和Student实体属性的冗余代码.或者我们想要编写一个可以格式化name的服务,而不用考虑这个name是来自一个instructor还是student.我们可以创建一个