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 Web Service 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 the 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 the 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 administrative 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 the 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 do is 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.
<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
End Class
Assuming you exposed a method named HelloWorld
in the above example, you would be able to invoke it from JavaScript like this:
Boogaloo.HelloWorld();
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
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
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("'")
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
If Not m.IsPublic AndAlso Not localPage Then
Exit For
End If
If Not attr.IsValid(Me) Then Exit For
script.Append(",")
script.Append(m.Name)
script.Append(":function(")
argSetter = New StringBuilder()
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
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 asynchronous 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
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
End Try
Next
If Not accessGranted Then Throw New Exception("Access Denied")
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
ar.result = m.Invoke(targetObject, args.ToArray())
Catch ex As Exception
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
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, it is 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. ForumAsyncMethodAttribute
uses this class to check out the currently logged-in user and compare its UserType
member to the attribute's 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 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 10, 2011 - Initial article.