Best practices

Configuration

JuggerNET has a very mature set of configuration options. Configuration is a critical part of the application because the .NET application will typically need to start up a JVM with all the options that you would be using to start up a corresponding Java process. Typically used configuration options include the classpath, the maximum heapsize, a security policy file, etc.

You can configure the runtime environment in several different ways. One way that you're probably familiar with as a .NET developer is via an XML config file, but here's the complete list of configuration mechanisms that are at your command:

  • Configuration hook
    Build a special callback into your proxy assembly. The callback will be invoked at various points in the lifecycle of the application, giving you multiple opportunities to preset, set, or override configuration options. See the Confighook example for more details. Config hooks only make sense if you build them into an assembly containing the types that you're using. If you're using a precreated integration assembly, config hooks do not apply to you.
  • Explicitly
    Provide all runtime settings in your application code. No one (except possibly configuration hooks) can mess around with these settings because they're completely hidden from the end user. Combine explicit configuration with your own custom configuration mechanisms for totally custom configuration mechanisms.
  • XML config file
    Put the configuration information into an XML file and either specify the filename or rely on the .NET configuration framework to find it for you based on the standard naming policies for config files.
  • Default
    If your application only uses built-in Java types and you're not tied to a particular version of Java, just let the runtime library find a JVM for you and run with it.

These configuration options, possibly even in combination, give you an immense degree of flexibility for creating a self-contained, self-configured JuggerNET-enabled application.

Let's focus on the two mechanisms that require coding: the explicit and the configuration hook mechanism. Let's look at the explicit mechanism first because the confighook is a derived mechasnism. Some background on what happens at runtime is probably helpful too, so let's look at that first.

Explicit Configuration

When your application is launched, it starts out as a .NET application that is totally unaware of the fact that later on there will be some Java components in the mix. The Java components are only required later, when your application issues its first call that is really implemented in terms of a wrapped Java class. That is the point where a correctly configured Java Virtual Machine (or Shared JVM) needs to be available or the Java call will fail. The generated .NET proxy types have the capability of "on-demand" JVM loading, i.e., you don't have to load a JVM at the beginning of your application, but you can.

We recommend that you initialize your JVM at a well-defined point in your application, as early as possible. The reasons are:

  • Having a well-defined point for configuration and loading of the JVM allows you to diagnose misconfigurations before your users have gotten too deeply into the .NET parts of the application and experience a harder to debug partial application failure with a cryptic exception message.
  • You have the ability to provide application-specific diagnostic messages in a central location.
    For example, you can attempt to load an application Java class and in case of failure display an application-specific error dialog that tells users which jar file might be missing.

The JVM will be up from the moment your application requests the execution of some Java code or you explicitly call Load() until you explicitly destroy it or your process terminates. Once you have loaded a JVM you usually cannot unload and then reload it successfully.

Here's an application Main() that employs some best practices and demonstrates some common patterns for JVM configuration and loading.

public static void Main( string[] args )
{      
    try      
    {              
        // use one of several overloaded factory methods to get a JvmLoader instance
        IJvmLoader     loader = JvmLoader.GetJvmLoader( true, true );
 
        // use a JVM based on a relative path (we're assuming that we supply our own JVM)
        loader.JvmPath = @"..\jre\bin\server\jvm.dll";

        // set the maximum heap size to at least 512M          
        // but use a larger value if already configured
        if( loader.MaximumHeapSize < 512 )
            loader.MaximumHeapSize = 512;
        loader.InitialHeapSize = loader.MaximumHeapSize;

        // set the classpath by using AppendToClassPath
        // in order not to overwrite otherwise configured classpath roots
        loader.AppendToClassPath( @"..\lib\myapp.jar" );
        loader.AppendToClassPath( @"..\lib\myutil.jar" );

        // set a custom -D option
        loader.DashDOption[ "com.me.myOption1" ] = "myValue1";

        // attempt to explicitly load a JVM
        IJvm           jvm = loader.Load();
        if( jvm != null )
        {
            // attempt to resolve a Java type that should be available if all is well;
            // this will throw an exception if a problem occurs
            Class.ForName( "com.me.MyType" );
        }
    }
    catch( Throwable t )
    {
        // complain that the jar file in which you know com.me.MyType to be
        // can't be found.  Possibly do some extended, app-specific diagnostics.
        // exit 
    }
    catch( JuggerNETFrameworkException jnfe )
    { 
        // handle the exception in an app-specific manner 
        // exit
    }

    // now comes your real application code
}

What do we see in this snippet?

  • The use of a factory method to acquire a JvmLoader instance (there are several overloaded variants of GetJvmLoader())
  • The use of a relative path to find a JVM. Using relative paths (relative to whatever directory you wish to use as a base directory) allows you to create xcopy-deployable applications.
  • The use of JVM properties
  • Explicit JVM loading via the Load() method.
  • The use of exception handling to handle misconfigurations resulting in JVM load errors and classpath misconfigurations

Some additional things that are not demonstrated but that you might wish to consider nevertheless:

  • You can also terminate the JVM explicitly by calling the Destroy() method on the loaded JVM instance.
    Destroy() will attempt to unload the JVM, after which you usually won't be able to reload it. There is usually no advantage in calling Destroy() manually because the JVM will be shut down automatically when your process terminates.
  • Use environment variables to provide some configuration settings or to derive configuration settings
  • Use a GetJvmLoader() version that specifies a configuration file and then double-check the settings using the explicit configuration API before trying to load the JVM, thereby combining flexibility and enforcement.

Configuration with a Config Hook

Configuration via config hooks is basically explicit configuration via a callback. Here's how it works:

  • You register a callback (config hook) with the runtime library
  • Your callback is invoked for the first time just after the JvmLoader has been instantiated.
    You can explicitly configure the default settings for your application at this time.
  • Your callback is invoked just before the JVM is loaded.
    You can check or override the settings that the application developer might have configured, again via explicit configuration calls.
  • Your callback is invoked just after the JVM was successfully loaded.
    You can attempt to perform some Java operations to see whether the classpath was correct or you can perform some application initialization before the application developer's first Java call is executed.

You can hopefully see from this overview just how versatile config hooks are. But how do you create a config hook? It's easy:

  1. Create a .NET type and tag it with the Codemesh.JuggerNET.JuggerNETInit attribute.
    This will cause the type's Init() method to get called by the JuggerNET runtime when the assembly is loaded.
  2. Implement a public static void Init() method in which you register your configuration hook.
  3. Implement your configuration hook method as a Codemesh.JuggerNET.ConfigurationHook delegate.
  4. Compile your .NET type into the proxy assembly that contains the proxy types for which you want to configure the application's Java environment.

The following example illustrates this approach:

using Codemesh.JuggerNET;
 
[JuggerNETInit]  
public class HookRegistrar  
{  
    // this method satisfies the JuggerNETInit contract and will be called  
    // when the assembly is loaded (because of the custom attribute) 
    public static void Init() 
    {    
        JvmLoader.RegisterConfigurationHook( new ConfigurationHook( ConfigMethod ) );
    }    
 
    // the actual callback that will be invoked by the runtime
    public static void ConfigMethod( IJvmLoader loader, int when )
    {
        switch( (When)when )
        {    
            case When.XMOG_AFTER_INITIALIZATION:
                // before user's application code is executed 
                loader.MaximumHeapSize = 512;       
                break;  
 
            case When.XMOG_BEFORE_LOADING:   
                // after user's application code has had a chance to execute but before  
                // JVM is loaded         
                if( loader.MaximumHeapSize < 512 )   
                    loader.MaximumHeapSize = 512;        
                loader.InitialHeapSize = loader.MaximumHeapSize;  
                break;               
 
            case When.XMOG_AFTER_LOADING:    
                // after JVM has been loaded     
                try     
                {        
                    Class.ForName( "com.my.TestClass" );  
                }          
                catch( ClassNotFoundException cnfe )  
                {             
                    // no matter what exception you throw from here, you
                    // will end up with a JuggerNETFrameworkException which
                    // has the thrown exception as its Cause.
                    throw new 
                        MyDotNetConfigException( cnfe, "There is a misconfiguration in ..." );
                }           
                break;
        }    
    }
}

You can of course also register configuration hooks programmatically from your own assemblies using the JvmLoader.RegisterConfigurationHook() method, but that almost (if not totally) defeats their purpose.

Classes, Types, and Casts

No, we're not talking about the latest theatrical broadway production, this is all about dealing with the type system represented by the proxy classes and interfaces. Let's take a quick look at a common snippet of Java to illustrate the issues:

 [Java]   
InitialContext   ictx = new InitialContext();
MyInterface      ifc = (MyInterface)ictx.lookup( "scope/ifc" );
MyOtherInterface ifc2 = null;     

// the returned instance might also implement another interface   
if( ifc instanceof MyOtherInterface )    
    ifc2 = (MyOtherInterface)ifc;  

How would you do this with proxy types? Here's a C# snippet that will probably work (we'll go into the reasons for the "probably" a little later):

 [C#]  
InitialContext   ictx = new InitialContext();   
MyInterface      ifc = (MyInterface)ictx.lookup( "scope/ifc" );   
MyOtherInterface ifc2 = null;     

if( ifc is MyOtherInterface )       
    ifc2 = (MyOtherInterface)ifc;  

This looks very nice and easy to use, but why did we use this disconcerting word "probably"? The resaon is that whether or not this works depends on the set of proxy types that you have. The lookup() method is declared to return Object, so it doesn't provide us a lot of help in figuring out what is the best proxy type to return from an invocation. Consequently, we inspect the Java type of the returned object and go looking for the "best" .NET proxy type that is available. To that effect, the JuggerNET runtime maintains a mapping between Java and available .NET proxy types. If we find an exactly matching proxy type, we will use it and everything works as expected because that proxy type will implement both proxy interfaces. But what if we don't have an exactly matching proxy type? Then we have to continue looking for a "best possible" match and this is where we get into very ambiguous territory.

In the above example, we have some out-of-band expert knowledge about the fact that the returned object implements two different interfaces and we use that knowledge in the code we are writing. The proxy classes and the runtime library don't have that expert knowledge. If no exact proxy type is available, the runtime has to pick a "decent" match and go with it. It might for example decide to return a proxy implementation type for the MyInterface type or it might decide to return a proxy implementation type for the MyOtherInterface type. The key word here is "or". It will pick one of the two choices and as a result of that choice one of the above casts or type tests will fail (remember: the .NET proxy type . So how do you avoid this problem? We provide a special casting method that is guaranteed to work because it inspects the Java side's type information and allows you to provide your expert knowledge, just like in the Java snippet. Here's what the guaranteed-to-work snippet looks like:

 [C#]       
InitialContext   ictx = new InitialContext();   
MyInterface      ifc = MyInterfaceImpl.From( ictx.lookup( "scope/ifc" ) );   
MyOtherInterface ifc2 = null;     

ifc2 = MyOtherInterfaceImpl.From( ifc );  

Every proxy interface type has a corresponding Impl class which declares static members, including the "cast" method called "From." Essentially, in the above snippet you're saying that you want the lookup() result to be treated as a MyInterface and, in a different context, you also want it to be treated as a MyOtherInterface. The From() method allows you to perform safe class casts independent of the set of proxy types that you have.

The one additional thing that you might run into has to deal with the class keyword. In Java, you can gain access to a class instance by simply using the <classname>.class notation. class is a reserved word in C#, so we cannot generate a property called "class" that would allow you the same usage. The workaround is simple: use Class.ForName(). The only thing you need to remember is to use the fully qualified classname, for example:

 [C#]      
Class   clsJavaString = Class.ForName( "java.lang.String" );  

With this information, you should be able to tackle any casting and class-realted problem from .NET.

Arrays

Arrays are fairly straightforward to use, once you remember that proxy arrays are not .NET arrays. .NET does not allow a type to extend that .NET Array type, which makes it impossible for us to make proxy array types full .NET arrays. The best we can do is to create types that are usable as if they were .NET array types (at least largely). The biggest differences between real .NET arrays and proxy arrays can be found in the following areas:

  • type name
    Proxy array types have a special naming convention that you just need to know (shown below).
  • you cannot instantiate proxy arrays instances using the rectangular bracket syntax, you have to use constructor syntax instead.
  • you cannot use proxy array instances in places where .NET would normally expect an array instance.

On the other hand, leaving these limitations aside, everything else works just as expected:

  • you can use .Length on proxy array instances
  • you can use subscript operators for element access
  • you can use the properties that the .NET framework normally defines for Array instances
  • you can use foreach on proxy arrays instances

Here's an example of how you might use a one-dimensional proxy array for the java.lang.String type (space for 4 string elements):

StringArray    arrStr = new StringArray( 4 );      
Console.WriteLine( "The array size is {0}", arrStr.Length );

arrStr[ 0 ] = "test"; arrStr[ 1 ] = "value"; arrStr[ 2 ] = null; arrStr[ 3 ] = "..."; foreach( string s in arrStr ) Console.WriteLine( "Element: {0}", s != null ? s : "(null)" );

I think this snippet gives you a pretty good idea about the usability of proxy arrays. The names for the proxy array types are derived from the type names of their elements:

Array type naming conventions
Java Type .NET Type Comments
boolean[] Codemesh.JuggerNET.boolArray The only primitive array type that does not follow the Java naming convention (bool vs. boolean)
byte[] Codemesh.JuggerNET.byteArray  
char[] Codemesh.JuggerNET.charArray  
double[] Codemesh.JuggerNET.doubleArray  
float[] Codemesh.JuggerNET.floatArray  
int[] Codemesh.JuggerNET.intArray  
long[] Codemesh.JuggerNET.longArray  
short[] Codemesh.JuggerNET.shortArray  
java.lang.Object[] Java.Lang.ObjectArray  
java.lang.Object[][] Java.Lang.ObjectArrayArray One 'Array' per dimension

Exceptions

In .NET, all types that can be used as exceptions need to extend the System.Exception type. This means that our proxy exception types cannot extend the proxy type for java.lang.Object because .NET (like Java) only allows one superclass per type. Consequently, proxy Exceptions are not proxy Objects and you cannot directly use a proxy Exception instance in a place that expects a proxy Object.

This is typically not a serious limitation and can be worked around using the From() cast. Other than this limitation, proxy exceptions can be used just like regular .NET exceptions. When a Java operation throws an exception, the JuggerNET runtime catches it and translates it into the best-matching .NET exception. The concept of "best-matching" is important because you never know for sure what kind of exception might get thrown and you will almost certainly not have proxy types for all possible Java exception types available!

The JuggerNET runtime is pretty smart about exceptions. When it catches a Java exception, it will first check to see whether we have the exact proxy type available. If the answer is yes, it will create an instance of that type and throw it. If the answer is no, it will check whether the exception's supertype is available as a proxy type. If yes, it will create an instance of that type and throw it; if not, it will continue up the inheritance chain of exception types until it has analyzed all of them and not found a matching proxy type. In this case, it will end up throwing a Codemesh.JuggerNET.JuggerNETProxyException which represents a generic proxy exception type.

There is one more kind of exception that can be thrown by the runtime: the Codemesh.JuggerNET.JuggerNETFrameworkException. This exception type is used to signal problems at the framework level, problems which do not have a corresponding Java root cause. The most basic of such problems is of course the overall inability to load a JVM. As a general rule, your application should have a well-defined JVM initialization block and catch the JuggerNETFrameworkException type to handle misconfiguration-based errors that are most likely to occur at JVM initialization time (see Configuration).

When you're handling .NET exceptions, you can gain access to the complete stacktrace via the StackTrace property and to the exception's message via the Message property.

Strings

Strings are just about as transparent as they can be: a Java string is translated into a .NET string and vice versa. You can use .NET string literals in any place that expects Java Objects or Strings. This comes with one limitation though: you do not have access to the methods provided by the Java String type. This is not a terribly serious limitation because the .NET String type is also very powerful and contains counterparts to pretty much the entire Java String API. Please let us know if this represents a serious limitation in your particular use case.

Threads

Multithreading is a complex subject, and it is very hard to find the right balance between too little and too much information in a document such as this. We will attempt to point out the basic issues related to multithreading so you know what follow-up questions to ask.

The first thing you should find out when you're considering the use of proxy types on multiple threads is whether the underlying Java types are thread-safe. The proxy types do not introduce any limitations as to their usage on multiple threads. Any proxy object may be used on as many threads as you wish, as long as the underlying Java object supports this usage. Please remember that a proxy object contains a reference to a Java object; it is possible for many different proxy objects to reference one and the same Java object.

As an example of some well-understood Java types with different thread-safety, take the java.util.ArrayList and the java.util.Vector types. They provide similar functionality, but they differ in their approach to thread-safety (chiefly for performance reasons). ArrayLists are faster but do not support unsynchronized concurrent modifications whereas Vectors are slower and do support unsynchronized concurrent modifications. An instance of a Vector could be used without additional safeguards on the .NET side, whereas an instance of an ArrayList could not.

What if you have to use a non-threadsafe type concurrently from multiple .NET threads? If the instance is only used from the .NET side, you can use .NET synchronization to safeguard access. If the instance is also concurrently modified by Java worker threads, you have to use Java synchronization to safeguard against concurrent access. The JuggerNET runtime exposes two helper classes for this purpose: Codemesh.JuggerNET.JavaProxyMonitor and Codemesh.JuggerNET.JavaProxyLockHolder. The JavaProxyMonitor type allows you to explicitly request and relinquish exclusive access to a proxy instance from the current thread of execution via the Enter() and Exit() functions. The following example illustrates the usage:

using Codemesh.JuggerNET;  
using Java.Util;      
 
ArrayList   list = new ArrayList();
...      
 
// code that might be executed concurrently on multiple threads;  
// essentially creates a critical section for the 'list' object;  
// you're in charge of entering/exiting the critical section  
JavaProxyMonitor  monitor = new JavaProxyMonitor( list );   
 
monitor.Enter();  
list.Add( "value1" );  
list.Add( "value2" );  
monitor.Exit();

The above code has just one problem: it's not exception-safe. If one of the Add() methods throws an exception, our thread will never relinquish its exclusive hold on the 'list' instance because the Exit() method never gets called. The JavaProxyLockHolder allows you to use a much nicer pattern that takes care of this concern:

using Codemesh.JuggerNET;  
using Java.Util;      
 
ArrayList   list = new ArrayList();    
...     
 
// code that might be executed concurrently on multiple threads;  
// essentially creates a critical section for the 'list' object  
// that has the scope of the using clause  
using( JavaProxyLockHolder h = new JavaProxyLockHolder( list ) )  
{      
    list.Add( "value1" );      
    list.Add( "value2" );  
}

The second example is much easier to write and much safer to use because the JavaProxyLockHolder automatically invokes Enter() in its constructor and is guaranteed to invoke Exit() when execution leaves the scope of the using clause, whether or not it's due to an exception. You could still call Exit() manually, but that is unnecessary.

Callbacks

Callbacks are one of the advanced features of JuggerNET. Basically, you can take a Java interface and implement its methods in a .NET language. When you create an instance of your .NET type, you can register the object with a Java event source and start receiving asynchronous callbacks. Most commonly, this pattern is used in connection with Listener interfaces, for example a java.jms.MessageListener or a java.awt.event.ActionListener.

As a developer who just uses proxy types (rather than creating them) it is important to know whether the callback support was "turned on" when the proxy types were created. You can be almost certain that this was the case when you're dealing with interfaces that end with "Listener" or with "Callback," or when you're dealing with a well-known callback interface such as java.lang.Runnable. If you're not sure about callback support, check for the presence of a type that has the same name as the interface with an appended "CB." The presence of such a type ensures that the interface is usable as a callback.

All that being said: how do you go about receiving callbacks for events based on an interface? Let's take a look at the JMS MessageListener interface and how you would use it. Let's assume that we have proxy types for the JMS API. The proxy interface for our interface more or less only contains this code:

namespace Javax.Jms  
{      
    public interface MessageListener      
    {         
        public virtual void OnMessage( Message m );
    }
}

In reality, there's a little bit more in the proxy type, but this is the important part that we should focus on. You create your implementation by implementing the above interface, i.e. by providing an implementation for every declared method:

public class MyListener : Javax.Jms.MessageListener  
{      
    public virtual void OnMessage( Javax.Jms.Message m )      
    {          
        Console.WriteLine( "Message: {0}", m.ToString() );    
    }  
}

Now all you have to do is register an instance of MyListener with a JMS message source and you will start seeing some .NET output!

In most applications you will create callbacks at some point relatively early on and keep them for the lifetime of the session. If your application is different and frequently registers/unregisters callbacks newly created callback instances, you might have to explicitly manage the lifecycle of the callback support objects in order to not leak resources. Use the Codemesh.JuggerNET.LifeCycleManager.release() method for that purpose.

The current implementation of JuggerNET has a limitation in that you cannot implement multiple callback interfaces in one type. If you wish to implement multiple Java interfaces on the .NET side, you need to have a proxy interface for a Java interface that extends the interfaces you're interested in.

Object Lifecycle Management and 'using'

The .NET types that you are using to interact with JuggerNET really stand for Java types; we call them proxy types. Every .NET instance of a proxy type is backed by a transparently created Java instance. This happens invisibly to you, under the hood. When your .NET object becomes unused, it will eventually be disposed of by the .NET garbage collector and that will also allow the Java garbage collector to clean up the corresponding Java instance.

In most applications, this works flawlessly without you having to worry about it at all. But there is one complication. The key complication in this picture lies in the word "eventually". Essentially, you have two garbage collectors, one on the Java side and one on the .NET side, each of which controls the lifetime of objects on its side. There is an asymmetrie though because the .NET garbage collector sits in the driver's seat. A Java object that is represented by a .NET proxy object only becomes eligible for garbage collection after it has been released from the .NET side. This happens by default when the .NET garbage collector executes object finalizers.

Unfortunately, the .NET garbage collector does not immediately have to collect the object when you're done with it, in fact it almost never does. When it eventually decides to collect the object, quite a lot of Java objects might have accumulated. It could even be possible that the .NET garbage collector sees no reason to run yet while the JVM has run out of heap space already! This can for example happen when you only used relatively few proxy objects with each of the objects representing massive objects (or object graphs) on the Java side.

Another possible problem can be in the area of performance. It can result from the delay between object creation and object release due to the .NET garbage collector not running frequently. If you create some local objects in a Java method, the Java GC knows that these objects were shortlived and it can throw them away very inexpensively. Once objects have stayed around for a while on the Java side, they migrate into a different heap that is more expensive to collect. This is where the complication arises: unless you do something about it, almost all Java objects are going to be longer lived because their lifecycle is controlled by the .NET garbage collector, which typically runs infrequently. In extreme cases, this can cause an application's performance to become dominated by the garbage collector.

You can avoid both of these problems (heap exhaustion and application slowdown) by employing the 'using' statement. All proxy types implement the .NET IDisposable interface. The IDisposable interface declares a Dispose() method in which you perform destructor-like cleanup. Our proxy types use the Dispose() method to inform the JVM that the underlying Java object is not used anymore. When you use such an IDisposable object with a using statement, the compiler will automatically generate a Dispose() invocation for you. For example:

    using ( Entry e = theSpace.Read( null, null ) )      
    {            
        ExternalEntry ee = (ExternalEntry)e;           
        ...      
    }

This is synonymous with:

    Entry              e = null;      
    try       
    {          
        e = theSpace.Read( null, null );


        ExternalEntry ee = (ExternalEntry)e;            
        ...      
    }       
    finally
    {          
        e.Dispose();
    }

You can see that the 'using' statement saves you quite a bit of work. If you use 'using' for shortlived proxy objects and for proxy objects in tight loops, you're providing tremendous help to the Java garbage collector. There are many applications that will work well without doing this, but there are also many that will only work well if you help the application a little in this way.

Another way to help out would be to force the .NET garbage collector to run (you can't really force it, but you can hint strongly) by invoking

    System.GC.Collect();

This will help, but it can also hurt overall perfomance if you do it too much (you're supposed to leave garbage collection to the .NET runtime).

Setting up your VisualStudio project

This is really not all that hard. All you need to do is add the JuggerNET runtime assembly to your list of assembly references. The only trouble is to pick one of the runtime assemblies. Should you use netrt.dll or netrtsn.dll?

The latter assembly is the strongly named version of the runtime ("sn" stands for strongly named), the former is the "weakly named" (there really is no such term) version. Use the strongly named assembly if you are building strongly named assemblies and the "weakly named" assembly if you're not doing that. Only ever use one version of the runtime assembly per application! This may be an important point if you're using proxy assemblies that have been built by other people. Find out which runtime assembly they are linked with and use the same one.

Why would you pick one version of the assembly over the other?

Strongly named assemblies have the following advantages:

  • they are signed by you.
    Your customers can create trust-based security configurations for their applications.
  • they are versioned.
    You can have multiple concurrent versions of an assembly.
  • they can be used for COM interoperability.
  • they can be deployed into the GAC (Global Assembly Cache)

Strongly named assemblies have the following disadvantages:

  • more infrastructure required to create them (key files, etc.)
  • build complications
  • "harder to get" for .NET novices

 


Copyright 2006-2011 by Codemesh, Inc., ALL RIGHTS RESERVED

:
juggernet best practices
home products support customers partners newsroom about us contact us