Previous Up Next

שרת אינטרנט לתוכן דינמי

מטרת תרגיל זה היא לכתוב שרת http פשוט שיהיה מסוגל לספק תוכן דינמי. כלומר, השרת אינו מספק לדפדפן תוכן של קבצים, אלא הוא מריץ תוכניות לפי בקשת הדפדפן ושולח חזרה לדפדפן את הפלט שלהן. השרת מיועד למערכות חלונות, אם כי מבחינת קריאות המערכת הקשורות לתקשורת, תוכנות שרת בלינוקס/יוניקס ממומשות בדיוק באותה צורה. התרגיל משלב למעשה התנהגות של שרת קבצים והתנהגות של מעטפת (shell).

האזנה לבקשות התחברות לשער מסויים. יצירת קשר tcp בין שרת ולקוח, למשל דפדפן, אינה פעולה סימטרית. בתרגיל הקודם ראינו שהלקוח מתחבר לשרת על ידי קריאה ל--connect לאחר יצירת השקע. השרת, לעומת זאת, ממתין לבקשות התחברות. ראשית, השרת צריך להכין את השקע:
  1. השרת יכול לשנות את התנהגות השקע על ידי קריאה ל--setsockopt אחרי יצירתו. אין חובה לעשות זאת, אך ברירות המחדל של התנהגות שקע מתאימות ללקוחות ואינן מתאימות בדיוק לשרתים. שני פרמטרים שרצוי לשנות הם: (1) להרשות שימוש חוזר מיידי במספרי שער, (2) להשאיר את הקשר חי עד שכל הנתונים נשלחו בהצלחה. בתרגיל זה אין צורך לשנות את התנהגות השקע.
  2. השרת קושר את השקע לשם מסוים שאליו יתחברו הלקוחות על ידי קריאה ל--bind. השם הוא בדרך כלל כתובת ה--ip שדרכה רוצים לקבל בקשות התחברות (לשרת יכולות להיות מספר כתובות אם יש לו מספר חיבורים לרשת) ומספר שער שמוסכם על השרת והלקוחות כאחד. שרתי http משתמשים בשער מספר 08 כברירת מחדל. (גם לקוח יכול לקרוא ל--bind לפני הקריאה ל--connect אם הוא רוצה מספר שער מסוים עבור הקשר, אך בדרך כלל אין סיבה לקשור את שקע של לקוח למספר שער מסוים).
  3. השרת מודיע על רצונו ביצירת תור של לקוחות שמבקשים להתחבר על ידי קריאה ל--listen. בקריאה השרת מודיע מה צריך להיות אורך התור המקסימלי. אם יותר לקוחות מנסים להתחבר, בקשתם תידחה. תור ארוך שימושי למצבים שבהם השרת עלול להיות עמוס עד כדי שאינו מסוגל לפתוח ערוצים בקצב קבלת הבקשות.
לאחר הכנת השקע, ההמתנה עצמה מתבצעת על ידי קריאה ל--accept. קריאה זו מכניסה את השרת (או לפחות את החוט או התהליך שקרא ל--accept) להמתנה עד שלקוח מנסה להתחבר. כאשר לקוח מנסה להתחבר, הקריאה חוזרת ומחזירה מזהה שקע חדש. מזהה זה מייצג את הקשר הפתוח לתקשורת וניתן לקרוא ולכתוב ממנו בעזרת read ו--write. בסיום השימוש בקשר הזה, יש לסגור אותו בעזרת close. השקע המקורי שעליו ביצענו accept אינו משמש לתקשורת עם הלקוח וניתן לקרוא איתו שוב ל--accept על מנת לאפשר ללקוחות נוספים להתחבר. בדרך כלל, שרתים משתמשים בחוט/תהליך אחד לביצוע accept בלולאה אינסופית, ובחוט/תהליך נפרד לטיפול בכל קשר שנפתח ללקוח.

#include <winsock2.h>
...
int bind  (SOCKET socket, struct sockaddr* my_addr,     int  addr_len);
int listen(SOCKET socket, int queue_size);
int accept(SOCKET socket, struct sockaddr* client_addr, int* addr_len);
הארגומנטים השני והשלישי ל--bind מציינים את כתובת השרת כולל מספר השער שאליו רוצים לקשור את השקע. בונים אותם כמו בדיוק כמו שבונים כתובת ל--connect. את שם המחשב שעליו רץ השרת ניתן לקבל על ידי קריאה ל--gethostname.

הארגומנט השני ל--listen הוא מספר בקשות ההתחברות שיכולות להמתין בתור לפני שבקשות ידחו.

הארגומנטים השני והשלישי ל--accept משמשים את השגרה להחזרת כתובת הלקוח. ניתן גם לקבל את כתובת הלקוח על ידי קריאה לשגרה getpeername לאחר יצירת הקשר. ניתן להפוך את הכתובת למחרוזת על ידי שימוש ב--inet_ntoa.

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

אנו נשתמש בתרגיל זה באסטרטגיה פשוטה לטיפול בלקוחות. כל פעם ש--accept יוצרת קשר חדש, השרת יקרא ל--CreateProcess על מנת ליצור תהליך חדש שיטפל בקשר שנוצר. התהליך המקורי צריך פשוט לסגור את השקע החדש שנוצר (הוא לא ישתמש בו) ולקרוא שוב ל--accept. התהליך החדש שנוצר צריך לסגור את השקע שעליו בוצע accept, לטפל בבקשת השירות מהלקוח, ואז לסגור את הקשר ולצאת.

אסטרטגיה זו אינה יעילה במיוחד מכיון שהיא יוצרת תהליך חדש, פעולה יקרה, בכל בקשת התחברות. עדיף היה להשתמש במאגר של תהליכים או חוטים שאינם מתים לאחר טיפול בלקוח אחד, כפי שעשינו תרגיל הקלט/פלט ללא המתנה.

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

כאמור, אנו נשתמש באסטרטגיה של יצירת תהליך חדש עבור כל בקשת שירות. התהליך החדש שנוצר צריך להיות מסוגל להשתמש בשקע הפתוח ללקוח, על מנת לקבל ממנו את הבקשה ולשלוח לו את התשובה. את היכולת הזו צריך להעניק לו על ידי הורשת המזהה של השקע לתהליך החדש. הדרך הפשוטה ביותר להוריש לו את המזהה היא לשכפל את המזהה, כך שהמזהה החדש, שמתייחס לאותו שקע, יהיה בר ירושה, ולבקש בזמן יצירת התהליך החדש שהוא יירש מזהים (רק את המזהים שנוצרו באופן מפורש כברי ירושה).

#include <windows.h>
...
BOOL DuplicateHandle(
       HANDLE  source_process,
       HANDLE  source_handle,
       HANDLE  target_process,
       HANDLE* target_handle,
       DWORD   desired_access,
       BOOL    inheritable_target,
       DWORD   options);
קריאת המערכת הזו משכפלת מזהה, ויכולה גם להעביר אותו מתהליך אחד לתהליך אחר. אנו נשתמש בה על מנת לשכפל מזהה בתוך התהליך הנוכחי, כך שנעביר בארגומנט הראשון והשלישי את הערך החוזר מ--GetCurrentProcess(). בארגומנט השני מעבירים את המזהה שרוצים לשכפל, וברביעי את כתובת המשתנה שיקבל את המזהה המשוכפל. אנו רוצים שהמזהה החדש ייהיה בר הורשה, ולכן נעביר את הערך TRUE. בארגומנט השישי. הארגונמנטים החמישי והשביעי מאפשרים לשלוט בהרשאות הגישה שהמזהה המשוכפל יספק. מכיון שאנו רוצים את אותן הרשאות כמו במזהה המשוכפל, נעביר בארגומנט האחרון את הערך DUPLICATE_SAME_ACCESS, ואז הקריאה תתעלם מהארגומנט החמישי (העבירו 0).

כדי שהתהליך החדש יוכל להשתמש במזהה המשוכפל שירש, צריך להעביר את הערך TRUE. בארגומנט החמישי של CreateProcess(), שמודיע למערכת ההפעלה שהתהליך החדש צריך לרשת את המזהים ברי ההורשה של התהליך הנוכחי. כמו כן, התהליך החדש צריך לדעת מהו המזהה, מה מספרו. בתרגיל הזה נעביר לו את מספר המזהה בשורת הפקודה.

הרצת תכנית עבור לקוח ואיסוף הפלט. על השרת להריץ תכנית עבור הלקוח ולשלוח לו את הפלט שלה. מכיון שעל השרת לשלוח לפני הפלט את אורך הפלט (בשורת המידע Content-Length), על השרת לאסוף את הפלט בחוצץ, לספור את אורכו ורק לאחר מכן לשלוח אותו ללקוח עם שורות מידע מתאימות.

איסוף הפלט מתבצע בעזרת צינור חסר שם שהשרת יוצר בעזרת קריאת המערכת CreatePipe.

#include <windows.h>
...
BOOL CreatePipe(HANDLE* read_pipe,
                HANDLE* write_pipe,
                SECURITY_ATTRIBUTES* sa,
                DWORD   size);
הקריאה מחזירה שני מזהים שניתן לכתוב לאחד מהם ולקרוא מהשני. הארגונמט האחרון הוא בגדר הצעה למערכת בנוגע לגודל החוצץ שצריך להקצות עבור הצינור, אבל הוא אינו מגביל בשום אופן את כמות המידע שניתן להעביר בצינור. את המזהה שניתן לכתוב אליו צריך לשכפל כך שניתן יהיה להוריש אותו לתוכנית שנריץ, כדי שהיא תשתמש בו בתור ערוץ הפלט הסטנדטי שלה. לאחר מכן השרת קורא ל--CreateProcess על מנת ליצור תהליך שיריץ את התכנית שהלקוח ביקש (בדומה למה שעשינו בתרגיל שנושאו הרצת תהליכים ותכניות). כדי שהתהליך החדש ישתמש בצינור בתור ערוץ הפלט, צריך לשנות במבנה מסוג -STARTUPINFO שאת כתובתו מעבירים ל--CreateProcess (ארגומנט לפני אחרון) שני שדות: בשדה dwFlags צריך להדליק את הקבוע STARTF_USESTDHANDLES, ובשדה hStdOutput צריך לשמור את המזהה שאליו התהליך החדש צריך לכתוב את הפלט שלו.

ZeroMemory( &start_info, sizeof(STARTUPINFO) );
start_info.cb         = sizeof(STARTUPINFO);
start_info.dwFlags    = STARTF_USESTDHANDLES;
start_info.hStdOutput = write_pipe_duplicate;
לאחר יצירת התהליך החדש יש לאסוף את הפלט שלו לתוך חוצץ, לחכות לסיומו, למדוד את כמות המידע בחוצץ, ולשלוח את המידע ללקוח. כעת התהליך שטיפל בלקוח יכול לסיים את פעולתו.

התרגיל. עליך לכתוב תכנית בשם win32-serve שתתפקד כשרת http המריץ תכניות ושולח את פלטן ללקוח. לתכנית יש ארגומנט אחד, מספר שער שיש להאזין לו (הריצו אותו עם מספר גבוה, המספרים עד 3201 שמורים ל--root).

השרת מפרש את ה--url שהלקוח שלח בבקשת ה--GET כשם תכנית וארגומנטים בצורה הבאה: שם הקובץ הוא שם תכנית שיש להריץ, ובצמוד אליו, מופרדים בסימני + מופיעים הארגומנטים לתכנית. למשל, ה--url

http://localhost:2000/fsutil+volume+diskfree+c: 
יפורש על ידי שרת שרץ באותו מחשב ומאזין לשער מספר 0002 בתור בקשה להריץ את התכנית fsutil (במדריך שבו רץ השרת) עם הארגומנט הנתונים. השרת מחפש את שם התכנית והארגומנטים בתוך ה--url על ידי חיפוש ה--/ האחרון ב--url והפרדת המחרוזת משם ואילך בעזרת סימני +.

השרת צריך להחזיר header בן שלוש שורות, שורת רווח, ולאחריה הפלט מהתכנית. כל שורה ב--header צריכה להסתיים ב--\r\n. שלוש שורות ה--header צריכות להיות

HTTP/1.0 200 OK 
Content-Length: 133 
Content-Type: text/plain
השורה הראשונה מציינת קוד הצלחה, השניה את אורך קובץ הפלט שישלח (והיא צריכה כמובן להכיל את האורך האמיתי של הפלט, לא את הקבוע 933), והשלישית את סוג הקובץ, כאן טקסט פשוט. אין צורך להחזיר קוד שגיאה מדויק במקרה של כשל (למשל אם התכנית אינה קיימת ו--execv נכשל).

השרת צריך להגיב רק לבקשות מסוג GET מלקוחות. על השרת לקרוא בכל מקרה את כל בקשת ה--http עד לשורת הרווח המציינת את סיומה (כלומר עד רצף של שתי מחרוזות \r\n רצופים).

על מנת לבדוק את השרת שלכם, כדאי להעתיק למדריך שבו אתם מריצים אותו תכנית כגון date.exe או fsutil.exe מתוך המדריך windows\system32.

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

מפאת מורכבות התרגיל, מוצע לממש אותו בשלושה שלבים ולבדוק את פעולתו לאחר כל שלב:
  1. בשלב ראשון ממשו שרת שמתעלם מה--url ותמיד שולח שורת פלט קבועה ללקוח (בצירוף header מתאים כמובן). כמו כן, השרת משרת את הלקוח ללא יצירת תהליך חדש. השרת משרת בקשה אחת, סוגר את הקשר, וקורא שוב ל--accept.
  2. כעת צרו תהליך חדש עבור כל לקוח.
  3. בשלב השלישי הוסיפו את פענוח ה--url והרצת התכנית המבוקשת לפי הנדרש בתרגיל.
ניסויים שיש לערוך עם השרת:
  1. הריצו את השרת וגשו אליו מדפדפן כגון mozilla או internet explorer, רצוי ממחשב אחר.
  2. הריצו את התכנית date בעזרת השרת וודאו שאתם מקבלים תאריך מעודכן כל פעם שאתם לוחצים על כפתור ה--reload או ה--refresh של הדפדפן.
  3. אם יש לכם גישה לשרת מורשה, נסו לגשת לשרת שלכם ישירות (לא דרך השרת המורשה) וגם דרך השרת המורשה והשוו את בקשות ה--http שהשרת מקבל.
Copyright Sivan Toledo 2004
Previous Up Next