Skip to content

Instantly share code, notes, and snippets.

@lpereira
Last active February 19, 2021 22:47
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lpereira/5f3d7b864efe3e9ca14dab85a1233eb9 to your computer and use it in GitHub Desktop.
Save lpereira/5f3d7b864efe3e9ca14dab85a1233eb9 to your computer and use it in GitHub Desktop.
C# Web Server Hello World
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace WebServerHelloWorld
{
class Router
{
public delegate string RouteAction(Request request);
private struct Route : IComparable<Route>
{
public readonly string Path;
public readonly RouteAction Action;
public Route(string path, RouteAction action) { Path = path; Action = action; }
public int CompareTo(Route other)
{
return -Path.Length.CompareTo(other.Path.Length);
}
};
// A prefix tree would be better here, but this will do for a toy web server.
private readonly SortedList<Route, Route> Routes;
public Router()
{
Routes = new SortedList<Route, Route>();
}
public void AddRoute(string path, RouteAction action)
{
var route = new Route(path, action);
Routes.Add(route, route);
}
public RouteAction FindRouteToPath(string path)
{
foreach (KeyValuePair<Route, Route> route in Routes)
{
if (path.StartsWith(route.Value.Path))
{
return route.Value.Action;
}
}
return null;
}
};
class Response
{
private readonly ResponseCode Code;
private readonly Request Request;
public enum ResponseCode
{
Ok = 200,
BadRequest = 400,
NotFound = 404,
InternalServerError = 500,
};
private static readonly Dictionary<ResponseCode, string> ResponseCodeToString = new Dictionary<ResponseCode, string>
{
[ResponseCode.Ok] = "200 Ok",
[ResponseCode.BadRequest] = "403 Bad Request",
[ResponseCode.NotFound] = "404 Not Found",
[ResponseCode.InternalServerError] = "500 Internal Server Error",
};
private static readonly string[] VersionToString = {
"HTTP/1.0 ", "HTTP/1.1 "
};
public Response(ResponseCode code, Request request)
{
this.Code = code;
this.Request = request;
}
public async Task SendHeaders(Dictionary<string, string> headers)
{
StringBuilder builder = new StringBuilder();
builder.Append(VersionToString[(int)Request.Version]);
string codeAsString = ResponseCodeToString[Code];
if (codeAsString == null)
{
codeAsString = ResponseCodeToString[ResponseCode.InternalServerError];
}
builder.Append(codeAsString);
builder.Append("\r\n");
if (headers != null)
{
foreach (KeyValuePair<string, string> entry in headers)
{
builder.Append($"{entry.Key}: {entry.Value}\r\n");
}
}
builder.Append("\r\n");
byte[] ba = Encoding.Default.GetBytes(builder.ToString());
await Request.Client.GetStream().WriteAsync(ba, 0, ba.Length);
}
public async Task Send(Dictionary<string, string> headers, string response)
{
await SendHeaders(headers);
byte[] asByteArray = Encoding.Default.GetBytes(response);
await Request.Client.GetStream().WriteAsync(asByteArray, 0, asByteArray.Length);
}
};
class Request
{
public enum HttpVerb
{
Get,
Head,
};
public enum HttpVersion
{
Http1_0,
Http1_1,
}
public TcpClient Client { get; }
private readonly byte[] RequestBuffer;
private readonly int UsableBytesInRequestBuffer;
public HttpVerb Verb { get; set; }
public HttpVersion Version { get; set; }
public string Fragment { get; set; }
public Dictionary<string, string> Query { get; set; }
public Dictionary<string, string> Headers { get; }
public bool IsClosed { get; set; }
private readonly Router Router;
public Request(TcpClient client, Router router, byte[] requestBuffer, int usableBytesInRequestBuffer)
{
this.Client = client;
this.RequestBuffer = requestBuffer;
this.UsableBytesInRequestBuffer = usableBytesInRequestBuffer;
this.Headers = new Dictionary<string, string>();
this.Router = router;
}
private ReadOnlySpan<char> ParseMethod(ReadOnlySpan<char> ros)
{
if (ros.StartsWith("GET ")) { Verb = HttpVerb.Get; return ros.Slice(4); }
if (ros.StartsWith("HEAD ")) { Verb = HttpVerb.Head; return ros.Slice(5); }
return null;
}
private ReadOnlySpan<char> GetPath(ReadOnlySpan<char> ros)
{
int spaceIndex = ros.IndexOf(' ');
if (spaceIndex < 0) { return null; }
return ros.Slice(0, spaceIndex);
}
private ReadOnlySpan<char> ParseHttpVersion(ReadOnlySpan<char> ros)
{
if (ros.StartsWith("HTTP/1.1\r\n")) { Version = HttpVersion.Http1_1; return ros.Slice(10); }
if (ros.StartsWith("HTTP/1.0\r\n")) { Version = HttpVersion.Http1_0; return ros.Slice(10); }
return null;
}
private Router.RouteAction BuildDefaultResponse(Response.ResponseCode responseCode)
{
string text = $"<html><head><title>{responseCode}</title></head>" +
$"<body><h1>{responseCode}</h1><img src=\"https://http.cat/{(int)responseCode}\"></body></html>";
return (Request r) => text;
}
public async Task SendResponse(Response.ResponseCode responseCode, string text)
{
Response response = new Response(responseCode, this);
Dictionary<string, string> headers = new Dictionary<string, string>
{
{"Content-Type", "text/html" },
{"Content-Length", text.Length.ToString() },
{"Server", "Hello C# Web Server" },
};
await response.Send(headers, text);
}
private static readonly char[] TrimmableWhiteSpace = new char[] { ' ', '\t', '\r', '\n' };
private bool IsKeepAlive()
{
string connectionHdr = Headers.GetValueOrDefault("connection", null);
switch (Version)
{
case HttpVersion.Http1_0:
if (connectionHdr == null) { return false; }
if (connectionHdr.Contains("keep-alive")) { return true; }
break;
case HttpVersion.Http1_1:
if (connectionHdr == null) { return true; }
if (connectionHdr.Contains("keep-alive")) { return true; }
break;
}
return false;
}
private string ParseRequest()
{
ReadOnlySpan<char> span = Encoding.Default.GetChars(RequestBuffer).AsSpan<char>(0, UsableBytesInRequestBuffer);
span = span.TrimStart(TrimmableWhiteSpace);
span = ParseMethod(span);
if (span == null)
{
return null;
}
ReadOnlySpan<char> pathSpan = GetPath(span);
if (pathSpan == null)
{
return null;
}
int origPathLength = pathSpan.Length;
ParseFragment(ref pathSpan);
ParseQueryString(ref pathSpan);
span = ParseHttpVersion(span.Slice(origPathLength + 1));
if (span != null)
{
ParseHeaders(ref span);
return pathSpan.ToString();
}
return null;
}
private void ParseHeaders(ref ReadOnlySpan<char> span)
{
foreach (string[] keyValue in from keyValuePair in span.ToString().Split("\r\n")
let keyValue = keyValuePair.Split(": ")
select keyValue)
{
if (keyValue.Length != 2)
{
break;
}
if (keyValue[0].Length > 0)
{
Headers[keyValue[0].ToLowerInvariant()] = keyValue[1];
}
}
}
private void ParseFragment(ref ReadOnlySpan<char> pathSpan)
{
int hashIndex = pathSpan.LastIndexOf('#');
if (hashIndex >= 0)
{
Fragment = pathSpan.Slice(hashIndex + 1).ToString();
pathSpan = pathSpan.Slice(0, hashIndex - 1);
}
}
private void ParseQueryString(ref ReadOnlySpan<char> pathSpan)
{
int questionMarkIndex = pathSpan.IndexOf('?');
if (questionMarkIndex >= 0)
{
ReadOnlySpan<char> querySpan = pathSpan.Slice(questionMarkIndex + 1);
pathSpan = pathSpan.Slice(0, questionMarkIndex);
Query = new Dictionary<string, string>();
foreach (string[] keyValue in from keyValuePair in querySpan.ToString().Split("&")
let keyValue = keyValuePair.Split("=")
select keyValue)
{
if (keyValue.Length != 2)
{
break;
}
if (keyValue[0].Length > 0)
{
string key = System.Web.HttpUtility.UrlDecode(keyValue[0]);
string value = System.Web.HttpUtility.UrlDecode(keyValue[1]);
Query[key] = value;
}
}
}
}
public async Task Process(CancellationToken ct)
{
string path = ParseRequest();
Response.ResponseCode rc;
Router.RouteAction action;
if (path != null)
{
action = Router.FindRouteToPath(path);
if (action == null)
{
rc = Response.ResponseCode.NotFound;
action = BuildDefaultResponse(rc);
}
else
{
rc = Response.ResponseCode.Ok;
}
}
else
{
rc = Response.ResponseCode.BadRequest;
action = BuildDefaultResponse(rc);
}
if (!ct.IsCancellationRequested)
{
await SendResponse(rc, action(this));
if (path != null)
{
Console.WriteLine($"{Verb} {path} {Version} {(int)rc}");
}
}
if (!IsKeepAlive())
{
Client.Close();
IsClosed = true;
}
}
}
class Server
{
private readonly CancellationTokenSource TokenSource;
private readonly TcpListener Listener;
private readonly Router Router;
public static void Main()
{
Server server = new Server();
server.Start();
}
Server()
{
TokenSource = new CancellationTokenSource();
Listener = new TcpListener(IPAddress.Any, 8080);
Router = new Router();
Router.AddRoute("/hello", HelloWorld);
Router.AddRoute("/dump", DumpRequest);
Router.AddRoute("/", (Request r) => "Nothing here, move along");
}
private static readonly string DefaultName = "World";
private string HelloWorld(Request r)
{
string name;
if (r.Query != null)
{
name = r.Query.GetValueOrDefault("name", DefaultName);
}
else
{
name = DefaultName;
}
return $"Hello, {name}!";
}
private string DumpRequest(Request r)
{
StringBuilder builder = new StringBuilder();
builder.Append("<pre>");
if (r.Fragment != null)
{
builder.Append($"Fragment => {r.Fragment}\n");
}
foreach (KeyValuePair<string, string> header in r.Headers)
{
builder.Append($"Header[{header.Key}] => {header.Value}\n");
}
if (r.Query != null)
{
foreach (KeyValuePair<string, string> query in r.Query)
{
builder.Append($"Query[{query.Key}] => {query.Value}\n");
}
}
builder.Append($"HttpVersion => {r.Version}\n");
builder.Append($"HttpVerb => {r.Verb}\n");
builder.Append("</pre>");
return builder.ToString();
}
public void Start()
{
try
{
CancellationToken ct = TokenSource.Token;
Listener.Start();
while (!ct.IsCancellationRequested)
{
_ = AcceptClientsAsync(Listener, ct);
}
}
finally
{
TokenSource.Cancel();
Listener.Stop();
}
}
private async Task AcceptClientsAsync(TcpListener listener, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync();
_ = HttpClient(client, ct);
}
}
private static int FindCrlfCrlfIndex(byte[] buf, int n)
{
if (n >= 4)
{
n -= 4;
// FIXME: A better way to perform a fast memmem()-like operation would probably be better
// than this naïve search here.
// A brute-force approach that let me search 4 bytes at once with AVX would probably beat
// KMP due to the setup cost.
for (int start = 0; start < n; start++)
{
int crIndex = Array.IndexOf<byte>(buf, (byte)'\r', start);
if (crIndex < 0)
{
break;
}
if (buf[crIndex + 1] == '\n' && buf[crIndex + 2] == '\r' && buf[crIndex + 3] == '\n')
{
return crIndex;
}
start = crIndex;
}
}
return -1;
}
private async Task HttpClient(TcpClient client, CancellationToken ct)
{
byte[] buf = new byte[4096];
int bytesRead = 0;
NetworkStream stream = client.GetStream();
while (!ct.IsCancellationRequested)
{
Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(30));
Task<int> readTask = stream.ReadAsync(buf, bytesRead, buf.Length - bytesRead, ct);
Task completed = await Task.WhenAny(timeoutTask, readTask);
if (completed == timeoutTask || readTask.Result == 0)
{
client.Close();
return;
}
bytesRead += readTask.Result;
int crlfcrlfIndex = FindCrlfCrlfIndex(buf, bytesRead);
if (crlfcrlfIndex >= 0)
{
Request request = new Request(client, Router, buf, crlfcrlfIndex);
await request.Process(ct);
if (request.IsClosed)
{
return;
}
bytesRead -= crlfcrlfIndex + 4;
// FIXME: buf should ideally be treated as a ring buffer to avoid this copy
Buffer.BlockCopy(buf, crlfcrlfIndex + 4, buf, 0, bytesRead);
}
}
}
}
}
@jeffschwMSFT
Copy link

Some nit pick comments (per request)

_ = HttpClient(client, ct);

_ = is very uncommon. It is more common to either not catch return, or change the type to throw on error

StringBuilder builder = new StringBuilder();

This is more commonly "var builder = new StringBuilder()" with the principle being the line is obvious what the type is.

private async Task HttpClient

There is a type called HttpClient - https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.-ctor?view=netframework-4.8

Task completed = await Task.WhenAny(timeoutTask, readTask);

Task has a wait with timeout: https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.wait?view=netframework-4.8#System_Threading_Tasks_Task_Wait_System_Int32_System_Threading_CancellationToken_. I am not an expert on async patterns, but I feel the cancellation token and no wait is the preferred pattern. Stephen Toub would be someone to get to know.

private struct Route

A common pattern you will see is using #region to separate protection levels in .NET classes. Eg. "#region private". This is 100% a code editor feature and has no barring on the semantics.

public RouteAction FindRouteToPath(string path)

Consider using a Dictionary or ConcurrentDictionary (if used without locking). https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2?view=netcore-3.0

class Request & class Response

Beyond samples for learning, these types exist and a lot of the heavy lifting is part of the framework (in fact this is Karel's team). I know you were doing this for learning, but it may be interesting to compare the performance of this implementation and the one in the framework. https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient?view=netcore-3.0 or https://docs.microsoft.com/en-us/dotnet/api/system.net.webrequest?view=netcore-3.0

ros.StartsWith("GET ")

StringComparison is very nice for a fast non-case specific comparision: https://docs.microsoft.com/en-us/dotnet/api/system.memoryextensions.startswith?view=netcore-3.0#System_MemoryExtensions_StartsWith_System_ReadOnlySpan_System_Char__System_ReadOnlySpan_System_Char__System_StringComparison_

foreach (string[] keyValue in from keyValuePair in querySpan.ToString().Split("&") let keyValue = keyValuePair.Split("=") select keyValue)

Ah Linq. These are awesome for readability and ease, but performance can greatly suffer. Learning Linq is great, but not likely something you would often use in system.private.corelib. My personal preference is the "." notation over the sql style, but they are equivalent. There is an app called LinqPad that is very insightful.

As potential next steps:

  • Performance analysis using Visual Studio and Perfview
  • Add dynamic routers with isolation using an AssemblyLoadContext (make some of the routers have dependencies on native components, other managed components, etc.)
  • Run and debug x-plat - if you have not already

Great example.

@lpereira
Copy link
Author

lpereira commented Jun 3, 2019

Thanks for the review!

_ = HttpClient(client, ct);

_ = is very uncommon. It is more common to either not catch return, or change the type to throw on error

Discarding the return value seemed to be a common practice for async methods when you just want them to be "executed in the background".

StringBuilder builder = new StringBuilder();

This is more commonly "var builder = new StringBuilder()" with the principle being the line is obvious what the type is.

Gotcha.

Task completed = await Task.WhenAny(timeoutTask, readTask);

Task has a wait with timeout: docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.wait?view=netframework-4.8#System_Threading_Tasks_Task_Wait_System_Int32_System_Threading_CancellationToken_. I am not an expert on async patterns, but I feel the cancellation token and no wait is the preferred pattern. Stephen Toub would be someone to get to know.

Will take a look at this. (Using a timeoutTask was a suggestion in some of the docs.)

private struct Route

A common pattern you will see is using #region to separate protection levels in .NET classes. Eg. "#region private". This is 100% a code editor feature and has no barring on the semantics.

I'm still fighting Visual Studio. Not used to IDEs or fancy editors (I use https://joe-editor.sf.net while on Linux), so I don't see much value in #region yet. Maybe someday.

public RouteAction FindRouteToPath(string path)

Consider using a Dictionary or ConcurrentDictionary (if used without locking). docs.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2?view=netcore-3.0

Using a SortedList was deliberate: a Dictionary query would match only entries that are in the table precisely, whereas with a SortedList (reverse-sorted by the Path length) will give me a naïve way to perform a longest-prefix match. There are better structures for this, but this is OK for the number of routes available.

Unless you're suggesting implementing a prefix tree using a Dictionary; in that case, yes, I do have a prototype in Python that I wrote for my web server, but never got around implementing it as a real deal.

class Request & class Response

Beyond samples for learning, these types exist and a lot of the heavy lifting is part of the framework (in fact this is Karel's team). I know you were doing this for learning, but it may be interesting to compare the performance of this implementation and the one in the framework. docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient?view=netcore-3.0 or docs.microsoft.com/en-us/dotnet/api/system.net.webrequest?view=netcore-3.0

The performance here is very likely pretty bad. In fact, I was very surprised to run this under a system call tracer on Linux; the syscall chatter is overwhelming, it's kind of hard to know what's happening.

ros.StartsWith("GET ")

StringComparison is very nice for a fast non-case specific comparision: docs.microsoft.com/en-us/dotnet/api/system.memoryextensions.startswith?view=netcore-3.0#System_MemoryExtensions_StartsWith_System_ReadOnlySpan_System_Char__System_ReadOnlySpan_System_Char__System_StringComparison_

Nice, will take a look at this.

foreach (string[] keyValue in from keyValuePair in querySpan.ToString().Split("&") let keyValue = keyValuePair.Split("=") select keyValue)

Ah Linq. These are awesome for readability and ease, but performance can greatly suffer. Learning Linq is great, but not likely something you would often use in system.private.corelib. My personal preference is the "." notation over the sql style, but they are equivalent. There is an app called LinqPad that is very insightful.

This Linq query was suggested by VS; I had written it as longhand code. This syntax is nice, but I agree: the "." notation is less "magical".

As potential next steps:

  • Performance analysis using Visual Studio and Perfview

Yes, this is something I want to do -- easier to learn how to profile when I understand a good chunk of the code I'm profiling. One thing I noticed is that this creates a lot of garbage while idling. I don't know yet why.

  • Add dynamic routers with isolation using an AssemblyLoadContext (make some of the routers have dependencies on native components, other managed components, etc.)

This should be fun to do.

  • Run and debug x-plat - if you have not already

It runs fine under .NET core and Mono :)

@AaronRobinsonMSFT
Copy link

Definitely not a fan of the #region. Editors use them, but often not well and they tend to inspire designs where classes get enormous because we can use #region to hide things. I find C# has way to many language features that breed bad patterns. LINQ is another such example - the delayed evaluation is a big gotcha.

public RouteAction FindRouteToPath(string path)

Using null as an invariant makes me cringe. I prefer to rely on "try" semantics whenever possible. The above signature would instead become: public bool TryFindRouteToPath(string path, out RouteAction)

private ReadOnlySpan ParseMethod(ReadOnlySpan ros)

Would highly recommend returning an empty span rather than null.

As potential next steps:
Performance analysis using Visual Studio and Perfview

Lots of plus 1

I am not an expert on async patterns, but I feel the cancellation token and no wait is the preferred pattern. Stephen Toub would be someone to get to know.

Cancellation tokens are very much the preferred way to to do things.

private static int FindCrlfCrlfIndex(byte[] buf, int n)

I would flip the predicate and return early. C is about the only language where not returning early is the preferred path.

private readonly

Nice job on using readonly. Very helpful C# language feature.

if (crlfcrlfIndex >= 0)

Flip the invariant and continue early.

The only other thing I would suggest is debug logging. There are APIs like Debug to provide logging in a debug build and is provided to the debugger it can help during a debug session.

In general my preference is for style consistency across a code base - not just a single file. My preference is for looking at a code base and not being able to tell that it was written by more than a single person. Reasoning for this is multi-faceted, but in general helps new members to a team and makes reviews faster because if the style isn't right logical errors tend to stand out. If you are familiar with the "no brown M&M" mechanism used by the band Van Halen in the 80's, that is how I think about style.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment