ساخت یک وب سرور Single-Threaded

ما با راه‌اندازی یک وب سرور Single-Threaded شروع خواهیم کرد. پیش از شروع، بیایید یک مرور سریع بر پروتکل‌های مرتبط با ساخت وب سرورها داشته باشیم. جزئیات این پروتکل‌ها خارج از محدوده این کتاب است، اما یک نمای کلی اطلاعات لازم را به شما می‌دهد.

دو پروتکل اصلی که در وب سرورها درگیر هستند، Hypertext Transfer Protocol (HTTP) و Transmission Control Protocol (TCP) هستند. هر دو پروتکل request-response هستند، به این معنی که یک client درخواست‌ها را آغاز می‌کند و یک server به درخواست‌ها گوش می‌دهد و پاسخی به client ارائه می‌دهد. محتوای این درخواست‌ها و پاسخ‌ها توسط پروتکل‌ها تعریف شده است.

TCP یک پروتکل سطح پایین‌تر است که جزئیات چگونگی انتقال اطلاعات از یک سرور به سرور دیگر را توصیف می‌کند اما مشخص نمی‌کند که آن اطلاعات چیست. HTTP بر روی TCP ساخته شده است و محتوای درخواست‌ها و پاسخ‌ها را تعریف می‌کند. از لحاظ فنی امکان استفاده از HTTP با سایر پروتکل‌ها وجود دارد، اما در اکثر موارد، HTTP داده‌های خود را بر روی TCP ارسال می‌کند. ما با بایت‌های خام درخواست‌ها و پاسخ‌های TCP و HTTP کار خواهیم کرد.

گوش دادن به اتصال TCP

وب سرور ما نیاز دارد به اتصال TCP گوش دهد، بنابراین این اولین بخشی است که روی آن کار می‌کنیم. کتابخانه استاندارد یک ماژول std::net ارائه می‌دهد که به ما این امکان را می‌دهد این کار را انجام دهیم. بیایید یک پروژه جدید به روش معمول ایجاد کنیم:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

حالا کد لیست ۲۱-۱ را در فایل src/main.rs وارد کنید تا شروع کنیم. این کد به آدرس محلی 127.0.0.1:7878 برای جریان‌های ورودی TCP گوش می‌دهد. وقتی یک جریان ورودی دریافت می‌کند، پیام Connection established! را چاپ می‌کند.

Filename: src/main.rs
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}
Listing 21-1: گوش دادن به جریان‌های ورودی و چاپ یک پیام هنگام دریافت یک جریان

با استفاده از TcpListener، می‌توانیم به اتصالات TCP در آدرس 127.0.0.1:7878 گوش دهیم. در این آدرس، بخش قبل از دونقطه یک آدرس IP است که نمایانگر کامپیوتر شما است (این آدرس روی همه کامپیوترها یکسان است و نمایانگر کامپیوتر نویسندگان نیست) و 7878 پورت است. این پورت را به دو دلیل انتخاب کرده‌ایم: HTTP معمولاً روی این پورت پذیرفته نمی‌شود، بنابراین احتمالاً سرور ما با هیچ وب سرور دیگری که ممکن است روی دستگاه شما اجرا شود تداخل نخواهد داشت، و 7878 روی تلفن به صورت rust تایپ می‌شود.

تابع bind در این سناریو مانند تابع new عمل می‌کند به این صورت که یک نمونه جدید از TcpListener بازمی‌گرداند. این تابع bind نامیده می‌شود زیرا در شبکه، اتصال به یک پورت برای گوش دادن به آن به عنوان “binding to a port” شناخته می‌شود.

تابع bind یک Result<T, E> بازمی‌گرداند که نشان می‌دهد امکان دارد فرآیند binding شکست بخورد. به عنوان مثال، اتصال به پورت 80 نیاز به دسترسی مدیر (administrator) دارد (کاربران عادی فقط می‌توانند به پورت‌های بالاتر از 1023 گوش دهند)، بنابراین اگر تلاش کنیم بدون دسترسی مدیر به پورت 80 متصل شویم، فرآیند binding کار نخواهد کرد. همچنین اگر دو نمونه از برنامه خود اجرا کنیم و در نتیجه دو برنامه به همان پورت گوش دهند، فرآیند binding شکست خواهد خورد. از آنجا که ما یک سرور ساده فقط برای اهداف آموزشی می‌نویسیم، نگرانی‌ای در مورد مدیریت این نوع خطاها نخواهیم داشت؛ در عوض از unwrap استفاده می‌کنیم تا در صورت بروز خطا برنامه متوقف شود.

متد incoming روی TcpListener یک iterator بازمی‌گرداند که به ما دنباله‌ای از جریان‌ها (streams) می‌دهد (به طور خاص، جریان‌هایی از نوع TcpStream). یک stream نشان‌دهنده یک اتصال باز بین client و server است. یک connection به فرآیند کامل درخواست و پاسخ گفته می‌شود که در آن یک client به سرور متصل می‌شود، سرور یک پاسخ تولید می‌کند، و سپس اتصال توسط سرور بسته می‌شود. بنابراین، ما از TcpStream برای خواندن آنچه client ارسال کرده استفاده می‌کنیم و سپس پاسخ خود را به جریان می‌نویسیم تا داده‌ها را به client بازگردانیم. به طور کلی، این حلقه for هر اتصال را به نوبت پردازش کرده و یک سری جریان‌ها را برای مدیریت به ما می‌دهد.

فعلاً، مدیریت ما روی جریان به فراخوانی unwrap محدود می‌شود تا در صورتی که جریان دارای خطا باشد، برنامه متوقف شود. اگر خطایی وجود نداشته باشد، برنامه یک پیام چاپ می‌کند. در لیست بعدی، عملکرد بیشتری برای حالت موفقیت اضافه خواهیم کرد. دلیل اینکه ممکن است از متد incoming هنگام اتصال یک client به سرور خطا دریافت کنیم این است که ما در واقع روی اتصالات تکرار نمی‌کنیم، بلکه روی تلاش‌های اتصال تکرار می‌کنیم. اتصال ممکن است به دلایل مختلف موفقیت‌آمیز نباشد که بسیاری از آن‌ها مربوط به سیستم‌عامل هستند. برای مثال، بسیاری از سیستم‌عامل‌ها محدودیتی برای تعداد اتصالات همزمان باز دارند؛ تلاش‌های اتصال جدیدی که بیش از این تعداد باشند، تا زمانی که برخی از اتصالات باز بسته نشوند، خطا تولید خواهند کرد.

بیایید این کد را اجرا کنیم! دستور cargo run را در ترمینال اجرا کنید و سپس آدرس 127.0.0.1:7878 را در یک مرورگر وب باز کنید. مرورگر باید پیامی خطا مانند “Connection reset” را نشان دهد، زیرا سرور در حال حاضر هیچ داده‌ای باز نمی‌گرداند. اما وقتی به ترمینال خود نگاه کنید، باید چندین پیامی که هنگام اتصال مرورگر به سرور چاپ شده‌اند را ببینید:

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

گاهی ممکن است برای یک درخواست مرورگر چندین پیام چاپ شود؛ دلیل آن می‌تواند این باشد که مرورگر علاوه بر درخواست صفحه، درخواست‌هایی برای منابع دیگر نیز ارسال می‌کند، مانند آیکون favicon.ico که در تب مرورگر ظاهر می‌شود.

همچنین ممکن است مرورگر تلاش کند چندین بار به سرور متصل شود زیرا سرور هیچ داده‌ای باز نمی‌گرداند. وقتی stream از محدوده خارج می‌شود و در انتهای حلقه حذف می‌شود، اتصال به عنوان بخشی از پیاده‌سازی drop بسته می‌شود. مرورگرها گاهی با اتصالات بسته شده با تلاش مجدد مقابله می‌کنند، زیرا ممکن است مشکل موقتی باشد. نکته مهم این است که ما با موفقیت به یک اتصال TCP دست پیدا کرده‌ایم!

به یاد داشته باشید که برنامه را با فشار دادن کلیدهای ctrl-c متوقف کنید وقتی که اجرای نسخه خاصی از کد تمام شد. سپس برنامه را با اجرای دستور cargo run پس از ایجاد هر مجموعه از تغییرات کد، مجدداً راه‌اندازی کنید تا مطمئن شوید که جدیدترین کد اجرا می‌شود.

خواندن درخواست

بیایید عملکرد خواندن درخواست از مرورگر را پیاده‌سازی کنیم! برای جدا کردن نگرانی‌ها از اتصال اولیه و سپس انجام برخی اقدامات با اتصال، یک تابع جدید برای پردازش اتصالات ایجاد می‌کنیم. در این تابع جدید handle_connection، داده‌ها را از جریان TCP می‌خوانیم و آن‌ها را چاپ می‌کنیم تا بتوانیم داده‌هایی که از مرورگر ارسال می‌شوند را ببینیم. کد را تغییر دهید تا شبیه لیست ۲۱-۲ شود.

Filename: src/main.rs
use std::{
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}
Listing 21-2: خواندن از TcpStream و چاپ داده‌ها

ما std::io::prelude و std::io::BufReader را وارد دامنه می‌کنیم تا به ویژگی‌ها و نوع‌هایی که به ما اجازه خواندن و نوشتن از جریان را می‌دهند دسترسی داشته باشیم. در حلقه for در تابع main، به جای چاپ یک پیام که می‌گوید یک اتصال برقرار کردیم، حالا تابع جدید handle_connection را فراخوانی می‌کنیم و stream را به آن ارسال می‌کنیم.

در تابع handle_connection، یک نمونه جدید از BufReader ایجاد می‌کنیم که یک ارجاع به stream را در خود نگه می‌دارد. BufReader با مدیریت فراخوانی متدهای ویژگی std::io::Read برای ما، بافر را اضافه می‌کند.

ما یک متغیر به نام http_request ایجاد می‌کنیم تا خطوط درخواست ارسالی مرورگر به سرور را جمع‌آوری کنیم. مشخص می‌کنیم که می‌خواهیم این خطوط را در یک بردار جمع‌آوری کنیم با اضافه کردن نوع Vec<_> به عنوان حاشیه‌نویسی.

BufReader ویژگی std::io::BufRead را پیاده‌سازی می‌کند که متد lines را ارائه می‌دهد. متد lines یک iterator از نوع Result<String, std::io::Error> بازمی‌گرداند، که با هر بار مشاهده یک بایت newline جریان داده را تقسیم می‌کند. برای دریافت هر String، هر Result را map و unwrap می‌کنیم. اگر داده‌ها UTF-8 معتبری نباشند یا اگر مشکلی در خواندن از جریان وجود داشته باشد، ممکن است Result خطایی باشد. باز هم، یک برنامه تولیدی باید این خطاها را به صورت کارآمدتری مدیریت کند، اما برای سادگی، ما انتخاب می‌کنیم که در حالت خطا برنامه متوقف شود.

مرورگر پایان یک درخواست HTTP را با ارسال دو کاراکتر newline متوالی نشان می‌دهد، بنابراین برای دریافت یک درخواست از جریان، خطوط را می‌گیریم تا زمانی که به یک خط خالی برسیم. پس از جمع‌آوری خطوط در بردار، آن‌ها را با استفاده از فرمت دیباگ زیبا چاپ می‌کنیم تا بتوانیم دستورالعمل‌هایی که مرورگر وب به سرور ما ارسال می‌کند را ببینیم.

بیایید این کد را امتحان کنیم! برنامه را اجرا کنید و دوباره یک درخواست در مرورگر وب ارسال کنید. توجه داشته باشید که همچنان در مرورگر یک صفحه خطا خواهیم دید، اما خروجی برنامه در ترمینال اکنون مشابه این خواهد بود:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

بسته به مرورگری که استفاده می‌کنید، ممکن است خروجی کمی متفاوت دریافت کنید. اکنون که داده‌های درخواست را چاپ می‌کنیم، می‌توانیم دلیل دریافت چندین اتصال از یک درخواست مرورگر را با نگاه کردن به مسیر بعد از GET در خط اول درخواست متوجه شویم. اگر اتصالات تکراری همه / را درخواست کنند، می‌دانیم که مرورگر سعی دارد / را بارها و بارها درخواست کند زیرا پاسخی از برنامه ما دریافت نمی‌کند.

بیایید این داده‌های درخواست را تجزیه کنیم تا متوجه شویم مرورگر از برنامه ما چه چیزی می‌خواهد.

نگاهی دقیق‌تر به یک درخواست HTTP

HTTP یک پروتکل مبتنی بر متن است و یک درخواست فرمت زیر را دارد:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

خط اول خط درخواست است که اطلاعاتی درباره آنچه client درخواست می‌کند را نگه می‌دارد. قسمت اول خط درخواست، method استفاده‌شده را نشان می‌دهد، مانند GET یا POST، که توضیح می‌دهد client چگونه این درخواست را انجام می‌دهد. client ما از یک درخواست GET استفاده کرده است، به این معنی که در حال درخواست اطلاعات است.

قسمت بعدی خط درخواست، / است که نشان‌دهنده شناسه منبع یکسان (Uniform Resource Identifier یا URI) است که client درخواست می‌کند: یک URI تقریباً اما نه کاملاً همان مکان‌نمای منبع یکسان (Uniform Resource Locator یا URL) است. تفاوت بین URIs و URLs برای اهداف ما در این فصل مهم نیست، اما استاندارد HTTP از اصطلاح URI استفاده می‌کند، بنابراین می‌توانیم ذهنی URL را به جای URI در نظر بگیریم.

قسمت آخر نسخه HTTP است که client استفاده می‌کند، و سپس خط درخواست با یک دنباله CRLF پایان می‌یابد. (CRLF به معنای بازگشت حامل و تغذیه خط است که اصطلاحاتی از روزهای ماشین تایپ هستند!) دنباله CRLF همچنین می‌تواند به صورت \r\n نوشته شود، جایی که \r یک بازگشت حامل و \n یک تغذیه خط است. دنباله CRLF خط درخواست را از بقیه داده‌های درخواست جدا می‌کند. توجه داشته باشید که وقتی CRLF چاپ می‌شود، به جای \r\n، یک خط جدید شروع می‌شود.

با نگاه کردن به داده‌های خط درخواست که تاکنون از اجرای برنامه خود دریافت کرده‌ایم، می‌بینیم که GET متد است، / شناسه URI درخواست‌شده است، و HTTP/1.1 نسخه است.

بعد از خط درخواست، خطوط باقی‌مانده از Host: به بعد هدرها هستند. درخواست‌های GET بدنه ندارند.

سعی کنید یک درخواست از مرورگری دیگر یا آدرسی متفاوت، مانند 127.0.0.1:7878/test، ارسال کنید تا ببینید داده‌های درخواست چگونه تغییر می‌کنند.

اکنون که می‌دانیم مرورگر چه چیزی می‌خواهد، بیایید داده‌ای را به آن بازگردانیم!

نوشتن یک پاسخ

ما قصد داریم ارسال داده در پاسخ به یک درخواست client را پیاده‌سازی کنیم. پاسخ‌ها فرمت زیر را دارند:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

خط اول یک خط وضعیت است که شامل نسخه HTTP استفاده‌شده در پاسخ، یک کد وضعیت عددی که نتیجه درخواست را خلاصه می‌کند، و یک عبارت دلیل که توضیح متنی برای کد وضعیت ارائه می‌دهد. پس از دنباله CRLF، هر هدر و سپس یک دنباله CRLF دیگر و بدنه پاسخ قرار می‌گیرد.

در اینجا یک مثال پاسخ آورده شده است که از نسخه HTTP 1.1 استفاده می‌کند، کد وضعیت 200 دارد، عبارت دلیل “OK” است، هیچ هدر و بدنه‌ای ندارد:

HTTP/1.1 200 OK\r\n\r\n

کد وضعیت 200 پاسخ استاندارد موفقیت است. این متن یک پاسخ HTTP کوچک و موفقیت‌آمیز است. بیایید این را به عنوان پاسخ خود به یک درخواست موفق به جریان بنویسیم! از تابع handle_connection، println! که داده‌های درخواست را چاپ می‌کرد، حذف کنید و آن را با کد موجود در لیست ۲۱-۳ جایگزین کنید.

Filename: src/main.rs
use std::{
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-3: نوشتن یک پاسخ HTTP کوچک و موفقیت‌آمیز به جریان

خط جدید اول متغیر response را تعریف می‌کند که داده پیام موفقیت را در خود نگه می‌دارد. سپس با فراخوانی as_bytes روی response داده رشته‌ای را به بایت‌ها تبدیل می‌کنیم. متد write_all روی stream یک &[u8] می‌گیرد و آن بایت‌ها را مستقیماً به اتصال ارسال می‌کند. از آنجا که عملیات write_all ممکن است شکست بخورد، مانند قبل، روی هر نتیجه خطا از unwrap استفاده می‌کنیم. باز هم، در یک برنامه واقعی باید اینجا مدیریت خطا اضافه کنید.

با این تغییرات، بیایید کد خود را اجرا کنیم و یک درخواست ارسال کنیم. دیگر هیچ داده‌ای به ترمینال چاپ نمی‌کنیم، بنابراین هیچ خروجی‌ای به غیر از خروجی Cargo نخواهید دید. وقتی آدرس 127.0.0.1:7878 را در یک مرورگر وب بارگذاری می‌کنید، باید یک صفحه خالی به جای یک خطا دریافت کنید. شما اکنون دریافت یک درخواست HTTP و ارسال یک پاسخ را به صورت دستی کدنویسی کرده‌اید!

بازگرداندن HTML واقعی (Returning Real HTML)

بیایید عملکرد بازگرداندن چیزی بیش از یک صفحه خالی را پیاده‌سازی کنیم. فایل جدیدی به نام hello.html در ریشه دایرکتوری پروژه خود ایجاد کنید، نه در دایرکتوری src. می‌توانید هر HTML که می‌خواهید وارد کنید؛ لیست ۲۱-۴ یک نمونه را نشان می‌دهد.

Filename: hello.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>
Listing 21-4: یک فایل نمونه HTML برای بازگرداندن در پاسخ

این یک سند HTML5 حداقلی با یک عنوان و مقداری متن است. برای بازگرداندن این فایل از سرور هنگام دریافت یک درخواست، کد handle_connection را همان‌طور که در لیست ۲۱-۵ نشان داده شده است تغییر می‌دهیم تا فایل HTML را بخواند، آن را به عنوان بدنه پاسخ اضافه کند و ارسال کند.

Filename: src/main.rs
use std::{
    fs,
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-5: ارسال محتوای hello.html به عنوان بدنه پاسخ

ما fs را به دستور use اضافه کرده‌ایم تا ماژول سیستم فایل کتابخانه استاندارد را وارد دامنه کنیم. کدی که محتوای یک فایل را به یک رشته می‌خواند باید آشنا به نظر برسد؛ ما در فصل ۱۲ زمانی که محتوای یک فایل را برای پروژه ورودی/خروجی خود خواندیم از آن استفاده کردیم (لیست ۱۲-۴).

سپس، از format! برای اضافه کردن محتوای فایل به عنوان بدنه پاسخ موفقیت استفاده می‌کنیم. برای اطمینان از یک پاسخ HTTP معتبر، هدر Content-Length را اضافه می‌کنیم که به اندازه بدنه پاسخ ما تنظیم شده است، که در این مورد اندازه فایل hello.html است.

این کد را با دستور cargo run اجرا کنید و آدرس 127.0.0.1:7878 را در مرورگر خود بارگذاری کنید؛ باید HTML خود را که به درستی رندر شده است ببینید!

در حال حاضر، ما داده‌های درخواست در http_request را نادیده می‌گیریم و فقط محتوای فایل HTML را بدون شرط بازمی‌گردانیم. این بدان معناست که اگر در مرورگر خود آدرس 127.0.0.1:7878/something-else را درخواست کنید، همچنان همین پاسخ HTML را دریافت خواهید کرد. در این لحظه، سرور ما بسیار محدود است و کارهایی که اکثر وب سرورها انجام می‌دهند را انجام نمی‌دهد. ما می‌خواهیم پاسخ‌های خود را بر اساس درخواست سفارشی کنیم و فقط فایل HTML را برای یک درخواست خوش‌ساخت به / بازگردانیم.

اعتبارسنجی درخواست و پاسخ‌دهی انتخابی

در حال حاضر، وب سرور ما محتوای فایل HTML را بدون توجه به درخواست client بازمی‌گرداند. بیایید عملکردی اضافه کنیم تا بررسی کند که مرورگر / را درخواست کرده باشد، سپس فایل HTML را بازگرداند و اگر مرورگر چیز دیگری درخواست کرد، یک خطا بازگرداند. برای این کار باید تابع handle_connection را همان‌طور که در لیست ۲۱-۶ نشان داده شده است تغییر دهیم. این کد جدید محتوای درخواست دریافتی را با آنچه که می‌دانیم یک درخواست برای / باید به نظر برسد مقایسه می‌کند و بلوک‌های if و else را اضافه می‌کند تا درخواست‌ها به صورت متفاوتی مدیریت شوند.

Filename: src/main.rs
use std::{
    fs,
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}
Listing 21-6: مدیریت درخواست‌های / به صورت متفاوت با سایر درخواست‌ها

ما فقط به خط اول درخواست HTTP نگاه خواهیم کرد، بنابراین به جای خواندن کل درخواست در یک بردار، از next استفاده می‌کنیم تا اولین آیتم از iterator را بگیریم. اولین unwrap مقدار Option را مدیریت می‌کند و اگر iterator هیچ آیتمی نداشته باشد برنامه را متوقف می‌کند. دومین unwrap مقدار Result را مدیریت می‌کند و همان اثری را دارد که unwrap در map اضافه‌شده در لیست ۲۱-۲ داشت.

سپس، مقدار request_line را بررسی می‌کنیم تا ببینیم آیا برابر با خط درخواست یک درخواست GET به مسیر / است یا خیر. اگر این‌طور باشد، بلوک if محتوای فایل HTML ما را بازمی‌گرداند.

اگر مقدار request_line برابر با درخواست GET به مسیر / نباشد، به این معنی است که یک درخواست دیگر دریافت کرده‌ایم. در بلوک else کدی اضافه خواهیم کرد تا به سایر درخواست‌ها پاسخ دهد.

این کد را اجرا کنید و آدرس 127.0.0.1:7878 را درخواست کنید؛ باید HTML موجود در فایل hello.html را دریافت کنید. اگر درخواست دیگری مانند 127.0.0.1:7878/something-else ارسال کنید، یک خطای اتصال مشابه آنچه در اجرای کدهای لیست ۲۱-۱ و ۲۱-۲ دیدید دریافت خواهید کرد.

حالا کد موجود در لیست ۲۱-۷ را به بلوک else اضافه کنید تا پاسخی با کد وضعیت 404 بازگرداند، که نشان می‌دهد محتوای درخواست‌شده پیدا نشد. همچنین مقداری HTML برای یک صفحه خطا بازمی‌گردانیم تا در مرورگر به کاربر نهایی نمایش داده شود.

Filename: src/main.rs
use std::{
    fs,
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}
Listing 21-7: پاسخ‌دهی با کد وضعیت 404 و یک صفحه خطا اگر چیزی به غیر از / درخواست شود

در اینجا، پاسخ ما یک خط وضعیت با کد وضعیت 404 و عبارت دلیل NOT FOUND دارد. بدنه پاسخ HTML موجود در فایل 404.html خواهد بود. باید فایل 404.html را در کنار فایل hello.html برای صفحه خطا ایجاد کنید؛ باز هم، می‌توانید هر HTML که می‌خواهید استفاده کنید یا از HTML نمونه موجود در لیست ۲۱-۸ استفاده کنید.

Filename: 404.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>
Listing 21-8: محتوای نمونه برای صفحه‌ای که با هر پاسخ 404 بازگردانده می‌شود

با این تغییرات، سرور خود را دوباره اجرا کنید. درخواست آدرس 127.0.0.1:7878 باید محتوای فایل hello.html را بازگرداند، و هر درخواست دیگری مانند 127.0.0.1:7878/foo باید HTML خطا از فایل 404.html را بازگرداند.

کمی بازسازی (Refactoring)

در حال حاضر، بلوک‌های if و else مقدار زیادی تکرار دارند: هر دو فایل‌ها را می‌خوانند و محتوای فایل‌ها را به جریان می‌نویسند. تنها تفاوت‌ها خط وضعیت و نام فایل هستند. بیایید کد را مختصرتر کنیم و این تفاوت‌ها را به خطوط جداگانه if و else انتقال دهیم که مقادیر خط وضعیت و نام فایل را به متغیرها اختصاص دهند؛ سپس می‌توانیم از این متغیرها به طور شرطی برای خواندن فایل و نوشتن پاسخ استفاده کنیم. لیست ۲۱-۹ کد نتیجه‌شده پس از جایگزینی بلوک‌های بزرگ if و else را نشان می‌دهد.

Filename: src/main.rs
use std::{
    fs,
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-9: بازسازی بلوک‌های if و else برای شامل شدن تنها کدی که بین دو حالت متفاوت است

اکنون بلوک‌های if و else تنها مقادیر مناسب برای خط وضعیت و نام فایل را در یک تاپل بازمی‌گردانند؛ سپس با استفاده از یک الگو در دستور let، این دو مقدار به status_line و filename تخصیص داده می‌شوند، همان‌طور که در فصل ۱۹ بحث شد.

کدی که قبلاً تکراری بود اکنون خارج از بلوک‌های if و else قرار دارد و از متغیرهای status_line و filename استفاده می‌کند. این کار تشخیص تفاوت بین دو حالت را آسان‌تر می‌کند و به این معنی است که اگر بخواهیم نحوه خواندن فایل و نوشتن پاسخ را تغییر دهیم، تنها یک مکان برای به‌روزرسانی کد داریم. رفتار کد در لیست ۲۱-۹ با لیست ۲۱-۷ یکسان خواهد بود.

عالی! اکنون یک وب سرور ساده با تقریباً ۴۰ خط کد Rust داریم که به یک درخواست با یک صفحه محتوا پاسخ می‌دهد و به تمام درخواست‌های دیگر یک پاسخ 404 می‌دهد.

در حال حاضر، سرور ما در یک Thread اجرا می‌شود، به این معنی که تنها می‌تواند یک درخواست را در یک زمان سرویس دهد. بیایید بررسی کنیم که چگونه این موضوع می‌تواند مشکل‌ساز شود، با شبیه‌سازی برخی درخواست‌های کند. سپس سرور را طوری بهبود می‌دهیم که بتواند چندین درخواست را همزمان مدیریت کند.