Click here to Skip to main content
15,861,125 members
Articles / Web Development / ASP.NET

Basic Routing for HttpHandler

Rate me:
Please Sign up or sign in to vote.
5.00/5 (19 votes)
10 Jun 2012CPOL14 min read 96K   1.8K   49   9
Simple way of mapping HttpHandler requests into controller/action

Table of contents

Introduction

This article offers a simple solution to mapping URL requests within

HttpHandler
(or HttpModule, if needs to be) to the form of {controller}/{action}, much like with MVC Routing, but without using MVC.   

The solution is based on a small and independent class SimpleRouter, optimized for work in a performance-demanding application.  

Background    

The mechanism of automatic URL routing is one of MVC's greatest virtues, allowing developers to designate areas, controllers and actions to reflect logic of their application in the most intuitive way, and not just internally, but on the protocol (URL) level at the same time.  

Once you have started using this approach, it is difficult to part with. However, there are certain tasks where you may want to stay away from it, and I do not mean the URL routing itself, but the entire MVC platform. Such tasks may include, but not limited by:

  • General low-level optimization tasks that require combination of quickest response and smallest footprint;
  • Intercepting or redirecting certain requests/parts of your website to a high-performing assembly;   
  • Writing your own low-level server that requires utmost scalability. 
Most of the time it would mean resorting to such interfaces as
HttpHandler
or HttpModule. There is even a lower protocol than this, class HttpListener, but on this level there is no automatic integration with IIS, it only bundles well with self-hosting solutions.

When using such class as HttpHandler (without any MVC), the first thing that's missing greatly is the Automatic URL Routing. Without such useful thing working, your project, as it grows, may end up being difficult to understand - which piece of code corresponds to which request, and how they are interconnected.

What this article offers is to solve this basic problem. If you do not want or can't use the MVC layer in your HttpHandler, but want to keep the concise controller/action architecture in your code, you can use this solution. 

It is not by any means one-to-one with MVC Routing, it is not supposed to be. Instead, this library focuses on benefits that are important when using class HttpHandler, such as:  

  • smallest footprint  
  • fastest execution  
  • quick and flexible integration  

Specification 

URL Routing can be a vast subject, and offers almost infinite possibilities for implementation. This is why it is very important to set clear goals here before we even begin.

Below is the exact list of goals that we set out to achieve in our version of URL Routing.

Goals/Requirements

  1. Requests are accepted only in the form of {controller}/{action}?[parameters] 
  2. Processing for controllers, actions and action parameters is not case-sensitive. 
  3. Controllers can reside in any namespace, and in any assembly.   
  4. Only public classes and public non-static actions can map to a request.
  5. To address a controller class whose name ends with "Controller", the latter can be omitted in the request.   
  6. Parameters in the request are mapped to the corresponding action parameters by their names (case-insensitive), while their order in the request is irrelevant.  
  7. Actions support all standard parameter types that may be passed via URL:   
    1. All simple types: string, bool, all integer and floating-point types; 
    2. Arrays of specified simple types;   
    3. Arrays of unspecified/mixed simple types;   
    4. Parameters with default values;    
    5. Parameters, declared as nullable. 
  8. Application that configures HttpHandler as reusable can also set the router to work with reusable controllers. 
  9. Each controller is automatically offered direct access to the current HTTP context.  
  10. Optimized and well-organized implementation  
    1. Only System.Web is used in combination with the Generics
    2. Minimalistic - implemented in just one class; 
    3. Fast-performing;
    4. Thoroughly documented.

Additional Provisions 

    1. Default controllers and actions are not supported.  
    2. Return type for an action is irrelevant and ignored. 
    3. Prefix segments are not used, i.e. we use only the last two segments in the request. For example, request www.server.com/one/two/three/controller/action will only use controller/action, while prefix one/two/three is not used, however made available to the controller/action, in case it is needed. 

Using the code

If you download the source code of the demo project, it is quite self-explaining. The solution includes three projects:

  • BasicRouter - the core library with class SimpleHandler, which implements the routing.
  • TestServer - a simple implementation of HttpHandler, plus a few demo controllers just to show how it all works.
  • TestClient - a single-page web application as a client that makes requests into TestServer. It was created to simplify testing the library, and to add some interesting benchmarking and statistics.

Adding the library

The recommended way of using this library is by adding project BasicRouter to your solution, as the library produces just a 12KB DLL. However, it will work just as well within your own assembly.

Below is implementation of the TestServer dynamic library, which shows how to declare and initialize the use of the router.

HttpHandler Class

C#
public class SimpleHandler : IHttpHandler
{
	/// <summary>
	/// Router instance. Making it static
	/// is not required, but recommended;
	/// </summary>
	private static SimpleRouter router = null;

	#region IHttpHandler Members
	public bool IsReusable
	{
		// NOTE: It is recommended to be true.
		get { return true; }
	}

	public void ProcessRequest(HttpContext ctx)
	{
		if (!router.InvokeAction(ctx)) // If failed to map the request to controller/action;
			router.InvokeAction(ctx, "error", "details"); // Forward to our error handler;
	}
	#endregion

	/// <summary>
	/// Default Constructor.
	/// </summary>
	public SimpleHandler()
	{
		if (router == null)
		{
			// Initialize routing according to the handler's re-usability:
			router = new SimpleRouter(IsReusable);

			// Adding namespaces where our controller classes reside:
			// - add null or "", if you have controllers in the root.
			// - also specify which assembly, if it is not this one.
			router.AddNamespace("TestServer.Controllers");

			// OPTIONAL: Setting exception handler for any action call:
			router.OnActionException += new SimpleRouter.ActionExceptionHandler(OnActionException);
		}
	}

	/// <summary>
	/// Handles exceptions thrown by any action method.
	/// </summary>
	/// <example>
	/// /simple/exception?msg=Ops!:)
	/// </example>
	/// <param name="ctx">current http context</param>
	/// <param name="action">fully-qualified action name</param>
	/// <param name="ex">exception that was raised</param>
	private void OnActionException(HttpContext ctx, string action, Exception ex)
	{
		// Here we just write formatted exception details into the response...
		Exception e = ex.InnerException ?? ex;
		StackFrame frame = new StackTrace(e, true).GetFrame(0);

		string source, fileName = frame.GetFileName();
		if(fileName == null)
			source = "Not Available";
		else
			source = String.Format("{0}, <b>Line {1}</b>", fileName, frame.GetFileLineNumber());

		ctx.Response.Write(String.Format("<h3>Exception was raised while calling an action</h3><ul><li><b>Action:</b> {0}</li><li><b>Source:</b> {1}</li><li><b>Message:</b> <span style=\"color:Red;\">{2}</span></li></ul>", action, source, e.Message));
	}
}

First off, it contains the object of type SimpleRouter - the very class that implements our routing:

C#
private static SimpleRouter router = null; 

The object is created and initialized inside the constructor:

  1. Creating the object, telling it to activate access to reusable controllers based on how our handler is being set up; 
  2. Registering all the namespaces where our controller classes reside;
  3. Optional: Registering a handler for any exception that an action may throw.

The most interesting part is method ProcessRequest:

C#
public void ProcessRequest(HttpContext ctx)
{
	if (!router.InvokeAction(ctx)) // If failed to map the request to controller/action;
		router.InvokeAction(ctx, "error", "details"); // Forward to our error handler;
}

The method first calls InvokeAction to locate controller/action that correspond to the request, and if found, invoke the action. If that fails, it invokes action on a controller that we created to process errors.

Controllers  

I created a few demo controllers in file Controllers.cs for the test and to show that you are very flexible in how you want to define action parameters. Those examples are shown below.

C#
/// <summary>
/// Simplest controller example.
/// </summary>
public class SimpleController : BaseController
{
	/// <example>
	/// /simple/time
	/// </example>
	public void Time()
	{
		Write(DateTime.Now.ToString("MMM dd, yyyy; HH:mm:ss.fff"));
	}

	/// <example>
	/// /simple/birthday?name=John&age=25
	/// </example>
	public void Birthday(string name, int age)
	{
		Write(String.Format("<h1>Happy {0}, dear {1}! ;)</h1>", age, name));
	}
	
	/// <example>
	/// /simple/exception?msg=exception message demo
	/// </example>
	public void Exception(string msg)
	{
		throw new Exception(msg);
	}

	/// <example>
	/// /one/two/three/simple/prefix
	/// - prefix will be {"one", "two", "three"}
	/// </example>
	public void Prefix()
	{
		string s = String.Format("{0} segments in the request prefix:<ol>", prefix.Length);
		foreach (string p in prefix)
			s += String.Format("<li>{0}</li>", p);
		Write(s + "</ol>");
	}
}

/// <summary>
/// Demonstrates use of arrays and default parameters.
/// </summary>
public class ListController : BaseController
{
	/// <example>
	/// /list/sum?values=1,2,3,4,5
	/// </example>
	public void Sum(int [] values)
	{
		int total = 0;
		string s = "";
		foreach (int i in values)
		{
			if (!string.IsNullOrEmpty(s))
				s += " + ";
			s += i.ToString();
			total += i;
		}
		s += " = " + total.ToString();
		Write(s);
	}

	/// <summary>
	/// Outputs the sum of all double values, with optional description.
	/// </summary>
	/// <example>
	/// /list/add?values=1.05,2.17,...[&units=dollars]
	/// </example>
	public void Add(double [] values, string units = null)
	{
		double total = 0;
		foreach (double d in values)
			total += d;
		Write(String.Format("Total: {0} {1}", total, units));
	}

	/// <summary>
	/// Spits out the array of passed strings into a paragraph,
	/// optionally changing the color.
	/// </summary>
	/// <example>
	/// /list/text?values=one,two,three,...[&color=Red]
	/// </example>
	public void Text(string[] values, string color = "Green")
	{
		string result = String.Format("<p style=\"color:{0};\">", color);
		foreach(string s in values)
			result += s + "<br/>";
		result += "</p>";
		Write(result);
	}

	/// <summary>
	/// Shows that we can pass an array of mixed types.
	/// </summary>
	/// <example>
	/// /list/any?values=1,two,-3.45
	/// </example>
	public void Any(object[] values, string desc = null)
	{
		string s = (desc ?? "") + "<ol>";
		foreach (object obj in values)
			s += "<li>" + obj.ToString() + "</li>";
		Write(s + "</ol>");
	}
}

/// <summary>
/// Shows how to quickly and efficiently render an image file.
/// </summary>
public class ImageController : BaseController
{
	/// <summary>
	/// Returns a cached image.
	/// </summary>
	public void Diagram()
	{
		if (image == null)
			image = FileToByteArray(ctx.Server.MapPath("~/Routing.jpg"));

		ctx.Response.ContentType = "image/jpeg";
		ctx.Response.BinaryWrite(image);
	}

	/// <summary>
	/// Simplest and quickest way for reading entire file,
	/// and returning its content as array of bytes.
	/// </summary>
	private static byte[] FileToByteArray(string fileName)
	{
		FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read);
		long nBytes = new FileInfo(fileName).Length;
		return new BinaryReader(fs).ReadBytes((int)nBytes);
	}

	private byte[] image = null; // Cached image;
}

/// <summary>
/// Controller for error handling;
/// </summary>
public class ErrorController:BaseController
{
	/// <summary>
	/// Shows formatted details about the request.
	/// </summary>
	public void Details()
	{
		string path = GetQueryPath();
		if (string.IsNullOrEmpty(path))
			path = "<span style=\"color:Red;\">Empty</span>";

		string msg = String.Format("<p>Failed to process request: <b>{0}</b></p>", path);
		msg += "<p>Passed Parameters:";
		if (ctx.Request.QueryString.Count > 0)
		{
			msg += "</p><ol>";
			foreach (string s in ctx.Request.QueryString)
				msg += String.Format("<li>{0} = {1}</li>", s, ctx.Request.QueryString[s]);
			msg += "</ol>";
		}
		else
			msg += " <b>None</b></p>";

		Write(msg);
	}
}

In addition, it is worthwhile mentioning that according to requirement 4, routing was implemented in such a way as to use only public classes and only public, non-static actions.  

Testing the code  

The simplest way to see the code working is by running the demo application that's attached to this article.

If you want to set it up on your local IIS, but not sure how to do that, the steps below will help you.

  1. Either build the demo project or unpack its binary version. 
  2. In IIS7, add a new website or new application, specifying the physical path to the project's folder (where Web.config sits). Also, tell it to use ASP.NET v4.0 as the application pool. 
  3. Select your website, and from context menu->Manage Web Site->Browse, to see that it opens. The default page should open with our error image, which is by design, because we do not support unspecified controller/action.  
  4. Launch Powershell in Administrative mode, and type in there inetsrv/appcmd list wp, to get Id-s of all application pools currently running in IIS
  5. In VS-2010, open the project and select menu Debug->Attach To Process, make sure to switch Show processes in all sessions ON. Find the process with that Id you saw in Powershell and click Attach
  6. Now, if you set a break-point and send a request from the browser, you will be able to debug the code.

To simplify testing the code even further, I published it on one of my hosting accounts, and though I understand it cannot be permanent, it will save some people time testing it online, at least for the next few month, and perhaps I will give it a more permanent hosting later and update the links here. 

Try a few examples below, according to the controllers we have in the source code:  

  1. /simple/time, outputs current date/time
  2. /simple/birthday?name=John&age=25, outputs formatted result
  3. /simple/exception?msg=some exception text, action throws an exception, and the handler catches it
  4. /one/two/three/four/five/simple/prefix, shows how prefix segments are taken away
  5. /list/sum?values=1,2,3,4,5, outputs sum of values (use of arrays)
  6. /list/add?values=1.03,2.17&units=Dollars, outputs formatted values (use of optional parameters)
  7. /list/text?values=first,second,third, outputs array of strings
  8. /list/any?values=one,2,-3.4&desc=mixed parameters, outputs array of mixed-type parameters
  9. /image/diagram, outputs an image
There are many other variations of requests that can be recognized by our demo controllers - see it in the source code.

Custom Types 

One way of supporting objects of custom types is by using type object[] as an action parameter, like shown in the following example:

C#
/// <summary>
/// Shows that we can pass an array of mixed types.
/// </summary>
/// <example>
/// /list/any?values=1,two,-3.45
/// </example>
public void Any(object[] values, string desc = null)
{
    string s = (desc ?? "") + "<ol>";
    foreach (object obj in values)
        s += "<li>" + obj.ToString() + "</li>";
    Write(s + "</ol>");
}

If you know which custom type parameter values represents, then you can easily initialize its properties. In the above example though, we just write all the passed values into the response. 

This approach is also good for just passing an array of mixed data types as a parameter.

URL Filters

In the attached demo I used the following configuration settings for the client:

XML
<system.web>
  <httpHandlers>
    <add verb="*" path="data/*/*" type="TestServer.SimpleHandler, TestServer" />
  </httpHandlers>
</system.web>

That's because the client is a UI application that also needs to return such files as HTML, CSS, JS and images. So, in order to avoid dealing with those we used prefix "/data" to filter them out from the requests for controller/action. This however, created a limitation for the UI demo itself, so it can only understand requests that come as /data/controller/action, and it cannot show use of prefix segments, for instance.

You may ask, so what if I want to handle all the requests within my HttpHandler, and not just the ones for controller/action? What does it take for my HttpHandler to have full control over the response?

The answer is - you need to be able to handle any request for a file and then return the contents of such file, and avoid mixing it with controller/action. To show how it can be done in a simple scenario I included class HttpFileCache within project TestServer. So let's make a few small changes in our demo application to see how it works.

First off, we modify file Web.config in project TestClient to let our HttpHandler catch all the incoming requests as shown below:

XML
<system.web>
  <httpHandlers>
    <add verb="*" path="*" type="TestServer.SimpleHandler, TestServer" />
  </httpHandlers>
</system.web>

Now we add support for handling files within our HttpHandler class as below:

C#
public class SimpleHandler : IHttpHandler
{
	// use this to handle file requests:
	HttpFileCache cache = new HttpFileCache();
	
	// ...the rest of the class;
}

And then we change method ProcessRequest:

C#
public void ProcessRequest(HttpContext ctx)
{
	if (cache.ProcessFileRequest(ctx) == ProcessFileResult.Sucess)
		return;

	if (!router.InvokeAction(ctx)) // If failed to map the request to controller/action;
		router.InvokeAction(ctx, "error", "details"); // Forward to our error handler;
}

Now, if a request for a file comes in, it will be handled by class HttpFileCache, and if it is not for an existing file, then we try to map the request into controller/action. This solution gives us full flexibility in handling the requests, including use of prefix segments within a UI application or any web application that needs to return files along with handling requests for controller/action.

It is important to note that we used class HttpFileCache mainly because the returned files must be cached, or it will kill the performance. And class HttpFileCache offers a very simple implementation for file caching, it does not allow for processing requests for big files that may come in as uploads, for instance (see declaration of HttpFileCache), i.e. handlig big files requires a little extra work for returning partial contents.

Implementation

Class SimpleRouter is well-documented, and has quite simple logic, so I don't see it justified re-publishing the code here in full. I will just list a few aspects of implementation that I found most interesting and/or challenging, and as for the rest - have a look at file SimpleRouter.cs - it's all there, and it is not much. 

The way classes and their methods are located is quite generic, there is nothing special there, just using methods Type.GetType to find the right class, and method Type.GetMethod to find the action method. 

When reusable controllers are active, I used a very simple cache implementation to store controllers and pull them from there when needed again.

Perhaps the only complicated bit in the entire implementation was in preparing array of parameters that need to be passed to an action. It is all done within the method shown below:  

C#
/// <summary>
/// Prepares parameters to be passed to an action method.
/// </summary>
/// <remarks>
/// For each action parameter in 'pi' tries to locate corresponding value in 'nvc',
/// adjusts the type, if needed, pack it all and return as array of objects.
/// </remarks>
/// <param name="pi">Parameters of the target action</param>
/// <param name="nvc">Parameters from the URL request</param>
/// <returns>Parameters for the target action, or null, if failed</returns>
private object[] PrepareActionParameters(ParameterInfo[] pi, NameValueCollection nvc)
{
    List<object> parameters = new List<object>(); // resulting array;
    foreach (ParameterInfo p in pi)
    {
        object obj = nvc.Get(p.Name); // Get URL parameter;
        if (string.IsNullOrEmpty((string)obj))
        {
            if (!p.IsOptional)
                return null; // failed;

            parameters.Add(p.DefaultValue); // Use default value;
            continue;
        }
        if (p.ParameterType != typeof(string))
        {
            // Expected parameter's type isn't just a string;
            // Try to convert the type into the expected one.
            try
            {
                if (p.ParameterType.IsArray)
                {
                    // For parameter-array we try splitting values
                    // using a separator symbol:
                    string[] str = ((string)obj).Split(arraySeparator);
                    Type baseType = p.ParameterType.GetElementType();
                    Array arr = Array.CreateInstance(baseType, str.Length);
                    int idx = 0;
                    foreach (string s in str)
                        arr.SetValue(Convert.ChangeType(s, baseType), idx++);
                    obj = arr;
                }
                else
                {
                    Type t = p.ParameterType;
                    if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) // If parameter is nullable;
                        t = Nullable.GetUnderlyingType(t); // Use the underlying type instead;

                    obj = Convert.ChangeType(obj, t); // Convert into expected type;
                }
            }
            catch (Exception)
            {
                // Failed to map passed value(s) to the action parameter,
                // and it is not terribly important why exactly.
                return null; // fail;
            }
        }
        parameters.Add(obj);
    }
    return parameters.ToArray(); // Success, returning the array;
}
This one was a good exercise, trying to handle all the situations listed in the requirements. It wasn't straightforward at all, figuring out how to properly initialize values for parameters - arrays (I saw many posts from people getting stuck on this one), and then handling nullable parameters.

In the end, what matters, it is all quite generic, and all the type conversion is done by method Convert.ChangeType, i.e. I am not dealing with any particular type here, and it is all quite elegant that way.   

Benchmarks and Conclusions

Such library can be benchmarked only on a local PC, because testing it on any web host will solely reflect performance of the hosting server, and not of the library. 

What we are interested in, primarily, is how long does it take on average between request arriving into method ProcessRequest and when the corresponding action begins its execution on a cached controller. In other words, how long does it take to route your average URL request to a cached controller. 

To that end I used a modified version of the library, one with multiple injections for reporting delays of execution, and these are the results... 

The length of routing and calling was consistently under 1*10-7 second, i.e. less than 100 nanoseconds. The only random slow-downs that I saw I would attribute to either internal garbage collection or some resource allocation on the PC, during which the time could momentarily jump to a whole 1 millisecond, but those aren't really important.   

The entire library came to be a mere 12KB DLL, with references only to the core DLL-s of:   

  1. Microsoft.CSharp
  2. System
  3. System.Core
  4. System.Web
This is the smallest footprint we could achieve for an assembly, and considering the library performance, the result looks quite worthwhile.

Points of Interest

There were a few interesting things I found during implementation of this demo, they are listed in detail in chapter Implementation.

History 

  • May 08, 2012. First version of the library, and initial draft of the article.   
  • May 08, 2012. Second revision of the library: Added support for prefix segments in BaseController, plus improved its documentation.  
  • May 09, 2012. Improved article formatting, added Contents table, plus a few more details. 
  • May 09, 2012. Added more details about supporting arrays of mixed types. 
  • May 10, 2012. Minor improvements in code and documentation. 
  • May 11, 2010. Added support for multiple assemblies. In consequence, had to place the library into its own assembly to simplify its reuse and make cross-assembly use possible. 
  • May 15, 2012. Added major changes to the demo source and documentation as listed below.
    1. Made property SimpleHandler.arraySeparator public, and added many helpful details in its declaration.
    2. Added method SimpleHandler.CleanSegment to prepare any segment for further processing. As a result, it is now allowed to pass a request with spaces in it, which will be stripped automatically. But most importantly, use of requests with combination of letter cases won't cause a separate controller instance per letter-case anymore.
    3. Added a comprehensive demo - a simple web application that can be run from Visual Studio and show how everything works. The demo also includes a simple benchmark for measuring speed of Ajax requests going through SimpleRouter.
  • May 20, 2012. Made huge changes to the library. In fact, once I finished changing it I started updating the article only to realize it now needs complete rewrite, for which I hope to find time in the near future. In the meantime, I list most of the changes I made here below, and suggest anyone who would like to use to rely more on the source that's enclosed, as it is quite simple and most up-to-date. And if you have any questions, I'd love answering them in the comments section. Thank you!
    1. Added support for the same controller name but in different namespaces or even different assemblies.
    2. Added support for use of a custom namespace, so one can be overridden based on prefix segments or anything else you may like. See method OnValidatePrefix.
    3. Added validation of prefix segments (method OnValidatePrefix), so one can choose a custom action based on the prefix contents, and even override the prefix itself, if needs to be.
    4. Added support for explicit action call, when name of controller and action are specified. This becomes necessary in cases, like forwarding call to a known controller/action, like in case of an error.
    5. Added support for timeouts, to expire long-unused controllers (method SetTimeout).
    6. A number of changes were made to the demo controllers.
    7. Changed the caching logic for controllers, so that only one controller is created for any action it implements.
    8. Added thread safety throughout the library.
  • June 10, 2012. Added chapter URL Filtering that explains:
    1. How and why we used the filters for the demo application, and how to change them to handle any request.
    2. Why class HttpFileCache was included into project ServerTest, plus how and when to use it.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior) Sibedge IT
Ireland Ireland
My online CV: cv.vitalytomilov.com

Comments and Discussions

 
GeneralThis is not thread safe. Pin
Thomas C. Hutchinson2-Feb-15 6:39
Thomas C. Hutchinson2-Feb-15 6:39 
GeneralA simple way to make things thread-safe. Pin
Thomas C. Hutchinson24-Apr-15 4:04
Thomas C. Hutchinson24-Apr-15 4:04 
GeneralThanks Pin
Sampath Sridhar4-Apr-13 17:42
Sampath Sridhar4-Apr-13 17:42 
QuestionNice article Pin
Suchi Banerjee, Pune12-Jun-12 18:24
Suchi Banerjee, Pune12-Jun-12 18:24 
AnswerRe: Nice article Pin
Vitaly Tomilov13-Jun-12 0:30
Vitaly Tomilov13-Jun-12 0:30 
GeneralMy vote of 5 Pin
member6012-May-12 1:32
member6012-May-12 1:32 
SuggestionWord of Advice Pin
Vitaly Tomilov10-May-12 6:41
Vitaly Tomilov10-May-12 6:41 
GeneralMy vote of 5 Pin
Vtech118-May-12 5:31
Vtech118-May-12 5:31 
GeneralRe: My vote of 5 Pin
Vitaly Tomilov10-May-12 6:32
Vitaly Tomilov10-May-12 6:32 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.