Services should be decoupled from their clients as much as
possible, especially when it comes to versioning and technologies. Any
version of the client should be able to consume any version of the service
and should do so without resorting to version numbers (such as those in
assemblies), because those are .NET-specific. When a service and a client
share a data contract, an important objective is to allow the service and
client to evolve their versions of the data contract separately. To allow
such decoupling, WCF needs to enable both backward and forward
compatibility, without even sharing types or version information. There
are three main versioning scenarios:By default, data contracts are version-tolerant and will silently
ignore incompatibilities.
1. New Members
The most common change made to a data contract is adding new
members on one side and sending the new contract to an old client or
service. When deserializing the type, DataContractSerializer will simply ignore the
new members. As a result, both the service and the client can accept
data with new members that were not part of the original contract. For
example, suppose the service is built against this data contract:
[DataContract]
struct Contact
{
[DataMember]
public string FirstName;
[DataMember]
public string LastName;
}
yet the client sends it this data contract instead:
[DataContract]
struct Contact
{
[DataMember]
public string FirstName;
[DataMember]
public string LastName;
[DataMember]
public string Address;
}
Note that adding new members and having them ignored in this way
breaks the data contract schema compatibility, because a service (or a
client) that is compatible with one schema is all of a sudden compatible
with a new schema.
2. Missing Members
By default, WCF lets either party remove members from the data
contract. That is, you can serialize a type without certain members and
send it to another party that expects the missing members. Although
normally you probably won’t intentionally remove members, the more
likely scenario is when a client that is written against an old
definition of the data contract interacts with a service written against
a newer definition of that contract that expects new members. When, on
the receiving side, DataContractSerializer does not find in the
message the information required to deserialize those members, it will
silently deserialize them to their default values; that is, null for reference types and a zero whitewash
for value types. In effect, it will be as if the sending party never
initialized those members. This default policy enables a service to
accept data with missing members or return data with missing members to
the client. Example 1
demonstrates this point.
Example 1. Missing members are initialized to their default values
/////////////////////////// Service Side ////////////////////////////// [DataContract] struct Contact { [DataMember] public string FirstName;
[DataMember] public string LastName;
[DataMember] public string Address; }
[ServiceContract] interface IContactManager { [OperationContract] void AddContact(Contact contact); ... }
class ContactManager : IContactManager { public void AddContact(Contact contact) { Trace.WriteLine("First name = " + contact.FirstName); Trace.WriteLine("Last name = " + contact.LastName); Trace.WriteLine("Address = " + (contact.Address ?? "Missing")); ... } ... } /////////////////////////// Client Side ////////////////////////////// [DataContract] struct Contact { [DataMember] public string FirstName;
[DataMember] public string LastName; }
Contact contact = new Contact() { FirstName = "Juval", LastName = "Lowy" };
ContactManagerClient proxy = new ContactManagerClient(); proxy.AddContact(contact);
proxy.Close();
|
The output of Example 3-13 will be:
First name = Juval
Last name = Lowy
Address = Missing
because the service received null for the Address data member and coalesced the trace to
Missing. The problem with Example 3-13 is that you will
have to manually compensate this way at every place the service (or any
other service or client) uses this data contract.
2.1. Using the OnDeserializing event
When you do want to share your compensation logic across
all parties using the data contract, it’s better to use the OnDeserializing event to initialize
potentially missing data members based on some local heuristic. If the
message contains values for those members, they will override your
settings in the OnDeserializing
event. If it doesn’t, the event handling method provides some
nondefault values. Using the technique shown here:
[DataContract]
struct Contact
{
[DataMember]
public string FirstName;
[DataMember]
public string LastName;
[DataMember]
public string Address;
[OnDeserializing]
void OnDeserializing(StreamingContext context)
{
Address = "Some default address";
}
}
the output of Example 3-13 will be:
First name = Juval
Last name = Lowy
Address = Some default address
2.2. Required members
Unlike ignoring new members, which for the most part is benign,
the default handling of missing members may very likely cause the
receiving side to fail further down the call chain, because the
missing members may be essential for correct operation. This may have
disastrous results. You can instruct WCF to avoid invoking the
operation and to fail the call if a data member is missing by setting
the IsRequired property
of the DataMember attribute to
true:
[DataContract]
struct Contact
{
[DataMember]
public string FirstName;
[DataMember]
public string LastName;
[DataMember(IsRequired = true)]
public string Address;
}
The default value of IsRequired is false; that is, to ignore the missing
member. When, on the receiving side, DataContractSerializer does not find the
information required to deserialize a member marked as required in the
message, it will abort the call, resulting in a NetDispatcherFaultException on the sending
side. For instance, if the data contract on the service side in Example 3-13 were to mark the
Address member as required, the
call would not reach the service. The fact that a particular member is
required is published in the service metadata, and when it is imported
to the client, the generated proxy definition will also have that
member as required.
Both the client and the service can mark some or all of the data
members in their data contracts as required, completely independently
of each other. The more members that are marked as required, the safer
the interaction with a service or a client will be, but at the expense
of flexibility and versioning tolerance.
When a data contract that has a required new member is sent to a
receiving party that is not even aware of that member, such a call is
actually valid and will be allowed to go through. In other words, if
Version 2 (V2) of a data contract has a new member for which IsRequired is set to true, you can send V2 to a party expecting
Version 1 (V1) that does not even have the member in the contract, and
the new member will simply be ignored. IsRequired has an effect only when the
V2-aware party is missing the member. Assuming that V1 does not know
about a new member added by V2, Table 1 lists the
possible permutations of allowed or disallowed interactions as a
product of the versions involved and the value of the IsRequired property.
Table 1. Versioning tolerance with required members
IsRequired | V1 to
V2 | V2 to
V1 |
---|
False | Yes | Yes |
True | No | Yes |
An interesting situation relying on required members has to do
with serializable types. Since serializable types have no tolerance
for missing members by default, the resulting data contract will have
all data members as required when they are exported. For example, this
Contact definition:
[Serializable]
struct Contact
{
public string FirstName;
public string LastName;
}
will have the metadata representation:
[DataContract]
struct Contact
{
[DataMember(IsRequired = true)]
public string FirstName
{get;set;}
[DataMember(IsRequired = true)]
public string LastName
{get;set;}
}
To set the same versioning tolerance regarding missing members
as the DataContract
attribute, apply the OptionalField
attribute on the optional member. For example, this Contact definition:
[Serializable]
struct Contact
{
public string FirstName;
[OptionalField]
public string LastName;
}
will have the metadata representation:
[DataContract]
struct Contact
{
[DataMember(IsRequired = true)]
public string FirstName
{get;set;}
[DataMember]
public string LastName
{get;set;}
}
3. Versioning Round-Trip
The versioning tolerance techniques discussed so far for
ignoring new members and defaulting missing ones are suboptimal: they
enable a point-to-point client-to-service call, but have no support for
a wider-scope pass-through scenario. Consider the two interactions shown
in Figure 1.
In the first interaction, a client that is built against a new
data contract with new members passes that data contract to Service A,
which does not know about the new members. Service A then passes the
data to Service B, which is aware of the new data contract. However, the
data passed from Service A to Service B does not contain the new members—they were silently dropped during
deserialization from the client because they were not part of the data
contract for Service A. A similar situation occurs in the second
interaction, where a client that is aware of the new data contract with
new members passes the data to Service C, which is aware only of the old
contract that does not have the new members. The data Service C returns
to the client will not have the new members.
This situation of new-old-new interaction is called a
versioning round-trip. WCF supports handling of
versioning round-trips by allowing a service (or client) with knowledge
of only the old contract to simply pass through the state of the members
defined in the new contract without dropping them. The problem is how to
enable services/clients that are not aware of the new members to
serialize and deserialize those unknown members without their schemas,
and where to store them between calls. WCF’s solution is to have the
data contract type implement the IExtensibleDataObject interface, defined
as:
public interface IExtensibleDataObject
{
ExtensionDataObject ExtensionData
{get;set;}
}
IExtensibleDataObject
defines a single property of the type ExtensionDataObject.
The exact definition of ExtensionDataObject is irrelevant, since
developers never have to interact with it directly. ExtensionDataObject has an internal linked
list of object references and type
information, and that is where the unknown data members are stored. In
other words, if the data contract type supports IExtensibleDataObject, when unrecognized new
members are available in the message, they are deserialized and stored
in that list. When the service (or client) calls out—passing the old
data contract type, which now includes the unknown data members inside
ExtensionDataObject—the unknown
members are serialized out into the message in order. If the receiving
side knows about the new data contract, it will get a valid new data
contract without any missing members. Example 2 demonstrates
implementing and relying on IExtensibleDataObject. As you can see, the
implementation is straightforward: just add an ExtensionDataObject automatic property with
explicit interface implementation.
Example 2. Implementing IExtensibleDataObject
[DataContract] class Contact : IExtensibleDataObject { ExtensionDataObject IExtensibleDataObject.ExtensionData {get;set;}
[DataMember] public string FirstName;
[DataMember] public string LastName; }
|
3.1. Schema compatibility
While implementing IExtensibleDataObject enables
round-tripping, it has the downside of enabling a service that is
compatible with one data contract schema to interact successfully with
another service that expects another data contract schema. In some
esoteric cases, the service may decide to disallow round-tripping and
enforce its own version of the data contract on downstream services.
Using the ServiceBehavior attribute,
services can instruct WCF to override the handling of unknown members
by IExtensibleDataObject and ignore
them even if the data contract supports IExtensibleDataObject. The ServiceBehavior
attribute offers the Boolean property IgnoreExtensionDataObject, defined
as:
[AttributeUsage(AttributeTargets.Class)]
public sealed class ServiceBehaviorAttribute : Attribute,...
{
public bool IgnoreExtensionDataObject
{get;set;}
//More members
}
The default value of IgnoreExtensionDataObject is false. Setting it to true ensures that all unknown data members
across all data contracts used by the service will always be
ignored:
[ServiceBehavior(IgnoreExtensionDataObject = true)]
class ContactManager : IContactManager
{...}
When you import a data contract using Visual Studio 2010, the
generated data contract type always supports IExtensibleDataObject, even if the original
data contract did not. I believe that the best practice is to always
have your data contracts implement IExtensibleDataObject and to avoid setting
IgnoreExtensionDataObject to
true. IExtensibleDataObject
decouples the service from its downstream services, allowing them to
evolve separately.