65.9K
CodeProject 正在变化。 阅读更多。
Home

sBlog.Net - 极简博客引擎 - 使用 ASP.NET MVC 3

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (55投票s)

2013年1月7日

CPOL

51分钟阅读

viewsIcon

216146

downloadIcon

6483

sBlog.Net 是一个使用 ASP.NET MVC 3 框架创建的极简博客引擎。

Default home screen

议程

引言

简而言之,sBlog.Net 是一个极简的博客引擎。该项目深受 WordPress 的启发,但未来还将推出更多功能!您将能够进行博客的所有常规操作,例如添加文章或页面、添加分类或标签、添加其他作者等等。如果必须用一句话来形容 sBlog.Net,我会说:“为了 ASP.NET MVC 和 WordPress 的热爱!” 我已经预见到很多人会问“为什么要重复造轮子”。但这只是一个尝试,旨在打造一个极其简单的东西,让其他人能够轻松修改和玩转代码,而不是使用一个庞大的系统而不理解其内部工作原理。另外,请参阅 FAQ 部分,了解我关于“为什么要重复造轮子”的解释!我坚信,只需很短的时间就可以轻松修改和扩展这个博客引擎,以满足您的需求!要开始使用,您只需要 Visual Studio 2010 和 ASP.NET MVC 3、MS SQL Server (Express 版本也可以) 以及可选的 IIS。

在我深入讲解代码之前,我将首先提供一系列关于如何设置开发环境和测试环境的步骤,因为了解博客引擎的外观肯定有助于理解下面的讨论。如果您想查看在线演示,请点击 这里!另外,这里是项目主页,您可以在那里找到更多截图和关于项目的简要说明!!!

设置开发环境

本节快速列出了开始使用开发版本的步骤。您需要安装 Visual Studio 2010 (或 2008/2012) 和 MS SQL Server (完整版/Express 版)。

  • 下载 sBlog.Net 源代码并在 Visual Studio 中打开
  • 启动 MS SQL Server,创建一个新的数据库供您的博客使用 - 以管理员身份或具有 db_creator 权限的用户。如果数据库是由 Web.config 文件中将要使用的用户创建的,则跳过以下步骤以授予 db_owner 访问权限。
    • 然后,如果您还没有登录名,请创建一个登录名
    • 现在,您需要为上一步中创建的用户提供对新创建数据库的访问权限
    • 为此,展开“安全性”,然后展开“登录名”,右键单击您创建的用户,选择“属性”
    • 在出现的对话框中,选择“用户映射”
    • 在右侧窗格中,选择您创建的数据库,勾选新数据库对应的复选框
    • 在“数据库角色成员身份”部分,选择“db_owner”,然后单击“确定”
  • 转到 Visual Studio,展开 sBlog.Net 项目,打开 Web.config 文件并修改对应您服务器的连接字符串
  • 按 Ctrl + F5,您将进入设置屏幕。之后只需按照屏幕上的说明操作即可!

设置测试环境 - IIS

本节讨论设置测试环境的部分。要使用这些步骤,您的 Windows 副本必须已启用 IIS 以及所有必需的功能 (IIS 可以从控制面板的 Windows 功能中安装/启用,但具体操作可能因 Windows 操作系统类型而异)。

  • 下载 sBlog.Net 二进制 zip 文件并解压缩文件。
  • 启动 MS SQL Server,创建一个新的数据库供您的博客使用 - 以管理员身份或具有 db_creator 权限的用户。如果数据库是由 Web.config 文件中将要使用的用户创建的,则跳过以下步骤以授予 db_owner 访问权限。
    • 然后,如果您还没有登录名,请创建一个登录名
    • 现在,您需要为上一步中创建的用户提供对新创建数据库的访问权限
    • 为此,展开“安全性”,然后展开“登录名”,右键单击您创建的用户,选择“属性”
    • 在出现的对话框中,选择“用户映射”
    • 在右侧窗格中,选择您创建的数据库,勾选新数据库对应的复选框
    • 在“数据库角色成员身份”部分,选择“db_owner”,然后单击“确定”
  • 现在 (假设您的 IIS 目录是 C:\inetpub\wwwroot),创建一个名为 sblog 的文件夹
  • 将解压缩文件夹的内容复制到您在上一步中创建的文件夹
  • 现在,右键单击“Uploads”文件夹,选择“属性”。然后选择“安全性”选项卡
  • 单击“编辑”,如果您找不到 IIS_IUSRS,请添加它。然后选择该用户,为 Uploads 文件夹授予“完全控制”权限,然后单击打开的对话框中的“确定”
  • 然后,打开 Web.config 文件并修改连接字符串以使用新数据库和您创建的用户
  • 现在打开 IIS 管理器,从“开始”菜单打开,或在“运行”对话框中输入 inetmgr
  • 通过右键单击 **站点** 节点并选择 **添加网站** 来创建一个新网站
  • 在 **添加网站** 对话框中,
    • 输入站点名称
    • 选择 ASP.Net 4.0 应用程序池 (或) 创建一个使用 ASP.Net 4.0 框架的新应用程序池
    • 在“物理路径”文本框中,选择您在上一步中创建的文件夹 (sblog)
    • 现在选择“确定”
    现在您的网站已准备就绪
  • 要启动网站,请右键单击它,选择“管理网站”,然后选择“浏览”

屏幕上的说明很容易遵循。但如果您需要更多信息,请参阅 这篇博文!

项目组织结构

sBlog.Net 解决方案总共包含 8 个项目。我已将它们列出,并为每一项提供简短描述

  • sBlog.Net - 此项目包含博客引擎的核心。使用的框架是 ASP.Net MVC 3
  • sBlog.Net.DB - 此项目包含与管理 sBlog.Net 数据库相关的文件。这包括管理哪些脚本已运行、哪些未运行等内容。对该项目的更改是 2.0 版本发布的一部分,这将使您的生活轻松很多,因为您不需要像以前那样手动运行脚本!
  • sBlog.Net.Domain - 此项目包含抽象接口、它们的具体实现、一些由解决方案中其他项目共享的扩展和实用程序
  • sBlog.Net.MetaData - sBlog.Net 项目中各种视图模型相关的元数据在此处管理。此外,用于元数据类的属性也添加到此项目中
  • sBlog.Net.Akismet - 评论垃圾邮件是博客引擎的一个主要问题。因此,sBlog.Net 内置了 Akismet 支持来验证提交的评论。但是,默认情况下,评论不会使用 akismet 进行验证。博客设置完成后,您需要从设置部分启用此功能
  • MVCSocialHelper - 为了获得曝光,社交分享非常重要。因此,sBlog.Net 在开源项目 http://mvcsocialhelper.codeplex.com/ 的帮助下内置了社交分享功能
  • sBlog.Net.Tests - 这个项目还需要解释吗?!必不可少的单元测试项目!
  • sBlog.Net.Tools - 此项目包含将在预构建和后构建事件中使用的工具/实用程序。目前,该项目包含一个 JavaScript 压缩器,该压缩器在后构建事件中使用,用于压缩管理区 JavaScript 文件。压缩后的脚本在项目以发布模式运行时使用

保存/恢复与博客相关的设置

对于这样规模的项目,为博客所有者提供轻松更新功能的选项,而无需修改源代码,这是非常必要的。您必须记住,这不仅仅是一个 PHP 应用程序,您可以直接访问托管您应用程序的服务器并进行修改 (即使是 PHP,我也不建议您这样做!)。因此,“一刀切”的方案不适合博客引擎,因为它必须高度可配置。因此,我们需要能够保存和恢复与博客实例相关的设置。有多种方法可以做到这一点。您可以只使用一个 XML 文件或自定义文件。但这也会驻留在文件系统中,如果 Web 应用程序受到攻击,也会危及博客的设置。所以我选择将设置保存在数据库中。另一个需要考虑的方面是,这将是一个不断变化的表。因此,一个具有每一项设置的列的平面表是行不通的。所以我选择了一个基于键/值对的非常简单的表,这样对该表的更改将是最小的,而无需随着项目可能增长到需要存储大量设置项的阶段而无限添加列 (目前有 19 个条目)。下面是对应于该表 (名为 sBlog_Settings) 的创建表语句。

CREATE TABLE [dbo].[sBlog_Settings](
    [KeyName] [varchar](50) NOT NULL,
    [KeyValue] [varchar](max) NULL
) ON [PRIMARY]

管理端有一个相应的设置页面,您可以在其中管理这些设置。下面是该页面的截图

Settings page

识别安装状态

我的主要意图之一是确保即使是 ASP.Net 知识为零的人也能使用它。所以我希望博客设置过程尽可能简单。因此,当他们按照通过 二进制文件 进行设置的方法操作时,屏幕上的说明应该能帮助他们完成博客设置,而无需深入研究代码。因此,在第一次运行时,博客所有者能够遵循屏幕上的说明来设置博客非常重要。

为了识别博客的设置是否完成,我们可能需要使用 Global.asax.cs 文件中的 Application_StartSession_Start 事件。如果您还记得,每当应用程序首次启动或应用程序重启时,都会调用 Application_Start 方法。可能导致应用程序重启的一些活动包括 (但不限于)

  • 修改 web.config 文件
  • 回收分配给网站的应用程序池
  • 重启网站
  • 重启 IIS 服务器本身
protected void Application_Start()
{
    // -- Snip --
    VerifyInstallation();
}

private void VerifyInstallation()
{
    var settingsRepository = InstanceFactory.CreateSettingsInstance();
    var pathMapper = InstanceFactory.CreatePathMapperInstance();
    var dbStatusGenerator = new SetupStatusGenerator(schemaInstance, pathMapper);
    Application["Installation_Status"] = dbStatusGenerator.GetSetupStatus();
}

如果您注意到上面的代码片段,在 Application_Start 事件期间会调用 VerifyInstallation 方法。该方法执行一些非常简单的操作 - 首先使用实例工厂创建设置存储库和路径映射器实例。请注意,这位于 Global.asax.cs 中,应用程序尚未完全运行。因此,我们无法在此处使用依赖注入。但是,由于此实例是使用 Ninject 依赖管理模块创建的,因此由它负责处理该实例的处置。版本 1 处理安装状态的方法非常简单明了,但由于 v2.0 做了更多工作,例如基于 Web 的数据库管理,因此当前方法更加复杂。首先,我有一个 SetupStatusGenerator 助手类,它使 Global.asax.cs 文件不必承担繁重的工作!这是该类中最重要的一个方法

        
public SetupStatus GetSetupStatus()
{
    var setupStatus = new SetupStatus();

    try
    {
        var allEntries = GetSchemaVersions();
        var sortedList = _pathMapper.GetAvailableScripts();

        var schemaInstance = allEntries.LastOrDefault();
        var lastInstance = sortedList.LastOrDefault();

        if (lastInstance != null && lastInstance.Equals(schemaInstance))
        {
           setupStatus.StatusCode = SetupStatusCode.NoUpdates;
           setupStatus.Message = "Your instance is up to date!";
        }
        else
        {
            setupStatus.StatusCode = SetupStatusCode.HasUpdates;
            setupStatus.Message = "Your instance has some updates";
        }
     }
     catch (Exception exception)
     {
          if (exception.Message == "Invalid object name 'Schema'.")
          {
             setupStatus.StatusCode = SetupStatusCode.DatabaseNotSetup;
             setupStatus.Message = "Database has not been setup";
          }
          else
          {
              setupStatus.StatusCode = SetupStatusCode.DatabaseError;
              setupStatus.Message = exception.Message;
          }
      }

      return setupStatus;
}

由于这是决定安装状态的核心方法,我不能仅仅抛出发生的异常,因为没有其他模块可以处理它。因此,当发生异常时,我会检查异常是否是因为应用程序找不到 Schema 表。在这种情况下,数据库从未设置过,因此您会看到安装屏幕。但是,其他数据库相关的错误,例如无效的连接字符串,将带您进入维护屏幕,提供一些关于可能出错之处的说明。

如果没有异常,我首先使用 GetSchemaVersions 获取已运行脚本的列表。sBlog.Net 项目包含要运行的 .sql 文件,这些文件位于根目录的 Sql 文件夹中。这些脚本现在已转换为下一行中的列表。现在只需几个步骤即可检查是否有任何待定脚本,如果有,则通知用户并显示更新表单!我将把这部分留给您,因为这本身就可以成为另一篇文章!

此方法可能因各种原因失败,例如连接字符串无效、数据库名称无效等。无论哪种情况,安装状态都存储在名为 Installation_Status 的应用程序变量中。下面将继续讨论依赖注入!

既然我们已经看到了应用程序状态是如何确定的,现在让我们看看应用程序启动后会发生什么。应用程序启动后,除了发生的其他活动外,我们更关心会话何时开始。当用户访问网站并收到第一个请求时,就会发生这种情况。每当开始一个新会话时,都会检查前面讨论的应用程序变量,以确保用户被重定向到设置页面,而不是加载应用程序,如下所示:

protected void Session_Start()
{
    var databaseStatus = (SetupStatus)Application["Installation_Status"];
    var urlHelper = new UrlHelper(HttpContext.Current.Request.RequestContext);

    if (databaseStatus != null && databaseStatus.StatusCode == SetupStatusCode.NoUpdates)
    {
       var installationStatus = GetInstallationStatus();

       if (installationStatus == false)
       {
             Response.Redirect(urlHelper.RouteUrl("SetupIndex"), true);
       }
     }
     else
     {
          if (databaseStatus != null && databaseStatus.StatusCode == SetupStatusCode.DatabaseError)
          {
              Response.Redirect(urlHelper.RouteUrl("SetupError"), true);
          }

          if (databaseStatus != null && databaseStatus.StatusCode == SetupStatusCode.DatabaseNotSetup)
          {
              Response.Redirect(urlHelper.RouteUrl("InitializeDatabase"));
          }
      }
}

根据应用程序变量中的设置状态,用户将被重定向到安装页面、更新页面或错误页面。请记住,您无法从 Application_Start 事件中将用户重定向到某个页面。这是博客的“安装”页面!

Install screen

设置包含两个步骤。在第一步中,为了确保您是所有者,您需要输入应用程序的连接字符串。验证后,您将看到 **继续 >>** 按钮以进入第二步。请注意,连接字符串文本框和按钮只有在满足 2 个条件时才会出现:(1) 数据库中的连接字符串有效 (2) Uploads 文件夹是“可写的”,即 IIS_IUSRS 用户对此文件夹拥有完全访问权限 (且仅此权限!)。在设置的第二页,您可以选择博客的名称、根 URL 和管理员密码 (默认用户名是 admin)。

依赖注入

管理连接是处理任何与数据库相关应用程序的组成部分。因此,不建议您自己管理它们。添加一个依赖注入框架肯定会有帮助,因为创建的实例的处置过程由框架本身管理。sBlog.Net 项目使用 NInject 来管理依赖项。这促进了各个模块之间的松耦合,使您可以独立地替换模块。NinjectControllerFactory 类负责控制器实例获取所需依赖项的过程。该类扩展了 mvc 框架提供的 DefaultControllerFactory 类。Application_Start 方法用于设置 ninject,如下所示

protected void Application_Start()
{
    // -- Snip --

    SetupDependencyManagement();

    // -- Snip --
}

// -- Snip --

private void SetupDependencyManagement()
{
    var ninjectControllerFactory = new NinjectControllerFactory();
    ControllerBuilder.Current.SetControllerFactory(ninjectControllerFactory);
    DependencyResolver.SetResolver(new NinjectDependencyResolver(ninjectControllerFactory.GetKernel()));
}

SetupDependencyManagement 方法处理 ninject 的设置过程。第一步是创建前面讨论的 ninject 控制器工厂。然后将此实例传递给当前 ControllerBuilder 对象的 SetControllerFactory 方法。下一步,使用 NinjectControllerFactoryGetKernel 方法获取 kernel 的引用,以便使用 NinjectDependencyResolver 类创建依赖项解析器,然后将其传递给 SetResolver。我将在本节末尾详细介绍依赖项解析器!

GetControllerInstance 方法用于返回所需控制器的实例。下面是该方法

protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType)
{
    if (controllerType == null)
    {
        try
        {
            var defaultController = base.GetControllerInstance(requestContext, null);
            return defaultController;
        }
        catch (HttpException httpException)
        {
            if (httpException.GetHttpCode() == (int) HttpStatusCode.NotFound)
                throw new UrlNotFoundException("Unable to find a controller");
            throw;
        }
    }

    return (IController)_kernel.Get(controllerType);
}

在第 3 行,如果框架无法创建控制器实例,则传递 null。否则,NInject 会通过传入所需的依赖项来负责创建控制器实例。除了 GetControllerInstance 方法,还有一个私有类用作 ninject 模块的“kernel”。类的第一行创建了一个 kernel,如下所示:

private readonly IKernel _kernel = new StandardKernel(new ApplicationIocServices());

上面的 kernel 将被 ninject 模块用于根据所需的依赖项查找/创建实例。ApplicationIocServices 类充当抽象实例和具体实例之间绑定的提供者。它继承自 NinjectModule 类并实现 Load 方法。在此方法中,将接口绑定到具体类。

private class ApplicationIocServices : NinjectModule
{
    public override void Load()
    {
        Bind<IUser>().To<User>();
        Bind<IPost>().To<Post>();
        // -- snip --
    }
}

在上面的代码片段中,您可以看到接口 IUserIPost 被绑定到具体实体 UserPost。这些以接口形式的依赖项可以像下面这样在 HomeController 中使用。

public class HomeController : BlogController
{
    private readonly int _postsPerPage;
    private readonly IPost _postRepository;
    private readonly IUser _userRepository;
    private readonly ICategory _categoryRepository;
    private readonly ITag _tagRepository;
    private readonly ICacheService _cacheService;

    public HomeController(IPost postRepository, IUser userRepository, 
           ICategory categoryRepository, ITag tagRepository, 
           ISettings settingsRepository, ICacheService cacheService)
        : base (settingsRepository)
    {
        _postRepository = postRepository;
        _userRepository = userRepository;
        _categoryRepository = categoryRepository;
        _tagRepository = tagRepository;
        _postsPerPage = settingsRepository.BlogPostsPerPage;
        _cacheService = cacheService;
    }

    // -- snip --
}

每个接口,如 IPostIUser 等,还强制用户实现 IDisposable 接口,从而安全地处置数据库连接。当 NInject 处置其创建的实例时,它还会调用 Dispose 方法,从而自动处置创建的 sql 连接。这是 IUser 接口的定义。

public interface IUser : IDisposable
{
    UserEntity GetUserObjByUserID(int userID);
    UserEntity GetUserObjByUserName(string userName, string passWord);
    // -- snip --
}

我知道关于 DI 的讨论已经很多了,但正如我在上一节承诺的那样,还有一件事需要指出。只要我需要将依赖项注入到控制器,我就处于一个很好的状态,因为 ninject 会自动处理这一切,一旦我为应用程序设置了控制器工厂。但我还需要考虑 ninject 无法自动注入依赖项的情况。一个例子就是 HTML 助手,ninject 对它们没有控制权。因此,在这些情况下,我使用依赖项解析器。早些时候,我向您介绍了将抽象类型绑定到具体类型的方法。所以 ninject 已经知道所有的绑定。因此,当您通过传入抽象类型请求服务时,它将能够返回具体类型的实例。为了让 ninject 做到这一点,我创建了一个名为 NinjectDependencyResolver 的依赖项解析器,它实现了 ninject 提供的 IDependencyResolver 接口,如下所示

public class NinjectDependencyResolver : IDependencyResolver
{
    private readonly IKernel _kernel;

    public NinjectDependencyResolver(IKernel kernel)
    {
        _kernel = kernel;
    }

    public object GetService(Type serviceType)
    {
        return _kernel.TryGet(serviceType);
    }

    public IEnumerable<object> GetServices(Type serviceType)
    {
        return _kernel.GetAll(serviceType);
    }
}

我想您还记得下面的那一行,它告知 ninject 应该使用 NinjectDependencyResolver 作为依赖项解析器

DependencyResolver.SetResolver(new NinjectDependencyResolver(ninjectControllerFactory.GetKernel()));

完成此步骤后,即使类不是控制器类,我也可以随时随地获取依赖项。考虑 GetCommonScriptsAndStyles HTML 扩展中的一部分,用于获取博客的脚本和样式。

public static MvcHtmlString GetCommonScriptsAndStyles(this HtmlHelper htmlHelper)
{
    // -- Snip --    
    var settingsRepository = InstanceFactory.CreateSettingsInstance();
    
    // -- Snip --
}

注意第 2 行,我使用 InstanceFactory 类中的 CreateSettingsInstance 方法获取设置存储库的实例。下面是此方法的详细内容

public static ISettings CreateSettingsInstance()
{
    return DependencyResolver.Current.GetService<ISettings>();
}

在此方法中使用应用程序的当前依赖项解析器提供的 GetService 方法来创建 ISettings 抽象类型的具体类型实例。因此,我只是让 ninject 本身来处理创建控制器以外的类的实例,而不是向 NinjectControllerFactory 类添加一个方法并使用 kernel 来创建实例。

缓存数据以减少数据库调用次数的功能

让我们考虑一个用户浏览我们博客引擎的场景。默认情况下,博客引擎会选取前 5 篇文章并显示它们。用户现在可以选择查看前 5 篇文章,或者点击单个文章,或者按类别/标签/月&年组合进行过滤。无论哪种类型,请注意,我们每次都必须访问数据库来获取文章。为了避免这种情况并使博客响应更灵敏,我添加了从缓存中获取文章的功能。默认情况下,缓存持续时间在 ApplicationConfiguration 类中设置为 5 分钟,如下所示

private const int DefaultCacheDuration = 5;
public static int CacheDuration
{
    get
    {
        var cacheDuration = ConfigurationManager.AppSettings["CacheDuration"];
        int parsedDuration;
        return int.TryParse(cacheDuration, out parsedDuration) ? parsedDuration : DefaultCacheDuration;
    }
}

注意 CacheDuration 属性,应用程序使用它来获取缓存持续时间。在第一行,我尝试从 <appSettings /> 部分获取缓存持续时间,这是 CacheDuration 键对应的值。

   <appSettings>
       // -- Snip --
       <add key="CacheDuration" value="5"/>
   </appSettings>

在最后一步,我尝试将该值解析为整数。如果失败,我将返回默认缓存持续时间 5。现在让我们看看这个缓存持续时间是如何被使用的。如果您还记得,我之前讨论过 ApplicationIocServices.Load 方法,该方法将抽象实例绑定到具体实例。该方法还将 ICacheService 类型绑定到 CacheService 具体类型,如下所示

private class ApplicationIocServices : NinjectModule
{
    public override void Load()
    {
        // -- Snip --
        Bind<ICacheService>().To<CacheService>();
    }
}

此依赖项仅在会使用它们的控制器中请求。例如,管理区的控制器不需要缓存服务,因为一旦用户登录,他们应该能够查看最新的内容。下面是主控制器的一部分。

public class HomeController : BlogController
{
    private readonly int _postsPerPage;
    private readonly IPost _postRepository;
    private readonly IUser _userRepository;
    private readonly ICategory _categoryRepository;
    private readonly ITag _tagRepository;
    private readonly ICacheService _cacheService;

    public HomeController(IPost postRepository, IUser userRepository, 
      ICategory categoryRepository, ITag tagRepository, 
      ISettings settingsRepository, ICacheService cacheService)
        : base (settingsRepository)
    {
        // -- Snip --
    }

    // -- Snip --

    private List<PostEntity> GetPostsInternal()
    {
        var posts = Request.IsAuthenticated ? GetProcessedPosts(
          _postRepository.GetPosts(GetUserId())) : 
          _cacheService.GetPostsFromCache(_postRepository, CachePostsUnauthKey);
        return posts;
    }
}

注意构造函数 - 它获取一个 ICacheService 实例,该实例在 GetPostsInternal 方法中使用。GetPostsFromCacheICacheService 接口的流畅扩展。它如下所示

public static List<PostEntity> GetPostsFromCache(
    this ICacheService cacheService, IPost postRepository, string keyName)
{
    return cacheService.Get(keyName, () => postRepository.GetPosts());
}

此方法在调用时接收一个文章存储库和一个键名,并使用一个缓存服务实例。此方法通过传入键名和当缓存不包含由传入键标识的项目时要执行的回调方法来调用 Get 方法。在我向您介绍 Get 方法时,这将会更清晰。

public class CacheService : ICacheService
{
    public T Get<t>(string cacheID, Func<t> getItemCallback) where T : class
    {
        var item = HttpRuntime.Cache.Get(cacheID) as T;
        if (item == null)
        {
            item = getItemCallback();
            HttpContext.Current.Cache.Insert(cacheID,item,null,DateTime.Now.AddMinutes(
              ApplicationConfiguration.CacheDuration),Cache.NoSlidingExpiration);
        }
        return item;
    }
}

在前一段中,我讨论了在数据不可用时用于更新缓存的回调。数据不可用发生在应用程序初始启动时,或者缓存持续时间过期时。这就是 CacheDuration 属性发挥作用的地方。让我们更深入地研究该方法,以便我解释这一点。此方法的第一行尝试使用 HttpRuntime.Cache.Get 方法从缓存中获取数据,并将其强制转换为泛型类型参数 T。如果项目为 null,则使用传入的回调来获取数据并将其插入缓存。然后将数据返回给用户。注意第 4 个参数,它使用了我们之前讨论的缓存持续时间。这是一个固定的缓存持续时间,之后由传入的键标识的缓存内容将失效。最后一个参数用于传递滑动持续时间,这意味着传入的持续时间将在每次访问缓存数据时续订。因此,假设它设置为 10,当首次访问数据时,将创建缓存项。然后,在 5 分钟后,如果访问了缓存项,持续时间将再次设置为 10 分钟,依此类推。

您可能会想,为什么我选择将缓存持续时间键放在 Web.config 文件中而不是设置中,从而可以在管理页面中更新缓存时间。在我开始讨论之前,我确信您现在知道,如果您对应用程序的 Web.config 文件进行更改,应用程序将会重启。这将清除缓存中的所有项、应用程序变量、会话变量等。这是我希望将缓存持续时间键放在 Web.config 文件中的一个原因,从而在您更新缓存时清除缓存。

用户认证

此项目使用自定义成员资格提供程序,以便完全控制用户身份验证方式。实现自定义成员资格提供程序的第一个步骤是创建一个扩展 MembershipProvider 类的类。该类有许多方法,但目前重点关注几个方法和属性 - 创建用户、通过用户名获取用户实体、最小密码长度、是否需要唯一电子邮件以及验证用户。然后,您必须修改 web.config 以告知 mvc 框架使用自定义成员资格提供程序来处理登录和注销。sBlog.Net 的 web.config 文件已通过 <membership /> 元素添加了自定义成员资格元素。注意 type 属性中的完全限定名称,以及 connectionStringName 属性。

<membership defaultProvider="CustomMembershipProvider">
  <providers>
    <clear/>
    <add name="CustomMembershipProvider" 
        type="sBlog.Net.Infrastructure.CustomMembershipProvider"
        connectionStringName="AppDb"
        enablePasswordRetrieval="false"
        enablePasswordReset="true"
        requiresQuestionAndAnswer="false"
        requiresUniqueEmail="false"
        maxInvalidPasswordAttempts="5"
        minRequiredPasswordLength="6"
        minRequiredNonalphanumericCharacters="0"
        passwordAttemptWindow="10"
        applicationName="/" />
  </providers>
</membership>

我的这篇文章更多地讨论了自定义成员资格提供程序。您可以随意查看,因为在此处添加该内容会使本文过长,您会放弃阅读 Smile | <img src=

基础控制器

此项目中的所有控制器都继承自 BlogController,后者继承自 Controller 类,所有控制器都应该继承该类,才能用作“控制器”。此基控制器提供了将在项目中的其他控制器中共享的许多属性和方法。它还执行最重要的工作,即根据用户选择的主题决定使用哪个布局页面。请看下面的 OnActionExecuted 方法

protected override void OnActionExecuted(ActionExecutedContext filterContext)
{
    var action = filterContext.Result as ViewResult;

    if (action != null && !string.IsNullOrEmpty(ExpectedMasterName))
    {
        var themeName = SettingsRepository.BlogTheme;

        if (ThemeExists(themeName))
        {
            action.MasterName = MasterExists(themeName) ? string.Format(LayoutFormat, themeName,                                    
                   ExpectedMasterName):string.Format(DefaultLayoutFormat,ExpectedMasterName);
        }
        else
        {
            throw new InvalidThemeException("Invalid theme {0}", themeName);
        }
    }
    
    base.OnActionExecuted(filterContext);
}

基控制器有一个名为 ExpectedMasterName 的属性。此属性仅在服务主博客的控制器中设置。管理区控制器不设置此属性。因此,该方法首先检查此属性是否已设置。如果是,则获取博客主题(从设置中)并检查主题是否存在 - 即“Themes”文件夹中是否存在指定名称的文件夹。然后,它检查主题文件夹是否包含布局文件。如果是,则使用该布局文件。否则,将使用根目录 Views 文件夹中可用的公共布局。如果设置了 ExpectedMasterName,但主题文件夹不存在,应用程序将抛出异常并终止。

我还将讨论一些其他 integral 方法。下面是 GetUserId 方法

protected int GetUserId()
{
    var userId = -1;
    if (Request.IsAuthenticated)
    {
        var userIdentity = (IUserInfo)User.Identity;
        userId = Int32.Parse(userIdentity.UserId);
    }
    return userId;
}

此方法返回已登录用户的用户 ID。如果没有用户登录,它只返回 -1。每个控制器都公开一个实现 IPrincipal 接口的 User 属性,此对象公开一个名为 IIdentity 的属性。此对象包含很多有用的信息,可供使用,如果当前用户已登录。User 对象在 Global.asax.cs 文件中的 PostAuthenticateRequest 事件期间,在用户成功认证后初始化,如下所示

public override void Init()
{
    PostAuthenticateRequest += MvcApplication_PostAuthenticateRequest;
    base.Init();
}

void MvcApplication_PostAuthenticateRequest(object sender, EventArgs e)
{
    var authCookie = HttpContext.Current.Request.Cookies[FormsAuthentication.FormsCookieName];
    if (authCookie != null)
    {
        var encTicket = authCookie.Value;
        if (!String.IsNullOrEmpty(encTicket))
        {
            var ticket = FormsAuthentication.Decrypt(encTicket);
            var id = new UserIdentity(ticket);
            var prin = new GenericPrincipal(id, null);
            HttpContext.Current.User = prin;
        }
    }
}

请注意上面代码片段中的第 16-18 行。一旦用户成功通过身份验证,此方法会首先验证身份验证 cookie,然后创建一个 UserIdentity 实例,该实例实现框架提供的 IIdentity (必需) 接口。此外,我还创建了一个名为 IUserInfo 的接口,它有两个属性 - UserIdUserToken。这两个属性用于在整个应用程序中获取与用户相关的信息。UserId 的用法应该一目了然。但是,UserToken 可能不是。在 sBlog.Net 中,许多活动都通过 AJAX 请求完成。为了安全地处理这些请求,除了检查请求是否已通过身份验证外,我还验证从客户端传递的令牌。仅当用户已通过身份验证且用户的令牌与数据库中用户的令牌匹配时,才会处理请求。此令牌在每次登录时设置/重置,并且对特定用户是唯一的。下面是管理评论视图中的一部分代码。每当页面需要发出 AJAX 请求时,此页面还必须传递用户的一次性令牌。因此,它呈现如下

@Html.HiddenFor(model => Model.OneTimeCode)

一旦令牌在视图中呈现,就可以像下面这样使用。这是执行 AJAX 请求以删除评论的部分。第 9 行非常关键。传递到 URL 的数据也包括一次性令牌以及要删除的元素的 ID。

$('.trashComment').click(function (e) {
    e.preventDefault();
    var anchor = this;
    var commentId = $(anchor).next('.trashCommentID').val();

    $.ajax({
        type: 'GET',
        url: siteRoot + 'Admin/CommentAdmin/TrashComment',
        data: { 'commentId': parseInt(commentId), 'token': $('#OneTimeCode').val() },
        dataType: 'json',
        success: function (data) {
            if (data.DeleteStatusString == "Delete succeeded") {
                var row = $(anchor).parent().parent().parent();
                $(row).remove();
            }
        },
        error: function (req, status, err) {
            alert('an error occurred while trying to delete the selected comment, please try again.');
        }
    });
});

以上所有讨论都是为了给您提供以下方法的背景信息,该方法为控制器提供了一种获取已登录用户的临时令牌的方法。

protected string GetToken()
{
    var userIdentity = (UserIdentity)User.Identity;
    return userIdentity.UserToken;
}

替换默认字符串哈希机制

既然我们已经讨论了用户如何通过身份验证,现在让我们看看如何根据我们的需求进行自定义。web.config 有一个非常重要的键 hasher,它决定了如何哈希您的字符串。此键的值默认为 sBlog.Net.Domain.Hashers.Md5Hasher,这是实现 IHasher 接口的类的完全限定名称。这是接口定义

public interface IHasher
{
    string HashString(string srcString);
}

默认的哈希器 Md5Hasher 仅使用项目中已有的实用程序类返回输入的字符串的计算 MD5 哈希,如下所示。请注意,它实现了 IHasher 接口。

public class Md5Hasher : IHasher
{
   public string HashString(string srcString)
   {
       return HashExtensions.GetMD5Hash(srcString);
   }
}

正如大家可能都听说过的,MD5 不是哈希字符串最安全的方法。因此,我总是建议将其替换为更安全的哈希器,例如 SHA-1。要替换默认哈希器,首先需要创建一个实现 IHasher 接口的新哈希器。例如,在与 Md5Hasher 相同的命名空间下创建一个新的类 ShaHasher。然后实现 IHasher 接口并实现用于哈希传入字符串的方法。要指示博客引擎使用此哈希器,请将 web.config 键更新为您创建的新类型的完全限定名称,例如 sBlog.Net.Domain.Hashers.ShaHasher

关于密码助手的一点看法

PasswordHelper 类使用此哈希器来哈希与用户代码组合的密码,以便用户名和密码不会容易受到字典攻击。让我们花几分钟时间转向 PasswordHelper 类。类定义如下

public static class PasswordHelper
{
    public static string GenerateHashedPassword(string userPassword, string randomCode)
    {
        var hasher = Hasher.Instance;
        var hashedPassword = hasher.HashString(string.Format("{0}{1}", userPassword, randomCode));
        return hashedPassword;
    }
}

sBlog.Net 将密码与博客引擎本身生成的 salt 一起哈希,salt 与其他用户信息一起存储在数据库中。作为额外的安全层,用户代码在数据库中进行了加密。但是,密码是**哈希**而不是**加密**,因为这是存储密码的推荐方法。因此,GenerateHashedPassword 方法接收用户输入的密码和一个**解密**的用户代码。该方法在需要验证用户输入的用户名和密码时使用。**重要提示:**一旦您完成了博客的设置,就不能再更改此密钥,否则所有密码验证都会失败。

既然我们已经了解了更新博客哈希机制的所有内容,现在让我简要介绍一下所有这些是如何关联起来的。如果您注意到,我从未讨论过以下行

var hasher = Hasher.Instance;

InstanceHasher 静态类中的一个静态属性。Hasher 类的功能是使用 Web.config 中定义的哈希器,通过前面介绍的 Hasher 键来创建一个实例。让我们看看这个类。我觉得它很有趣 Smile | <img src= ! 

public static class Hasher
{
    private const string DefaultHasher = "sBlog.Net.Domain.Hashers.Md5Hasher";

    public static IHasher Instance
    {
        get
        {
            IHasher iHasher;
            var assemblyName = Assembly.Load("sBlog.Net.Domain").CodeBase;
            try
            {
                var hasher = ApplicationConfiguration.HasherTypeName;
                iHasher = (IHasher)Activator.CreateInstanceFrom(assemblyName, hasher).Unwrap();
            }
            catch
            {
                var instance = Activator.CreateInstanceFrom(assemblyName, DefaultHasher).Unwrap();
                iHasher = (IHasher)instance;
            }
            
            return iHasher;
        }
    }
}

ApplicationConfiguration,顾名思义,包含可以在整个应用程序中共享的属性。它包含一个名为 HasherType 的属性,该属性返回 Web.config 文件中指定的完全限定类型名称。Instance 属性首先尝试获取此值并尝试创建该类型的实例。请注意,目前它仅尝试从 sBlog.Net.Domain 程序集中加载。如果创建实例失败,则创建一个默认的 md5 哈希器实例并返回。CreateInstanceFrom 方法用于创建 ObjectHandle 实例,通过传递可以识别此类型元数据的程序集名称以及哈希器的完全限定类型名称。然后,此 ObjectHandle 被转换为可以使用 Unwrap 方法强制转换为已知类型的形式。因此,在整个博客引擎中,当需要哈希时,我们只需从此静态类获取实例,这样我们就不必担心查找各种程序集并为我们使用创建实例的复杂细节。

自定义绑定复杂数据类型 - 管理文章/页面的添加和编辑

自定义模型绑定是 mvc 3 的一个强大功能,可用于绑定复杂的 C# 对象。在 sBlog.Net 的情况下,自定义模型绑定用于将发布到 PostViewModel 的数据内容绑定,其中还包含一个 CheckBoxListViewModel 属性。让我们分析其中一个,即 PostViewModel 的绑定器。它被称为 PostViewModelBinder,位于 sBlog.Net (核心) 项目的 Binders 文件夹中。

public class PostViewModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var postModel = new PostViewModel
                {
                   Post = new PostEntity {PostID = int.Parse(bindingContext.GetValue("Post.PostID"))    
                   // -- Snip -- 
                };

        postModel.Post.Order = postModel.Post.EntryType == 2 ? 
          (int?)GetOrder(bindingContext.GetValue("Post.Order")) : null;

        IModelBinder ckBinder = new CheckBoxListViewModelBinder();
        postModel.Categories = 
          (CheckBoxListViewModel)ckBinder.BindModel(controllerContext, bindingContext);

        if (postModel.Post.EntryType == 1)
        {
            if (!postModel.Categories.Items.Any(c => c.IsChecked))
            {
                var general = postModel.Categories.Items.SingleOrDefault(c => c.Value == "1");
                if (general != null)
                {
                    general.IsChecked = true;
                }
            }

            postModel.Tags = bindingContext.GetValue("hdnAddedTags");
        }

                return postModel;
    }
        
    private static int GetOrder(object value)
    {
        int parsedValue;
        if (value == null || value.ToString() == string.Empty)
            return int.MaxValue;
        return int.TryParse(value.ToString(), out parsedValue) ? parsedValue : int.MaxValue;
    }
}

我不想通过详细解释每一行来测试您的耐心。所以,我只指出一些要点。请注意,该类实现了 IModelBinder 接口,其中包含 BindModel 方法。当 mvc 框架调用此方法时,它会传递一个 ControllerContext 对象和一个 ModelBindingContext 对象。ModelBindingContext 对象包含所有已发布字段,后续行尝试获取值并创建一个新的 PostViewModel 对象。当我们尝试将复杂模型绑定到对象时,模型绑定器就会发挥作用。只要动作方法参数中的类型是简单的 C# 对象,我们就不必担心模型绑定器,让 mvc 框架来处理它。但在本例中,只需看一眼我们要绑定的模型就会证明,它不仅仅是一个需要原始绑定的简单类。因此,需要创建一个自定义模型绑定器。下面是利用模型绑定器的 Add 方法。

public ActionResult Add()
{
    // -- Snip --
}

[HttpPost]
[ValidateInput(false)]
public ActionResult Add(PostViewModel postModel)
{
    // -- Snip --
}

注意 Add 方法中的 PostViewModel 参数,它被 HttpPost 属性装饰。模型绑定器在此处发挥作用。当为 Add 方法发出 POST 请求时,会使用必要的参数调用 PostViewModelBinder.BindModel 方法。但是,这个方法不是“神奇地”被调用的,您必须进行必要的更改才能实现这一点。这种“魔法”是在 Global.asax.cs 文件中完成的,如下所示

protected void Application_Start()
{
    // -- Snip --

    SetupCustomModelBinders();

    // -- Snip --
}

// -- Snip --

private void SetupCustomModelBinders()
{
    ModelBinders.Binders.Add(typeof(CheckBoxListViewModel), new CheckBoxListViewModelBinder());
    ModelBinders.Binders.Add(typeof(PostViewModel), new PostViewModelBinder());
}

在 Global.asax.cs 文件中的 Application_Start 方法中,必须注册绑定器,以便框架调用绑定器,如上所示。此时,SetupCustomModelBinders 方法中的第一行注册 CheckBoxListViewModel 不是由框架调用的,而是由 PostViewModelBinder.BindModel 方法调用的,如您之前所见 (代码的相应行如下所示)

postModel.Categories = (CheckBoxListViewModel)ckBinder.BindModel(controllerContext, bindingContext);

即使框架没有调用复选框列表的模型绑定器,如果您在操作方法的参数中使用 CheckBoxListViewModel,并带有 HttpPost 属性装饰,框架也会调用我们自定义绑定器的 BindModel 方法。

节省一些周期

当您安装 ASP.Net MVC 3 时,您可以选择使用 Razor 视图引擎 (MVC 3 的默认设置) 或 WebForms 视图引擎 (MVC 2 的默认设置) 来创建视图。如果您注意到,在此项目中,我只使用 Razor 视图。因此,在考虑性能改进时,我阅读了一篇关于 MVC 3 如何查找视图的文章。请看下面的一个简单操作方法

public class HomeController
{
   public ActionMethod Index()
   {
      return View();
   }
}

当用户通过输入 URL /home/index 请求此方法时,ASP.NET MVC 会查找“Index”视图。但请注意,我们在这里没有指定扩展名,这意味着它使用哪个视图引擎。因此,ASP.NET MVC 将查找使用 Razor 模型和 WebForms 模型创建的 Index 视图。对于 sBlog.Net,它会找到 Index.cshtml (Razor) 并进行渲染。但要做到这一点,ASP.NET MVC 首先会搜索 Index.aspx 视图,然后搜索 Index.cshtml 视图。在这种情况下,框架会浪费一个周期来查找 Index.aspx 视图。为了节省这个周期,我将指示 ASP.NET MVC 只考虑 Razor 视图引擎。在我展示代码之前,请看下面的截图。在这种情况下,操作方法返回一个不存在的视图,并注意文件的搜索顺序

Order used for searching a view

从上面的屏幕截图中,您可能会注意到,首先查找基于 WebForms 的视图,然后查找基于 Razor 的视图。现在,我将向您介绍如何通过下面的代码片段节省一些周期

protected void Application_Start()
{
   // -- Snip --

   SetupViewEngines();

   // -- Snip --
}

private void SetupViewEngines()
{
    ViewEngines.Engines.Clear();
    ViewEngines.Engines.Add(new RazorViewEngine());
}

Application_Start 方法中,我调用 SetupViewEngines 方法。该方法首先清除框架的 ViewEngines 类提供的 Engines 属性中添加的所有引擎 (即 WebForms 和 Razor 视图引擎)。然后我只添加 RazorViewEngine,从而节省了每个操作方法调用的几个周期,因为 ASP.NET MVC 现在知道在请求视图时不需要查找 WebForms 视图。下面是显示当添加上述修改并渲染不存在的视图时会发生什么的屏幕截图。请注意,现在它只查找基于 Razor 视图引擎的视图!

Order used for searching a view

生成唯一的 Slug (URL 别名)

在博客引擎中,能够为文章或页面的 URL、标签和分类生成唯一的 Slug 是至关重要的。对于 URL,并非所有字符串都是有效的,因为这些 Slug 是 URL 的一部分。例如,作为 URL 的一部分,web.config 是无效的。因此,我们应确保它不会成为 URL 的一部分。为了使其简单且不易出错,在生成 Slug 之前,我首先剥离任何非字母 (a-z, A-Z)、数字 (0-9)、空格、句点和连字符的字符。让我先向您展示一些代码,然后再用一组简单的步骤解释正在发生的事情

public static string GetUniqueSlug(this string srcString, List<string> allItems)
{
    var regex = new Regex(@"[^a-zA-Z 0-9\.\-]+");
    
    var slug = regex.Replace(srcString.ToLower(), string.Empty);

    slug = ReplaceMatches(slug, @"[ ]{2,}").Replace(" ", "-");
    slug = ReplaceMatches(slug, @"[\.]{2,}").Replace(".", "-");
    slug = ReplaceMatches(slug, @"[\-]{2,}");

    if (slug.StartsWith("-") && !slug.EndsWith("-"))
        slug = string.Format("0{0}", slug);

    if (!slug.StartsWith("-") && slug.EndsWith("-"))
        slug = string.Format("{0}0", slug);

    return allItems.Any(s => s == slug) ? GetUniqueSlugInternal(slug, allItems)  : slug;
}

private static string ReplaceMatches(string srcString, string pattern)
{
    var removalPattern = new Regex(pattern);
    return removalPattern.Replace(srcString, "-");
}

现在您已经看到了一些代码,让我简短地描述一下我正在做什么。

  • 首先,我删除任何非字母 (小写/大写)、数字 (0-9) 以及空格、句点、连字符之外的字符,并将其转换为小写,因为 URL 中的大小写无关紧要
  • 然后我将两个或多个空格替换为连字符,并将单个空格替换为连字符
  • 然后我将两个或多个句点替换为连字符,并将单个句点替换为连字符
  • 然后我将多个连字符替换为单个连字符
  • 在下一步中,如果 Slug 以连字符开头/结尾 (即使不是问题),则会在其前面/后面加上一个“0”
  • 最后,如果 Slug 从未使用过 (通过 allItems,即现有的 Slug 列表来识别),则按原样返回。否则,将调用一个内部方法,通过传递到目前为止形成的 Slug 来以 <slug>-<number> 的格式查找 Slug。如下所示
private static string GetUniqueSlugInternal(string srcString, List<string> srcList)
{
    var slugRegex = new Regex(string.Format(@"^{0}-([0-9]+)$", srcString));
    var matchingSlugs = new List<int>();
    srcList.ForEach(s =>
    {
        var match = slugRegex.Match(s);
        if (match.Success)
        {
            var number = int.Parse(match.Groups[1].Captures[0].Value);
            matchingSlugs.Add(number);
        }
    });
    if (matchingSlugs.Any())
    {
        var max = matchingSlugs.Max();
        return string.Format("{0}-{1}", srcString, max + 1);
    }
    return string.Format("{0}-2", srcString);
}

我认为这个方法很有趣,因为我需要找到一个未使用的 URL。我不能只使用一个固定数字来生成我之前指定的格式的 Slug。例如,假设已经有一个名为“f”的标签,对应于标签名“F#”。现在,如果用户输入一个名为“F”的新标签,它也会归结为 Slug “f”,但它已经被使用了。因此,我们必须找到一个不会与现有标签冲突的新 Slug。所以您必须开始按“f-2”、“f-3”、“f-4”... 等顺序查找可用性。因此,不建议使用固定数字,例如 i=99999 并顺序尝试。因此,我采用的方法是定义一个正则表达式,该正则表达式标识任何以冲突的 Slug 开头的标签,后跟一个连字符,然后是任意数量的数字。然后,对于所有匹配项,我创建一个数字列表,然后找到其中的最大值。将此数字加 1 即可得到一个从未使用过的数字。对于我之前描述的情况,将不会有任何数字可供查找,这就是为什么我有一个默认情况,即我只返回 <slug>-2 格式的 Slug。

精简 JavaScript 文件 (管理区)

由于涉及到大量的 AJAX 调用和许多其他活动,这个博客引擎的管理区包含大量的 JavaScript 内容。即使浏览器不总是获取 JavaScript 文件,减小浏览器必须保存的 JavaScript 文件的大小也非常重要。这就是为什么我认为在构建过程中自动精简 JavaScript 文件是有利的。此外,仅在项目以发布模式运行时使用脚本文件的精简版本是必要的。应用程序当前在 IIS 下运行的模式可以通过 Web.config 文件中的 <compilation /> 元素来识别。如下所示

<compilation debug="true" targetFramework="4.0">
</compilation>

在我解释应用程序如何识别其运行在调试/发布模式下之前,让我先讨论一下我如何处理精简过程。我在构建事件中使用 jsmin.exe (Douglas Crockford 的 jsmin.exe)。为了方便精简而无需复杂的逻辑,脚本的组织方式如下。在 sBlog.Net 项目中的“Scripts”文件夹内,有一个名为“Required”的文件夹。在此文件夹内,还有 2 个子文件夹 - “debug”和“minified”。“debug”文件夹包含所有必需的脚本,其前缀数字表示它们在精简文件中的出现顺序。构建时,精简文件将复制到“minified”文件夹。下面是我用于构建精简脚本的命令。

type "$(ProjectDir)Scripts\Required\debug\*.admin.js" |  "$(SolutionDir)sBlog.Net.Tools\Minifiers\jsmin" > "$(ProjectDir)Scripts\Required\minified\script-bundle.min.js"

要在项目中找到此命令,请右键单击 sBlog.Net 项目,选择“属性”,然后选择“构建事件”选项卡,在右侧窗格中,您会找到一个用于“后构建事件命令行”的文本区域。这就是输入此命令的位置。此命令仅列出每个文件的内容,将其重定向到 jsmin.exe 文件。jsmin.exe 文件会对其进行精简,并将输出重定向到追加到 script-bundle.min.js 文件。现在让我们进入本节的下一部分,即应用程序如何确定它运行的模式。即使 web.config 文件包含带有 debug 属性的 compilation 元素,您也不需要解析 web.config 文件或做任何花哨的事情来获取此信息。ASP.Net 已经通过 HttpContext.Current 中的 IsDebuggingEnabled 属性处理了这一点,如下所示 (来自 AdminScriptProviderHelpers.cs)

private static bool IsDebug()
{
    return HttpContext.Current.IsDebuggingEnabled;
}

管理站点错误

所有未被顶层类捕获和处理的错误都会被记录到一个名为 Errors 的表中。博客所有者还可以选择是否从设置部分接收关于博客引擎中发生的每一个全局异常的电子邮件。仅仅在 Global.asax.cs 中注册一个 HandleErrorAttribute 是不够的,对于博客引擎来说。我们还必须处理 Application_Error 方法,以捕获可能不会被错误处理属性捕获的错误,因为它发生在 mvc 框架介入之前。让我对此进行更深入的探讨。请看下面的代码片段

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
    filters.Add(new BlogErrorAttribute());
}

Application_Start 方法调用 RegisterGlobalFilters 来注册错误过滤器。此属性会覆盖 OnException 方法并首先记录异常。然后,根据各种属性,GetRedirectResultByExceptionType 方法决定将用户重定向到哪个错误页面。例如,如果操作方法属于管理员级别的控制器,由 BlogController 基类中的 IsAdminController 属性标识,这是 IControllerProperties 接口的一部分。然而,生活并不像您希望的那样轻松 Smile | <img src= " /> 错误可能发生在 mvc 框架启动之前。例如,在 Application_Start 方法中,或者 SQL 连接错误等等。这就是为什么我也在 Application_Error 方法中处理错误,如下所示

protected void Application_Error()
{
    var exception = Server.GetLastError();
    var urlHelper = new UrlHelper(HttpContext.Current.Request.RequestContext);

    if (exception is UrlNotFoundException)
    {
        Log(exception);
        Response.Redirect(urlHelper.RouteUrl("Error404"), true);
    }
    else if (exception is SqlException)
    {
        Response.Redirect(urlHelper.RouteUrl("SetupError"), true);
    }
}

请注意,如果异常是 SqlException 类型,则尝试记录它没有意义。所以我们放弃记录,直接将用户重定向到一个告知用户问题的页面并停止执行。

用于创建文章/页面的富文本编辑器

好的,我们已经看了很多“后端”的东西,现在是时候看一些“前端”的东西了,这是博客引擎的普通用户或管理员会看到的。首先,让我们看看管理方面的一些内容。我将从讨论博客引擎最重要的功能开始 - 用于处理文章/页面的富文本编辑器。富文本编辑器是博客引擎的重要组成部分,因为没有它,编写基于 HTML 的内容可能会非常繁琐。我使用 **CKEditor** 来提供此功能。除了提供编写基于 HTML 的内容的基本功能外,它还提供许多其他功能,例如挂钩功能以允许用户上传文件并为他们生成的 HTML 内容提供链接。使用 ckeditor 非常简单。以下是创建它的步骤

  • 将 jQuery 添加到页面
  • 将 CKEdoitor 文件夹中的 ckeditor.js 添加到页面
  • 将 CKEditor\adapters 文件夹中的 jquery.js 添加到页面
  • 目前,所有引用的文件都已添加到将用于管理区的 _LayoutAdmin.cshtml 布局页面
  • CKEditor 启用的文本区域用于许多页面 - 用于添加/编辑文章、添加/编辑页面。所以我创建了一个名为 AddEditPost.cshtml 的用户控件,该控件将在所有这些页面之间共享,从而实现重用而不会重复。
  • 此用户控件包含一个 textarea 输入字段,将应用 ckeditor 插件,CSS 类指定为 adminRichText
  • 最后,站点的 admin JavaScript 文件像下面这样应用插件
$(document).ready(function () {
    jQuery('.adminRichText').ckeditor();
}

通过遵循这些步骤,可以创建一个功能齐全的富文本/HTML 编辑器,从而使博客作者能够轻松创建基于 HTML 的文章。下面是添加文章的页面外观的截图

Creating a post

下面是添加页面的外观的截图

Creating a page

sBlog.Net 中的评论功能

没有读者评论和表达意见的博客算什么?在我看来,这相当于一本有声书!所以,不用说,sBlog.Net 默认包含评论功能!但博客面临的一个普遍问题是,评论垃圾邮件不断涌入!我在设计 sBlog.Net 的评论系统时考虑了所有这些。

回到 sBlog.Net 的原生评论系统,它非常简单。每篇文章/页面都可以选择性地包含一个表单,供用户输入评论。它会询问评论者的姓名、电子邮件 (可选) 和评论本身等基本信息。很简单,对吧?但我如何限制垃圾邮件而不使用 CAPTCHA 或其他花哨的工具呢?我采用了两层安全措施来保护评论系统,其中一层是可选的。让我来讨论第一层。这个想法深受 Growmap anti spam-bot plugin 的影响。在此方法中,除了指定的字段外,还会使用 JavaScript 从客户端添加一个复选框。因此,添加的复选框对垃圾邮件机器人不可见,因为 HTML 中不包含该复选框元素。但想要评论的“正常”用户可以看到复选框,以及使用 JavaScript 添加的与此复选框关联的标签会指示用户“勾选”此复选框,以表明他不是**垃圾邮件机器人**!

如果不勾选此复选框,则无法提交表单,因此基于 HTML 输入元素运行的垃圾邮件机器人根本无法提交此表单!但是,如果浏览器禁用了 JavaScript 怎么办?是的,这会绕过此检查,导致提交垃圾邮件。这就是为什么我添加了 akismet 支持 (默认关闭)。通过 akismet,可以在服务器端验证评论,并识别它是否属于“ham”(有效评论) 或“spam”。这无法被垃圾邮件机器人绕过,从而解决了评论垃圾邮件问题!

博客评论可以在网站范围内启用/禁用,也可以在每篇文章/页面基础上禁用。当您只想关闭某一篇/页面 (例如,“关于”页面) 的评论时,这会很有用。每个作者都可以管理自己文章的评论,博客管理员可以管理网站范围内的评论。请参阅上一节以获取显示评论如何按文章/页面启用/禁用的屏幕截图。

另外,下面是博客管理员和“普通”作者的评论管理页面的屏幕截图。

管理员视图:其他作者的评论

Admin view of other author's comments

管理员视图:(管理员)文章的评论

Admin view of comments for (admin's) posts

作者视图:作者文章的评论部分

Author's view of comments section for author's posts

随着 v2.0 的发布,sBlog.Net 支持 Disqus!Disqus 是一个免费的评论系统,它负责管理评论的繁重工作!一旦您在 Disqus 上注册并获得“Disqus 短名称”,您就可以开始了!现在您可以转到博客的设置页面,启用 Disqus 并提供此短名称。从此刻开始,sBlog.Net 将接管一切!每篇博客文章/页面都会获得 Disqus 评论系统,取代博客引擎的原生评论。在注册 Disqus 时,您还可以提供您现有的 Akismet 密钥以获得自动垃圾邮件保护!下面是显示 Disqus 正在工作的屏幕截图!

Disqus Integration

语法高亮和社交分享

sBlog.Net 具有语法高亮器 (由 Alex Gorbatchev) 和社交分享功能 (借助 MVC Social Helper)。作为一名开发者,我一直认为通过相应的代码来解释事物很有帮助,因为它使读者的生活更容易,查看代码片段有助于理解事物,并且还可以减少传达您意图所需的解释量。下面是一篇包含 C# 代码的文章。

Syntax highlighter enabled page

随着各种社交网络的出现,它们支持共享,我认为拥有内置的社交分享功能也至关重要。因此,sBlog.Net 还内置了社交分享功能。下面是一个显示带有社交分享图标的页面的屏幕截图。

Social sharing enabled page

语法高亮器和社交分享都可以从设置页面在全站范围内启用/禁用。一旦其中一个被启用,作者还可以控制仅为单个文章/页面启用/禁用它们。

仅使用 CSS 文件创建新主题

现在,是时候讨论 sBlog.Net 博客引擎的一个很棒的功能了。这一节和后续的章节将讨论如何创建主题来定制博客的布局。您可以通过几种方式做到这一点,所以,让我们从**“简单”**的方式开始!通过遵循这种方法,您可以快速创建自定义设计的主题,而不是使用默认提供的主题。这种方法在使用 Visual Studio 或 IIS 时都有效。以下是创建主题的步骤: 

  • 选择 *Themes* 文件夹
  • 右键单击 *Themes* 文件夹,选择 *新建文件夹*,输入您的主题名称
  • 最低限度,您至少应该创建一个 css 文件。所以,在您的新文件夹内,创建一个名为 *css* 的文件夹
  • 添加一个 css 文件,我们称之为 *style.css*
  • 用于文章 (_Layout.cshtml) 和页面 (_LayoutPage.cshtml) 的布局位于 ~/View/Shared 文件夹中。在您的 style.css 文件中添加您需要的所有 css。       
  • 现在,保存更改,构建/发布您的项目 (如果您正在使用 Visual Studio 操作),然后启动它       
  • 以管理员身份登录管理区,然后转到 *设置* 部分        
  • 现在,如果您在 *博客主题* 下拉列表中,您将看到您的新主题
  • 选择您的新主题并保存更改
  • 您现在应该能够看到您的主题已被应用! 

使用布局文件创建新主题

本节讨论一种更详细的创建自定义设计主题的方法,而不是使用默认提供的布局。这种方法在使用 Visual Studio 或 IIS 时都有效。以下是创建也定义布局的主题的步骤:       

  • 选择 *Themes* 文件夹
  • 右键单击 *Themes* 文件夹,选择 *新建文件夹*,输入您的主题名称
  • 在此方法中,您需要创建文章、页面以及 CSS 的布局
  • 现在创建 2 个文件:_Layout.cshtml_LayoutPage.cshtml 
  • 创建一个名为 css 的文件夹,添加一个名为 style.css 的文件,并添加您喜欢的样式
  • 参考任何具有这两个文件的默认主题,将 HTML 添加到您的新文件中,使其与默认主题中的相似
  • 您也可以复制/粘贴现有布局并根据需要进行修改
  • 只需确保您拥有所有部分,例如分类部分、最新文章部分等! 
  • 现在,保存更改,构建/发布您的项目 (如果您正在使用 Visual Studio 操作),然后启动它 
  • 以管理员身份登录管理区,然后转到 *设置* 部分 
  • 现在,如果您在 *博客主题* 下拉列表中,您将看到您的新主题
  • 选择您的新主题并保存更改
  • 您现在应该能够看到您的主题已被应用! 

生成博客的 RSS Feed 

最后,让我谈谈 RSS Feed!RSS Feed 是任何博客引擎的重要组成部分。它帮助用户跟踪博客的状态,而无需每次都访问博客来检查是否有新文章。每种主流浏览器都支持订阅 RSS Feed,因此它是博客引擎中的一项重要功能。ASP.Net MVC 3 有许多 ActionResult 类型,例如用于返回 HTML、JSON、Content 等的类型,但没有用于返回 RSS 数据的类型。以下类解决了这个缺失的链接!您可以看到它继承自 ActionResult 类并重写了 ExectuteResult 方法。注意第一行。这一行将 Content-Type 设置为“application/rss+xml”,否则浏览器无法将此页面解释为 RSS Feed。下一行创建一个 Rss20FeedFormatter 实例。使用此格式化程序,将从响应对象的 Output 属性接收的实际内容写入 RSS 格式化程序。

public class RssActionResult : ActionResult
{
    public SyndicationFeed Feed { get; set; }

    public override void ExecuteResult(ControllerContext context)
    {
        context.HttpContext.Response.ContentType = "application/rss+xml";
        var rssFormatter = new Rss20FeedFormatter(Feed);
        using (var writer = XmlWriter.Create(context.HttpContext.Response.Output))
        {
            rssFormatter.WriteTo(writer);
        }
    }
}

现在让我们看看我如何利用这个类。

public ActionResult Index()
{
    var rssFeedViewModel = GetRssFeedViewModel();
    var feed = RssFeedGenerator.GetRssFeedData(rssFeedViewModel, Url);
    return new RssActionResult { Feed = feed };
}

上面方法的第二行更为重要。用于生成 RSS Feed 的某些属性被传递给一个生成器 RssFeedGenerator。该类有一个名为 GetRssFeedData 的方法来生成 Feed。该方法生成一个 SyndicationFeed 项,其中包含一个 SyndicationItem 实例列表。每个项对应于博客中按最新项排序的文章。请注意,此 Feed 不包含页面。最后,在方法的最后一行,返回先前创建的 RssActionResult 类型的操作结果,传入 Feed 项。

常见问题

为什么要重复造轮子?

是的,当然,我们已经有了 WordPress 和一些 ASP.Net 博客引擎。但我喜欢 WordPress 的博客方式,所以我想用 ASP.Net MVC 3 来创建一个能提供相同体验的东西,在我看来,这是微软最好的创作之一!这是一次很好的学习经历,我相信对于所有将要尝试这个项目的人来说也是如此。向大家保证——这个项目不是/也不会只是一个简单的复制品,您可以期待在不久的将来添加一些有趣的功能!这个项目高度模块化,因此您可以轻松地拔出某些部分并用自己的代码进行修改。浏览项目也会很简单,因为它不试图实现大量功能,也不使用任何复杂的逻辑/代码。许多基于 ASP.Net 的博客引擎/CMS 是使用 WebForms 创建的,而一些博客引擎/CMS 使用 MVC 3。所以我想创建一个像 WordPress 一样“简单”的博客体验,而不是创建像 Orchard 那样复杂的东西 (我同意它很棒,但对于像我这样的 Orchard 初学者来说,设置和调试出现的问题非常痛苦)。

为什么没有使用 Entity Framework?

在我开始研究这个问题的时候,我对 Entity Framework 还不是非常精通/有信心。(尽管我正在进步!)。但我确实提供了替换当前数据库模块的方法——例如 Entity Framework、MySQL 甚至 XML!

好的,没问题。当前的评论系统是基于线程的吗?

抱歉,目前不是。但这也在下一版本任务的路线图中!

我需要自己深入研究代码,还是会有任何“知识库”?

有一个 博客,我计划定期发布一些关于设计以及如何修改这里的一些内容。

对下一个版本有什么计划吗?

当然!我计划在下一个版本中添加的一些功能包括 (除了前面问题中提到的功能)...

  • 基于使用频率的标签列表页面
  • 原生评论系统的线程支持
  • 全球化/本地化支持
  • 置顶“文章”

关注点

在创建这个博客引擎的过程中,我经历了很多有趣的时刻。其中之一就是生成唯一 Slug 的部分。我不得不多次更新该方法,因为每一步都发现了我未曾预见到的问题,例如,我生成了“web.config”作为有效的 Slug,尽管它不是!

我必须解决的另一个有趣问题是设计评论系统,因为它对博客至关重要。评论使博客所有者能够接收读者的意见。上面的评论部分详细介绍了我的想法。如今,在工作中,我一直在处理一个性能关键型应用程序,因为它操作非常大的数据集。我想将其应用于这个博客引擎,因为我决心提高这个博客引擎的性能。即使是现在,性能已经相当不错了,但通过使用 Orchard 等 CMS 引擎中一些最先进的技术,我肯定希望能够进一步提高启动时间。

其他新功能

其中一项最新功能是一个列出博客作者的页面,当点击作者姓名时,可以列出该作者的文章。作者页面的链接在侧边栏中提供,其中有登录/仪表盘和其他链接。

sBlog.Net 现在支持按“角色”对用户进行分类。默认情况下,有 3 个角色可供管理员为用户选择 - 分别是:“超级管理员”、“管理员”和“作者”。超级管理员可以做任何事情,顾名思义。管理员除了正常的活动 (如创建文章) 外,还可以管理用户、管理公开的文章和评论。作者仅限于创建和管理自己的文章和评论。

历史

  • 文章已更新以匹配 v2.0 版本
  • 关于近期开发的部分,更新了议程
  • 更新了项目和演示链接
  • 添加了关于评论系统的新部分
  • 添加了关于视图引擎的新部分
  • 更新了依赖注入和自定义模型绑定部分
  • 文章第 1 版发布  
© . All rights reserved.