Skip to content

Instantly share code, notes, and snippets.

@lpereira
Last active February 19, 2021 22:47
Show Gist options
  • 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);
}
}
}
}
}
@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