Weicon socket-tutoriaali osa 2
Mureakuha
Weicon socket-tutoriaali
Osa 1 | Osa 2
Sisällysluettelo |
Esipuhe
Tämä artikkeli on jatko-osa Socket Programming with C artikkeliin. Lukijaa neuvotaan lukemaan se ennen tätä. Artikkelin tarkoitus on hieman selventää palvelin / asiakas yhteyksiä. Artikkeli perustuu pitkälti omiin kokemuksiini ja näkökantoihini ja jotkin asiat voivat poiketa "standardeista". Artikkeli dokumentoi myös osittain Remote File API:ni, joka ei varmaan koskaan tule kuitenkaan valmiiksi...
Koodit ovat tarkoitettu toimimaan Windows käyttöjärjestelmissä, mutta niiden pitäisi olla helposti portattavissa Unix järjestelmiinkin. Lisää projektiisi ainakin winsock2.h ja ws2_32.lib tiedostot kääntääksesi koodit. Myös joitain muita otsikkotiedostoja voidaan tarvitaan kuten winbase.h.
Takaisin perusteisiin
Ensiksi tarvitsemme socketin. Tulemme käyttämään TCP protokollaa. Avattuamme socketin laitamme sen kuuntelutilaan (palvelinpään socket).
/* Global socket descriptor */ SOCKET fd; int InitSocket() { WSADATA wsaData; struct sockaddr_in sa = { 0 }; if (WSAStartup(MAKEWORD(2,2), &wsaData) == SOCKET_ERROR) return -1; fd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); if (fd == INVALID_SOCKET) return -1; sa.sin_family = AF_INET; sa.sin_port = htons(8888); sa.sin_addr.s.addr = htonl(INADDR_ANY); if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) == SOCKET_ERROR) return -1; if (listen(fd, SOMAXCONN) == SOCKET_ERROR) return -1; return 0; }
Hyvä. Nyt meillä on socket. Tämän jälkeen meidän täytyy enää hyväksyä (accept) sisääntuleva yhteys ja asiakas / serveri - yhteys on valmis. Mutta mikä palvelin hyväksyisi vain yhden asiakkaan kerrallaan? Useampi asiakas vaatii useampaa accept - kutsua ja asiakkaiden käsittely tulee melko monimutkaiseksi.
Ratkaisin ongelman luomalla useamman threadin, jotka käsittelevät asiakkaiden pyynnöt. Yksi threadi per asiakas. Mutta tämä johtaa uuteen ongelmaan. Yhteydenmuodostus ja uuden threadin luonti vie melko paljon aikaa. Päätin luoda threadit heti ohjelman alussa ja siirtää accept kutsut pääthreadista asikasthreadeihin.
/* Thread functions prototype */ DWORD WINAPI ClientThread(void *dummy); int InitThreads() { DWORD threadId; for (int n = 0 ; n < 10 ; n++) /* We accept 10 clients */ if (CreateThread(NULL, 0, ClientThread, NULL, 0, &threadId) == NULL) return -1; return 0; }
Koska WSAStartup linkkaa socket DLL:n dynaamisesti, ajattelin että olisi hyvän tavan mukaista vapauttaa se, kun palvelin sammuu. Tätä varten rekisteröin signaalikäsittelijän, joka vapauttaa kirjaston.
/* Signal handler */ void SignalHandler(int sig) { WSACleanup(); exit(0); } int RegisterSignalHandler() { if (signal(SIGINT, SignalHandler) == SIG_ERR || signal(SIGTERM, SignalHandler) == SIG_ERR) return -1; return 0; }
Nyt olemme saaneet palvelimen alkutekijöihinsä. Tämän jälkeen pistämme pääthreadin nukkumaan ja alamme odottamaan asiakkaita. Palvelin hyväksyy vain 10 asiakasta kerrallaan. Jos asiakkaita tulee enemmän, kaikki uudet yhteydet menevät jonoon odottamaan kunnes vanha asiakas poistuu tai asiakkaan socketin timeout tulee täyteen.
void ServerRun() { SuspendThread(GetCurrentThread()); }
Asiakkaan "sisäänotto"
Kun ohjelma kutsuu accept-funktiota, menee threadi ns. jumiin ja jatkaa kunnes asiakas on ottanut yhteyden ja yhteys on muodostettu. Accept palauttaa uuden socketin, jota käytetään ko. asiakkaan kanssa keskusteltaessa. Kun asiakas poistuu, threadi palaa accept kutsuun odottamaan uutta asiakasta.
Siis me hyväksymme asiakkaan ja annamme uuden socketin "asiakaskäsittelijälle". Remote File API rekisteröisi tässä tilanteessa joitain eventtejä, varaisi muistia erinäköisiin asioihin yms. mutta emme mene nyt siihen. Laitamme myös socketin ei-blokkaavaan tilaan. Jos socket olisi blokkaavassa tilassa jokainen recv-kutsu laittaisi threadin odottamaan tulevaa dataa. Mahdollinen hyökkääjä voisi käyttää tätä hyväkseen lähettämällä epämuodostuneen headerin (headerista enemmän myöhemmin) ja olisimme jumissa mailman loppuun asti.
DWORD WINAPI ClientThread(void *Dummy) { ULONG ulValue = 1; while (TRUE) { /* This thread should never return. */ SOCKET newSocket = accept(fd, NULL, NULL); if (newSocket == SOCKET_ERROR) continue; ioctlsocket(fd, FIONBIO, &ulValue); while (HandleNewClient(newSocket)); } return 0; }
Asiakaskäsittelijäfunktio kutsuu tämän jälkeen select-funktiota ja jää odottamaan, että socketin tila muuttuu. Paluuarvo 0 tarkoittaa, että asikaan yhteys on katkennut/katkaistu ja voimme ottaa uuden asiakkaan sisään.
int HandleNewClient(SOCKET clientFd) { char *dataBuffer; fd_set readfds; FD_ZERO(&readfds); FD_SET(clientFd, &readfds); if (select(0, &readfds, NULL, NULL, NULL) == SOCKET_ERROR) return 0; /* Error occured. Leave this client and wait for new one. */ dataBuffer = ReceiveData(clientFd); if (dataBuffer == NULL) return 0; /* Error occured or client has disconnected. */ HandleData(dataBuffer); return 1; }
Data header
Header (otsikko) sisältää paketin koko pituuden, joten meidän täytyy ottaa vastaan headeri ensimmäisenä. Jos headerin vastaanotto epäonnistuu, emme voi tehdä asialle paljoa, sillä emme tiedä paljonko dataa olisi tulossa (yleinen ongelma TCP protokollan kanssa). Tässä tapauksessa hylkäämme paketin, tiputamme vain asiakkaan pois ja palaamme accept-funktioon.
Koska socket laitettiin ei-blokkaavaan tilaan, täytyy meidän selvittää ensin, miksi select-funktio palasi. Select voi palata kahdesta eri syystä, asiakas on lähettänyt dataa tai asiakas on sulkenut yhteyden. Tämän selvittäminen tapahtuu tarkistamalla paljonko dataa on tulossa. Jos dataa ei ole tulossa päättelemmä, että asiakas on katkaissut yhteyden.
int ActualReceive(SOCKET clientFd, char **dataBuffer, int *dataLeft) { int offset = 0; /* Is enough data received? */ while (*dataLeft > 0) { int n = recv(clientFd, dataBuffer[offset], *dataLeft, 0); if (n == SOCKET_ERROR) return 0; /* Error receiving data. */ if (n == 0 && *dataLeft > 0) return 0; /* Client didn't send whole packet. */ *dataLeft -= n; offset += n; } /* Enough data is received, return. */ return 1; } char *ReceiveData(SOCKET clientFd) { char *dataBuffer; int dataLeft = 5; ULONG n; /* Get first packet size. If size equals to zero disconnect client. */ ioctlsocket(clientFd, FIONREAD, &n); if (n == 0) { closesocket(clientFd); return NULL; } /* Allocate space for header. */ dataBuffer = (char *)malloc(5 * sizeof(char)); /* Receive packet header. If header cannot be received send error answer message to client and disconnect. */ if (!ActualReceive(clientFd, &dataBuffer, &dataLeft)) { free(dataBuffer); SendError(clientFd); closesocket(clientFd); return NULL; } /* Get packet length and allocate more memory for it. */ dataLeft = ((struct DataHeader *)dataBuffer)->PacketLength; dataBuffer = (char *)realloc(dataBuffer, dataLeft); /* Receive rest of the packet. Since the header is being receive push packet pointer 5 bytes (size of header) and subtract header size from dataLeft. If error occures send error message and disconnect. */ dataLeft -= 5; if (!ActualReceive(clientFd, &(dataBuffer + 5), &dataLeft)) { free(dataBuffer); SendError(clientFd); closesocket(clientFd); return NULL; } return dataBuffer; }
ReceiveData-funktio kutsuu ActualReceive-funktiota, joka vastaanottaa dataa asiakkaalta. ActualReceive kutsuu recv-funktiota niin monta kertaa että recv palauttaa 0, tarpeeksi dataa on saatu tai virhe on tapahtunut. Tässä tarvitsemme ei-blokkaavaa sockettia. Olisi hyvin helppo kaataa tai jumittaa serveri blokkaavilla socketeilla. Asiakas voisi lähettää headerin, jossa datan pituudeksi olisi ilmoitettu enemmän, kuin mitä itseasiassa on tulossa. Tämä aiheuttaisi sen, että threadi jäisi odottamaan dataa, jota ei koskaan tule. Tietysti on keinoja, joilla tämäkin serveri kaadetaan, mutta niiden kaikkien torjuminen olisi mahdotonta.
Paketin käsittely
Koska paketti määrittää paketin tyypin, tulee paketin käsittelystä melko yksinkertaista. Palvelimella on yksinkertainen lista, johon pyynnöt lisätään ja josta event-käsittelijä hakee ne yksi kerrallaan, kun ohjelmalla on enemmän "luppoaikaa". Palvelin tarkistaa oliko pyyntö validi ja lähettää ACK viestin jos oli (tuntuu kuin keksisin pyörää uudelleen). Remote File API:n headerissa on toki enemmän kenttiä, kuten asiakkaan ID numero, timestampit, pyynnön TTL, fragmentit yms.
Paketin käsittelyn toteutus jätetään täysin nyt lukijalle. Tee paketille mitä lystää, kunhan muistat vapauttaa dynaamisesti varatun muistin myöhemmin.
Datan lähetys
Datan lähetys on paljon helpompaa, kuin vastaanotto. Rakenna paketti tarvittavinen headereineen ja siirrä paketti asiakkaalle. API:ni käyttää yksinkertaista TCP-tyylistä ack-mekanismia vastauksiin. Jos pyyntö oli validi, lähetetään siis ACK viesti ja data, jota asiakas pyysi (itse asiassa minun tapauksessa ACK lähetetään ensin ja data myöhemmin). Jos pyyntö oli epävalidi tai jokin virhe tapahtui palvelimella, lähetetään ERR viesti ja tieto virheestä välittömästi.
char *MakePacketWithHeader(int packetType, char *data, int dataLength) { char *dataBuffer = _ (char *)malloc((dataLength + sizeof(struct PacketHeader) * sizeof(char)); ((struct PacketHeader *)dataBuffer)->PacketType = packetType; ((struct PacketHeader *)dataBuffer)->PacketLenght = dataLength; if (data) memcpy(PacketLength + 5, data, dataLength); return dataBuffer; } void SendAck(SOCKET clientFd) { char *dataBuffer; /* Packet type 0 means succesful query. */ /* Note : ACK reply doesn't need data. */ dataBuffer = MakePacketWithHeader(0, NULL, 0); send(clientFd, dataBuffer, _ ((struct PacketHeader *)dataBuffer)->PacketLength, 0); free(dataBuffer); } void SendError(SOCKET clientFd) { char *dataBuffer; /* Packet type 1 means invalid query or server error. */ /* Note : error reply doesn't need data. */ dataBuffer = MakePacketWithHeader(1, NULL, 0); send(clientFd, dataBuffer, _ ((struct PacketHeader *)dataBuffer)->PacketLength, 0); free(dataBuffer); } void SendData(SOCKET clientFd, char data, int len) { char *dataBuffer; /* Packet type 2 means reply on query. */ dataBuffer = MakePacketWithHeader(2, data, len); send(clientFd, dataBuffer, _ ((struct PacketHeader *)dataBuffer)->PacketLength, 0); free(dataBuffer); }
Asiakas - palvelin - asiakaskommunikointi
Kun asiakas haluaa lähettää dataa toisille asiakkaille tulee asioista hieman monimutkaisempia. Tarvitset globaalin listan asiakkaista joka pitää sisällään niiden socketit, mahdollisesti asiakkaiden nimet ja muita tietoja. Tarvitset myös lukot, joilla suojata lista ettei useampi threadi kirjoita listaan yhtäaikaa.
Uusia asiakkaita voidaan lisätä listaan dynaamisesti kun accept-funktio palaa. Kun asiakas katkaisee yhteyden, poista se listasta.
Kun lähetät dataa asiakkaalle, mene listan läpi ja kutsu SendDAta-funktiota lähettääksesi dataa. Muista kuitenkin, että alkuperäisen viestin lähettäjä on myös lista, joten sinun tarvitsee iteroida se pois viestin saavista asiakkaista. Jos asiakas taas haluaa lähettää "yksityisen viestin" toiselle asiakkaalle, etsi listasta vain vastaanottava asiakas.
Tämän jälkeen
Nyt luultavasti puhkut intoa koodata oma chat-ohjelmasi, irc-client ja 100 muuta hienoa asiaa. Kätesi tärisevät ja pääsi on täynnä uusia häikäiseviä ideoita, jotka odottavat ulospääsyä käännösyksiköiden syvyyksiin. Ota siis nyt teksti-editori, IDE tms. sekä C kääntäjäsi esille ja anna palaa. Muista kuitenkin hankkia itsellesi hyvä debuggeri, sillä tulet tarvitsemaan sitä (kuten minäkin).
