目录
可能的情形
可行的方法
截获页面处理程序
添加新的事件处理程序
获得对控件树的引用
使用 HTTP 处理程序的页面重写
URL 重写
总结
某客户最近和找到我说“我们需要对某些 ASP.NET 页面进行更改。您能帮忙吗?”就像任何其他顾问一样,我马上答道“当然,告诉我具体情况吧。”但除了这些页面的 URL,该客户实际上几乎什么都没提供。不管怎样,该客户就是要修改一些 ASP.NET 页面,却没有源代码。一开始,我觉得这只是个问题而已,但随着与该客户交谈的深入,我愈发觉得这是一个有趣的挑战。
如果不给您按钮的源代码来创建派生类,也不给您 ASP.NET 页面的源代码来修改其行为,则在 Windows 编程中,通常要关联底层消息并子类化窗口。在 ASP.NET 中,则可以尝试关联页面事件以便重写页面行为并输出。
经过一番周密思考,我总结出了一些实际情形,在这些情形中,可能需要在不改动源代码的情况下修改 ASP.NET 页面的运行时行为。我还找到了许多可以完成这项任务的方法。在本文(共两部分)中,我将介绍这些方法以及如何仅通过只读访问甚至根本不访问源代码来更改 ASP.NET 页面的用户界面和行为。
可能的情形
还有一种情形,就是需要临时更改多种页面。对于单个页面,您只需创建旧页面的一个副本并替换它即可。不过,更新多个页面可能就比较困难了,并且维护工作也可能及其复杂。
第三种情形,就是企业策略禁止对源代码进行写操作。在这种情况下,源代码可读,但不可修改。不过,您可以向站点添加扩展。
最后一种情形,也是最复杂并且我真的遇到过的一种情形,即公司运行的 Web 应用程序的源代码由于某种原因不再可用。
可以看出,需要在不访问源代码的情况下修改页面的运行时行为的情形非常多。那么接下来该怎么办呢?
可行的方法
有许多方法可以在不接触源代码的情况下修改正在运行的 ASP.NET 页面。图 1 列出了几种方法。不是所有的方法在任何情形下都有效,而且有些方法可以一起使用。有的方法可能需要编写新的 HTTP 模块或 HTTP 处理程序,有的方法则可能要求对 web.config 文件进行更改。大多数情况下都需要重新启动应用程序。实际上,如果更改 web.config 或 global.asax 文件,或添加到 Bin 文件夹或 App_Code 文件夹,就会自动重新启动应用程序。
Figure1在不访问源代码的情况下修改页面
方法 | 实现 |
访问控件树 | HTTP 模块 |
修改页面基类 | web.config |
控件替换 | web.config |
构建提供程序 | 程序集 |
重定向页面 | HTTP 处理程序 |
重写页面 | HTTP 处理程序或 URL 重写 |
使用 HTTP 模块可以关联应用程序生命周期中的任意事件,还可以捕获用于呈现页面的 HTTP 处理程序。具有这样的情况时,您可以管理 ASP.NET 页面事件(如 Page_Load 和 Page_PreRender)的处理程序,还可以添加或删除控件,更改资源和图像的链接,以及添加或删除 CSS 类。同样,HTTP 处理程序还可用于替换单个 ASP.NET 页面。此外,URL 重写功能还可以将用户重定向到最初部署应用程序时不存在的 ASP.NET 页面。
如果使用特定控件时遇到问题,您可以使用 web.config 文件的 <tagMapping> 元素将页面分析器自动重定向到从旧控件派生的新控件。我将在我下次的专栏中介绍此方法。现在,我们先来探讨在不更改源代码的情况下,直接重写页面级事件和直接修改页面用户界面的工具和方法。
截获页面处理程序
处理各个 ASP.NET 请求时会引发一系列应用程序级事件,如图 2 所示。请求以 BeginRequest 事件开始,以 EndRequest 事件结束。名为 HttpApplication 的系统对象会控制请求的处理过程,并确保专用组件(HTTP 模块)能够为某些应用程序级事件注册处理程序。在身份验证并获得授权之后,如果请求未通过输出缓存被解析,则系统将把请求自己的处理程序组件分配给它。
图 2请求
每个 ASP.NET 请求的处理都需要一个专用组件,即 HTTP 处理程序。当在应用程序生命周期内触发 PostMapRequestHandler 事件时,可引用负责处理当前请求的 HTTP 处理程序。
应用程序级事件可以通过两种方式关联。您可以在 global.asax 文件(应用程序的源文件之一)中编写处理程序,也可以编写自定义 HTTP 模块以便为指定事件注册侦听器。就功能而言,这两种方法类似。但如果使用 HTTP 模块,则只需添加一个新组件并对 web.config 文件略作编辑即可。这两种情况都需要重新启动应用程序。
确定页面处理程序后要触发的第一个应用程序事件是 PostMapRequestHandler。如图 3 所示,HTTP 模块可注册此事件的侦听器,并在找到每个 ASP.NET 请求的处理程序时获得通知。如果只需针对几个页面这样做,则可能需要在模块代码中筛选掉不需要的页面。
Figure3用于为页面处理程序进行侦听的 HTTP 模块
Imports System
Imports System.Web
Imports System.Web.UI
Imports System.Web.Compilation
Imports System.Web.Hosting
Imports System.IO
Imports System.Text
Namespace Samples
Public Class SubclassModule : Implements IHttpModule
Private _app As HttpApplication
#Region “IHttpModule”
Public Sub Dispose() Implements IHttpModule.Dispose
End Sub
Public Sub Init(ByVal context As HttpApplication) _
Implements IHttpModule.Init
If context Is Nothing Then
Throw New ArgumentNullException(“[context] argument”)
End If
‘ Cache the HTTP application reference
_app = context
‘ Map the app event to hook up the page
AddHandler context.PostMapRequestHandler, _
AddressOf OnPostMapRequestHandler
End Sub
#End Region
#Region “App Event Handlers”
Private Sub OnPostMapRequestHandler( _
ByVal source As Object, ByVal e As EventArgs)
‘ Get the just mapped HTTP handler and cast to Page
Dim pageHandler As IHttpHandler = Nothing
If TypeOf _app.Context.Handler Is System.Web.UI.Page Then
pageHandler = _app.Context.Handler
End If
‘ If OK, register an event handler for PreRender
If Not pageHandler Is Nothing Then
HookUpPage(pageHandler)
End If
End Sub
#End Region
#Region “Helpers”
Private Sub HookUpPage(ByVal pageHandler As Page)
‘ Wire up as many application events as needed
AddHandler pageHandler.Load, AddressOf OnLoad
‘ ...
End Sub
Private Sub OnLoad(ByVal sender As Object, ByVal e As EventArgs)
Dim pageHandler As Page = DirectCast(sender, Page)
‘ TODO: add your code here
End Sub
#End Region
End Class
End Namespace
HTTP 模块是一个实现以下两个方法的类:Init 和 Dispose。应用程序启动时调用一次 Init 方法。如图 3 所示,Init 方法会注册为每个 ASP.NET 请求调用的侦听器。通常,您需要在 OnPostMapRequestHandler 方法中检查传入请求的 URL。例如:
Sub OnPostMapRequestHandler( _
ByVal source As Object, ByVal e As EventArgs)
Dim url as Uri
url = _app.Context.Request.Url
If Not url.AbsolutePath.Equals( _
"/WebSite1/default.aspx") Then
Return
End If
...
End Sub
ASP.NET 请求是与表示当前请求的上下文的系统对象一起通过运行时管道的。该对象是 HttpContext 类的实例。Handler 是 HttpContext 对象的属性之一,包含对将用于处理请求的 HTTP 处理程序对象的引用。除非已为请求的资源定义了自定义 HTTP 处理程序,否则 ASPX 请求将由某个从 System.Web.UI.Page 类继承的类的实例来管理。此时,您已拥有对用于处理该页面的对象的引用,并且可以为普通的页面事件(如 PreRender 和 Load)动态添加新的处理程序。例如:
Private Sub HookUpPage(ByVal pageHandler As Page)
' Wire up as many application events as needed
AddHandler pageHandler.Load, AddressOf OnLoad
...
End Sub
更准确地说,绑定到 Handler 属性的对象是动态创建的 Page 类的实例,该类是通过模仿您在 ASPX 服务器资源中所编写的标记创建的。也就是说,如果您在此代码隐藏类中放置了 Page_Load 方法,ASP.NET 页面处理程序将包含一个针对该页面的 Load 事件的事件处理程序。
Visual Basic? 中的 AddHandler 关键字(还有 C# 中的 += 运算符)用于向绑定到给定事件的委托链中添加新的处理程序。因此,添加到 Handler 对象的任何代码都的执行时间都会晚于在该页面的 ASPX 中定义的任何等效的处理程序。上面所引用的 OnLoad 方法中的代码则会晚于 Page_Load 中的代码运行。
此时,可以在 OnLoad 中做些什么呢?只要知道页面的结构及其控件树中的元素,几乎什么都可以做。如果可以读取该页面的源代码,便可轻松获得此信息。否则,将需要转储该页面的控件树。一种转储方法是启用页面跟踪并将输出重定向到 ASP.NET 内置的 trace.axd 工具。您可以通过 web.config 文件启用跟踪,如下所示:
<system.web>
<trace enabled="true" />
</system.web>
接着,在所使用的网站上调用 trace.axd 实用工具,并查看控件树。图 4 显示了运行中的该实用工具。
图 4运行中的 Trace.axd
图 5个人网站的默认版本
添加新的事件处理程序
如果能编辑源代码,您或许会向 Page_Load 处理程序中添加一两行代码,从而在引用 Login 控件时调用 Focus 方法。从控件树(部分显示在图 4 中)中可以了解到该页面包含一个名为 Login1 的 Login 控件。以下代码用于在页面树中检索子控件,将输入焦点移动到该子控件区域,并重设页面标题:
Private Sub OnLoad(ByVal sender As Object, ByVal e As EventArgs)
Dim pageHandler As Page = DirectCast(sender, Page)
' Move the input focus to control Login1
Dim ctl As Control = FindControlRecursive(pageHandler, "Login1")
ctl.Focus()
' Change title of the page
pageHandler.Title = "This page has been hooked up"
End Sub
每个 ASP.NET Control 类的 FindControl 方法都只允许您查看控件的直接子级。任何情况下它都不允许查看控件中的子树。对于此功能,您必须像我在我的 FindControlRecursive 方法中所做的那样编写自己的递归查找方法。
有没有一种办法可以避免沿着控件树来查看给定页面的每一个请求呢?好像没有。您不能缓存控件实例,因为对于每个请求都要重新生成页面树。此外,如果不更改源代码,就不能向代码隐藏页面中添加额外属性,进而通过属性公开给定控件。当然,如果您能创建现有源文件的副本并替换类或预编译程序集,事情会容易许多。
图 6 显示了示例站点的一个改写版本。页面标题已通过 HTTP 模块更改,输入焦点已移动到登录控件,站点主题也已通过外部代码更改。如果要更改主题,必须关联 PreInit 事件:
Private Sub HookUpPage(ByVal pageHandler As Page)
AddHandler pageHandler.PreInit, AddressOf OnPreInit
AddHandler pageHandler.Load, AddressOf OnLoad
End Sub
Private Sub OnPreInit(ByVal sender As Object, _
ByVal e As EventArgs)
Dim pageHandler As Page = DirectCast(sender, Page)
pageHandler.Theme = "Black"
End Sub
图 6更改后的个人网站
对页面进行操作需要获得对页面控件树的引用。页面处理程序的 Controls 集合是页面中最外层的控件的集合,也是在控件树中导航的起点。
获得对控件树的引用
假定客户需要一种快速方式来向站点添加新内容,如最新的重大新闻。拥有对页面处理程序的引用是关键。第一步,可在示例 HTTP 模块的 HookUpPage 方法中添加一个新的 PreRender 事件处理程序:
AddHandler pageHandler.PreRender, AddressOf OnPreRender
在该处理程序中,首先获取页面引用,然后查找所需的插入点,如图 7 所示。假定您需要声明网站由外部组件驱动,并且为此,您需要在 HTTP 模块所管理的每一页面的最顶部添加一个新的标签。下面的代码段用于设置 Label 控件,然后将其添加到页面的 Controls 集合中:
Sub AddStaticText(ByVal pageHandler As Page)
Dim msg As String = ConfigurationManager.AppSettings("StaticMessage")
Dim lbl As New Label
lbl.ID = "SubclassModule_Label1"
lbl.BackColor = Color.White
lbl.ForeColor = Color.Red
lbl.Text = String.Format("<div>{0}</div>", msg)
pageHandler.Controls.AddAt(0, lbl)
End Sub
Figure7用于截获页面处理程序的 HTTP 模块
Private Sub OnLoad(ByVal sender As Object, ByVal e As EventArgs)
Dim pageHandler As Page = DirectCast(sender, Page)
If pageHandler Is Nothing Then Return
‘ Move the input focus to control Login1
Dim controlName As String = “Login1”
Dim ctl As Control = FindControlRecursive(pageHandler, controlName)
If Not ctl Is Nothing Then ctl.Focus()
‘ Change the page title
pageHandler.Title = “This page has been hooked up”
‘ Add a new control tree with postback support
AddLinkButton(pageHandler)
End Sub
Private Sub OnPreRender(ByVal sender As Object, ByVal e As EventArgs)
Dim pageHandler As Page = DirectCast(sender, Page)
If pageHandler Is Nothing Then Return
‘ Add static text (no postback)
AddStaticText(pageHandler)
End Sub
Private Sub AddStaticText(ByVal pageHandler As Page)
Dim msg As String = ConfigurationManager.AppSettings(“StaticMessage”)
Dim lbl As New Label
lbl.ID = “SubclassModule_Label1”
lbl.BackColor = Color.White
lbl.ForeColor = Color.Red
lbl.Text = String.Format( _
“<div style=’margin:0;width:100%;font-size:20pt’;>{0}</div>”, msg)
pageHandler.Controls.AddAt(0, lbl)
End Sub
Private Sub AddLinkButton(ByVal pageHandler As Page)
Dim controlName As String = “Main”
Dim ctl As Control = FindControlRecursive(pageHandler, controlName)
If ctl Is Nothing Then Return
Dim msg As String = ConfigurationManager.AppSettings(“LinkMessage”)
Dim link As New LinkButton
link.ID = “SubclassModule_LinkButton1”
link.ToolTip = “Click to hide the ‘Create account’ button”
link.BackColor = Color.Blue
link.ForeColor = Color.Yellow
link.Text = String.Format( _
“<hr><div style=’width:100%;font-size:30pt’;>{0}</div><hr>”, msg)
AddHandler link.Click, AddressOf SubclassModule_LinkButton1_Click
ctl.Controls.AddAt(0, link)
End Sub
Private Sub SubclassModule_LinkButton1_Click( _
ByVal sender As Object, ByVal e As EventArgs)
Dim pageHandler As Page = DirectCast(_app.Context.Handler, Page)
If pageHandler Is Nothing Then Return
Dim controlName As String = “Image1”
Dim ctl As WebControl = DirectCast( _
FindControlRecursive(pageHandler, controlName), WebControl)
If ctl Is Nothing Then Return
ctl.Visible = False
End Sub
Controls 属性是 ControlCollection 类的实例,并且具有两个添加新控件实例的方法:Add 和 AddAt。Add 用于向集合增加新的控件;AddAt 则更灵活些,可以指定想要的位置,位置序号从 0 开始计算。标签文本可以包含任何 HTML 标记,并且可从任何外部资源(包括数据库、XML 文档和 web.config 文件)全部或部分读取而来。
标签或文本控件很适合显示动态内容,但不能很好地生成和处理回发事件。因此,我们添加一个链接按钮。如果将此链接按钮用于把用户带到一个新页面,则这与我刚才考虑的情况没有什么大的区别。但如果此链接按钮必须生成回发、运行服务器上的一些代码再更新页面,则需要重新考虑解决方案。
正如所料,处理回发事件时,在预呈现阶段添加的控件还不是控件树的一部分。ASP.NET 页面运行库会找到回发目标,并在预呈现阶段之前引发该事件。回发的发送方写在 HTTP 请求中。
ASP.NET 运行库需要查找与发送方名称匹配的现有控件,以触发回发事件。因此,链接按钮的创建必须早于 PreRender 事件。在触发 Load 事件时创建链接按钮比较合适。假定插入的控件树包含一些输入字段,如复选框或文本框。这些控件的状态必须使用从客户端发送的数据进行更新。要使更新内容自动生效,这些控件的创建不得晚于 Load 事件。只有这样,ASP.NET 运行库才能保证正确恢复视图状态并应用任何相关的被发送数据。
在 Load 事件中,添加一个新的 LinkButton 控件,然后为其 Click 事件添加一个处理程序:
AddHandler link.Click, AddressOf SubclassModule_LinkButton1_Click
当用户单击链接时,页面将回发,ASP.NET 将拥有正确解析回发目标所需的所有信息。结果就会调用指定的事件处理程序。
那么可在处理程序中做些什么呢?几乎什么都可以做,更确切地说,只要知道怎么做就可以做。但如果没有源标记和代码隐藏类环境,简单的任务也可能很难完成。假定需要更改页面中另一个控件的状态。例如,假定需要让用户单击页面中新添加的链接按钮并禁用另一个按钮。不可否认,上述功能没有多大意义。但是,单独来看,这些功能意味着可能需要在已关联和已子类化的 ASP.NET 页面上完成任务。
那么问题就是如何在处理程序中检索页面对象?页面对象是获得对整个控件树的访问权和查找要使用的控件所必需的。对当前 HTTP 处理程序(即 ASP.NET 页面对象)的引用存储在 HttpContext 对象的 Handler 属性中。一般情况下不使用这种技巧也可以在代码隐藏中获得页面引用,但在使用 HTTP 模块时此技巧是必需的:
Sub LinkButton1_Click(sender As Object, e As EventArgs)
Dim pageHandler As Page = DirectCast(_app.Context.Handler, Page)
If pageHandler Is Nothing Then Return
...
End Sub
使用页面对象启动另一个递归搜索可以查找特定的控件。例如,假定您需要禁用图 5 中所示的“create account”按钮。对跟踪输出进行快速分析并结合该页面的 HTML 源代码可以看出,该按钮之后并没有任何实际的按钮。“个人”网站大量使用了现实生活中的方法构建站点。例如,它大量使用了层叠样式表的布局功能,并大量使用图像来提供更动人的用户界面。因此,“create account”按钮的表示形式如下所示:
<a href="http://register.aspx">
<asp:image id="Image1" runat="server" />
</a>
在 Controls 集合中,您将找到对 Image 控件的引用,但找不到专门针对 <a> 标记的任何内容,因为它缺少 runat="server" 属性。这是此处要使用的子类化方法所固有的限制。子类化不同于直接编程,只能在必要时使用。子类化未知页面并不能重现通常可在 ASP.NET 中重现的所有内容。在 ASP.NET 中,任何不带 runat 属性的标记文本序列都被视作纯文本并按原样一字不差地发出。而且,连续的文本将被分组到一个 LiteralControl 中,结果使得子类化程序查找对特定标记块及其服务器副本的各类引用都变得更加困难。不过您可以尝试推测 HTML 标记的 ID,并注入一些在加载页面时执行此工作的脚本代码。遗憾的是,在这种情况下,<a> 标记甚至连 ID 属性都没有,因此在服务器和客户端上实际上都无法进行编程。禁用图像是可以的,但这并不能禁用单击。一种可行的替代方法是隐藏控件:
Dim controlName As String = "Image1"
Dim ctl As WebControl = DirectCast( _
FindControlRecursive(pageHandler, controlName), WebControl)
If ctl Is Nothing Then Return
ctl.Visible = False
另一种操控现有页面的输出的方法是在将响应发送给浏览器以前捕获响应。在 HTTP 模块中,关联 PostRequestHandlerExecute 事件并为 HttpResponse 对象的 Filter 属性分配一个自定义流。这样,要写入该响应流的任何文本都会经过您自己的流,因此您就可以趁机预览 HTML 源代码并进行任何必需的更改,如,修改 DOM 和脚本。在实际应用中采用此方法的情形是更改以简单的 <a> 标记插入源代码页面的超链接 URL。
使用 HTTP 处理程序的页面重写
子类化是在不改动源代码的情况下对现有页面进行有限更改的一种好方法。如果可以用新页面(名称相同但内容不同)替换给定页面,那么无论如何都应使用此方法。子类化不同于替换,并且应在没有更简单的方法可用时使用。
如果要在不改动源代码的情况下彻底更改页面的输出,则可将页面重定向到其他 HTTP 处理程序。这种更改应在 web.config 文件中进行,如下所示:
<httpHandlers>
<add verb="*"
path="register.aspx"
type="Samples.Modules.RegisterAspxHandler, Subclass"
validate="false" />
</httpHandlers>
任何访问 register.aspx 的尝试都将由指定的 HTTP 处理程序来处理,而不是由原始页面处理。显然,HTTP 处理程序负责处理显示给最终用户的所有输出。HTTP 处理程序是实现 IHttpHandler 接口的类。所有最终输出都是在 ProcessRequest 方法中生成的:
Public Sub ProcessRequest(ByVal context As HttpContext) _
Implements IHttpHandler.ProcessRequest
context.Response.ContentType = "text/plain"
context.Response.Write("Hello World")
End Sub
在 web.config 文件中注册了这样一个处理程序后,register.aspx 页面的输出将是一个简单的“Hello World”消息。
HTTP 处理程序是处理任何 ASP.NET 请求的最底层工具。安装之后,HTTP 处理程序代码将是请求的唯一执行端点。因此,对于页面请求,自定义的 HTTP 处理程序意味着没有任何视图状态,没有任何回发,也没有任何生命周期事件。如果请求必须经过常规的生命周期,则应考虑 Page 类的 SetRenderMethodDelegate 方法。此方法会指定一个回调,通过调用此回调,可将页面内容呈现给浏览器。此方法实际上是在 Control 类上定义的,并且服务器控件可以使用此方法将其内容呈现到父控件中。例如:
Private Sub OnPreRenderComplete(object sender, EventArgs e)
Dim pageHandler As Page = DirectCast(sender, Page)
If Not pageHandler Is Nothing Then
Dim method As New RenderMethod(RenderPageContents)
pageHandler.SetRenderMethodDelegate(method);
End If
End Sub
Private Sub RenderPageContents( _
ByVal output As HtmlTextWriter, _
ByVal container As Control)
...
End Sub
此方法仅供该框架使用,不作公用。不过,如果要做的只是重写页面呈现而不影响页面生命周期,那么也可以考虑使用此方法。但要记住,如果以前的呈现方法委托设置与现在的不同,此方法则可能出现问题。
URL 重写
URL 重写是以编程方式将请求重定向到其他 URL 的一种好方法。URL 重写主要是指在应用程序请求生命周期的早期更改请求 URL,例如,在 BeginRequest 中更改。如果要重写 URL,可使用 HttpContext 对象的 RewritePath 方法:
HttpContext context = HttpContext.Current;
context.RewritePath("newpage.aspx");
除了浏览器不发出新的物理请求外,此方法的效果与使用传统的 HTTP 302 重定向相同。通过在 HTTP 模块中嵌入上述代码段(或通过简单编辑 global.asax 文件),可以检查当前请求的目标以决定是否将其重定向到其他页面和其他内容。
ASP.NET 2.0 支持将 <urlMappings> 部分用于纯声明性的和无条件的 URL 重写。
总结
和任何其他类型的应用程序一样,网站也由源代码(即以代码隐藏方式编译的代码,标记,或脚本)组成。原始源代码负责处理用户界面和站点行为。如果需要更改用户界面和行为,最容易的方法是编辑源代码。但如果无法编辑源代码,可以尝试本文中所述的方法来实现您的目标。
通过比较图 5 和图 6,可以看出添加到站点的外部二进制组件能执行那些操作。您所看到的所有更改都已通过简单地添加一个组件(即 HTTP 模块)和完全忽略源文件实现。请注意,进行运行时更改会带来请求开销,因此,应尽可能提高站点的灵活性和可配置性,并设法预测可能要进行的更改。
将您想向 Dino 询问的问题和提出的意见发送至:cutting@microsoft.comcutting@microsoft.com.
Dino Esposito是 Solid Quality Learning 的顾问以及《Programming Microsoft ASP.NET 2.0》(Microsoft 2005 年出版)一书的作者。Dino 先生定居于意大利,他经常在世界各地的业内活动中发表演讲。Dino 的联系方式是 cutting@microsoft.com,他的博客网址为 weblogs.asp.net/despos