次の方法で共有


Serializing Read-Only Collections to Code

In some cases, a Component or Control may have the need to contain a read-only collection of complex objects. Additionally, although you don't want to enable a developer to change the collection itself at design time, you still want to make it possible for the developer to edit the items in the collection.

Creating such a collection is not very difficult, but it turns out that the standard CodeDOM serializer for the component doesn't serialize the collection, so the next time the developer loads the component, all the changes are gone. This is because the standard CodeDomSerializer classes don't know how to reference the collection items; the default way is to create new instances of the collection items and add them to the collection, but this isn't possible in this case, since the collection is read-only.

You can still achieve the desired result by creating a custom serializer for the component, and here I'll show you how.

Consider this example Control:

 [DesignerSerializer(typeof(MyControlCodeDomSerializer),
    typeof(CodeDomSerializer))]

public partial class MyControl : UserControl
{
    private MyReadOnlyCollection<MyClass> readOnlyClasses_;

    private List<MyClass> writableClasses_;
 
    public MyControl()

    {

        this.InitializeComponent();
 
        this.writableClasses_ = new List<MyClass>();

        this.readOnlyClasses_ =

            new MyReadOnlyCollection<MyClass>

            (this.writableClasses_);
 
        this.writableClasses_.Add(new MyClass());

        this.writableClasses_.Add(new MyClass());

    }
 
    public MyReadOnlyCollection<MyClass> ReadOnlyClasses

    {

        get { return this.readOnlyClasses_; }

    }
}

This control exposes a single read-only collection as a property. For simplicity, this collection only contains two items, but in a real-world case, other logic in the control would probably add or remove items internally. Although the collection itself can't be edited by external callers (you can't add or remove items), it's perfectly possible and legal to reference and edit the existing items in the collection. I'll show you what I mean in a minute, but for completeness, let's first list the MyClass class:

 public class MyClass
{
    private int myInt_;
    private string myString_;
 
    internal MyClass()
    {
    }
 
    [DefaultValue(0)]
    public int MyInt
    {
        get { return this.myInt_; }
        set { this.myInt_ = value; }
    }
 
    [DefaultValue(null)]
    public string MyString
    {
        get { return this.myString_; }
        set { this.myString_ = value; }
    }
}

Referencing and editing one of the MyClass instances in the control is straightforward. If the name of a MyControl instance is mc, the following code assigns the number 10 to the first element in the collection:

 mc.ReadOnlyClasses[0].MyInt = 10;

Although you can do this from code, or from within the Visual Studio designer, the default CodeDOM serializer doesn't serialize this value, so the next time you load the control, the element's MyInt property will have the default value of 0.

A solution to this challenge is to associate a custom serializer to the control, and as you can tell from the code above, the MyControl class has a DesignerSerializer attribute which defines a custom CodDOM serializer for the control; if that attribute had not been present, or if the serializer isn't implemented correctly, the read-only collection will never be serialized.

However, before we look at MyControlCodeDomSerializer, we need to define how the serialized code should look. My first take on this was to serialize code like this:

 this.myControl1.ReadOnlyClasses[0].MyInt = 1;

This compiles and works at run-time, but doesn't work at design-time! This happens because the designer infrastructure interprets the angle brackets as an array indexer (CodeArrayIndexerExpression) instead of an indexer property (CodeIndexerExpression). At this time, I haven't been able to figure out if this can be fixed (but if anyone knows, please leave a message), so the workaround is to define a method on the collection class which enables you to access an item in a manner which isn't syntactically ambiguous. The collection class is very simple:

 public class MyReadOnlyCollection<T> : ReadOnlyCollection<T>
{
    public MyReadOnlyCollection(IList<T> list)
        : base(list)
    {
    }
 
    [EditorBrowsable(EditorBrowsableState.Never)]
    public T GetItemAt(int index)
    {
        return this[index];
    }
}

When using this collection class, code can now be serialized like this:

 this.myControl1.ReadOnlyClasses.GetItemAt(0).MyInt = 1;

Note that the GetItemAt method is hidden from Intellisense by its EditorBrowsable attribute. Using a custom collection class is obviously a limitation in usability of this approach, but the rest of the code in this post would work similarly if it turns out to be possible to indicate to the design-time environment that the angle brackets in this case should be interpreted as an indexer property.

Let's now turn to the MyControlCodeDomSerializer class, which derives from System.ComponentModel.Design.Serialization.CodeDomSerializer. To make it serialize the desired code, I override its Serialize method:

 public override object Serialize
    (IDesignerSerializationManager manager,
    object value)
{
    CodeDomSerializer serializer =
        manager.GetSerializer(value.GetType().BaseType,
        typeof(CodeDomSerializer))
        as CodeDomSerializer;
 
    object codeObject = serializer.Serialize(manager,
        value);
 
    CodeStatementCollection codeStatements =
        codeObject as CodeStatementCollection;
    MyControl mc = value as MyControl;
 
    if ((codeStatements != null) && (mc != null))
    {
        this.SerializeCollectionProperty(manager,
            codeStatements, mc);
    }
 
    return codeObject;
}

Initially, I extract the serializer for the value's base type and use that to serialize the object. This creates all the standard code statements, such as a creation statement for the component, setting of the Name, Size, and other properties, etc. This is the collection of code statements that I intend to append with the read-only collection code, and this is done (partly) by the SerializeCollectionProperty method:

 private void SerializeCollectionProperty
    (IDesignerSerializationManager manager,
    CodeStatementCollection codeStatements,
    MyControl mc)
{
    CodeExpression myControlExp =
        this.GetExpression(manager, mc);
    CodePropertyReferenceExpression colPropExp =
        new CodePropertyReferenceExpression
        (myControlExp, "ReadOnlyClasses");
 
    for (int i = 0; i < mc.ReadOnlyClasses.Count; i++)
    {
        MyControlCodeDomSerializer.SerializeMyClass
            (manager, codeStatements, mc, colPropExp,
            i);
    }
}

This method just sets up some expressions for the SerializeMyClass method, which does the real work. It uses the the base class' GetExpression method to retrieve an expression for the MyControl instance. This will typically be an expression that serializes to something like this.myControl1. It then creates an expression representing the ReadOnlyClasses property of MyControl. For every item in the collection, the method then delegates the real work to the SerializeMyClass method:

 private static void SerializeMyClass
    (IDesignerSerializationManager manager,
    CodeStatementCollection codeStatements,
    MyControl mc,
    CodePropertyReferenceExpression colPropExp,
    int i)
{
    MyClass currentMC = mc.ReadOnlyClasses[i];
    CodeMethodInvokeExpression currentMCExp =
        new CodeMethodInvokeExpression
        (colPropExp, "GetItemAt",
        new CodePrimitiveExpression(i));
 
    ExpressionContext newContext =
        new ExpressionContext(currentMCExp,
        typeof(MyClass), mc, currentMC);
    manager.Context.Push(newContext);
 
    try
    {
        CodeDomSerializer currentMCSerializer =
            manager.GetSerializer(currentMC.GetType(),
            typeof(CodeDomSerializer))
            as CodeDomSerializer;
        CodeStatementCollection currentMCStmts =
            currentMCSerializer.Serialize(manager,
            currentMC) as CodeStatementCollection;
        foreach (CodeStatement stmt in currentMCStmts)
        {
            codeStatements.Add(stmt);
        }
    }
    finally
    {
        manager.Context.Pop();
    }
}

This is the core of the solution: The first thing to do is to get a reference to the MyClass instance to serialize. For this, we need the real instance, as well as an expression that indicates to the CodeDomProvider which will eventually serialize the code, how that instance should be serialized. In this case, the instance should be serialized as a call to the GetItemAt method (if you still recall that particular discussion above), so I create an expression that references that method with the correct parameter value.

At this point, I could obviously just continue in that vein and create new CodeExpressions for every property of MyClass, but that's a lot of work: For once, I need to take into account such things as each property's DefaultValue attribute so I don't serialize default values; secondly, imagine that MyClass in itself was a deeply nested structure - then I'd need to write complex recursive code to traverse that object graph. Fortunately, the standard CodeDomSerializers can already do this; they just need a little help in figuring out how to serialize the MyClass reference itself.

This is done by creating a new ExpressionContext for the MyClass instance. That ExpressionContext is populated with the expression for the instance (you will recall that this was the call to the GetItemAt method), the type, parent, and the actual instance itself. When this ExpressionContext is pushed on the ContextStack, other serializers will use this pre-populated expression every time they encounter the MyClass instance - instead of creating an expression themselves!

At this point, all that is left is to get a CodeDomSerializer for MyClass, serialize the instance and merge the resulting code statements into the overall collection of code statements for MyControl. Finally, you need to remember to Pop the ContextStack when you are done with the MyClass instance.

To reiterate, this will serialize code like this:

 this.myControl1.ReadOnlyClasses.GetItemAt(0).MyInt = 1;
this.myControl1.ReadOnlyClasses.GetItemAt(0).MyString = "ploeh";

Whenever the designer loads code like this into a DesignSurface, the items in the read-only collection will be pre-populated with the correct values.

Obviously, CodeDomSerializer code like this is a bit complex, so you'd want to be able to unit test your code to ensure that it works as intended. To do so, refer to my post about unit testing serializers.