Živě.cz o počítačích a internetu

Umíme to s Delphi: 132. díl – praktická ukázka socketů: jak si poradit s více zaslanými zprávami

Václav Kadlec 14.6.2004

V dnešním, ryze praktickém článku se zaměříme na otázku, co si počít s vícenásobnými zprávami. Pokud totiž jedna aplikace zašle několik zpráv krátce po sobě, dostane druhá strana obvykle jednu spojenou zprávu. Nemůžeme totiž předpokládat, že počet zpráv u příjemce bude stejný jako počet odeslaných zpráv. Co s tím?

Minulý díl našeho seriálu byl ryze teoretický: týkal se popisu serverových aplikací, které programujeme v Delphi do verze 7 za použití komponenty ServerSocket ze záložky Internet v paletě komponent. Vysvětlili jsme si, jaký je rozdíl mezi pojmy blokující a synchronní spojení a mezi pojmy neblokující a asynchronní spojení. Potom jsme dokončili popis jednotlivých kroků, které musíme provést za účelem vytvoření serverové aplikace.

V předchozím článku se neobjevil žádný ukázkový příklad, žádný zdrojový kód. V jeho závěru článku jsme si proto slíbili, že dnešní díl bude mnohem praktičtější a že společně vytvoříme ukázkové aplikace, ně nichž si demonstrujeme zase další zajímavé problémy související se socketovou komunikací.

Pojďme na to. Obsah dnešního článku je tvořen dvěma aplikacemi: jednoduchým klientem, který bude požadovat od serveru zaslání aktuálního systémového času, a jednoduchým serverem, který bude klientům na požádání posílat svůj systémový čas. Na aplikacích si ukážeme některé zajímavé problémy, které při síťové komunikaci (a vůbec u distribuovaných aplikací) mohou nastávat.

Jedním z těchto problémů je více než jednoho údaje. Předvedeme si, co dělat, chceme-li na server poslat více údajů. Až dosud jsme ve všech ukázkových aplikacích posílali z klienta na server (a vlastně i naopak) pouze jeden údaj. Použili jsme jedno volání SendText pro odeslání údaje a jedno volání ReceiveText pro příjem údaje. Nevysvětlili jsme si ale, jak se zachovat, potřebujeme-li zaslat více údajů. Situace totiž není tak jednoduchá, jak se zdá.

Začneme naprogramováním jednoduchého klienta, který se nebude příliš lišit od klientů, které jsme používali v předchozích ukázkách.

Programujeme klienta

Naprogramování klienta bude jednoduché. Klient bude umět jen velmi málo činností:

Klient bude využívat pouze následující komponenty: 1x komponentu Label, 1x Edit, 3x Button a 1x komponentu ClientSocket ze záložky Internet.

Ošetříme následující události:

Začneme obsluhou události OnCreate formuláře. V jejím těle především zvolíme typ klientské aplikace (neblokující) a také číslo portu, k němuž se hodláme na serveru připojovat. Pak už jen nastavíme titulky komponent:

procedure TForm1.FormCreate(Sender: TObject);
begin
  Caption := `Klientská aplikace pøes sockety - odpojeno`;
  Button1.Caption := `&Pøipoj`;
  Button2.Caption := `&Odpoj`;
  Button3.Caption := `&Zjisti èas`;

  Label1.Caption := `Zadejte adresu serveru`;

  Button2.Enabled := False;
  Button3.Enabled := False;

  Edit1.Text := `127.0.0.1`;

  // nastaveni klienta
  ClientSocket1.ClientType := ctNonBlocking;  // neblokujici spojeni
  ClientSocket1.Port := 5050;                // pripojujeme se k portu 5050
end;

Dále ošetříme událost Button1.OnClick, v jejíž obsluze se připojíme ke vzdálenému serveru:

procedure TForm1.Button1Click(Sender: TObject);
begin
  if Edit1.Text <> `` then
    ClientSocket1.Address := Edit1.Text
  else
    ClientSocket1.Address := `127.0.0.1`;      // nezadano->lokalni pocitac

  ClientSocket1.Open;                          // navazeme spojeni
end;

Po stisku tlačítka Button2 se naopak od serveru odpojíme:

procedure TForm1.Button2Click(Sender: TObject);
begin
  ClientSocket1.Close;                          // zrusime spojeni
end;

Asi nejdůležitější obsluhou je OnClick tlačítka Button3. Ta totiž bude sloužit k zaslání dat na server. Protože si budeme chtít ukázat zpracování většího množství zaslaných dat, pošleme serveru nejen požadavek na poslání jeho aktuálního systémového času, ale pošleme mu pro zajímavost i náš aktuální lokální systémový čas. Kromě toho si nadefinujeme následující komunikační protokol (jinak řečeno, se serverem se dohodneme na následujícím významu zasílaných zpráv):

Obsluha události bude intuitivní: kolik údajů chceme serveru poslat, tolikrát zavoláme metodu SendText:

procedure TForm1.Button3Click(Sender: TObject);
begin
  ClientSocket1.Socket.SendText(`INFO:MYTIME\`);
  ClientSocket1.Socket.SendText(TimeToStr(Time) + `\`);
  ClientSocket1.Socket.SendText(`REQ:TIME\`);
end;

Dále ošetříme události ClientSocket.OnConnect, resp. ClientSocket.OnDisconnect, které jsou generovány poté, co došlo k připojení klienta k serveru, resp. k odpojení klienta od serveru:

procedure TForm1.ClientSocket1Connect(Sender: TObject;
  Socket: TCustomWinSocket);
begin
  Caption := `Klientská aplikace pøes sockety - pripojeno`;
  Button1.Enabled := False;
  Button2.Enabled := True;
  Button3.Enabled := True;
end;

procedure TForm1.ClientSocket1Disconnect(Sender: TObject;
  Socket: TCustomWinSocket);
begin
  Caption := `Klientská aplikace pøes sockety - odpojeno`;
  Button1.Enabled := True;
  Button2.Enabled := False;
  Button3.Enabled := False;
end;

Předposlední ošetřená událost se týká přijetí dat ze serveru (ClientSocket.OnRead). Víme, že server nám nezasílá nic jiného než informace o systémovém času. Obsluha proto může vypadat třeba takto:

procedure TForm1.ClientSocket1Read(Sender: TObject;
  Socket: TCustomWinSocket);

var
  PrijataData: string;

begin
  PrijataData := Socket.ReceiveText;

  // zde bychom nastavili cas podle prijateho udaje

  ShowMessage(`Prisel cas ze serveru. Nas lokalni cas je ` + TimeToStr(Time) + `, ze serveru prisel cas ` + PrijataData);
end;

Pokud se ptáte, jakým způsobem by mohlo být implementováno nastavení systémového času, dovolím si vás odkázat na 33. díl tohoto seriálu, kde je takový algoritmus popsán.

Nyní už jen ošetříme událost FormClose, v jejíž obsluze ukončíme připojení:

procedure TForm1.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  ClientSocket1.Close;
end;

Klientská aplikace je hotova. Nyní se podíváme na to, jak si poradit s programováním serveru.

Programujeme server

Na řadě je programování serveru. Co by měl tento server umět?

K programování serveru bude zapotřebí následujících komponent: 2x Label, 2x Memo, 1x ServerSocket.

Ošetříme následující události:

Začneme tradičně – obsluhou události OnCreate formuláře. V její obsluze provedeme známé kroky – nastavíme typ serveru (stNonBlocking), číslo portu (5050) a server aktivujeme (Active := True):

procedure TForm1.FormCreate(Sender: TObject);
begin
  // nastaveni serveru
  ServerSocket1.Port := 5050;                // cislo portu - zvolime 5050
  ServerSocket1.ServerType := stNonBlocking;  // server bude neblokujici
  ServerSocket1.Active := True;              // server "zapneme"

  Caption := `Serverová aplikace pøes sockety`;

  Label1.Caption := `Spojeni:`;
  Label2.Caption := `Prijata data:`;
end;

Nyní se budeme zabývat událostmi komponenty ServerSocket. Začneme událostí OnClientConnect, která je generována po připojení kteréhokoliv klienta k serveru. V obsluze pouze vypíšeme informaci o připojení do komponenty Memo:

procedure TForm1.ServerSocket1ClientConnect(Sender: TObject;
  Socket: TCustomWinSocket);
begin
  ListBox1.Items.Add(`Pøipojeno: ` + Socket.RemoteHost + `(` + Socket.RemoteAddress + `)`);
end;

Další ošetřená událost – ServerSocket.OnDisconnect – také jen vypíše informaci o odpojení klienta:

procedure TForm1.ServerSocket1ClientDisconnect(Sender: TObject;
  Socket: TCustomWinSocket);
begin
  ListBox1.Items.Add(`Odpojeno: ` + Socket.RemoteHost + ` (` + Socket.RemoteAddress + `)`);
end;

Další událost je však daleko zajímavější. Jedná se o událost ServerSocket.OnRead, která je vlastně srdcem celé aplikace. Připomeňme, že server musí být připraven na to, že mu klient může poslat více údajů najednou. Tento problém je nutné trochu rozebrat, protože nejjednodušší řešení, která nás napadnou, bohužel nefungují.

Jako první by nás mohlo napadnout, že uvnitř obsluhy OnRead zavoláme víckrát metodu ReceiveText. Toto řešení však není dobré už z pouhého názoru: kolikrát bychom tuto metodu volali? Vždyť přece server neví, kolik zpráv mu který klient bude chtít poslat! Nemůžeme proto určit počet volání metody ReceiveText.

Další řešení už je logičtější: zavolat uvnitř obsluhy události OnRead jen jednou ReceiveText, a předpokládat, že tato událost bude volána tolikrát, kolikrát zavolal klient metodu SendText. Ani tento předpoklad však není správný.

Při posílání a přijímání zpráv a vůbec při síťové komunikaci totiž musíme vycházet z toho, že po odeslání zprávy pomocí SendText přesně nevíme, jakým způsobem a hlavně jak rychle bude tato zpráva odeslána, resp. přijata. Pokud pošleme dvě zprávy rychle po sobě, může se stát, že budou díky síťové infrastruktuře doručeny jako jediná zpráva, a naopak pokud pošleme jednu jedinou zprávu, může se teoreticky stát, že dojde „rozkouskovaná“ na více částí. Z toho musíme vycházet: nelze předpokládat, že počet přijímacích událostí OnRead bude přesně korespondovat počtu „odesílacích“ metod SendText.

Jediné správné řešení proto spočívá v implementaci nějakého přijímacího bufferu. Pokud existuje nebezpečí, že aplikaci může být odesláno více zpráv, měli bychom na přijímací straně implementovat nějaký mechanismus, který umožní přijatá data rozparsovat, rozkouskovat na jednotlivé (korektní) zprávy.

K tomuto rozkouskování samozřejmě potřebujeme znát buď strukturu (formát) zasílaných zpráv, nebo alespoň oddělovače, kterými jsou jednotlivé zprávy odlišovány. V diskusi pod jedním z předchozích článků se objevilo hezké přirovnání, jehož autorem je čtenář podepsaný jako Jiří: Přijatou zprávu je nutné „rozkouskovat podle nějakých speciálních znaků, něco jako start a stop v telegrafu.“

Je samozřejmé, že takto implementovaný buffer si musí pamatovat dosud přijatá data i „mezi“ jednotlivými generovanými událostmi OnRead pro případ, že by zpráva přišla rozdělena do více částí. V naší jednoduhé ukázce budeme toto nebezpečí ignorovat a jednoduché parsování přijatého textu implementujeme přímo uvnitř obsluhy OnRead. Není to nejčistší řešení a není ani obecné, nicméně pro ukázku principu snad postačí.

Nyní se podíváme na zdrojový kód obsluhy OnRead, pak si jej ještě podrobně vysvětlíme:

procedure TForm1.ServerSocket1ClientRead(Sender: TObject;
  Socket: TCustomWinSocket);
var I, J: Integer;
    Vysledek, Token, PrijataData: String;
    PrijateUdaje: array[0..100] of string;

begin
  PrijataData := Socket.ReceiveText;

  ListBox2.Items.Add(`===========================================`);
  ListBox2.Items.Add(`Prijato od ` + Socket.RemoteHost + `: ` + PrijataData);

  Token := ``; J := 0;

  for I := 1 to StrLen(PChar(PrijataData)) do begin
    if PrijataData[I] <> `\` then
      Token := Token + PrijataData[I]
    else begin
      PrijateUdaje[J] := Token;
      Token := ``;
      Inc(J);
    end;
  end;

  for I := 0 to J-1 do begin
    Token := PrijateUdaje[I];
    if Token = `INFO:MYTIME` then begin
      ListBox2.Items.Add(`Klient nas informuje o svem lokalnim case`);
    end else

    if Token = `REQ:TIME` then begin
      ListBox2.Items.Add(`Klient pozaduje zaslani casu serveru`);
      Vysledek := TimeToStr(Time);
      Socket.SendText(Vysledek);
    end else
      ListBox2.Items.Add(`Klientsky lokalni cas: ` + Token);
  end;
end;

Algoritmus funguje přibližně takto:

Poslední událost, FormClose, je jednoduchá: spočívá v uzavření socketu.

procedure TForm1.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  ServerSocket1.Active := False;              // server "vypneme"
end;

Testujeme aplikace

Aplikace jsou hotovy a vy si je můžete směle otestovat. Spusťte klienta, spusťte server a kochejte se fungující komunikací (viz obrázek). Protože už je dnešní článek opět neúměrně dlouhý, v tomto okamžiku jej přerušíme a slíbíme si, že se k vytvořeným aplikacím ještě za týden vrátíme a ukážeme si celou řadu dalších problémů, s nimiž na nás mohou tyto aplikace vyrukovat :-)

Zdrojové kódy

Jako obvykle, i dnes si pro úplnost uvedeme kompletní zdrojové kódy obou aplikací. Začneme kódem klienta:

unit Unit1;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  StdCtrls, ScktComp;

type
  TForm1 = class(TForm)
    Label1: TLabel;
    Edit1: TEdit;
    Button1: TButton;
    Button2: TButton;
    ClientSocket1: TClientSocket;
    Button3: TButton;
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
    procedure Button2Click(Sender: TObject);
    procedure Button3Click(Sender: TObject);
    procedure FormClose(Sender: TObject; var Action: TCloseAction);
    procedure ClientSocket1Connect(Sender: TObject;
      Socket: TCustomWinSocket);
    procedure ClientSocket1Disconnect(Sender: TObject;
      Socket: TCustomWinSocket);
    procedure ClientSocket1Read(Sender: TObject; Socket: TCustomWinSocket);
  private
    { Private declarations }
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.DFM}

procedure TForm1.FormCreate(Sender: TObject);
begin
  Caption := `Klientská aplikace pøes sockety - odpojeno`;
  Button1.Caption := `&Pøipoj`;
  Button2.Caption := `&Odpoj`;
  Button3.Caption := `&Zjisti èas`;

  Label1.Caption := `Zadejte adresu serveru`;

  Button2.Enabled := False;
  Button3.Enabled := False;

  Edit1.Text := `127.0.0.1`;

  // nastaveni klienta
  ClientSocket1.ClientType := ctNonBlocking;  // neblokujici spojeni
  ClientSocket1.Port := 5050;                // pripojujeme se k portu 5050
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  if Edit1.Text <> `` then
    ClientSocket1.Address := Edit1.Text
  else
    ClientSocket1.Address := `127.0.0.1`;      // nezadano->lokalni pocitac

  ClientSocket1.Open;                          // navazeme spojeni
end;

// Button2 - odpojeni od serveru
procedure TForm1.Button2Click(Sender: TObject);
begin
  ClientSocket1.Close;                          // zrusime spojeni
end;

// Button3 - zaslani dat
procedure TForm1.Button3Click(Sender: TObject);
begin
  ClientSocket1.Socket.SendText(`INFO:MYTIME\`);
  ClientSocket1.Socket.SendText(TimeToStr(Time) + `\`);
  ClientSocket1.Socket.SendText(`REQ:TIME\`);
end;

procedure TForm1.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  ClientSocket1.Close;
end;

procedure TForm1.ClientSocket1Connect(Sender: TObject;
  Socket: TCustomWinSocket);
begin
  Caption := `Klientská aplikace pøes sockety - pripojeno`;
  Button1.Enabled := False;
  Button2.Enabled := True;
  Button3.Enabled := True;
end;

procedure TForm1.ClientSocket1Disconnect(Sender: TObject;
  Socket: TCustomWinSocket);
begin
  Caption := `Klientská aplikace pøes sockety - odpojeno`;
  Button1.Enabled := True;
  Button2.Enabled := False;
  Button3.Enabled := False;
end;

procedure TForm1.ClientSocket1Read(Sender: TObject;
  Socket: TCustomWinSocket);

var
  PrijataData: string;

begin
  PrijataData := Socket.ReceiveText;

  // zde bychom nastavili cas podle prijateho udaje

  ShowMessage(`Prisel cas ze serveru. Nas lokalni cas je ` + TimeToStr(Time) + `, ze serveru prisel cas ` + PrijataData);
end;

end.

Nyní se podívejte na kompletní kód serverové aplikace:

unit Unit1;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  ScktComp, StdCtrls;

type
  TForm1 = class(TForm)
    ListBox1: TListBox;
    ListBox2: TListBox;
    Label1: TLabel;
    Label2: TLabel;
    ServerSocket1: TServerSocket;
    procedure FormCreate(Sender: TObject);
    procedure ServerSocket1ClientConnect(Sender: TObject;
      Socket: TCustomWinSocket);
    procedure ServerSocket1ClientDisconnect(Sender: TObject;
      Socket: TCustomWinSocket);
    procedure ServerSocket1ClientRead(Sender: TObject;
      Socket: TCustomWinSocket);
    procedure FormClose(Sender: TObject; var Action: TCloseAction);
  private
    { Private declarations }
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.DFM}

procedure TForm1.FormCreate(Sender: TObject);
begin
  // nastaveni serveru
  ServerSocket1.Port := 5050;                // cislo portu - zvolime 5050
  ServerSocket1.ServerType := stNonBlocking;  // server bude neblokujici
  ServerSocket1.Active := True;              // server "zapneme"

  Caption := `Serverová aplikace pøes sockety`;

  Label1.Caption := `Spojeni:`;
  Label2.Caption := `Prijata data:`;
end;


// udalost OnClientConnect nastane po pripojeni klienta
procedure TForm1.ServerSocket1ClientConnect(Sender: TObject;
  Socket: TCustomWinSocket);
begin
  ListBox1.Items.Add(`Pøipojeno: ` + Socket.RemoteHost + `(` + Socket.RemoteAddress + `)`);
end;


// udalost OnClientDisconnect nastane po odpojeni klienta
procedure TForm1.ServerSocket1ClientDisconnect(Sender: TObject;
  Socket: TCustomWinSocket);
begin
  ListBox1.Items.Add(`Odpojeno: ` + Socket.RemoteHost + ` (` + Socket.RemoteAddress + `)`);
end;


// udalost OnClientRead nastane, ma-li server precist data zaslana klientem
procedure TForm1.ServerSocket1ClientRead(Sender: TObject;
  Socket: TCustomWinSocket);
var I, J: Integer;
    Vysledek, Token, PrijataData: String;
    PrijateUdaje: array[0..100] of string;

begin
  PrijataData := Socket.ReceiveText;

  ListBox2.Items.Add(`===========================================`);
  ListBox2.Items.Add(`Prijato od ` + Socket.RemoteHost + `: ` + PrijataData);

  Token := ``; J := 0;

  for I := 1 to StrLen(PChar(PrijataData)) do begin
    if PrijataData[I] <> `\` then
      Token := Token + PrijataData[I]
    else begin
      PrijateUdaje[J] := Token;
      Token := ``;
      Inc(J);
    end;
  end;

  for I := 0 to J-1 do begin
//    ShowMessage(`Udaj cislo ` + IntToStr(I+1) + `: ` + PrijateUdaje[I]);
    Token := PrijateUdaje[I];
    if Token = `INFO:MYTIME` then begin
      ListBox2.Items.Add(`Klient nas informuje o svem lokalnim case`);
    end else

    if Token = `REQ:TIME` then begin
      ListBox2.Items.Add(`Klient pozaduje zaslani casu serveru`);
      Vysledek := TimeToStr(Time);
      Socket.SendText(Vysledek);
    end else
      ListBox2.Items.Add(`Klientsky lokalni cas: ` + Token);
  end;
end;


procedure TForm1.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  ServerSocket1.Active := False;              // server "vypneme"
end;

end.

Na závěr

V dnešním článku jsme si ukázali především způsob, jak si poradil s vícenásobnými zprávami, jinak řečeno s více zprávami poslanými krátce po sobě. Vysvětlili jsme si, že nelze použít vícenásobné volání metody ReceiveText ani očekávat vícenásobné generování události OnRead: řešením je implementovat jakýsi přijímací buffer, který je schopen přijmout veškeré doručené údaje (bajty) a vyrobit z nich (rozdělit jej na) odpovídající počet korektních, původně odeslaných událostí. Taková je podstata síťové komunikace.

Za týden se k dnešním aplikacím ještě jednou vrátíme a ukážeme si další zajímavé problémy.