![]() |
Platforms, Frameworks & Libraries »
Windows Communication Foundation »
General
Intermediate
License: The GNU General Public License (GPL)
DrawMe - A network ink-chat application exploring .NET 3.5, WPF and WCFBy Tim Callaghan, Alvin LimA demonstration network ink-chat application exploring some aspects of .NET 3.5, WPF and WCF |
C#, .NET (.NET 3.0, .NET 3.5), Visual Studio (VS2008), WCF, XAML, WPF, Dev
|
||||||||||
|
Advanced Search |
|
|
|
||||||||||||||||

This demo project came about as a result of brainstorming on ideas for making an article to enter into the VS2008 contest! We wanted to experiment with and showcase a few of the great new .NET 3.0 (and 3.5) technologies that have been introduced with the lastest version of Visual Studio. Initially we came up with the concept of a network chat program - we were going to implement the GUI with WPF and the network communication with WCF. After some experimenting around with the new WPF controls we decided it would be more fun to use the new InkCanvas control and make a multi-user network drawing demo. DrawMe is the result of our experiments and in this article we'll walk through some of the interesting WPF and WCF code features we encountered along the way.
At the highest level, DrawMe uses a client-server architecture with the server hosted on one of the client's computers. When a user starts DrawMe they are given the choice of creating a new server to locally host ink drawings, or connecting to an existing DrawMe server which can be either local or remote. When a user draws on the ink canvas, the drawing strokes are broadcast to all clients registered with the main DrawMe server. In this way users can participate in real-time collaborative drawing. Although this concept isn't new, the goal of this article was to see how easy this would be to implement using WPF and WCF.
If you just want to try out the finished product you can download the demo application using the link at the top of the article. Chances are high that most people will just be testing it out on one computer, in which case it should work with minimal firewall configuration required. Simply start up a couple of instances of the DrawMe.exe and set the first one to be the server. When connecting the second instance change the type to Client and specify either localhost, the machine name, or the machine IP address for the server address. If you want to try it out on two or more separate computers on a LAN you will likely need to allow DrawMe access through any firewalls you have running. If you want to try it out on two or more computers across the internet you will also probably need to port-forward TCP 8000 to your computer from any router you are using etc. We've successfully tested it in all of the scenarios listed here so hopefully it should work for you too!
The user interface for DrawMe consists of two main windows:
The following sub-sections explore the function and creation of each of these areas of the user interface.
When the user first starts DrawMe they are presented with a login screen. The purpose of the login screen is to allow the user to either join an existing DrawMe session or to create a new DrawMe server and also join that session as the first client. The following screenshot shows how the login window looks.

It's a relatively basic UI, but it doesn't need to do much so we've kept it simple. There's a few nice features that WPF brings to the table that we should point out:
CornerRadius property for the element LinearGradientBrush to the background of elements The following xaml code listing shows how we made the login control. We found that working with the raw xaml was the fastest way to experiement and fine tune the design; however, the layout design manager in VS2008 also does a nice job if you want to play around with the controls.
<UserControl x:Class="DrawMe.LoginControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="300" Width="350" Loaded="UserControl_Loaded">
<StackPanel>
<Border Height="50" BorderBrush="#FFFFFFFF" Background="Black" BorderThickness ="2,2,2,0" CornerRadius="5,5,0,0">
<Label Content="Welcome to DrawMe" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="20" Foreground="White"/>
</Border>
<Border Height="220" BorderBrush="#FFFFFFFF" BorderThickness="2,2,2,0" CornerRadius="5,5,0,0">
<Border.Background>
<LinearGradientBrush EndPoint="0.713,0.698" StartPoint="0.713,-0.139">
<GradientStop Color="#FFFFFFFF" Offset="0.933"/>
<GradientStop Color="LightBlue" Offset="0.337"/>
</LinearGradientBrush>
</Border.Background>
<StackPanel Name="infoPanel" Orientation="Vertical" Margin="10,10,10,10">
<StackPanel Name="typePanel" Orientation="Horizontal">
<Label Name="lblChatType" FontSize="20" Width="120" HorizontalContentAlignment="Right" VerticalContentAlignment="Center">Type:</Label>
<RadioButton Name="chatTypeServer" FontSize="20" VerticalAlignment="Center" Margin="0,0,20,0"
Checked="chatTypeServer_Checked" VerticalContentAlignment="Center">Server</RadioButton>
<RadioButton Name="chatTypeClient" FontSize="20" VerticalAlignment="Center"
Checked="chatTypeClient_Checked" VerticalContentAlignment="Center">Client</RadioButton>
</StackPanel>
<StackPanel Name="serverPanel" Orientation="Horizontal" Margin="0,10,0,0">
<Label Name="lblServer" FontSize="20" Width="120" HorizontalContentAlignment="Right" VerticalContentAlignment="Center">Server:</Label>
<TextBox Height="30" Name="txtServer" Width="160" FontSize="20" VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Name="usernamePanel" Orientation="Horizontal" Margin="0,10,0,10">
<Label Name="lblUserName" FontSize="20" Width="120" HorizontalContentAlignment="Right">User Name:</Label>
<TextBox Height="30" Name="txtUserName" Width="160" FontSize="20" VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Name="buttonPanel" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<Button Name="btnLogin" Width="120" FontSize="20" Margin="10,10,10,10" Click="btnLogin_Click">Connect</Button>
<Button Name="btnCancel" Width="120" FontSize="20" Margin="10,10,10,10" Click="btnCancel_Click">Cancel</Button>
</StackPanel>
</StackPanel>
</Border>
<Border Height="30" Background="#FF2E2E2E" BorderBrush="#FFFFFFFF" BorderThickness="2,0,2,2" CornerRadius="0,0,5,5">
<Label Content="DrawMe is using .NET 3.5 (WPF and WCF)" FontSize="9" Foreground="#FFFFFFFF"
HorizontalAlignment="Center" VerticalAlignment="Center" Background="#00FFFFFF"/>
</Border>
</StackPanel>
</UserControl>
After the user has logged in to a DrawMe server the application enables the main DrawMe window where all drawing takes place. The window consists of four major sections:
StackPanel along the top of the window that displays the connection status (as a fade-in/fade-out animation) as welll as information about who has drawn most recently. There is also a sign out button for when a user wants to leave the session. ListView along the left of the window that displays user names for all connected clients. StackPanel below the Information bar that allows users to select how to interact with the ink canvas. InkCanvas control that handles the display of ink drawings from all connected clients. The following screenshot shows how the main application window looks.

Again, there's a few nice WPF features in use that we should point out:
DropShadowBitmapEffect for xaml elements - notice the drop-shadow around the Client List. DoubleAnimation element - In the xaml code listing that follows you can see we've set the connection status animation to cycle between opaque and transparent every 5 seconds. InkCanvas control is ready to use without alteration - All we had to do was wire up some handlers for the events raised when a stroke is collected or erased. The different interaction modes (Ink, Erase By Stroke, Erase By Point) are standard included editing modes for the InkCanvas control. DependencyProperty) in the underlying class - We store the current ink colour in the FillColor property, which is just a wrapper around a DependencyProperty. The interesting thing to note here is that when FillColor is updated programatically in the code-behind file, no extra effort is required to update the actual displayed colour in the GUI; the update happens automatically once the property has been bound correctly. When the user clicks on the colour button a colour picker dialog is displayed. Unfortunately,WPF has no native colour selection dialog. Luckily, we found this colour picker dialog on one of the MSDN blogs. We modified it slightly to be consistent with our colour scheme, but essentially it's been used as-is.
The following xaml code listing shows how we made the main application window.
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="DrawMeMainWindow"
x:Class="DrawMe.DrawMeWindow"
Title="DrawMeWindow" Height="600" Width="800"
Background="#FF3B3737" Loaded="Window_Loaded" MinWidth="800" MinHeight="500">
<Grid x:Name="LayoutRoot" >
<Grid.RowDefinitions>
<RowDefinition Height="65" />
<RowDefinition Height="50" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Grid.Row="0" Grid.ColumnSpan="2" BorderBrush="Gray" BorderThickness="1,1,1,1" CornerRadius="8,8,8,8">
<StackPanel Name="loginStackPanel" Orientation="Horizontal" HorizontalAlignment="Left">
<StackPanel Orientation="Vertical" Margin="10,10,20,0">
<TextBlock Name="ApplicationTypeMessage" Width="120" Height="25" FontSize="10" Foreground="White" TextAlignment="Center">
Waiting for connection...
<TextBlock.Triggers>
<EventTrigger RoutedEvent="TextBlock.Loaded">
<BeginStoryboard>
<Storyboard Name="ApplicationTypeMessageStoryBoard">
<DoubleAnimation Name="ApplicationTypeMessageAnimation"
Storyboard.TargetName="ApplicationTypeMessage"
Storyboard.TargetProperty="(TextBlock.Opacity)"
From="1.0" To="0.0" Duration="0:0:5"
AutoReverse="True" RepeatBehavior="Forever"
/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBlock.Triggers>
</TextBlock>
<Button Name="btnLeave" Width="100" Height="20" FontSize="10" Click="btnLeave_Click">
Sign Out
</Button>
</StackPanel>
<TextBlock Name="AnimatedMessage" FontSize="35" FontWeight="Bold" Foreground="White" VerticalAlignment="Center">
Welcome to DrawMe
</TextBlock>
</StackPanel>
</Border>
<Border Name="BorderUsersList" Grid.Column="0" Grid.Row="1" Grid.RowSpan="2" CornerRadius="8,8,8,8" Background="LightBlue" BorderThickness="4,4,4,4">
<ListView Name="lvUsers" Margin="10" FontSize="20">
<ListView.BitmapEffect>
<DropShadowBitmapEffect />
</ListView.BitmapEffect>
</ListView>
</Border>
<Border Name="BorderEditingType" Grid.Column="1" Grid.Row="1" CornerRadius="8,8,8,8" Background="LightBlue" BorderThickness="0,4,4,4">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<RadioButton Name="rbInk" Content="Ink" Margin="15,0,0,0" VerticalAlignment="Center" FontSize="20" IsChecked="True"
Tag="{x:Static InkCanvasEditingMode.Ink}" Click="rbInkType_Checked">
</RadioButton>
<RadioButton Name="rbEraserByStroke" Content="Erase By Stroke" Margin="15,0,0,0" VerticalAlignment="Center" FontSize="20"
Tag="{x:Static InkCanvasEditingMode.EraseByStroke}" Click="rbInkType_Checked">
</RadioButton>
<RadioButton Name="rbEraserByPoint" Content="Erase By Point" Margin="15,0,0,0" VerticalAlignment="Center" FontSize="20"
Tag="{x:Static InkCanvasEditingMode.EraseByPoint}" Click="rbInkType_Checked">
</RadioButton>
<TextBlock Margin="25,0,10,0" VerticalAlignment="Center" FontSize="20" >Colour:</TextBlock>
<Button Margin="0,0,0,0" Background="White" Height="28" Width="64" Click="OnSetFill">
<Rectangle Width="54" Height="20" Stroke="Black" StrokeThickness="2">
<Rectangle.Fill>
<SolidColorBrush Color="{Binding ElementName=DrawMeMainWindow, Path=FillColor}" />
</Rectangle.Fill>
</Rectangle>
</Button>
</StackPanel>
</Border>
<Border Name="BorderInkCanvas" Grid.Column="1" Grid.Row="2" Background="LightBlue" BorderThickness="0,0,4,4" CornerRadius="8,8,8,8" >
<InkCanvas x:Name="inkCanv" Margin="10" Background="White"
StrokeCollected="inkCanv_StrokeCollected" StrokeErasing="inkCanv_StrokeErasing"
StrokeErased="inkCanv_StrokeErased">
</InkCanvas>
</Border>
<Canvas Name="loginCanvas" Grid.Column="1" Grid.Row="2" Width="500" Height="300" VerticalAlignment="Top" HorizontalAlignment="Center" />
</Grid>
</Window>
To help explain the main runtime scenarios when using DrawMe, we have constructed some UML sequence diagrams to represent the state of the application at various stages.
During login up to four main events can occur:
ClientCallBack (implements IDrawMeServiceCallback) to provide a means for the server to invoke functions on the client. Also construct a DrawMeServiceClient to handle TCP communication with the DrawMe server, and use this to connect to the server 
The following code listing shows how we've implemented the login process. Note that for the sake of simplicity we've disabled all security (see App.config). We've also hard-coded the communication port to 8000. Again, this is just to make things easier for the sake of the demo. In a real-world application we probably wouldn't do this!
App.config
<bindings>
<netTcpBinding>
<binding name="DrawMeNetTcpBinding">
<security mode="None">
<transport clientCredentialType="None" />
<message clientCredentialType="None" />
</security>
</binding>
</netTcpBinding>
</bindings>
private void btnLogin_Click(object sender, RoutedEventArgs e)
{
EndpointAddress serverAddress;
if (this.chatTypeServer.IsChecked == true)
{
DrawMe.App.s_IsServer = true;
serverAddress = new EndpointAddress("net.tcp://localhost:8000/DrawMeService/service");
}
else
{
DrawMe.App.StopServer();
DrawMe.App.s_IsServer = false;
if (txtServer.Text.Length == 0)
{
MessageBox.Show("Please enter server name");
return;
}
serverAddress = new EndpointAddress(string.Format("net.tcp://{0}:8000/DrawMeService/service", txtServer.Text));
}
if (txtUserName.Text.Length == 0)
{
MessageBox.Show("Please enter username");
return;
}
if (DrawMeServiceClient.Instance == null)
{
if (App.s_IsServer)
{
DrawMe.App.StartServer();
}
try
{
ClientCallBack.Instance = new ClientCallBack(SynchronizationContext.Current, m_mainWindow);
DrawMeServiceClient.Instance = new DrawMeServiceClient
(
new DrawMeObjects.ChatUser
(
txtUserName.Text,
System.Environment.UserName,
System.Environment.MachineName,
System.Diagnostics.Process.GetCurrentProcess().Id,
App.s_IsServer
),
new InstanceContext(ClientCallBack.Instance),
"DrawMeClientTcpBinding",
serverAddress
);
DrawMeServiceClient.Instance.Open();
}
catch (System.Exception ex)
{
DrawMe.App.StopServer();
DrawMeServiceClient.Instance = null;
MessageBox.Show(string.Format("Failed to connect to chat server, {0}", ex.Message),this.m_mainWindow.Title);
return;
}
}
if (DrawMeServiceClient.Instance.IsUserNameTaken(DrawMeServiceClient.Instance.ChatUser.NickName))
{
DrawMeServiceClient.Instance = null;
MessageBox.Show("Username is already in use");
return;
}
if (DrawMeServiceClient.Instance.Join() == false)
{
MessageBox.Show("Failed to join chat room");
DrawMeServiceClient.Instance = null;
DrawMe.App.StopServer();
return;
}
this.m_mainWindow.ChatMode();
}
Once the client has connected to a server the application is ready to send and receive inkstrokes. The two main events that can happen at this stage are:

All inkstrokes in DrawMe are sent as MemoryStream objects (or the underlying byte array representation). Note that we are not being very smart about how we are sending the strokes; we send the entire contents of the inkcanvas rather than the most recent update. For demonstration purposes this is ok since it makes the erasing code simple to handle (it's the same as the drawing code!). We have plans in place to optimise the sending of inkstrokes but unfortunately we couldn't implement this in time for this article. The following code listing show how we implemented this functionality in the client.
private void SaveGesture()
{
try
{
MemoryStream memoryStream = new MemoryStream();
this.inkCanv.Strokes.Save(memoryStream);
memoryStream.Flush();
DrawMeServiceClient.Instance.SendInkStrokes(memoryStream);
}
catch (Exception exc)
{
MessageBox.Show(exc.Message, Title);
}
}
Once the strokes are sent to the server, the following code is executed to update all the registered clients. Notice how we call GetBuffer() on the memory stream passed in when we are sending the stroke updates back to each client. Initially we were just passing the MemoryStream object around, but we soon ran into problems with the object being garbage collected before we could use it. This is because each client needs to ensure that all user interface updates occur on the main GUI thread, and so we use an anonymous delegate to post an asynchronous call to the GUI thread. By the time the GUI thread processes the update, it's possible that the MemoryStream has already been garbage collected. This seems obvious now, but at the time it had us baffled for a few minutes!
public class DrawMeService : IDrawMeService
{
public void SendInkStrokes(MemoryStream memoryStream)
{
IDrawMeServiceCallback client = OperationContext.Current.GetCallbackChannel ();
foreach (IDrawMeServiceCallback callbackClient in s_dictCallbackToUser.Keys)
{
if (callbackClient != OperationContext.Current.GetCallbackChannel ())
{
callbackClient.OnInkStrokesUpdate(s_dictCallbackToUser[client], memoryStream.GetBuffer());
}
}
}
...
}
When a user logs off, the application contacts the server and notifies it that the client should be removed from the registered clients list. If the user is also hosting the server then all clients are disconnected and returned to login mode.

Here's the code that gets executed on the server when a client leaves.
public void Leave(ChatUser chatUser)
{
IDrawMeServiceCallback client = OperationContext.Current.GetCallbackChannel ();
if (s_dictCallbackToUser.ContainsKey(client))
{
s_dictCallbackToUser.Remove(client);
}
foreach (IDrawMeServiceCallback callbackClient in s_dictCallbackToUser.Keys)
{
if (chatUser.IsServer)
{
if (callbackClient != client)
{
//server user logout, disconnect clients
callbackClient.ServerDisconnected();
}
}
else
{
//normal user logout
callbackClient.UpdateUsersList(s_dictCallbackToUser.Values.ToList());
}
}
if (chatUser.IsServer)
{
s_dictCallbackToUser.Clear();
}
}
So far we haven't really talked too much about how we used WCF to implement the communication between instances of the DrawMe application. In this section we provide an overview of the key WCF features that we used. There were three main problems that we needed to solve to get the communiation working:
WCF provides solutions to all of these problems!
Many in-built types in .NET are serializable by default. This means that they can be represented in a standard manner for passing over network connections. However, when you define a new class it is not serializable by default. To store information about each DrawMe client user we created a ChatUser class. In order to pass ChatUser objects over a network connection we need to specify that they are serializable.
We have setup the ChatUser class to use the WCF System.Runtime.Serialization - [DataContract] attribute. Applying this attribute to a class indicates that we are interested in serializing it. To serialize a specfic member of the class we need to apply the [DataMember] attribute. This is because data contracts have been designed with an "opt-in" programming model. i.e. Anything that is not explicitly marked with the DataMember attribute is not serialized. The following code snippet shows how we applied these attributes to the ChatUser class. See ChatUser.cs for the whole implementation.
[DataContract]
public class ChatUser
{
...
[DataMember]
public string NickName
{
get { return m_strNickName; }
set { m_strNickName = value; }
}
...
}
In order for each DrawMe client to be able to communicate with the server, a contract needs to be established. The purpose of the contract is to publish the interface that the server will implement so that clients know what methods are available on the server. In WCF, a contract can be specified by applying the ServiceContract attribute to an interface. When applying this attribute it is also possible to specify a CallbackContract which represents a callback interface that the client will implement. You can see how we used the attribute in the following code.
[
ServiceContract
(
Name = "DrawMeService",
Namespace = "http://DrawMe/DrawMeService/",
SessionMode = SessionMode.Required,
CallbackContract = typeof(IDrawMeServiceCallback)
)
]
public interface IDrawMeService
{
[OperationContract()]
bool Join(ChatUser chatUser);
[OperationContract()]
void Leave(ChatUser chatUser);
[OperationContract()]
bool IsUserNameTaken(string strUserName);
[OperationContract()]
void SendInkStrokes(MemoryStream memoryStream);
}
Each client only needs to know about the IDrawMeService interface; however, the server needs to contain the implementation. When providing the implementation it is possible to specify a ServiceBehavior attribute. The DrawMe server uses the following service behaviour.
DrawMeService object is used for all incoming calls and is not recycled subsequent to the calls. If the DrawMe service object does not exist, one is created. This is effectively a singleton. Here is how we have applied the ServiceBehavior attribute to the DrawMeService implementation.
[
ServiceBehavior
(
ConcurrencyMode = ConcurrencyMode.Single,
InstanceContextMode = InstanceContextMode.Single
)
]
public class DrawMeService : IDrawMeService
{
...
}
DrawMe has a IDrawMeServiceCallback interface which allows the DrawMe server to send messages back to the client application. For example, when a new user has joined the chat room, the server uses the callback mechanism to notify all other users. The callback interface is defined in a shared DrawMeInterfaces.dll; the implementation is located at the client side - see ClientCallBack.cs
DrawMe clients implement three callback functions:
It's possible to specify an OperationContract attribute on each callback function. In DrawMe, we have chosen to implement the callbacks with the IsOneWay=true attribute, i.e. The operations don't relay any information back to the server about whether or not they were successful.
public interface IDrawMeServiceCallback
{
[OperationContract(IsOneWay = true)]
void UpdateUsersList(List listChatUsers);
[OperationContract(IsOneWay = true)]
void OnInkStrokesUpdate(ChatUser chatUser, byte[] bytesStroke);
[OperationContract(IsOneWay = true)]
void ServerDisconnected();
}
This article has hopefully given you an overview of some of the available features in WCF. In terms of meeting our goal of implementing a collaborative drawing program, we've demonstrated that this is not only possible but relatively easy to do using some of the cool new WCF functionality. Actually, we think we probably spent more time writing this acticle than we did writing the code for it, so that should give you some indication of how powerful the WCF framework is (assuming we're not terrible writers!). Please feel free to download the source code and delve into the structure, and leave you comments or questions below!
During the design and coding stages of this small project we wanted a way to collaborate without having to keep going to each other's house every time we wanted to work on the project. Using a web-based free source control system was the obvious solution. We decided to try out CodePlex (http://www.codeplex.com/) which is Microsoft's open source project hosting web site. We found CodePlex to be a very useful tool in terms of coordinating the work effort and keeping track of what still needed to be implemented. What's more, CodePlex has a very intuitive user interface and neither of us had any difficulty in using it.
The backend of CodePlex uses a system of Team Foundation Server (TFS) databases to store all the community projects on. Given that VS2008 integrates tightly with TFS we originally planned to use Team Explore 2008 as the source control client. Team Explore 2008 is a free, simplified TFS client from Microsoft that can integrate with the VS2008 development environment directly. Unfortunately Team Explore 2008 doesn't work with VS2008 Beta 2 (we found this out the hard way after downloading 387Mb). But in the end it didn't really matter because we were able to use TortoiseSVN (a subversion client for windows) to access the TFS that our project was stored on. Information on how to do this is readily available in the CodePlex FAQ.
Once we got the source control access sorted out it was very easy for us to collaborate on the project. The thing we really liked about CodePlex was the integrated Issue Tracker; raising issues was painless and easy, as was assigning issues to each other for work. In summary, if you're thinking about starting up an open-source project with more than one developer then using CodePlex is definitely an option worth exploring.
If you are interested in "checking out" the DrawMe project on CodePlex head on over to http://www.codeplex.com/drawme and take a look around. The "Issue Tracker" and "Source Code" tabs are probably the most interesting in terms of seeing the workflow process we went through.
| You must Sign In to use this message board. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
General
News
Question
Answer
Joke
Rant
Admin
|
PermaLink |
Privacy |
Terms of Use
Last Updated: 21 Dec 2007 Editor: |
Copyright 2007 by Tim Callaghan, Alvin Lim Everything else Copyright © CodeProject, 1999-2009 Web17 | Advertise on the Code Project |