Saturday, March 29, 2008

StateBag Control - Simplifying Data Exchange in Heavy AJAX Pages

Download source code

View samples live:

 

Sample 1

Sample 2

Using standard ASP.NET postback model, browser sends data to the server using QueryString and Form objects.

If we look farther, ASP.NET has state persistence mechanism known also as ViewState, which uses simple hidden input to store the state data. Every time we make postback - the value of that hidden input is send with all other form data in Form object.

But the problem is - the state inside ViewState is encoded and is not accessible from the client side scripts. I already discussed this problem in my previous post(Passing complex objects between client browser and web server using ASP.NET AJAX framework). You can find in that post also some reasons why I need to view or modify some UI related state from client script.

The approach I used - is again the simple hidden input element containing some JSON formatted state representation, which is accessible from the javascript, which can be modified from javascript and sent back to the server, where it can be again modified and sent back.

In this post I want to write down my farther thoughts about this model, and how it can be used in more complex scenarios.

For example suppose the following page model:

image

Here we have a page that is loaded and constructed from different sources on the server.

I want my page to be very fast to render different dynamic areas in the browser, and the same time not to use postback to achieve this.

 

Suppose I use three dynamic areas in the page:

First area is being rendered by original page and contains main contents of the page.

Second is some iframe that is controlled from javascript. I want to load some another pages to it, and this way split functionality into separate pages in the system. There are many reasons to do this. For example you could load there page performing file upload functionality. Or you want to implement tabbed browsing in your website, and in each tab you want to place new iframe with different page from the server.

Third area is loaded using web service. Web service calls in AJAX are very fast comparing to simple postback, we can send them only the data we need in it, and not the whole form data as in the case of postback. Besides this on the server only the web service method is invoked - no page lifecycle, nothing unnecessary, but only the small and fast piece of code.

JSON state can help us to exchange data between these parts of the page. But I have to explain why JSON.

1) JSON is very useful when using rich client scripting in the browser. We can easily access and modify state object and it's parts.

2) JSON comparing to XML or some other formats occupies less space and is shorter. If you use XML to describe some data object it will need many extra attributes to be defined as node element names for example, and the resulting string will be longer. In web we have to think about size because we send and receive data to remote machines, and therefore network traffic should be as small as possible.

3) We can save state on the server - this is preferred of course when we don't need it on the client because in this case we don't have to pass the state over the wire at all, but as I said this approach is needed when we want to be aware of some state in the javascript. With javascript we can do many UI-related tasks without requesting the server, making page much faster and more useful for users.

I am going to use javascript to sync these parts of the page and pass some centralized state between them. Suppose that web services need to know something about part of the page in UpdatePanel. for example they return some data depending on what was selected in main dynamic area (UpdatePanel). Also after web services return some data and user changes something in that part (for example selects something or enters some text) I want to make other parts know about it(for example UpdatePanel part).

I created very simple but useful control that can help us with this.

The simple figure explains what control does:

image

The control has APIs on server and client.

server-side API:

image

GetStateID<T>() - method that is used to retrieve the state. In T generic parameter you need to specify the type into which you want to deserialize the state. The object is deserialized only after first call, on every subsequent call the cached copy is returned. This way if you modify it in any way before EndRequest handler - the first instance will be modified. This is important to know that in page lifecycle if you access the object using this method several times, the T generic parameter should be the same all the times.

StateID is optional property. Set it to some descriptive name and you will be able to call corresponding StateBag object on the client using this syntax: $find("StateID"). If property is not set - ClientID of the control will be used

UpdateMode property, can be set to one of two values: Always - the state is always sent to the server.  OnDemand - Developer needs to call Update() method manually to send the updated state to the browser (makes sense only during partial postback, when normal postback occurs - the state is always rendered to the value attribute of hidden input).

The following is diagram for enumeration of UpdateMode property type:

image

Update() method:  Call this method only when UpdateMode is set to OnDemand. Marks current state object to be sent to the client.

 

Client side API:

get_state() - returns state object.

set_state(value) - sets state object.

getSerializedString() - Returns current state object - serialized into string. We will need this function when calling web services. In postback model synchronization with the server is handled automatically.

setSerializedString(value) - sets current state object from string passed to this function. This function can be used when web service returns some result containing modified state.

update() - Marks current state object for sync operation. on first postback the state will be sent to the server. Can be called only when updateMode property is set to OnDemand value.

 

Actually server side API will be used only on initial page render or on subsequent postback, in the case of communicating with web services of another pages in iframe area we will use client side API to pass data back and forth.

 

I created server side control that is derived from Control type and is implementing IScriptControl interface. On the client the control will represent javascript control derived from Sys.UI.Control class.

If you want to know more about IScriptControl and Sys.UI.Control see the following links:

 

1) Adding Client Capabilities to a Web Server Control by Using ASP.NET AJAX Extensions.

2) Creating Custom ASP.NET AJAX Client Controls

3) IScriptControl Interface (System.Web.UI)

4) Sys.UI.Control Class

 

StateBag.cs file listing:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Security.Permissions;
using System.Web;
using System.Web.Script.Serialization;
using System.Web.UI;

namespace Devarchive.Net
{
    /// <summary>
    /// StateBag control simplifies data exchange between JavaScript and C# code
    /// </summary>
    [
        AspNetHostingPermission(SecurityAction.LinkDemand, Level = AspNetHostingPermissionLevel.Minimal),
        AspNetHostingPermission(SecurityAction.InheritanceDemand, Level = AspNetHostingPermissionLevel.Minimal),
        DefaultProperty("StateID")
    ]
    public class StateBag : Control, IScriptControl
    {
        #region Class level variables
        private object _state = null;
        private bool _stateDirty = false;
        private Page _page;
        private ScriptManager _scriptManager;
        #endregion

        #region Overrides
        protected override void OnInit(EventArgs e)
        {
            // disable the viewstate - we don't need it
            this.EnableViewState = false;
            base.OnInit(e);
        }

        protected override void OnPreRender(EventArgs e)
        {
            // Register control as ScriptControl
            this.ScriptManager.RegisterScriptControl(this);

            // Send state to the browser using DataItems collection
            // during async postback
            if (this.ScriptManager.IsInAsyncPostBack)
            {
                string state = GetJsonState();
                if (_stateDirty)
                {
                    this.ScriptManager.RegisterDataItem(this, state, false);
                }
            }
            else
            {
                _stateDirty = true;
            }

            // register onSubmit statement - it will serialize the js object and send back to the server
            string onSubmitHandler = GetOnSubmitStatement();
            if (
                !ContainingPage.ClientScript.IsOnSubmitStatementRegistered(
                    this.GetType(),
                    String.Format("StateBag_{0}", StateID)
                    )
                )
            {
                ContainingPage.ClientScript.RegisterOnSubmitStatement(
                    this.GetType(),
                    String.Format("StateBag_{0}", StateID),
                    onSubmitHandler
                    );
            }

            base.OnPreRender(e);
        }

        protected override void Render(HtmlTextWriter writer)
        {
            // Render control as hidden field with id set to StateID
            // and original UniqueID.
            // If the request was not async, or the page is being requested first time -
            // assign serialized graph to the value property
            if (!DesignMode)
            {
                ContainingPage.VerifyRenderingInServerForm(this);
                writer.AddAttribute(HtmlTextWriterAttribute.Id, StateID);
                writer.AddAttribute(HtmlTextWriterAttribute.Name, UniqueID);
                writer.AddAttribute(HtmlTextWriterAttribute.Value, GetJsonState());

                writer.AddAttribute(HtmlTextWriterAttribute.Type, "hidden");
                writer.RenderBeginTag(HtmlTextWriterTag.Input);
                writer.RenderEndTag();

                // Register descriptors
                ScriptManager.RegisterScriptDescriptors(this);
            }
            else
            {
                writer.AddStyleAttribute(HtmlTextWriterStyle.BorderColor, "black");
                writer.AddStyleAttribute(HtmlTextWriterStyle.BorderWidth, "2px");
                writer.AddStyleAttribute(HtmlTextWriterStyle.BorderStyle, "dashed");
                writer.AddStyleAttribute(HtmlTextWriterStyle.Padding, "5px");
                writer.RenderBeginTag(HtmlTextWriterTag.Div);
                writer.WriteEncodedText(
                        String.Format(
                            "StateBag <{0}> | UpdateMode <{1}>",
                            StateID,
                            UpdateMode
                            )
                        );
                writer.RenderEndTag();
            }
        }
        #endregion

        #region Public Properties
        /// <summary>
        /// StateID is optional property.
        /// Set it to some descriptive name and you will
        /// be able to call corresponding StateBag object
        /// on the client using this syntax: $find("StateID")
        /// If not set - ClientID will be used
        /// </summary>
        [
            DefaultValue(""),
            Browsable(true)
        ]
        public string StateID
        {
            get
            {
                string id = GetPropertyValue("StateID", "");
                return (string.IsNullOrEmpty(id) ? ClientID : id);
            }
            set
            {
                SetPropertyValue("StateID", value);
            }
        }

        /// <summary>
        /// UpdateMode property, can be set to one of two values:
        /// Always - The state is always sent to the server.
        /// OnDemand - Developer needs to call Update() method manually
        /// to send the updated state to the browser (makes sense only during
        /// Partial postback, when normal postback occurs - the state is always rendered)
        /// </summary>
        [
            DefaultValue(StateBagUpdateMode.Always),
            Browsable(true)
        ]
        public StateBagUpdateMode UpdateMode
        {
            get
            {
                return GetPropertyValue("UpdateMode", StateBagUpdateMode.Always);
            }
            set
            {
                SetPropertyValue("UpdateMode", value);
            }
        }

        // Hide this derived property from designer - it is not needed
        [
            Browsable(false),
            DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden),
            EditorBrowsable(EditorBrowsableState.Never)
        ]
        public override bool Visible
        {
            get
            {
                return base.Visible;
            }
            set
            {
                throw new NotImplementedException();
            }
        }
        #endregion

        #region Public Methods
        /// <summary>
        /// Returns state object. Type of expected object should be set in T.
        /// Object is cached after first call during page's lifecycle, so if second time 
        /// the object will be requested with different type, unhandled exception will occur.
        /// </summary>
        /// <typeparam name="T">
        /// Type of object to exchange with client
        /// </typeparam>
        /// <returns>
        /// Instanse on state object of type T
        /// </returns>
        public T GetState<T>() where T : class, new()
        {
            if (_state == null)
            {
                string stateString = GetRequestParamString(UniqueID, "");
                if (String.IsNullOrEmpty(stateString))
                {
                    _state = new T();
                }
                else
                {
                    _state = new JavaScriptSerializer().Deserialize<T>(stateString);
                }
            }
            return _state as T;
        }

        /// <summary>
        /// Call this method only when UpdateMode is set to OnDemand.
        /// Marks current state object to be sent to the client
        /// </summary>
        public void Update()
        {
            if (UpdateMode == StateBagUpdateMode.Always)
            {
                throw new InvalidOperationException("Update can not be called for StateBag control with UpdateMode set to Always");
            }
            UpdateInternal(false);
        }
        #endregion

        #region Internal helper members
        internal string GetOnSubmitStatement()
        {
            return
                String.Format(
                    "$find('{0}')._onSubmitHandler();",
                    StateID
                    );
        }

        /// <summary>
        /// Internal method - serializes current object to string
        /// </summary>
        /// <returns></returns>
        internal string GetJsonState()
        {
            string result = "";
            UpdateInternal(true);
            if (_stateDirty)
            {
                if (null == _state)
                {
                    GetState<object>();
                }
                string serializedObject = "";
                if (null != _state)
                {
                    serializedObject = new JavaScriptSerializer().Serialize(_state);
                }
                result = serializedObject;
            }

            return result;
        }

        internal void UpdateInternal(bool internalCall)
        {
            if ((internalCall && UpdateMode == StateBagUpdateMode.Always) || !internalCall)
            {
                _stateDirty = true;
            }
        }

        internal V GetPropertyValue<V>(string propertyName, V nullValue)
        {
            if (ViewState[propertyName] == null)
            {
                return nullValue;
            }
            return (V)ViewState[propertyName];
        }

        internal void SetPropertyValue<V>(string propertyName, V value)
        {
            ViewState[propertyName] = value;
        }

        /// <summary>
        /// Method is used internally to get JSON string from request
        /// </summary>
        /// <param name="parameterName"></param>
        /// <param name="defaultValue"></param>
        /// <returns></returns>
        internal string GetRequestParamString(string parameterName, string defaultValue)
        {
            HttpContext context = HttpContext.Current;
            string result;
            if (context.Request[parameterName] != null)
            {
                result = context.Request[parameterName];
            }
            else
            {
                result = defaultValue;
            }
            return result;
        }

        internal ScriptManager ScriptManager
        {
            get
            {
                if (_scriptManager == null)
                {
                    Page page = ContainingPage;
                    _scriptManager = ScriptManager.GetCurrent(page);
                    if (_scriptManager == null)
                    {
                        throw new InvalidOperationException(
                            "Script Manager Required On The Page For StateBag Control"
                            );
                    }
                }
                return _scriptManager;
            }
        }

        internal Page ContainingPage
        {
            get
            {
                if (null == _page)
                {
                    Page page = Page;
                    if (null == page)
                    {
                        throw new InvalidOperationException("Page Cannot Be Null");
                    }
                    _page = page;
                }
                return _page;
            }
        }
        #endregion


        #region IScriptControl Members
        IEnumerable<ScriptDescriptor> IScriptControl.GetScriptDescriptors()
        {
            ScriptComponentDescriptor s =
                new ScriptControlDescriptor("Devarchive.Net.StateBag", StateID);
            s.AddProperty("updateMode", (int)UpdateMode);
            s.AddProperty("clientID", ClientID);
            yield return s;
        }

        IEnumerable<ScriptReference> IScriptControl.GetScriptReferences()
        {
            yield return new ScriptReference("Devarchive.Net.StateBag.js", this.GetType().Assembly.FullName);
        }
        #endregion
    }
}

 

 

StateBag.js file listing:

 

/// <reference name="MicrosoftAjax.debug.js" />
/// <reference name="MicrosoftAjaxTimer.debug.js" />
/// <reference name="MicrosoftAjaxWebForms.debug.js" />

// define Devarchive.Net namespace
Type.registerNamespace("Devarchive.Net");

// --------------------------------------------------------------
// StateBag class, derived from Sys.Component
// --------------------------------------------------------------
Devarchive.Net.StateBag = function(element) {
    // init base constructor
    Devarchive.Net.StateBag.initializeBase(this, [element]);
    
    // declare class level variables
    this._state = null;
    this._stateDirty = false;
    this._updateMode = null;
    this._clientID = null;
    this._pageRequestManager = null;
    this._pageLoadingHandlerDelegate = null;
};
Devarchive.Net.StateBag.prototype = {
    
    // --------------------------------
    // Overrides
    // --------------------------------
    
    initialize: function() {
        /// <summary> 
        /// Initializes Sys.Component instance
        /// <summary>
        
        // call base method
        Devarchive.Net.StateBag.callBaseMethod(this, 'initialize');
        
        // deserialize state first time
        this._deserializeState();
        
        // subscribe to pageLoading event to deserialize state every time after async postback
        
        // define delegate for event handler
        this._pageLoadingHandlerDelegate = 
            Function.createDelegate(this,this._pageLoadingHandler);
        
        // get the instance of PageRequestManager object
        if (Sys.WebForms && Sys.WebForms.PageRequestManager){
           this._pageRequestManager = Sys.WebForms.PageRequestManager.getInstance();  
        }
        
        // Subscribe to pageLoading event
        if (this._pageRequestManager !== null ){
            this._pageRequestManager.add_pageLoading(this._pageLoadingHandlerDelegate);
        }

    },
    
    dispose: function() {
        /// <summary>
        /// Dispozes Sys.Component instance
        /// </summary>
        
        // release resources
        this._state = null;
        
        // detach events
        if(this._pageRequestManager !== null){
            this._pageRequestManager.remove_pageLoading(this._pageLoadingHandlerDelegate);
        }
        
        // call base method
        Devarchive.Net.StateBag.callBaseMethod(this, 'dispose');
    },
    
    // --------------------------------
    // Properties
    // --------------------------------

    // state object itself
    get_state : function() {
        if (arguments.length !== 0) throw Error.parameterCount();
        return this._state;
    },
    
    set_state : function(value) {
        var e = Function._validateParams(arguments, [{name: "value", type: Object}]);
        if (e) throw e;
        if (this._state !== value) {
            this._state = value;
            this.raisePropertyChanged('state');
        }
    },
    
    // ClientID of a hiddenField - is needed to get the correct dataItem object (see below)
    get_clientID : function() {
        if (arguments.length !== 0) throw Error.parameterCount();
        return this._clientID;
    },
    
    set_clientID : function(value) {
        var e = Function._validateParams(arguments, [{name: "value", type: String}]);
        if (e) throw e;
        if (this._clientID !== value) {
            this._clientID = value;
            this.raisePropertyChanged('clientID');
        }
    },
    
    // updateMode property - May be set to Always or OnDemand values
    get_updateMode : function() {
        if (arguments.length !== 0) throw Error.parameterCount();
        return this._updateMode;
    },
    
    set_updateMode : function(value) {
        var e = Function._validateParams(arguments, [{name: "value", type: Number}]);
        if (e) throw e;
        if (this._updateMode !== value) {
            this._updateMode = value;
            this.raisePropertyChanged('updateMode');
        }
    },

    // --------------------------------
    // Public methods 
    // --------------------------------
    
    getSerializedString : function() {
        /// <summary>
        /// Returns current state object - serialized into string
        /// </summary>
        /// <returns type="String"></returns>
        return Sys.Serialization.JavaScriptSerializer.serialize(this.get_state());
    },
    
    setSerializedString : function(value) {
        /// <summary>
        /// sets current state object from string passed to this function
        /// </summary>
        this.set_state(Sys.Serialization.JavaScriptSerializer.deserialize(
            value)
            );
    },
    
    update : function() {
        /// <summary>
        /// Marks current state object for sync operation.
        /// on first postback the state will be sent to the server
        /// can be called only when updateMode property is set to OnDemand value (2)
        /// </summary>
        if(this.get_updateMode() == Devarchive.Net.StateBagUpdateMode.Always) {
            throw Error.invalidOperation(
                "Update can not be called for StateBag control when it's"+
                " updateMode property is set to Always");
        }
        this._stateDirty = true;
    },
    
    // --------------------------------
    // Event handlers
    // --------------------------------
    
    _pageLoadingHandler : function(sender, args) {
        /// <summary>
        /// Raised after the response from the server to an asynchronous 
        /// postback is received but before any content on the page is updated.
        /// At this stage we deserialize string if any was sent by server.
        /// </summary>
        var dataItem = args.get_dataItems()[this.get_clientID()];
        if (dataItem){
            this.get_element().value = dataItem;
            this._deserializeState();
          }
    },
    
    _onSubmitHandler : function(sender, args) {
        /// <summary>
        /// Raised before form is submitted.
        /// If needed serialized version of state object is created and
        /// stored in the hidden field
        /// </summary>
        if(
            // check if object was marked to sent to the server an updated version
            this._stateDirty || 
            // or if updateMode is set to Always - send it's updated version everytime
            this.get_updateMode() == Devarchive.Net.StateBagUpdateMode.Always
          ) {
            this._serializeState();
            this._stateDirty = false;
        } else {
            this.get_element().value = "";
        }
    },
    
    _serializeState : function() {
        /// <summary>
        /// Converts a JavaScript object graph into a JSON string
        /// and saves it in the HiddenField element
        /// </summary>
        this.get_element().value = this.getSerializedString();
    },
    
    _deserializeState : function() {
        /// <summary>
        /// Converts a JSON string into a JavaScript object graph
        /// and sets this object to state property
        /// </summary>
        this.setSerializedString(this.get_element().value);
    }
};
Devarchive.Net.StateBag.registerClass('Devarchive.Net.StateBag', Sys.UI.Control);

// --------------------------------------------------------------
// StateBagUpdateMode enumeration
// --------------------------------------------------------------
Devarchive.Net.StateBagUpdateMode = function(){};
Devarchive.Net.StateBagUpdateMode.prototype = 
{
        Always      :   1, 
        OnDemand    :   2
}
Devarchive.Net.StateBagUpdateMode.registerEnum("Devarchive.Net.StateBagUpdateMode", false);

if (typeof(Sys) !== 'undefined') Sys.Application.notifyScriptLoaded();

 

Here I have to stop for a while. I will use this post as a reference to StateBag control in my next posts, where I will show sample code showing how to use this control. Also in next postings I am going to show how to exchange the data between three parts of the page as was shown on the first figure.

Stay tuned !

 

Read Part 2 of the article here: StateBag Control - Simplifying Data Exchange in Heavy AJAX Pages - Sample Source Code Part 1

Read Part 3 of the article here: StateBag Control - Simplifying Data Exchange in Heavy AJAX Pages - Sample Source Code Part 2

 

Technorati Tags:

kick it on DotNetKicks.com

No comments: