יום שבת

TCP Server - Echo Server

בפוסט הזה נציג מימוש של מערכת  client/server. בפוסט קודם שדן ב-IPC Sockets כבר הוצגה דוגמא של client/server כך שמומלץ לקרוא אותה קודם. הדוגמא הנוכחית לא מתקדמת בהרבה מהקודמת, אך היא בכל זאת מעט יותר מורכבת.
מה הדוגמא מבצעת?
ה-client שולח הודעות ל-server. ה-server שולח הודעה חזרה - echo.
איך נשלחת ההודעה?
ההודעה מוקלדת ע"י המשתמש.
מה תוכן ההודעות?
ה-client שולח ל-server שני מספרים. ה-server מסכם אותם ושולח חזרה את הסכום. ה-client מציג את המספרים שנשלחו ואת סכומם.
מה לגבי הודעות לא חוקיות?
אם ה-client מזהה שההודעה שהוקלדה אינה חוקית, כלומר אינה זוג מספרים, תודפס הודעת שגיאה וכלום לא ישלח ל-server.
מהן הפעולות שה-client מבצע?
הוא מחכה להקלדת הודעה מהמקלדת, שולח אותה ל-socket, ואח"כ עובר להמתנה להודעה מה-server.
מהן הפעולות שה-server מבצע?
כשנוצר connection, ה-server יוצר child process עבור ה-client ומשם מטפל בהודעות ה-client. ה-server תומך בהרבה clients.


נציג את הקוד של ה-server ואחריו את הקוד של ה-client, אבל לפני כן, חייב לציין שאת הדוגמא לקחתי מהספר

Unix Network Programming Volume 1, Third Edition: The Sockets Networking API 
By W. Richard Stevens, Bill Fenner, Andrew M. Rudoff


זהו ספר יסוד בנושא.  ספר מומלץ כמובן.



הנה הקוד של ה-server.
שם הקובץ: tcpserv09.c
התוכנית כוללת שלוש פונקציות עקריות:
main - הפונקציה הראשית.
str_echo (בכתום) - פונקציית עזר שקוראת את ההודעה מה-client ומחזירה לו echo.
פרוט בהמשך.

sig_chld (בטורקיז) - זהו ה-handler שמטפל ב-signal ששולחת המערכת כשה-child process מפסיק להתקיים. פרוט בהמשך.


  1. #include "unp.h"


  2. struct args {
  3.   long arg1;
  4.   long arg2;
  5. };

  6. struct result {
  7.   long sum;
  8. };
  9. void
  10. sig_chld(int signo)
  11. {
  12. pid_t pid;
  13. int stat;

  14. while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
  15. printf("child %d terminated\n", pid);
  16. return;
  17. }


  18. void
  19. str_echo(int sockfd)
  20. {
  21. ssize_t n;
  22. struct args args;
  23. struct result result;

  24. for ( ; ; ) {
  25. if ( (n = Readn(sockfd, &args, sizeof(args))) == 0)
  26. return; /* connection closed by other end */

  27. result.sum = args.arg1 + args.arg2;
  28. Writen(sockfd, &result, sizeof(result));
  29. }
  30. }


  31. int
  32. main(int argc, char **argv)
  33. {
  34. int listenfd, connfd;
  35. pid_t childpid;
  36. socklen_t clilen;
  37. struct sockaddr_in cliaddr, servaddr;
  38. void sig_chld(int);

  39. listenfd = Socket(AF_INET, SOCK_STREAM, 0);

  40. bzero(&servaddr, sizeof(servaddr));
  41. servaddr.sin_family      = AF_INET;
  42. servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  43. servaddr.sin_port        = htons(SERV_PORT);

  44. Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

  45. Listen(listenfd, LISTENQ);

  46. Signal(SIGCHLD, sig_chld);

  47. for ( ; ; ) {
  48. clilen = sizeof(cliaddr);
  49. if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
  50. if (errno == EINTR)
  51. continue; /* back to for() */
  52. else
  53. err_sys("accept error");
  54. }

  55. if ( (childpid = Fork()) == 0) { /* child process */
  56. Close(listenfd); /* close listening socket */
  57. str_echo(connfd); /* process the request */
  58. exit(0);
  59. }
  60. Close(connfd); /* parent closes connected socket */
  61. }
  62. }



4 הפונקציות הראשיות ב-main מודגשות בצהוב:
-Socket
-Bind
-Listen
-accept

שימו לב לעובדה ששמות הפונקציות (Socket, Bind, Listen) מתחילים באותיות גדולות. הסיבה - נעשה כאן שימוש בפונקציות עוטפות (wrapper). הפונקציה Socket למשל עוטפת את socket.
עיקר התוספת של הפונקציות העוטפות הן הודעות שגיאה.

הנה לדוגמא המימוש של Bind:


void
Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
if (bind(fd, sa, salen) < 0)
err_sys("bind error");
}


בפוסט הקודם הוצג תרשים בלוקים שמתאר את תהליך יצירת התקשורת עם ה-client. לשם הנוחות אציג את התרשים גם כאן:







הסבר נוסף על התהליך אפשר למצוא כאמור בפוסט של IPC-Socket. עדין יש הבדל בין שני המימושים: שלא כמו ב-IPC - Socket' כאן לא השתמשנו ב-getaddrinfo להכנת הפרמטרים עבור socket ו-bind. מומלץ להשתמש ב-getaddrinfo.


נעקוב אחר הכנת הפרמטרים והקריאה לפונקציות Socket ו- Bind, היות שהכנת הפרמטרים שונה מזו שהכרנו בפוסט הקודם.


שורה 50:
listenfd = Socket(AF_INET, SOCK_STREAM, 0);


ה-prototype של socket:

int socket(int domain, int type, int protocol);

ה-socket הוא אמצעי לתקשורת בין process. הוא מוגדר ע"י 3 פרמטרים: domain, type, protocol. הפונקציה מחזירה  file descriptor שישמש לצורך גישה ל-socket.

3 הפרמטרים שהפונקציה מקבלת:
domain:  הערך AF_INET מציין IPv4.
type: הערך SOCK_STREAM מציין מערכת full duplex' בד"כ TCP.
protocol: הערך 0 מציין בחירת ה-default protocol עבור ה-type. במקרה שלנו, TCP.

והנה הכנת הפרמטרים ל-bind:
שורות 52-57:
  1.         bzero(&servaddr, sizeof(servaddr));
  2. servaddr.sin_family      = AF_INET;
  3. servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  4. servaddr.sin_port        = htons(SERV_PORT);

Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));


הסבר: 4 השורות הראשונות מכינות את הפרמטרים עבור Bind. Bind כזכור מחברת בין ה-Socket לבין הפורט הלוקאלי.

הנה ה-prototype שלה:

int bind(int sockfd, struct sockaddr *my_addr, int addrlen);

הפרמטרים מסודרים ב- struct sockadd.
bzero - מאפסת את ה-structure. זוהי פונקציה שמאפסת מספר bytes  לפי addrelen החל מהכתובת my_addr.
וכעת יש להכניס 3 פרמטרים ל-structure:
שורה 2: AF_INET  זה IPv4.
שורה 3: כאן נקבע שה-socket יעשה bind לכל הכתובות הקיימות במחשב הלוקלי.
שורה 4: קביעת הפורט לו ה-server יאזין. SERV_PORT מוגדר בקובץ unp.h:

#define SERV_PORT 9877 /* TCP and UDP client-servers */


עד כאן על Bind.


שורה 59:
Listen(listenfd, LISTENQ);
כאשר LISTENQ הוא מספר ה-CONNECTION המקסימלי שיאופשר.
הוא מוגדר ב-UNP.H:

#define LISTENQ 1024 /* 2nd argument to listen() */






שורה 61:


Signal(SIGCHLD, sig_chld);


כאשר child process מפסיק לפעול, המערכת שולחת signal. עפ"י סיגנל זה, תוכל התוכנית לחסל את ה-process. אחרת - הוא ישאר כ-zombie ועלול תפוס משאבי מערכת.
איך פועל המנגנון?
צריך לבצע רגיסטרציה של פונקציה-handler,  שתופעל  כשהסיגנל יופיע. אצלנו ה-handler היא הפונקציה sig_chld (נמצאת למעלה בצבע טורקיז). הרגיסטרציה של sig_chld כ-handler עבור הסיגנל SIGCHLD, מבוצעת ע"י הפונקציה Signal.


הנה signal:







  1. Sigfunc *
  2. signal(int signo, Sigfunc *func)
  3. {
  4. struct sigaction act, oact;

  5. act.sa_handler = func;
  6. sigemptyset(&act.sa_mask);
  7. act.sa_flags = 0;
  8. if (signo == SIGALRM) {
  9. #ifdef SA_INTERRUPT
  10. act.sa_flags |= SA_INTERRUPT; /* SunOS 4.x */
  11. #endif
  12. } else {
  13. #ifdef SA_RESTART
  14. act.sa_flags |= SA_RESTART; /* SVR4, 44BSD */
  15. #endif
  16. }
  17. if (sigaction(signo, &act, &oact) < 0)
  18. return(SIG_ERR);
  19. return(oact.sa_handler);
  20. }
  21. Sigfunc *
  22. Signal(int signo, Sigfunc *func) /* for our signal() function */
  23. {
  24. Sigfunc *sigfunc;

  25. if ( (sigfunc = signal(signo, func)) == SIG_ERR)
  26. err_sys("signal error");
  27. return(sigfunc);
  28. }


נתרכז בשורות בצהוב. מתבצעים כאן 3 דברים:
שורה 7: הכנסת המצביע ל-handler.
שורה 8: קביעת sa_mask - האם לחסום סיגנלים אחרים בזמן הטיפול ב-SIGCHLD? היות שאיננו מעונינים בחסימה, מפעילים את sigemptyset שקובע שה-mask יהיה ריק.
sigemptyset(&act.sa_mask);

שורה 15: ה-flag  קובע את ההתנהגות כשחוזרים לאחת מפונקציות הספריה כגון open, read, write, אחרי שה-signal handler קטע את פעולתה. במצב זה יתכנו שתי תוצאות:
1. פונקצית הספריה תמשיך לאחר החזרה מה-handler. זה יקרה אם הדגל SA_RESTART פועל.
2. פונקצית הספריה תכשל ותחזור עם קוד שגיאה EINTR. זה במקרה שהדגל SA_RESTART לא דולק.
אנו בוחרים באופציה הראשונה:

act.sa_flags |= SA_RESTART; /* SVR4, 44BSD */


שורה 19 מכניסה את 3 הקונפיגורציות הנ"ל למערכת.
if (sigaction(signo, &act, &oact) < 0)


עד כאן ההכנה לטיפול בסיגנל SIGCHLD. את ה-handler כלומר את הפונקציה sig_chld נסקור בהמשך.


כעת נסתכל על הקריאה ל-accept:



שורה 65-70: 
  1. if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
  2. if (errno == EINTR)
  3. continue; /* back to for() */
  4. else
  5. err_sys("accept error");
  6. }


כאן ה-server נתקע בהמתנה להווצרות connection.

הנה ה-prototype של accept:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);


sockfd - כרגיל, ה-descriptor שמחזירה socket.
addr - זהו ה-struct אליו יכנסו הנתונים של ה-host שהתחבר. נראה מיד את השימוש בהם.
addrlen - הגודל של struct addr.

שורות 2-7: טיפול בשגיאה. הסבר:
במקרה של חזרה מה-signal handler לפונקציה accept שנחתכה על ידו, יכולים לקרות אחד מהשניים:
1. הפונקציה accept תחזור לפעולה רגילה. זה גם בהתאם ל- SA_RESTART שקבענו קודם - ראה למעלה.
2. במערכות מסוימות הפונקציה אמורה להכשל ולחזור עם קוד שגיאה EINTR - למרות ה-SA_RESTART. שורה 2 מטפלת במצב זה ודואגת להפעלת accept מחדש במקרה זה.


נמשיך עם התוכנית: עם הווצרות connection ל-client חדש, התוכנית תתקדם מהפונקציה accept, וננגיע לשורה 72.

שורות 72-76:

  1. if ( (childpid = Fork()) == 0) { /* child process */
  2. Close(listenfd); /* close listening socket */
  3. str_echo(connfd); /* process the request */
  4. exit(0);
  5. }
פרוט:
שורה 1: יצירת process חדש ע"י fork.  ה-process יטפל ב-client.
שורות 2-5 פועלות מתוך ה-child process, היות ש- childpid=0.
הסבר:
childpid=0: זה ה-child process.
childpid >0: זה ה-father process.
childpid<0: זה error.

שורה 2: סגירת ה-socket המאזין. אין בו צורך אצל ה-child! נזכיר כי פעולת ה-fork משכפלת הכל, כולל ה-descriptors וה-sockets.
שורה 3: קריאה ל-פונקציה str_echo. זוהי הפונקציה שתטפל ב-client ומיד נעבור להסתכל עליה. היא כמובן רצה ב-process של הclient.
שורה 4: (exit(0  לסיום ה-child process. יגרום לשליחת סיגנל SIGCHLD.

שורה 66 - 
Close(connfd); /* parent closes connected socket */

השורה מתבצעת ב-context של ה-father process. היות שכך, צריך לסגור את הקישור ל-client. ה-client מטופל ע"י ה-child process.


נעבור לפונקצה str_echo. היא כאמור למעלה מופעלת ב-child process ומטפלת בהודעות מה-client ואליו.

בעיקרה הפונקציה היא לולאה אינסופית בה קוראים הודעה מה-client, מבצעים עיבוד קטן, ושולחים הודעה חזרה. הנה היא:

  1. for ( ; ; ) {
  2. if ( (n = Readn(sockfd, &args, sizeof(args))) == 0)
  3. return; /* connection closed by other end */

  4. result.sum = args.arg1 + args.arg2;
  5. Writen(sockfd, &result, sizeof(result));
  6. }


שורה 2: Readn. קריאה מה-socket. הפונקציה כמובן תקועה עד שה-buffer מכיל הודעה.
שורה 3: במקרה שה-client מודיע הודעת סיום - FIN - ה-buffer יכיל 0, וכתוצאה מזה יהיה return.
שורה 5: ה-client שולח שני מספרים. ה-server מחבר אותם ושולח חזרה.
שורה 6: כתיבת ההודעה  ל-socket.

Readn ו- Writen שתיהן פונקציות שעוטפות את read ו- write בהתאמה. ליתר דיוק הן עוטפות את readn ואת writen. האחרונות הן אלה שעוטפות את read ו-write. מה הן עושות? הן מבצעות את לולאת הקריאה והכתיבה  מה-socket ואליו. 
נראה תחילה את פונקציות ה-readn - נמצאות בקובץ readn.c:




  1. ssize_t /* Read "n" bytes from a descriptor. */
  2. readn(int fd, void *vptr, size_t n)
  3. {
  4. size_t nleft;
  5. ssize_t nread;
  6. char *ptr;

  7. ptr = vptr;
  8. nleft = n;
  9. while (nleft > 0) {
  10. if ( (nread = read(fd, ptr, nleft)) < 0) {
  11. if (errno == EINTR)
  12. nread = 0; /* and call read() again */
  13. else
  14. return(-1);
  15. } else if (nread == 0)
  16. break; /* EOF */

  17. nleft -= nread;
  18. ptr   += nread;
  19. }
  20. return(n - nleft); /* return >= 0 */
  21. }
  22. /* end readn */

  23. ssize_t
  24. Readn(int fd, void *ptr, size_t nbytes)
  25. {
  26. ssize_t n;

  27. if ( (n = readn(fd, ptr, nbytes)) < 0)
  28. err_sys("readn error");
  29. return(n);
  30. }

מספר דגשים:
שורות 10-21: זוהי הלולאה מסביב לפונקציה read.
הסבר: הפונקציה read פונה ל-kernel ומבקשת לקרוא מספר מסוים של בתים. במקרה שלנו, מדובר בשמונה בתים - זהו גודלו של struct args. אם הקרנל מספק רק חלק ממספר הבתים שביקשנו, נמשיך ונקרא ממנו, עד שיגיעו כל הבתים להם ציפינו. זו מהות ה- while.
שורות 12-15: טיפול בשגיאה מ-read. אמנם קבענו למעלה ש-SA_RESTART ידאג לחזרה לפעולה של read אחרי חזרה מה-signal handler, אבל במערכות מסוימות תתכן שגיאה עם EINTR בכל זאת. במקרה זה, ה- while ימשיך לפעול.

שורה 27-34: הפונקציה Readn. היא עוטפת את readn ומטפלת במקרה  של שגיאה.

והנה באופן דומה, writen מהקובץ writen.c:



  1. ssize_t /* Write "n" bytes to a descriptor. */
  2. writen(int fd, const void *vptr, size_t n)
  3. {
  4. size_t nleft;
  5. ssize_t nwritten;
  6. const char *ptr;

  7. ptr = vptr;
  8. nleft = n;
  9. while (nleft > 0) {
  10. if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
  11. if (nwritten < 0 && errno == EINTR)
  12. nwritten = 0; /* and call write() again */
  13. else
  14. return(-1); /* error */
  15. }

  16. nleft -= nwritten;
  17. ptr   += nwritten;
  18. }
  19. return(n);
  20. }
  21. /* end writen */

  22. void
  23. Writen(int fd, void *ptr, size_t nbytes)
  24. {
  25. if (writen(fd, ptr, nbytes) != nbytes)
  26. err_sys("writen error");
  27. }

גם כאן writen מבצעת while loop סביב write עד סיום הכתיבה.
גם כאן מזוהה קוד השגיאה EINTR, שמופיע בגלל הפרעה של signal handler.


לפני סיום ה-server, נעיף מבט על ה-signal handler - שורות 12-21 למעלה.
הנה הפונקציה שוב:


  1. void
  2. sig_chld(int signo)
  3. {
  4. pid_t pid;
  5. int stat;

  6. while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
  7. printf("child %d terminated\n", pid);
  8. return;
  9. }




הסברים על sig_chld:
זהו כאמור ה-handler של ה-SIGCHLD. כשה-Client מסיים, הוא שולח הודעת FIN. כתוצאה מכך ה-child process שקשור לאותו client יפסיק לפעול. כעת ישלח ה-SIGCHILD. מטרתו - לגרום לכך שה-child process יחוסל, ולא ישאר כ-zombie.
אם כך, למה צריך את ה-while loop? האם sig_chld לא יקרא בנפרד עבור כל סיגנל? התשובה היא - לא תמיד. במצב בו כמה child process יפסיקו לעבוד באותו זמן, יתכן שישלח רק סיגנל אחד, ולפעמים גם אם ישלחו מספר סיגנלים, יתכן שלא כולם יטופלו כי הם לא מחכים ב-queue.
איך פותרים את הבעיה? הפתרון מוצג בשורות 7-9 למעלה.
נראה את ה-prototype של waitpid:

pid_t waitpid (pid_t pid, int *statloc, int options);

הפונקציה מקבלת 3 פרמטרים:
pid - ה-process id שאותו רוצים לחסל. אם pid= -1, אז המערכת מוכנה לטפל בכל process.
statloc - ה-status של ה-process שהסתיים.
options - בחרנו לשים WNOHANG. זה יגרום ל-waitpid לא להתקע אם יש עדיין processes חיים במערכת (אחרת היא היתה נתקעת ומחכה ש-process ימות).
מכאן,  שכל עוד יש zombie process, ה-loop ימשיך. אחרת - יהיה return.

הפונקציה מחזירה את ה-process id במקרה שהכל תקין.
הפונקציה מחזירה 0 אם אין יותר zombies.
הפונקציה מחזירה 1- במקרה של שגיאה.




עד כאן על ה-server.

חבילת הקבצים שלנו מורכבת  מחבילה של server וחבילה של client המקומפלות ורצות בנפרד.

הנה ה-client.
שם קובץ: tcpcli09.c:



#include "unp.h"

struct args {
  long arg1;
  long arg2;
};

struct result {
  long sum;
};



  1. void
  2. str_cli(FILE *fp, int sockfd)
  3. {
  4. char sendline[MAXLINE];
  5. struct args args;
  6. struct result result;

  7. while (Fgets(sendline, MAXLINE, fp) != NULL) {

  8. if (sscanf(sendline, "%ld%ld", &args.arg1, &args.arg2) != 2) {
  9. printf("invalid input: %s", sendline);
  10. continue;
  11. }
  12. Writen(sockfd, &args, sizeof(args));

  13. if (Readn(sockfd, &result, sizeof(result)) == 0)
  14. err_quit("str_cli: server terminated prematurely");

  15. printf("%ld\n", result.sum);
  16. }
  17. }


  18. int
  19. main(int argc, char **argv)
  20. {
  21. int sockfd;
  22. struct sockaddr_in servaddr;

  23. if (argc != 2)
  24. err_quit("usage: tcpcli <IPaddress>");

  25. sockfd = Socket(AF_INET, SOCK_STREAM, 0);

  26. bzero(&servaddr, sizeof(servaddr));
  27. servaddr.sin_family = AF_INET;
  28. servaddr.sin_port = htons(SERV_PORT);
  29. Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

  30. Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

  31. str_cli(stdin, sockfd); /* do it all */

  32. exit(0);
  33. }


הקובץ מכיל שתי פונקציות: main ו-str_cli. האחרונה מטפלת בשליחת וקבלת הודעות ל-server וממנו לאחר יצירת ה-connection.

נתחיל עם main:
שורה 30: בדיקה של מספר הארגומנטים. הפונקתיה מופעלת עם הכתובת של ה-server כארגומנט.
שורה 33: Socket - בדומה ל-server.
שורות 35-40: הכנה ל-conect וביצוע connect.
שימו לב ל-38: הפונקציה בונה את ה-address עפ"י [argv[1 המצביע על הכתובת של ה-server.
הפונקציה connect נתקעת עד להווצרות ה-connection.
שורה 41: קריאה לפונקציה str_cli שתטפל בהודעות ל-server.



str_cli - מודגשת בכתום למעלה.
str_cli בנויה כ-while loop.
3 הפונקציות העיקריות:
Fgets בשורה 8: קוראת characters מה-std io עם הפונקציה Fgets - זהו wrapper של fgets שנמצא ב-wrapstdio.c. מוסיף טיפול במקרה שגירה - הנה הוא:


char *
Fgets(char *ptr, int n, FILE *stream)
{
char *rptr;

if ( (rptr = fgets(ptr, n, stream)) == NULL && ferror(stream))
err_sys("fgets error");

return (rptr);
}




Writen בשורה 14


סקרנו כבר את ה-wrapper של write למעלה. כותבים ל-socket את שני הארגומנטים שהתקבלו ע""י fgets והועתקו עם scanf. במקרה שלא נקלטו שני ארגומנטים, תודפס הודעת שגיאה.


Readn בשורה 16: 

הפונקציה קוראת את ההודעה מה-socket. זהו ה-echo ששולח ה-server ומכיל את סכום 2 המספרים.
גם את Readn סקרנו והצגנו למעלה.


הרצת התוכנית.
נריץ את ה-client וה-server בשני חלונות שונים של conslole.


הנה העתק של ה-console בו רץ ה-client:

  1. ronen@tcpcli1 127.0.0.1
  2. 1 2
  3. 3
  4. a d
  5. invalid input: a d

  6. 44 232
  7. 276
  8. 4
  9. invalid input: 4


ה-client מופעל עם כתובת ה-loop back : 127.0.0.1.
שורה 5, שורה 10: אם הפרמטרים שמוקלדים אינם תקינים, מודפסת הודעת שגיאה.
שורות 2-3: ה-client מדפיס את הקלט ואת התשובה שקיבל מה-server.



אין תגובות:

הוסף רשומת תגובה