Do it yourself Webserver (Teil 1)



Ein Webserver ist ein einfaches Stück Software. Ich möchte im folgenden
zeigen, wie man einen solchen Server Schritt für Schritt bauen kann.

HTTP, das "Webprotokol", basiert auf Sockets und TCP/IP. Sockets sind
die Endpunkte einer TCP/IP-Verbindung, dem Standardkommunikations-
protokoll des Internets, assoziiert mit einer IP-Adresse und einem
bestimmten Port. Meist laufen HTTP-Server auf Port 80, doch jeder freie
Port funktioniert.

Unser WebServer-Exemplar horcht auf einem ServerSocket (für alle
IP-Adressen des Rechners, meist ist das nur eine) und startet für jede
Anfrage einen neuen Handler-Thread:

public class WebServer {
public WebServer(int port) throws IOException {
ServerSocket ss = new ServerSocket(port);
try {
while (!Thread.interrupted()) {
new Thread(new Handler(ss.accept())).start();
}
} finally {
ss.close();
}
}
...
}

Wir wollen ihn auf Port 3000 starten:

public class WebServer...
public static void main(String... args) throws IOException {
new WebServer(3000);
}
}

Ein Socket ist eine bidirektionale Verbindung und man kann die vom
Client gesendeten Daten aus einem InputStream lesen und dem Client neue
Daten über einen OutputStream schicken. Genau dies wird unser Handler
mit Hilfe von zwei weiteren Objekten machen, die die Anfrage des Clients
und die Antwort des Servers repräsentieren.

Ein WebRequest kann eine Anfrage gemäß der HTTP 1.0-Spezifikation (ohne
diverse Spezialfälle zu berücksichtigen, um die sich ein echter Server
kümmern müsste) verstehen. Eine WebResponse kann eine Antwort gemäß der
selben Spezifikation erzeugen.

public class Handler implements Runnable {
private final Socket socket;

public Handler(Socket socket) {
this.socket = socket;
}

public void run() {
try {
handle(
new WebRequest(socket.getInputStream()),
new WebResponse(socket.getOutputStream()));
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
...
}

Das klassische HTTP kennt nur eine Anfrage und eine Antwort und beendet
danach die Verbindung zwischen Client und Server. So wollen wir auch
vorgehen und schließen daher das Socket sofort wieder. HTTP in der
Version 1.1 erlaubt, das Verbindungen aufrecht erhalten werden, was
deutlich effizienter (aber auch schwerer zu implementieren) ist.

Wir wollen mit dieser beispielhaften Implementierung von handle() beginnen:

public class Handler...
protected void handle(WebRequest request, WebResponse response)
throws IOException {
response.print("Hallo, Welt");
response.close();
}
}

Fehlen noch die Definitionen für die beiden Objekte, die die eigentliche
Arbeit verrichten. Beginnen wir mit dem WebRequest. Ein HTTP 1.0
Request (es gibt tatsächlich noch HTTP/0.9, was noch primitiver ist aber
keine Rolle mehr spielt) gemäß Spezifikation sieht ungefähr so aus:

GET /foo/bar?a=b HTTP/1.0<\r\n>
Host: foobar.com<\r\n>
User-Agent: Mozilla/5.0 (Windows; ...)<\r\n>
<\r\n>

HTTP ist textbasiert und nutzt ISO-8859-1 als Encoding. Zeilenenden
werden durch \r\n (also carriage return und line feed, wie bei Windows)
markiert.

Die erste Zeile besteht aus drei durch ein Leerzeichen getrennten
Angaben: Der Methode, der URL und dem Protokoll. Danach folgen beliebig
viele Header-Zeilen, Namen und Werte durch einen ":" getrennt. Eine
Leerzeile beendet den Headerbereich und es folgt ein optionaler Body.
Es muss einen Header "Content-Length" geben, der die Länge des Body
angibt, wenn es einen Body gibt, doch das soll uns erstmal egal sein.

public class WebRequest {
private final String method;
private final String url;
private final String protocol;
private final Map<String, String> headers;
private final Map<String, String> parameters;

public WebRequest(InputStream stream) throws IOException {
String[] firstLine = readLine(stream).split(" ");
method = firstLine[0];
url = firstLine[1];
protocol = firstLine[2];

headers = new HashMap<String, String>();
String headerLine;
while ((headerLine = readLine(stream)).length() != 0) {
int index = headerLine.indexOf(':');
if (index != -1) {
headerLine.put(
headerLine.substring(0, index).toLowerCase(),
headerLine.substring(index + 1).trim());
}
}
}
...
private String readLine(InputStream stream) throws IOException {
StringBuilder b = new StringBuilder(256);
int c;
while ((c = stream.read()) != -1 && c != 13 && c != 10) {
b.append((char) c);
}
if (c == 13) {
c = stream.read();
}
return b.toString();
}
}

Wir sind ein bisschen großzügig, was das Zeilende des Requests angeht.

Für alle eingelesenen Parameter gibt es Getter, die ich hier nicht
zeige. Zu beachten ist, dass es bei den Header-Namen nicht auf Groß-
oder Kleinschreib ankommt:

public class WebRequest...
public String getHeader(String name) {
return headers.get(name.toLowerCase());
}

Wie muss jetzt die Antwort aussehen? Dies ist eine minimale
standardkonforme HTTP Response:

HTTP/1.0 200 OK<\r\n>
Server: SMA/1.0<\r\n>
Content-Type: text/plain<\r\n>
<\r\n>
Hallo, Welt

Auch die Antwort ist textbasiert mit durch \r\n markierten Zeilenenden
und sofern nicht anders definiert, ISO-8859-1-kodiert. Die erste Zeile
besteht aus drei durch ein Leerzeichen getrennten Angaben: Dem
Protokoll, dem Status und einer Beschreibung des Status. Danach folgen
wieder Header-Zeilen bis zu einer Leerzeile und danach der Body, in
unserem Fall der Text "Hallo, Welt".

Eigentlich wäre es nett von uns, auch noch die Länge des Body über einen
"Content-Length"-Header anzugeben, aber das ist nicht zwingend
erforderlich. Soll der Browser doch die Arbeit haben.

public class WebResponse {
private final OutputStream stream;
private final Map<String, String> headers;
private final String protocol = "HTTP/1.0";
private String status = "200 OK";
private boolean committed;

public WebResponse(OutputStream stream) {
this.stream = stream;
headers = new HashMap<String, String>();
headers.put("Server", "SMA/1.0");
headers.put("Date", new Date().toString());
headers.put("Content-Type", "text/plain");
headers.put("Connection", "Close");
}

...

public void print(String str) throws IOException {
comitHeaders();
stream.write(asBytes(str));
}

public void close() throws IOException {
comitHeaders();
stream.close();
}

private void comitHeaders() throws IOException {
if (committed) {
return;
}
stream.write(asBytes(protocol));
stream.write(' ');
stream.write(asBytes(status));
stream.write('\r');
stream.write('\n');
for (Map.Entry<String, String> header : headers.entrySet()) {
stream.write(asBytes(header.getKey()));
stream.write(':');
stream.write(' ');
stream.write(asBytes(header.getValue()));
stream.write('\r');
stream.write('\n');
}
stream.write('\r');
stream.write('\n');
committed = true;
}

private byte[] asBytes(String str)
throws UnsupportedEncodingException {
return str.getBytes("ISO-8859-1");
}
}

Solange noch kein Byte für den Body geschrieben wurde, dürfen Header
(und Status) verändert werden. Danach gilt die WebResponse als
"committed" und nichts geht mehr. Am besten, man wirft einen
Laufzeitfehler, wenn danach noch versucht wird, einen Header zu setzen.

Getter und Setter für die Parameter sind entsprechend zu ergänzen, auch
hier ist bei Header-Namen die Groß- und Kleinschreibung egal.

Starten wir unseren Server. Wird nun im Browser "http://localhost:3000/";
aufgerufen, sollte "Hallo Welt" als Antwort zu sein sein. Der Webserver
ist fertig.

Naja, fast. Weiter geht's im nächsten Teil.

--
Stefan Matthias Aust
.