We will be taking a look at the internal working of a Server in .Net Core by implementing our own custom server.
To create a custom server, the custom server class must implement IServer interface and its corresponding methods. The server we will be building will bind to localhost on a port (127.0.0.1:8091), listen for HTTP request then forward the HTTP request to the rest of the request pipeline of the application. After the request is processed a corresponding response is issued.
We will start by creating a new project, a console app project called CustomServerExample.
The Program.cs is the main entry point of the application and should look like this.
class Program
{
static void Main(string[] args)
{
BuildWebHost(args).Run();
}
public static IWebHost BuildWebHost(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseCustomServer(o => o.ListeningEndpoint = @"127.0.0.1:8091")
.UseStartup<Startup>()
.Build();
}
The UseCustomServer method is the point of entry for our Custom Server implementation. We are also passing the endpoint at which our server will be listening for HTTP traffic.
We will create an extension class that will define our UseCustomerServer Method.
public static class ServerExtensions
{
public static IWebHostBuilder UseCustomServer(this IWebHostBuilder hostBuilder, Action<CustomServerOptions> options)
{
return hostBuilder.ConfigureServices(services =>
{
services.Configure(options);
services.AddSingleton<IServer, CustomServer>();
});
}
}
In the extension above we registered our CustomServer Class for Injection into the application pool by registering a singleton of type IServer.
With all this done we can now move into implementation of our CustomServer proper.
The IServer Interface has the following declarations:
//
// Summary:
// Represents a server.
public interface IServer : IDisposable
{
//
// Summary:
// A collection of HTTP features of the server.
IFeatureCollection Features { get; }
//
// Summary:
// Start the server with an application.
//
// Parameters:
// application:
// An instance of Microsoft.AspNetCore.Hosting.Server.IHttpApplication`1.
//
// cancellationToken:
// Indicates if the server startup should be aborted.
//
// Type parameters:
// TContext:
// The context associated with the application.
Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken);
//
// Summary:
// Stop processing requests and shut down the server, gracefully if possible.
//
// Parameters:
// cancellationToken:
// Indicates if the graceful shutdown should be aborted.
Task StopAsync(CancellationToken cancellationToken);
}
The Features property contains necessary HTTP features of this Server, it also contains StartAsync and StopAsync methods. The former is invoked when the server starts with the necessary application context and the latter is invoked to gracefully shut down a server.
Since our CustomServer class was registered as type IServer in the ServerExtension earlier declared, we will be implementing all the signatures of this interface in our class. So our CustomServer will look like below
public class CustomServer : IServer
{
public CustomServer(IOptions<CustomServerOptions> options)
{
Features.Set<IHttpRequestFeature>(new HttpRequestFeature());
Features.Set<IHttpResponseFeature>(new HttpResponseFeature());
var serverAddressesFeature = new ServerAddressesFeature();
serverAddressesFeature.Addresses.Add(options.Value.ListeningEndpoint);
Features.Set<IServerAddressesFeature>(serverAddressesFeature);
}
public IFeatureCollection Features { get; } = new FeatureCollection();
public void Dispose() { }
public Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
var listener = new CustomHTTPListener<TContext>(application, Features);
listener.Listen();
});
}
...
}
In the constructor, we have the Features for both Request and Response setup also we initiate a new instance of ServerAddressesFeature while adding the listening endpoint from the IOptions injected then adding the feature to the IFeatureCollection.
The CustomServer has a server listening address now and can process HTTP request and response. Next, we move to StartAsync.
When the Server starts, a new instance of CustomHTTPListener is created and it is responsible for listening to HTTP request. For the content in CustomHTTPListener;
public class CustomHTTPListener<TContext>
{
private IHttpApplication<TContext> application;
private IFeatureCollection features;
private string uri;
private HttpListener listener;
public CustomHTTPListener(IHttpApplication<TContext> application, IFeatureCollection features)
{
this.application = application;
this.features = features;
uri = features.Get<IServerAddressesFeature>().Addresses.FirstOrDefault();
listener = new HttpListener();
listener.Prefixes.Add(uri);
}
public void Listen()
{
while (true)
{
if (listener.IsListening) return;
ThreadPool.QueueUserWorkItem(async (_) =>
{
listener.Start();
HttpListenerContext ctx = listener.GetContext();
var context = (HostingApplication.Context)(object)application.CreateContext(features);
context.HttpContext = new CustomHttpContext(features, ctx);
await application.ProcessRequestAsync((TContext)(object)context);
context.HttpContext.Response.OnCompleted(null, null);
//listener.Stop();
});
}
}
}
The listener constructor accepts an IHttpApplication<TContext>, which is an instance of the application itself, it is the conduit to which important information is passed around the application. We also have IFeatureCollection as a parameter. An instance of HttpListerner is initialized and the uri gotten from the Application Features is added to the listener.prefixes.add method.
In the Listen method, within the while loop, if the Listener is listening, the iteration is exited. We add a delegate to a thread pool to help manage the listening. We then start the listener, get the listener context and pass this and the application features to CustomHttpContext which is intended to infuse the derivative of the HttpRequest and HttpResponse into the current application Context.
Below is what out CustomHttpContext looks like;
public class CustomHttpContext : HttpContext
{
private IFeatureCollection features;
private HttpListenerContext ctx;
private readonly HttpRequest request;
private readonly HttpResponse response;
public CustomHttpContext(IFeatureCollection features, HttpListenerContext ctx)
{
this.Features = features;
Request = new CustomHttpRequest(this, ctx);
Response = new CustomHttpResponse(this, ctx);
}
public override IFeatureCollection Features { get; }
public override HttpRequest Request { get; }
public override HttpResponse Response { get; }
.....
CustomHttpContext inherits the Abstract class HttpContext and we are implementing it default methods.
In the constructor, Request and Response property are assigned with CustomHttpRequest and CustomHttpResponse both inherit from and override the HttpRequest and HttpResponse, respectively.
Looking at CustomHttpRequest, we set the Method and Path properties which both needed for proper request routing within the application pipeline.
public class CustomHttpRequest : HttpRequest
{
private CustomHttpContext customHttpContext;
public CustomHttpRequest(CustomHttpContext customHttpContext, HttpListenerContext ctx)
{
this.customHttpContext = customHttpContext;
Method = ctx.Request.HttpMethod;
Path = ctx.Request.RawUrl;
}
public override string Method { get; set; }
public override string Scheme { get; set; }
public override bool IsHttps { get; set; }
public override HostString Host { get; set; }
....
In the CustomHttpResponse below, the OnCompleted method is triggered and the response body is read and added back to the HttpListener stream of response.
...
public override Stream Body { get; set; } = new MemoryStream();
public override void OnCompleted(Func<object, Task> callback, object state)
{
if (!Body.CanRead) return;
using (var reader = new StreamReader(Body))
{
HttpListenerResponse response = ctx.Response;
Body.Position = 0;
var text = reader.ReadToEnd();
byte[] buffer = System.Text.Encoding.UTF8.GetBytes(text);
response.ContentLength64 = Body.Length;
response.ContentLength64 = buffer.Length;
System.IO.Stream output = response.OutputStream;
output.Write(buffer, 0, buffer.Length);
output.Close();
Body.Flush();
Body.Dispose();
}
}
...
With all these done we are still missing the critical route handling part. So we will create Startup.cs and in there we add callback into the application pipeline that will help us handle the routing.
public void Configure(IApplicationBuilder app)
{
app.MapWhen(c => c.Request.Path == "/foo/bar", config =>
{
config.Run(async (context) =>
{
context.Response.StatusCode = 200;
await context.Response.WriteAsync("This is Foo Bar!");
});
})
.Run(async (context) =>
{
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Not Found");
});
}
With this, our server is done and we can give it a spin. Running our code we should see the terminal pop up with something like this.
While you necessarily wouldn't have to implement a server of your own as the default one that comes with .netcore is powerful enough and the webapi already abstract and bootstrapped things like routing and HTTP request and response handling. This post is meant to help show you what goes on under the hood.
The full repository of code can be found here https://github.com/omoabobade/CustomServer
Top comments (0)