Table of Contents
Introduction
Single Page web Applications are very common these days.
In order to simplify the binding between the view (the HTML elements) and the model (the JavaScript object), we can use one of the JavaScript libraries
(e.g. Knockout, Angular,
Backbone, Ember, etc.)
that help us to apply that binding on our page.
Sometimes, we also want to synchronize our web pages with some changes that occur in the server side.
The SignalR library enables invoking client functions from the server side (and vice versa).
That can be very helpful for that purpose but, this isn't the binding I wanted.
Using WPF (with the MVVM pattern),
we can reflect the changes of the business logic models to the view, by changing the corresponding values in the view-model
(and, thanks to WPF binding, the changes are automatically reflected to the UI).
That method works fine for Windows applications but, when dealing with web application, the picture is different.
While in Windows applications, the .NET model (of the client) and the view are in the same side (the client side),
in the web world, the .NET model is in the server side (the web server) and, the view is an HTML page that is presented in the client's web-browser.
Since I wanted to keep going programming as I used to in WPF (bind the view to a .NET view-model), also with web applications,
I thought that it can be good if we'll have the same mechanism, for web applications too. But, let's see what we already have.
We can bind JavaScript objects' properties to HTML elements (using one of the JavaScript libraries that help us to apply that binding).
We can bind .NET objects' properties (dependency properties)
to other .NET objects' properties (using the WPF binding).
So, all what we need to complete the picture is a way to bind client's JavaScript objects to corresponding server's .NET objects.
This article shows how we can implement a mechanism for binding .NET server objects to JavaScript client objects and,
apply it on HTML elements.
Background
This article assumes a familiarity with the C# language, the Javascript language and, the ASP.NET framework.
Since, we implement the dedicate part of our client script using the Knockout library
(due to the generic implementation of the client script, it can be implemented with other libraries too...),
a basic familiarity with that library is recommended too.
How It Works
Handle Server Bindings
Describe the binding mapping
The first step for creating our web-binding mechanism is,
creating a data-structure for describing the mapping between the server object's properties and the client object's properties:
public enum BindingMappingMode
{
None = 0,
OneWay = 1,
OneWayToSource = 2,
TwoWay = 3
}
public class PropertyMapping
{
public PropertyMapping()
{
MappingMode = BindingMappingMode.TwoWay;
}
public string ClientPropertyPath { get; set; }
public string ServerPropertyPath { get; set; }
public bool IsCollection { get; set; }
public BindingMappingMode MappingMode { get; set; }
}
public class BindingMapping
{
public BindingMapping()
{
}
public BindingMapping(PropertyMapping rootPropertyMapping)
{
_rootMapping = rootPropertyMapping;
}
#region RootMapping
private PropertyMapping _rootMapping;
public PropertyMapping RootMapping
{
get { return _rootMapping ?? (_rootMapping = new PropertyMapping()); }
}
#endregion
}
The RootMapping
property holds a mapping between a server object's property and a client object's property.
The value of that property can be sufficient for describing properties with simple types (e.g. string
, int
, etc..) but,
when dealing with more complex types, that have properties of their own, we need a way to describe the mapping of the sub-properties too.
For that purpose, we add the SubPropertiesMapping
and the HasSubPropertiesMapping
properties:
#region SubPropertiesMapping
private List<BindingMapping> _subPropertiesMapping;
public List<BindingMapping> SubPropertiesMapping
{
get { return _subPropertiesMapping ?? (_subPropertiesMapping = new List<BindingMapping>()); }
}
#endregion
#region HasSubPropertiesMapping
public bool HasSubPropertiesMapping
{
get { return _subPropertiesMapping != null && _subPropertiesMapping.Count != 0; }
}
#endregion
Another case that has to be supported is collections.
For cases of collections of simple types, the value of the RootMapping
property can be sufficient.
But, when dealing with collections of more complex types, we need a way to describe the mapping for the collection's element's type.
For that purpose, we add the CollectionElementMapping
and the HasCollectionElementMapping
properties:
#region CollectionElementMapping
private List<BindingMapping> _collectionElementMapping;
public List<BindingMapping> CollectionElementMapping
{
get { return _collectionElementMapping ?? (_collectionElementMapping = new List<BindingMapping>()); }
}
#endregion
#region HasCollectionElementMapping
public bool HasCollectionElementMapping
{
get { return _collectionElementMapping != null && _collectionElementMapping.Count != 0; }
}
#endregion
Handle properties bindings
So, we have a data-structure for holding the binding mapping. Now, we need mechanism for applying that binding.
Fortunately, we already have a same mechanism built-in in the .NET framework - the WPF Data Binding.
We can use that mechanism for our purpose too.
For handling the binding of a server's object property, we add a DependencyObject
and,
apply WPF binding on a DependencyProperty
of it:
public class PropertyBindingHandler : DependencyObject, IDisposable
{
#region Fields
private BindingsHandler _rootBindingsHandler;
private BindingMapping _mapping;
private object _rootObject;
private Binding _propertyBinding;
#endregion
public PropertyBindingHandler(BindingsHandler rootBindingsHandler, BindingMapping mapping, object rootObject,
string serverPropertyPath, string clientPropertyPath)
{
_rootBindingsHandler = rootBindingsHandler;
_mapping = mapping;
_rootObject = rootObject;
ServerPropertyPath = serverPropertyPath;
ClientPropertyPath = clientPropertyPath;
_propertyBinding = new Binding
{
Source = rootObject,
Path = new PropertyPath(serverPropertyPath),
Mode = BindingMappingModeToBindingMode(mapping.RootMapping.MappingMode)
};
BindingOperations.SetBinding(this, CurrentValueProperty, _propertyBinding);
}
private BindingMode BindingMappingModeToBindingMode(BindingMappingMode bmm)
{
if (bmm == BindingMappingMode.TwoWay)
{
return BindingMode.TwoWay;
}
if (bmm == BindingMappingMode.OneWayToSource)
{
return BindingMode.OneWayToSource;
}
return BindingMode.OneWay;
}
public string ClientPropertyPath { get; set; }
public string ServerPropertyPath { get; set; }
#region CurrentValue
protected object CurrentValue
{
get { return GetValue(CurrentValueProperty); }
set { SetValue(CurrentValueProperty, value); }
}
public static readonly DependencyProperty CurrentValueProperty =
DependencyProperty.Register("CurrentValue", typeof(object),
typeof(PropertyBindingHandler), new UIPropertyMetadata(null));
#endregion
#region IDisposable implementation
public void Dispose()
{
BindingOperations.ClearBinding(this, PropertyBindingHandler.CurrentValueProperty);
}
#endregion
}
In that way, we have our server's object property bound to the CurrentValue
property.
In addition to that, in order to be notified when the server's object property is changed, we raise an event for every change of the CurrentValue
property:
public class ValueChangedEventArgs : EventArgs
{
public object OldValue { get; set; }
public object NewValue { get; set; }
}
public class PropertyBindingHandler : DependencyObject, IDisposable
{
public static readonly DependencyProperty CurrentValueProperty =
DependencyProperty.Register("CurrentValue", typeof(object),
typeof(PropertyBindingHandler), new UIPropertyMetadata(null, OnCurrentValueChanged));
private static void OnCurrentValueChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
PropertyBindingHandler pbh = o as PropertyBindingHandler;
if (null == pbh)
{
return;
}
EventHandler<ValueChangedEventArgs> handler = pbh.CurrentValueChanged;
if (null != handler)
{
ValueChangedEventArgs args = new ValueChangedEventArgs
{
OldValue = e.OldValue,
NewValue = e.NewValue
};
handler(pbh, args);
}
}
public event EventHandler<ValueChangedEventArgs> CurrentValueChanged;
}
Later in this article, we'll see how we can use the CurrentValue
property, for applying that binding to a client's (JavaScript) object property.
Handle objects bindings
For now, we have a class for handling the binding for a single property.
The next step is to create a class for handling the binding for an entire object:
public class BindingsHandler : DependencyObject, IDisposable
{
#region Fields
private readonly Dictionary<string, PropertyBindingHandler> _propertiesHandlers;
#endregion
public BindingsHandler(object serverObject, BindingMapping bm)
{
_propertiesHandlers = new Dictionary<string, PropertyBindingHandler>();
Locker = new object();
InitializePropertiesHandlers(serverObject, bm);
}
#region Properties
public string BindingId { get; set; }
public object Locker { get; private set; }
#endregion
#region InitializePropertiesHandlers
private void InitializePropertiesHandlers(object serverObject, BindingMapping bm)
{
AddPropertiesHandlers(serverObject, bm, string.Empty, string.Empty);
}
public void AddPropertiesHandlers(object serverObject, BindingMapping bm,
string baseServerPropertyPath, string baseClientPropertyPath)
{
if (bm.RootMapping.MappingMode == BindingMappingMode.None)
{
return;
}
string serverPropertyPath =
string.IsNullOrEmpty(baseServerPropertyPath)
? bm.RootMapping.ServerPropertyPath
: string.IsNullOrEmpty(bm.RootMapping.ServerPropertyPath)
? baseServerPropertyPath
: string.Format("{0}.{1}", baseServerPropertyPath, bm.RootMapping.ServerPropertyPath);
string clientPropertyPath =
string.IsNullOrEmpty(baseClientPropertyPath)
? bm.RootMapping.ClientPropertyPath
: string.IsNullOrEmpty(bm.RootMapping.ClientPropertyPath)
? baseClientPropertyPath
: string.Format("{0}.{1}", baseClientPropertyPath, bm.RootMapping.ClientPropertyPath);
if (bm.HasSubPropertiesMapping)
{
foreach (BindingMapping subPropertyMapping in bm.SubPropertiesMapping)
{
AddPropertiesHandlers(serverObject, subPropertyMapping, serverPropertyPath, clientPropertyPath);
}
}
else
{
PropertyBindingHandler pbh = null;
if (CheckAccess())
{
pbh = new PropertyBindingHandler(this, bm, serverObject, serverPropertyPath, clientPropertyPath);
}
else
{
Dispatcher.Invoke(
new Action(
() =>
pbh =
new PropertyBindingHandler(this, bm, serverObject, serverPropertyPath, clientPropertyPath)));
}
pbh.CurrentValueChanged += OnPropertyCurrentValueChanged;
PropertyBindingHandler oldHandler = null;
lock (Locker)
{
if (_propertiesHandlers.ContainsKey(clientPropertyPath))
{
oldHandler = _propertiesHandlers[clientPropertyPath];
_propertiesHandlers.Remove(clientPropertyPath);
}
_propertiesHandlers.Add(clientPropertyPath, pbh);
}
if (oldHandler != null)
{
oldHandler.Dispose();
}
}
}
void OnPropertyCurrentValueChanged(object source, ValueChangedEventArgs e)
{
EventHandler<PropertyValueChangedEventArgs> handler = PropertyValueChanged;
if (handler != null)
{
PropertyBindingHandler pbh = source as PropertyBindingHandler;
PropertyValueChangedEventArgs arg =
new PropertyValueChangedEventArgs
{
NewValue = e.NewValue,
OldValue = e.OldValue,
ServerPropertyPath = pbh != null ? pbh.ServerPropertyPath : string.Empty,
ClientPropertyPath = pbh != null ? pbh.ClientPropertyPath : string.Empty
};
handler(this, arg);
}
}
#endregion
public event EventHandler<PropertyValueChangedEventArgs> PropertyValueChanged;
#region IDisposable implementation
public void Dispose()
{
List<PropertyBindingHandler> propHandlers;
lock (Locker)
{
propHandlers = _propertiesHandlers.Select(p => p.Value).ToList();
_propertiesHandlers.Clear();
}
propHandlers.ForEach(p => p.Dispose());
}
#endregion
}
public class PropertyValueChangedEventArgs : ValueChangedEventArgs
{
public string ServerPropertyPath { get; set; }
public string ClientPropertyPath { get; set; }
}
In that class, we have a Dictionary
that holds the property binding handler, for each client property (_propertiesHandlers
).
After we create that Dictionary
, we initialize it (in the InitializePropertiesHandlers
method),
according to the given BindingMapping
.
Note that in order to synchronize the whole of the access to the bound dependency property to one thread,
the PropertyBindingHandler
instances are created using the Dispatcher
's thread.
This will be discussed in more detail in the next section.
Handle pages bindings
The dispatcher thread
As mentioned in the previous section, we use a Dispatcher
for synchronizing some of our operations.
This topic is more related to WPF than the ASP.NET but, since this is a major part of our binding mechanism,
I think that it is worth some words.
One of the issues that we have to deal with, when working with dependency properties
(in our case, the PropertyBindingHandler.CurrentValue
property),
is that they can be used only from the thread that has created the dependency-object that contains them (a.k.a "the owner thread").
For solving this issue, every DispatcherObject
(base class of DependencyObject
)
has a Dispatcher
property that holds the dispatcher for the thread that has created the object.
Using that dispatcher, we can invoke actions (on the appropriate thread) with dependency propeperties, also from threads that don't own the object.
In order to apply that behavior,
we create a singleton class that maintains a dispatcher thread for our operations:
public class BinderContext : IDisposable
{
#region Singleton implementation
private BinderContext()
{
InitializeBinderDispatcher();
}
private readonly static BinderContext _instance = new BinderContext();
public static BinderContext Instance { get { return _instance; } }
#endregion
#region Binder Dispatcher
private System.Windows.Threading.Dispatcher _binderDispatcher;
private Thread _binderDispatcherThread;
private void InitializeBinderDispatcher()
{
AutoResetEvent threadCreateEvent = new AutoResetEvent(false);
_binderDispatcherThread =
new Thread(() =>
{
_binderDispatcher = System.Windows.Threading.Dispatcher.CurrentDispatcher;
threadCreateEvent.Set();
System.Windows.Threading.Dispatcher.Run();
});
_binderDispatcherThread.Start();
threadCreateEvent.WaitOne();
}
private void ShutdownBinderDispatcher()
{
if (_binderDispatcherThread != null && _binderDispatcher != null)
{
_binderDispatcher.InvokeShutdown();
_binderDispatcherThread.Join();
_binderDispatcher = null;
_binderDispatcherThread = null;
}
}
public void Invoke(Action a)
{
if (_binderDispatcher != null)
{
_binderDispatcher.Invoke(a);
}
}
public void BeginInvoke(Action a)
{
if (_binderDispatcher != null)
{
_binderDispatcher.BeginInvoke(a);
}
}
#endregion
#region IDisposable implementation
public void Dispose()
{
ShutdownBinderDispatcher();
}
#endregion
}
In that class, we run a thread that manages our dispatcher loop and,
expose methods for invoking action on our dispatcher thread synchronously (Invoke
) and asynchronously (BeginInvoke
).
Register pages bindings
In order to register bindings for our pages, we:
- Add a
Dictionary
for holding the bindings handlers of the pages:
private readonly Dictionary<string, Dictionary<string, BindingsHandler>> _pagesBindings;
- Add a method for registering a binding for a page:
public object Locker { get; private set; }
public bool RegisterBinding(string pageId, string bindingId,
object serverObject, BindingMapping objectBindingMapping, bool overrideIfExist)
{
if (string.IsNullOrEmpty(pageId) ||
string.IsNullOrEmpty(bindingId) ||
null == serverObject ||
null == objectBindingMapping)
{
return false;
}
BindingsHandler bh = null;
Invoke(() => bh = new BindingsHandler(serverObject, objectBindingMapping));
bh.BindingId = bindingId;
lock (Locker)
{
_pagesLastActionTime[pageId] = DateTime.Now;
Dictionary<string, BindingsHandler> pages;
if (_pagesBindings.ContainsKey(pageId))
{
pages = _pagesBindings[pageId];
}
else
{
pages = new Dictionary<string, BindingsHandler>();
_pagesBindings[pageId] = pages;
}
if (!pages.ContainsKey(bindingId) || overrideIfExist)
{
pages[bindingId] = bh;
}
}
return true;
}
Handle Changes Notifications
Client object model
The data structure
Until now, we discussed about the server's object model. Now, let's create the client-side JavaScript code.
The first thing we do is create a script file (BinderClient.js) with an object (constructor function) for handling the binding in the client:
function _WebBindingBinderClient_(id) {
var self = this;
this.pageId = id;
}
To that object, we add objects for storing the needed data:
- An object for storing the bound objects:
var _rootBindingObjects_ = {};
- An object for storing the identifier of the bound object, for each binding:
var _bindingsObjectsIds_ = {};
- An object for storing a creator function for each bound property:
var _objectsCreators_ = {};
- An object for storing the properties' names for each bound property's object (if it isn't of a simple type):
var _objectsPropertiesNames_ = {};
In addition to those objects, we add some functions for getting and setting their data:
function _getBindingObjectId_(_bindingId_) {
return _bindingsObjectsIds_[_bindingId_];
}
function _getBindingObject_(_bindingId_) {
var _rootObjId_ = _getBindingObjectId_(_bindingId_);
var res = _rootObjId_ ? _rootBindingObjects_[_rootObjId_] : null;
return res;
}
function _setBindingObject_(_bindingId_, rootObj) {
var _rootObjId_ = null;
for (var objId in _rootBindingObjects_) {
if (_rootBindingObjects_[objId] == rootObj) {
_rootObjId_ = objId;
}
}
if (!_rootObjId_) {
_rootObjId_ = _bindingId_ + "O";
_rootBindingObjects_[_rootObjId_] = rootObj;
}
_bindingsObjectsIds_[_bindingId_] = _rootObjId_;
return _rootObjId_;
}
function _getBindingObjectsCreators_(_rootObjId_) {
return _objectsCreators_[_rootObjId_];
}
function _setObjectCreator_(_rootObjId_, _propId_, objCreator) {
var bindingObjectsCreators = _retrieveObjectProperty_(_objectsCreators_, _rootObjId_, {});
bindingObjectsCreators[_propId_] = objCreator;
}
function _getObjectCreator_(_rootObjId_, _propId_) {
var res;
var bindingObjectsCreators = _getBindingObjectsCreators_(_rootObjId_);
if (_propId_ && bindingObjectsCreators && bindingObjectsCreators[_propId_]) {
res = bindingObjectsCreators[_propId_];
} else {
res = _createEmptyObject_;
}
return res;
}
function _getObjectPropertiesNames_(_rootObjId_, _propId_) {
var bindingObjectsPropertiesNames = _objectsPropertiesNames_[_rootObjId_];
return bindingObjectsPropertiesNames ? bindingObjectsPropertiesNames[_propId_] : null;
}
function _setObjectPropertiesNames_(_rootObjId_, _propId_, objProperties) {
var bindingObjectsPropertiesNames = _retrieveObjectProperty_(_objectsPropertiesNames_, _rootObjId_, {});
bindingObjectsPropertiesNames[_propId_] = objProperties;
}
function _retrieveObjectProperty_(obj, propName, defualtValue) {
var p = obj[propName];
if (!p) {
p = defualtValue;
obj[propName] = p;
}
return p;
}
Generic implementation
As mentioned before, our solution is for binding server's .NET objects to client's JavaScript objects.
For binding JavaScript objects to HTML DOM elements, we have other JavaScript libraries
(like Knockout, etc..) that can be used.
In order to make our solution compatible with any JavaScript library, we separate the implementation of the dedicate (for a specific library) code.
For that purpose, we add variables for holding the dedicate functions:
- Functions for creating an object holder (a wrapper object for a specific library):
this.createObjectHolder = function() { return {}; };
this.createArrayHolder = function() { return []; };
- Functions for getting or setting an object from an object holder:
this.getObjectValue = function(objHolder) { return {}; };
this.getArrayValue = function(arrHolder) { return []; };
this.setObjectValue = function(objHolder, val) {};
this.setArrayValue = function(arrHolder, val) {};
- Functions for registering for changes notifications:
this.registerForPropertyChanges = function(objHolder, propNotificationFunc) {};
this.registerForArrayChanges = function(arrHolder, arrNotificationFunc) {};
For implementing those functions using the knockout library, we add a script file (KnockoutDedicateImplementation.js) with the dedicate implementation:
function WebBinding_ApplyKnockoutDedicateImplementation(wbObj) {
wbObj.getObjectValue = function (objHolder) {
return objHolder();
};
wbObj.getArrayValue = function (arrHolder) {
return arrHolder();
};
wbObj.setObjectValue = function (objHolder, val) {
objHolder(val);
};
wbObj.setArrayValue = function (arrHolder, val) {
arrHolder(val);
};
wbObj.createObjectHolder = function () {
return ko.observable();
};
wbObj.createArrayHolder = function () {
return ko.observableArray([]);
};
wbObj.registerForPropertyChanges = function (objHolder, propNotificationFunc) {
objHolder.subscribe(function (newValue) {
propNotificationFunc();
});
};
wbObj.registerForArrayChanges = function (arrHolder, arrNotificationFunc) {
arrHolder.subscribe(function (changes) {
arrNotificationFunc();
}, null, "arrayChange");
};
}
This is the only dedicate code that we have to write. The whole of the other implementation is with standard JavaScript only.
Later, we'll see how the things are joined together.
Construct the client model
For creating objects for the bound properties according to the binding-mapping, we add a function that uses the appropriate stored creator function:
function _createObject_(_rootObjId_, _propId_) {
var creatorFunc = _getObjectCreator_(_rootObjId_, _propId_);
var res = creatorFunc();
return res;
}
For setting the appropriate creator functions, we add:
- A function for setting a creator function for a non-array property:
function _addCreatorForObjectId_(_rootObjId_, _propId_, objPropNames) {
var initialObjectCreator = _getObjectCreator_(_rootObjId_, _propId_);
if (objPropNames.length > 0) {
var objCreator = function () {
var objHolder = initialObjectCreator();
var obj = self.getObjectValue(objHolder);
for (var propInx = 0; propInx < objPropNames.length; propInx++) {
var currPropName = objPropNames[propInx];
var currPropId = _propId_ + "." + currPropName;
obj[currPropName] = _createObject_(_rootObjId_, currPropId);
}
return objHolder;
};
_setObjectCreator_(_rootObjId_, _propId_, objCreator);
} else {
_setObjectCreator_(_rootObjId_, _propId_, initialObjectCreator);
}
}
- A function for setting a creator function for an array property:
function _addCreatorsForArrayId_(_rootObjId_, _propId_, elementPropNames, isArrayOfArray) {
_setObjectCreator_(_rootObjId_, _propId_, _createEmptyArray_);
if (!isArrayOfArray) {
var arrayElementId = _propId_ + "[]";
_addCreatorForObjectId_(_rootObjId_, arrayElementId, elementPropNames);
}
}
function _createEmptyArray_() {
var arrHolder = self.createArrayHolder();
self.setArrayValue(arrHolder, []);
return arrHolder;
}
For setting the appropriate data for each binding-mapping, we:
- Add a method for generating a client object string from a
BindingMapping
:
public string ToClientBindingMappingObjectString()
{
StringBuilder sb = new StringBuilder();
AppendClientBindingMappingObjectString(sb);
return sb.ToString();
}
private void AppendClientBindingMappingObjectString(StringBuilder sb)
{
string clientPropPath = RootMapping.ClientPropertyPath ?? string.Empty;
string[] clientPathParts = clientPropPath.Split('.');
if (clientPropPath.Length > 1)
{
bool isLowerPart = true;
BindingMapping separatedMapping = null;
for (int partInx = clientPathParts.Length - 1; partInx >= 0; partInx--)
{
BindingMapping currMapping;
if (isLowerPart)
{
currMapping = FromPropertyMapping(clientPathParts[partInx], RootMapping.ServerPropertyPath,
RootMapping.MappingMode);
currMapping.RootMapping.IsCollection = RootMapping.IsCollection;
if (HasSubPropertiesMapping)
{
SubPropertiesMapping.ForEach(p => currMapping.SubPropertiesMapping.Add(p));
}
if (HasCollectionElementMapping)
{
CollectionElementMapping.ForEach(p => currMapping.CollectionElementMapping.Add(p));
}
isLowerPart = false;
}
else
{
currMapping = FromPropertyMapping(clientPathParts[partInx], string.Empty, RootMapping.MappingMode);
currMapping.SubPropertiesMapping.Add(separatedMapping);
}
separatedMapping = currMapping;
}
if (separatedMapping != null)
{
separatedMapping.AppendSimpleClientBindingMappingObjectString(sb);
}
}
else
{
AppendSimpleClientBindingMappingObjectString(sb);
}
}
private void AppendSimpleClientBindingMappingObjectString(StringBuilder sb)
{
sb.Append("{PP:\"");
sb.Append(RootMapping.ClientPropertyPath);
sb.Append("\",SPM:[");
if (HasSubPropertiesMapping)
{
bool isFirstSubProperty = true;
foreach (BindingMapping subPropertyMapping in SubPropertiesMapping)
{
if (isFirstSubProperty)
{
isFirstSubProperty = false;
}
else
{
sb.Append(',');
}
subPropertyMapping.AppendClientBindingMappingObjectString(sb);
}
}
sb.Append("],CEM:[");
if (HasCollectionElementMapping)
{
bool isFirstElementProperty = true;
foreach (BindingMapping elemMapping in CollectionElementMapping)
{
if (isFirstElementProperty)
{
isFirstElementProperty = false;
}
else
{
sb.Append(',');
}
elemMapping.AppendClientBindingMappingObjectString(sb);
}
}
sb.Append("],IC:");
sb.Append(RootMapping.IsCollection ? "true" : "false");
sb.Append('}');
}
public static BindingMapping FromPropertyMapping(string clientPropertyName, string serverPropertyPath,
BindingMappingMode mappingMode = BindingMappingMode.TwoWay)
{
BindingMapping bm = new BindingMapping();
bm.RootMapping.ClientPropertyPath = clientPropertyName;
bm.RootMapping.ServerPropertyPath = serverPropertyPath;
bm.RootMapping.MappingMode = mappingMode;
return bm;
}
- Add a function for updating the client model, according to a client object string:
this.addBindingMapping = function (_bindingId_, rootObj, bindingMappingObj) {
var _rootObjId_ = _setBindingObject_(_bindingId_, rootObj);
_addObjectsCreatorsAndProperties_(_rootObjId_, bindingMappingObj);
};
function _addObjectsCreatorsAndProperties_(_rootObjId_, bindingMappingObj) {
_addSubObjectsCreatorsAndProperties_(_rootObjId_, "", bindingMappingObj);
}
function _addSubObjectsCreatorsAndProperties_(_rootObjId_, basePropId, subBindingMappingObj) {
var _propId_ = basePropId;
if (_propId_ != "" && subBindingMappingObj.PP != "") {
_propId_ += ".";
}
_propId_ += subBindingMappingObj.PP;
if (subBindingMappingObj.IC) {
var colElemMapping = subBindingMappingObj.CEM;
var isArrayOfArray = colElemMapping.length == 1 && colElemMapping[0].IC;
var elemPropNames = [];
for (var elemPropInx = 0; elemPropInx < colElemMapping.length; elemPropInx++) {
var currElemMapping = colElemMapping[elemPropInx];
var currElemProp = currElemMapping.PP;
if (currElemProp != "") {
elemPropNames.push(currElemProp);
}
_addSubObjectsCreatorsAndProperties_(_rootObjId_, _propId_ + "[]", currElemMapping);
}
_addCreatorsForArrayId_(_rootObjId_, _propId_, elemPropNames, isArrayOfArray);
_addObjectPropertiesNames_(_rootObjId_, _propId_ + "[]", elemPropNames);
} else {
var subPropMapping = subBindingMappingObj.SPM;
var subPropNames = [];
for (var subPropInx = 0; subPropInx < subPropMapping.length; subPropInx++) {
var currSubMapping = subPropMapping[subPropInx];
var currSubProp = currSubMapping.PP;
if (currSubProp != "") {
subPropNames.push(currSubProp);
}
_addSubObjectsCreatorsAndProperties_(_rootObjId_, _propId_, currSubMapping);
}
_addCreatorForObjectId_(_rootObjId_, _propId_, subPropNames);
_addObjectPropertiesNames_(_rootObjId_, _propId_, subPropNames);
}
}
function _addObjectPropertiesNames_(_rootObjId_, _propId_, subPropNames) {
var currPropNames = _getObjectPropertiesNames_(_rootObjId_, _propId_);
if (currPropNames && currPropNames.length > 0) {
for (var newPropInx = 0; newPropInx < subPropNames.length; newPropInx++) {
var newPropName = subPropNames[newPropInx];
var isPropAlreadyExist = false;
for (var oldPropInx = 0; oldPropInx < currPropNames.length; oldPropInx++) {
if (newPropName == currPropNames[oldPropInx]) {
isPropAlreadyExist = true;
}
}
if (!isPropAlreadyExist) {
currPropNames.push(newPropName);
}
}
} else {
_setObjectPropertiesNames_(_rootObjId_, _propId_, subPropNames);
}
}
Handle data requests
For sending requests to the server from the client, we add a function that sends an
AJAX POST request:
function _sendAjaxPost_(postData, handleResponseFunc) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function () {
if (xmlhttp.readyState == 4) {
if (handleResponseFunc) {
handleResponseFunc(xmlhttp);
}
}
};
var url = "/webBindingHandler";
xmlhttp.open("POST", url, true);
xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xmlhttp.send(postData);
}
For routing the binder client requests via the ASP.NET Routing,
to be processed by a custom HTTP handler,
we create an HTTP handler for handling the binder requests and an HTTP router for routing to that HTTP handler:
internal class BinderHttpHandler : IHttpHandler
{
#region IHttpHandler implementation
public bool IsReusable
{
get { return false; }
}
public void ProcessRequest(HttpContext context)
{
}
#endregion
}
internal class BinderRouteHandler : IRouteHandler
{
public System.Web.IHttpHandler GetHttpHandler(RequestContext requestContext)
{
return new BinderHttpHandler();
}
}
After we created the HTTP router, we have to register our routing.
We can do that by adding a new Route
to the RouteTable
.
We can achieve that goal by adding the registration code to the Application_Start
method in the Global.asax.cs file
(or other place in the application's startup). Using that way, we have to add our registration code for any application that uses our technology.
In our case, in order to save us from that task, we take a different approach and, register it automatically at the first time we register a page's binding:
public class BinderContext : IDisposable
{
private static bool _isBinderHandlerRegistered = false;
public bool RegisterBinding(string pageId, string bindingId,
object serverObject, BindingMapping objectBindingMapping, bool overrideIfExist)
{
if (!_isBinderHandlerRegistered)
{
var binderRoute = new Route("webBindingHandler", new BinderRouteHandler());
RouteTable.Routes.Insert(0, binderRoute);
_isBinderHandlerRegistered = true;
}
}
}
Later, we'll implement our HTTP handler to process our binder client requests.
Notify about client properties changes
Send notification on properties changes
In order to notify the server about properties' changes in the client side,
we have to send a notification message that contains the relevant changes. That can be done as follows:
var _pendingChangedPropertiesPathes_ = {};
function _sendPropertiesChangeNotification_() {
var propertiesNotification = "[";
var hasNotifications = false;
var isFirstBindingId = true;
for (var _bindingId_ in _pendingChangedPropertiesPathes_) {
var pendingProperties = _pendingChangedPropertiesPathes_[_bindingId_];
if (pendingProperties && pendingProperties.length > 0) {
if (isFirstBindingId) {
isFirstBindingId = false;
} else {
propertiesNotification += ",";
}
hasNotifications = true;
propertiesNotification += '{"BindingId":"' + _bindingId_ + '","PropertiesNotifications":[';
var isFirstPropPath = true;
while (pendingProperties.length > 0) {
if (isFirstPropPath) {
isFirstPropPath = false;
} else {
propertiesNotification += ",";
}
var _propPath_ = pendingProperties.pop();
propertiesNotification += _createPropertyPathNotification_(_bindingId_, _propPath_);
}
propertiesNotification += ']}';
}
}
propertiesNotification += "]";
if (hasNotifications) {
var postData = "requestId=propertiesChangedNotification" +
"&pageId=" + self.pageId + "&propertiesNotification=" + propertiesNotification;
_sendAjaxPost_(postData, function (xmlhttp) { });
}
}
function _createPropertyPathNotification_(_bindingId_, _propPath_) {
var objHolder = _getPropertyObject_(_bindingId_, _propPath_);
var propVal = objHolder ? self.getObjectValue(objHolder) : null;
var res = '{"PropertyPath":"' + _propPath_ + '", "NewValue":"' + propVal + '"}';
return res;
}
In the _createPropertyPathNotification_
function,
we build a JSON string that contains the property's property-path and the property's new value.
In the _sendPropertiesChangeNotification_
function,
we build a notification message that contains the whole of the pending (changed but, a notification hasn't been sent yet) properties' changes and,
send an AJAX request with that message.
Handle special characters
As some of you probably have noticed, we send our AJAX requests' content, using the url format.
Using the url format, there are some characters that have a special meaning (like &
, %
, etc...).
In order to send those characters as a plain text, they should be encoded.
In addition to that, since we use the JSON format, some characters (like \
, "
, etc...) have to be changed too.
Therefore, in order to send the properties' values properly, some of their characters have to be encoded. That can be done as follows:
function _NoteReplacement_(orgNote, replacementString) {
this.orgNote = orgNote;
this.replacementString = replacementString;
}
var _notesReplacementsForSend_ = [];
_notesReplacementsForSend_.push(new _NoteReplacement_(' ', '%20'));
_notesReplacementsForSend_.push(new _NoteReplacement_('`', '%60'));
_notesReplacementsForSend_.push(new _NoteReplacement_('~', '%7E'));
_notesReplacementsForSend_.push(new _NoteReplacement_('!', '%21'));
_notesReplacementsForSend_.push(new _NoteReplacement_('@', '%40'));
_notesReplacementsForSend_.push(new _NoteReplacement_('#', '%23'));
_notesReplacementsForSend_.push(new _NoteReplacement_('$', '%24'));
_notesReplacementsForSend_.push(new _NoteReplacement_('%', '%25'));
_notesReplacementsForSend_.push(new _NoteReplacement_('^', '%5E'));
_notesReplacementsForSend_.push(new _NoteReplacement_('&', '%2F%2D%26%2D%2F'));
_notesReplacementsForSend_.push(new _NoteReplacement_('*', '%2A'));
_notesReplacementsForSend_.push(new _NoteReplacement_('(', '%28'));
_notesReplacementsForSend_.push(new _NoteReplacement_(')', '%29'));
_notesReplacementsForSend_.push(new _NoteReplacement_('_', '%5F'));
_notesReplacementsForSend_.push(new _NoteReplacement_('-', '%2D'));
_notesReplacementsForSend_.push(new _NoteReplacement_('+', '%2B'));
_notesReplacementsForSend_.push(new _NoteReplacement_('=', '%3D'));
_notesReplacementsForSend_.push(new _NoteReplacement_('[', '%5B'));
_notesReplacementsForSend_.push(new _NoteReplacement_(']', '%5D'));
_notesReplacementsForSend_.push(new _NoteReplacement_('{', '%7B'));
_notesReplacementsForSend_.push(new _NoteReplacement_('}', '%7D'));
_notesReplacementsForSend_.push(new _NoteReplacement_('|', '%7C'));
_notesReplacementsForSend_.push(new _NoteReplacement_('\\', '%5C%5C'));
_notesReplacementsForSend_.push(new _NoteReplacement_('"', '%5C%22'));
_notesReplacementsForSend_.push(new _NoteReplacement_('\'', '%27'));
_notesReplacementsForSend_.push(new _NoteReplacement_(':', '%3A'));
_notesReplacementsForSend_.push(new _NoteReplacement_(';', '%3B'));
_notesReplacementsForSend_.push(new _NoteReplacement_('?', '%3F'));
_notesReplacementsForSend_.push(new _NoteReplacement_('/', '%2F'));
_notesReplacementsForSend_.push(new _NoteReplacement_('>', '%3E'));
_notesReplacementsForSend_.push(new _NoteReplacement_('.', '%2E'));
_notesReplacementsForSend_.push(new _NoteReplacement_('<', '%3C'));
_notesReplacementsForSend_.push(new _NoteReplacement_(',', '%2C'));
function _getFixedValueForSend_(orgVal) {
var orgValStr = orgVal ? orgVal.toString() : "";
if (orgValStr === '[object Object]' && orgVal !== '[object Object]') {
orgValStr = "";
}
var newValStr = "";
for (var noteInx = 0; noteInx < orgValStr.length; noteInx++) {
var currNote = orgValStr.charAt(noteInx);
var isReplaced = false;
for (var replacementInx = 0; replacementInx < _notesReplacementsForSend_.length && !isReplaced; replacementInx++) {
var currReplacement = _notesReplacementsForSend_[replacementInx];
if (currNote == currReplacement.orgNote) {
newValStr += currReplacement.replacementString;
isReplaced = true;
}
}
if (!isReplaced) {
newValStr += currNote;
}
}
return newValStr;
}
function _createPropertyPathNotification_(_bindingId_, _propPath_) {
var objHolder = _getPropertyObject_(_bindingId_, _propPath_);
var propVal = objHolder ? self.getObjectValue(objHolder) : null;
var fixedVal = _getFixedValueForSend_(propVal);
var res = '{"PropertyPath":"' + _propPath_ + '", "NewValue":"' + fixedVal + '"}';
return res;
}
The _notesReplacementsForSend_
object contains the special characters and the replacement strings for them.
For the '&'
note, we set a wrapped string ('/-&-/'
) - we convert this string
back in the server side.
In the _getFixedValueForSend_
function, we convert a given value to an encoded string
.
In the _createPropertyPathNotification_
function, we use the _getFixedValueForSend_
function for converting the property's value.
Process properties changes request
As mentioned before, we have a bound dependency property, for each server's property
(the PropertyBindingHandler.CurrentValue
property).
So, in order to apply a new value for a server's property, we can just change its bound dependency property:
public class PropertyBindingHandler : DependencyObject, IDisposable
{
public void SetCurrentValue(object value)
{
object currValue = GetCurrentValue();
if (currValue == value)
{
return;
}
if (CheckAccess())
{
CurrentValue = value;
}
else
{
Dispatcher.Invoke(new Action(() => CurrentValue = value));
}
}
}
public class BindingsHandler : DependencyObject, IDisposable
{
public void SetCurrentValue(string clientPropertyPath, object value)
{
PropertyBindingHandler pbh = GetPropertyBindingHandler(clientPropertyPath);
if (pbh != null)
{
pbh.SetCurrentValue(value);
}
}
public PropertyBindingHandler GetPropertyBindingHandler(string clientPropertyPath)
{
PropertyBindingHandler res = null;
lock (Locker)
{
if (_propertiesHandlers.ContainsKey(clientPropertyPath))
{
res = _propertiesHandlers[clientPropertyPath];
}
}
return res;
}
}
Since a dependency property can be changed only from its owner thread,
we use the Dispatcher
for setting its value.
For getting the request's content, we create an equivalent data-structure
(the same structure as the JSON string
of the propertiesNotification
request's field):
public class PropertiesValuesNotificationData
{
public string BindingId { get; set; }
public List<PropertyValueData> PropertiesNotifications { get; set; }
}
public class PropertyValueData
{
public string PropertyPath { get; set; }
#region NewValue
private string _newValue;
public string NewValue
{
get { return _newValue; }
set
{
_newValue = Regex.Replace(value, "/-&-/", "&");
}
}
#endregion
}
Using that data-structure, we can handle the properties-notification request to apply the new properties' values, as follows:
internal class BinderHttpHandler : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
string requestId = context.Request.Params["requestId"];
switch (requestId)
{
case "propertiesChangedNotification":
{
HandleClientPropertiesChangedNotificationRequest(context);
break;
}
}
}
protected bool HandleClientPropertiesChangedNotificationRequest(HttpContext context)
{
string pageId = context.Request.Params["pageId"];
string propertiesNotificationStr = context.Request.Params["propertiesNotification"];
JavaScriptSerializer jss = new JavaScriptSerializer();
PropertiesValuesNotificationData[] propertiesNotification =
jss.Deserialize<PropertiesValuesNotificationData[]>(propertiesNotificationStr);
BinderContext.Instance.PostPropertiesUpdate(pageId, propertiesNotification);
return true;
}
}
public class BinderContext : IDisposable
{
public BindingsHandler GetBindingsHandler(string pageId, string bindingId)
{
if (string.IsNullOrEmpty(pageId) ||
string.IsNullOrEmpty(bindingId))
{
return null;
}
BindingsHandler res = null;
lock (Locker)
{
if (_pagesBindings.ContainsKey(pageId))
{
Dictionary<string, BindingsHandler> pages =
_pagesBindings[pageId];
if (pages.ContainsKey(bindingId))
{
res = pages[bindingId];
}
}
}
return res;
}
public void UpdateProperties(string pageId, PropertiesValuesNotificationData[] propertiesNotification)
{
foreach (PropertiesValuesNotificationData pn in propertiesNotification)
{
BindingsHandler bh = GetBindingsHandler(pageId, pn.BindingId);
if (bh != null)
{
foreach (PropertyValueData pv in pn.PropertiesNotifications)
{
bh.SetCurrentValue(pv.PropertyPath, pv.NewValue);
}
}
}
}
public void PostPropertiesUpdate(string pageId, PropertiesValuesNotificationData[] propertiesNotification)
{
BeginInvoke(() => UpdateProperties(pageId, propertiesNotification));
}
}
In the UpdateProperties
method, we get the appropriate BindingsHandler
for each binding-id and,
set the new values of the changed properties.
In the HandleClientPropertiesChangedNotificationRequest
method,
we create a collection of PropertiesValuesNotificationData
objects from the given request's propertiesNotification
parameter and,
run the UpdateProperties
method asynchronously with it.
In order to prevent cases that the code of a later request will run before the code of an earlier request (due to threads race condition),
we synchronize the whole of the update operations to the dispatcher thread (by using the BeginInvoke
method).
Notify about server properties changes
Request server changes notifications
In order to apply server's properties' changes on the client's properties, we need a notification for every property's change.
We can achieve that goal by sending an Ajax request and, getting the needed notifications in its response.
That can be done as follows:
this.lastChangesResult = "[]";
function _requestServerChanges_() {
var postData = "requestId=propertyChangeRequest" +
"&pageId=" + self.pageId +
"&lastChangesResult=" + self.lastChangesResult;
_sendAjaxPost_(postData, _handleServerChangesResult_);
}
function _handleServerChangesResult_(xmlhttp) {
if (xmlhttp.status == 200) {
_applyServerChangesResponse_(xmlhttp.responseText);
}
setTimeout(function() {
_requestServerChanges_();
}, 0);
}
function _applyServerChangesResponse_(response) {
var parsedResponse = eval("(" + response + ")");
for (var elementInx = 0; elementInx < parsedResponse.length; elementInx++) {
var currElement = parsedResponse[elementInx];
var currProperties = currElement ? currElement.PropertiesValues : null;
if (currProperties) {
for (var propertyResultInx = 0; propertyResultInx < currProperties.length; propertyResultInx++) {
var currPropertyResult = currProperties[propertyResultInx];
_applyServerPropertyChange_(currElement.BindingId, currPropertyResult.SubPropertyPath, currPropertyResult.NewValue);
}
}
}
self.lastChangesResult = response;
}
function _applyServerPropertyChange_(_bindingId_, _propPath_, newValue) {
var propObject = _getPropertyObject_(_bindingId_, _propPath_);
if (propObject) {
self.setObjectValue(propObject, newValue);
}
}
function _getPropertyObject_(_bindingId_, _propPath_) {
var propObject = null;
var currVal = _getBindingObject_(_bindingId_);
var propPathExt = _propPath_;
while (propPathExt.length > 0) {
var firstDotIndex = propPathExt.indexOf(".");
var currPathPart;
if (firstDotIndex > 0) {
currPathPart = propPathExt.substr(0, firstDotIndex);
propPathExt = propPathExt.substr(firstDotIndex + 1);
} else {
currPathPart = propPathExt;
propPathExt = "";
}
propObject = _getSubPropertyObject_(currVal, currPathPart);
currVal = propObject ? self.getObjectValue(propObject) : null;
}
return propObject;
}
function _getSubPropertyObject_(obj, subPropPath) {
var propObject = null;
if (obj && subPropPath && subPropPath.length > 0) {
var propNamePart;
var arrayIndicesPart;
var firstBracketIndex = subPropPath.indexOf("[");
if (firstBracketIndex > 0) {
propNamePart = subPropPath.substr(0, firstBracketIndex);
arrayIndicesPart = subPropPath.substr(firstBracketIndex);
} else {
propNamePart = subPropPath;
arrayIndicesPart = "";
}
propObject = obj[propNamePart];
while (arrayIndicesPart.length > 0) {
var firstCloseBracketIndex = arrayIndicesPart.indexOf("]");
var currIndexStr;
if (firstCloseBracketIndex > 0) {
currIndexStr = arrayIndicesPart.substr(1, firstCloseBracketIndex - 1);
arrayIndicesPart = arrayIndicesPart.substr(firstCloseBracketIndex + 1);
} else {
currIndexStr = arrayIndicesPart.substr(1, arrayIndicesPart.length - 2);
arrayIndicesPart = "";
}
var currIndex = parseInt(currIndexStr);
var arrVal = propObject ? self.getArrayValue(propObject) : null;
if (arrVal && arrVal.length > currIndex) {
propObject = arrVal[currIndex];
} else {
propObject = null;
}
}
}
return propObject;
}
In the _getPropertyObject_
function, we get a property according to a given property-path.
In the _requestServerChanges_
function, we send an AJAX request for server properties changes.
In the _handleServerChangesResult_
we apply the properties changes according to the response and, request for another changes.
In each changes request, we also send the previous changes response. In this way, we can update the server on the client's properties' state.
Get server changes notification
In order to create a notification about the changed values, we have to know which values have been changed (aren't synchronized with the client).
For that purpose, for each property, we add counters for indicating a difference between the client and server
(if the values are different, the properties aren't synchronized):
public uint ChangeCounter { get; set; }
public uint ClientChangeCounter { get; set; }
public bool HasUpdatedValue { get { return ChangeCounter != ClientChangeCounter; } }
Using those counters, we can generate the changes response:
public class ServerPropertyValueRespose
{
public string SubPropertyPath { get; set; }
public string NewValue { get; set; }
public uint ChangeCounter { get; set; }
}
public class PropertyBindingHandler : DependencyObject, IDisposable
{
public ServerPropertyValueRespose GetUpdatedValueResponse()
{
return new ServerPropertyValueRespose
{
SubPropertyPath = ClientPropertyPath,
ChangeCounter = ChangeCounter,
NewValue = GetCurrentValueString()
};
}
public string GetCurrentValueString()
{
object val = GetCurrentValue();
return val != null ? val.ToString() : string.Empty;
}
}
public class BindingsHandler : DependencyObject, IDisposable
{
public List<ServerPropertyValueRespose> GetUpdatedValuesResponses()
{
List<PropertyBindingHandler> changedProperties;
lock (Locker)
{
changedProperties =
_propertiesHandlers.Where(p => p.Value.HasUpdatedValue).OrderBy(p => p.Key).Select(p => p.Value).
ToList();
}
List<ServerPropertyValueRespose> res = changedProperties.Select(p => p.GetUpdatedValueResponse()).ToList();
return res;
}
}
For indicating a property's change, we increase the ChangeCounter
, for each time the property is changed:
private static void OnCurrentValueChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
pbh.ChangeCounter++;
}
Asynchronous HTTP handler
In order to notify the client about server properties' changes, we have to send a response message when there are properties that have been changed.
If there are changes when we get the client's request, we can immediately send those changes back.
But, if there are no changes at that point, we have to find a way for sending the changes back, when they exist.
One way for handling that is, to always send a response (even if it is empty).
That way isn't so good, because it overloads the network with a lot of unnecessary (most of the responses are empty) traffic.
Another way for handing that is to wait synchronously until the changes exist and, then complete the request.
That way isn't so good either, because the request can take a lot of time and suspend other requests.
So, we want to wait until there are properties' changes but, in the meantime, we want to free ASP.NET to handle other requests.
For that purpose, we create an asynchronous HTTP handler:
internal class BinderHttpAsyncHandler : IHttpAsyncHandler
{
public IAsyncResult BeginProcessRequest(HttpContext context, AsyncCallback cb, object extraData)
{
ChangeRequestAsyncResult res = new ChangeRequestAsyncResult(cb, context, extraData);
res.BeginProcess();
return res;
}
public void EndProcessRequest(IAsyncResult result)
{
}
public bool IsReusable
{
get { return false; }
}
public void ProcessRequest(HttpContext context)
{
}
}
internal class ChangeRequestAsyncResult : IAsyncResult
{
private bool _isCompleted;
private bool _isCompletedSynchronously;
private Object _state;
private AsyncCallback _callback;
private HttpContext _context;
public ChangeRequestAsyncResult(AsyncCallback callback, HttpContext context, Object state)
{
_callback = callback;
_context = context;
_state = state;
_isCompleted = false;
_isCompletedSynchronously = false;
}
#region IAsyncResult implementation
public object AsyncState
{
get { return _state; }
}
public System.Threading.WaitHandle AsyncWaitHandle
{
get { return null; }
}
public bool CompletedSynchronously
{
get { return _isCompletedSynchronously; }
}
public bool IsCompleted
{
get { return _isCompleted; }
}
#endregion
public void BeginProcess()
{
}
}
In that asynchronous HTTP handler, we begin an asynchronous operation,
for handling the changes request. This asynchronous operation is implemented as follows:
internal class ChangeRequestAsyncResult : IAsyncResult
{
private List<BindingsHandler> _bindingsHandlers;
public void BeginProcess()
{
ThreadPool.QueueUserWorkItem(o => Process());
}
protected void Process()
{
string pageId = _context.Request.Params["pageId"];
string lastChangesResult = _context.Request.Params["lastChangesResult"];
JavaScriptSerializer jss = new JavaScriptSerializer();
ServerBindingValuesRespose[] lastResponses = jss.Deserialize<ServerBindingValuesRespose[]>(lastChangesResult);
foreach (var sbvr in lastResponses)
{
BindingsHandler bh = BinderContext.Instance.GetBindingsHandler(pageId, sbvr.BindingId);
if (bh != null)
{
bh.UpdateClientCounters(sbvr.PropertiesValues);
}
}
_bindingsHandlers = BinderContext.Instance.GetBindingsHandlers(pageId);
if (_bindingsHandlers.Any(b => b.HasUpdatedValue))
{
Complete();
}
else
{
_bindingsHandlers.ForEach(b => b.PropertyValueChanged += OnPropertyValueChanged);
}
}
protected void OnPropertyValueChanged(object source, PropertyValueChangedEventArgs e)
{
AsyncComplete();
}
private void AsyncComplete()
{
_bindingsHandlers.ForEach(b => b.PropertyValueChanged -= OnPropertyValueChanged);
Complete();
}
protected void Complete()
{
BinderContext.Instance.BeginInvoke(
() =>
{
List<ServerBindingValuesRespose> resList = _bindingsHandlers.Where(b => b.HasUpdatedValue).
Select(b => new ServerBindingValuesRespose
{
BindingId = b.BindingId,
PropertiesValues = b.GetUpdatedValuesResponses()
}).ToList();
ThreadPool.QueueUserWorkItem(
o =>
{
JavaScriptSerializer jss = new JavaScriptSerializer();
string res = jss.Serialize(o);
_context.Response.Write(res);
_isCompleted = true;
_callback(this);
}, resList);
});
}
}
internal class ServerBindingValuesRespose
{
public string BindingId { get; set; }
public List<ServerPropertyValueRespose> PropertiesValues { get; set; }
}
In the Process
method, we update the change-counters with the client's properties state and, complete the request.
If there are properties' changes, we call the Complete
method immediately.
Otherwise, we call it when there is a property change.
In the Complete
method, we get the whole of the properties' changes and, create a response using them.
For routing the changes requests to our asynchronous HTTP handler, we can change our HTTP router as follows:
internal class BinderRouteHandler : IRouteHandler
{
public IHttpHandler GetHttpHandler(RequestContext requestContext)
{
string requestId = requestContext.HttpContext.Request.Params["requestId"];
if (requestId == "propertyChangeRequest")
{
return new BinderHttpAsyncHandler();
}
return new BinderHttpHandler();
}
}
Living with browsers connections limitation
So, we have an asynchronous mechanism for the server side but, what about the client?
As some of you probably noticed, we have one AJAX request, for the whole of the properties' changes of the page.
If we'll perform an AJAX request for each property, since the browser holds an opened socket until the request is completed,
we'll easily exceed the limit of the opened connections of the browser.
So, the problem is solved for a single page.
But, if we'll open many tabs (with pages that hold connections), we'll see that some of the tabs cannot be loaded
(cannot create a connection for processing the request).
For solving that issue, we return a response (and free the browser connection), also after an elapsed timeout:
internal class ChangeRequestAsyncResult : IAsyncResult
{
private System.Timers.Timer _waitIntervalTimer;
protected void Process()
{
_bindingsHandlers = BinderContext.Instance.GetBindingsHandlers(pageId);
if (_bindingsHandlers.Any(b => b.HasUpdatedValue))
{
Complete();
}
else
{
uint waitInterval = BinderContext.Instance.ChangeRequestWaitInterval;
if (waitInterval > 0)
{
_waitIntervalTimer = new System.Timers.Timer();
_waitIntervalTimer.AutoReset = false;
_waitIntervalTimer.Interval = waitInterval;
_waitIntervalTimer.Elapsed += OnWaitIntervalTimerElapsed;
_waitIntervalTimer.Start();
}
_bindingsHandlers.ForEach(b => b.PropertyValueChanged += OnPropertyValueChanged);
}
}
void OnWaitIntervalTimerElapsed(object sender, System.Timers.ElapsedEventArgs e)
{
BinderContext.Instance.Invoke(() =>
{
if (_waitIntervalTimer != null)
{
AsyncComplete();
}
});
}
private void AsyncComplete()
{
if (_waitIntervalTimer != null)
{
_waitIntervalTimer.Elapsed -= OnWaitIntervalTimerElapsed;
_waitIntervalTimer.Close();
_waitIntervalTimer = null;
}
_bindingsHandlers.ForEach(b => b.PropertyValueChanged -= OnPropertyValueChanged);
Complete();
}
}
Notify about client collections changes
Send notification on collections changes
In order to synchronize the server with the client's collections' state,
in addition to sending notifications about collection's elements properties changes,
we have to send notifications also about added and removed elements. At first thought, it seemed like it could be sufficient to send notifications only about the specific changes
(the indices of the removed elements, the indices of the added elements and, the new elements' properties' values).
This approach can work fine, if we have only one client. But, if we have some clients, we can get unexpected results.
Suppose we have a collection with 3 elements and, 2 clients that view a page with that collection.
Now, each client removes the second element of the collection (simultaneously).
Each client expects to see a collection with 2 elements (the first and the third).
But, let's see what really happens. When the clients remove the element, they send a notifications with the element's index.
The server receives the first notification and, removes the second element of the collection.
Then, the server receives the second notification and, again, removes the second element of the collection (which was originally the third element).
Then, the server updates the clients with the new collection (which has only one element...).
As a result, both of the clients show only one element.
For preventing such kind of things, instead of sending notification only about the specific changes,
we send a full snapshot of the new collection:
var _pendingChangedArraysPathes_ = {};
function _sendArraysChangeNotification_() {
var arraysNotification = "[";
var hasNotifications = false;
var isFirstBindingId = true;
for (var _bindingId_ in _pendingChangedArraysPathes_) {
var pendingArrays = _pendingChangedArraysPathes_[_bindingId_];
if (pendingArrays && pendingArrays.length > 0) {
if (isFirstBindingId) {
isFirstBindingId = false;
} else {
arraysNotification += ",";
}
hasNotifications = true;
arraysNotification += '{"BindingId":"' + _bindingId_ + '","CollectionsNotifications":[';
var isFirstArrayPath = true;
while (pendingArrays.length > 0) {
var currArrayPath = pendingArrays.shift();
var arrHolder = _getPropertyObject_(_bindingId_, currArrayPath);
var arrVal = arrHolder ? self.getArrayValue(arrHolder) : null;
if (arrVal) {
if (isFirstArrayPath) {
isFirstArrayPath = false;
} else {
arraysNotification += ",";
}
var currArrCount = arrVal.length;
arraysNotification += '{"PropertyPath":"' + currArrayPath + '","NewCount":' + currArrCount +
',"PropertiesNotifications":[';
var isFirstSubProp = true;
for (var elemInx = 0; elemInx < currArrCount; elemInx++) {
var currElemPath = currArrayPath + "[" + elemInx + "]";
var subPropPathes = _getSubPropertiesPathes_(_bindingId_, currElemPath);
if (subPropPathes.length > 0) {
for (var subPropInx = 0; subPropInx < subPropPathes.length; subPropInx++) {
var currSubPropPath = currElemPath + '.' + subPropPathes[subPropInx];
if (_isArray_(_bindingId_, currSubPropPath)) {
_addPendingChangedArrayPath_(_bindingId_, currSubPropPath);
} else {
if (isFirstSubProp) {
isFirstSubProp = false;
} else {
arraysNotification += ",";
}
arraysNotification += _createPropertyPathNotification_(_bindingId_, currSubPropPath);
}
}
} else {
if (_isArray_(_bindingId_, currElemPath)) {
_addPendingChangedArrayPath_(_bindingId_, currElemPath);
} else {
if (isFirstSubProp) {
isFirstSubProp = false;
} else {
arraysNotification += ",";
}
arraysNotification += _createPropertyPathNotification_(_bindingId_, currElemPath);
}
}
}
arraysNotification += "]}";
}
}
arraysNotification += ']}';
}
}
arraysNotification += "]";
if (hasNotifications) {
var postData = "requestId=arraysChangedNotification" +
"&pageId=" + self.pageId + "&arraysNotification=" + arraysNotification;
_sendAjaxPost_(postData, function (xmlhttp) { });
}
}
function _getSubPropertiesPathes_(_bindingId_, ownerPropPath) {
var _rootObjId_ = _getBindingObjectId_(_bindingId_);
var _propId_ = _removeArrayIndicesNumbers_(ownerPropPath);
var propPathes = [];
var objPropPathes = _getObjectPropertiesNames_(_rootObjId_, _propId_);
if (objPropPathes) {
for (var objPropPathInx = 0; objPropPathInx < objPropPathes.length; objPropPathInx++) {
propPathes.push(objPropPathes[objPropPathInx]);
}
}
var propInx = 0;
while (propInx < propPathes.length) {
var currPropPath = propPathes[propInx];
var subPropPathes = _getObjectPropertiesNames_(_rootObjId_, _propId_ + '.' + currPropPath);
if (subPropPathes && subPropPathes.length > 0) {
propPathes.splice(propInx, 1);
for (var subPropInx = 0; subPropInx < subPropPathes.length; subPropInx++) {
propPathes.push(currPropPath + '.' + subPropPathes[subPropInx]);
}
} else {
propInx++;
}
}
return propPathes;
}
function _isArray_(_bindingId_, _propPath_) {
var _rootObjId_ = _getBindingObjectId_(_bindingId_);
var _propId_ = _removeArrayIndicesNumbers_(_propPath_) + '[]';
var objPropNames = _getObjectPropertiesNames_(_rootObjId_, _propId_);
return objPropNames ? true : false;
}
In the _getSubPropertiesPathes_
function, we get the properties' paths for the collections element.
In the _sendArraysChangeNotification_
function,
we build a notification message that contains snapshots of the changed collections and, send an AJAX request with that message.
Apply new collection's elements count
For updating a collection's count, we can add or remove elements to that collection. That can be done as follows:
public uint CollectionElementsCount { get; private set; }
public void SetCurrentCollectionElementsCount(uint newCount)
{
IList currCollection = GetCurrentValue() as IList;
if (null == currCollection)
{
return;
}
uint currCount = CollectionElementsCount;
if (currCount > newCount)
{
uint countDiff = currCount - newCount;
for (uint removedInx = 0; removedInx < countDiff; removedInx++)
{
currCollection.RemoveAt((int)newCount);
}
}
if (newCount > currCount)
{
Type elementType = GetElementType(currCollection.GetType());
if (elementType != null)
{
uint countDiff = newCount - currCount;
for (uint addedInx = 0; addedInx < countDiff; addedInx++)
{
object addedItem = CreateObject(elementType);
currCollection.Add(addedItem);
}
}
}
}
private object CreateObject(Type t)
{
object res = null;
try
{
res = Activator.CreateInstance(t);
}
catch (Exception ex)
{
ConstructorInfo[] typeConstructors = t.GetConstructors();
foreach (ConstructorInfo ctor in typeConstructors)
{
object[] ctorParametersValues = new object[ctor.GetParameters().Count()];
try
{
res = Activator.CreateInstance(t, ctorParametersValues);
}
catch (Exception ex2)
{
}
if (res != null)
break;
}
}
return res;
}
In addition of adding or removing elements,
we have to add or remove appropriate PropertyBindingHandler
objects for handling the bindings of the appropriate collection's elements.
That can be done as follows:
public void SetCurrentCollectionElementsCount(uint newCount)
{
ApplyNewCollectionCount(newCount);
}
private void ApplyNewCollectionCount(uint newCount)
{
if (CollectionElementsCount != newCount)
{
UpdateCollectionElementsBindings(CollectionElementsCount, newCount);
CollectionElementsCount = newCount;
ChangeCounter++;
}
}
private void UpdateCollectionElementsBindings(uint oldCount, uint newCount)
{
if (oldCount < newCount)
{
for (uint addedInx = oldCount; addedInx < newCount; addedInx++)
{
AddCollectionElementBindings(addedInx);
}
}
else if (oldCount > newCount)
{
for (uint removedInx = newCount; removedInx < oldCount; removedInx++)
{
RemoveCollectionElementBindings(removedInx);
}
}
}
private void AddCollectionElementBindings(uint elementInx)
{
string elemClientPropertyPath = string.Format("{0}[{1}]", ClientPropertyPath, elementInx);
string elemServerPropertyPath = string.Format("{0}[{1}]", ServerPropertyPath, elementInx);
if (_mapping.HasCollectionElementMapping)
{
foreach (BindingMapping elemPropMapping in _mapping.CollectionElementMapping)
{
_rootBindingsHandler.AddPropertiesHandlers(_rootObject, elemPropMapping,
elemServerPropertyPath, elemClientPropertyPath);
}
}
else
{
_rootBindingsHandler.AddPropertiesHandlers(_rootObject,
BindingMapping.FromPropertyMapping(string.Empty, string.Empty, _mapping.RootMapping.MappingMode),
elemServerPropertyPath, elemClientPropertyPath);
}
}
private void RemoveCollectionElementBindings(uint elementInx)
{
string elemClientPropertyPath = string.Format("{0}[{1}]", ClientPropertyPath, elementInx);
_rootBindingsHandler.RemovePropertyBindingHandlersTree(elemClientPropertyPath);
}
Process collections changes request
For getting the request's content, we create an equivalent data-structure
(the same structure as the JSON string
of the arraysNotification
request's field):
public class CollectionsValuesNotificationData
{
public string BindingId { get; set; }
public List<CollectionValuesData> CollectionsNotifications { get; set; }
}
public class CollectionValuesData
{
public string PropertyPath { get; set; }
public uint NewCount { get; set; }
public List<PropertyValueData> PropertiesNotifications { get; set; }
}
Using that data-structure, we can handle the collections-notification request to apply the new collections' values,
in the same manner as we handle the properties changes request:
internal class BinderHttpHandler : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
string requestId = context.Request.Params["requestId"];
switch (requestId)
{
case "arraysChangedNotification":
{
HandleClientArraysChangedNotificationRequest(context);
break;
}
}
}
protected bool HandleClientArraysChangedNotificationRequest(HttpContext context)
{
string pageId = context.Request.Params["pageId"];
string arraysNotificationStr = context.Request.Params["arraysNotification"];
JavaScriptSerializer jss = new JavaScriptSerializer();
CollectionsValuesNotificationData[] arraysNotification =
jss.Deserialize<CollectionsValuesNotificationData[]>(arraysNotificationStr);
BinderContext.Instance.PostCollectionsUpdate(pageId, arraysNotification);
return true;
}
}
public class BinderContext : IDisposable
{
public void UpdateCollections(string pageId, CollectionsValuesNotificationData[] collectionsNotification)
{
foreach (CollectionsValuesNotificationData cnd in collectionsNotification)
{
BindingsHandler bh = GetBindingsHandler(pageId, cnd.BindingId);
if (bh != null)
{
foreach (var cn in cnd.CollectionsNotifications)
{
bh.SetCurrentCollectionElementsCount(cn.PropertyPath, cn.NewCount);
foreach (var pn in cn.PropertiesNotifications)
{
bh.SetCurrentValue(pn.PropertyPath, pn.NewValue);
}
}
}
}
}
public void PostCollectionsUpdate(string pageId, CollectionsValuesNotificationData[] collectionsNotification)
{
BeginInvoke(() => UpdateCollections(pageId, collectionsNotification));
}
}
Notify about server collections changes
In order to create a notification about the changed collections, we have to be notified about the collections' changes.
If the full collection has been replaced (another reference is set),
we can get the notification in the same way we get the notification for other properties
(by implementing the INotifyPropertyChanged
interface in the source object).
But, if the collection itself is changed (elements added or removed), we need another way to get the notification.
For that purpose, we have the INotifyCollectionChanged
interface.
In the case of properties changes, we get the notification automatically by the binding
(WPF binding registers to the INotifyPropertyChanged.PropertyChanged
event).
In the case of collections changes, we have to implement it by ourselves:
public class PropertyBindingHandler : DependencyObject, IDisposable
{
private static void OnCurrentValueChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
if (pbh.IsCollection)
{
INotifyCollectionChanged oldCollection = e.OldValue as INotifyCollectionChanged;
if (oldCollection != null)
{
oldCollection.CollectionChanged -= pbh.OnCollectionChanged;
}
INotifyCollectionChanged newCollection = e.NewValue as INotifyCollectionChanged;
if (newCollection != null)
{
newCollection.CollectionChanged += pbh.OnCollectionChanged;
}
uint elementsCount = pbh.GetEnumerableCount(e.NewValue as IEnumerable);
pbh.ApplyNewCollectionCount(elementsCount);
}
}
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
uint newCollectionCount = GetEnumerableCount(sender as IEnumerable);
uint oldCollectionCount = CollectionElementsCount;
ApplyNewCollectionCount(newCollectionCount);
EventHandler<ValueChangedEventArgs> handler = CurrentValueChanged;
if (null != handler)
{
ValueChangedEventArgs args = new ValueChangedEventArgs
{
OldValue = oldCollectionCount,
NewValue = newCollectionCount
};
if (CheckAccess())
{
handler(this, args);
}
else
{
Dispatcher.BeginInvoke(new Action(() => handler(this, args)));
}
}
}
}
When the value of the CurrentValue
property is changed,
we register to the INotifyCollectionChanged.CollectionChanged
event and, apply the new collection's count.
When the collection is changed, we apply the new collection's count and, raise an event about the property's change.
When getting the updated value response, we set the collection's count instead of current property value (the collection itself):
public bool IsCollection { get { return _mapping.RootMapping.IsCollection; } }
public ServerPropertyValueRespose GetUpdatedValueResponse()
{
return new ServerPropertyValueRespose
{
SubPropertyPath = ClientPropertyPath,
ChangeCounter = ChangeCounter,
NewValue = IsCollection ? CollectionElementsCount.ToString() : GetCurrentValueString(),
IsCollection = IsCollection
};
}
In order to apply the server changes on the client side, we have to change the client-side collection according to the received notification.
That can be done as follows:
function _applyServerChangesResponse_(response) {
for (var propertyResultInx = 0; propertyResultInx < currProperties.length; propertyResultInx++) {
var currPropertyResult = currProperties[propertyResultInx];
if (currPropertyResult.IsCollection) {
_applyServerCollectionChange_(currElement.BindingId,
currPropertyResult.SubPropertyPath, currPropertyResult.NewValue);
} else {
_applyServerPropertyChange_(currElement.BindingId,
currPropertyResult.SubPropertyPath, currPropertyResult.NewValue);
}
}
}
function _applyServerCollectionChange_(_bindingId_, _propPath_, newCount) {
var propObject = _getPropertyObject_(_bindingId_, _propPath_);
if (propObject) {
var arr = self.getArrayValue(propObject);
if (!arr) {
arr = [];
}
var orgCount = arr.length;
var lengthDiffernce = newCount - orgCount;
if (lengthDiffernce > 0) {
for (var elemInx = 0; elemInx < lengthDiffernce; elemInx++) {
arr.push(_createBoundObjectTree_(_bindingId_, _propPath_ + "[" + (orgCount + elemInx) + "]"));
}
self.setArrayValue(propObject, arr);
}
if (lengthDiffernce < 0) {
arr.splice(newCount, Math.abs(lengthDiffernce));
self.setArrayValue(propObject, arr);
}
}
}
function _createBoundObjectTree_(_bindingId_, _propPath_) {
var _rootObjId_ = _getBindingObjectId_(_bindingId_);
var _propId_ = _removeArrayIndicesNumbers_(_propPath_);
var objHolder = _createObject_(_rootObjId_, _propId_);
_registerForChanges_(_bindingId_, objHolder, _propPath_);
return objHolder;
}
In the _applyServerCollectionChange_
function, we add or remove elements according to the new collection's count.
In the _createBoundObjectTree_
function, we create a new object for the collection's element and,
register for changes notifications for that object.
Register for client changes notifications
In order to send notifications about client objects changes, we have to register to that changes. That can be done as follows:
this.applyBindings = function () {
for (var _bindingId_ in _bindingsObjectsIds_) {
var _rootObjId_ = _getBindingObjectId_(_bindingId_);
var objCreators = _getBindingObjectsCreators_(_rootObjId_);
if (objCreators) {
for (var _propId_ in objCreators) {
if (_propId_ != "" &&
_propId_.indexOf(".") < 0 &&
_propId_.indexOf("[") < 0) {
_bindRootPropertyTree_(_bindingId_, _propId_);
}
}
}
}
};
function _bindRootPropertyTree_(_bindingId_, propName) {
var rootObj = _getBindingObject_(_bindingId_);
if (rootObj) {
var objHolder = rootObj[propName];
if (!objHolder) {
var _rootObjId_ = _getBindingObjectId_(_bindingId_);
var _propId_ = _removeArrayIndicesNumbers_(propName);
objHolder = _createObject_(_rootObjId_, _propId_);
rootObj[propName] = objHolder;
}
_registerForChanges_(_bindingId_, objHolder, propName);
}
}
function _registerForChanges_(_bindingId_, objHolder, _propPath_) {
var _rootObjId_ = _getBindingObjectId_(_bindingId_);
var _propId_ = _removeArrayIndicesNumbers_(_propPath_);
var arrayElemId = _propId_ + "[]";
var arrayElemPropNames = _getObjectPropertiesNames_(_rootObjId_, arrayElemId);
if (arrayElemPropNames) {
var arrayNotificationFunc = function () {
_addPendingChangedArrayPath_(_bindingId_, _propPath_);
setTimeout(function () {
_sendArraysChangeNotification_();
}, 0);
};
self.registerForArrayChanges(objHolder, arrayNotificationFunc);
var arrVal = self.getArrayValue(objHolder);
if (arrVal) {
for (var elemInx = 0; elemInx < arrVal.length; elemInx++) {
_registerForChanges_(_bindingId_, arrVal[elemInx], _propPath_ + '[' + elemInx + ']');
}
}
} else {
var objVal = self.getObjectValue(objHolder);
if (objVal) {
var subPropNames = _getObjectPropertiesNames_(_rootObjId_, _propId_);
if (subPropNames && subPropNames.length > 0) {
for (var subPropInx = 0; subPropInx < subPropNames.length; subPropInx++) {
var currSubPropPath = _propPath_;
var currSubPropName = subPropNames[subPropInx];
if (currSubPropPath != "" && currSubPropName != "") {
currSubPropPath += ".";
}
currSubPropPath += currSubPropName;
var propObjHolder = objVal[subPropNames[subPropInx]];
_registerForChanges_(_bindingId_, propObjHolder, currSubPropPath);
}
} else {
var propNotificationFunc = function() {
_addPendingChangedPropertyPath_(_bindingId_, _propPath_);
setTimeout(function() {
_sendPropertiesChangeNotification_();
}, 0);
};
self.registerForPropertyChanges(objHolder, propNotificationFunc);
}
}
}
}
In the applyBindings
function, we go over the whole of the root properties (properties of the binding's root object) and,
call the _bindRootPropertyTree_
function with them.
In the _bindRootPropertyTree_
function, we get the object holder for a property of the binding's root object and,
register for changes notifications for that object.
In the _registerForChanges_
function, we register for changes notifications for the given property-path and its sub-property-pathes.
In order to register for arrays and simple properties changes, we create a function (for notifying about the change) and,
register it using the registerForArrayChanges
and registerForPropertyChanges
functions appropriately.
Since we have a generic implementation, those functions can be implemented differently for different JavaScript libraries.
When a property's value is changed by a server update, we don't have to send this value back to the server.
Since we queue the sending of the notifications for a later execution (using the setTimeout
function),
we can cancel unneeded notifications.
For canceling a property's notification, we can remove its property-path from the pending changes array:
function _applyServerPropertyChange_(_bindingId_, _propPath_, newValue) {
var propObject = _getPropertyObject_(_bindingId_, _propPath_);
if (propObject) {
_removePendingChangedPropertyPath_(_bindingId_, _propPath_);
}
}
function _applyServerCollectionChange_(_bindingId_, _propPath_, newCount) {
var propObject = _getPropertyObject_(_bindingId_, _propPath_);
if (propObject) {
_removePendingChangedArrayPath_(_bindingId_, _propPath_);
}
}
Apply Page Bindings
Apply server bindings
Since there can be some binding-mappings for a page, we need a way to identify each binding-mapping of the page.
For that purpose, we add a class that holds the whole of the binding-mappings of the page and, registers each one with a different identifier:
public abstract class BinderDefinitions
{
private uint _bindingIdCounter;
public string PageId { get; set; }
#region Bindings
private List<BindingData> _bindings;
protected List<BindingData> Bindings
{
get { return _bindings ?? (_bindings = new List<BindingData>()); }
}
#endregion
protected void AddBinding(string clientObjectString,
object serverObject, BindingMapping objectBindingMapping)
{
_bindingIdCounter++;
string bindingId = string.Format("B{0}", _bindingIdCounter);
BindingData bd = new BindingData(bindingId, clientObjectString, serverObject, objectBindingMapping);
Bindings.Add(bd);
}
public void ApplyServerBindings()
{
foreach (BindingData bd in Bindings)
{
BinderContext.Instance.RegisterBinding(PageId, bd.BindingId, bd.ServerObject,
bd.ObjectBindingMapping, false);
}
}
}
public class BindingData
{
public BindingData(string bindingId, string clientObjectString,
object serverObject, BindingMapping objectBindingMapping)
{
BindingId = bindingId;
ClientObjectString = clientObjectString;
ServerObject = serverObject;
ObjectBindingMapping = objectBindingMapping;
}
#region Properties
public string BindingId { get; set; }
public string ClientObjectString { get; set; }
public object ServerObject { get; set; }
public BindingMapping ObjectBindingMapping { get; set; }
#endregion
}
Inject client script
Generate the script
So, we have a class that holds the whole of the page's binding-mappings. The next step is to generate an appropriate client script.
The first script we have to provide is, the client's object model implementation.
One approach for doing that is, providing a JavaScript file that contains the script.
In that way, every user of our library can add a script
tag with a reference to our script file.
But, inserting client script separately, can lead to some problems: the user can forget to include the script file or,
even if the user has included the script file, the script version can be incompatible with the server-side version.
In order to ensure that the client-side script is compatible with the server-side code,
we take another approach and, generate the client-side script for each page.
For generating the client's object model script, we add the script file as a resource of our project and,
use it for returning the script's string
:
protected static object ResourcesLocker { get; private set; }
private const string _originalBinderClientConstructorFunctionName = "_WebBindingBinderClient_";
public string BinderClientConstructorFunctionName { get; set; }
private string GetBinderClientScript()
{
string res = string.Empty;
Uri resUri = new Uri("/WebBinding;component/Scripts/BinderClient.js", UriKind.Relative);
lock (ResourcesLocker)
{
StreamResourceInfo resInfo = Application.GetResourceStream(resUri);
if (resInfo != null)
{
using (StreamReader sr = new StreamReader(resInfo.Stream))
{
res = sr.ReadToEnd();
}
}
}
res = Regex.Replace(res, _originalBinderClientConstructorFunctionName, BinderClientConstructorFunctionName);
return res;
}
Since the client's object model script is a generic script,
we have to inject the dedicate implementation too.
That can be done by implementing a dedicate class for the dedicate library (in our case: the Knockout.js library):
public class KnockoutBinderDefinitions : BinderDefinitions
{
private const string _originalApplyDedicateImplementationFunctionName = "WebBinding_ApplyKnockoutDedicateImplementation";
public KnockoutBinderDefinitions()
{
ApplyDedicateImplementationFunctionName = "WebBinding_ApplyKnockoutDedicateImplementation";
}
#region Properties
public string ApplyDedicateImplementationFunctionName { get; set; }
#endregion
#region BinderDefinitions implementation
protected override string GetApplyDedicateImplementationScript()
{
StringBuilder sb = new StringBuilder();
sb.AppendLine(GetDedicateImplementationScript());
sb.AppendFormat("{0}({1});", ApplyDedicateImplementationFunctionName, BinderClientObjectName);
return sb.ToString();
}
#endregion
private string GetDedicateImplementationScript()
{
string res = string.Empty;
Uri resUri = new Uri("/WebBinding.Knockout;component/Scripts/KnockoutDedicateImplementation.js", UriKind.Relative);
lock (ResourcesLocker)
{
StreamResourceInfo resInfo = Application.GetResourceStream(resUri);
if (resInfo != null)
{
using (StreamReader sr = new StreamReader(resInfo.Stream))
{
res = sr.ReadToEnd();
}
}
}
res = Regex.Replace(res, _originalApplyDedicateImplementationFunctionName, ApplyDedicateImplementationFunctionName);
return res;
}
}
In that class, we implement a virtual method (GetApplyDedicateImplementationScript
) that generates the dedicate script.
After we have the client's object model script, we can generate a client script for applying the page's binding-mappings:
public string BinderClientObjectName { get; set; }
public virtual string GetBinderScript()
{
StringBuilder clientScript = new StringBuilder();
clientScript.Append(GetBinderClientScript());
clientScript.AppendLine();
clientScript.AppendFormat("var {0}=new {1}(\"{2}\");",
BinderClientObjectName, BinderClientConstructorFunctionName, PageId);
clientScript.AppendLine();
clientScript.Append(GetApplyDedicateImplementationScript());
clientScript.AppendLine();
clientScript.AppendFormat("{0}.beginServerChangesRequests();", BinderClientObjectName);
clientScript.AppendLine();
foreach (BindingData bd in Bindings)
{
clientScript.Append(GetBindingRegistrationScript(bd));
clientScript.AppendLine();
}
clientScript.AppendFormat("{0}.applyBindings();", BinderClientObjectName);
return clientScript.ToString();
}
protected abstract string GetApplyDedicateImplementationScript();
private string GetBindingRegistrationScript(BindingData bd)
{
StringBuilder sb = new StringBuilder();
string mappingObjectString = bd.ObjectBindingMapping.ToClientBindingMappingObjectString();
sb.AppendFormat("{0}.addBindingMapping(\"{1}\",{2},{3});",
BinderClientObjectName, bd.BindingId, bd.ClientObjectString, mappingObjectString);
return sb.ToString();
}
Additional functionality
In addition to the basic functionality, we add an option for automatically creating the root objects of the bindings, if they don't exist:
public bool DefineRootObjectsIfNotExist { get; set; }
private string GetBindingRegistrationScript(BindingData bd)
{
if (DefineRootObjectsIfNotExist)
{
sb.Append("((function() {if (!this[\"").Append(bd.ClientObjectString).Append("\"]){this[\"").
Append(bd.ClientObjectString).Append("\"]={};}})());");
}
}
It is more recommended to define those objects manually but, that can be a good solution for simple cases.
Sometimes, we may want to create new objects (like adding collection's elements) in the bound model.
Since we deal with a bound model, we can create those objects in the server side
(using Web API or something like) and,
they will be reflected to the whole of the clients.
But, if we still want to create the objects in the client-side (and want their changes to be reflected to the server),
we have to register for their properties' changes too.
For that purpose, we add the following function:
this.createBoundObjectForPropertyPath = function (rootObj, _propPath_) {
var _rootObjId_ = null;
for (var objId in _rootBindingObjects_) {
if (_rootBindingObjects_[objId] == rootObj) {
_rootObjId_ = objId;
}
}
if (_rootObjId_) {
var _propId_ = _removeArrayIndicesNumbers_(_propPath_);
var objHolder = _createObject_(_rootObjId_, _propId_);
for (var _bindingId_ in _bindingsObjectsIds_) {
if (_bindingsObjectsIds_[_bindingId_] == _rootObjId_) {
_registerForChanges_(_bindingId_, objHolder, _propPath_);
}
}
return objHolder;
}
return null;
};
In that function, we create an object according to the given property-path using the stored creator function and,
register for properties' changes for the whole of the bindings that use the given root object.
Additional data we may want to use in the client side is the current page identifier.
We can generate functions for creating bound objects and for getting the page's identifier, as follows:
public string CreateBoundObjectFunctionName { get; set; }
public string GetPageIdFunctionName { get; set; }
private string GetAdditionalFunctionsScript()
{
StringBuilder sb = new StringBuilder();
if (!string.IsNullOrEmpty(CreateBoundObjectFunctionName))
{
sb.Append("function ").Append(CreateBoundObjectFunctionName).
Append("(o,p){return ").Append(BinderClientObjectName).
Append(".createBoundObjectForPropertyPath(o,p);}");
}
if (!string.IsNullOrEmpty(GetPageIdFunctionName))
{
sb.Append("function ").Append(GetPageIdFunctionName).
Append("(){return ").Append(BinderClientObjectName).Append(".pageId;}");
}
return sb.ToString();
}
Minimize injected javascript code
In order to minimize the data amount that is transferred for the page,
standard JavaScript libraries (e.g. jQuery, Knockout, etc.) are provided also by minimized JavaScript files.
We take the same approach, also for our library:
private static readonly Dictionary<string, string> _defaultVariablesReplacementValues;
static BinderDefinitions()
{
_defaultVariablesReplacementValues =
new Dictionary<string, string>
{
{"_bindingsObjectsIds_", "a1"},
{"_rootBindingObjects_", "a2"},
{"_objectsCreators_", "a3"},
{"_applyServerChangesResponse_", "i7"},
{"_applyServerPropertyChange_", "i8"},
{"_applyServerCollectionChange_", "i9"}
};
}
protected BinderDefinitions()
{
VariablesReplacementValues = new Dictionary<string, string>(_defaultVariablesReplacementValues);
}
protected Dictionary<string, string> VariablesReplacementValues { get; private set; }
public bool MinimizeClientScript { get; set; }
private string GetBinderClientScript()
{
if (MinimizeClientScript)
{
res = GetMinimizedBinderClientScript(res);
}
}
private string GetMinimizedBinderClientScript(string originalScript)
{
string res = Regex.Replace(originalScript, "/\\*-{3}([\\r\\n]|.)*?-{3}\\*/", string.Empty);
foreach (var variableReplacement in VariablesReplacementValues)
{
res = Regex.Replace(res, variableReplacement.Key, variableReplacement.Value);
}
res = Regex.Replace(res, "[\\r\\n][\\r\\n \\t]*", string.Empty);
res = Regex.Replace(res, " ?([=\\+\\{\\},\\(\\)!\\?:\\>\\<\\|&\\]\\[-]) ?", "$1");
return res;
}
In the GetMinimizedBinderClientScript
method, we remove comments and unnecessary spaces and,
change some of the variables' names to shorter names.
In the VariablesReplacementValues
dictionary, we hold the replacement string, for each variable's name that should be replaced.
So, in order to change the default replacements, we can change that dictionary.
Add HtmlHelper extension
After we have the client's script, we can inject it to the rendered page.
For that purpose, we add an extension method to the HtmlHelper
type:
public static class BinderHtmlHelperExtensions
{
private static uint _pageIdCounter = 1;
private static string GeneratePageId(HtmlHelper helper)
{
string idPrefix;
try
{
string sessionId = helper.ViewContext != null && helper.ViewContext.HttpContext != null && helper.ViewContext.HttpContext.Session != null
? helper.ViewContext.HttpContext.Session.SessionID
: "<null>";
string conrollerName = helper.ViewContext != null && helper.ViewContext.RouteData.Values["Controller"] != null
? helper.ViewContext.RouteData.Values["Controller"].ToString()
: string.Empty;
string actionName = helper.ViewContext != null && helper.ViewContext.RouteData.Values["Action"] != null
? helper.ViewContext.RouteData.Values["Action"].ToString()
: string.Empty;
idPrefix = string.Format("{0}/{1}@{2}", conrollerName, actionName, sessionId);
}
catch (Exception)
{
idPrefix = Guid.NewGuid().ToString();
}
string id = string.Format("{0}#{1}", idPrefix, _pageIdCounter);
_pageIdCounter++;
return id;
}
public static IHtmlString WebBinder(this HtmlHelper helper, BinderDefinitions bd)
{
bd.PageId = GeneratePageId(helper);
bd.ApplyServerBindings();
string binderScript = bd.GetBinderScript();
return new HtmlString(string.Format("<script type=\"text/javascript\">{0}</script>", binderScript));
}
}
In the GeneratePageId
method, we generate a unique identifier for a page, based on the ViewContext
and a counting number.
In the WebBinder
method, we register the server's bindings and, generates the client's script.
This method returns an HtmlString
object with the client's script wrapped with a script
tag.
Generate Binding-mapping using Attributes
In order to simplify the creation of the binding-mapping, we add an attribute for marking the bound properties:
[AttributeUsage(AttributeTargets.Property)]
public class WebBoundAttribute : Attribute
{
public WebBoundAttribute()
{
IndicateCollectionTypeAutomatically = true;
MappingMode = BindingMappingMode.TwoWay;
IndicateMappingModeAutomatically = true;
RecursionLevel = 5;
}
public string ClientPropertyName { get; set; }
public bool IsCollection { get; set; }
public bool IndicateCollectionTypeAutomatically { get; set; }
public BindingMappingMode MappingMode { get; set; }
public bool IndicateMappingModeAutomatically { get; set; }
public uint RecursionLevel { get; set; }
}
Using that attribute, we can generate a binding mapping for a given type:
public static BindingMapping FromType(Type serverObjectType, bool discardInvalidMappings = false)
{
if (null == serverObjectType)
{
throw new ArgumentNullException("serverObjectType");
}
BindingMapping bm = new BindingMapping();
Dictionary<PropertyInfo, uint> propertiesRecursionLevel = new Dictionary<PropertyInfo, uint>();
try
{
AddSubPropertiesMapping(serverObjectType, bm.SubPropertiesMapping, propertiesRecursionLevel, discardInvalidMappings);
}
catch (InvalidOperationException ioe)
{
string msg = string.Format("Binding cannot fully work for type '{0}'. See inner exception for more details.",
serverObjectType.FullName);
throw new InvalidOperationException(msg, ioe);
}
return bm;
}
private static bool IsPropertyWebBound(PropertyInfo pi, Dictionary<PropertyInfo, uint> propertiesRecursionLevel)
{
if (!propertiesRecursionLevel.ContainsKey(pi))
{
WebBoundAttribute wba =
pi.GetCustomAttributes(typeof(WebBoundAttribute), true).FirstOrDefault() as WebBoundAttribute;
propertiesRecursionLevel[pi] = wba != null ? wba.RecursionLevel : 0;
}
if (propertiesRecursionLevel[pi] > 0)
{
propertiesRecursionLevel[pi]--;
return true;
}
return false;
}
private static void AddSubPropertiesMapping(Type t, List<BindingMapping> dstMappingList,
Dictionary<PropertyInfo, uint> basePropertiesRecursionLevel, bool discardInvalidMappings)
{
if (t == null || dstMappingList == null || basePropertiesRecursionLevel == null)
{
return;
}
Dictionary<PropertyInfo, uint> propertiesRecursionLevel =
new Dictionary<PropertyInfo, uint>(basePropertiesRecursionLevel);
IEnumerable<PropertyInfo> webBindedProperties =
t.GetProperties().Where(p => IsPropertyWebBound(p, propertiesRecursionLevel));
foreach (PropertyInfo pi in webBindedProperties)
{
BindingMapping bm;
try
{
bm = new BindingMapping(PropertyMapping.FromProperty(pi));
}
catch (InvalidOperationException)
{
if (discardInvalidMappings)
{
continue;
}
throw;
}
if (bm.RootMapping.IsCollection)
{
Type elemType = GetElementType(pi.PropertyType);
AddCollectionElementMapping(elemType, bm.CollectionElementMapping, propertiesRecursionLevel, discardInvalidMappings);
}
else
{
AddSubPropertiesMapping(pi.PropertyType, bm.SubPropertiesMapping, propertiesRecursionLevel, discardInvalidMappings);
}
dstMappingList.Add(bm);
}
}
private static void AddCollectionElementMapping(Type elemType, List<BindingMapping> dstMappingList,
Dictionary<PropertyInfo, uint> propertiesRecursionLevel, bool discardInvalidMappings)
{
if (IsCollection(elemType))
{
BindingMapping bm = new BindingMapping();
bm.RootMapping.IsCollection = true;
Type subElemType = GetElementType(elemType);
AddCollectionElementMapping(subElemType, bm.CollectionElementMapping, propertiesRecursionLevel, discardInvalidMappings);
dstMappingList.Add(bm);
}
else
{
AddSubPropertiesMapping(elemType, dstMappingList, propertiesRecursionLevel, discardInvalidMappings);
}
}
private static bool IsCollection(Type t)
{
if (t == null)
{
return false;
}
if (t == typeof(string))
{
return false;
}
Type[] typeIntefaces = t.GetInterfaces();
if (typeIntefaces.Contains(typeof(IEnumerable)))
{
return true;
}
return false;
}
private static Type GetElementType(Type containerType)
{
Type res = null;
if (containerType.HasElementType)
{
res = containerType.GetElementType();
}
if (containerType.IsGenericType)
{
Type[] genericArguments = containerType.GetGenericArguments();
if (genericArguments.Length == 1)
{
res = genericArguments[0];
}
}
return res;
}
In the FromType
method, we create a new BindingMapping
object and
fill its SubPropertiesMapping
according to the bound properties (properties that are marked with the WebBound
attribute).
In the AddSubPropertiesMapping
method, we go over the whole of the bound properties and,
create an appropriate BindingMapping
object for them.
If the property is a collection, we fill the CollectionElementMapping
according to the bound properties of the collection's element type.
Otherwise, we fill the SubPropertiesMapping
according to the bound properties of the property's type.
If a property's type has a descendant (a nested property) of its own type, since we generate the binding-mapping according to the type's information,
we can easily get into an infinite recursion (that is usually stop in the middle by a StackOverflowException
exception).
In order to prevent that, we have the RecursionLevel
property for each WebBound
attribute.
Using that property, we can ignore properties that are too deep in the recursion level. That is done by the IsPropertyWebBound
method.
For creating the property-mapping for each property, we use the PropertyMapping.FromProperty
method.
In this method, we create a PropertyMapping
object according to the property's WebBound
attribute and,
throw an appropriate exception when there is an invalid mapping.
Garbage Collection
In order to be notified about pages that are not in use any more, we store the last action time for each page:
private readonly Dictionary<string, DateTime> _pagesLastActionTime;
public BindingsHandler GetBindingsHandler(string pageId, string bindingId)
{
_pagesLastActionTime[pageId] = DateTime.Now;
}
public List<BindingsHandler> GetBindingsHandlers(string pageId)
{
_pagesLastActionTime[pageId] = DateTime.Now;
}
Using the stored last actions times, we can determine which page isn't in use any more (closed).
In order to clean the unneeded pages' data, we add a timer that runs a "garbage collection":
private System.Timers.Timer _gcTimer;
private void StartGarbageCollectionTimer()
{
_gcTimer = new System.Timers.Timer();
_gcTimer.AutoReset = true;
_gcTimer.Interval = GarbageCollectionInterval;
_gcTimer.Elapsed += OnGcTimerElapsed;
_gcTimer.Start();
}
private void OnGcTimerElapsed(object sender, System.Timers.ElapsedEventArgs e)
{
CollectNotInUsePagesData();
}
private void StopGarbageCollectionTimer()
{
if (_gcTimer != null)
{
_gcTimer.Stop();
_gcTimer = null;
}
}
private void CollectNotInUsePagesData()
{
lock (Locker)
{
string[] unusedPagesIds = _pagesLastActionTime.
Where(p => (DateTime.Now - p.Value).TotalMilliseconds > MaxInactivationMilliseconds).
Select(p => p.Key).ToArray();
foreach (string pageId in unusedPagesIds)
{
_pagesLastActionTime.Remove(pageId);
if (_pagesBindings.ContainsKey(pageId))
{
Dictionary<string, BindingsHandler> currPagesData = _pagesBindings[pageId];
foreach (var pd in currPagesData)
{
BindingsHandler bh = pd.Value;
BeginInvoke(() => bh.Dispose());
}
_pagesBindings.Remove(pageId);
}
}
}
}
In the CollectNotInUsePagesData
method, we go over the whole of the pages that aren't in use and,
clean their data.
In order to enable additional cleaning when a page is removed, we add an event for indicating about a removed page:
public class PageEventArgs : EventArgs
{
public string PageId { get; set; }
}
public class BinderContext : IDisposable
{
public event EventHandler<PageEventArgs> PageRemoved;
private void CollectlNotInUsePagesData()
{
EventHandler<PageEventArgs> handler = PageRemoved;
if (handler != null)
{
PageEventArgs arg = new PageEventArgs {PageId = pageId};
handler(this, arg);
}
}
}
How To Use It
Example 1: Shared view-model vs. unique view-model
For demonstrating the use of the WebBinding
library, we create an example view-model and,
an ASP.NET MVC4 (can be downloaded from: http://www.asp.net/mvc/mvc4) web page that presents that view-model.
In the first example, we create 2 instances of a view-model that contains a web-bound Text
property.
One of the instances is shared with the whole of the pages and, the other one is unique for the specific page.
For our view-model, we create the following classes:
public class BaseViewModel : INotifyPropertyChanged
{
#region INotifyPropertyChanged implementation
public event PropertyChangedEventHandler PropertyChanged;
protected void NotifyPropertyChanged(string propName)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propName));
}
}
#endregion
}
public class ExampleViewModel : BaseViewModel
{
#region Text
private string _text;
[WebBound(ClientPropertyName = "text")]
public string Text
{
get { return _text; }
set
{
if (_text != value)
{
_text = value;
NotifyPropertyChanged("Text");
}
}
}
#endregion
}
For creating a shared instance of our view-model, we create a singleton that holds our view-model:
public class ExampleContext
{
#region Singleton implementation
private ExampleContext()
{
CommonBindPropertiesExampleViewModel = new ExampleViewModel();
}
private static readonly ExampleContext _instance = new ExampleContext();
public static ExampleContext Instance
{
get { return _instance; }
}
#endregion
#region Properties
public ExampleViewModel CommonBindPropertiesExampleViewModel { get; private set; }
#endregion
}
Now, let's add an action for presenting a view with a new instance of our view-model as its Model
:
public class HomeController : Controller
{
public ActionResult Index()
{
ExampleViewModel vm = new ExampleViewModel();
return View(vm);
}
}
For applying WebBinding
(binding the view-model in the server side, to a corresponding view-model in the client side) on our view, we:
- Add JavaScript objects for holding the corresponding view-models in the client side:
<script type="text/javascript">
var uniqueVm = {};
var sharedVm = {};
</script>
- Create a
BinderDefinitions
object that holds the binding-mappings for both of the view-models:
@{
BinderDefinitions bd = new KnockoutBinderDefinitions();
bd.AddBinding("uniqueVm", ViewData.Model);
bd.AddBinding("sharedVm", ExampleContext.Instance.CommonBindPropertiesExampleViewModel);
}
- Apply the
WebBinding
on the page:
@Html.WebBinder(bd)
Since we use the Knockout.js library for binding between the JavaScript view-model and the HTML elements, we have to apply the Knockout binding too:
<script type="text/javascript" src="~/Scripts/knockout-3.0.0.js"></script>
<script type="text/javascript">
// ...
function ExampleViewModel() {
var self = this;
this.uniqueVm = uniqueVm;
this.sharedVm = sharedVm;
}
var viewModel = new ExampleViewModel();
</script>
<script type="text/javascript">
ko.applyBindings(viewModel);
</script>
For presenting our example, we add 2 input
tags that presents the bound Text
property
(one for the shared view-model and, one for the unique view-model):
<section>
<h3>Example 1: Shared view-model vs. unique view-model</h3>
<p class="exampleDescription">In this example, we compare between shared (with the other pages) view-model and, unique (to this page) view-model.
We can see how the change on the shared view-model is reflected to the other pages (open this page in some tabs/windows),
while the change on the unique view-model stays unique to that page.</p>
<h4>Shared view-model</h4>
<p>
Text: <input type="text" data-bind="value: sharedVm.text"/> -
Entered value: <span style="color :blue" data-bind="text: sharedVm.text"></span>
</p>
<h4>Unique view-model</h4>
<p>
Text: <input type="text" data-bind="value: uniqueVm.text"/> -
Entered value: <span style="color :blue" data-bind="text: uniqueVm.text"></span>
</p>
</section>
The result is:
Example 2: 2 Dimensional Collection
In the second example, we present a web-bound 2 dimensional collection of integers.
The collection's values are updated randomly by the server (and reflected to the whole of the clients).
For holding our collection, we add an appropriate property to our view-model:
private ObservableCollection<ObservableCollection<int>> _numbersBoard;
[WebBound(ClientPropertyName = "numbersBoard")]
public ObservableCollection<ObservableCollection<int>> NumbersBoard
{
get { return _numbersBoard; }
set
{
if (_numbersBoard != value)
{
_numbersBoard = value;
NotifyPropertyChanged("NumbersBoard");
}
}
}
For changing the collection's dimensions from the client side, we add 2 more properties:
#region NumbersBoardRowsCount
private int _numbersBoardRowsCount;
[WebBound(ClientPropertyName = "numbersBoardRowsCount")]
public int NumbersBoardRowsCount
{
get { return _numbersBoardRowsCount; }
set
{
if (_numbersBoardRowsCount != value)
{
lock (_numbersBoard)
{
while (_numbersBoard.Count > value)
{
_numbersBoard.RemoveAt(value);
}
while (_numbersBoard.Count < value)
{
ObservableCollection<int> newRow = new ObservableCollection<int>();
for (int colInx = 0; colInx < NumbersBoardColumnsCount; colInx++)
{
newRow.Add(colInx);
}
_numbersBoard.Add(newRow);
}
}
_numbersBoardRowsCount = value;
NotifyPropertyChanged("NumbersBoardRowsCount");
}
}
}
#endregion
#region NumbersBoardColumnsCount
private int _numbersBoardColumnsCount;
[WebBound(ClientPropertyName = "numbersBoardColumnsCount")]
public int NumbersBoardColumnsCount
{
get { return _numbersBoardColumnsCount; }
set
{
if (_numbersBoardColumnsCount != value)
{
if (_numbersBoardColumnsCount > value)
{
lock (_numbersBoard)
{
foreach (var row in NumbersBoard)
{
while (row.Count > value)
{
row.RemoveAt(value);
}
}
}
}
if (_numbersBoardColumnsCount < value)
{
lock (_numbersBoard)
{
foreach (var row in NumbersBoard)
{
while (row.Count < value)
{
row.Add(row.Count);
}
}
}
}
_numbersBoardColumnsCount = value;
NotifyPropertyChanged("NumbersBoardColumnsCount");
}
}
}
#endregion
When the values of those properties are changed, we update the collection's dimensions appropriately.
In order to demonstrate how the server's changes are reflected to the clients,
we add a timer that changes the collection's values randomly after elapsed intervals:
private System.Timers.Timer _nbTimer;
private void StartNumbersBoardTimer()
{
_nbTimer = new System.Timers.Timer();
_nbTimer.AutoReset = true;
_nbTimer.Interval = 1000;
_nbTimer.Elapsed += OnNbTimerElapsed;
_nbTimer.Start();
}
private void OnNbTimerElapsed(object sender, System.Timers.ElapsedEventArgs e)
{
lock (_numbersBoard)
{
Random r = new Random((int)DateTime.Now.Ticks);
for (int rowInx = 0; rowInx < _numbersBoard.Count; rowInx++)
{
ObservableCollection<int> currRow = _numbersBoard[rowInx];
for (int colInx = 0; colInx < currRow.Count; colInx++)
{
currRow[colInx] = r.Next(0, 100);
}
}
}
}
private void StopNumbersBoardTimer()
{
if (_nbTimer != null)
{
_nbTimer.Stop();
_nbTimer = null;
}
}
For presenting our example, we add 2 input
tags for setting the collection's dimensions and,
a table
for presenting the collection:
<section>
<h3>Example 2: 2 dimensional collection</h3>
<p class="exampleDescription">In this example, we change the columns' number and the rows' number of a 2D collection.
In addition to that, the cells' values are changed randomly by the server.
We can see how the values are synchronized with the other pages.</p>
<p>
Rows count: <input type="text" data-bind="value: sharedVm.numbersBoardRowsCount"/> -
Entered value: <span style="color :blue" data-bind="text: sharedVm.numbersBoardRowsCount"></span>
<br />
Columns count: <input type="text" data-bind="value: sharedVm.numbersBoardColumnsCount"/> -
Entered value: <span style="color :blue" data-bind="text: sharedVm.numbersBoardColumnsCount"></span>
<br />
</p>
<table style="background:lightgray;border:gray 1px solid;width:100%">
<tbody data-bind="foreach: sharedVm.numbersBoard">
<tr data-bind="foreach: $data">
<td style="background:lightyellow;border:goldenrod 1px solid">
<span style="color :blue" data-bind="text: $data"></span>
</td>
</tr>
</tbody>
</table>
</section>
The result is:
Example 3: String as a Collection
In the third example, we present a web-bound string
in two ways: as a string
and, as a collection of characters.
For holding our string
, we add 2 web-bound properties:
#region StringEntry
private string _stringEntry;
[WebBound]
public string StringEntry
{
get { return _stringEntry; }
set
{
if (_stringEntry != value)
{
_stringEntry = value;
NotifyPropertyChanged("StringEntry");
NotifyPropertyChanged("StringEntryCharacters");
}
}
}
#endregion
#region StringEntryCharacters
[WebBound(IndicateCollectionTypeAutomatically = false, IsCollection = true)]
public string StringEntryCharacters
{
get { return _stringEntry; }
}
#endregion
The StringEntry
property encapsulates the value of the _stringEntry
field.
The StringEntryCharacters
property returns the same value but,
is exposed as a collection (using the appropriate properties of the WebBound
attribute).
Note that in the other examples, we use the ClientPropertyName
property for setting the client-side's property's name.
In order to demonstrate the default behavior (give the client-side property the same name as the server-side property), in this example,
we omit the use of the ClientPropertyName
property.
For presenting our example, we add an input
tag for presenting our string
as a string
and,
a table
for presenting our string
as a characters' collection:
<section>
<h3>Example 3: String as a collection</h3>
<p class="exampleDescription">In this example, we show a string as a collection of characters.</p>
<h4>The string</h4>
<p>
StringEntry: <input type="text" data-bind="value: sharedVm.StringEntry"/> -
Entered value: <span style="color :blue" data-bind="text: sharedVm.StringEntry"></span>
</p>
<h4>The string's characters</h4>
<table style="background:lightgray;border:gray 1px solid;width:100%">
<tbody>
<tr data-bind="foreach: sharedVm.StringEntryCharacters">
<td style="background:lightyellow;border:goldenrod 1px solid">
<span style="color :blue" data-bind="text: $data"></span>
</td>
</tr>
</tbody>
</table>
</section>
The result is:
Example 4: Change Collections from the Client Side
In the fourth example, we present a web-bound collections of a more complex (than simple types like string
, int
, etc...) type.
We can add or remove items from these collections in the client side (and, see how the changes are reflected to the other clients).
For the collection's element type, we create a view-model that presents a person:
public class PersonViewModel : BaseViewModel
{
public PersonViewModel()
{
Children = new ObservableCollection<PersonViewModel>();
}
#region Name
private FullNameViewModel _name;
[WebBound(ClientPropertyName = "name")]
public FullNameViewModel Name
{
get { return _name; }
set
{
if (value != _name)
{
_name = value;
NotifyPropertyChanged("Name");
}
}
}
#endregion
#region Age
private int _age;
[WebBound(ClientPropertyName = "age")]
public int Age
{
get { return _age; }
set
{
if (value != _age)
{
_age = value;
NotifyPropertyChanged("Age");
}
}
}
#endregion
#region Children
private ObservableCollection<PersonViewModel> _children;
[WebBound(ClientPropertyName = "children")]
public ObservableCollection<PersonViewModel> Children
{
get { return _children; }
set
{
if (_children != value)
{
_children = value;
NotifyPropertyChanged("Children");
}
}
}
#endregion
}
public class FullNameViewModel : BaseViewModel
{
#region FirstName
private string _firstName;
[WebBound(ClientPropertyName = "firstName")]
public string FirstName
{
get { return _firstName; }
set
{
if (value != _firstName)
{
_firstName = value;
NotifyPropertyChanged("FirstName");
}
}
}
#endregion
#region LastName
private string _lastName;
[WebBound(ClientPropertyName = "lastName")]
public string LastName
{
get { return _lastName; }
set
{
if (value != _lastName)
{
_lastName = value;
NotifyPropertyChanged("LastName");
}
}
}
#endregion
}
For holding our collection, we add a web-bound property to our view-model:
private ObservableCollection<PersonViewModel> _people;
[WebBound(ClientPropertyName = "people")]
public ObservableCollection<PersonViewModel> People
{
get { return _people; }
set
{
if (_people != value)
{
_people = value;
NotifyPropertyChanged("People");
}
}
}
In order to create new collection's elements in the client side, in a way that their changes will be reflected to the server,
we have to create objects that have registrations for their properties' changes. For that purpose, we expose a function that creates a web-bound object:
@{
BinderDefinitions bd = new KnockoutBinderDefinitions();
bd.CreateBoundObjectFunctionName = "createWebBoundObject";
}
For enabling adding or removing collection's elements in the client side, we add appropriate functions that perform those actions:
function ExampleViewModel() {
this.removePerson = function (person) {
var peopleArr = self.sharedVm.people();
var foundIndex = -1;
for (var personInx = 0; personInx < peopleArr.length && foundIndex < 0; personInx++) {
if (peopleArr[personInx]() == person) {
foundIndex = personInx;
}
}
if (foundIndex >= 0) {
peopleArr.splice(foundIndex, 1);
}
self.sharedVm.people(peopleArr);
};
this.removeChild = function (child) {
var peopleArr = self.sharedVm.people();
var foundIndex = -1;
for (var personInx = 0; personInx < peopleArr.length && foundIndex < 0; personInx++) {
var childrenHolder = peopleArr[personInx]().children;
var childrenArr = childrenHolder();
for (var childInx = 0; childInx < childrenArr.length && foundIndex < 0; childInx++) {
if (childrenArr[childInx]() == child) {
foundIndex = childInx;
}
}
if (foundIndex >= 0) {
childrenArr.splice(foundIndex, 1);
childrenHolder(childrenArr);
}
}
};
this.addPerson = function () {
var peopleArr = self.sharedVm.people();
var newIndex = peopleArr.length;
var propPath = "people[" + newIndex + "]";
var person = createWebBoundObject(self.sharedVm, propPath);
person().name().firstName("Added_First" + (newIndex + 1));
person().name().lastName("Added_Last" + (newIndex + 1));
person().age(40 + newIndex);
peopleArr.push(person);
self.sharedVm.people(peopleArr);
};
this.addChild = function (person) {
var peopleArr = self.sharedVm.people();
var foundIndex = -1;
for (var personInx = 0; personInx < peopleArr.length && foundIndex < 0; personInx++) {
if (peopleArr[personInx]() == person) {
foundIndex = personInx;
}
}
if (foundIndex >= 0) {
var childrenHolder = peopleArr[foundIndex]().children;
var childrenArr = childrenHolder();
var newIndex = childrenArr.length;
var propPath = "people[" + foundIndex + "].children[" + newIndex + "]";
var child = createWebBoundObject(self.sharedVm, propPath);
child().name().firstName("Added_First" + (foundIndex + 1) + "_" + (newIndex + 1));
child().name().lastName("Added_Last" + (foundIndex + 1) + "_" + (newIndex + 1));
child().age(20 + newIndex);
childrenArr.push(child);
childrenHolder(childrenArr);
}
};
}
For presenting our example, we add a list for presenting our collection and, buttons for applying the appropriate actions:
<section>
<h3>Example 4: Change collections from the client side</h3>
<p class="exampleDescription">In this example, we add and remove collection's elements (from the client side).
We can see how the changes are reflected to the other pages.</p>
<h4>People collection</h4>
<ol data-bind="foreach: sharedVm.people">
<li>Name: <span style="color :blue" data-bind="text: name().firstName"></span>
<span style="color :brown">, </span>
<span style="color :blue" data-bind="text: name().lastName"></span>
Age: <span style="color :blue" data-bind="text: age"></span>
<button data-bind="click: $root.removePerson">Remove</button>
<br />
Children:
<ol data-bind="foreach: children">
<li>
Name: <span style="color :blue" data-bind="text: name().firstName"></span>
<span style="color :brown">, </span>
<span style="color :blue" data-bind="text: name().lastName"></span>
Age: <span style="color :blue" data-bind="text: age"></span>
<button data-bind="click: $root.removeChild">Remove</button>
</li>
</ol>
<button data-bind="click: $root.addChild">Add child</button>
</li>
</ol>
<button data-bind="click: $root.addPerson">Add person</button>
</section>
The result is:
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.