En minimal http-server i .Net


torsdag 18. februar 2010 C# OO Patterns Webutvikling

.Net-rammeverket er fullt av moduler for å lage webtjenester; du kan bruke WebForms eller ASP.NET MVC, SOAP web services eller WCF, RIA services eller Astoria data services, alt etter dagsform og hvilket behov du har. Noen ganger kan det derimot være greit å vite hvordan man på aller enklest måte kan lage en http-basert server. I Chrome-vinduet under ser du hvordan jeg aksesserer en "no-fuss" service som kan fortelle meg hva klokka er…

CropperCapture[48]

I denne artikkelen presenterer jeg den ikke så veldig godt kjente klassen HttpListener (i System.Net namespacet), og viser hvordan man enkelt kan bruke den til å lage en slags webserver. Denne teknikken kan være aktuell om man f.eks. skulle trenge å raskt mocke opp noen webservicer som ikke følger de vanlige standardene, eller om man skal lage moduler som kommuniserer over http med en proprietær protokoll.

Men jeg har mer på lur: Http-serveren jeg presenterer her er designet for å være utvidbar, og bruker derfor STRATEGY PATTERN i håndteringen av forespørslene. Du vil også få se hvordan jeg kombinerer attributter og reflection for å kunne dynamisk legge til ny adferd uten å måtte editere eksisterende kode.

Her følger selve server-klassen: SimpleHttpServer. Det sentrale skjer i linje 30 til 33, hvor jeg oppretter en HttpListener, registrerer adresse og port for lytting, og starter å lytte. Dette tilsvarer mer eller mindre hvordan Internet Information Server (IIS) selv registrerer seg for lytting mot operativsystemet.

    9 public class SimpleHttpServer

   10 {

   11     private string _address;

   12     private int _port;

   13     private ResponderFactory _dispatcher;

   14     public SimpleHttpServer(string address, int port, ResponderFactory dispatcher)

   15     {

   16         _port = port;

   17         _address = address;

   18         _dispatcher = dispatcher;

   19     }

   20 

   21     private HttpListener _httpListener;

   22     public void Start()

   23     {

   24         StartHttpListener();

   25         while (true)

   26             WaitForRequestThenHandle();

   27     }

   28     private void StartHttpListener()

   29     {

   30         _httpListener = new HttpListener();

   31         _httpListener.Prefixes.Add(

   32             String.Format("http://{0}:{1}/", _address, _port));

   33         _httpListener.Start();

   34     }

   35     private void WaitForRequestThenHandle()

   36     {

   37         var incomingRequestContext = _httpListener.GetContext();

   38 

   39         ThreadPool.QueueUserWorkItem((state) =>

   40         {

   41             try

   42             {

   43                 var context = state as HttpListenerContext;

   44                 var url = context.Request.Url;

   45                 var encoding = context.Request.ContentEncoding;

   46 

   47                 var result = _dispatcher

   48                     .GetResponder(GetCommandKey(url, encoding))

   49                     .RespondTo(GetCommandArguments(url, encoding));

   50 

   51                 Respond(context, encoding, result);

   52             }

   53             catch (Exception ex)

   54             {

   55                 // Don't want the service to die, just log it..

   56                 Console.WriteLine(ex.ToString());

   57                 // Could then responde with some error code...

   58             }

   59         },

   60         incomingRequestContext);

   61     }

   62 

   63     private static string GetCommandKey(Uri url, Encoding encoding)

   64     {

   65         // AbsolutePath always starts with a '/'

   66         return HttpUtility.UrlDecode(url.AbsolutePath.Substring(1), encoding);

   67     }

   68     private static string GetCommandArguments(Uri url, Encoding encoding)

   69     {

   70         // Query always starts with a '?', but may be null

   71         return url.Query != null && url.Query.Length > 1

   72                         ? HttpUtility.UrlDecode(url.Query.Substring(1), encoding)

   73                         : string.Empty;

   74     }

   75     private static void Respond(HttpListenerContext context, Encoding encoding, string result)

   76     {

   77         var bytes = encoding.GetBytes(result);

   78         context.Response.ContentLength64 = bytes.Length;

   79         context.Response.OutputStream.Write(bytes, 0, bytes.Length);

   80         context.Response.StatusCode = 200; // everything is ok :)

   81         context.Response.Close();

   82     }

   83 }

Etter å ha opprettet HttpListener kjører jeg en uendelig løkke som mottar innkommende forespørsler og håndterer dem (en robust implementasjon ville også hatt en Stop-metode som terminerte løkken). Kallet til GetContext() i linje 37 returnerer når en request mottas, og så bruker jeg ThreadPool til å spawn'e en ny tråd som håndterer den og svarer tilbake.

Denne serveren bruker selve URL'en til å avgjøre hva den skal gjøre. I eksempelet i starten av artikkelen var requesten http://127.0.0.1:8081/time?. Alt etter domenet og porten men før spørsmålstegnet bruker jeg til å avgjøre hvilken Responder som skal brukes. Det er her STARTEGY pattern kommer inn i bildet – en responder er en klasse som implementerer interfacet Responder

    5 public interface Responder

    6 {

    7     string RespondTo(string arguments);

    8 }

    5 public interface ResponderFactory

    6 {

    7     Responder GetResponder(string responderKey);

    8 }

Argumentene som sendes til responderen er alt som kommer etter spørsmålstegnet i requesten. HttpListener støtter mye mer enn dette, men alt jeg er interessert i denne gangen er selve URL'en.

SimpleHttpServer ble initialisert med en ResponderFactory. Denne vil - gitt en nøkkel - returnere riktig responder. Ta en titt til på serveren om du ikke fikk det helt med deg første gangen, spesielt linje 47 til 49.

En første versjon av ResponderFactory (den faktiske implementasjonen kommer lengre nede) kunne vært en klasse som uansett nøkkel returnerte en UnknownCommandResponder:

    5 public class UnknownCommandResponder : Responder

    6 {

    7     private string _request;

    8     public UnknownCommandResponder(string request)

    9     {

   10         _request = request;

   11     }

   12     public string RespondTo(string arguments)

   13     {

   14         return String.Format("Unknown command: '{0}' with arguments '{1}'",

   15             _request,

   16             arguments);

   17     }

   18 }

Det kan være greit å ha en slik default strategi/responder til å svare på alle mulige ting man måtte finne på å etterspørre. Her ser du den i aksjon:

CropperCapture[46]

Opprette flere tjenester: Attributter og reflection

Før jeg legger til den første "fornuftige" responderen oppretter jeg et .net-attributt. Det trenger ikke være noe mer avansert enn å lage en klassen som arver fra Attribute.

    6 public class RespondToAttribute : Attribute

    7 {

    8     public string Key { get; set; }

    9     public RespondToAttribute(string key) {

   10         Key = key;

   11     }

   12 }

Jeg har laget svært få attributter i min karriære, men det er en teknikk som kan gi veldig elegante løsninger om det brukes riktig. Måten jeg bruker det på her er ganske vanlig. Og enkel! Den vil rett og slett la meg legge til nye respondere uten at jeg behøver å endre noen eksisterende kode.

Men first thing first: Når jeg implementerer mine respondere vil RespondToAttribute la meg spesifisere hvilken nøkkel hver responder gjelder for. Her er f.eks. en enkel responder som legger sammen en rekke med tall separert med komma:

    6 [RespondTo("add")]

    7 public class Add : Responder

    8 {

    9     public string RespondTo(string arguments)

   10     {

   11         int result = 0;

   12         Array.ForEach(arguments.Split(','),

   13             (arg) => result += Int32.Parse(arg));           

   14         return string.Format("The answer is {0}", result);

   15     }

   16 }

(Det er en konvensjon i .Net at man slipper å skrive "Attribute"-delen av attributt-navnet. Dermed blir linje 6 så fin.., denne klassen "responderer på add".)

Når jeg gjør ting som dette er det typisk endel implisitte regler jeg må følge. I dette tilfelle vil det for eksempel ikke gi mening om mer enn én klasse responderer på "add". Nøkkelen må med andre ord være unik. For å håndheve slike regler oppretter jeg som regel validerende enhetstester. Følgende test bruker reflection til å hente ut alle typer i prosjektet, samle opp alle RespondToAttributes fra typene, og sjekke at de er unike..

   12 [Test]

   13 public void Should_all_be_unique()

   14 {

   15     var keys = new List<string>();

   16     var allTypes = Assembly.GetExecutingAssembly().GetTypes();

   17     foreach (var t in allTypes)

   18     {

   19         var attribute = Attribute

   20             .GetCustomAttribute(t, typeof(RespondToAttribute))

   21             as RespondToAttribute;

   22 

   23         if (attribute == null)

   24             continue;

   25 

   26         if (keys.Contains(attribute.Key))

   27             Assert.Fail(attribute.Key + " appear more than once!");

   28 

   29         keys.Add(attribute.Key);

   30     }

   31 }

Og denne samme teknikken vil jeg bruke når jeg implementerer den endelige ResponderFactory-klassen. Den oppretter en dictionary med nøkler mappet til respondere (eller mer nøyaktig mappet til funksjoner som oppretter respondere).

    7 public class ReflectiveResponderFactory : ResponderFactory

    8 {

    9     private Dictionary<string, Func<Responder>> _dispatchTable;

   10 

   11     public ReflectiveResponderFactory()

   12     {

   13         _dispatchTable = new Dictionary<string, Func<Responder>>();

   14         Array.ForEach(Assembly.GetExecutingAssembly().GetTypes(),

   15             (type) => AddDispatchIfResponder(type, GetResponderInfo(type)));

   16     }

   17     private static RespondToAttribute GetResponderInfo(Type maybeResponderType)

   18     {

   19         return Attribute.GetCustomAttribute(maybeResponderType,

   20             typeof(RespondToAttribute)) as RespondToAttribute;

   21     }

   22     private void AddDispatchIfResponder(Type type, RespondToAttribute responderInfo)

   23     {

   24         if (responderInfo != null)

   25             _dispatchTable.Add(

   26                 responderInfo.Key,

   27                 () => Activator.CreateInstance(type) as Responder);

   28     }

   29 

   30     public Responder GetResponder(string responderKey)

   31     {

   32         if (!_dispatchTable.ContainsKey(responderKey))

   33             return new UnknownCommandResponder(responderKey);

   34         return _dispatchTable[responderKey].Invoke();

   35     }

   36 }

Her forutsettes det at alle respondere (med RespondTo-attributt) har en default, parameter-løs konstruktør, slik at den kan opprettes i lambda-uttrykket i linje 27. Du bør opprette en enhetstest for å validere dette også, slik at ingen lager en responder med konstruktør-parametre i fremtiden og dermed introduserer en bug (ikke at de ikke ville ha oppdaget det, men rask tilbakemelding er alltid kjekt).

Om du nå har skjønt hvordan disse klassene henger sammen gjenstår det bare å opprette og starte en SimpleHttpServer for at dette skal fungere. Her kjører jeg serveren i et konsollprogram, men i et mer realistisk senario vil du typisk kjøre den i en enkel Windows service.

    7 static void Main(string[] args)

    8 {

    9     new SimpleHttpServer

   10         ("*", 8081, new ReflectiveResponderFactory())

   11         .Start();

   12 }

Og nå kan vi endelig få vite hvor mye 10 + 20 + 30 er…

CropperCapture[47]

For å utvide serveren med mer funksjonalitet er det nå bare til å opprette flere klasser som arver fra Responder-interfacet, og legge til et RespondToAttribute. ReflectiveResponderFactory vil finne og registrere den nye klassen under oppstart, og delegere til en ny instans av responderen om nøkkelen kommer i en forespørsel.

Jeg håper noen klarte å henge med óg få noe fornuftig ut av dette. Mer info om HttpListener finner du her, og info om .net attributes finner du via google. I neste blogpost vil du få se hvordan jeg implementerer nøyaktig samme funksjonalitet ved hjelp av Ruby.

Knagger: , ,


comments powered by Disqus