Using the techniques I will describe in this post you will be able to call just about any method on your business objects that your website project has a reference to, avoiding pass-through web service methods or static PageMethodAttribute'd methods on the aspx page.
History
If you've worked with ASP.net AJAX for any length of time you're probably called your fair share of webservices from client JavaScript or static methods on an aspx page via the PageMethods AJAX object and the PageMethodAttribute.
I was looking for a way to be able to call a method in my business objects directly from the client JavaScript, bypassing the pass-through methods. I knew this was possible because ASP.net AJAX does this itself. If I had to create all of these pass-through methods for the duration of my project, I was going to get very bored and there would be a lot of basically useless code that basically forwarded calls.
Revelation
I realized, after researching how AJAX makes its calls (via an HttpHandler), that I could put together a ScriptControl that implemented IHttpHandler that would broker calls to any other object that the web project had a reference to (via reflection). This means that I could set up a call on the client (in JavaScript) directly to a method on my business object on the server, without an intervening PageMethodAttribute'd static method on the aspx page or creating a web service.
Start
I called my control ClientBridge since it bridges the client and the business logic layer. Sometimes names are the hardest part of developing code, and I'm not convinced this name is all that great, but it'll work for now. Anyway, as I mentioned, this class is both a ScriptControl as well as an HttpHandler, so it must inherit from ScriptControl and IHttpHandler:
1: namespace Norimek.Web.UI.WebControls
2: {
3: [ToolboxData("<{0}:ClientBridge runat=\"server\"></{0}:ClientBridge>")]
4: public class ClientBridge : ScriptControl, IHttpHandler
Creating The HttpHandler
I'll start with the HttpHandler part of the code since it's short and more difficult. As I'm sure you know, the IHttpHandler interface has only one method: ProcessRequest. This is where you put the code to do whatever you need to do when your handler is called. The handler is called in response to whatever path you set up in your web.config for your handler. In the httpHandlers section of the web.config, you add your handler via the add tag:
1: <httpHandlers>
2: <add verb="*" path="ClientBridge.axd" validate="false" type="Norimek.Web.UI.WebControls.ClientBridge, Norimek.Web.UI.WebControls" />
3: </httpHandlers>
We don't care what the path is, it just needs to be the same as what is called on the client JavaScript. This will be taken care of with code we add to the ScriptControl, so as long as those two match, the user of the control will not need to deal with this information.
In ProcessRequest what we need to do is take the parameters that are passed in via the HttpContext parameter (specifically, in the context.Request.InputStream property). These are parameters that are sent as part of the web method call, which is set up in the ScriptControl's JavaScript. The method takes three parameters: the name of the type to call, the name of the method to call, and a serialized parameters object, which is the parameters to the business object, which are parsed in ProcessRequest:
1: public void ProcessRequest(HttpContext context)
2: {
3: const string Parameter_TypeName = "typeName";
4: const string Parameter_MethodName = "methodName";
5: const string Parameter_Parameters = "parameters";
6:
7: try
8: {
9: IDictionary<string, string> parameters = Common.DeserializeRequestParameters(context);
10:
11: if (string.IsNullOrEmpty(parameters[Parameter_TypeName]))
12: throw new ArgumentNullException(Parameter_TypeName);
13:
14: if (string.IsNullOrEmpty(parameters[Parameter_MethodName]))
15: throw new ArgumentNullException(Parameter_MethodName);
16:
17: string typeName = parameters[Parameter_TypeName];
18: string methodName = parameters[Parameter_MethodName];
19:
20: Type objType;
21:
22: try
23: {
24: objType = Type.GetType(typeName, false);
25: }
26: catch (Exception e)
27: {
28: throw new Exception(string.Format("The typename specified '{0}' could not be loaded.", typeName), e);
29: }
30:
31: object[] parameterList;
32: Type[] typeList;
33:
34: CreateParameterList(parameters[Parameter_Parameters], out parameterList, out typeList);
35:
36: MethodInfo mi = objType.GetMethod(methodName, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, null, typeList, null);
37:
38: if (mi == null)
39: throw new Exception(string.Format("Could not find a method for type '{0}' with the name '{1}' and the parameters types '{2}'.", typeName, methodName, OutputArrayItems<Type>(typeList)));
40:
41: if (mi.GetCustomAttributes(typeof(WebMethodAttribute), false).Length == 0)
42: throw new Exception(string.Format("Could not find a method for type '{0}' with the name '{1}' and the parameters types '{2}'.", typeName, methodName, OutputArrayItems<Type>(typeList)));
43:
44: ResponseObject response = new ResponseObject(mi.Invoke(null, parameterList));
45:
46: context.Response.ContentType = "application/json";
47: context.Response.Cache.SetMaxAge(new TimeSpan(0));
48:
49: string responseString = new JavaScriptSerializer().Serialize(response);
50:
51: if (responseString != string.Empty)
52: context.Response.Write(responseString);
53: }
54: catch (Exception e)
55: {
56: Common.WriteExceptionJsonString(context, e);
57: }
58:
59: }
Common.DeserializeRequestParameters is a helper function that does what it says and CreateParameterList is another helper function that deserializes the parameters parameter into an array of objects and types that are used in both GetMethod to find the specified method and in the method invocation to pass the parameter values.
Using these data (type to call, name of method, and parameters), the code uses reflection to load the specified type, find the specified method, and pass in the specified parameters.
Please note some design choices: the method it looks for can be public, private, or internal, but must be static and must be attributed with the WebMethodAttribute. This last constraint is a security consideration. The reason I added this constraint is because it makes it so that the client JavaScript can only call methods I want it to call. This constraint can easily be removed, if you like, but I suggest keeping it so that a malicious user cannot just make calls into your business objects all day long trying to find out what he or she can do.
Creating the Control
The reason I implemented a ScriptControl is because then all I have to do is drop this control on a page and I can make all the calls to my business objects I want. Creating a ScriptControl is not trivial, and the documentation is a bit sketchy.
ScriptControl
The two keys to implementing a ScriptControl is setting up the client properties of the client object (the object that will exist as a javascript object on the client) and creating the reference to the client JavaScript that will exist in a JavaScript file as an embedded resource file:
1: protected override IEnumerable<ScriptDescriptor> GetScriptDescriptors()
2: {
3: ScriptControlDescriptor descriptor = new ScriptControlDescriptor("AFU.ClientBridge", this.ClientID);
4:
5: descriptor.AddProperty("timeout", this.Timeout);
6: descriptor.AddProperty("userContext", this.UserContext);
7: descriptor.AddProperty("succeededCallback", this.CallbackSucceededHandler);
8: descriptor.AddProperty("failedCallback", this.CallbackFailedHandler);
9: descriptor.AddProperty("url", Common.MakeUrl("ClientBridge.axd", this.Page.Request.Url));
10:
11: return new ScriptDescriptor[] { descriptor };
12: }
13:
14: protected override IEnumerable<ScriptReference> GetScriptReferences()
15: {
16: ScriptReference reference = new ScriptReference("Adot.Fast.Web.UI.WebControls.ClientBridge.js",
17: "Adot.Fast.Web.UI.WebControls");
18:
19: return new ScriptReference[] { reference };
20: }
In GetScriptDescriptors, all of the descriptor.AddProperty calls are properties that exist on the JavaScript object. If they do not exist there, an error will occur upon loading the page on the client (note that the property names are case sensitive). As you can see, the url property is set here explicitly. As I mentioned earlier, the end-user need not worry about this parameter because it is set here by us once and for all. Whatever endpoint is here (in this case, ClientBridge.axd), it must match the line in the httpHandlers section, described earlier.
Client JavaScript
The two purposes of the JavaScript is to define the object and to call the IHttpHandler. Conveniently, the ScriptControl implementation and the IHttpHandler implementation are in the same class. The JavaScript is rather short:
1: //<![CDATA[
2: Type.registerNamespace("Norimek.Web.UI");
3:
4: Norimek.Web.UI.ClientBridge = function(element)
5: {
6: Norimek.Web.UI.ClientBridge.initializeBase(this, [element]);
7:
8: this._timeout = 0;
9: this._userContext = null;
10: this._succeededCallback = null;
11: this._failedCallback = null;
12: this._url;
13: }
14:
15: Norimek.Web.UI.ClientBridge.prototype =
16: {
17: get_timeout: function() { return this._timeout; },
18: get_userContext: function() { return this._userContext; },
19: get_succeededCallback: function() { return this._succeeededCallback; },
20: get_failedCallback: function() { return this._failedCallback; },
21: get_url: function() { return this._url; },
22:
23: set_timeout: function(value) { this._timeout = value; },
24: set_userContext: function(value) { this._userContext = value; },
25: set_succeededCallback: function(value) { this._succeededCallback = value; },
26: set_failedCallback: function(value) { this._failedCallback = value; },
27: set_url: function(value) { this._url = value; },
28:
29: invoke : function(typeName, methodName, parameters, succeededCallback, failedCallback, userContext, timeout)
30: {
31: return Sys.Net.WebServiceProxy.invoke(this._url, "invoke", false, { "typeName" : typeName,
32: "methodName" : methodName, "parameters": parameters },
33: succeededCallback == null ? eval(this._succeededCallback) : succeededCallback,
34: failedCallback == null ? eval(this._failedCallback) : failedCallback,
35: userContext == null ? this._userContext : userContext,
36: timeout == null ? this._timeout : timeout);
37: }
38: }
39: Norimek.Web.UI.ClientBridge.registerClass("Norimek.Web.UI.ClientBridge", Sys.UI.Control);
40: //]]>
All there is are the property set and get accessors and the call to the Sys.Net.WebServiceProxy.invoke method. Since the url is "hard-coded" to the value set up in the ScriptControl.GetScriptDescriptors, the end-user does not need to know what it is. Note that this file must be marked as an "Embedded Resource" in the build action (right-click the file in the Solution Explorer and select Properties).
Use
To use the control, add a reference to the dll to your web project, add a register tag to the page for the control's library, and then drop the control on your page. Once the control is on your page you can call the invoke method of the control to call just about any type's method that your web project has a reference to:
1: <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="default.aspx.cs" Inherits="ClientBridgeTest._Default" %>
2: <%@ register assembly="Norimek.Web.UI.WebControls" namespace="Norimek.Web.UI.WebControls" tagprefix="nor" %>
3: <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
4: <html xmlns="http://www.w3.org/1999/xhtml" >
5: <head runat="server">
6: <title>ClientBridge Test</title>
7: </head>
8: <body>
9:
10: <form runat="server" id="frmMain">
11:
12: <script type="text/javascript">
13:
14: function test1()
15: {
16: $find("<%=ncbMain.ClientID %>").invoke("Norimek.BusinessInterface.System1, Norimek.BusinessInterface", "TestMethod1", "{ 'value1': 'System.Int32,10' }", onCallbackSucceeded);
17: }
18:
19: function test2()
20: {
21: $find("<%=ncbMain.ClientID %>").invoke("Norimek.BusinessInterface.System1, Norimek.BusinessInterface", "TestMethod2", "{ 'value1': 'String,Value from parameter', 'value2': { 'value1': [1, 2, 3], 'value2': 5 }, 'value3': 'Int32,8' }", onCallbackSucceeded);
22: }
23:
24: function onCallbackSucceeded(result, userContext, methodName)
25: {
26: alert(String.format("Response from method '{0}': '{1}'.", methodName, result));
27: }
28: </script>
29:
30: <asp:ScriptManager runat="server" ID="scrMain" EnablePageMethods="true" EnablePartialRendering="true" />
31:
32: <nor:ClientBridge runat="server" id="ncbMain" />
33:
34: <button type="button" onclick="test1();">Test 1</button>
35: <button type="button" onclick="test2();">Test 2</button>
36:
37: </form>
38:
39: </body>
40: </html>
Note: there must be an AJAX ScriptManager control on the page for the ClientBridge control to work; it uses the AJAX library's infrastructure to define itself.
The first test (test1) shows a call to a simple method that takes a single integer value and returns a single integer value. The second test (test2) shows how you can pass strings and arrays into the server method. Not shown, but also possible, is the ability to return arrays and complex objects from the server method. Note that the format of the parameters is a JSON string. The type of a parameter is specified as the value in the form, "type,value". Type can be either a fully-specified type (e.g. System.Int32) or just Int32. The type of the parameter is important so that the reflection code can find the the method signature.
Solution Example
In the example (linked below), there are three projects: Norimek.Web.UI.WebControls, Norimek.Web.ClientBridgeTest, and Norimek.BusinessInterface. The first contains the code for the control. The second is a web project used to test the control. In this project you can see how to set up the calls in JavaScript. The third is a sample business interface layer library with a couple of test methods that are called from the web project's default.aspx via JavaScript associated with the buttons on the page. With this project you can see how to handle the parameters that are passed in to the method.
ClientBridge.zip (76.62 kb)