Customizing generated Web Service proxies in Visual Studio 2005

Jelle Druyts

Applies to:
  • Visual Studio 2005
  • Web Services
Summary:

In the early days of the .NET Framework, Visual Studio .NET gave us a huge productivity boost by making it very easy to create and consume Web Services. The simplicity of the [WebMethod] attribute to publish a method as a Web Service operation, and the ease of use of the "Add Web Reference" dialog box made Web Services accessible to the masses. Over time, however, the Web Services platform has been shifting more and more towards a mature enterprise environment that goes way beyond the canonical "HelloWorld" web method that comes with every new .asmx file. This also means that the need for power, control and customization has grown considerably in the last years.

One of the main complaints on the consumer side of Web Services has been the lack of extensibility of the "Add Web Reference" tool - this is the infrastructure that generates the client-side proxy classes for Web Service Description Language (WSDL) documents, so that calling your Web Service is as easy as calling a method on a local object. Popular customization demands have been ranging from minor tweaks (such as generating properties instead of public fields to allow for easier databinding), to full control over which code gets generated. You'll be happy to know that the first demand has already been satisfied by default in Visual Studio 2005, where every field is now wrapped in a public property. (If you're unsure why that's such a good thing, Jan Tielens' "Data binding and Web Services using Custom Collections" article on MSDN Belux proposes an interesting solution to a common problem in that space.) But less known is the fact that you now have much more control over the code that gets generated in Visual Studio 2005, as well. In this article, I'll show you how you can inject your own logic in the proxy generation process.

Downloads:
Contents:

Motivation

Let's take a look at why you would want to customize the generated proxy in the first place. One of the most common reasons I've come across is that you might want to replace one of the generated classes by a type that you have defined yourself. Perhaps you would like to use a much richer type, loaded with business functionality, instead of passing along the very simple generated class. Or perhaps you're using a type that has been defined in a common layer, such as an enterprise framework, which is used both at the server side and the client side of the Web Service.

"Hold on! Isn't sharing types over Service boundaries going to make Don Box go crazy?" While the Four Tenets of Service Orientation indeed clearly state "Share Schema, Not Class", sharing types in this way is not a violation of this principle. The data that travels over the wire is still plain XML and the service and the consumer will only talk by means of this data contract. This means they are effectively decoupled on the protocol layer, but in this case the consumer chooses to use a pre-defined type which has the same wire format as the expected XML message. It will take more to get Don Box to go crazy...

Current solutions to this proxy customization problem include manually editing the generated code, but this has the obvious drawback that all your changes are lost if you re-generate the proxy. Another option is not to generate a proxy at all, and manually coding up the typed proxy. This is error-prone and cumbersome work, but it's not too hard and it has its advantages (like the added control over the proxy you gain). You could also consider creating or buying a separate tool to generate the proxies for you, but then you'd need yet another development tool to install and upgrade.

As a side note: don't confuse this concept of "shared types" with the new "wsdl.exe /sharetypes" option. WSDL.exe is the tool that drives the proxy generation process, and the /sharetypes option will just look at all the types it's generating from multiple WSDL files and only generate types with the exact same wire format once, instead of generating a separate one for each Web Service. So this option applies to types that are shared between different Web Service instances, not between one Web Service instance and its consumer.


Schema Importer Extensions

In the .NET Framework 2.0 and Visual Studio 2005, the new concept of Schema Importer Extensions makes it possible to resolve these shared types. Schema Importer Extensions allow you to customize the code generated from a WSDL document when using automated query tools, such as WSDL.exe or the Add Web Reference dialog box in Visual Studio. It works by inheriting from the System.Xml.Serialization.Advanced.SchemaImporterExtension class, overriding one or more methods, deploying the assembly containing that class to a discoverable location, and finally registering the Schema Importer Extension on your machine. At that time, your Schema Importer Extension class will be called whenever WSDL.exe or Add Web Reference are used to generate classes for a WSDL file. That sounds like a mouthful, but luckily, this process is quite easy. Let's look at a simple implementation for a basic scenario.


A Basic Schema Importer Extension

Let's say we have an assembly containing types that are shared between a Web Service and one of its consumers. In our simple case, we just have one shared type, which is the Customer class in the SampleBusinessTypes namespace that is defined in C# as follows:

[Serializable]
[XmlType(Namespace="http://schemas.samplebusiness.net/SharedTypes")]
public class Customer
{
    public int ID;
    public string FirstName;
    public string LastName;

    public Customer()
    {
    }

    public Customer(int id, string firstName, string lastName)
    {
        this.ID = id;
        this.FirstName = firstName;
        this.LastName = lastName;
    }

    public override string ToString()
    {
        return string.Format("Customer {0}: {1} {2}", this.ID,
            this.FirstName, this.LastName);
    }
}
Since these Customer objects will be serialized, the class has to carry the [Serializable] attribute, and we tell the XML serializer that its namespace should be "http://schemas.samplebusiness.net/SharedTypes" (this will become important later on). The ToString method will simply return a formatted string containing the properties of the object.

Now say we have a Web Service with a method that will return these Customer objects, defined in Visual Basic .NET as follows:

<WebMethod()> _
Public Function GetCustomer(ByVal customerID As Integer) As Customer
    Return New Customer(customerID, "Jelle", "Druyts")
End Function
The client requests a customer by its ID, and gets a dummy object back that will contain the requested customerID.

When we use Add Web Reference to generate a proxy for this Web Service, we find that a class very similar to the Customer class above gets generated file. To look at the generated classes, select the "Show All Files" option in the Solution Explorer, and navigate to the Reference.cs file as shown below:



If we open this file, we will find the generated Customer class defined as follows:



[Serializable]
[XmlType(Namespace="http://schemas.samplebusiness.net/SharedTypes")]
public partial class Customer {
    private int idField;
    private string firstNameField;
    private string lastNameField;

    public int ID {
        get {
            return this.idField;
        }
        set {
            this.idField = value;
        }
    }

    public string FirstName {
        get {
            return this.firstNameField;
        }
        set {
            this.firstNameField = value;
        }
    }

    public string LastName {
        get {
            return this.lastNameField;
        }
        set {
            this.lastNameField = value;
        }
    }
}
In fact, the only functional difference in this simple case is that it doesn't include the ToString method we specified, but it's important to note that this is an entirely different class as far as the CLR is concerned: it lives in a different namespace in a different assembly. All other methods that we would have added on the original type would also have disappeared, only the data contract (i.e. fields and properties) has remained intact. Note that the private fields are now wrapped as public properties as well, as you might recall from the introduction.

Our goal here is to substitute this generated class with the actual class from the shared assembly, bringing back all of its functionality in full glory. Here's where our Schema Importer Extension comes into play. In its most basic form, a Schema Importer Extension will look at the XML namespace and name of a type that is about to be generated, and decide if it wants to substitute it with a well-known CLR type.

public class CustomerSchemaImporterExtension : SchemaImporterExtension
{
    public override string ImportSchemaType(string name, string ns,
        XmlSchemaObject context, XmlSchemas schemas, XmlSchemaImporter importer,
        CodeCompileUnit compileUnit, CodeNamespace mainNamespace,
        CodeGenerationOptions options, CodeDomProvider codeProvider)
    {
        // Check if the namespace and type name match.
        if (ns == "http://schemas.samplebusiness.net/SharedTypes" &&
            name == "Customer")
        {
            // Add a 'using' directive ('Imports' in VB.NET) for the CLR 
            // namespace.
            mainNamespace.Imports.Add(
                new CodeNamespaceImport("SampleBusinessTypes"));

            // Indicate that no XML schema type should be imported but that a
            // well-known CLR type will be used.
            return "SampleBusinessTypes.Customer";
        }
        else
        {
            // No match, delegate to the base class.
            return base.ImportSchemaType(name, ns, context, schemas, importer, 
                compileUnit, mainNamespace, options, codeProvider);
        }
    }
}
As you can see above, the implementation is rather simple: the ImportSchemaType method comes in two flavors that can be overridden. The overload we've chosen here simply accepts the XML type name and namespace (along with a bunch of other parameters for advanced scenarios) on which a decision can be based. If we find that the XML namespace matches the one declared on the [XmlType] attribute on the Customer class, and that the XML type name is exactly that Customer type, then we decide to return the full name of the CLR type that it will be substituted with, in this case SampleBusinessTypes.Customer. If no match was found, we delegate the call back to the base class so a proxy class will be generated for the type. Notice in the sample above that we can do more advanced things here as well, such as adding imports (using statements in C#) to the main namespace, by using the other arguments that have been passed to the ImportSchemaType method. Another common requirement is adding a reference to the assembly containing the shared type, which can be done by calling compileUnit.ReferencedAssemblies.Add(assemblyPath). But for our simple scenario, this will do just fine.


Deploying the Schema Importer Extension

As you can see above, the implementation of the Schema Importer Extension is quite simple. Depending on your requirements and options, deploying this class can range from quite easy to a little more complex. Remember that both Visual Studio and WSDL.exe can use this feature so the most official and generally applicable solution is to strongly sign the assembly and deploy it to the Global Assembly Cache (GAC). This way, it's available to every .NET application running on your machine. To register the Schema Importer Extension, we simply add the following lines to the <configuration> section of the machine.config file:

<system.xml.serialization>
  <schemaImporterExtensions>
    <add name="CustomerSchemaImporterExtension"
         type="JelleDruyts.SchemaImporterExtensions.CustomerSchemaImporterExtension
               JelleDruyts.SchemaImporterExtensions, Version=1.0.0.0, 
               Culture=neutral, 
               PublicKeyToken=c88d0fcd698a2de7" />
  </schemaImporterExtensions>
</system.xml.serialization>
This will tell the .NET runtime that it needs to call the CustomerSchemaImporterExtension from our custom assembly "JelleDruyts.SchemaImporterExtensions.dll" whenever it tries to generate proxies for a WSDL file. With this configuration in place, our Schema Importer Extension is ready to be used from within Visual Studio. If we right-click the Web Reference in the Solution Explorer and choose "Update Web Reference", the Customer class won't be generated anymore:



Looking at the Reference.cs file now, will show that the Customer class has successfully been substituted by our shared SampleBusinessTypes.Customer type. Goal achieved!

Modifying the machine.config file and deploying assemblies to the GAC, however, is something that most enterprise environments don't encourage. You will be happy to know that there are other, less intrusive, options as well. First of all, if you don't want to modify the machine.config file, you can put the configuration section mentioned above in the application's configuration. For Visual Studio 2005, this file is typically located at C:\Program Files\Microsoft Visual Studio 8\Common7\IDE\devenv.exe.config. Since this file is rarely modified, you could even have it installed initially as part of your enterprise wide setup procedure. Second, if you would like to refrain from adding assemblies to the GAC, you can put the assembly in the same directory as the application. Visual Studio even has a location specifically created for such design-time helper assemblies: its PrivateAssemblies directory, which is located right below the directory in which the devenv.exe.config file can be found.

Note, however, that this approach will not always work very well with the new compilation model in ASP.NET 2.0. The actual generation of the Web Service proxy classes will be delayed until runtime, which means that the "Add Web Reference" dialog will only download the WSDL file without generating the proxies. If you wish to make use of a Schema Importer Extension in web scenarios, this means that it must be registered on the web server. Another, perhaps more suitable option, is to pre-compile the entire web site before moving it onto the production server, which also increases performance and security. Installing the Schema Importer Extension on the developers' machines and on the build server should then suffice to make use of its advantages.

So the two most typical deployment locations will probably be the GAC combined with machine.config, or Visual Studio's PrivateAssemblies directory combined with its devenv.exe.config file. This should be perfectly allowable within most enterprises.


Another Scenario: Sharing IEnumerable Types

Let's take a look at another scenario where you would want to share types across a service boundary. Let's say we have a collection type that we want to pass over a Web Service, and that type implements the IEnumerable interface.

public class CustomerCollection : IEnumerable
{
    // ...
}
The Web Service infrastructure requires that we implement a public void Add(object) method on any IEnumerable class, in order for it to successfully serialize across a web method. Unfortunately, this Add method we have to implement isn't very type-safe and it clutters our public interface with technical implementation details. Even worse is that if we generate a proxy for this class, we will find that all type information has been lost: the proxy simply declares the collection as an array of objects (object[]), instead of at least an array of Customers (Customer[]). This way, the clients won't even know which objects to pass along to the Web Service. In most cases, this will be unacceptable, but Schema Importer Extensions can come to the rescue here if we use a simple trick.

First, we define a base class that has the same functionality as the collection shown above, but this time without implementing the IEnumerable interface.

public class CustomerCollectionBase
{
    // ...
}
Next, we declare the class above as inheriting from this base class and implement the IEnumerable interface by simply using the powerful new yield statement in C# as follows:

public class CustomerCollection : CustomerCollectionBase, IEnumerable
{
    public IEnumerator GetEnumerator()
    {
        foreach (Customer customer in customerList)
        {
            yield return customer;
        }
    }
}
We can now use this CustomerCollection type with its IEnumerable support everywhere on the server without a problem. If we make sure that the Web Service returns the CustomerCollectionBase type instead, we avoid the problems mentioned above. Now if the client would generate a proxy for this Web Service, the object array would have been replaced by a properly typed array, so this is already better. But we can even take it one step further: if we substitute this generated class with our well-known CustomerCollection class again using a Schema Importer Extension, then we will have full support for the IEnumerable interface both on the server as on the client. Since both classes have the exact same wire format, the Web Service infrastructure will be able to translate the SOAP messages to instances of this CustomerCollection class without a problem.


A More Advanced Schema Importer Extension

As you can see, there can be quite some use for these Schema Importer Extensions. Therefore, keeping the implementation flexible is probably a big requirement if you intend to use it within an enterprise environment. One solution to make the basic implementation shown above a bit more flexible is to provide the necessary mapping data in a configuration file. This file would define which XML namespaces and type names map to which CLR types, and the Schema Importer Extension could base its decisions on these shared type mappings. If we assume that we have this configuration of mappings in place, the ImportSchemaType implementation of this new SharedTypeSchemaImporterExtension class simply looks like this:

foreach (SharedTypeMappingElement mapping in 
    SchemaImporterExtensionsConfiguration.Instance.SharedTypeMappings)
{
    // Check if the namespace and type name match.
    if (mapping.XmlNamespace == ns && mapping.XmlTypeName == name)
    {
        // Add an assembly reference.
        if (!string.IsNullOrEmpty(mapping.ClrAssemblyPath))
        {
            compileUnit.ReferencedAssemblies.Add(mapping.ClrAssemblyPath);
        }

        // Indicate that no XML schema type should be imported but that a
        // well-known shared CLR type will be used.
        return mapping.ClrClassName;
    }
}
The SchemaImporterExtensionsConfiguration.Instance call in the code above retrieves the configuration settings through the new configuration system available in the .NET Framework 2.0, and its SharedTypeMappings property returns a collection of all the defined mappings. Each mapping is configured in a SharedTypeMapping class, which links the XmlNamespace and XmlTypeName to a ClrClassName and an optional ClrAssemblyPath (an assembly to which to add a reference). The code still remains very simple, but with this configuration system in place it's now possible to dynamically add shared type mappings only by changing a simple configuration section in the application's configuration file. To make this work, it's enough to add the following lines to the same configuration file as before.

<configSections>
  <section name="schemaImporterExtensions"
  type="JelleDruyts.SchemaImporterExtensions.Configuration.SchemaImporterExtensionsConfiguration, 
        JelleDruyts.SchemaImporterExtensions, 
        Version=1.0.0.0, 
        Culture=neutral, 
        PublicKeyToken=c88d0fcd698a2de7"/>
</configSections>
<schemaImporterExtensions>
  <sharedTypeMappings>
    <mapping 
       xmlNamespace="http://schemas.samplebusiness.net/SharedTypes"
       xmlTypeName="Customer"
       clrAssemblyPath="C:\Sample\SampleBusinessTypes.dll"
       clrClassName="SampleBusinessTypes.Customer" />
  </sharedTypeMappings>
</schemaImporterExtensions>
<system.xml.serialization>
  <schemaImporterExtensions>
    <add name="SharedTypeSchemaImporterExtension"
        type="JelleDruyts.SchemaImporterExtensions.SharedTypeSchemaImporterExtension, 
            JelleDruyts.SchemaImporterExtensions, 
            Version=1.0.0.0, 
            Culture=neutral, 
            PublicKeyToken=c88d0fcd698a2de7" />
  </schemaImporterExtensions>
</system.xml.serialization>
The new configuration system even allows this <schemaImporterExtensions> section to be defined in another file through the configSource attribute, so you can write the following:

<schemaImporterExtensions 
  configSource="PrivateAssemblies\JelleDruyts.SchemaImporterExtensions.dll.config"/>
In this case (assuming we're configuring this for Visual Studio), we've placed the configuration in a file called "JelleDruyts.SchemaImporterExtensions.dll.config" in the PrivateAssemblies directory, so it sits right besides the assembly we deployed in there earlier. This configuration file itself would now simply look like the following:

<?xml version="1.0" encoding="utf-8" ?>
<schemaImporterExtensions>
  <sharedTypeMappings>
    <mapping xmlNamespace="http://schemas.samplebusiness.net/SharedTypes"
             xmlTypeName="Customer"
             clrAssemblyPath="C:\Sample\SampleBusinessTypes.dll"
             clrClassName="SampleBusinessTypes.Customer" />
  </sharedTypeMappings>
</schemaImporterExtensions>
Now the entire configuration is separated from the application's configuration file and very easily updateable. Note that, unfortunately, you cannot place this configuration file on a shared location or network drive, since this configSource system forces you to use a relative path at or below the current directory for security reasons. But other than that, we've arrived at a highly flexible, easily deployable and maintainable Schema Importer Extension to be used within Visual Studio.

The full implementation of this SharedTypeSchemaImporterExtension class, along with a test project to try it out with, is available in the downloadable package that accompanies this article. Possible features to even further improve this Schema Importer Extension could include matching the XML namespace and type name to a CLR class using regular expressions, adding additional imports (using statements) to the main namespace, using other advanced features such as CodeDOM to generate additional code, ... But all of this, as they say, is left as an exercise to the reader.


Wrapping Up

As you have seen, Schema Importer Extensions are a powerful new feature in .NET 2.0, allowing you to customize the Web Service proxies that are generated for you by the "Add Web Reference" dialog box in Visual Studio 2005, or by the command-line WSDL.exe tool. The required code can range from very straightforward XML to CLR mappings, to complex type generation using CodeDOM. The deployment of the custom assemblies can be quite easy as well, and even configuration can be handled with minimal deployment impact and maximum flexibility.

For further reading, I encourage you to look at the SchemaImporterExtension Technology Sample and the MSDN documentation for the SchemaImporterExtension Class.


About the author

Jelle Druyts is a consultant, specializing in all .NET-related products at the edge of the technology stack. You can regularly find him lurking in the obscure corners of early alphas and betas, exploring the mysterious unknowns so you don't have to. He keeps up with the latest Microsoft-related technologies on his blog at http://jelle.druyts.net.

Jelle Druyts