前言
本科期間修讀了《計算機網路》課程,但是課上佈置的作業比較簡單,只是分析了一下 Wireshark 抓包的結構,沒有動手實現過協議。所以最近在嗶哩大學線上學習了史丹佛大學的 CS144 計算機網課程,這門課搭配了幾個 Lab,要求動手實現一個 TCP 協議,而不是簡單地呼叫系統為我們提供好的 Socket。
實驗準備
CS144 Fall2019 的課件和實驗指導書可以下載自 CS144 映象網站,程式碼可以從我的 Github 倉庫獲取。
本篇部落格將會介紹 Lab0 的實驗過程,實驗環境為 Ubuntu20.04 虛擬機器,使用 VSCode 完成程式碼的編寫。
實驗過程
Lab0 有兩個任務,第一個任務是實現能傳送 Get 請求到任意網址的 webget 程式,第二個任務是實現記憶體內的可靠位元組流。
webget
實驗指導書中讓我們先用 Telnet 程式連線到史丹佛大學的 Web 伺服器上,在命令列中輸入 telnet cs144.keithw.org http
並回車,不出意外的話會提示已成功連線上伺服器。之後手動構造請求報文,包括請求行和請求頭,輸入兩次回車就能得到響應,響應體內容為 Hello, CS144
。
應用層的 Http 協議使用 TCP 傳輸層協議進行資料的可靠性傳輸,由於我們目前還沒有實現 TCP 協議,只能先借用一下作業系統寫好的的 socket 來傳送 http 請求。CS144 的老師們十分貼心地對 socket 庫進行了二次封裝,類圖如下所示:
FileDescriptor
的部分程式碼如下,可以看到內部類 FDWrapper
持有檔案描述符,會在析構的時候呼叫 close()
函式釋放對檔案描述符的引用 。FileDescriptor
還提供了 read()
和 write()
函式進行檔案讀寫操作:
class FileDescriptor {
//! \brief A handle on a kernel file descriptor.
//! \details FileDescriptor objects contain a std::shared_ptr to a FDWrapper.
class FDWrapper {
public:
int _fd; //!< The file descriptor number returned by the kernel
bool _eof = false; //!< Flag indicating whether FDWrapper::_fd is at EOF
bool _closed = false; //!< Flag indicating whether FDWrapper::_fd has been closed
//! Construct from a file descriptor number returned by the kernel
explicit FDWrapper(const int fd);
//! Closes the file descriptor upon destruction
~FDWrapper();
//! Calls [close(2)](\ref man2::close) on FDWrapper::_fd
void close();
};
//! A reference-counted handle to a shared FDWrapper
std::shared_ptr<FDWrapper> _internal_fd;
public:
//! Construct from a file descriptor number returned by the kernel
explicit FileDescriptor(const int fd);
//! Free the std::shared_ptr; the FDWrapper destructor calls close() when the refcount goes to zero.
~FileDescriptor() = default;
//! Read up to `limit` bytes
std::string read(const size_t limit = std::numeric_limits<size_t>::max());
//! Read up to `limit` bytes into `str` (caller can allocate storage)
void read(std::string &str, const size_t limit = std::numeric_limits<size_t>::max());
//! Write a string, possibly blocking until all is written
size_t write(const char *str, const bool write_all = true) { return write(BufferViewList(str), write_all); }
//! Write a string, possibly blocking until all is written
size_t write(const std::string &str, const bool write_all = true) { return write(BufferViewList(str), write_all); }
//! Close the underlying file descriptor
void close() { _internal_fd->close(); }
int fd_num() const { return _internal_fd->_fd; } //!< \brief underlying descriptor number
bool eof() const { return _internal_fd->_eof; } //!< \brief EOF flag state
bool closed() const { return _internal_fd->_closed; } //!< \brief closed flag state
};
// 析構的時候自動釋放檔案描述符
FileDescriptor::FDWrapper::~FDWrapper() {
try {
if (_closed) {
return;
}
close();
} catch (const exception &e) {
// don't throw an exception from the destructor
std::cerr << "Exception destructing FDWrapper: " << e.what() << std::endl;
}
}
我們知道,在 Linux 系統中 “萬物皆檔案”,socket 也被認為是一種檔案,socket 被表示成檔案描述符,呼叫 socket()
函式返回就是一個檔案描述符,對 socket 的讀寫就和檔案的讀寫一樣。所以 Socket
類繼承自 FileDescriptor
類,同時擁有三個子類 TCPSocket
、UDPSocket
和 LocalStreamSocket
,我們將使用 TCPSocket
完成第一個任務。
第一個任務需要補全 apps/webget.cc
的 get_URL()
函式,這個函式接受兩個引數:主機名 host
和請求路徑 path
:
void get_URL(const string &host, const string &path) {
TCPSocket socket;
// 連線到 Web 伺服器
socket.connect(Address(host, "http"));
// 建立請求報文
socket.write("GET " + path + " HTTP/1.1\r\n");
socket.write("Host: " + host + "\r\n\r\n");
// 結束寫操作
socket.shutdown(SHUT_WR);
// 讀取響應報文
while (!socket.eof()) {
cout << socket.read();
}
// 關閉 socket
socket.close();
}
首先呼叫 connect()
函式完成 TCP 的三次握手,建立與主機的連線,接著使用 write()
函式手動構造請求報文。請求報文的格式如下圖所示,其中請求行的方法是 GET,URI 為請求路徑 path
,Http 協議版本為 HTTP/1.1
,而首部行必須含有一個 Host
鍵值對指明將要連線的主機:
傳送完請求報文後就可以結束寫操作,並不停呼叫 TCPSocket.read()
函式讀取響應報文的內容直至結束,最後關閉套接字釋放資源。其實這裡也可以不手動關閉,因為 socket
物件被析構的時候會自動呼叫 FDWrapper.close()
釋放檔案描述符。
在命令列中輸入下述命令完成編譯:
mkdir build
cd build
cmake ..
make -j8
之後執行 ./apps/webget cs144.keithw.org /hello
就能看到響應報文了:
接著執行測試程式,也順利透過了:
in-memory reliable byte stream
任務二要求我們實現一個記憶體內的有序可靠位元組流:
- 位元組流可以從寫入端寫入,並以相同的順序,從讀取端讀取
- 位元組流是有限的,寫者可以終止寫入。而讀者可以在讀取到位元組流末尾時,不再讀取。
- 位元組流支援流量控制,以控制記憶體的使用。當所使用的緩衝區爆滿時,將禁止寫入操作。
- 寫入的位元組流可能會很長,必須考慮到位元組流大於緩衝區大小的情況。即便緩衝區只有1位元組大小,所實現的程式也必須支援正常的寫入讀取操作。
- 在單執行緒環境下執行,無需考慮多執行緒生產者-消費者模型下各類條件競爭問題。
由於寫入順序和讀出順序相同,這種先入先出的 IO 特性可以使用佇列來實現。C++ 標準庫提供了 std::queue
模板類,但是 std::queue
不支援迭代器,這會對後續編碼造成一點麻煩,所以這裡換成雙端佇列 std::deque
。
類宣告如下所示,使用 deque<char>
儲存資料,_capacity
控制佇列長度,_is_input_end
代表寫入是否結束:
class ByteStream {
private:
size_t _capacity;
std::deque<char> _buffer{};
size_t _bytes_written{0};
size_t _bytes_read{0};
bool _is_input_end{false};
bool _error{}; //!< Flag indicating that the stream suffered an error.
public:
//! Construct a stream with room for `capacity` bytes.
ByteStream(const size_t capacity);
//! Write a string of bytes into the stream. Write as many
//! as will fit, and return how many were written.
//! \returns the number of bytes accepted into the stream
size_t write(const std::string &data);
//! \returns the number of additional bytes that the stream has space for
size_t remaining_capacity() const;
//! Signal that the byte stream has reached its ending
void end_input();
//! Indicate that the stream suffered an error.
void set_error() { _error = true; }
//! Peek at next "len" bytes of the stream
//! \returns a string
std::string peek_output(const size_t len) const;
//! Remove bytes from the buffer
void pop_output(const size_t len);
//! Read (i.e., copy and then pop) the next "len" bytes of the stream
//! \returns a vector of bytes read
std::string read(const size_t len) {
const auto ret = peek_output(len);
pop_output(len);
return ret;
}
//! \returns `true` if the stream input has ended
bool input_ended() const;
//! \returns `true` if the stream has suffered an error
bool error() const { return _error; }
//! \returns the maximum amount that can currently be read from the stream
size_t buffer_size() const;
//! \returns `true` if the buffer is empty
bool buffer_empty() const;
//! \returns `true` if the output has reached the ending
bool eof() const;
//! Total number of bytes written
size_t bytes_written() const;
//! Total number of bytes popped
size_t bytes_read() const;
};
類實現:
ByteStream::ByteStream(const size_t capacity) : _capacity(capacity) {}
size_t ByteStream::write(const string &data) {
size_t ws = min(data.size(), remaining_capacity());
for (size_t i = 0; i < ws; ++i)
_buffer.push_back(data[i]);
_bytes_written += ws;
return ws;
}
//! \param[in] len bytes will be copied from the output side of the buffer
string ByteStream::peek_output(const size_t len) const {
auto rs = min(buffer_size(), len);
return {_buffer.begin(), _buffer.begin() + rs};
}
//! \param[in] len bytes will be removed from the output side of the buffer
void ByteStream::pop_output(const size_t len) {
auto rs = min(len, buffer_size());
_bytes_read += rs;
for (size_t i = 0; i < rs; ++i)
_buffer.pop_front();
}
void ByteStream::end_input() { _is_input_end = true; }
bool ByteStream::input_ended() const { return _is_input_end; }
size_t ByteStream::buffer_size() const { return _buffer.size(); }
bool ByteStream::buffer_empty() const { return _buffer.empty(); }
bool ByteStream::eof() const { return buffer_empty() && input_ended(); }
size_t ByteStream::bytes_written() const { return _bytes_written; }
size_t ByteStream::bytes_read() const { return _bytes_read; }
size_t ByteStream::remaining_capacity() const { return _capacity - buffer_size(); }
之後重新 make -j8
編譯,make check_lab0
的測試結果如下,也是成功透過了全部的測試用例:
後記
由於 Lab0 只是個熱身實驗,所以整體而言還是比較簡單的,透過這個實驗,可以加深對 Http 請求報文結構的理解,同時對 C++ 的 RAII 機制也會有更直觀的認識,以上~~