This article is part of a series of 5 about undo/redo:
- Announcement and introduction
- Getting started – tutorial
- Logical operations, scopes, naming
- Recorders, recorder providers, callbacks
- Case study: Visual Designer
NOTE: This blog post is about an available pre-release of PostSharp. You can install PostSharp 4.0 only using NuGet by enabling the “Include pre-release” option. Undo/Redo is implemented in the package PostSharp.Patterns.Model.
This blog post covers the following topics:
-
A conceptual introduction to logical operations
-
Defining the operation name declaratively
-
Avoiding automatic scopes
-
Defining atomic scopes declaratively
-
Defining scopes imperatively
-
Customizing scope names
Logical Operations
The Recordable patterns record primitive changes to an object model into a Recorder object. Primitive changes are typically field value changes or primitive collections operations such as Add or Remove. The Recorder itself is basically a list (i.e. a linear collection) of atomic changes. This simple and beautiful structure is sufficient to implement the undo changes and revert the object model to any point in the past.
In practice however, you don’t want to undo to any random point in the past, but only to safe points. Typically, work on an object model is separated into logical operations, which must be undone or redone as a whole. Users always want to undo an operation, not a primitive change. Logical operations are implemented using the concept of recording scope. All changes done within a scope are grouped into a single operation.
By default, any public method of a Recordable object automatically executes inside a scope. That is, all changes done by a single method call will always be undone as a whole.
Note that scopes don’t necessarily define new operations. Because scopes can be nested but operations cannot, only the outermost will open an operation. Nested scope will be ignored unless they are atomic scopes (see below).
Defining the operation name declaratively
Because they are apparent to users, operations must have a name that is meaningful to the user. One way to do that is to use the [RecordingScope] custom attribute. Let’s see this on an example:
[Recordable] class TableBooking { public string CustomerName { get; set; } public DateTime StartTime { get; set; } public DateTime EndTime { get; set; }
[RecordingScope("Postpone booking")] public void Postpone(TimeSpan time) { this.StartTime += time; this.EndTime += time; } }
Avoiding automatic scopes
There are situations where you want to prevent the default behavior that executes each public method in a scope. To opt-out, you can use the [RecordingScope] custom attribute and define the RecordingScopeOption.Skip flag.
[Recordable] class TableBooking { public string CustomerName { get; set; } public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } [RecordingScope(RecordingScopeOption.Skip)] public void Postpone(TimeSpan time) { this.StartTime += time; this.EndTime += time; } }
Defining atomic scopes declaratively
An atomic scope is a scope whose changes get rolled back if the code running inside the scope is not successful. Atomic scopes give the feeling that the code runs in a “transaction”. However, unlike real transactional systems, atomic recording scopes don’t provide transaction isolation. Changes are visible from other threads as soon as they are performed; there is no Commit semantic.
In the following code snippet, the [RecordingScope] attribute ensures that any change performed by the LoadFile method to the object will be rolled back to its initial state in case the input file contains a line that cannot be parsed into an integer.
[Recordable] class IntList { [Child] private readonly AdvisableCollection<int> list = new AdvisableCollection<int>();
[RecordingScope(RecordingScopeOption.Atomic)] public void LoadFile(string file) { foreach (string line in File.ReadAllLines(file)) { this.list.Add(int.Parse(line)); } } }
Defining scopes imperatively
So far, we’ve seen how to define scopes declaratively using the [RecordingScope] custom attribute. Declarative programming is convenient, but sometimes more flexibility is needed. For these situations, the Recorder.OpenScope method must be used. It allows you to open a scope, set its name, and determine whether it should be atomic. The OpenScope method returns a RecordingScope object, which must be disposed at the end of the scope. For atomic scopes, you must invoke the Complete method before disposing the object, otherwise the changes performed in this scope will be rolled back.
The code snippet below uses the [RecordingScope] attribute to opt-out from the default scope, then uses OpenScope to open a new scope with a custom name.
[Recordable] class IntList { [Child] readonly AdvisableCollection<int> list = new AdvisableCollection<int>();
[RecordingScope(RecordingScopeOption.Skip)] public void LoadFile(string file) { string scopeName = string.Format("Loading from {0}", file);
using ( RecordingScope scope = RecordingServices.DefaultRecorder.OpenScope(scopeName, RecordingScopeOption.Atomic)) { foreach (string line in File.ReadAllLines(file)) { this.list.Add(int.Parse(line)); } scope.Complete(); } } }
Customizing scope names
In the example above, I’ve shown how to specify a custom scope name when the scope is opened. This approach requires to mix UI code (generation of human-readable names) into domain code. It also demands more boilerplate code and can be cumbersome if you need have a large number of operations to customize. For instance, what if you need to have a proper name for the operation of setting every property in your classes?
This use case is covered by the OperationFormatter class. PostSharp comes with a default formatter, which can be overridden thanks to the RecordingServices.OperationFormatter property. Formatters form a chain of responsibility; if one formatter is not able to provide a name for an operation, it should return null, which causes the next formatter to be invoked.
Formatters have access to the operation descriptor, which is an object describing the operation. The IOperationDescriptor interface has a single property named OperationKind, which informs you to which class the descriptor must be cast.
In the following code snippet, we will show how to create a custom formatter that generates the same name as in the previous example.
class CustomFormatter : OperationFormatter { public CustomFormatter(OperationFormatter next) : base(next) { }
protected override string FormatImpl(IOperationDescriptor operation) { if (operation.OperationKind != OperationKind.Method) return null;
MethodExecutionOperationDescriptor descriptor = (MethodExecutionOperationDescriptor) operation; if (descriptor.Method.DeclaringType == typeof (IntList) && descriptor.Method.Name == "LoadFile") { string file = (string) descriptor.Arguments[0]; return string.Format("Loading from {0}", file); } return null; } }
The following code inserts our custom formatter in the chain of responsibility:
RecordingServices.OperationFormatter = new CustomFormatter(RecordingServices.OperationFormatter);
Of course, the example above is rather naïve because there is too much coupling between the formatter and the domain code. A more serious implementation would probably rely on other custom attributes such as [DisplayName] of the System.ComponentModel namespace. PostSharp is agnostic about the implementation of custom formatters and I just wanted to keep this example simple.
Summary
Scopes are the mechanism through which logical operations are defined. Logical operations are the “things” that are exposed to the user and that can be undone or redone. Therefore, they must be named. PostSharp provides three ways to define scope: implicitly, declaratively, and imperatively. It also provides, orthogonally, two ways to name operations: by setting the name when the scope is created, or lazily, based on the concept of operation formatter, which allows to better separate UI concerns from business logic. Finally, PostSharp defines the notion of atomic scope, which causes all changes to be rolled back in case of exception.
These few concepts allow to customize how your code maps to undoable operations.
But there is more. In the next post, I will talk about multiple recorders and custom operations.
Happy PostSharping!
-gael
UPDATE: Change product version from PostSharp 3.2 to PostSharp 4.0.