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

使用 ASP.NET 进行 URL 重写以优化 SEO

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (24投票s)

2009年2月12日

CPOL

15分钟阅读

viewsIcon

169725

downloadIcon

6839

本文介绍如何使用三种不同的方法在 ASP.NET 中实现 URL 重写

引言

URL 重写是指通过使用一系列扁平的、通常是冗长的 URL 来隐藏复杂的参数化查询字符串 URL,例如 http://www.somedomain.com/showproduct.aspx?id=12345&otherid=67890,这些 URL 不包含任何查询字符串参数,例如 http://www.somedomain.com/products/some-product-name.html。 这样做的目的是,当服务器请求扁平 URL 时,我们会在内部确定所需的参数,然后调用原始的基于查询字符串的 URL。

您可能考虑这样做的主要原因是为了 SEO(搜索引擎优化)目的。搜索引擎通常不喜欢网页中的查询字符串参数,因为这通常表明页面是动态变化的,并且由于同一页面有许多出现(通常包含晦涩难懂的无用数据)而更难索引,并且不被消费者认为“友好”。我将专门撰写关于将扁平 HTML 页面资源重写为带查询的 ASPX 脚本的内容。

我们遇到的第一个问题是,默认情况下,IIS 配置为自行处理对 \*.htm\*.html 资源的所有请求,并且如果此类请求不存在,将简单地响应“404 页面未找到”错误。由于我们要重写 \*.html 页面,因此我们需要告诉 IIS 将所有这些资源的请求转发到 ASP.NET,而不是由其自行处理。我更喜欢将 \*.html 路由到 ASP.NET,而保留 \*.htm 不变,但您可以根据需要转发任何资源——如果您愿意,甚至可以转发所有请求到 ASP.NET。您可以通过转到网站属性,在“主目录”选项卡(对于应用程序虚拟文件夹,请选择“虚拟目录”选项卡)上单击“配置”按钮,然后为要路由到 ASP.NET ISAPI 扩展的扩展名添加“映射”来实现这一点。这通常位于 C:\Windows\Microsoft.NET\Framework\v2.0.50727\aspnet_isapi.dll,但如果您不确定,可以随时查找并复制 \*.aspx 映射的属性。您还需要确保取消选中“确保请求的资源存在”选项!

一旦请求通过 ASP.NET,我们就可以使用 HttpContext.RewritePath 方法执行 URL 重写。此方法实际上会更改从 IIS 传递到不同 URL 的原始请求信息。重写可以在多种位置进行,包括 global.asax 应用程序处理程序、HTTP 模块或 HTTP 处理程序。

与其他通常通过配置文件和正则表达式将友好 URL 映射到后端 URL 的文章不同,本文旨在简要概述在 ASP.NET 中使用各种方法构建强大 URL 重写系统所需的底层基础。我已经为 HTTP 模块和处理程序实现了一个简单的存根方法,可以根据您的需求进行扩展。

使用 global.asax 进行重写

global.asax 文件允许我们处理应用程序和会话级别的事件,并位于应用程序的根文件夹中。我们可以使用该文件的 Application_BeginRequest 事件处理程序实现简单的 URL 重写,每次 IIS 向 ASP.NET 发送新请求进行处理时都会调用此事件处理程序。

void Application_BeginRequest(object sender, EventArgs e)
{
    HttpApplication app = sender as HttpApplication;
    if(app.Request.Path.IndexOf("FriendlyPage.html") > 0)
    {
        app.Context.RewritePath("/UnfriendlyPage.aspx?SomeQuery=12345");
    }
}

在上面的代码片段中,我们将所有对 FriendlyPage.html 页面的请求重写为 UnfriendlyPage.aspx 页面,并带有查询字符串 SomeQuery=12345。随着请求在管道中进行,它现在将使用新重写的资源而不是原始资源。

显然,这是一个非常简单的硬编码的 URL 重写示例,它没有考虑应用程序路径等。通常,重写不会以 global.asax 处理程序中硬编码条目的形式进行,而是会在一个专门构建的 HTTP 模块中进行,或者使用 HTTP 处理程序进行。

如前所述,上面的示例仅在 IIS 已配置为将 \*.html 资源请求发送到 ASP.NET 时才有效,但该示例对于任何类型的请求都同样有效,包括目录(如果 IIS 已配置了 \* 映射)或对其他 aspx 页面的请求。

使用 HTTP 模块进行重写

HTTP 模块是一个实现 IHttpModule 接口的类。它基本上需要实现两个方法:

  • Init,用于挂接模块感兴趣处理的管道事件
  • Dispose,用于释放任何分配的资源

通过 HTTP 模块进行 URL 重写的方式与前面显示的 global.asax 方法非常相似。HTTP 模块通过在 web.config 文件中定义它们来集成到 ASP.NET 应用程序的处理管道中。ASP.NET 会自动加载和实例化任何已定义的模块,并调用它们的 Init() 方法。Init() 方法可用于订阅请求管道中的其他事件。

HTTP 模块通常按顺序执行,一个接一个地按照它们在 web.config 文件中的指定顺序执行,并且它们的调用时机在 global.asax 的等效事件之前。

以下代码片段显示了一个典型的(非常简单的)HTTP 模块的编写方式

public class UrlRewritingModule : IHttpModule
{
    public UrlRewritingModule()
    {
    }

    public String ModuleName
    {
        get
        {
            return "UrlRewritingModule";
        }
    }

    const string ORIGINAL_PATH = "OriginalRequestPathInfo";

    public void Init(HttpApplication application)
    {
        application.AuthorizeRequest += new EventHandler(application_AuthorizeRequest);
        application.PreRequestHandlerExecute +=
		new EventHandler(application_PreRequestHandlerExecute);
        application.Context.Items[ORIGINAL_PATH] = null;
    }

    void application_PreRequestHandlerExecute(object sender, EventArgs e)
    {
        HttpApplication app = sender as HttpApplication;
        String strOriginalPath = app.Context.Items[ORIGINAL_PATH] as String;
        if (strOriginalPath != null && strOriginalPath.Length > 0)
        {
            app.Context.RewritePath(strOriginalPath);
        }
    }

    void application_AuthorizeRequest(object sender, EventArgs e)
    {
        HttpApplication app = sender as HttpApplication;
        String strVirtualPath = "";
        String strQueryString = "";
        MapFriendlyUrl(app.Context, out strVirtualPath, out strQueryString);

        if (strVirtualPath.Length>0)
        {
            app.Context.Items[ORIGINAL_PATH] = app.Request.Path;
            app.Context.RewritePath(strVirtualPath, String.Empty, strQueryString);
        }
    }

    void MapFriendlyUrl(HttpContext context,
	out String strVirtualPath, out String strQueryString)
    {
        strVirtualPath = ""; strQueryString = "";

        // TODO: This routine should examine the context.Request properties and implement
        //       an appropriate mapping system.
        //
        //       Set strVirtualPath to the virtual path of the target aspx page.
        //       Set strQueryString to any query strings required for the page.

        if (context.Request.Path.IndexOf("FriendlyPage.html") >= 0)
        {
            strVirtualPath = "~/Main.aspx";
            strQueryString = "Message=You smell of updated cheese!";
        }
    }

    public void Dispose()
    {
    }
}

正如您所见,Application_AuthorizeRequest 方法的作用与 global.asax Application_BeginRequest 处理程序中的例程完全相同。实际上,可以在 HTTP 模块中订阅 BeginRequest 事件,但通常最好使用 AuthorizeRequest,因为它会考虑 Forms Authentication,这可能会执行重定向来获取登录详细信息;如果发生这种情况,并且 URL 已经被 BeginRequest 事件重写,那么它会将用户重定向回重写的页面而不是原始的友好页面。通过在 AuthorizeRequest 事件中进行重写,我们将确保 Forms Authentication 子系统将我们返回到友好的资源名称。

为了执行映射,我实现了一个存根方法 MapFriendlyUrl,其任务是确定请求的资源需要如何重写。这完全取决于您的设置。在此示例中,我使用快速而粗糙的硬编码测试来处理任何对“FriendlyPage.html”的请求,并将其简单地映射到“UnfriendlyPage.aspx?FirstQuery=1&SecondQuery=2”。显然,您需要完成此方法以执行您想要的操作,并确保“strVirtualPath”和“strQueryStringout 参数已完成。如果资源无法映射,该方法将返回一个空路径。

您可能已经注意到在 PreRequestHandlerExecute 处理程序中对 RewritePath 方法的额外调用。这样做的原因是,任何发生在目标页面中的表单回发都会回发到原始的友好 URL,而不是丑陋的重写 URL。在上面的示例中,所有对“FriendlyPage.html”的请求都重写为“UnfriendlyPage.aspx?FirstQuery=1&SecondQuery=2”。如果我们省略了 PreRequestHandlerExecute 阶段中的第二次重写,那么 UnfriendlyPage.aspx 页面上的任何回发都会回发到 UnfriendlyPage.aspx 而不是 FriendlyPage.html

然而,有一个有趣的副作用;尽管页面本身得到了恢复,但重写的页面中使用的任何查询都会继续出现在回发引用的部分。有一些有趣的解决方案,包括使用 CSS Control Adapter 来覆盖页面 <form> 标签的 action 属性的渲染,但我们也可以通过使用 HTTP 处理程序而不是 HTTP 模块来更干净地解决这个问题,这样我们就可以使用相同的双重 RewritePath 技巧,但处于管道中的更好位置。

另一点值得注意的是,我们使用 HttpContext.Items 状态管理属性在对 HTTP 模块事件处理程序的独立框架调用之间存储和共享键/值对。此属性对于在 HTTP 模块和 HTTP 处理程序之间传递状态信息也很有用。在这种情况下,我们需要记录原始路径以便稍后在管道中检索它,然后将其重写回来。

使用 HTTP 处理程序进行重写

HTTP 处理程序是一个实现 IHttpHandler 接口的类,它旨在为发送到 ASP.NET 引擎的特定类型的资源请求实现自定义响应。应用程序的 web.config 文件包含指示哪个处理程序应该处理哪些资源以及用于哪些 HTTP 方法的映射。例如,当收到对 \*.aspx 资源的请求时,ASP.NET 会知道使用默认页面处理程序。这与 HTTP 模块不同,HTTP 模块会在所有请求中被调用,而我们必须在模块内部确定是否要对其执行某些操作。

为了对 \*.html 资源执行一些重写,我们需要创建一个处理 \*.html 请求的处理程序。IHttpHandler 接口要求我们实现两个方法:

  • ProcessRequest,当需要处理合适的请求时由 ASP.NET 框架调用,并且必须实现适当的响应。
  • IsReusable,它返回一个布尔标志,指示是否可以使用相同的处理程序来处理多个请求。

人们在实现 HTTP 处理程序重写时常犯的一个错误是,他们只是调用 HttpContext.RewritePath 方法来重写请求并期望它能正常工作。关于 HTTP 处理程序,需要记住的关键一点是,它必须实际 **处理** 请求才能提供响应。仅仅将 \*.html 请求重写为 \*.aspx 请求在 HTTP 模块或 global.asax 处理程序中是可行的,因为 ASP.NET 框架只需调用正确的处理程序(即 \*.aspx 默认页面处理程序),因为在选择和调用适当的处理程序 **之前** 已经重写了请求信息。一旦调用了处理程序,它就负责发出正确的响应。

为了实现处理 \*.html 资源的 HTTP 处理程序,我们需要利用默认页面处理程序,并在重写原始 URL 后将其转发给它。幸运的是,我们可以使用 PageParse.GetCompiledPageInstance 方法来返回特定资源的默认页面处理程序的实例。给定目标 aspx 资源的默认页面处理程序的实例,我们就可以在 **我们自己的** 处理程序的 ProcessRequest 方法中直接调用 **它的** ProcessRequest 方法。

由于我们使用 GetCompiledPageInstance 方法来返回页面实例,该实例基于所需的目标 aspx 页面,因此我们实际上不需要使用 RewritePath 方法来更改请求的页面,只需更改查询字符串即可。下面是一个简单的 HTTP 处理程序实现:

public class UrlRewriter : IHttpHandler
{
    public void ProcessRequest(HttpContext context)
    {
        // Map the friendly URL to the back-end one..
        String strVirtualPath = "";
        String strQueryString = "";
        MapFriendlyUrl(context, out strVirtualPath, out strQueryString);

        if(strVirtualPath.Length>0)
        {
            // Apply the required query strings to the request
            context.RewritePath(context.Request.Path, string.Empty, strQueryString);

            // Now get a page handler for the ASPX page required, using this context.
            Page aspxHandler = (Page)PageParser.GetCompiledPageInstance
		(strVirtualPath, context.Server.MapPath(strVirtualPath), context);

            // Execute the handler..
            aspxHandler.PreRenderComplete +=
		new EventHandler(AspxPage_PreRenderComplete);
            aspxHandler.ProcessRequest(context);
        }
    }

    void MapFriendlyUrl(HttpContext context,
	out String strVirtualPath, out String strQueryString)
    {
        strVirtualPath = ""; strQueryString = "";

        // TODO: This routine should examine the
        // context.Request properties and implement
        //       an appropriate mapping system.
        //
        //       Set strVirtualPath to the virtual path of the target aspx page.
        //       Set strQueryString to any query strings required for the page.

        if (context.Request.Path.IndexOf("FriendlyPage.html") >= 0)
        {
            // Example hard coded mapping of "FriendlyPage.html"
	   // to "UnfriendlyPage.aspx"

            strVirtualPath = "~/UnfriendlyPage.aspx";
            strQueryString = "FirstQuery=1&SecondQuery=2";
        }
    }

    void AspxPage_PreRenderComplete(object sender, EventArgs e)
    {
        HttpContext.Current.RewritePath(HttpContext.Current.Request.Path,
		String.Empty, String.Empty);
    }

    public bool IsReusable
    {
        get
        {
            return true;
        }
    }
}

处理程序首先确定处理请求所需的 and 目标 aspx 页面,并确定需要传递给该目标页面的任何查询字符串。为简单起见,我没有包含任何特定的映射实现——这取决于您,可以是简单的硬编码页面,也可以包括对包含正则表达式的配置文件进行查找。MapFriendlyUrl 方法只需确保“strVirtualPathout 参数设置为目标 aspx 页面的虚拟路径,而“strQueryStringout 参数设置为目标脚本所需的查询字符串。

接下来,我们使用 HttpContext.RewritePath 方法仅重写查询字符串(因为 **原始友好 URL** 的路径已经正确)。然后,我们为目标 aspx 页面创建一个默认页面处理程序的实例。这样做的原因是,当 aspx 页面执行时,它将看到所有必需的查询参数,但仍会将 URL 视为友好 URL——我们不需要更改到 aspx 页面的请求路径,因为我们是手动调用它。

在我们实际处理请求之前,我们挂接 PreRenderComplete 页面事件处理程序。当页面完成其所有控件的创建、分页结束、视图状态准备好写入以及最终 HTML 准备好发出时,就会触发此事件。挂接此事件为我们提供了一个执行双重 RewritePath 技巧(类似于我们前面在 HTTP 模块方法中所做的)的机会。PreRenderComplete 处理程序只是调用 HttpContext.RewritePath 来删除上次重写添加的查询(本质上是反向操作)。这样做的效果是确保回发生成的 URL 是完全友好的 URL,并且与 HTTP 模块实现不同,它也不会包含之后添加的任何额外查询字符串。

虽然处理程序现在可以将 \*.html 友好页面成功重写为所需的带查询的 \*.aspx 页面,但它仍然存在一些问题:

  • 在 aspx 目标页面中无法获得会话状态

这对大多数在 aspx 页面中使用会话状态的用户来说是个问题,但实际上很容易解决。通过将 IReadOnlySessionStateIRequiresSessionState 接口添加到类中,我们可以获得只读或读/写访问权限。我们不需要在处理程序中实现任何不同的东西,因为这些接口只是“标记”接口,不公开任何方法,但它们会向 ASP.NET 信号在消耗处理程序时启用会话状态访问。

  • 实际的 *.html 文件的请求无法再提供。

这可能对您来说是个问题,也可能不是。如果您仍然希望提供实际的静态 \*.html 文件,那么我们需要确保处理程序为您处理。不要忘记我们已经指示 IIS 将所有对 \*.html 资源的请求转发给我们处理。我们可以通过在处理程序的开头(或结尾)编写代码来检查请求是否为真实页面,如果是,则提供它来解决此问题。

  • 友好 URL 本身不能包含任何查询字符串。

这可能不是一个问题,因为重写的主要目的是从 URL 中删除查询;但是,在内部能够使用编程查询调用友好页面可能很有用,以保持用户认为正在提供扁平的 \*.html 页面,而不是不得不回退到不理想的 \*.aspx 页面。我们需要在 PreRenderComplete 页面事件处理程序中进行更改以记录原始查询并恢复它们。我们还需要确保映射的查询被“合并”到请求的查询中。

  • 处理程序需要根据需要优雅地处理页面未找到错误。

根据您的映射系统的工作方式,有时指定的友好页面可能与任何内容都不相关。您可以选择将响应重定向到错误页面,或者您的 aspx 页面可能会相应地响应,但如果不是,处理程序实际上应该被编写为响应适当的消息并返回 404 响应代码。如果您不这样做,您可能会得到一个处理程序返回一个空白响应。

构建更好的 HTTP 处理程序

以下是一个 HTTP 处理程序的完整实现,它将 \*.html 资源重写为带查询字符串的 \*.aspx 页面,并考虑了前面提到的要点。

public class BetterUrlRewriter : IHttpHandler, IRequiresSessionState
{
    const string ORIGINAL_PATHINFO = "UrlRewriterOriginalPathInfo";
    const string ORIGINAL_QUERIES = "UrlRewriterOriginalQueries";

    public void ProcessRequest(HttpContext context)
    {
        // Check to see if the specified HTML file actual exists and serve it if so..
        String strReqPath = context.Server.MapPath
	(context.Request.AppRelativeCurrentExecutionFilePath);
        if (File.Exists(strReqPath))
        {
            context.Response.WriteFile(strReqPath);
            context.Response.End();
            return;
        }

        // Record the original request PathInfo and
        // QueryString information to handle graceful postbacks
        context.Items[ORIGINAL_PATHINFO] = context.Request.PathInfo;
        context.Items[ORIGINAL_QUERIES] = context.Request.QueryString.ToString();

        // Map the friendly URL to the back-end one..
        String strVirtualPath = "";
        String strQueryString = "";
        MapFriendlyUrl(context, out strVirtualPath, out strQueryString);

        if(strVirtualPath.Length>0)
        {
            foreach (string strOriginalQuery in context.Request.QueryString.Keys)
            {
                // To ensure that any query strings passed in the original request
	       // are preserved, we append these
                // to the new query string now, taking care not to add any keys
	       // which have been rewritten during the handler..
                if (strQueryString.ToLower().IndexOf(strOriginalQuery.ToLower()
								+ "=") < 0)
                {
                    strQueryString += string.Format("{0}{1}={2}",
			((strQueryString.Length > 0) ? "&" : ""),
			strOriginalQuery,
			context.Request.QueryString[strOriginalQuery]);
                }
            }

            // Apply the required query strings to the request
            context.RewritePath(context.Request.Path, string.Empty, strQueryString);

            // Now get a page handler for the ASPX page required, using this context.
            Page aspxHandler = (Page)PageParser.GetCompiledPageInstance
		(strVirtualPath, context.Server.MapPath(strVirtualPath), context);

            // Execute the handler..
            aspxHandler.PreRenderComplete +=
		new EventHandler(AspxPage_PreRenderComplete);
            aspxHandler.ProcessRequest(context);
        }
        else
        {
            // No mapping was found - emit a 404 response.

            context.Response.StatusCode = 404;
            context.Response.ContentType = "text/plain";
            context.Response.Write("Page Not Found");
            context.Response.End();
        }
    }

    void MapFriendlyUrl(HttpContext context, out String strVirtualPath,
						out String strQueryString)
    {
        strVirtualPath = ""; strQueryString = "";

        // TODO: This routine should examine the context.Request properties and implement
        //       an appropriate mapping system.
        //
        //       Set strVirtualPath to the virtual path of the target aspx page.
        //       Set strQueryString to any query strings required for the page.

        if (context.Request.Path.IndexOf("FriendlyPage.html") >= 0)
        {
            // Example hard coded mapping of "FriendlyPage.html"
	   // to "UnfriendlyPage.aspx"

            strVirtualPath = "~/UnfriendlyPage.aspx";
            strQueryString = "FirstQuery=1&SecondQuery=2";
        }
    }

    void AspxPage_PreRenderComplete(object sender, EventArgs e)
    {
        // We need to rewrite the path replacing the original tail and query strings..
        // This happens AFTER the page has been loaded and setup
        // but has the effect of ensuring
        // postbacks to the page retain the original un-rewritten pages URL and queries.

        HttpContext.Current.RewritePath(HttpContext.Current.Request.Path,
                    	HttpContext.Current.Items[ORIGINAL_PATHINFO].ToString(),
                          	HttpContext.Current.Items[ORIGINAL_QUERIES].ToString());
    }

    public bool IsReusable
    {
        get
        {
            return true;
        }
    }
}

首先,您会注意到,除了 IHttpHandler 接口之外,我们还指定了 IRequiresSessionState 接口。这确保我们在页面生命周期中获得对会话状态的读/写访问权限。

我们在 ProcessRequest 方法中所做的第一件事是检查请求的 \*.html 资源是否实际上是对 **真实** HTML 资源的请求。您可能不想检查这一点,具体取决于您的需求,或者可能想在检查映射 **之后** 进行检查,但我更喜欢在这里包含它,以便虽然需要重写的虚拟 HTML 页面可以使用重写引擎工作,但对实际存在的 HTML 文件的任何请求都可以优先处理并仍被提供。

接下来,我们使用 HttpContext.Items 键/值状态管理集合来保存当前的请求查询字符串和尾部,以便我们可以在 PreRenderComplete 页面处理程序中将其拉出来。在调用 RewriteUrl 方法执行映射工作后,我们然后编写一些额外的代码来合并此请求中指定的任何查询以及映射所需的查询。这样做的目的是使指定的映射查询优先,并且不会被请求中的相同查询参数覆盖——但当然这可以根据需要进行更改。

最后,如果我们未能获得映射,那么我们将其视为无效请求,并通过提供一个简单的 404 响应来终止。您可能选择显示比我在此实现的响应更好的响应,或者显示站点地图或其他内容。

祝您重写愉快!

关注点

希望本文能帮助您在 Web 项目中实现 URL 重写,您可以使用本文展示的一种解决方案。虽然本文没有实现(或尝试实现)将友好 URL 映射到不友好 URL 的代码,但它提供了一个可以扩展的存根实现,并有望为未来的工作奠定基础。

如果我无法访问 IIS 怎么办?

此处提供的信息专门针对重写 \*.html 资源,这意味着您需要能够向 IIS 添加映射以将 \*.html 请求转发到 ASP.NET。但是,您可以使用完全相同的过程重写您想要的任何类型的资源——只要请求能够到达 ASP.NET ASAPI 模块。如果您托管解决方案不允许您添加 IIS 映射,并且您也无法访问 IIS,那么您仍然可以使用此处说明的重写技术——只需使用另一个您知道会被处理的资源类型即可。例如,\*.ashx 扩展名是一个很好的选择,可以代替 \*.html,因为 \*.ashx 请求会自动映射。

参考来源

历史

  • 2009 年 2 月 12 日 - 初始修订
  • 2009 年 2 月 24 日 - 添加了 ashx 演示以及与无法访问 IIS 相关的文本
© . All rights reserved.