Sockets en protocollen: berichten uitwisselen. Met bytes.

Door CodeCaster op dinsdag 14 augustus 2012 10:00 - Reacties (11)
Categorie: Tech, Views: 8.301

English version

Wanneer twee applicaties over het netwerk of over internet met elkaar willen communiceren, wordt al snel gebruikgemaakt van netwerksockets. Deze sockets zijn een door tegenwoordig vrijwel ieder besturingssysteem aangeboden API, waarmee je de netwerkstack van het OS gemakkelijk kunt aanspreken om data over het lokale netwerk of het internet te kunnen versturen naar andere computers.
TCP-Sockets
Om een socket te instantiëren, verbindt men een IP-adres en poortnummer aan een socketadres, waarna een verbinding (in het geval van TCP) of gegevensstroom (bij UDP) tot stand kan worden gebracht. Het socketadres kan aan een specifieke (netwerk)interface gebonden zijn via een aan die adapter toegewezen IP-adres, of aan het 'magische' any-adres, 0.0.0.0 (of :: bij IPv6). Verder is nog te binden aan het loopback-adres (127.0.0.1, of ::1), waardoor de socket alleen vanaf dezelfde computer te benaderen is.
Server
Een server creëren middels C# en het .NET-Framework is snel gedaan: een socket aanmaken en kunnen laten luisteren naar verzoeken kost slechts een handvol regels.
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
internal void StartListening(int port)
{
  // Create the listening socket which will accept all incoming connections and bind it to the given port.
  Socket listenSocket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp);
  listenSocket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, 0);
  listenSocket.Bind(new IPEndPoint(IPAddress.IPv6Any, port));
      
  Socket client = null;

  try
  {
    // Start listening and accept a client.
    listenSocket.Listen(1);

    Console.WriteLine("Waiting for a connection...");

    client = listenSocket.Accept();

    Console.WriteLine("Client connected, starting to Receive...");

  }
  catch (Exception ex)
  {
    Console.WriteLine("Error starting server: '{0}'", ex);
    throw ex;
  }

  StartReceiving(client);
}


De naamgeving van de variabelen en methoden die worden gebruikt bij de instantiëring van de socket is voor zichzelf sprekend: er wordt een streaming internetsocket aangemaakt die zijn data verstuurt middels het TCP-protocol en vervolgens wordt gebind() aan het any-adres (dus niet specifiek aan een netwerkinterface) op de opgegeven poort. De socket staat ook IPv4-verbindingen toe, ondanks dat 'ie aan de IPv6-uitvoering van het any-adres is gebonden, wat te danken is aan de optie IPv6Only die wordt uitgeschakeld.

Hierna wordt de poort daadwerkelijk geopend, middels de Listen()-aanroep. De methode Accept() die vervolgens wordt aangeroepen, blokkeert totdat een client met de server verbindt. Wanneer dit gebeurt, zal de methode retourneren waardoor vervolgens de functie StartReceiving(client) wordt aangeroepen:
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private void StartReceiving(Socket client)
{
  try
  {
    int bytesReceived = 1;
    var buffer = new byte[8192];

    while (bytesReceived > 0)
    {
      bytesReceived = client.Receive(buffer);
      MessageReceived(buffer, bytesReceived);
    }

    Console.WriteLine("Client disconnected.");
  }
  catch (SocketException ex)
  {
    Console.WriteLine("Client unexpectedly disconnected: '{0}'", ex);
  }
  catch (Exception ex)
  {
    Console.WriteLine("Exception occurred: '{0}'", ex);
  }
  finally
  {
    if (client != null && client.Connected)
    {
      client.Close();
    }
  }
}


Net als de Accept() is ook Receive() een blocking call; de code erna wordt pas uitgevoerd wanneer er data is ontvangen of wanneer de client de verbinding netjes afsluit, waardoor de returnwaarde 0 wordt.

De (niet getoonde) MessageReceived()-methode converteert de byte-array om naar een UTF8-string, die naar de console wordt afgedrukt.
Client
Om met een server te verbinden is nog minder code nodig:
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
internal void Connect(IPAddress ip, int port)
{
  try
  {
    IPEndPoint ep = new IPEndPoint(ip, port);
        
    _clientSocket = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

    _clientSocket.Connect(ep);

    Console.WriteLine("Connected to server.");
  }
  catch (Exception ex)
  {
    Console.WriteLine("Error connecting to server: {0}", ex);
  }
}


Na het aanroepen van de Connect()-methode is de socket klaar om gegevens te versturen en ontvangen. Dit gebeurt middels de volgende code:
C#:
1
2
3
4
5
internal void SendMessage(String message)
{
  _clientSocket.Send(Encoding.UTF8.GetBytes(message));
  Console.WriteLine("{0} bytes sent", message.Length);
}

Klaar?
Helaas leeft de wijdverbreide misvatting dat men nu klaar is om berichten uit te wisselen tussen server en client. Deze misvatting is gekoppeld aan de gedachte dat één aanroep naar Send() altijd resulteert in één Receive() aan de andere kant. Niets is echter minder waar! In tegenstelling tot bijvoorbeeld WebSockets, waar berichten worden uitgewisseld, werken 'gewone' sockets enkel met bytes zonder verdere semantiek. De hoeveelheid data die middels de socket-aanroepen op de netwerkkabel wordt gezet of daarvan wordt gelezen, is afhankelijk van vele factoren. Data die in één Send()-aanroep geschreven wordt en kleiner is dan pakweg een byte of 1448 maakt inderdaad veel kans om in één Receive() te worden ontvangen, maar wanneer snel achter elkaar korte berichten worden verstuurd, maak je veel kans dat enkele van deze (wellicht onvolledige) berichten samen als in één blok data worden ontvangen.

Dit wordt veroorzaakt door het algoritme van Nagle, dat meerdere byte-arrays bewaart tot er voldoende zijn om een zo "vol" mogelijk pakket te versturen om zo de TCP- en IP-overhead te minimaliseren en dus de bandbreedte te vergroten. De toepassing kan worden uitgeschakeld, maar dan nog is er geen garantie dat er niet meerdere berichten in een receive-buffer terechtkomen. Begrijp dus goed dat oplossingen met timers (bijvoorbeeld door één bericht per seconde te sturen, of door vijf seconden te wachten na de laatste Receive() om vervolgens maar aan te nemen dat het hele bericht is ontvangen) nooit zullen werken, om maar niet te spreken van de onnodige vertraging die dit veroorzaakt.
Protocollen
Om uit zo'n aan elkaar geplakte brei van data nog iets van betekenis te kunnen halen, zal moeten worden afgesproken hoe de data er daadwerkelijk uit ziet. Deze regels worden vastgelegd in een protocol. Doordat (en wanneer) client en server zich aan het protocol houden, kunnen ze over een streaming protocol toch berichten uitwisselen.
HTTP
Zoals gezegd moet onderscheid gemaakt kunnen worden tussen verschillende berichten in één blok data dat zich in de buffer bevindt. Anderzijds moeten ook grotere berichten aan elkaar kunnen worden geplakt, wanneer deze in verschillende stukken worden ontvangen. De grootste uitdaging hierin (de volgorde) wordt gelukkig door TCP al afgevangen, de data wordt in exact de volgorde gepresenteerd via Receive() zoals ze via Send() zijn verstuurd, maar hoe weet je nu wanneer je het hele bericht hebt ontvangen?

Hier komt de applicatielaag kijken. Het bekendste protocol uit deze laag is wellicht HTTP, maar ook bijvoorbeeld FTP en RDP behoren tot deze klasse.

Een verzoek in het HTTP-protocol bestaat uit een drietal delen: eerst de startregel, daarna de verzoekheader(s), met daaronder een eventuele body. De startregel en headers worden, zo staat in de specificatie, elk afgesloten door een CrLf. Doordat beschreven staat dat dit móet (met nog een subtiele hint naar "buggy HTTP/1.0 clients" die dat wat minder consequent deden), weet een server die een verzoek krijgt dat hij kan blijven lezen tot de eerste enter, alvorens ook maar enige poging te doen de ontvangen data te verwerken. Tot die tijd wordt het verzoek gebufferd, waardoor je zelfs met bijvoorbeeld telnet kunt browsen (of een e-mail versturen als je met een SMTP-server verbindt): pas als je op de enter-toets drukt, wordt de regel door de server verwerkt. Het maakt dus niet uit dat voor iedere letter die je indrukt (tenzij je wellicht bloedsnel tikt) een receive() op de server retourneert, met een waarde van 1 byte. Dit is ook de reden dat de backspace-toets niet werkt wanneer je handmatig een HTTP-verzoek intikt richting een server: er wordt dan gewoon een byte met de waarde 8 aan de al ingetikte string toegevoegd. Helaas kan iedere receive() voor diverse context switches zorgen en werkt dus relatief vertragend.

Om dit (voor HTTP althans, "ons" protocol gaat er geen profijt van hebben) op te lossen, heeft men in FreeBSD 4.0 de kernelmodule accf_http(9) geïntroduceerd. Deze module grijpt in op de kernel en in de sockets-API die door de webserver gebruikt wordt, en filtert zo ieder binnenkomend verzoek op de socket waarop hij middels de socket-opties is ingeschakeld. Pas wanneer het filter een volledig HTTP-verzoek (van het type GET of HEAD, overige verbs worden niet ondersteund) heeft ontvangen, zet hij de receive() naar de webserver door, met in de buffer (als dat past) het gehele verzoek. Zodoende treden minder context switches op, en kan de server dus nog efficiënter werken. Maar ik dwaal af.
Reserved bits
Het spannende bij het ontwikkelen van een protocol is dat je van tevoren niet kan weten welke functionaliteit een eventuele volgende (of zelfs de huidige) versie zal hebben. Dit houdt in dat je bij iedere keuze de implicaties moet onderkennen. Bij HTTP is hier aardig over nagedacht (helaas doet de wat verbose structuur afbreuk aan de briljantheid), door alle delen van het protocol uitbreidbaar te maken. Zo is er WebDAV, een techniek die qua functionaliteit op FTP lijkt (maar veel geavanceerder is met rechten en versioning en die met metadata om kan gaan) en een uitbreiding is van het HTTP-protocol zelf, met nieuwe methodes die kunnen worden uitgevoerd (zoals PROPFIND) en voor de server begrijpbare message bodies in de vorm van een in de standaard beschreven XML-formaat. Een andere vorm van uitbreiding is dat men zelf headers mag 'verzinnen', waarmee informatie tussen client en server kan worden uitgewisseld die niet in de originele RFC staat, zoals bijvoorbeeld de header X-XSS-Protection, of dat bestaande headers worden uitgebreid met nieuwe opties.

Dit kan eenvoudig, omdat het berichtformaat geen vaste headerlengte heeft, noch een vaste lengte voor opties: slechts een CrLf geeft het einde van een door een key: value-paar gevormde header aan, terwijl een dubbele CrLf de laatste van alle headers afsluit. Na deze vier bytes kan een optionele payload verstuurd worden, waarvan de lengte (doorgaans) in de voorafgaande headers is vermeld. Een alternatief is het volledig specificeren van de berichtkop, waarbij ieder element een vaste waarde heeft. Een voorbeeld hiervan is TCP, waarbij de layout van de eerste 20 bytes van ieder TCP-pakket vastligt, waarbij iedere bepaalde groep van bits of bytes zijn eigen functie heeft, afhankelijk van de locatie in de header. Er moet dus bij het ontwerpen van het protocol al rekening gehouden worden met de data die in deze header kan komen te staan (hoeveel posities heb je nodig voor een jaartal?). Dit is niet altijd te voorspellen; tijdens het ontwikkelen van de grondbeginselen van het internet is er wel vaker een experiment uit de hand gelopen, kijk maar naar IPv4. Hierom kom je nog wel eens iets als reserved bits tegen, wat in de éénaprilgrap van het RFC in 2000 op de hak wordt genomen:
Reserved [een ruimte van 32 bits in de header] must be 0 and will always be 0 in future uses. It is included because every other protocol specification includes a "future use" reserved field which never, ever changes and is therefore a waste of bandwidth and memory.
Een andere oplossing, die ook door TCP wordt gebruikt, is het aangeven van extra opties in hun eigen blok data, voorafgegaan door de lengte van het blok, waardoor het geheel weer iets flexibeler wordt.
Een eigen protocol
Met bovenstaande kennis in het achterhoofd kan een eigen, basaal protocol worden vastgelegd. Een middels dit protocol verzonden bericht heeft de volgende indeling (gewoon, omdat het kan):

De eerste vier bytes representeren een in ASCII gecodeerde string, die het berichttype aanduidt. Vooralsnog wordt alleen het type "TMSG" erkend, waarmee tekstberichten kunnen worden uitgewisseld tussen client en server. De volgende vier bytes geven, als unsigned 32-bit integer (uint / UInt32), de berichtlengte weer in bytes. Deze waarde en dus de lengte van de payload die hierop volgt kan variëren van 0 tot 4.294.967.295 bytes.
Bytevolgorde
Waar verder nog rekening mee gehouden moet worden is het feit dat data die over het netwerk gaat, door een ander soort apparaat kan worden ontvangen. Het relevante hieraan is de bytevolgorde bij waarden die meerdere bytes beslaan, zoals onze 32-bit integer: schrijf je 'm nu van groot naar klein, of andersom? Hoewel veel architecturen little-endianness hanteren, is de gangbare schrijfwijze bij het transporteren over het netwerk toch echt big-endian. Omdat de enige eenheid die verstuurd kan worden middels een socket een byte is, zullen alle waarden die meerdere bytes beslaan dus in big-endian volgorde verstuurd moeten worden. Omwille de standaarden natuurlijk, want deze applicatie is de enige die dit protocol zal gebruiken.

Voor onze eerste vier karakters maakt dit niet uit, die zijn immers als ASCII gedefiniëerd en passen per stuk per definitie in één byte. De unsigned int die daarop volgt wordt daarentegen op de gemiddelde CPU waarop deze code wordt uitgevoerd als little-endian bewaard, wat blijkt uit de uitvoer van de volgende code:
C#:
1
2
var bytes = BitConverter.GetBytes((UInt32)1);
Console.WriteLine(BitConverter.ToString(bytes));


Uitvoer:
01-00-00-00


Gelukkig is dit voor een enkele eenheid van vier bytes geen probleem (en onthoud de gebruikte functie voor je volgende sollicitatie, know your framework):
C#:
1
2
3
4
if (BitConverter.IsLittleEndian)
{
  Array.Reverse(bytes);
}



Helaas heeft het omdraaien van alle bytes van een Unicode-string weinig zin, omdat je dan eenvoudigweg de tekst achterstevoren zet en daarnaast multibyte-karakters corrumpeert. Een alternatief is het gebruik van een byte order mark die de bytevolgorde van een Unicode-string aangeeft, die is te verkrijgen middels Encoding.GetPreamble(). Een al dan niet van een BOM voorziene byte array is vervolgens terug te lezen middels een StreamReader, die aan de hand van de eventuele BOM de juiste encoding bepaalt. Er wordt echter ook gezegd:
For standards that provide an encoding type, a BOM is somewhat redundant.
Wanneer is bepaald dat multibyte-karakters big-endian worden verstuurd, is aan server- en clientkant eenvoudigweg gebruik te maken van BigEndianUnicode-implementatie van de Encoding-klasse, die met de functies GetBytes() en GetString() in alle mogelijke wensen voorziet. Waar in .NET overigens "Unicode" wordt gebruikt, spreekt men van een UTF-16-codering, waarbij voor iedere verwijzing naar een Unicode-codepunt twee of vier bytes gebruikt worden. Een efficiëntere codering is UTF-8, waarbij de karakters die in Westerse talen het meest gebruikt worden meestal slechts één byte in beslag nemen. Hierbij is de bytevolgorde altijd hetzelfde, zelfs als er meerdere bytes gebruikt worden om een codepunt aan te wijzen, dus kan bij het gebruik daarvan de hele BOM zelfs achterwege worden gelaten.
Implementatie
Door al deze kennis over de berichtlayout aan de server- en clientkant te implementeren, kunnen beide programma's berichten uitwisselen. De implementatie begint met de volgende interface:
C#:
1
2
3
4
5
6
public interface IMessage
{
  String MessageType { get; }
  Byte[] Payload { get; }
  UInt32 Size { get; }
}



Die wordt geïmplementeerd door de volgende klasse:
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class TextMessage : IMessage
{
  public String MessageType { get; private set; }
  public Byte[] Payload { get; private set; }
  public UInt32 Size
  {
    get
    {
      return (this.Payload == null) ? 0 : (UInt32)this.Payload.GetLongLength(0);
    }
  }

  public TextMessage()
  {
    MessageType = MessageTypes.TextMessage;
  }

  public TextMessage(Byte[] payload)
    : base()
  {
    this.Payload = payload;
  }

  public String GetMessage()
  {
    if (this.Size == 0)
    {
      return String.Empty;
    }

    return Encoding.UTF8.GetString(this.Payload);
  }

  public void SetMessage(String message)
  {
    if (String.IsNullOrEmpty(message))
    {
      this.Payload = null;
      return;
    }

    this.Payload = Encoding.UTF8.GetBytes(message);
  }
}

Parsen
Nu een structuur bestaat om berichten te kunnen lezen en schrijven naar een representatie in het geheugen van de applicatie, kan worden begonnen met het schrijven naar het netwerk en het uitlezen van binnenkomende data om daarin berichten te herkennen. Dit laatste wordt parsen genoemd.

De volgende code zet een bericht om naar een byte-array die over de met de server verbonden socket kan worden verzonden:
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void SendMessage(IMessage message)
{
  var messageType = Encoding.ASCII.GetBytes(message.MessageType.ToUpperInvariant());
  var payloadSize = BitConverter.GetBytes(message.Size);

  if (BitConverter.IsLittleEndian)
  {
    Array.Reverse(payloadSize);
  }

  var data = new List<Byte>();

  data.AddRange(messageType);
  data.AddRange(payloadSize);
  if (message.Size > 0)
  {
    data.AddRange(message.Payload);
  }

  _clientSocket.Send(data.ToArray());
}

Waar zijn we nou helemaal mee bezig?
Aan de ontvangende kant komen nu één of meerdere Receive()-calls aan, waarna de applicatie met de ontvangen data aan de slag kan. De ontvangen data bestaat enkel uit een byte-array en een integer die aangeeft hoeveel bytes zijn gelezen, en de applicatie is verder niet op de hoogte van eventuele eerder ontvangen data. Om hier rekening mee te kunnen houden, is een object nodig dat de huidige stand van zaken bijhoudt: de ParserState, die hier overigens niet wordt getoond. Deze state bevat in eerste instantie een nieuw, leeg bericht en een buffer om de ontvangen headers in op te slaan. Het state-object wordt bewaard en telkens doorgegeven aan de parser, zodat bij het ontvangen van nieuwe data deze data aan een vorig, misschien nog half-af bericht kan worden toegevoegd.

Het eigenlijke werk wordt door de volgende krap tachtig regels tellende code gedaan:
C#:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public static List<ParserState> ParseBuffer(ParserState previousState, byte[] buffer, int bytesReceived)
{
  // Multiple messages can be received in one buffer
  var result = new List<ParserState>();

  // Start with the previous state
  result.Add(previousState);
  
  int bufferPointer = 0;

  while (bufferPointer < bytesReceived)
  {
    var state = result.Last();

    if (state.CurrentResult == ParserResult.InvalidHeaders)
    {
      // We aren't going to process this, please initialize a new connection
      // TODO: throw exception? Consumer should have checked the state before it passes it in...
      return result;
    }

    int receivedHeaderSize = 0;

    // If we haven't received all fixed headers yet
    if (!state.HeadersReceived)
    {
      state.CurrentResult = ParserResult.ReadingHeaders;

      // We might have received more data than just the headers and header data may already be available in the state from a
      // previous call, so read only the required amount of bytes.
      receivedHeaderSize = Math.Min(MessageHeaders.FixedHeaderSize - state.Headers.Pointer, bytesReceived - bufferPointer);

      state.Headers.AddData(buffer, bufferPointer, receivedHeaderSize);

      if (state.HeadersReceived)
      {
        state.Headers.Parse();
        state.CurrentResult = ParserResult.HeadersReceived;
      }
    }

    int receivedPayloadSize = 0;
    int remainingBytesInBuffer = 0;

    // We might also (just) receive a piece of data, if all headers have been received
    if (state.HeadersReceived && !state.PayloadReceived)
    {
      state.CurrentResult = ParserResult.ReadingPayload;

      if (state.Message == null)
      {
        state.Message = MessageFactory.GetMessage(state.Headers.MessageType, state.Headers.PayloadSize);
      }

      remainingBytesInBuffer = (bytesReceived - receivedHeaderSize - bufferPointer);

      // Read only the required amount of bytes.
      receivedPayloadSize = Math.Min((int)(state.Headers.PayloadSize - state.Message.Pointer), remainingBytesInBuffer);

      if (receivedPayloadSize > 0)
      {
        state.Message.AddData(buffer, (UInt32)(receivedHeaderSize + bufferPointer), (UInt32)receivedPayloadSize);            
      }
    }

    // If all payload data for this message has been received, create a new state
    if (state.HeadersReceived && state.PayloadReceived)
    {
      state.CurrentResult = ParserResult.MessageReceived;
      result.Add(new ParserState());
    }

    bufferPointer += receivedHeaderSize + receivedPayloadSize;
  }

  return result;
}



Zoals aan het commentaar is te zien, zijn er een aantal situaties mogelijk. De uitgangspositie is uiteraard een schone lei: er is nog geen data ontvangen en de state is zojuist geïnitialiseerd. Dan komt er data binnen: het begin van een nieuw bericht. De eerste acht bytes worden in de state.Headers bewaard, en als die volledig zijn ontvangen kan worden gekeken hoeveel bytes aan payload er nog verwacht wordt.

Het is echter mogelijk dat er minder dan de acht bytes die de headers vormen moeten worden geparseerd in één aanroep, omdat er eenvoudigweg nog te weinig data is ontvangen. Geen probleem: de rest van de code wordt overgeslagen, en de state (met daarin de tot nog toe ontvangen data) wordt geretourneerd. De verwachte data kan nog 'in flight' zijn, of kan zelfs nog worden opgebouwd door de applicatie aan de andere kant. Bij de volgende aanroep kunnen de verder ontvangen bytes worden toegevoegd aan dezelfde state, net zolang tot de headers compleet zijn.

Vervolgens kan de payload worden uitgelezen, als tenminste uit de headers is gebleken dat er daadwerkelijk data aan zit te komen. Zo niet, dan is het huidige bericht als compleet aangemerkt en wordt een nieuwe state aangemaakt, de buffer-"pointer" opgehoogd en de loop nogmaals doorlopen met de eventueel resterende data uit de receive-buffer.

Door het loopen door de buffer en door het retourneren van alle ontvangen berichten in hun bijbehorende state, kunnen in theorie oneindig veel achter elkaar geplakte berichten worden herkend, zolang er voldoende geheugen beschikbaar blijft voor deze lijst en zolang alle berichten maar aan het protocol voldoen. Wanneer dat laatste niet het geval is, en er meer of minder dan de afgesproken hoeveelheid bytes worden verstuurd door de client, zal een willekeurig deel van de resterende data eens in een header terechtkomen. Wanneer hierdoor een onbekend berichttype ontstaat (die immers door de eerste vier bytes van een bericht worden bepaald) zal de MessageFactory een exception gooien.
Het protocol uitbreiden
Er kan nu nog maar één soort bericht worden uitgewisseld, en de server noch de client beschikken over ook maar enige logica: ze doen niets met de berichten behalve ze naar de console afdrukken. Daarnaast kan de server nog maar één client tegelijk aan.

De sockets-API van .NET beschikt gelukkig ook over asynchrone methodes, waardoor eenvoudig een non-blocking, multithreaded server opgezet kan worden. De klasse die deze functionaliteit implementeert, handelt het ontvangen en versturen van berichten af en zet deze om in aan te roepen methoden en delegates waar op is in te schrijven. Dit is te veel code om hier te tonen, maar wanneer daar vraag naar is kan ik 't wel op een site als GitHub zetten.

Met een client en server die succesvol berichten kunnen uitwisselen is het protocolhoofdstuk in ieder geval afgesloten.
Toepassingsspecifiek gedrag definiëren
Hierna volgt het verfijnen van het protocol door het toevoegen van meerdere berichttypen, al naar gelang de applicatie dat vereist. Deze berichten zijn qua vorm exact gelijk aan de hierboven besproken TextMessage, echter wordt het onderscheid gemaakt in de MessageType-header. Zo kan bijvoorbeeld, wanneer een chatserver wordt gebouwd, een JOIN-bericht worden afgesproken waarin in de body (oftewel de payload) een string wordt verwacht met een chatkamernaam die de verbonden client wenst te joinen. Hoe een server verschillende chatrooms bijhoudt, wie deze rooms mag aanmaken en binnengaan en wellicht zelfs wie welke rechten hebben moet allemaal worden vastgelegd door de ontwerper(s) van het protocol, zodat een client en server die dit protocol implementeren elkaar blijven begrijpen.

De structuur om berichten te kunnen versturen bestaat nu, er rest enkel het bepalen en realiseren van de functionaliteit, door alle voorwaarden ("Een client moet een NICK-bericht sturen voordat hij een chatroom kan JOINen") aan de server- en clientkant te implementeren. Hoewel de infrastructuur er ligt, is deze nog wel uitbreidbaar. Er kunnen bijvoorbeeld beperkingen worden gelegd op de berichtlengte (ook al kan volgens het transportgedeelte van het protocol 4 GB ineens worden verstuurd, als client en server afspreken bepaalde limieten te hanteren op sommige of alle berichten), of er kan betekenis worden gegeven aan de inhoud van het bericht (zoals bij het JOIN-bericht). Zo kan, gebruikmakend van de bestaande berichtinfrastructuur, informatie worden uitwisselen via de payload.
Don't reinvent the wheel
Realiseer je wel tijdig dat ik hier het wiel genaamd IRC opnieuw aan het uitvinden ben. Dergelijke code schrijven voor een demonstratie valt nog te verdedigen, maar vaak is een bestaand protocol prima in te zetten voor een volgens jou uniek probleem, omdat een applicatieprotocol ook als transportmiddel gebruikt kan worden: zie bijvoorbeeld ook het gebruik van HTTP bij REST en SOAP. Hierbij worden (voor client en server) inhoudsvolle XML- of JSON-berichten uitgewisseld over het HTTP-protocol.
Multiplexing
De huidige implementatie van ons protocol bevat helaas nog een aantal tekortkomingen. Het grootste nadeel is dat er nu slechts één bericht tegelijk ontvangen of verzonden kan worden: de parser blijft immers uit dezelfde buffer lezen tot het volledige bericht is ontvangen. Wanneer veel data uitgewisseld wordt, bijvoorbeeld wanneer je (grote) bestanden zou willen kunnen versturen, blijft de overige communicatie tussen deze twee nodes wachten tot het volledige bestand is verstuurd.

Dit is grofweg op twee manieren op te lossen: meerdere TCP-verbindingen of UDP-streams tussen client en server instantiëren (kost veel resources en zorgt weer voor andere problemen), of het inbouwen van multiplexing zoals SPDY dat doet. Hierbij versturen client en server maximaal een bepaald aantal bytes van een bericht (bijvoorbeeld een byte of 1400, zodat voor ieder gedeelte van een bericht zo mogelijk altijd slechts één IP-pakketje

Volgende: WCF: Beveiliging middels certificaten 10-'12 WCF: Beveiliging middels certificaten
Volgende: Advertenties in EA's Origin verwijderen 07-'12 Advertenties in EA's Origin verwijderen

Reacties


Door Tweakers user Infant, dinsdag 14 augustus 2012 10:15

Ik wordt hier heel blij van.

Ik ben zelf met een non-blocking variant in C++ bezig, wat het verhaal gelijk een stuk moeilijker maakt.

Dat big vs little endianness is een probleem waar ik gisteren nog tegen aan liep. Ik wissel data uit tussen meerdere platformen, de 8-bit, 32-bit, 64-bit. Voor je gevoel is 1234 een logische volgorde, maar als al je code niks meer doet omdat het als 3412 binnen komt... zucht je even diep.

En dan nu een strikvraag:

Hoe handel je het af als je socket over een UMTS verbinding loopt, laten we zeggen van KPN, en KPN besluit de verbinding te killen zonder ook maar enige vorm van communicatie.

Door Tweakers user CodeCaster, dinsdag 14 augustus 2012 10:18

Dank! :)
En dan nu een strikvraag:

Hoe handel je het af als je socket over een UMTS verbinding loopt, laten we zeggen van KPN, en KPN besluit de verbinding te killen zonder ook maar enige vorm van communicatie.
Ik vermoed dat de eerstvolgende Send() of Receive() een exception gooit. ;)

Door Tweakers user YopY, dinsdag 14 augustus 2012 11:36

Infant schreef op dinsdag 14 augustus 2012 @ 10:15:
Hoe handel je het af als je socket over een UMTS verbinding loopt, laten we zeggen van KPN, en KPN besluit de verbinding te killen zonder ook maar enige vorm van communicatie.
Door geen sockets te gebruiken, :+. Mobiele applicaties en de communicatie daarvan gebruiken in de meeste gevallen HTTP of HTTP-achtige communicatie via TCP; pakketjes die niet aankomen worden (dankzij TCP) opnieuw verstuurd, net zolang tot er een timeout voorkomt of alle data overgeheveld is. De enige socket-verbinding die ik tegengekomen ben bij mobiele applicaties zijn die voor bijvoorbeeld push notificaties, en die worden automagisch opnieuw aangelegd indien de verbinding wegraakt (en dat gebeurt nogal vaak). Ook die pushberichten houden rekening met een braque verbinding, door de berichten vast te houden en opnieuw naar de telefoon te sturen totdat die aangeeft dat het bericht ontvangen is.

tl;dr: je applicatie moet met brakke verbindingen om moeten gaan, zowel de server als de client(s) (afhankelijk van de toepassing).

Door Tweakers user bastv, dinsdag 14 augustus 2012 11:52

Voor de mensen die echt met sockets bezig willen gaan zal ik ook eens kijken naar supersocket http://supersocket.codeplex.com/
Maakt het socket programmeren een stuk makkelijker

Door Tweakers user Jrz, dinsdag 14 augustus 2012 11:56

Kijk eens naar protocolbuffers.

Door Tweakers user ThaStealth, dinsdag 14 augustus 2012 13:46

Infant schreef op dinsdag 14 augustus 2012 @ 10:15:
Ik wordt hier heel blij van.

Ik ben zelf met een non-blocking variant in C++ bezig, wat het verhaal gelijk een stuk moeilijker maakt.

Dat big vs little endianness is een probleem waar ik gisteren nog tegen aan liep. Ik wissel data uit tussen meerdere platformen, de 8-bit, 32-bit, 64-bit. Voor je gevoel is 1234 een logische volgorde, maar als al je code niks meer doet omdat het als 3412 binnen komt... zucht je even diep.

En dan nu een strikvraag:

Hoe handel je het af als je socket over een UMTS verbinding loopt, laten we zeggen van KPN, en KPN besluit de verbinding te killen zonder ook maar enige vorm van communicatie.
Over het algemeen
Als je geen socket close krijgt moet je iets sturen om de socket dicht te zien vallen, de recieve zal 9 van de 10x lukken.

Aan de UMTS kant, byte sturen, als het goed is blijft deze in je outbuffer staan wanneer de connectie dicht is.
Aan de netwerkkant, schrijven naar de socket, socket valt na verloop van tijd dicht


Door Tweakers user CodeCaster, dinsdag 14 augustus 2012 14:32

Aanleiding voor dit schrijven was dat ik al enige tijd op dagelijkse basis vragen voorbij zag komen op Stack Overflow, in de trant van "Waarom Receive() ik niet precies wat ik Send()?". Dit is dus een basale uitleg van hoe sockets werken en waarom 'berichten' gefragmenteerd worden.

Behalve protobuf (nu verwerkt in de conclusie, bedankt) zijn er nog legio protocollen waarmee berichten uitgewisseld kunnen worden, de keuze voor een bepaald protocol (of de keuze er tóch een zelf te schrijven) is desalniettemin nog steeds afhankelijk van de context en overige requirements.

Door Tweakers user Infant, dinsdag 14 augustus 2012 14:55

YopY schreef op dinsdag 14 augustus 2012 @ 11:36:
[...]
Door geen sockets te gebruiken, :+.
10 Punten! U wint 1 internet.

Door Tweakers user Kosty, dinsdag 14 augustus 2012 20:56

Ik kwam dit bericht eerder tegen op Reddit dan hier, wat een verrassing. Leuk artikel, interessante materie, goed geschreven, mooi :P

Door Tweakers user H!GHGuY, donderdag 16 augustus 2012 08:52

Voor multiplexing kun je ook kijken naar SCTP.

Reageren is niet meer mogelijk