Einen single-threaded Webserver erstellen
Wir beginnen damit, einen single-threaded Webserver zum Laufen zu bringen. Bevor wir beginnen, wollen wir uns einen kurzen Überblick über die Protokolle verschaffen, die beim Aufbau von Webservern eine Rolle spielen. Die Einzelheiten dieser Protokolle sprengen den Rahmen dieses Buches, aber ein kurzer Überblick wird dir die Informationen geben, die du benötigst.
Die beiden wichtigsten Protokolle, die bei Webservern zum Einsatz kommen, sind das Hypertext-Übertragungsprotokoll (Hypertext Transfer Protocol, kurz HTTP) und das Übertragungssteuerungsprotokoll (Transmission Control Protocol, kurz TCP). Beide Protokolle sind Anfrage-Antwort-Protokolle, d.h. ein Client sendet Anfragen und ein Server nimmt Anfragen entgegen und gibt eine Antwort an den Client zurück. Der Inhalt dieser Anfragen und Antworten wird durch die Protokolle definiert.
TCP ist ein Basisprotokoll, das im Detail beschreibt, wie Informationen von einem Rechner zu einem anderen gelangen, aber nicht spezifiziert, um welche Informationen es sich dabei handelt. HTTP baut auf TCP auf, indem es den Inhalt der Anfragen und Antworten definiert. Es ist technisch möglich, HTTP mit anderen Protokollen zu verwenden, aber in den allermeisten Fällen sendet HTTP seine Daten über TCP. Wir werden mit den Roh-Bytes von TCP- und HTTP-Anfragen und -Antworten arbeiten.
Lauschen auf eine TCP-Verbindung
Unser Webserver muss auf eine TCP-Verbindung lauschen (listen), also ist das
der erste Teil, an dem wir arbeiten werden. Die Standardbibliothek bietet ein
Modul std::net an, mit dem wir dies tun können. Lass uns ein neues Projekt
auf die übliche Art und Weise erstellen:
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
Gib nun den Code in Listing 21-1 in src/main.rs ein, um zu beginnen. Dieser
Code lauscht unter der lokalen Adresse 127.0.0.1:7878 auf eingehende
TCP-Anfragen. Wenn er eine eingehende Anfrage erhält, wird er Verbindung hergestellt! ausgeben.
Dateiname: 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!("Verbindung hergestellt!");
}
}
Listing 21-1: Warten auf eingehende Anfragen und Ausgeben einer Nachricht, wenn wir eine Anfrage empfangen
Mit TcpListener können wir unter der Adresse 127.0.0.1:7878 auf
TCP-Verbindungen warten. In der Adresse ist der Abschnitt vor dem Doppelpunkt
eine IP-Adresse, die deinen Computer repräsentiert (dies ist auf jedem Computer
gleich und gilt nicht nur speziell für den Computer der Autoren), und 7878
ist der Port. Wir haben diesen Port aus zwei Gründen gewählt: HTTP wird auf
diesem Port normalerweise nicht akzeptiert, sodass unser Server wahrscheinlich
nicht mit anderen Webservern in Konflikt geraten wird, die du auf deinem
Rechner hast, und 7878 steht für rust, wenn du es auf einem Telefon tippst.
Die Funktion bind in diesem Szenario arbeitet wie die Funktion new, indem
sie eine neue TcpListener-Instanz zurückgibt. Die Funktion wird bind
genannt, weil in Netzwerken das Verbinden mit einem Port zum Lauschen als
„Binden (binding) an einen Port“ bezeichnet wird.
Die Funktion bind gibt ein Result<T, E> zurück, was anzeigt, dass es
möglich ist, dass das Binden fehlschlagen könnte, wie etwa wenn wir zwei
Instanzen unseres Programms laufen lassen und somit zwei Programme auf
demselben Port lauschen. Da wir einen einfachen Server nur für Lernzwecke
schreiben, werden wir uns nicht um die Behandlung dieser Art von Fehlern
kümmern; stattdessen verwenden wir unwrap, um das Programm zu stoppen, wenn
Fehler auftreten.
Die Methode incoming von TcpListener gibt einen Iterator zurück, der uns
eine Sequenz von Streams (genauer gesagt Streams vom Typ TcpStream) liefert.
Ein einzelner Stream stellt eine offene Verbindung zwischen dem Client und dem
Server dar. Eine Verbindung (connection) ist der Name für den vollständigen
Anfrage- und Antwortprozess, bei dem sich ein Client mit dem Server verbindet,
der Server eine Antwort erzeugt und der Server die Verbindung schließt. Daher
werden wir aus dem TcpStream lesen, um zu sehen, was der Client gesendet hat,
und dann unsere Antwort in den Stream schreiben, um Daten zurück an den Client
zu senden. Insgesamt wird diese for-Schleife jede Verbindung der Reihe nach
verarbeiten und eine Reihe von Streams erzeugen, die wir verarbeiten müssen.
Im Moment besteht unsere Behandlung des Streams darin, dass wir unwrap
aufrufen, um unser Programm abzubrechen, wenn der Stream Fehler aufweist; wenn
keine Fehler vorliegen, gibt das Programm eine Nachricht aus. Wir werden im
nächsten Listing mehr Funktionalität für den Erfolgsfall hinzufügen. Der Grund,
warum wir Fehler von der Methode incoming erhalten könnten, wenn sich ein
Client mit dem Server verbindet, ist, dass wir nicht wirklich über Verbindungen
iterieren. Stattdessen iterieren wir über Verbindungsversuche. Die Verbindung
kann aus einer Reihe von Gründen nicht erfolgreich sein, viele davon sind
betriebssystemspezifisch. Zum Beispiel haben viele Betriebssysteme ein Limit für
die Anzahl der gleichzeitig offenen Verbindungen, die sie unterstützen können;
neue Verbindungsversuche über diese Anzahl hinaus führen zu einem Fehler, bis
einige der offenen Verbindungen geschlossen werden.
Lass uns versuchen, diesen Code auszuführen! Rufe cargo run im Terminal auf
und öffne dann 127.0.0.1:7878 in einem Web-Browser. Der Browser sollte eine
Fehlermeldung wie „Verbindung abgebrochen“ anzeigen, da der Server derzeit
keine Daten zurücksendet. Aber wenn du auf dein Terminal siehst, solltest du
mehrere Meldungen sehen, die ausgegeben wurden, als der Browser eine Verbindung
mit dem Server herstellte!
Running `target/debug/hello`
Verbindung hergestellt!
Verbindung hergestellt!
Verbindung hergestellt!
Manchmal werden mehrere Nachrichten für eine Browser-Anfrage ausgegeben; der Grund dafür könnte sein, dass der Browser sowohl eine Anfrage für die Seite als auch eine Anfrage für andere Ressourcen stellt, z.B. das Symbol favicon.ico, das in der Browser-Registerkarte erscheint.
Es könnte auch sein, dass der Browser mehrmals versucht, eine Verbindung mit dem
Server herzustellen, weil der Server nicht mit Daten antwortet. Wenn stream
den Gültigkeitsbereich verlässt und am Ende der Schleife aufgeräumt wird, wird
die Verbindung als Teil der drop-Implementierung geschlossen. Browser
reagieren auf geschlossene Verbindungen manchmal damit, diese erneut aufzubauen
und es erneut zu versuchen, weil das Problem möglicherweise nur vorübergehend
besteht.
Browser öffnen manchmal auch mehrere Verbindungen zum Server, ohne Anfragen zu senden, damit spätere Anfragen schneller bearbeitet werden können. In diesem Fall sieht unser Server alle Verbindungen, unabhängig davon, ob über diese Verbindung Anfragen gesendet werden. Viele Chrome-basierte Browser verhalten sich beispielsweise so. Du kannst diese Optimierung deaktivieren, indem du den privaten Modus verwendest oder einen anderen Browser nutzt.
Der wichtige Punkt ist, dass wir erfolgreich eine TCP-Verbindung hergestellt haben!
Denke daran, das Programm durch Drücken von Strg+c
abzubrechen, wenn du mit der Ausführung einer bestimmten Version des Codes
fertig bist. Starte dann das Programm neu, indem du den Befehl cargo run
aufrufst, nachdem du die einzelnen Codeänderungen vorgenommen hast, um
sicherzustellen, dass du den neuesten Code ausführst.
Lesen der Anfrage
Lass uns die Funktionalität zum Lesen der Anfrage vom Browser implementieren! Um
die Zuständigkeiten zu trennen, also zuerst eine Verbindung entgegenzunehmen und
dann mit der Verbindung etwas zu machen, werden wir eine neue Funktion zur
Verarbeitung von Verbindungen anfangen. In dieser neuen Funktion
handle_connection lesen wir Daten aus dem TCP-Stream und geben sie aus, sodass
wir sehen können, welche Daten vom Browser gesendet werden. Ändere den Code so,
dass er wie Listing 21-2 aussieht.
Dateiname: 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(&mut 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: Lesen aus dem TcpStream und Ausgeben
der Daten
Wir bringen std::io::BufReader und std::io::prelude in den
Gültigkeitsbereich, um Zugang zu Traits und Typen zu erhalten, die es uns
ermöglichen, aus dem Stream zu lesen und in den Stream zu schreiben. In der
for-Schleife in der Funktion main rufen wir jetzt, statt eine Nachricht
auszugeben, dass wir eine Verbindung hergestellt haben, die neue Funktion
handle_connection auf und übergeben ihr den stream.
In der Funktion handle_connection erstellen wir eine neue BufReader-Instanz,
die eine Referenz auf den stream enthält. BufReader sorgt für die Pufferung,
indem es die Aufrufe der Trait-Methoden von std::io::Read für uns verwaltet.
Wir erstellen eine Variable namens http_request, um die Zeilen der Anfrage
aufzusammeln, die der Browser an unseren Server sendet. Wir geben an, dass wir
diese Zeilen in einem Vektor sammeln wollen, indem wir die Typ-Annotation
Vec<_> hinzufügen.
BufReader implementiert das Trait std::io::BufRead, das die Methode lines
bereitstellt. Die Methode lines gibt einen Iterator von Result<String, std::io::Error> zurück, indem sie den Datenstrom immer dann aufteilt, wenn sie
ein Neue-Zeile-Byte sieht. Um jeden String zu erhalten, wird jedes Result
mit map abgebildet und unwrap aufgerufen. Das Result könnte einen Fehler
darstellen, wenn die Daten kein gültiges UTF-8 sind oder wenn es ein Problem
beim Lesen aus dem Stream gab. Auch hier sollte ein Produktivprogramm diese
Fehler besser behandeln, aber der Einfachheit halber brechen wir das Programm im
Fehlerfall ab.
Der Browser signalisiert das Ende eines HTTP-Headers, indem er zwei Zeilenumbrüche hintereinander sendet. Um also eine Anfrage aus dem Stream zu erhalten, nehmen wir so lange Zeilen an, bis wir eine leere Zeile erhalten. Sobald wir die Zeilen im Vektor gesammelt haben, geben wir sie mit einer hübschen Debug-Formatierung aus, damit wir einen Blick auf die Anweisungen werfen können, die der Webbrowser an unseren Server sendet.
Lass uns diesen Code ausprobieren! Starte das Programm und stelle erneut eine Anfrage in einem Webbrowser. Beachte, dass wir immer noch eine Fehlerseite im Browser erhalten, aber die Ausgabe unseres Programms im Terminal wird nun ähnlich aussehen:
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished `dev` profile [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",
]
Je nach Browser erhältst du möglicherweise eine etwas andere Ausgabe. Jetzt, wo
wir die Anfragedaten ausgeben, können wir sehen, warum wir mehrere Verbindungen
von einer Browser-Anfrage erhalten, wenn wir uns den Pfad nach GET in der
ersten Zeile der Anfrage ansehen. Wenn die wiederholten Verbindungen alle /
anfordern, wissen wir, dass der Browser wiederholt versucht, / abzurufen,
weil er keine Antwort von unserem Programm erhält.
Lass uns diese Anfragedaten aufschlüsseln, um zu verstehen, was der Browser von unserem Programm will.
Ein genauerer Blick auf eine HTTP-Anfrage
HTTP ist ein textbasiertes Protokoll und eine Anfrage hat dieses Format:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
Die erste Zeile ist die Anfragezeile (request line), die Informationen darüber
enthält, was der Client anfragt. Der erste Teil der Anfragezeile gibt die
Methode an, die verwendet wird, z.B. GET oder POST, die beschreibt, wie der
Client diese Anfrage stellt. Unser Client hat eine GET-Anfrage verwendet, was
bedeutet, dass er nach Informationen fragt.
Der nächste Teil der Anfragezeile ist /, der den einheitlichen
Ressourcenbezeichner (Uniform Resource Identifier, kurz URI) angibt, den der
Client anfragt: Ein URI ist fast, aber nicht ganz dasselbe wie ein
einheitlicher Ressourcenzeiger (Uniform Resource Locator, kurz URL). Der
Unterschied zwischen URIs und URLs ist für unsere Zwecke in diesem Kapitel
nicht wichtig, aber die HTTP-Spezifikation verwendet den Begriff URI, sodass
wir hier einfach gedanklich URL durch URI ersetzen können.
Der letzte Teil ist die HTTP-Version, die der Client verwendet, und dann endet
die Anfragezeile mit einer CRLF-Sequenz. (CRLF steht für carriage return
(Wagenrücklauf) und line feed (Zeilenvorschub), das sind Begriffe aus der
Schreibmaschinenzeit!) Die CRLF-Sequenz kann auch als \r\n geschrieben
werden, wobei \r ein Wagenrücklauf und \n ein Zeilenvorschub ist. Die
CRLF-Sequenz trennt die Anfragezeile von den restlichen Anfragedaten. Beachte,
dass wir beim Ausgeben von CRLF eine neue Zeile sehen und nicht \r\n.
Wenn wir uns die Daten der Anfragezeile ansehen, die wir bisher beim Ausführen
unseres Programms erhalten haben, sehen wir, dass GET die Methode, / die
Anfrage-URI und HTTP/1.1 die Version ist.
Nach der Anfragezeile sind die restlichen Zeilen ab Host: Kopfzeilen.
GET-Anfragen haben keinen Rumpf (body).
Versuche, eine Anfrage von einem anderen Browser aus zu stellen oder nach einer anderen Adresse zu fragen, z.B. 127.0.0.1:7878/test, um zu sehen, wie sich die Anfragedaten ändern.
Jetzt, da wir wissen, was der Browser anfragt, schicken wir ein paar Daten zurück!
Schreiben einer Antwort
Wir implementieren das Senden von Daten als Antwort auf eine Clientanfrage. Die Antworten haben das folgende Format:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
Die erste Zeile ist eine Statuszeile, die die in der Antwort verwendete HTTP-Version, einen numerischen Statuscode, der das Ergebnis der Anfrage zusammenfasst, und eine Begründungsphrase, die eine Textbeschreibung des Statuscodes liefert, enthält. Nach der CRLF-Sequenz folgen beliebige Kopfzeilen, eine weitere CRLF-Sequenz und der Rumpf der Antwort.
Hier ist eine Beispielantwort, die HTTP-Version 1.1 verwendet, den Statuscode 200, eine OK-Begründungsphrase, keine Kopfzeilen und keinen Rumpf hat:
HTTP/1.1 200 OK\r\n\r\n
Der Statuscode 200 ist die Standard-Erfolgsantwort. Der Text ist eine winzige
erfolgreiche HTTP-Antwort. Lass uns dies als Antwort auf eine erfolgreiche
Anfrage in den Stream schreiben! Entferne aus der Funktion handle_connection
das println!, das die Anfragedaten ausgegeben hat, und ersetze es durch den
Code in Listing 21-3.
Dateiname: 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(&mut 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: Schreiben einer kleinen erfolgreichen HTTP-Antwort in den Stream
Die erste neue Zeile definiert die Variable response, die die Daten der
Erfolgsmeldung enthält. Dann rufen wir as_bytes auf unserer response auf, um
die String-Daten in Bytes zu konvertieren. Die Methode write_all auf stream
nimmt ein &[u8] und sendet diese Bytes direkt in die Verbindung. Da die
Operation write_all fehlschlagen könnte, verwenden wir wie bisher bei jedem
Fehlerergebnis unwrap. Auch hier würdest du in einer echten Anwendung eine
Fehlerbehandlung hinzufügen.
Lass uns mit diesen Änderungen unseren Code ausführen und eine Anfrage stellen. Wir geben keine Daten mehr im Terminal aus, sodass wir außer der Ausgabe von Cargo keine weiteren Ausgaben sehen werden. Wenn du 127.0.0.1:7878 in einem Webbrowser lädst, solltest du statt eines Fehlers eine leere Seite sehen. Du hast soeben das Empfangen einer HTTP-Anfrage und das Senden einer Antwort von Hand programmiert!
Echtes HTML zurückgeben
Lass uns die Funktionalität für die Rückgabe von mehr als einer leeren Seite implementieren. Erstelle die neue Datei hello.html in der Wurzel deines Projektverzeichnisses, nicht im Verzeichnis src. Du kannst beliebiges HTML eingeben, das du willst; Listing 21-4 zeigt eine Möglichkeit.
Dateiname: hello.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Hallo!</title>
</head>
<body>
<h1>Hallo!</h1>
<p>Hallo von Rust</p>
</body>
</html>
Listing 21-4: Eine Beispiel-HTML-Datei, die in einer Antwort zurückgegeben werden soll
Dies ist ein minimales HTML5-Dokument mit einer Überschrift und etwas Text. Um
dies vom Server zurückzugeben, wenn eine Anfrage empfangen wird, modifizieren
wir handle_connection wie in Listing 21-5 gezeigt, um die HTML-Datei zu
lesen, sie der Antwort als Rumpf hinzuzufügen und sie zu senden.
Dateiname: src/main.rs
use std::{
fs,
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
};
// --abschneiden--
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(&mut 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: Senden des Inhalts von hello.html als Rumpf der Antwort
Wir haben fs zur use-Deklaration hinzugefügt, um das Dateisystemmodul der
Standardbibliothek in den Gültigkeitsbereich zu bringen. Der Code zum Lesen des
Inhalts einer Datei in einen String sollte vertraut aussehen; wir haben ihn
verwendet, als wir den Inhalt einer Datei für unser E/A-Projekt in Listing 12-4
gelesen haben.
Als Nächstes verwenden wir format!, um den Inhalt der Datei als Rumpf der
Erfolgsantwort hinzuzufügen. Um eine gültige HTTP-Antwort zu formulieren, fügen
wir den Header Content-Length hinzu, der auf die Größe unseres Antwortrumpfs
gesetzt wird, in diesem Fall auf die Größe von hello.html.
Führe diesen Code mit cargo run aus und lade 127.0.0.1:7878 im Browser; du
solltest dein HTML gerendert sehen!
Gegenwärtig ignorieren wir die Anfragedaten in http_request und senden
einfach den Inhalt der HTML-Datei bedingungslos zurück. Das heißt, wenn du
versuchst, 127.0.0.1:7878/something-else in deinem Browser anzufragen,
erhältst du immer noch dieselbe HTML-Antwort zurück. Unser Server ist im Moment
sehr begrenzt und macht nicht das, was die meisten Webserver tun. Wir wollen
unsere Antworten je nach Anfrage anpassen und nur die HTML-Datei für eine
wohlgeformte Anfrage an / zurücksenden.
Validieren der Anfrage und selektives Beantworten
Im Moment wird unser Webserver das HTML in der Datei zurückgeben, unabhängig
davon, was der Client angefragt hat. Fügen wir Funktionen hinzu, um zu
überprüfen, ob der Browser / anfragt, bevor er die HTML-Datei zurückgibt, und
um einen Fehler zurückzugeben, wenn der Browser etwas anderes anfragt. Dazu
müssen wir handle_connection modifizieren, wie in Listing 21-6 gezeigt.
Dieser neue Code prüft den Inhalt der erhaltenen Anfrage, ob / angefragt
wird, und fügt if- und else-Blöcke hinzu, um die Anfragen unterschiedlich
zu behandeln.
Dateiname: 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);
}
}
// --abschneiden--
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&mut 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 {
// eine andere Anfrage
}
}
Listing 21-6: Behandlung von Anfragen an / anders als
andere Anfragen
Wir werden uns nur die erste Zeile der HTTP-Anfrage ansehen. Anstatt also die
gesamte Anfrage in einen Vektor zu lesen, rufen wir next auf, um das erste
Element aus dem Iterator zu erhalten. Das erste unwrap kümmert sich um die
Option und hält das Programm an, wenn der Iterator keine Elemente hat. Das
zweite unwrap behandelt das Result und hat den gleichen Effekt wie das
unwrap in map in Listing 21-2.
Als nächstes überprüfen wir request_line, um zu sehen, ob es der Anfragezeile
einer GET-Anfrage mit dem Pfad / entspricht. Ist dies der Fall, gibt der
if-Block den Inhalt unserer HTML-Datei zurück.
Wenn request_line nicht der GET-Anfrage mit dem Pfad / entspricht,
bedeutet das, dass wir eine andere Anfrage erhalten haben. Wir werden dem
else-Block gleich Code hinzufügen, um auf alle anderen Anfragen zu reagieren.
Führe diesen Code jetzt aus und frage 127.0.0.1:7878 an; du solltest das HTML in hello.html erhalten. Wenn du eine andere Anfrage stellst, z.B. 127.0.0.1:7878/something-else, erhältst du einen Verbindungsfehler, wie du ihn beim Ausführen des Codes in Listing 21-1 und Listing 21-2 gesehen hast.
Fügen wir nun den Code in Listing 21-7 in den else-Block ein, um eine
Antwort mit dem Statuscode 404 zurückzugeben, der signalisiert, dass der Inhalt
für die Anfrage nicht gefunden wurde. Wir geben auch etwas HTML für eine Seite
zurück, die im Browser dargestellt werden soll, um dem Endbenutzer die Antwort
anzuzeigen.
Dateiname: 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(&mut 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();
// --abschneiden--
} 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: Antworten mit Statuscode 404 und einer
Fehlerseite, wenn etwas anderes als / angefragt wurde
Hier hat unsere Antwort eine Statuszeile mit Statuscode 404 und der
Begründungsphrase NOT FOUND (nicht gefunden). Der Rumpf der Antwort wird das
HTML in der Datei 404.html sein. Du musst neben hello.html eine Datei
404.html für die Fehlerseite erstellen; auch hier kannst du jedes beliebige
HTML verwenden oder das Beispiel-HTML in Listing 21-8.
Dateiname: 404.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Hallo!</title>
</head>
<body>
<h1>Ups!</h1>
<p>Entschuldige, ich weiß nicht wonach du gefragt hast.</p>
</body>
</html>
Listing 21-8: Beispielinhalt für die Seite, die mit jeder 404-Antwort zurückgesendet werden soll
Lass deinen Server mit diesen Änderungen erneut laufen. Die Anfrage 127.0.0.1:7878 sollte den Inhalt von hello.html zurückgeben und jede andere Anfrage, wie 127.0.0.1:7878/foo, sollte das Fehler-HTML von 404.html zurückgeben.
Refactoring
Im Moment haben die if- und else-Blöcke eine Menge Wiederholungen: Sie lesen
beide Dateien und schreiben den Inhalt der Dateien in den Stream. Die einzigen
Unterschiede sind die Statuszeile und der Dateiname. Lass uns den Code
prägnanter gestalten, indem wir diese Unterschiede in separate if- und
else-Zeilen herausziehen, die die Werte der Statuszeile und des Dateinamens
Variablen zuweisen; wir können diese Variablen dann bedingungslos im Code
verwenden, um die Datei zu lesen und die Antwort zu schreiben. Listing 21-9
zeigt den resultierenden Code nach dem Ersetzen der großen if- und
else-Blöcke.
Dateiname: 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);
}
}
// --abschneiden--
fn handle_connection(mut stream: TcpStream) {
// --abschneiden--
let buf_reader = BufReader::new(&mut 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: Refactoring der if- und else-Blöcke,
damit sie nur den Code enthalten, der sich zwischen den beiden Fällen
unterscheidet
Die Blöcke if und else geben jetzt nur noch die entsprechenden Werte für
die Statuszeile und den Dateinamen in einem Tupel zurück; wir verwenden dann
die Destrukturierung, um diese beiden Werte den Variablen status_line und
filename zuzuweisen, unter Verwendung eines Musters in der let-Anweisung,
wie in Kapitel 18 besprochen.
Der zuvor duplizierte Code befindet sich jetzt außerhalb der Blöcke if und
else und verwendet die Variablen status_line und filename. Dies macht es
einfacher, den Unterschied zwischen den beiden Fällen zu erkennen, und es
bedeutet, dass wir nur einen Ort haben, an dem wir den Code aktualisieren
müssen, wenn wir ändern wollen, wie das Lesen der Datei und das Schreiben der
Antwort funktionieren. Das Verhalten des Codes in Listing 21-9 ist dasselbe
wie in Listing 21-7.
Fantastisch! Wir haben jetzt einen einfachen Webserver mit etwa 40 Zeilen Rust-Code, der auf eine Anfrage mit einer Inhaltsseite antwortet und auf alle anderen Anfragen mit einer 404-Antwort.
Derzeit läuft unser Server in einem einzigen Thread, d.h. er kann immer nur eine Anfrage gleichzeitig bedienen. Lass uns untersuchen, warum das ein Problem sein kann, indem wir einige langsame Anfragen simulieren. Dann werden wir es beheben, indem unser Server mehrere Anfragen auf einmal bearbeiten kann.