Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Automatic Undo/Redo for .NET classes

0.00/5 (No votes)
29 Jul 2005 1  
A unique spin on implementing generic Undo/Redo funcionality for .NET classes using method invocation interception...

Introduction

Almost anyone who has operated a word processor has used undo/redo to some extent. This is the feature that lets you effectively roll back (undo) and roll forward (redo) any state changes (i.e. add words) you have made to the document while editing. This concept can be quite useful beyond text editors and documents.

The idea is to provide automatic Undo/Redo functionality for .NET classes. The functionality is implemented as an IMessageSink. This article will demonstrate how to intercept method invocations 'in-flight' before they reach the target, allowing for pre and post processing of method calls.

Design goals/requirements

Classes wanting automatic Undo/Redo functionality need to do nothing more than sub-class ContextBoundObject or MarshalByRefObject (in the MarshalByRefObject case, you must insure all calls must come through a TransparentProxy) and 'mark' the class with the custom attribute (UndoRedoAttribute) included in this article. One other small requirement is that the class must also implement IUndoRedo. Implementing is actually odd in this context because the class just needs to provide the methods, they can be empty as they are never called. An abstract base class is also provided that performs these steps for you (described later).

Design notes

This technique is built using undocumented features of the CLR, namely IContributeObjectSink. I don�t find this to be an issue; however a similar technique can be applied using a RealProxy approach (which is a documented class).

The RealProxy approach may not be quite as performant (MSFT), but clearly performance is not a consideration when MarshalByRefObject is sub-classed. Note: calls on MarhsalByRefObject can�t be inlined, plus calls on TransparentProxy objects force the call stack to be serialized. Reflection is also used, which isn�t known for its performance, but allows for some very powerful techniques.

The code can also be very easily modified to support Undo/Redo for field setter operations, but I�ll leave that to the reader.

The functionality is exposed through the IUndoRedo interface.

public interface IUndoRedo
{
  void Undo();
  void Redo();
  void Flush();
}

How it works

The basic idea is to track object state via interception of property set operations. To do this code must be injected between the caller and the target. The CLR provides a great facility for doing this using custom attributes and implementing IContributeObjectSink. I will briefly explain how code injection works.

When an object that derives from ContextBoundObject is created the CLR will look for attributes (class level) that implement IContextAttribute. Here is the custom attribute implementation:

internal sealed class UndoRedoAttribute : Attribute, 
                                     IContextAttribute
 {
  
  public void GetPropertiesForNewContext(
                         IConstructionCallMessage msg)
  {
   IContextProperty prop = new UndoRedoProperty();
   msg.ContextProperties.Add(prop);
  }

  public bool IsContextOK(Context ctx, 
                         IConstructionCallMessage msg)
  {
   if(ctx.GetProperty("UndoRedo Property") != null)
   {
    return true;
   }
   return false;
  }

 }

The CLR will first invoke IsContextOK. This allows the attribute to determine if the current Context is going to be appropriate (UndoRedoProperty is 'installed'). If it returns false, GetPropertiesForNewContext will be invoked, allowing the attribute to 'install' the UndoRedoProperty into the Context.

The UndoRedoProperty is responsible for injecting the interception class (a class that implements IMessageSink).

public sealed class UndoRedoProperty : IContextProperty, 
                                      IContributeObjectSink
 {

  public IMessageSink GetObjectSink(MarshalByRefObject obj, 
                                      IMessageSink nextSink)
  {
   return new UndoRedoSink(obj, nextSink);
  }

  public string Name
  {
   get
   {
    return "UndoRedo Property";
   }
  }

  public bool IsNewContextOK(Context newCtx){return true;}
  public void Freeze(Context newContext){}
 }

The main thing to notice on the Context property is the implementation of IContributeObjectSink. The CLR will call GetObjectSink every time a new object is created that has the UndoRedoAttribute. Now all invocations will come through the custom message sink.

Now that we have all the boilerplate code out of the way let's look at the IMessageSink implementation:

internal sealed class UndoRedoSink : IMessageSink
{

 private IMessageSink    _nextSink;  /*ref to next sink in chain*/
 private object          _target;    /*target of method invocations*/
 private Type            _targetType;/*type of target(cached for perf)*/
 private ArrayList       _actions;   /*set history*/
 private int             _index;     /*current index of set buffer*/
 private int             _cnt;       /*count of undo calls*/

 
 public IMessageSink NextSink
 {
  get
  {
   return _nextSink;
  }
 }

 
 private void Add(SetAction sa)
 {
  /*
   * store set operation and update current index...
   */
   if(_actions.Count <= _index)
   {
    _actions.Add(sa);
   }
   else
   {
    _actions[_index] = sa;
   }
  
   _index++;

  /*
   * clear 
   */
  for(int i = _index;i<_actions.Count;i++)
  {
   _actions[i] = null;
  }
 }

 private bool CanUndo()
 {
  return _index > 0 ;
 }

 private bool CanRedo()
 {
  return _cnt > 0 && _actions.Count >_index && 
                          _actions[_index] != null;
 }

 public IMessage SyncProcessMessage(IMessage msg)
 {

   IMethodCallMessage mcm = msg as IMethodCallMessage;
   IMethodReturnMessage mrm = null;

   /*
   * is the message a method call?...
   */
   if (mcm != null)
   {               
     /*
     * is the message a property setter?...
     */
     if (mcm.MethodName.StartsWith("set_"))
     {
       /*
       * grab property name...
       */
       string propertyName = mcm.MethodName.Substring(4);
                      
       /*
       * record the set operation...
       */
       SetAction action    = new SetAction();
       action.propertyName = propertyName;
       action.newVal  = mcm.InArgs[0];

       /*
       * capture old value...
       */
       PropertyInfo pi  = _targetType.GetProperty(propertyName,
                                   BindingFlags.Instance | 
                                BindingFlags.Public | 
                                BindingFlags.NonPublic);
       action.oldVal       = pi.GetValue(_target,new object[0]);
    
       /*
       * store...
       */
       Add(action);

     }

     /*
     * undo last action...
     */
     if (mcm.MethodName == "Undo")
     {
               
       if(CanUndo())
       {
          _cnt++;
          --_index;

          /*
          * set the state back to the prior value...
          */
          PropertyInfo pi = 
            _targetType.GetProperty(
              ((SetAction)_actions[_index]).propertyName, 
               BindingFlags.Instance |
               BindingFlags.NonPublic | 
               BindingFlags.Public);
          pi.SetValue(_target, 
             ((SetAction)_actions[_index]).oldVal,
             new object[0]);
       }
                
       /*
       * no need to forward on...
       */
       mrm = new ReturnMessage(null, mcm);
       return mrm;
     }

           

     /*
     * redo last action...
     */
     if (mcm.MethodName == "Redo")
     {
       if(CanRedo())
       {
         _cnt--;
         SetAction action = 
            (SetAction)_actions[_index];
         PropertyInfo pi = 
           _targetType.GetProperty(action.propertyName, 
            BindingFlags.Instance | BindingFlags.NonPublic | 
            BindingFlags.Public);
         pi.SetValue(_target, action.newVal, new object[0]);
         _index++;

       }
               
       mrm = new ReturnMessage(null, mcm);
       return mrm;
     }
   
   } 

   /*
   * are we being told to 'empty' the 
   * undo/redo history?
   */
   if (mcm.MethodName == "Flush")
   {
     _actions.Clear();
     _cnt = 0;
     _index = 0;

     /*
     * no need to forward to actual object,
     * we are all done...
     */
     mrm = new ReturnMessage(null, mcm);
     return mrm;
   }

   /*
   * forward to terminator sink...
   */
   return _nextSink.SyncProcessMessage(msg);
            
 }
 
 public IMessageCtrl AsyncProcessMessage(IMessage msg, 
                                IMessageSink replySink)
 {
   return _nextSink.AsyncProcessMessage(msg,replySink);
 }

 public UndoRedoSink(MarshalByRefObject target, 
                                 IMessageSink nextSink)
 {
        
   _target  = (UndoRedo)target;
   _targetType = _target.GetType();
   _nextSink   = nextSink;
   _actions = new ArrayList();
   _index  = 0;
 }
}

The sink handles four types of invocations:

  1. Property set calls
  2. Flush calls
  3. Undo calls
  4. Redo calls

All the rest of the invocations are simply forwarded onto the next sink.

Using the code

Using the sink couldn't be easier. As stated earlier, just implement IUndoRedo, sub-class ContextBoundObject and place the UndoRedoAttribute above your class. A convenience class is also provided, which does the required steps on your behalf:

[UndoRedo]
public abstract class UndoRedo : ContextBoundObject, 
                                            IUndoRedo
{
 public void Undo() { }
 public void Redo() { }
 public void Flush() { }
 
}

Simply derive your class from UndoRedo and your ready to go.

Conclusion

Using this technique, other interesting services can also be implemented like:

  • Security
  • Just-In-Time Activation
  • Logging

Whether you find this sink useful or not, I hope you can see the power of method interception. The notion of message sinks provide new and interesting ways to reuse code.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here