AsyncMethods - 对 Microsoft ScriptMethods 的改进






4.76/5 (9投票s)
Microsoft ScriptMethods 的轻量级、安全且组织更佳的版本。
引言
我喜欢 Microsoft 的脚本服务。在我的日常工作中我一直都在使用它们。事实上,我非常喜欢它们,以至于我在业余时间的工作中用 PHP 复制了它们。话虽如此,总有改进的空间,而且我使用它们的次数越多,就越发现它们有更好的实现方式。
对于不熟悉脚本服务的人来说,它们是 Web 服务的扩展,通过使用 AJAX,可以从客户端调用任何用 ScriptMethod
属性修饰的 Web 服务方法。我个人使用它们作为我网站中的业务逻辑层。
背景
在我目前开发的 ASP.NET 网站中,我使用以下架构
我有一个带有全局样式表和 JavaScript 文件的母版页,以及 jQuery 和一个 <asp:ScriptManager>
来支持 ScriptServices。每个页面都有自己的样式表(用于处理页面的特定样式)、自己的 JavaScript 文件,当然还有它的代码隐藏文件。
ASPX 页面中包含空容器和模态弹出表单。页面上 90% 的内容来自调用 Web 服务并显示结果。在 Web 服务内部,对数据库的请求被转换为 XML(通常使用 LINQ),然后使用 XSLT 文件进行转换,然后返回到网页以插入到容器中。目前,我所有的 .aspx.vb 代码隐藏文件都只包含(最多)一个安全检查,用于将未经授权的用户重定向到主页;所有业务逻辑都存在于脚本服务中。
最初,每个网页的所有 HTML 和 JavaScript 都包含在 .aspx 文件中,所有 CSS 都内联或位于母版页的全局样式表中,但这变得相当混乱和难以管理,所以我将所有内容拆分成了单独的部分。
虽然这种架构很好地分离了表现层和业务逻辑层,但它确实有一些缺点
- 对脚本服务的引用创建了到 Web 服务器的额外连接,以检索它生成的动态 JavaScript。
- 所有用
ScriptMethod
属性修饰的方法都会在客户端脚本中公开,无论用户是谁。 - 每个公开方法的安全性必须在方法内部处理(即,您必须在方法体内部确定调用该方法的用户是否实际拥有执行此操作的权限)。
- 脚本服务是 Web 服务的扩展,仍然可以作为 Web 服务访问,这可能不是您真正想要的。
- 为了使用脚本服务,页面上(或母版页中)必须有一个
<asp:ScriptManager>
(或 AJAX Control Toolkit 等效控件)。此控件会转换为多个额外的<script>
引用到内部脚本,而这些脚本又需要更多与服务器的连接才能检索它们。
这些问题并非不可克服。例如,你_可以_在每个方法的开头进行安全检查,以确保用户有权调用它。风险在于你忘记这样做,一些知道如何使用 IE8 或更高版本中 F12 键的聪明黑客开始以你可能没有预料到的方式手动调用你的脚本方法。
大多数网站也有不止一种用户类型。例如,论坛网站中的页面,可能允许公众查看帖子(只读访问),注册用户只能发布和删除自己的帖子,版主可以批准用户帖子并删除任何帖子,以及管理员可以执行所有这些操作并管理用户列表。编写一个“发帖”页面,并根据用户身份显示/隐藏功能,而不是根据用户编写相同页面的变体,这样做很有意义。但是,如果你想将所有与页面相关的异步代码保存在一个地方,使用 Microsoft 的脚本服务会将所有方法(包括管理方法)的原型暴露给所有用户!即使每个方法都有安全检查,将方法原型透露给潜在的黑客可能也不是最好的做法。
作为喜欢重新发明轮子的编程怪咖,我决定重新创建 ScriptMethod
的功能,但以一种更适合我并提高安全性方式完成。
要求
我首先定义了一些我对最终结果的期望要求
- 异步方法应存在于其所应用页面的代码隐藏中。
- 应暴露在母版页或页面祖先类中定义的异步方法。
- 页面上定义的异步方法应能相对轻松地被网站内其他页面重用。
- 使用
Protected
修饰符声明的方法仅在为其定义页生成客户端脚本时才应公开。 - 开发人员应该能够轻松识别方法向客户端公开的条件(例如,基于用户身份等)。
- 我们的网站已经引用了 jQuery 和 JSON 库。
既然我们知道目标是什么,我们就可以开始了。
解决方案
我们首先需要定义两个继承自 Attribute
的类。第一个将应用于任何包含异步方法的页面,第二个将应用于开发人员希望暴露给异步代码的任何方法。
<AttributeUsage(AttributeTargets.Class)> _
Public Class AsyncClassAttribute
Inherits Attribute
Private MyScriptClassName As String = ""
Public ReadOnly Property ScriptClassName() As String
Get
Return MyScriptClassName
End Get
End Property
Public Sub New(ByVal scriptClassName As String)
MyScriptClassName = scriptClassName
End Sub
End Class
该属性非常基础。此属性允许您指定在生成的客户端脚本中包含方法的类/对象的名称。我之所以不简单地匹配页面的类名,是因为一些页面名称可能是 JavaScript 中的保留字,更不用说 Default.aspx 的类名是“Default_aspx”这样愚蠢的名字,谁想用那个呢?
这是一个如何使用此属性的示例
<AsyncClass("Boogaloo")> _
Partial Class _Default
Inherits System.Web.UI.Page
' Class stuff goes here
End Class
假设您在上面的示例中公开了一个名为 HelloWorld
的方法,您可以通过 JavaScript 像这样调用它
Boogaloo.HelloWorld(/*args go here */);
下一个属性是应用于您希望公开的方法的属性
<AttributeUsage
(AttributeTargets.Method)> _
Public Class AsyncMethodAttribute
Inherits Attribute
Public Overridable Function IsValid(ByVal p As Page) As Boolean
Return True
End Function
End Class
这个类看起来相当简朴,但我们稍后会再讨论它。
使用属性
我们有我们的属性,我们甚至可以用它们来装饰类和方法。但是现在呢?这些属性究竟_如何_发挥作用呢?
答案是属性什么都不做。但是我们现在可以查找这些属性并根据它们的存在进行操作。由于我们的目标是将异步方法放入页面的脚本后面,我们应该将代码放入页面本身,或者更好的是,放入所有页面都可以继承的基类中
Imports Microsoft.VisualBasic
Imports System.Reflection
Imports System.IO
Imports System.Xml.Xsl
Public Class AsyncPage
Inherits Page
Protected Overrides Sub OnLoadComplete(ByVal e As System.EventArgs)
If Request.QueryString("__asyncmethod") <> "" Then
ExecuteAsyncMethod()
Return
ElseIf Request.QueryString("asyncscript") IsNot Nothing AndAlso _
Request.QueryString("asyncscript").ToLower() = "y" Then
Response.ContentType = "text/javascript"
BuildAsynchronousScriptCalls(False)
Response.End()
Return
End If
BuildAsynchronousScriptCalls(True)
MyBase.OnLoadComplete(e)
End Sub
这是我们基类 AyncPage
的开头,它继承自 System.Web.UI.Page
。我们做的第一件事是重写 Page
的 OnLoadComplete
方法。在该方法内部,我们首先检查是否存在异步方法执行请求(稍后会详细介绍)。如果不存在,我们检查是否有其他页面请求了我们 Public
方法的客户端脚本,在这种情况下,我们只输出客户端脚本而不输出页面内容。如果两个条件都不满足,这是一个常规页面请求,因此我们需要生成调用我们方法所需的客户端脚本。
我们将在 AsyncPage
中创建的下一个方法是 BuildAsynchronousScriptCalls
。这个私有方法有一个参数,一个 Boolean
值,指示页面请求是针对页面本身还是仅针对客户端脚本。
Private Sub BuildAsynchronousScriptCalls(ByVal localPage As Boolean)
Dim script As New StringBuilder()
Dim name As String = Me.GetType().Name
'Check if this class has been decorated with an AsyncClassAttribute
'and use that for the name of the client-side object:
For Each a As Attribute In Me.GetType().GetCustomAttributes(True)
Try
Dim attr As AsyncClassAttribute = a
name = attr.ScriptClassName
Catch ex As Exception
End Try
Next
'Include methods from the master object, if we're on a local page:
If localPage AndAlso Master IsNot Nothing Then
script.Append("var Master={__path:'")
script.Append(Request.Url.ToString().Replace("'", "\'"))
script.Append("'")
ExtractClientScriptMethods(script, localPage, Master.GetType())
script.Append("};")
End If
script.Append("var ")
script.Append(name)
script.Append("={__path:'")
script.Append(Request.Url.ToString().Replace("'", "\'"))
script.Append("'")
'Include local methods:
ExtractClientScriptMethods(script, localPage, Me.GetType())
script.Append("};")
If localPage Then
ClientScript.RegisterClientScriptBlock(Me.GetType(), _
"AsyncScript", script.ToString(), True)
Else
Response.Write(script.ToString())
End If
End Sub
此代码检查 AsyncClass
属性并提取客户端类名(否则默认为当前类的名称),并使用 StringBuilder
构造 JavaScript 方法原型。如果请求是本地的_并且_此页面有一个主页面,我们调用 ExtractClientScriptMethods
方法(见下文),并将主页面的类型传递给它。最后,我们对当前类的类型调用 ExtractClientScriptMethods
。一旦所有脚本都生成完毕,我们要么使用 ClientScript
类将脚本块注册到正在生成的页面中,要么简单地使用 Response.Write
导出 JavaScript。
现在我们进入 JavaScript 生成的核心;ExtractClientScriptMethods
Private Sub ExtractClientScriptMethods(ByVal script As StringBuilder, _
ByVal localPage As Boolean, ByVal theType As Type)
Dim argSetter As New StringBuilder
For Each m As MethodInfo In theType.GetMethods(BindingFlags.Instance _
Or BindingFlags.NonPublic Or BindingFlags.Public)
For Each a As Attribute In m.GetCustomAttributes(True)
Try
Dim attr As AsyncMethodAttribute = a
'Check to see if this method is private or public and who the referrer is:
If Not m.IsPublic AndAlso Not localPage Then
Exit For
End If
'Check to see if the current user is someone
'who has permission to see this method:
If Not attr.IsValid(Me) Then Exit For
script.Append(",")
script.Append(m.Name)
script.Append(":function(")
argSetter = New StringBuilder()
'Load the arguments:
For Each p As ParameterInfo In m.GetParameters()
script.Append(p.Name)
script.Append(",")
If argSetter.Length > 0 Then argSetter.Append(",")
argSetter.Append("'")
argSetter.Append(p.Name)
argSetter.Append("':")
argSetter.Append(p.Name)
Next
script.Append("onSuccess,onFailure,context){")
For Each p As ParameterInfo In m.GetParameters()
Dim t As Type = p.ParameterType
script.Append("if(typeof(" & p.Name & _
") == 'function'){throw 'Unable to cast function to " _
& t.ToString() & ", parameter " & p.Name & ".';}")
If t Is GetType(String) Then
ElseIf t Is GetType(Integer) Then
End If
Next
script.Append("__async(this.__path, '")
script.Append(m.Name)
script.Append("',{")
script.Append(argSetter.ToString())
script.Append("},onSuccess,onFailure,context);}")
Catch ex As Exception
'Do nothing!
End Try
Next
Next
End Sub
在这个方法中,我们使用反射来获取类中所有方法的列表。我们测试它是否应用了 AsyncMethodAttribute
。总而言之,我们构建了与我们公开的方法同名的 JavaScript 方法,参数数量相同_加上_三个额外的参数:onSuccess
、onFailure
和 context
。熟悉 Microsoft 脚本方法的人会知道前两个是异步调用成功或失败时(分别)要调用的 JavaScript 函数,第三个是上下文变量,可以包含您想要的任何内容。这三个附加参数都是可选的。
这些 JavaScript 方法的主体都包含对名为 __async
的方法的调用。这是您需要包含在全局 JavaScript 文件中的一个方法,该文件使用 jQuery 的 ajax
方法异步调用我们的服务器端方法
function __async(path, method, args, onSuccess, onFailure, context)
{
var delim = path.match(/\?/ig) ? '&' : '?';
$.ajax({ type: 'POST',
url: path + delim + '__asyncmethod=' + method,
data: JSON.stringify(args).replace('&', '%26'),
success: function(result, status, method)
{
if (result.status == 1)
{
onSuccess(result.result, context, method);
}
else
{
onFailure(result.result, context, method);
}
},
error: function(request,status, errorThrown)
{
onFailure(request.responseText + '\n' + errorThrown, context, status);
}
});
}
还记得在 OnLoadComplete
事件中,我们首先检查是否正在请求异步方法调用吗?好吧,如果你看一下 ajax
方法的 url
参数,我们包含了一个名为 "__asyncmethod
" 的查询字符串项,并将其设置为我们的方法名。方法的参数被转换为 JSON 字符串并作为 POST 数据传递。正是这个查询字符串设置的存在导致 ExecuteAsyncMethod
被调用
Private Sub ExecuteAsyncMethod()
Dim m As MethodInfo = Me.GetType().GetMethod(Request.QueryString("__asyncmethod"), _
BindingFlags.Instance Or BindingFlags.Public Or BindingFlags.NonPublic)
Dim ar As New AsyncResults
Dim js As New System.Web.Script.Serialization.JavaScriptSerializer()
Dim args As New List(Of Object)
Dim targetObject As Object = Me
Dim debugParamName As String = ""
Dim debugParamValue As String = ""
ar.status = 1
ar.result = "null"
If m Is Nothing AndAlso Master IsNot Nothing Then
m = Master.GetType().GetMethod(Request.QueryString("__asyncmethod"), _
BindingFlags.Instance Or BindingFlags.Public Or BindingFlags.NonPublic)
targetObject = Master
End If
If m IsNot Nothing Then
Dim accessGranted As Boolean = False
'Check to make sure that the current user has permission to execute this method
'This prevents hackers from trying to invoke methods that they shouldn't):
For Each a As Attribute In m.GetCustomAttributes(True)
Try
Dim attr As AsyncMethodAttribute = a
If Not attr.IsValid(Me) Then
accessGranted = False
Exit For
End If
accessGranted = True
Catch Ex As Exception
'Do nothing
End Try
Next
If Not accessGranted Then Throw New Exception("Access Denied")
'Change the content-type to application/json, as we're returning
'a JSON object that contains details about
'the success or failure of the method
Response.ContentType = "application/json"
Try
Dim referrerPath As String = Request.UrlReferrer.LocalPath
If referrerPath.EndsWith("/") Then referrerPath &= "Default.aspx"
If Not m.IsPublic AndAlso referrerPath.ToLower() <> _
Request.Url.LocalPath.ToLower() Then
Throw New Exception("Access Denied")
End If
If Request.Form.Count > 0 Then
Dim jp As New JsonParser()
Dim params As Dictionary(Of String, Object) = _
jp.Parse(HttpUtility.UrlDecode(Request.Form.ToString()))
For Each pi As ParameterInfo In m.GetParameters()
Dim destType As Type = pi.ParameterType
debugParamName = pi.Name
debugParamValue = params(pi.Name)
If Nullable.GetUnderlyingType (destType) IsNot Nothing Then
destType = Nullable.GetUnderlyingType(destType)
End If
If params(pi.Name) Is Nothing OrElse (params (pi.Name).GetType() Is _
GetType(String) AndAlso params(pi.Name) = "") Then
args.Add(Nothing)
Else
args.Add(System.Convert.ChangeType(params(pi.Name), destType))
End If
Next
End If
ar.status = 1
'Invoke the local method:
ar.result = m.Invoke(targetObject, args.ToArray())
Catch ex As Exception
'Return exception information:
ar.status = 0
ar.result = ex.Message
ex = ex.InnerException
While ex IsNot Nothing
ar.result = ex.Message
ex = ex.InnerException
End While
End Try
'Write the response and then terminate this page:
Response.Write(js.Serialize(ar))
Response.End()
End If
End Sub
Private Class AsyncResults
Public status As Integer
Public result As Object
End Class
我不会深入探讨这个方法的工作原理的细节,它通过名称在页面和主页面(如果存在)中搜索请求的方法。它验证参数的数量和类型是否匹配,并尝试调用该方法。无论是成功还是失败,此方法都返回相同的结果:一个私有类 AsyncResults
的实例,序列化为 JSON。status
成员让 AJAX 调用知道返回是否成功(从而在客户端确定是调用 onSuccess
还是 onFailure
)。
此时,如果这一切都讲得通,您应该会问我系统如何根据当前用户决定是否公开/调用这些方法。这一切都归结为上面代码块中的这段代码片段
Dim attr As AsyncMethodAttribute = a
If Not attr.IsValid(Me) Then
accessGranted = False
Exit For
End If
请记住,AsyncMethodAttribute
类中的 IsValid
方法默认返回 True
。我这样做是故意的,因为并非所有网站都具有相同的规则或用户类型。期望是您(开发人员)将创建一个派生自 AsyncMethodAttribute
的类,该类在其构造函数中接受某个值,并在 IsValid
中使用该值来确定是否应该公开该方法。
为了(希望)更清楚地说明这一点,我附带了一个演示网站。
示例
为了演示这一切是如何工作的,我创建了一个名为 AsyncMethodsDemo 的示例网站,并将其附加到本文中。这是一个简单的单页消息发布网站,具有以下规则
- 所有已批准、未删除的帖子对所有人可见
- 只有注册用户才能创建新帖子
- 普通用户帖子在版主或管理员批准之前不可见
- 帖子可以由版主、管理员和帖子作者删除
- 只有管理员才能添加或删除用户
请不要抱怨这个发帖网站多么愚蠢/丑陋/缺乏数据验证/等等;它只是为了演示异步方法。对于数据库,我在 App_Data 文件夹中使用了一个简单的 XML 文件。是的,如果多个人同时访问这个网站,数据冲突就会发生。再说一遍,这不是这个网站的重点。
实施安全
为了保护我的方法,我需要为我的系统支持的各种用户类型定义一个枚举。此枚举定义在 UserType.vb 中
<Flags()> _
Public Enum UserType As Byte
Anonymous = 1 << 0
User = 1 << 1
Moderator = 1 << 2
Administrator = 1 << 3
Everyone = Byte.MaxValue
End Enum
然后我创建了一个新的属性类,它继承自 AsyncMethodAttribute
并使用了这个枚举
Public Class ForumAsyncMethodAttribute
Inherits AsyncMethodAttribute
Private MyValidUser As UserType = UserType.Everyone
Public ReadOnly Property ValidUser() As UserType
Get
Return MyValidUser
End Get
End Property
Public Sub New(ByVal validUser As UserType)
MyValidUser = validUser
End Sub
Public Overrides Function IsValid(ByVal p As Page) As Boolean
Dim fp As ForumsPage = CType(p, ForumsPage)
If (fp.CurrentUser.UserType And ValidUser) = _
fp.CurrentUser.UserType Then Return True
Return False
End Function
End Class
然后我声明了一个继承自 AsyncPage
的新类,其中包含我的 SiteUser
对象的一个实例。ForumAsyncMethodAttribute
使用这个类来检查当前登录的用户,并将其 UserType
成员与属性的 ValidUser
成员进行比较。_这_就是我们的客户端脚本生成器如何确保只有当前用户可用的方法才被公开。现在在我们的 Default.aspx.vb 代码隐藏文件中,我们可以声明如下方法...
<ForumAsyncMethod(UserType.Administrator)> _
Protected Function GetUsers() As String
Dim db As New Database
Return TransformXml(db.Data.<Users>.Single, "Users.xslt")
End Function
...并且只有当当前登录用户是管理员时,JavaScript 中的 GetUsers
调用才会显示。由于我们使用 Flags
属性修饰了 UserType
枚举,我们可以使用位运算符为方法指定多个有效用户。回想一下,我们的要求是版主和管理员可以批准帖子
<ForumAsyncMethod(UserType.Moderator Or UserType.Administrator)> _
Protected Sub ApprovePost(ByVal id As Integer)
Dim db As New Database
db.Data.<Posts>.<Post>
(id).@Approved = "1"
db.Save()
End Sub
同理,UserType
为 Everyone
将匹配任何用户类型,因为其二进制值为 11111111。
在客户端,我们像调用脚本方法一样调用我们的方法
function approvePost(id)
{
if (!confirm('Are you sure you want to approve this post?')) return;
Forums.ApprovePost(id, refreshPosts, alertResult);
}
随意以不同类型的用户身份登录,然后查看 Default.aspx 的源代码,看看 JavaScript 代码如何变化。我希望您会喜欢。
享受这段代码,如果您有任何意见或问题,请告诉我!
历史
- 2011年8月10日 - 初版文章。