הודעות ונתיבי רשת¶
בTCP היינו צריכים לבנות גבולות הודעה מעל זרם. עכשיו נשתמש בUDP, שבו כל שליחה היא datagram נפרד. זה נשמע כאילו קיבלנו את כל הפרוטוקול בחינם, אבל קיבלנו חוזה אחר: יש גבולות הודעה, ואין הבטחה מובנית שההודעות יגיעו, יגיעו פעם אחת או יגיעו לפי הסדר שאנחנו רוצים.
נניח שאנחנו שולחים דגימת חיישן פעם בשנייה. לעיתים עדיף לקבל את הדגימה החדשה ולוותר על הישנה, במקום לעכב את החדשה עד שישוחזר מידע שכבר אינו רלוונטי. לעומת זאת, בבקשת תשלום אסור להפוך datagram שאבד לחיוב חוזר בלי מזהה ופרוטוקול מתאים. הבחירה בתעבורה מגיעה ממשמעות המידע.
ניסוי עם הודעות ממוספרות¶
ניצור שני socketים באותו process. אחד יקבל בloopback על port שהמערכת בוחרת, והשני ישלח אליו ארבע הודעות. נשלח בכוונה מספרים בסדר 2, 1, 1, 4. כך נבדוק את לוגיקת היישום בלי להעמיד פנים שגרמנו לרשת עצמה לשנות סדר או לשכפל packetים.
כל הודעה תכיל magic, מספר רצף וארבעה bytes של payload. שמרו בdatagrams.cpp.
#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <ws2tcpip.h>
#include <array>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <set>
#include <stdexcept>
#include <string>
#include <string_view>
#pragma comment(lib, "ws2_32.lib")
[[noreturn]] void fail(const char* call, int code = WSAGetLastError()) {
throw std::runtime_error(std::string(call) + ": " + std::to_string(code));
}
struct winsock_scope {
winsock_scope() {
WSADATA data{};
int error = WSAStartup(MAKEWORD(2, 2), &data);
if (error) fail("startup", error);
}
winsock_scope(const winsock_scope&) = delete;
winsock_scope& operator=(const winsock_scope&) = delete;
~winsock_scope() { WSACleanup(); }
};
struct socket_owner {
SOCKET value = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
socket_owner() { if (value == INVALID_SOCKET) fail("socket"); }
socket_owner(const socket_owner&) = delete;
socket_owner& operator=(const socket_owner&) = delete;
~socket_owner() { closesocket(value); }
};
int main(int argc, char** argv) {
try {
bool short_buffer = argc == 2 && std::string_view(argv[1]) == "short";
if (argc > 2 || (argc == 2 && !short_buffer))
throw std::runtime_error("use: datagrams [short]");
winsock_scope winsock;
socket_owner receiver;
socket_owner sender;
sockaddr_in destination{};
destination.sin_family = AF_INET;
destination.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
destination.sin_port = 0;
if (bind(receiver.value, reinterpret_cast<sockaddr*>(&destination),
sizeof(destination)) == SOCKET_ERROR) fail("bind");
int address_size = sizeof(destination);
if (getsockname(receiver.value, reinterpret_cast<sockaddr*>(&destination),
&address_size) == SOCKET_ERROR) fail("local address");
DWORD deadline = 2000;
if (setsockopt(receiver.value, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<char*>(&deadline), sizeof(deadline)) == SOCKET_ERROR)
fail("timeout");
for (std::uint32_t sequence : {2u, 1u, 1u, 4u}) {
std::array<char, 12> packet{};
std::uint32_t magic = htonl(0x414d5431u);
std::uint32_t wire_sequence = htonl(sequence);
std::memcpy(packet.data(), &magic, 4);
std::memcpy(packet.data() + 4, &wire_sequence, 4);
std::memcpy(packet.data() + 8, "test", 4);
int sent = sendto(sender.value, packet.data(), static_cast<int>(packet.size()),
0, reinterpret_cast<sockaddr*>(&destination), sizeof(destination));
if (sent == SOCKET_ERROR) fail("send datagram");
if (sent != static_cast<int>(packet.size()))
throw std::runtime_error("unexpected short datagram send");
}
std::set<std::uint32_t> seen;
for (int i = 0; i < 4; ++i) {
std::array<char, 12> packet{};
sockaddr_storage source{};
int source_size = sizeof(source);
int count = recvfrom(receiver.value, packet.data(), short_buffer ? 8 : 12,
0, reinterpret_cast<sockaddr*>(&source), &source_size);
if (count == SOCKET_ERROR) {
int error = WSAGetLastError();
if (error == WSAEMSGSIZE) {
std::cout << "datagram discarded: receive buffer too short\n";
continue;
}
fail("receive datagram", error);
}
if (count != 12) {
std::cout << "application length rejected=" << count << '\n';
continue;
}
std::uint32_t magic = 0, sequence = 0;
std::memcpy(&magic, packet.data(), 4);
std::memcpy(&sequence, packet.data() + 4, 4);
if (ntohl(magic) != 0x414d5431u) continue;
sequence = ntohl(sequence);
bool fresh = seen.insert(sequence).second;
std::cout << "sequence=" << sequence << " duplicate=" << !fresh << '\n';
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
בנו והריצו פעם עם buffer מלא ופעם עם buffer קטן מדי.
בריצה הרגילה, אם ארבע ההודעות הגיעו, נראה שמספר 1 התקבל פעמיים. עדיין לא נקבע מיד שמספר 3 אבד לנצח. בפרוטוקול רשת אמיתי ייתכן שהוא מתעכב; נצטרך חלון זמן או חלון רצף לפני שנכריע מה חסר. גם קבוצת seen אינה פתרון בלתי מוגבל למוצר שרץ שנים: נבחר חלון מוגבל וכלל לגלישת מספרי רצף.
קבלה לbuffer קטן אינה קריאה חלקית של זרם¶
במצב short, הdatagram גדול מהbuffer. בUDP דרך הממשק הזה נקבל WSAEMSGSIZE, והמידע העודף של אותה הודעה לא יישמר כדי שנחבר אותו לקריאה הבאה. הקריאה הבאה מתייחסת לdatagram הבא. לכן הלולאה read_exact של TCP אינה דרך לתקן buffer קצר כאן. החוזה מופיע בתיעוד recvfrom.
גם אפס bytes דורש פירוש לפי סוג הsocket. בUDP יכולה להתקבל הודעה באורך אפס; אין כאן FIN של חיבור TCP. בפרוטוקול שלנו אפס נדחה כי חסר header, אבל זאת החלטת יישום. אם נעתיק קוד שמפרש אפס כסגירת חיבור, ניישם סמנטיקה של פרוטוקול אחר.
גודל הודעה וגודל נתיב¶
אפשר שהודעה חוקית מבחינת הAPI עדיין אינה מתאימה לMTU של הנתיב. פיצול IP מוסיף תלות בהגעת חלקים, ומידע חסר בחלק אחד עלול למנוע את קבלת כל ההודעה. לא נקבע גודל payload לפי מספר שנוח למבנה שלנו בלי לחשוב על הנתיב, headers ותעבורה משתנה.
גם שליחה חוזרת צריכה בקרה. אם כל לקוח מגיב לאובדן בהצפה מהירה יותר, האובדן יכול להחמיר. נצטרך pacing, הגבלת ניסיונות והתנהגות שמתחשבת בעומס. ההנחיות לשימוש בUDP נותנות מסגרת לדיון הזה; מספרי רצף לבדם אינם מנגנון בקרת עומס או אימות.
שם אינו כתובת אחת¶
עד עכשיו השתמשנו בכתובת מספרית. במוצר נקבל לעיתים שם כמו שם שרת ארגוני, ונצטרך resolver. הפונקציה getaddrinfo מחזירה רשימה של מועמדים לפי hints והגדרות המערכת. הרשימה יכולה לכלול IPv4 וIPv6; הצלחת resolution אינה הצלחת חיבור לאחד מהם. אפשר לבדוק את חוזה פתרון השמות.
כדי לראות את המועמדים, העתיקו את stream.cpp מההרצאה הראשונה לקובץ ניסוי והחליפו רק את main בקוד הבא. העטיפות winsock_scope וaddresses, וכן fail, נשארות מהקובץ המקורי.
int main(int argc, char** argv) {
try {
if (argc > 2) throw std::runtime_error("use: resolve [host]");
winsock_scope winsock;
addresses resolved;
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
int error = getaddrinfo(argc == 2 ? argv[1] : "localhost", "443",
&hints, &resolved.value);
if (error) fail("resolve", error);
for (auto item = resolved.value; item; item = item->ai_next) {
char numeric[NI_MAXHOST]{};
error = getnameinfo(item->ai_addr, static_cast<int>(item->ai_addrlen),
numeric, sizeof(numeric), nullptr, 0, NI_NUMERICHOST);
if (error) fail("numeric name", error);
std::cout << "family=" << item->ai_family << " address=" << numeric << '\n';
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
האורך מועבר לפי חתימת Winsock שבheaders של המעבדה. ההמרה כאן מתייחסת למבנה כתובת קטן שהresolver החזיר; היא אינה דוגמה להמרה עיוורת של buffer שרירותי מsize_t לint.
במוצר לא נתקע על המועמד הראשון למשך דקות כשיש מועמד אחר שעובד. נגדיר תקציב כולל וניסיונות חיבור מדורגים או מקבילים מוגבלים, ואז נבטל וננקז את המפסידים. נשמור את שם היעד המקורי לצורך אימות TLS; הכתובת שאליה הצלחנו להתחבר אינה מחליפה את הזהות שאליה התכוון המשתמש.
ממשק רשת משתנה¶
חיבור VPN, יציאה משינה או החלפת Wi-Fi יכולים לשנות כתובות, routes ומידע DNS. אפשר לקרוא snapshot של מתאמים באמצעות GetAdaptersAddresses, כולל משפחות הכתובות הרלוונטיות. הגודל יכול להשתנות בין ניסיון לניסיון, ולכן נתכנן טיפול בbuffer קטן בלי לולאת הקצאה בלתי מוגבלת. הממשק מתועד בשאילתת כתובות המתאמים.
הsnapshot אינו נעילה על הרשת. מיד לאחר הקריאה מתאם יכול להיעלם. התוכנית צריכה לטפל בכשל החיבור עצמו ולהחליט אילו בקשות אפשר לנסות שוב. חיבור TCP קיים לא הופך אוטומטית לבקשה חדשה לכתובת חדשה רק מפני שהresolver החזיר מועמד אחר.
איפה התקלה¶
אם אין כתובת, נחקור resolution והגדרות שם. אם יש כתובת אבל החיבור לא נוצר, נחקור route, listener, מדיניות ותעבורה. אם החיבור קיים אך ההודעה לא שלמה, נחזור לframing ולחיי הbuffers. אם השרת ענה כראוי אבל העבודה כפולה, נחקור retry ומזהי בקשה.
בcapture או trace נרשום מאיזה ממשק ומאיזו נקודה אספנו. היעדר packet בקובץ אינו מוכיח שלא נשלח אם אספנו בממשק אחר או איבדנו אירועים. נצמיד לתצפית את גבולותיה, ונבחר ניסוי קטן שמבדיל בין שתי השערות במקום להחליף בבת אחת DNS, firewall וקוד.