MVC 基本站点:第 2 步 - 异常管理
“MVC 基础站点”系列的第二篇文章详细介绍了 ASP.NET MVC 网站的异常管理规则及其实现,并提供了一些可重用的有用的日志记录和异常管理基类和源代码。
MVC 基本站点
- 步骤 1 - 多语言站点骨架
- 步骤 2 - 异常管理
- 步骤 3 - 使用 AJAX、jqGrid、控制器扩展、Html 帮助器等实现动态布局和站点管理
- 步骤 4 - 使用 AJAX、JSON、jQuery、LINQ 和序列化在 MVC4.0 中集成 jqGrid
目录
- 引言
- 软件环境
- MVC 基础站点中的异常管理规则
- 将消息和异常写入日志
- 管理预期异常
- 管理未经授权的访问异常
- 管理未处理的异常
- 从 ASP.NET MVC3 升级到 ASP.NET MVC4
- 运行此代码之前
- 参考文献
- 历史
引言
MVC 基本站点旨在成为一系列关于创建使用 ASP.NET MVC 的基本且可扩展网站的教程文章。
本系列的第一篇文章,名为《MVC 基础站点:第一步 - 多语言站点骨架》,主要侧重于使用 ASP.NET MVC 3.0 创建多语言网站骨架。此外,其中还描述了从头开始创建的用户身份验证和注册。
本文是本系列的第二篇文章,它详细介绍了 ASP.NET MVC 网站的异常管理规则及其实现,并提供了一些有用的基类和源代码,用于日志记录和异常管理,这些内容(只需稍作修改)不仅可以在其他 ASP.NET 站点中使用,而且可以普遍用于任何 .NET 项目。
MVC 基础站点采用增量迭代的方法进行开发,这意味着每一步都会在上一歩的基础上添加更多功能。因此,本文提供的下载源代码包含了迄今为止实现的所有功能(来自两篇文章)。
请注意,所有提供的源代码都经过精心注释并且非常清晰,阅读和理解它们应该没有任何问题。
异常管理在任何软件解决方案的开发中都非常重要。如果忽略异常管理或未正确实现,将会对软件解决方案的质量和性能产生负面影响。
软件环境
- .NET 4.0 框架
- Visual Studio 2010(或 Express 版)
- ASP.NET MVC 4.0
- SQL Server 2008 R2(或 Express Edition 版本 10.50.2500.0)
MVC 基础站点中的异常管理规则
在每个软件项目中,在项目开发开始时,应定义团队必须遵循的软件开发规则。这些规则的一部分应该是异常管理规则。
在本章中,我将描述我在 MVC 基础站点解决方案中使用的异常管理规则,但这些规则可以被视为适用于任何 ASP.NET 解决方案,并且稍加调整也可用于任何 .NET 解决方案的实用建议。
通常,异常管理使用 `try`、`catch` 和 `finally` 或 `using` 关键字来管理可能失败的操作(例如访问数据库表、访问文件系统中的文件、使用内存分配、发送电子邮件等),在认为合理的情况下处理故障,并清理使用的资源。请注意,异常可能由 .NET Framework、使用的第三方库或通过 `throw` 关键字的应用程序代码生成。
MVC 基础站点解决方案中使用的基本异常管理规则是:
- 使用 `finally` 块来释放任何不属于 disposable(未实现 `IDisposable`)但需要某些释放操作的资源,例如关闭在 `try` 块中打开的任何流或文件。
- 处理可能生成异常的 disposable 资源的访问,并记住在最后通过使用 `try-catch-finally` 或 `using` 语句来释放使用的资源;
- 在不需要的地方不要使用 `try-catch`,而应使用其他方法,例如 `if-else` 语句。例如,在对可能为 null 的对象执行任何操作之前检查 null 值,可以通过避免异常来显著提高性能。
- 不要 `catch` 然后再次 `throw` 同一类型的异常。
- 对于整个解决方案,使用一个从 `ApplicationException` 派生的新异常类,在本例中命名为 `BasicSiteException`,以标识由数据库层或逻辑层生成的异常;
- 为整个解决方案使用一个有用的类,用于将消息和异常信息写入 Windows 事件日志。在我们的解决方案中,这个类名为 `MvcBasicLog`(详见下一章)。
- 仅在有必要在重新抛出的异常中添加更多信息时,才在数据库或逻辑层捕获异常,并为此使用 `MvcBasicException` 类型的对象。不要忘记在重新抛出的异常中包含原始异常作为内部异常。
- 预期异常应最终在用户界面层捕获,然后通过使用 `MvcBasicLog` 类将异常信息(消息和堆栈跟踪)写入事件日志,并在当前页面向用户显示一个友好的错误消息。
- 管理未经授权的访问异常,以防止用户在未进行身份验证或没有正确权限的情况下访问网站的主要功能。
- 在用户界面层不要忘记管理未处理的异常(意外异常),在这种情况下,错误信息也必须写入事件日志,并在错误页面上显示一个友好的错误消息。
- 通过使用适当的方法,避免生成不必要的未处理异常。例如,在使用 Entity Framework 的情况下,使用 `FirstOrDefault()` 然后与 `null` 进行比较,而不是像下面的代码那样使用 `First()`:
if (ModelState.IsValid)
{
//
// Verify the user name and password.
//
User user = _db.Users.FirstOrDefault(item => item.Username.ToLower() ==
model.Username.ToLower() && item.Password == model.Password);
if (user == null)
{
ModelState.AddModelError("", Resources.Resource.LogOnErrorMessage);
//
return View(model);
}
else
{
//
// User logined succesfully ==> create a new site session!
//
FormsAuthentication.SetAuthCookie(model.Username, false);
//
SiteSession siteSession = new SiteSession(_db, user);
Session["SiteSession"] = siteSession; // Cache the user login data!
//
// Log a message about the user LogOn.
//
MvcBasicLog.LogMessage(string.Format("LogOn for user: {0}", siteSession.Username));
//
// Redirect to Home page.
//
return RedirectToAction("Index", "Home");
}
}
将消息和异常写入日志
在任何软件解决方案中,将软件使用过程中生成的消息、错误和异常数据保存在日志中都非常重要。这样,消息和异常数据就可以在以后访问和分析。在 Windows 中,用于保存日志消息的最佳位置是 Windows 事件日志。
`MvcBasicLog` 是一个类,它是用于将消息和异常信息写入 Windows 应用程序事件日志的有用的类。
从上面的类图可以看出,这个类有一组公共静态方法,用于将消息、错误和异常记录到 Windows 事件日志中。所有这些消息都将写入同一个日志源(在 `_logSource` 静态成员中定义为静态值),并且对于某些方法,用户可以指定要用作消息前缀的类别名称。
所有这些公共方法都使用私有方法 `AddLogLine` 来完成将日志写入事件日志源的工作。
private static void AddLogLine(string logMessage, bool isError)
{
EventLog log = new EventLog();
log.Source = _logSource;
//
try
{
log.WriteEntry(logMessage, (isError ? EventLogEntryType.Error : EventLogEntryType.Information));
}
catch (System.Security.SecurityException ex)
{
//
// In Web app you do not have right to create event log source and
// the log source must be created first by using the provided CreateEventLogEntry project!
//
throw new ApplicationException("You must create the event log entry " +
"for our source by using CreateEventLogEntry project!", ex);
}
catch
{
//
// The log file is to large, so clear it first.
//
log.Clear();
log.WriteEntry(logMessage, (isError ? EventLogEntryType.Error : EventLogEntryType.Information));
}
//
log.Close();
}
要将消息通知写入日志,您需要这样做:
MvcBasicLog.LogMessage(string.Format("LogOn for user: {0}", siteSession.Username));
您可以使用“事件查看器”工具检查 Windows 应用程序事件日志。
请注意,对于 `LogException` 方法,完整的异常数据将写入事件日志,在这种情况下,事件日志的结果将类似于下面的内容。
从上图可以看出,事件日志详细记录了异常消息和异常堆栈跟踪,包括生成异常的源代码行。
因此,对于事件日志中的每个与程序使用期间生成的异常相关的“错误”条目,开发人员都可以轻松地识别生成问题的源代码行和上下文。
管理预期异常
当源代码中实现了可能失败的操作时,例如访问数据库表、访问文件系统中的文件、使用内存分配、发送电子邮件等等,这些操作可能会生成预期异常。此外,一些源代码解决方案在实现其逻辑时可能会抛出预期异常,如果应用程序逻辑的某些规则被违反(例如用户权限访问冲突)。
预期异常的管理是异常管理过程的主要部分,为了做到这一点,创建和使用一个解决方案特定的异常基类是合适的。
在这个管理过程中,另一个重要的方面是,对于许多由源代码低层生成的预期异常,应该在用户界面层也向用户显示一个问题通知消息;因此,关于原始异常的信息需要从一层一层地传播到最终在用户界面层处理。在此传播过程中,异常数据应该累积更多上下文信息,以描述和本地化问题,并且所有这些信息都应该保存到日志中,以供将来使用(例如日志报告和问题修复)。
我用于管理预期异常的基类是 `MvcBasicException` 类。它继承自 `ApplicationException`,并使用 `[Serializable]` 属性标记为可序列化。
注意:序列化在很多地方都会发生,而用户可能不知道。序列化机制是所有跨应用程序域调用的基础,即使它们在同一进程边界内,例如从逻辑层的 DLL 到 UI 应用程序层。因此,因为我们希望完整的异常数据(异常消息和堆栈跟踪)得以保留并在应用程序层之间传输,所以我们必须用 `Serializable` 属性标记我们的异常类,并且还必须提供一个受保护的构造函数用于序列化。
从上面的类图可以看出,这个类有三个构造函数,可以在以下情况中使用:
- 受保护的构造函数使用序列化数据初始化 `MvcBasicException` 类的新实例。此构造函数由 .NET Framework 在应用程序层之间传递所有异常数据时内部使用。
- 使用指定的错误消息初始化 `MvcBasicException` 类的新实例,该消息应解释我们应用程序角度的异常原因。
- 使用指定的错误消息和对内部异常(是当前异常的原因)的引用初始化 `MvcBasicException` 的新实例。此构造函数的第一参数是一个应解释我们应用程序角度的异常原因的错误消息,第二个参数是导致当前异常的内部异常。
在逻辑层的实体类中,预期异常的管理应如下面的示例所示。
public static int GetNormalSearchCount(int userID, params object[] parameters)
{
MvcBasicSiteEntities dataContext = null;
//
try
{
dataContext = new MvcBasicSiteEntities();
return dataContext.ExecuteStoreCommand("GetNormalSearchCount", parameters);
}
catch (System.Data.SqlClient.SqlException exception)
{
//
// Manage the SQL expected exception by generating a MvcBasicException
// with more info added to the orginal exception.
//
throw new MvcBasicException(string.Format(
"GetNormalSearchCount for user: {0}", userID), exception);
}
finally
{
//
// Dispose the used resource.
//
if(dataContext != null)
dataContext.Dispose();
}
}
您可以看到上面的代码,我们正在尝试带参数调用 SQL 命令;因此,由于访问数据库可能生成异常,并且由于使用的数据库连接必须在所有可能的情况下都释放,我使用了一个 `try-catch-finally` 块,其中包含以下操作:
- 在 `try` 部分,我创建了用于访问数据库的数据实体上下文对象,然后使用数据上下文对象调用 SQL 命令。
- 在 `catch` 部分,我只捕获我想管理的预期异常。在本例中,只捕获 `SqlExeption` 异常。然后,我通过创建一个 `MvcBasicException` 类型的对象来处理异常,该对象将包含用于标识当前给定用户的附加信息,并将原始 `SQLException` 信息存储在其内部参数中。然后将包含所有必要数据的 resultant exception object抛出到应用程序的上层。
- 在 `finally` 部分,我释放使用的资源,在本例中是用于访问数据库的数据实体上下文。请注意,在我们释放数据上下文对象之前,我测试它是否已成功创建,因为它的构造函数可能会生成 `SQLException`,在这种情况下,数据上下文对象将为 null!
在用户界面层,在这种情况下,预期异常的管理是在 `AcoountController` 的下一个方法中处理的。
public ActionResult TestExpectedException()
{
SiteSession siteSession = this.CurrentSiteSession;
//
try
{
//
// Invoke a method that could generate an exception!
//
int count = MvcBasic.Logic.User.GetNormalSearchCount(
siteSession.UserID, new object[] { "al*", "231" });
//
// TO DO!
//...
}
catch (MvcBasicException ex)
{
MvcBasicLog.LogException(ex);
ModelState.AddModelError("", Resources.Resource.ErrorLoadingData);
}
//
// Stay in MyAcount page.
//
return View("MyAccount");
}
在上面的代码中,您可以看到预期异常如何在用户界面层处理。首先,调用可能生成预期异常的逻辑方法在 `try` 块中执行,然后在 `catch` 块中,预期的 `MvcBasicException` 类型异常被捕获并处理。用户界面层中处理预期异常的操作如下:
- 将异常数据(消息和堆栈跟踪)保存到日志(有关详细信息,请参阅上一章);
- 使用我们资源文件中的多语言消息向用户显示通知错误消息。
为了在 `_Header` 局部视图(在站点布局中使用)中显示错误消息,我添加了一个 `ValidationSummary` 类型的对象(但其他方法也可以使用 `Label` 控件、错误消息页面或弹出消息窗口)。
<div class="headerTitle">
@Resources.Resource.HeaderTitle
</div>
@if (!(Model is LogOnModel))
{
<div class="errorMessage">
@Html.ValidationSummary(true)
</div>
}
请注意,在一个视图中应该只有一个验证摘要(否则错误消息会显示多次),因此上面的代码中有一个 `if` 语句,用于在登录页面(该页面已有一个)的情况下避免创建验证消息。
我们将使用一个现有用户的凭据来测试 MVC 基础站点中的所有这些登录功能,例如用户名:Ana,密码:ana。登录后,您应该访问“我的帐户”菜单,然后将显示下一个页面。
现在,如果您从上面的页面单击“测试预期异常”链接,您的网站将成功管理预期异常,并在页面标题中显示错误消息,如下图所示。
此外,如果您在 Windows 日志中打开事件查看器,您将在应用程序部分看到我们网站的新条目。
正如您在上图中看到的,在事件日志中,异常消息和堆栈跟踪被保存,包括我们添加的数据(当前用户 ID)、原始异常消息以及源代码行。所有这些信息都可以用于分析、报告和/或修复问题。
管理未经授权的访问异常
未经授权的访问异常是在 ASP.NET MVC 站点异常中可能发生的特殊异常,当某个用户尝试访问没有访问权限(权利)的页面或操作,以及/或需要身份验证时发生。
所有这些异常都仅由 `BaseController` 类中的所有控制器单独管理,通过覆盖 `OnException` 方法(该方法继承自 MVC 框架的 `Controller` 类)。
protected override void OnException(ExceptionContext filterContext)
{
if (filterContext.Exception is UnauthorizedAccessException)
{
//
// Manage the Unauthorized Access exceptions
// by redirecting the user to Home page.
//
filterContext.ExceptionHandled = true;
filterContext.Result = RedirectToAction("Home", "Index");
}
//
base.OnException(filterContext);
}
如您在上面的代码中看到的,我们只需将用户重定向到主页即可处理这些异常。
请注意,在 `OnException` 方法中,可以像我对未经授权的访问异常所做的那样,筛选和处理其他特殊异常。
在 ASP.NET MVC 中,对网站功能的未经授权访问应使用 `[Authorize]` 属性进行管理。因此,在所有需要身份验证的控制器的公共操作中,我们必须使用此属性,如下面的文本所示。
[Authorize]
public ActionResult MyAccount()
{
// TO DO!
return View();
}
要进行测试,请启动 MVC 基础站点,然后在不登录的情况下,使用 URL 尝试访问我们网站的下一个操作:https://:50646/Account/MyAccount。
请注意,由于此操作需要授权,因此访问将把您重定向到登录页面。
管理未处理的异常
未处理的异常是指应用程序中可能发生但未按预期方式在源代码中处理的所有异常。在这里,我们还包括了应用程序的 bug(错误和问题)。
要启用未处理的异常管理,必须在站点的 web.config 文件中添加/修改以下行:
<customErrors mode="On"/>
当抛出未处理的异常时,ASP.NET MVC 框架将激活 Error.cshtml 页面。因此,管理所有未处理异常的代码必须在错误视图中(详见下文)。
@using MvcBasic.Logic
@using MvcBasicSite.Models
@model System.Web.Mvc.HandleErrorInfo
@{
ViewBag.Title = "Error";
//
// Log out the user and clear its cache.
//
SiteSession.LogOff(this.Session);
//
// Log the exception.
//
MvcBasicLog.LogException(Model.Exception);
}
<meta http-equiv="refresh" content="5;url=/Home/Index/" />
<h2>@Resources.Resource.ErrorPageMessage</h2>
从上面的源代码可以看出,未处理的异常管理包含四个操作:
- 通过调用以下方法注销当前用户:
public static void LogOff(HttpSessionStateBase httpSession) { // // Write in the event log the message about the user's Log Off. // Note that could be situations that this code was invoked from Error page // after the current user session has expired, or before the user to login! // SiteSession siteSession = (httpSession["SiteSession"] == null ? null : (SiteSession)httpSession["SiteSession"]); if(siteSession != null) MvcBasicLog.LogMessage(string.Format("LogOn for user: {0}", siteSession.Username)); // // Log Off the curent user and clear its site session cache. // FormsAuthentication.SignOut(); httpSession["SiteSession"] = null; }
- 使用上面描述的 `MvcBasicLog` 类将异常数据写入事件日志;
- 向用户显示一个通用的错误消息(来自资源文件)。
- 5 秒后将用户重定向到主页。
要测试所有这些,请使用现有用户的凭据登录 MVC 基础站点,然后访问“我的帐户”菜单,之后将显示下一个页面。
现在,如果您从上面的页面单击“测试未处理的异常”链接,将在下一个 `AccountController` 操作中模拟未处理异常的生成。
public ActionResult TestUnhandledException()
{
//
// Next line of code will try to open an view that does not exist ==> Exception.
//
return View();
}
然后,未处理的异常将由我们的源代码从错误视图(如上所述)进行管理,用户将被注销(请注意菜单也会相应更改),用户将在错误页面上看到错误消息。
5 秒后,用户将自动重定向到站点的主页。
请注意,异常数据将保存在事件日志中。如果您在 Windows 日志中打开事件查看器,您将在应用程序部分看到我们网站的新错误条目。
通过分析事件日志中的错误,您可以查看有关异常以及生成该异常的源代码行的详细信息。(在本例中,我们尝试访问一个不存在的视图页面。)
从 ASP.NET MVC3 升级到 ASP.NET MVC4
为了将解决方案从 ASP.Net MVC3.0 升级到 ASP.NET MVC4.0,我采用了 MVC4 发行说明中指示的手动步骤。
升级后,我遇到了以下两个问题:
- 最新的
jquery.unobtrusive-ajax.min.js
JavaScript 文件存在一些错误。我通过创建一个新的 ASP.NET MVC4.0 类型项目来解决这些错误,然后将我在更新步骤后解决方案中拥有的最新版本的
jQuery
脚本替换为新项目中的脚本。 - ASP.NET MVC4.0 框架不再在每次回发时调用 `BaseControler` 类中的受保护方法 `ExecuteCore()`,因此当前使用的语言更改不再起作用。
为了解决这个问题,在我的 `BaseController` 类中,我覆盖了 `DisableAsyncSupport` 属性,如下面的源代码所示:
protected override bool DisableAsyncSupport { get { return true; } }
因此,现在此文章附带了一个新的 ZIP 文件,其中包含适用于 ASP.NET MVC4.0 的MVC 基础站点解决方案的升级版本。
运行此代码之前
在运行此代码之前,您应该执行以下步骤:
- 运行CreateEventLogEntry应用程序(以管理员身份运行)(CreateEventLogEntry 应用程序的源代码作为我们解决方案的一部分提供)在事件日志中创建一个新条目。
- 在您的 SQL Server(或 SQL Express)中创建一个名为 MvcBasicSite 的数据库,然后将提供的 MvcBasicSiteDatabase.bak 数据库恢复到其中。
- 根据第 2 步中的设置,修改 MvcBasicSite Web 应用程序的 Web.config 文件中的连接字符串。
参考文献
- MVC 基础站点:第一步:多语言站点骨架
- Microsoft Visual Studio 2010 Express
- ASP.NET MVC 4.0
- SQL Server 2008 R2 Express
历史
- 2013 年 2 月 9 日:版本 1.0.0.1:草稿版本。
- 2013 年 2 月 14 日:版本 1.0.0.2:首次发布版本审查后的小改动。
- 2013 年 2 月 21 日:版本 1.0.0.3 - 基础站点步骤。
- 2013 年 2 月 24 日:版本 1.0.0.4 - 添加了一些细节。
- 2013 年 3 月 2 日:版本 1.0.1.1 - 升级到 ASP.NET MVC 4.0。
- 2013 年 4 月 23 日:版本 1.0.1.2 - 更新基础站点步骤。
- 2013 年 5 月 18 日:版本 1.0.1.3 - 更新基础站点步骤。