maanantai 28. heinäkuuta 2008

Socketit - tapa välittää tietoa.

C suomeksi...

C ja C++ kieli ovat jo suhteellisen ikääntyneitä, mutta edelleen elinvoimaisia ja tehokkaita ohjelmointikieliä. Jostain syystä suomekielistä dokumentaatiota on kuitenkin aika niukasti. Tähän blogiin on tarkoitus kirjoitella simppeleitä pätkiä sieltä täältä C:n laajasta valikoimasta. Tarkoituksena ei ole kirjoittaa kaikenkattavaa C-kielen opasta, vaan poimia kiinnostavia ja hyödyllisiä palasia ja antaa niistä riittävä tieto jotta kiinnostunut suomalainen koodarinalku pääsee liikkeelle.

Ohjelmoija joutuu usein tilanteeseen, jossa hänen on saatava tieto kulkemaan prosessilta toiselle, tai jopa tietokoneelta toiselle. Socketit on yksi mahdollisuus.

Miksi socketit?

Samalla koneella pyörivien prosessien väliseen tiedonjakoon löytyy muitakin keinoja, kuten putket, jaettu muisti, tiedostot jne. Kaikissa näissä on omat hyvät ja huonot puolensa. Jaettu muisti on nopea keino, putket ovat yksinkertaisia ja suhteellisen nopeita, tiedostot säilyttävät datansa ohjelman suorituksen päätyttyä...

Sockettien vahvuus on suunnittelun vapaus. Socketteja käyttämällä voidaan tarvittaessa siirtää kahdesta keskenään keskustelevasta prosessista toinen kokonaan eri koneelle, varsin pienin muutoksin. Lisäksi siinä missä esim putket ovat varsinaisesti Linux/Unix maailmaan kuuluvia, ja jaetun muistin käyttäminenkin tapahtuu eri käyttöjärjestelmissä eri tavoin, socket rajapinta löytyy kaikista POSIX ympäristöistä, ja windowskin tarjoaa oman winsock kirjastonsa joka eroaa POSIX socketeista vain muutamissa kohdissa. Niinpä sockettien avulla, pienellä etukäteistutustumisella on varsin mahdollista tehdä "portattavaa" softaa.

Ennen esimerkin pariin siirtymistä, mainitsen vielä, että socketteja voi käyttää eri verkkoprotokollien kanssa. Socketit tarjoavat mahdollisuuden lähettää dataa UDP:n, TCP/IP:n ja muutamien muiden protokollien kautta (UNIX maailmassa on mainittava samalla koneella ajettavien prosessien väliseen kommunikointiin tarkoitetut UNIX socketit, jotka ovat erinomainen vaihtoehto harkittaessa miten prosessien välinen kommunikointi tulisi hoitaa). Ja valitusta protokollasta huolimatta, varsinainen datan lähetys/vastaanotto on tehty varsin samanlaiseksi, käyttäjän tarvitsee vain huolehtia oikeantyyppisen protokollan määrittämisestä, ja mahdollisen yhteyden luonnista.

(TCP/IP on niin kutsuttu 'connected' protokolla, eli protokolla pitää vatsaanottajan ja lähettäjän välillä "yhteyttä auki", Ts. yhteyden muodostus, ylläpito ja lopettaminen tapahtuvat lähettämällä tietynlaisia ennaltasovittuja paketteja. Mutta sockettien käyttäjän ei tarvitse näistä murehtia, alla olevat kerrokset [IP-pinkka] huolehtivat siitä).

Koetan käydä läpi yksinkertaisen esimerkin (UNIX) socketien käytöstä. Käytän yksinkertaisuuden vuoksi "blokkaavia" funktioita (Ts. funktioita jotka pysäyttävät ohjelmakoodin ajon siksi aikaa kun odottavat socketin lukemisen/sockettiin kirjoittamisen olevan mahdollista). Ihan lopuksi saatan käydä läpi esimerkin select() funktion käytöstä, tutkittaessa onko uutta dataa saatavilla. HUOM: Windows ja UNIX maailmassa select() funktiossa on pieniä eroja. Windowsissa select():iä voi käyttää vain sockettien kanssa, tutkittaessa socketin tilaa. UNIX maailma ei erittele socketia muista 'file descriptoreista', ja select() funktiota voidaankin UNIX koneissa käyttää myös esim. tiedostojen tilan tutkimiseen.


HUOM! Esimerkkikoodeja ei taata bugittomiksi, koetan toki parhaani mukaan varmistaa, että koodit kääntyvät, ja toimivat, mutta kaikenkarvaiset pikku (ja isommat) bugit ovat toki mahdollisia. Tarkoitus tosin olikin esitellä vain perusperiaatteet, mutta kunhan saan aikaiseksi, koetan kääntää ja ajaa esimerkit ja korjailla pahimmat mokat :)

Esimerkki 1: TCP/IP Datayhteyden muodostus "kuuntelevaan" koneeseen(Client program)



#include <sys/socket.h> //otsikkotiedosto jossa esitellään socket ohjelmoinnissa tarpeellisia tietotyyppejä ja funktioita.
#include <unistd.h> //yleishyödyllinen kirjasto
#include <stdio.h> //perus I/O toiminnot
#include <stdlib.h> //malloc
#include <arpa/inet.h> //verkkoliikenne matskua
#include <string.h> //memcpy


int main()
{
int sock; //socketti kahva; vertaa tiedoston avaaminen => kahva
struct sockaddr_in osoite = {0};
void *lahetettava_data;
int luettu_koko;
unsigned short int serverin_portti=12345;
void *luettu_data;
int datan_koko;
char lahteva_sanoma[]="fooooo";
int luvun_paluuarvo;

/*
aseta socketin "perhe" AF_INET:iksi (connections using ethernet),
tyyppi SOCK_STREAM:iksi (stream soketti, ei paketti data SOCK_DGRAM
kuten UDP:ssä), protocolla TCP:ksi ja luo socketti.
*/
if( (sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) ) < 0 )
{
//virhekäsittely, socketin luonti epäonnistui
}
/*
Täytä vastaanottajan tiedot, kuten portti ja IP sekä sockettisi
perhe sockaddr_in tyyppiseen structiin.
*/
osoite.sin_family = AF_INET;
/*
inet_addr() funktio muuttaa osoitteen struktiin sopivaan binary
muotoon, ja kääntää bittijärjestyksen "network byte orderiin"
(Eniten merkitsevä tavu ensin. Normaalissa PC arkkitehtuurissahan
käytetään Ns. "Little endian" arkkitehtuuria, missä eniten
merkitsevät tavut tulevat viimeisenä.
*/
osoite.sin_addr.s_addr = inet_addr("127.0.0.1");
/*
htons() eli host to network short muuttaa short integer tyypin
luvun bittijärjestyksen "network byte orderiin".
*/
osoite.sin_port = htons(serverin_portti);

/*
Yhdistä socketti. Tässä vaiheessa IP-pinkka aloittaa neuvottelut
vastapelurin kanssa, ja mikäli connect kutsu palauttaa jotain muuta
kuin 0:n, se tarkoittaa, että yhteydenotto syystä tai toisesta
epäonnistui.
*/
if(
connect
(
sock,
(struct sockaddr *)&osoite,
sizeof(osoite)
)
!= 0
)
{
//virheenkäsittely, sokettia ei voitu yhdistää.
}
/*
Kun yhteys on pystyssä, voidaan varsinainen viestien lähettäminen
ja vastaanotto aloittaa: Tehdään siis viesti joka halutaan lähettää.
Tässävaiheessa on syytä muistuttaa, että STREAM socketit kuten
TCP:n tapauksessa, eivät takaa sitä, että koko kerralla lähetetty
sanoma tulee perille yhdellä kertaa ( yhdellä read() tai recv()
kutsulla). Siksi kun teet oman ohjelmasi, jossa haluat lähettää ja
vastaanottaa dataa sockettien kautta, esim. vaihtaessasi tietoa
prosessien välillä, on hyvä idea toteuttaa jokin oma protokolla.
Yksinkertaisimmillaan vain päättämällä, että heti jokaisen viestin
alussa on 32 bittiä (yksi integeri) joihin on talletettu lähetetyn
datan pituus. UDP-socketteja käytettäessä, pakettien saapumis-
järjestys taas saattaa olla eri kuin lähetysjärjestys.
*/
lahetettava_data=malloc(sizeof(int)+7*sizeof(char));
if(NULL==lahetettava_data)
{
/*
Virhekäsittely, muistin allokointi datan lähettämiseksi
epäonnistui.
*/
}
/*
jätän koon ilmoittavan integerin viemän tilan huomioimatta sekä
lähetys että vastaanottopäässä
*/
datan_koko=7*sizeof(char);
//Kopioi lähetetyn datan koko, lähetettävän datan alkuun.
memcpy(lahetettava_data,(void *)&datan_koko, sizeof(int));
//kopioi varsinainen viesti heti koon perään.
memcpy
(
(void*)((char *)lahetettava_data)+sizeof(int),
lahteva_sanoma,7*sizeof(char)
);
//Tähän olisi kannattanut käyttää structia :)

//lähetä varsinainen viesti
write(sock, lahetettava_data, datan_koko+sizeof(int));
/*
varaa tilaa luettavalle datalle.
Luetaan ensin ensimmäiset 32 bittiä, ja tulkitaan se integerinä
joka kertoo viestin koon. Tämän jälkeen luetaan loppu viesti.
*/

datan_koko=recv(sock, (void *)&luettu_koko, sizeof(int), 0)
if(datan_koko<sizeof(int))
{
/*
virhekäsittely, luku palautti vähemmän kuin 4 tavua
(sizeof(int)). katso palauttiko recv 0, tai negatiivisen luvun,
ja mikäli palautti tapahtui virhe tai yhteys suljettiin. Mikäli
luku on positiivinen, mutta pienempi kuin sizeof(int) (ei pitäisi
olla mahdollista!), lue uudelleen, ja aseta luettavaksi määräksi
sizeof(int)-datan_koko jotta saat koko koon luettua.
*/
}

// Luetaan nyt varsinainen data:
luettu_data=malloc(luettu_koko);
if(NULL==luettu_data)
{
//virhekäsittely.
}
datan_koko=0;
while(datan_koko<luettu_koko)
{
if(
( luvun_paluuarvo=recv
(
sock,
(void *)(((char *) luettu_data) + datan_koko),
luettu_koko-datan_koko, 0
)
)
<= 0
)
{
/*
virhekäsittely, recv palautti 0 tai negatiivisen luvun.
0==yhteys suljettu, -1==virhe. Virhetapauksessa tutki
errno saadaksesi tarkemman virhenumeron.
*/

}
datan_koko+=luvun_paluuarvo;
}
//luetun datan tulisi nyt olla luettu_data muuttujassa.
/*
Huomaa, tällainen printti on vaarallinen. Mikäli lähetetty data
ei pääty NULL characteriin, tapahtuu luettaessa ylivuoto!
*/

printf("Client - vastaanotettu: %s",(char *)luettu_data);
//Lopuksi on hyvä käytäntä kutsua
close(sock);
/*
vaikkakin käyttöjärjestelmä toki sulkee roikkumaan jääneet
yhteydet, viimeistään prosessin päättyessä.
*/
return EXIT_SUCCESS;
}



Esimerkki 2: Tulevia yhteyksiä kuuntelevan TCP/IP socketin teko (Server program)



#include <sys/socket.h> //otsikkotiedosto jossa esitellään socket ohjelmoinnissa tarpeellisia tietotyyppejä ja funktioita.
#include <unistd.h> //yleishyödyllinen kirjasto
#include <stdio.h> //perus I/O toiminnot
#include <stdlib.h> //malloc
#include <arpa/inet.h> //verkkoliikenne matskua
#include <string.h> //memcpy



/*
Client koodissa olevasta viestin luonnin hankaluudesta
viisastuneena, luomme serveri koodiin viesti structin:
*/
#define MAX_DATA 1024

typedef struct viesti
{
int viestin_koko;
void data[MAX_DATA];
}viesti;

int main()
{
/*
socketti johon ulkopuolelta tulevat yhteydet saapuu kahva;
vertaa tiedoston avaaminen => kahva
*/
int kuunteleva_socketti;
/*
Socketti joka luodaan varsinaista kommunikointia varten kun
serveri hyväksyy kuuntelevaan sockettiin tulevan yhteyden. Näin
kuunteleva socketti vapautuu kuuntelemaan uusia yhteyspyyntöjä.
*/
int yhdistetty_socketti;

struct sockaddr_in oma_osoite = {0};
struct sockaddr_in clientin_osoite ={0};
socklen_t osoitteen_pituus;
unsigned short int kuunneltava_portti=12345;
int luettu_koko;
int datan_koko;
viesti vastaanotettu_sanoma;
viesti vastaus_sanoma;
int luvun_paluuarvo;

//Luodaan socketti jota kuunnellaan, samoin kuin clientin tapauksessa.
if( (kuunteleva_socketti=socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
//virhekäsittely, socketin luonti epäonnistui
}
// Sitten asetetaan oman serverin tiedot sockaddr_in structiin
oma_osoite.sin_family = AF_INET;
oma_osoite.sin_addr.s_addr = htonl(INADDR_ANY);
oma_osoite.sin_port = htons(kuunneltava_portti);
/*
Tässävaiheessa client koodissa luotiin yhteys kuuntelevaan
koneeseen. Nyt kun perustiedot on saatu kasaan, serverikoneessa
sidotaan luotu socketti porttiin jota tahdotaan kuunnella, ja
aletaan "kuuntelemaan" yhteydenottoja.
Huomaa, että 'listen' kutsu ei ole blokkaava! Eli ohjelman
suoritus jatkuu välittömästi eteenpäin, riippumatta siitä
tuleeko clientiltä yhteydenottoa vai ei!
*/
if
(
bind
(
kuunteleva_socketti,
(struct sockaddr *) &oma_osoite,
sizeof(oma_osoite)
)
< 0
)
{
//virhekäsittely, socketin sitominen porttiin epäonnistui.
}
/*
listen funktion toinen parametri, kertoo montako clientin
yhteyttä serveri pitää 'jonossa' odottamassa hyväksyntää.
*/
if(-1==listen(kuunteleva_socketti, 1))
{
//Virhekäsittely, listen epäonnistui.
}
/*
Nyt tapahtuu varsinainen yhteyksien vastaanotto. Huomaa, accept
on blokkaava kutsu, ohjelman suoritus pysähtyy kunnes ensimmäinen
yhteydenottopyyntö saapuu
*/
osoitteen_pituus = sizeof(clientin_osoite);

yhdistetty_socketti = accept
(
kuunteleva_socketti,
(struct sockaddr *) &clientin_osoite,
&osoitteen_pituus
);
if (yhdistetty_socketti < 0)
{
//virhekäsittely, yhteyden hyväksyntä epäonnistui!
}
/*
Huomaa, accept päivittää client_ddress structiin clientin tiedot,
joten voit jossain määrin valvoa mistä tulevien yhteydenottojen
kanssa tahdot jatkaa, ja mitkä tahdot lopettaa saman tien.
*/
//vastaanota ensimmäiset 32 bittiä (viestin koko)
luettu_koko=recv(
yhdistetty_socketti,
(void *)&vastaanotettu_sanoma.viestin_koko,
sizeof(int),
0
);
if(luettu_koko<(int)sizeof(int))
{
//virhekäsittely
}
if(vastaanotettu_sanoma.viestin_koko>MAX_DATA-sizeof(int))
{
printf("Liian iso sanoma => lopetan");
return EXIT_FAILURE;
}
//vastaanota loppu viesti:


datan_koko=0;
while(datan_koko<vastaanotettu_sanoma.viestin_koko)
{
if(
(luvun_paluuarvo=recv
(
yhdistetty_socketti,
((char *)vastaanotettu_sanoma.data)+datan_koko,
vastaanotettu_sanoma.viestin_koko-datan_koko,
0
)
)
<= 0
)
{
/*
virhekäsittely, recv palautti 0 tai negatiivisen luvun.
0==yhteys suljettu, -1==virhe. Virhetapauksessa tutki errno
saadaksesi tarkemman virhenumeron.
*/

}
datan_koko+=luvun_paluuarvo;
luvun_paluuarvo=0;
}
//luetun datan tulisi nyt olla luvun_paluuarvo muuttujassa.
printf("Saatu: %s",(char *)vastaanotettu_sanoma.data);
// Läheta vastaus


memset(vastaus_sanoma.data,0,MAX_DATA);
vastaus_sanoma.viestin_koko=4*sizeof(char);
strncpy((char *)vastaus_sanoma.data,"bar",4*sizeof(char));
write
(
yhdistetty_socketti,
(void *)&vastaus_sanoma,
sizeof(int)+4*sizeof(char)
);
//Lopuksi on hyvä käytäntä kutsua
close(yhdistetty_socketti);
close(kuunteleva_socketti);
return EXIT_SUCCESS;
}



Pari sanaa vielä...
Ehkä yleisin tapa toteuttaa serveriohjelmat on, että listen funktion jälkeen tehdään ikilooppi, jossa kutsutaan acceptia. Ja aina kun uusi yhteys tulee, 'forkataan' uusi prosessi (tai tehdään uusi threadi) jossa yhteyttä käytetään, alkuperäisen prosessin aloittaessa loopissa uuden kierroksen valmiina vastaanottamaan seuraavan sisääntulevan yhteyden.


Esimerkki 3: Select()

Ja vielä lupaamani select() esimerkki:
Allaolevalle funktiolle annetaan tutkittava socketti, ja se hyödyntää select() funktiota tarkistaakseen onko uutta dataa saatavilla vai ei. select():iä voi myös käyttää tutkimaan voiko sockettiin kirjoittaa, tai onko socketissa jokin poikkeus käsiteltäväksi. Tähän select funktio käyttää fd_set tyyppisiä olioita, siten että luettavuuden, kirjoitettavuuden ja poikkeuksen tarkistamiseksi on määritettävä oma fd_setti. Seuraavassa esimerkissä jätän kirjoitus ja poikkeus setit läpikäymättä, sillä kirjoitussetti on samanlainen kuin lukusettikin, ja poikkeukset taas menevät pitkälle tämän blogiartikkelin ulkopuolelle.




int onko_uutta_dataa_luettavissa(int sock)
{
//Tee fd_set lukemiselle
fd_set fdset_luku;

int paluuarvo=-1;
/*
select() kutsun voi asettaa blockkaamaan haluamakseen aikaa,
määrittämällä blokkaus ajan timeval tyyppiseen structiin
*/
struct timeval time;
//alustetaan tutkittava fd_setti 'ei mitään uutta' tilaan
FD_ZERO(&fdset_luku);
//sidotaan tutkittava socketti fdsettiin
FD_SET(sock,&fdset_luku);
/*
koska en halua selectin blokkaavan ohjelman ajoa,
nollaan timeval structin
*/
memset(&time,0,sizeof(time));
//kutsutaan selectiä asettaen muut kuin luku fdsetin nollaksi.
paluuarvo=select(sock+1,&fdset_luku,0,0,&time);
if(paluuarvo==-1)
{
//virhekäsittely, select epäonnistui!
}
//luetaan fdsetistä onko socketissa uutta dataa odottamassa.
if(FD_ISSET(sock,&fdset_luku))
{
//Uutta dataa saatavilla!!!
return 1;
}
//Ei uutta luettavaa :(
return 0;
}



Loppuun vielä lyhyehkö yhteenveto winsock (windows sockettien) ja UNIX sockettien eroista:

Windowsissa sockettien esittely tapahtuu kirjastossa winsock.h, ei sys/socket.h
Windowsissa socketin kahva ei ole pelkkä integer, vaan tyyppiä SOCKET
Windowsissa ennen socket kirjaston käyttöönottoa tulee ajaa sokettikirjastolle startti. Se tapahtuu tekemällä seuraavat temput:

1. luo muuttuja
WSADATA wsadata;
2. aja funktio WSAStartup ja varmista että sen paluuarvo ei ole SOCKET_ERROR
WSAStartup(MAKEWORD(2, 2), &wsadata);

Windowsissa socketti suljetaan functiolla closesocket() ei close()
Lopuksi windowsissa ajetaan funktio:
WSACleanup()

Muuta huomioitavaa:
Windowsissa select():iä voi käyttää vain sockettien tutkimiseen.

Ja vielä viimeiset sanat, windows puolella sockettien käyttöä pääsee helposti kokeilemaan asentamalla ilmaisen DEV-CPP IDE:n (integrated developement environment == kääntäjä, linkkeri ja koodi editori samassa paketissa), ja asentamalla help valikon päivitysten kautta socket lisäosan. Linux maailmassa socketit kuuluvatkin vakiokalustoon.

1 kommentti:

Maz kirjoitti...

30.07.2008: Kokeilin client ja server koodeja RedHat linuxilla ja gcc:llä (versio 3.4.6). Pienten viilausten jälkeen esimerkit toimivat. Päivitin kyseiset viilaukset myös esimerkkeihin.