AsyncMethods - An Improvement on Microsoft's ScriptMethods
Introduction
I love Microsoft's script services. I use them all the time in my day job. In fact, I love them so much that I
replicated them in PHP for the work I do on the side. That being said, there's always room for improvement and the more I've used them the more I've come across ways where they could have been implemented better.
For those unfamiliar with script services they're an extension of
web services that expose any method in the web service decorated
with a ScriptMethod
attribute as invokable
from the client side using AJAX. What I personally use them for
is the business logic layer in my websites.
Background
In the current ASP.NET sites I develop, I use the following architecture:
I
have a Master page with a global stylesheet and javascript file,
along with jQuery and an <asp:ScriptManager>
to
support ScriptServices. Each page has its own stylesheet (to handle
specific styles for the page), its own Javascript file, and of course
its code-behind file.
Contained within the aspx page are empty containers and modal popup forms. 90% of the content on the page comes from invoking the webservice and displaying the results. Inside the web service requests to the database are converted into XML (normally using LINQ) and then transformed using xslt files before being returned to the web page to be inserted into the containers. Presently all my .aspx.vb code-behind files contain only (at most) a security check to redirect unauthorized users to the home page; all business logic exists in script services.
Originally each web page had all HTML and Javascript contained in the .aspx file, with all CSS inline or located in the global stylesheet from the master page, but it got quite messy and unruly so I split it all into its separate pieces.
While this architecture very nicely separates presentation and business logic layers, it does have a few drawbacks:
- The reference to the script service creates an additional connection to the web server to retrieve the dynamic Javascript generated by it.
- All methods
decorated with a
ScriptMethod
attribute are exposed in client script regardless of the user. - Security on each exposed method must be handled within the method (i.e. you have to determine if the user invoking the method actually has permission to do so inside the body of the method).
- Script services are extensions of web services and can still be accessed as web services, which you might not really want.
- In order to use script
services you must have an
<asp:ScriptManager>
(or the AJAX Control Toolkit equivalent) on the page (or Master page). This control converts into several additional<script>
references to internal scripts which in turn require more connections to the server to retrieve them.
These problems aren't surmountable. For example you can do a security check at the start of each method to ensure that the user has permission to invoke it. The risk comes where you forget to do so and some clever hacker that knows how to use the F12 key in IE8 or later starts manually invoking your script methods in ways you might not have predicted.
Most websites also have more than one flavour of user. A page in a forum site, for example, might have the public viewing the posts (with read only access), registered users who can only post and delete their own posts, moderators who can approve posts by users and delete any post, and administrators who can do all that plus manage the user list. It makes a lot of sense to write one "posting" page and just show/hide functionality based on who the user is rather than write variations of the same page based on the user. If you want to keep all the asynchronous code related to the page in one place however, using Microsoft's script services exposes the prototypes for all methods to all users (including the adminstrative ones!). Even with security checks in each method, revealing method prototypes to potential hackers probably isn't the best thing to do.
Being
the type of programming wonk who likes to reinvent the wheel, I
decided to recreate the functionality of ScriptMethod
,
but done in a way that works better for me and improves security.
The Requirements
I started off by defining some requirements of what I expected the end result to have:
- The asynchronous methods should exist in the code-behind of the page that they apply to.
- Asynchronous methods defined in the master page or in the page's ancestor classes should be exposed.
- Asynchronous methods defined on a page should be able to be reused by other pages within the site with relative ease.
- Methods declared with the
Protected
modifier should only be exposed when generating the client-side script for the page in which they are defined. - The developer should be able to easily identify the conditions under which methods are exposed to client side (e.g. based on who the user is, etc.).
- Our site already has references to jQuery and the JSON library
Now that we know what we're aiming for, we can begin.
The Solution
The first thing we need to
define two classes that inherit from
Attribute
. The first will be applied to any page that
contains asynchronous methods, the second will be applied to any
method the developer wishes to expose to asynchronous code.
Note: I apologize in advanced for the messy state of the code below; CodeProject's online editor did a number on its layout.
<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
The attribute is pretty basic. This attribute allows you to specify the name of the class/object that contains the methods in the resulting client-side script. The reason I don't simply match the class name of the page is that some page names might be reserved words in Javascript, not to mention the fact that the class name for Default.aspx is something stupid like "Default_aspx", and who wants to use that?
Here is an example of how this attribute would be used:
<AsyncClass("Boogaloo")> _
Partial Class _Default
Inherits System.Web.UI.Page
' Class stuff goes here
End Class
Assuming you exposed a method named
HelloWorld
in the above example, you would be able to
invoke in from Javascript like this:
Boogaloo.HelloWorld(/*args go here */);
The next attribute is the one that will be applied to methods you wish to expose:
<AttributeUsage
(AttributeTargets.Method)> _
Public Class AsyncMethodAttribute
Inherits Attribute
Public Overridable Function IsValid(ByVal p As Page) As Boolean
Return True
End Function
End Class
This class looks pretty spartan but we'll talk about it a bit later.
Using the Attributes
We have our attributes and we can even decorate classes and methods with them. But now what? How do these attributes actually do anything?
The answer is that attributes do nothing. But we can now look for those attributes and act based on their existence. And since our goal is to put our asynchronous methods in the script-behind of our pages we should put our code in the page itself, or better yet, in a base class that all our pages can inherit from:
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
Here's the start of our base class,
AyncPage
, which inherits from
System.Web.UI.Page
. The first thing we do is override
the OnLoadComplete
method from Page
. Inside
the method, we first check for whether or not a request for
asynchronous method execution exists (more on that later). If not we
look to see if another page has requested the client-side script for
our Public
methods, in which case we dump out only the
client-side script and none of the contents of the page. If neither
condition is met, this is a regular page request so we need to
generate the client-side script required to invoke our methods.
The next method in AsyncPage
that we'll create is
BuildAsynchronousScriptCalls
. This private method has
one argument, a Boolean
value indicating whether the
page request is for the page itself or for only the client-script.
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
This code checks for the AsyncClass
attribute and extracts the client-side class name (defaulting to the
current class' name otherwise), and using a
StringBuilder
constructs the Javascript method
prototypes. If the request is local and this page has a
master page, we call our ExtractClientScriptMethods
method (see below), passing it the master page's type. Finally we
call ExtractClientScriptMethods
on the current class'
type. Once all the script has been generated, we either use the
ClientScript
class to register our script block into the
page being generated or we simple dump the Javascript using
Response.Write
.
Now we get into the guts of the
Javascript generation; 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
In this method we're using reflection to get a list
of all the methods in our class. We test to see if it has an
AsyncMethodAttribute
applied to it. To summarize, we
build Javascript methods named the same as our exposed methods, with
the same number of parameters plus three additional ones:
onSuccess
, onFailure
and
context
. Those familiar with Microsoft's script methods
will know that the first two are the Javascript functions to be
invoked when the asynchronous call succeeds or fails (respectively),
and the third is a context variable that can contain whatever you'd
like it to contain. All three of these additional parameters are
optional.
The body of these Javascript methods all contain a
call to a method named __async
. This is the one method
you need to include in a global javascript file which uses jQuery's
ajax method to asynchronously invoke our server-side methods:
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);
}
});
}
Remember back in the
OnLoadComplete
event we first checked to see if an
asyncronous method invocation was being requested? Well if you look
at the url
argument of the ajax
method we
include a query string item called "__asyncmethod" setting
it to our method name. The arguments to the method are converted to a
JSON string and passed as POST data. It's the existence of that query
string setting that causes ExecuteAsyncMethod
to be
invoked:
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
I won't
go into the big, gory details of how this method works, it searches
for the requested method by name both in the page and in the master
page (if one exists). It verifies that the number and types of
arguments match and attempts to invoke the method. Whether it
succeeds or fails this method returns the same result: an instance of
the private class AsyncResults
, serialized to JSON. The
status
member lets the ajax call know if the return was
successful or not (thus determining in the client-side whether
onSuccess
or onFailure
gets called).
At this point, if any of this has made sense, you should be asking me how the system determines whether or not to expose/invoke the methods based on the current user? It all boils down to this code snippet from the above code block:
Dim attr As AsyncMethodAttribute = a
If Not attr.IsValid(Me) Then
accessGranted = False
Exit For
End If
Recall that the
IsValid
method in the AsyncMethodAttribute
class returns True
by default. I did this on purpose, as
not all websites will have the same rules or user types. The
expectation is that you, the developer, will create a class derived
from AsyncMethodAttribute
that accepts some value as
part of its constructor that it uses in IsValid
to
determine whether or not the method should be exposed.
To (hopefully) make this clearer, I've included a sample web site that does just that.
The Sample
To demonstrate how this all works I've created a sample website called AsyncMethodsDemo, attached to this article. It is a simple one-page message posting site with the following rules:
- All approved, non- deleted posts are visible to everyone
- Only registered users can create new posts
- Standard user posts are not visible until approved by a moderator or administrator
- Posts can be deleted by moderators, administrators and the author of the post
- Only administrators can add or delete users
Please don't complain about how dumb/ugly/lacking in data validation/etc. this posting site is; it's really there to demonstrate the asynchronous methods. For the database I use a simple XML file in the App_Data folder. Yes, if multiple people access this site at the same time data collisions will occur. Once again, its not the point of the site.
Implementing the Security
In order to secure my methods I needed to define an enumeration for the various types of users my system supports. This enumeration is defined in 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
Then I create a new
attribute class derived from AsyncMethodAttribute
that
makes use of this enumeration:
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
I then declared a new class inherited from AsyncPage
which contains an instance of my SiteUser
object. The
ForumAsyncMethodAttribute
uses this class to check out
the currently logged-in user and compare its UserType
member to the attributes ValidUser
member. This
is how our client-side script generator ensures that only methods
available to the current user are exposed. Now in our Default.aspx.vb
code behind, we can declare methods like the following...
<ForumAsyncMethod(UserType.Administrator)> _
Protected Function GetUsers() As String
Dim db As New Database
Return TransformXml(db.Data.<Users>.Single, "Users.xslt")
End Function
...and the GetUsers call in Javascript will only show up if the currently logged-in user is an administrator. Since we decorated the UserType enumeration with the Flags attribute we can use bitwise operators to specify more than one valid user for a method. Recall that our requirements state that moderators and administrators can approve posts:
<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
In the same vein, a UserType of Everyone will match against any user type, since its value in binary is 11111111.
On the client- side, we invoke our methods just as if we were invoking script methods:
function approvePost(id)
{
if (!confirm('Are you sure you want to approve this post?')) return;
Forums.ApprovePost(id, refreshPosts, alertResult);
}
Feel free to log in as the different types of users, then look at the source of Default.aspx to see how the Javascript code changes. I hope you'll be entertained.
Enjoy this code and let me know if you have any comments or questions!
History
August 10th, 2011 - Initial ArticlePost Comment
hGiyAe Very interesting information!Perfect just what I was searching for! Charity is injurious unless it helps the recipient to become independent of it. by John Davidson Rockefeller, Sr..