Projekt (Ruby)2 DoppelstundenIm Handbuch: HTTP
image: teaser.webp kind: projekt language: Ruby topic: networks duration: 2 Doppelstunden requires: [tcpip] reference: [http]

Ein eigener Webserver

Jedes Mal, wenn du eine Webseite öffnest, schickt dein Browser eine Anfrage an einen Webserver, und der schickt die Seite zurück. In dieser Aktivität schreibst du so einen Server selbst, in Ruby und ohne fertige Bibliothek: Er wartet auf Verbindungen, liest die Anfrage, antwortet, liefert HTML-Seiten aus, zählt seine Besuche und nimmt Stimmen aus einem Formular an. Am Ende teilst du ihn mit deiner Klasse.

Auf der Seite TCP/IP hast du mit nc -l an einem Port auf eine Verbindung gewartet und mit nc -C info.cern.ch 80 eine HTTP-Anfrage von Hand getippt. Ein Webserver ist die andere Seite davon: Er wartet an einem Port auf eine TCP-Verbindung, liest Text und schickt Text zurück. Damit Browser und Server sich verstehen, folgt der Text einer festen Form, dem HTTP-Protokoll. Der Live Server, mit dem du sonst Webseiten ansiehst, ist genau so ein Programm. Hier baust du ihn selbst und siehst, was er tut.

1. Einrichten

Lege im Terminal (StrgJ) einen Ordner an und öffne ihn mit StrgK StrgO:

mkdir /workspace/webserver

Lege darin die Datei server.rb an.

2. Ein Server, der zuhört

Schreibe in server.rb:

require 'socket'

server = TCPServer.new(8000)
puts "Der Server wartet auf Port 8000 ..."

loop do
  client = server.accept          # wartet, bis jemand eine Verbindung aufbaut
  puts "--- Eine neue Verbindung"
  # Die Anfrage zeilenweise lesen, bis zur ersten leeren Zeile
  while (line = client.gets) && line != "\r\n"
    puts line
  end
  client.close
end
  • TCPServer.new(8000) öffnet Port 8000. Ab jetzt nimmt das Betriebssystem Verbindungen an diesem Port für dein Programm entgegen.
  • server.accept wartet, bis jemand eine Verbindung aufbaut, und gibt sie zurück. Mit client liest und schreibst du dann wie mit einer Datei.
  • client.gets liest eine Zeile. Eine HTTP-Anfrage endet mit einer leeren Zeile, "\r\n". Bis dahin gibt der Server jede Zeile im Terminal aus.

Starte den Server im Terminal:

ruby server.rb

Er wartet jetzt und hört auf Port 8000. Öffne daneben ein zweites Terminal mit StrgShift5 und schicke ihm mit curl eine Anfrage, so wie ein Browser es tut:

curl localhost:8000

Zwei Terminals nebeneinander: links gibt der Server die Anfrage von curl aus, GET / HTTP/1.1, Host, User-Agent und Accept; rechts meldet curl, dass keine Antwort kam.

Im linken Terminal steht, was curl geschickt hat. Die erste Zeile ist die Request-Line: die Methode GET (»gib mir«), der Pfad / und die Version des Protokolls. Danach kommen Header, je ein Name und ein Wert: an wen die Anfrage geht (Host), welches Programm fragt (User-Agent), was es annimmt (Accept). Rechts beschwert sich curl: Der Server hat die Verbindung geschlossen, ohne zu antworten.

Aufgabe 1Die erste Zeile

Was ist die erste Zeile, die curl localhost:8000/time an deinen Server schickt?

Die erste Zeile

3. Eine Antwort

Eine Antwort hat dieselbe Form wie eine Anfrage: eine Status-Line, Header, eine leere Zeile, und danach kommt der Inhalt. Beende den Server im linken Terminal mit StrgC und ersetze server.rb durch:

require 'socket'

server = TCPServer.new(8000)
puts "Der Server wartet auf Port 8000 ..."

loop do
  client = server.accept
  # Jede Verbindung bekommt einen eigenen Thread: So wartet der Server nicht
  # auf einen Besucher, während ein anderer schon etwas will.
  Thread.new(client) do |client|
    while (line = client.gets) && line != "\r\n"
      puts line
    end

    text = "Hallo aus meinem eigenen Server!\n"
    client.print "HTTP/1.1 200 OK\r\n"
    client.print "Content-Type: text/plain; charset=utf-8\r\n"
    client.print "Content-Length: #{text.bytesize}\r\n"
    client.print "Connection: close\r\n"
    client.print "\r\n"
    client.print text
    client.close
  end
end
  • HTTP/1.1 200 OK ist die Status-Line: Version, Statuscode 200 und »OK«, alles in Ordnung.
  • Content-Type sagt, was für Inhalt kommt: einfacher Text in UTF-8. Content-Length sagt, wie viele Bytes er hat. text.bytesize zählt Bytes, nicht Zeichen; das ist ein Unterschied, sobald ein Umlaut im Text steht.
  • Connection: close kündigt an, dass der Server die Verbindung nach der Antwort schließt.
  • "\r\n" ist das Zeilenende in HTTP, Wagenrücklauf und Zeilenvorschub. Die leere Zeile trennt die Header vom Inhalt.
  • Thread.new lässt jede Verbindung in einem eigenen Thread laufen, gleichzeitig mit den anderen. Browser öffnen manchmal mehrere Verbindungen auf einmal; ohne Threads würde der Server bei der ersten warten und die anderen liegen lassen.

Starte den Server neu (↑ holt den letzten Befehl zurück) und frag im rechten Terminal mit -i, dann zeigt curl auch Status-Line und Header:

curl -i localhost:8000

Rechts zeigt curl -i die Antwort des Servers: HTTP/1.1 200 OK, die Header und den Text »Hallo aus meinem eigenen Server!«.

Aufgabe 2Die erste Antwort

Lass deinen Server mit einer vollständigen HTTP-Antwort antworten, jede Verbindung in einem eigenen Thread (Thread.new), und speichere.

Aufgabe 3Zeichen oder Bytes?

Der Inhalt einer Antwort ist "Grüße\n". Welchen Wert muss Content-Length haben?

Bytes
Tipp

In UTF-8 sind ü und ß je zwei Bytes lang, \n ist ein Byte.

Jetzt der Browser. Visual Studio Code leitet Port 8000 an eine Adresse weiter, die dein Browser erreicht. Du findest sie im Reiter »Ports« neben »Terminal«, in der Spalte »Forwarded Address«. Klicke mit gedrückter Strg-Taste darauf, und dein Server antwortet in einem neuen Tab.

Oder du schreibst die Anfrage selbst, Zeile für Zeile: Öffne die TCP-Konsole und verbinde dich mit localhost, Port 8000. Die Konsole beginnt in deinem Workspace, also landet sie bei deinem Server. Tippe GET / HTTP/1.1, Host: localhost und eine leere Zeile. Im Terminal siehst du, wie dein Server die Zeilen liest, in der Konsole, was er antwortet. Probier auch, was passiert, wenn du die leere Zeile weglässt oder statt einer Anfrage einfach Hallo schickst.

4. Pfade und 404

Bisher bekommt jeder Pfad dieselbe Antwort. Ein richtiger Server schaut in die Request-Line und antwortet je nach Pfad etwas anderes, und auf einen unbekannten Pfad mit dem Statuscode 404 Not Found. Weil sich jede Antwort gleich aufbaut, steht das jetzt in einer Funktion respond:

require 'socket'

# Schickt eine vollständige HTTP-Antwort und schließt die Verbindung
def respond(client, status, type, body)
  client.print "HTTP/1.1 #{status}\r\n"
  client.print "Content-Type: #{type}\r\n"
  client.print "Content-Length: #{body.bytesize}\r\n"
  client.print "Connection: close\r\n"
  client.print "\r\n"
  client.print body
  client.close
end

server = TCPServer.new(8000)
puts "Der Server wartet auf Port 8000 ..."

loop do
  Thread.new(server.accept) do |client|
    # Die erste Zeile: Methode, Pfad, Version
    request_line = client.gets.to_s
    method, path, version = request_line.split(' ')
    puts "#{method} #{path}"
    # Die übrigen Zeilen (Header) brauchen wir noch nicht
    while (line = client.gets) && line != "\r\n"
    end

    case path
    when '/'
      respond(client, '200 OK', 'text/html; charset=utf-8',
              "<h1>Willkommen!</h1><p>Das ist mein eigener Webserver.</p>")
    when '/time'
      respond(client, '200 OK', 'text/plain; charset=utf-8',
              "Es ist #{Time.now.strftime('%H:%M:%S')} Uhr.\n")
    else
      respond(client, '404 Not Found', 'text/plain; charset=utf-8',
              "Die Seite #{path} gibt es nicht.\n")
    end
  end
end

split(' ') zerlegt die Request-Line an den Leerzeichen in Methode, Pfad und Version. case path wählt die Antwort. Probiere die drei Fälle im zweiten Terminal aus:

curl localhost:8000/time
curl -i localhost:8000/missing

Rechts antwortet der Server auf /time mit der Uhrzeit und auf /missing mit HTTP/1.1 404 Not Found; links stehen die beiden Anfragen.

Vermutung 4Eine Seite, die es nicht gibt

Dein Server antwortet auf /missing mit 404 Not Found und dem Text »Die Seite /missing gibt es nicht.« Was zeigt der Browser? Überleg erst, dann probier es aus.

Eine Seite, die es nicht gibt

5. Webseiten ausliefern

Ein Webserver liefert meistens Dateien aus: HTML, CSS, Bilder. Lege neben server.rb einen Ordner public an und darin index.html:

<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="utf-8">
  <title>Mein Webserver</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>Mein eigener Webserver</h1>
  <p>Diese Seite kommt aus dem Ordner <code>public</code>, ausgeliefert von
  einem Server, den ich selbst in Ruby geschrieben habe.</p>
  <ul>
    <li><a href="time">Wie spät ist es?</a></li>
    <li><a href="hello?name=Ada">Begrüße Ada</a></li>
    <li><a href="visits">Zähle meinen Besuch</a></li>
    <li><a href="missing">Eine Seite, die es nicht gibt</a></li>
  </ul>
</body>
</html>

und style.css:

/* Die Seiten des Servers: eine weiße Karte auf hellem Grund */
html {
  background: #f4f1ea;
}

body {
  font-family: system-ui, sans-serif;
  line-height: 1.5;
  max-width: 34em;
  margin: 3em auto;
  padding: 2em 2.5em;
  background: white;
  border-radius: 14px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  color: #2b2b2b;
}

h1 {
  margin-top: 0;
  color: #b5482b;
}

a {
  color: #1f6f8b;
}

code {
  padding: 0.1em 0.35em;
  border-radius: 4px;
  background: #f4f1ea;
}

/* Für die Umfrage später: jede Antwort eine Kachel, die ganz klickbar ist */
label {
  display: block;
  padding: 0.6em 1em;
  border: 2px solid #ece6dc;
  border-radius: 10px;
  cursor: pointer;
}

label:has(input:checked) {
  border-color: #b5482b;
}

input[type="radio"] {
  margin-right: 0.5em;
  accent-color: #b5482b;
}

button {
  padding: 0.6em 1.6em;
  border: 0;
  border-radius: 8px;
  background: #b5482b;
  color: white;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

/* Das Ergebnis: ein Balken in der Farbe der Seite für jede Antwort */
table {
  width: 100%;
}

td {
  padding: 0.3em 0;
}

td:first-child {
  width: 5em;
}

td:last-child {
  width: 2em;
  text-align: right;
  font-weight: 600;
}

progress {
  appearance: none;
  width: 100%;
  height: 0.9em;
  border: 0;
  border-radius: 999px;
  background: #ece6dc;
  overflow: hidden;
}

progress::-webkit-progress-bar {
  background: #ece6dc;
}

progress::-webkit-progress-value {
  border-radius: 999px;
  background: #d9774f;
}

progress::-moz-progress-bar {
  border-radius: 999px;
  background: #d9774f;
}

Der Server sucht jetzt zu jedem Pfad eine Datei in public. / steht für /index.html. Am Dateinamen erkennt er, welcher Content-Type dazugehört, damit der Browser weiß, ob er HTML, CSS oder ein Bild bekommt:

require 'socket'

# Welcher Content-Type zu welcher Endung gehört
TYPES = {
  '.html' => 'text/html; charset=utf-8',
  '.css'  => 'text/css; charset=utf-8',
  '.js'   => 'text/javascript; charset=utf-8',
  '.png'  => 'image/png',
  '.jpg'  => 'image/jpeg',
  '.txt'  => 'text/plain; charset=utf-8',
}

PUBLIC_DIR = File.expand_path('public', __dir__)

# Schickt eine vollständige HTTP-Antwort und schließt die Verbindung
def respond(client, status, type, body)
  client.print "HTTP/1.1 #{status}\r\n"
  client.print "Content-Type: #{type}\r\n"
  client.print "Content-Length: #{body.bytesize}\r\n"
  client.print "Connection: close\r\n"
  client.print "\r\n"
  client.print body
  client.close
end

# Eine Datei aus dem Ordner public, aber nie etwas außerhalb davon
def serve_file(client, path)
  path = '/index.html' if path == '/'
  file = File.expand_path('.' + path, PUBLIC_DIR)
  if file.start_with?(PUBLIC_DIR + '/') && File.file?(file)
    type = TYPES[File.extname(file)] || 'application/octet-stream'
    respond(client, '200 OK', type, File.binread(file))
  else
    respond(client, '404 Not Found', 'text/plain; charset=utf-8',
            "Die Seite #{path} gibt es nicht.\n")
  end
end

server = TCPServer.new(8000)
puts "Der Server wartet auf Port 8000 ..."

loop do
  Thread.new(server.accept) do |client|
    request_line = client.gets.to_s
    method, path, version = request_line.split(' ')
    puts "#{method} #{path}"
    while (line = client.gets) && line != "\r\n"
    end

    if path == '/time'
      respond(client, '200 OK', 'text/plain; charset=utf-8',
              "Es ist #{Time.now.strftime('%H:%M:%S')} Uhr.\n")
    else
      serve_file(client, path.to_s)
    end
  end
end

Wichtig ist die Zeile mit start_with?. File.expand_path macht aus dem Pfad einen vollständigen Dateinamen, und der muss im Ordner public liegen. Ohne diese Prüfung könnte jemand nach /../server.rb fragen und bekäme deinen Quelltext, oder jede andere Datei, die dein Workspace lesen kann.

Starte den Server neu (StrgC, dann ↑ und Enter) und prüfe im zweiten Terminal, ob die CSS-Datei mit dem richtigen Content-Type kommt. Mit -I fragt curl nur nach den Headern, mit der Methode HEAD statt GET:

curl -I localhost:8000/style.css

Rechts zeigt curl -I die Header der Antwort auf /style.css: 200 OK, Content-Type text/css und Content-Length; links steht die Anfrage HEAD /style.css.

Öffne dann die Adresse aus dem Reiter »Ports« (»Forwarded Address«) im Browser:

Im Browser die Seite »Mein eigener Webserver« mit vier Links: Uhrzeit, Ada begrüßen, Besuch zählen und eine Seite, die es nicht gibt.

Aufgabe 5Eine Seite aus public

Lege public/index.html an und lass deinen Server die Dateien aus public ausliefern.

Probier den Angriff aus, gegen den die Prüfung mit start_with? schützt. Browser und curl räumen ein .. in der Adresse normalerweise auf, bevor sie fragen; curl -v localhost:8000/../server.rb zeigt, dass in Wirklichkeit GET /server.rb geschickt wird. Mit --path-as-is geht der Pfad so hinaus, wie er dasteht:

curl --path-as-is localhost:8000/../server.rb

Dein Server antwortet »Die Seite /../server.rb gibt es nicht.« Nimm zum Vergleich file.start_with?(PUBLIC_DIR + '/') && kurz aus der Bedingung heraus, starte den Server neu und frag noch einmal: Jetzt schickt er seinen eigenen Quelltext, an jeden, der fragt. Setz die Prüfung danach wieder ein. Solche Lücken heißen Path Traversal; echte Webserver hatten sie immer wieder.

Aufgabe 6Nur aus public

Warum prüft der Server mit start_with?(PUBLIC_DIR + '/'), wo die Datei liegt, bevor er sie schickt?

Nur aus public

6. Parameter und ein Zähler

Adressen können Werte mitbringen: /hello?name=Ada. Nach dem ? stehen Parameter, name=wert, mehrere getrennt durch &. Sonderzeichen wie Leerzeichen und Umlaute sind dort kodiert, %20 ist ein Leerzeichen. URI.decode_www_form macht daraus wieder lesbare Werte.

Dazu kommt ein Zähler, der mitzählt, wie oft /visits aufgerufen wurde. Er gehört dem ganzen Server, nicht einer einzelnen Verbindung, darum steht er in einer globalen Variable $visits:

require 'socket'
require 'uri'

# Welcher Content-Type zu welcher Endung gehört
TYPES = {
  '.html' => 'text/html; charset=utf-8',
  '.css'  => 'text/css; charset=utf-8',
  '.js'   => 'text/javascript; charset=utf-8',
  '.png'  => 'image/png',
  '.jpg'  => 'image/jpeg',
  '.txt'  => 'text/plain; charset=utf-8',
}

PUBLIC_DIR = File.expand_path('public', __dir__)

# Der Zähler gehört dem Server, nicht einer Verbindung: Alle Threads
# benutzen ihn, darum zählt immer nur einer auf einmal (Mutex).
$visits = 0
$visits_lock = Mutex.new

# Schickt eine vollständige HTTP-Antwort und schließt die Verbindung
def respond(client, status, type, body)
  client.print "HTTP/1.1 #{status}\r\n"
  client.print "Content-Type: #{type}\r\n"
  client.print "Content-Length: #{body.bytesize}\r\n"
  client.print "Connection: close\r\n"
  client.print "\r\n"
  client.print body
  client.close
end

# Eine Datei aus dem Ordner public, aber nie etwas außerhalb davon
def serve_file(client, path)
  path = '/index.html' if path == '/'
  file = File.expand_path('.' + path, PUBLIC_DIR)
  if file.start_with?(PUBLIC_DIR + '/') && File.file?(file)
    type = TYPES[File.extname(file)] || 'application/octet-stream'
    respond(client, '200 OK', type, File.binread(file))
  else
    respond(client, '404 Not Found', 'text/plain; charset=utf-8',
            "Die Seite #{path} gibt es nicht.\n")
  end
end

server = TCPServer.new(8000)
puts "Der Server wartet auf Port 8000 ..."

loop do
  Thread.new(server.accept) do |client|
    request_line = client.gets.to_s
    method, target, version = request_line.split(' ')
    puts "#{method} #{target}"
    while (line = client.gets) && line != "\r\n"
    end

    # /hello?name=Ada: der Pfad und die Parameter nach dem Fragezeichen
    path, query = target.to_s.split('?', 2)
    params = URI.decode_www_form(query.to_s).to_h

    case path
    when '/time'
      respond(client, '200 OK', 'text/plain; charset=utf-8',
              "Es ist #{Time.now.strftime('%H:%M:%S')} Uhr.\n")
    when '/hello'
      name = params['name'] || 'Unbekannt'
      respond(client, '200 OK', 'text/plain; charset=utf-8', "Hallo, #{name}!\n")
    when '/visits'
      count = $visits_lock.synchronize { $visits += 1 }
      respond(client, '200 OK', 'text/plain; charset=utf-8',
              "Du bist Besuch Nummer #{count}.\n")
    else
      serve_file(client, path)
    end
  end
end

$visits_lock ist ein Mutex, ein Lock, den immer nur ein Thread auf einmal hält. $visits += 1 sieht aus wie ein Schritt, sind aber drei: lesen, eins dazuzählen, zurückschreiben. Kommen zwei Threads gleichzeitig, könnten beide denselben alten Wert lesen, und ein Besuch ginge verloren.

Starte den Server neu und probiere es im zweiten Terminal aus:

curl "localhost:8000/hello?name=Ada%20Lovelace"
curl localhost:8000/visits
curl localhost:8000/visits

Rechts antwortet der Server »Hallo, Ada Lovelace!«, dann »Du bist Besuch Nummer 1.« und »Du bist Besuch Nummer 2.«; links stehen die drei Anfragen.

Im Browser funktioniert es genauso: Hänge /hello?name=… an die Adresse aus »Forwarded Address«.

Aufgabe 7Ein Name in der Adresse

Was antwortet dein Server auf /hello?name=Ada%20Lovelace?

Ein Name in der Adresse
Aufgabe 8Zwei auf einmal

Ohne Mutex: $visits ist 10, und zwei Besucher kommen genau gleichzeitig. Was kann passieren?

Zwei auf einmal

7. Ein Formular annehmen

Bisher hat der Browser nur gefragt. Ein Formular kann auch etwas schicken, zum Beispiel eine Stimme in einer Umfrage. Lege in public die Datei poll.html an:

<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="utf-8">
  <title>Umfrage</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>Umfrage</h1>
  <form method="post" action="vote">
    <p>Was isst du in der Mensa am liebsten?</p>
    <p><label><input type="radio" name="choice" value="pizza" required> Pizza</label></p>
    <p><label><input type="radio" name="choice" value="pasta"> Nudeln</label></p>
    <p><label><input type="radio" name="choice" value="curry"> Curry</label></p>
    <p><label><input type="radio" name="choice" value="salad"> Salat</label></p>
    <button>Abstimmen</button>
  </form>
</body>
</html>

Wegen method="post" stehen die Felder nicht in der Adresse wie bei /hello?name=Ada. Sie kommen nach den Headern und der leeren Zeile, im Body der Anfrage. Wählt jemand »Curry« und klickt auf »Abstimmen«, schickt der Browser:

POST /vote HTTP/1.1
Host: localhost:8000
Content-Type: application/x-www-form-urlencoded
Content-Length: 12

choice=curry

Hinter choice=curry steht kein Zeilenumbruch. Mit client.gets würde der Server auf ein Zeilenende warten, das nie kommt. Er muss wissen, wie viele Bytes er lesen soll, und das steht im Header Content-Length. Darum hebt er die Header jetzt in einem Hash auf, statt sie zu überspringen:

require 'socket'
require 'uri'

# Welcher Content-Type zu welcher Endung gehört
TYPES = {
  '.html' => 'text/html; charset=utf-8',
  '.css'  => 'text/css; charset=utf-8',
  '.js'   => 'text/javascript; charset=utf-8',
  '.png'  => 'image/png',
  '.jpg'  => 'image/jpeg',
  '.txt'  => 'text/plain; charset=utf-8',
}

PUBLIC_DIR = File.expand_path('public', __dir__)

# Der Zähler gehört dem Server, nicht einer Verbindung: Alle Threads
# benutzen ihn, darum zählt immer nur einer auf einmal (Mutex).
$visits = 0
$visits_lock = Mutex.new

# Die Umfrage: was im Formular steht, wie es auf der Seite heißt, und die
# Stimmen dazu, ebenfalls geschützt durch einen Lock
OPTIONS = { 'pizza' => 'Pizza', 'pasta' => 'Nudeln', 'curry' => 'Curry', 'salad' => 'Salat' }
$votes = Hash.new(0)
$votes_lock = Mutex.new

# Schickt eine vollständige HTTP-Antwort und schließt die Verbindung
def respond(client, status, type, body)
  client.print "HTTP/1.1 #{status}\r\n"
  client.print "Content-Type: #{type}\r\n"
  client.print "Content-Length: #{body.bytesize}\r\n"
  client.print "Connection: close\r\n"
  client.print "\r\n"
  client.print body
  client.close
end

# Schickt den Browser weiter zu einer anderen Adresse: »Sieh dort nach«
def redirect(client, location)
  client.print "HTTP/1.1 303 See Other\r\n"
  client.print "Location: #{location}\r\n"
  client.print "Content-Length: 0\r\n"
  client.print "Connection: close\r\n"
  client.print "\r\n"
  client.close
end

# Eine Datei aus dem Ordner public, aber nie etwas außerhalb davon
def serve_file(client, path)
  path = '/index.html' if path == '/'
  file = File.expand_path('.' + path, PUBLIC_DIR)
  if file.start_with?(PUBLIC_DIR + '/') && File.file?(file)
    type = TYPES[File.extname(file)] || 'application/octet-stream'
    respond(client, '200 OK', type, File.binread(file))
  else
    respond(client, '404 Not Found', 'text/plain; charset=utf-8',
            "Die Seite #{path} gibt es nicht.\n")
  end
end

server = TCPServer.new(8000)
puts "Der Server wartet auf Port 8000 ..."

loop do
  Thread.new(server.accept) do |client|
    request_line = client.gets.to_s
    method, target, version = request_line.split(' ')
    puts "#{method} #{target}"
    # Die Header in einen Hash: »Name: Wert«
    headers = {}
    while (line = client.gets) && line != "\r\n"
      name, value = line.split(':', 2)
      headers[name.strip.downcase] = value.to_s.strip
    end

    # /hello?name=Ada: der Pfad und die Parameter nach dem Fragezeichen
    path, query = target.to_s.split('?', 2)
    params = URI.decode_www_form(query.to_s).to_h

    case path
    when '/time'
      respond(client, '200 OK', 'text/plain; charset=utf-8',
              "Es ist #{Time.now.strftime('%H:%M:%S')} Uhr.\n")
    when '/hello'
      name = params['name'] || 'Unbekannt'
      respond(client, '200 OK', 'text/plain; charset=utf-8', "Hallo, #{name}!\n")
    when '/visits'
      count = $visits_lock.synchronize { $visits += 1 }
      respond(client, '200 OK', 'text/plain; charset=utf-8',
              "Du bist Besuch Nummer #{count}.\n")
    when '/vote'
      if method != 'POST'
        respond(client, '405 Method Not Allowed', 'text/plain; charset=utf-8',
                "Abstimmen geht nur mit POST.\n")
        next
      end
      # Nach der leeren Zeile kommt der Inhalt, genau Content-Length Bytes
      body = client.read(headers['content-length'].to_i).to_s
      choice = URI.decode_www_form(body).to_h['choice']
      if OPTIONS.key?(choice)
        $votes_lock.synchronize { $votes[choice] += 1 }
        redirect(client, '/results')
      else
        respond(client, '400 Bad Request', 'text/plain; charset=utf-8',
                "Diese Antwort gibt es nicht.\n")
      end
    when '/results'
      # Ein Balken für jede Antwort; <progress> zeichnet ihn ohne CSS
      rows = $votes_lock.synchronize do
        most = [$votes.values.max || 0, 1].max
        OPTIONS.map do |key, label|
          "<tr><td>#{label}</td><td><progress value='#{$votes[key]}' max='#{most}'></progress></td>" \
            "<td>#{$votes[key]}</td></tr>"
        end.join("\n")
      end
      respond(client, '200 OK', 'text/html; charset=utf-8',
              "<!DOCTYPE html><meta charset='utf-8'><link rel='stylesheet' href='style.css'>" \
              "<h1>Ergebnis</h1><table>#{rows}</table><p><a href='poll.html'>Noch einmal abstimmen</a></p>\n")
    else
      serve_file(client, path)
    end
  end
end

Was neu ist:

  • Jede Header-Zeile wird an ihrem ersten : geteilt. Der Name kommt klein geschrieben in den Hash, weil HTTP Groß- und Kleinschreibung bei Header-Namen nicht unterscheidet: headers['content-length'] findet Content-Length und content-length.
  • /vote nimmt nur POST an und liest mit client.read genau so viele Bytes, wie Content-Length sagt. Der Body hat dieselbe Form wie die Parameter in einer Adresse, darum hilft wieder URI.decode_www_form.
  • Der Server zählt nur Antworten, die in OPTIONS stehen. Was ankommt, muss nicht aus deinem Formular stammen: Jeder kann mit curl an /vote schicken, was er will.
  • Die Stimmen zählt er unter einem eigenen Lock, aus demselben Grund wie die Besuche.
  • Die Ergebnisseite zeichnet für jede Antwort einen Balken mit <progress>: value ist die Zahl ihrer Stimmen, max die größte Zahl. Den Balken malt der Browser selbst, ganz ohne CSS.

Nach einer Stimme zeigt der Server nicht gleich das Ergebnis, sondern antwortet mit 303 See Other und dem Header Location: /results: »Sieh dort nach.« Der Browser holt dann /results mit GET. Der Umweg hat einen Grund: Wäre das Ergebnis die Antwort auf das POST, würde der Browser beim Neuladen das Formular noch einmal schicken, und die Stimme zählte doppelt. So lädt er nur die Ergebnisseite neu. Das Muster heißt Post/Redirect/Get.

Starte den Server neu und stimme im zweiten Terminal mit curl ab. -d schickt Daten mit POST, wie das Formular:

curl -i -d choice=curry localhost:8000/vote
curl localhost:8000/results

Rechts antwortet der Server auf die Stimme mit 303 See Other und Location: /results, danach zeigt curl das HTML der Ergebnisseite: eine Tabelle mit einem progress-Element für jede Antwort, bei Curry value 1. Links stehen POST /vote und GET /results.

Öffne jetzt im Browser die Adresse aus »Forwarded Address« mit /poll.html dahinter, wähle etwas und stimme ab:

Im Browser die Seite »Umfrage« mit der Frage nach dem liebsten Mensa-Essen, vier Antworten, Pizza ist ausgewählt, und dem Knopf »Abstimmen«.

Im Browser die Seite »Ergebnis« mit einem Balken für jede Antwort: Pizza 1 und Curry 1 mit vollem Balken, Nudeln 0 und Salat 0 mit leerem, darunter der Link »Noch einmal abstimmen«.

Lade die Ergebnisseite neu: Die Zahlen bleiben gleich. Im linken Terminal siehst du, dass der Browser nur GET /results geschickt hat.

Aufgabe 9Eine Umfrage

Lass deinen Server Stimmen mit POST annehmen, sie unter einem Lock zählen und mit 303 See Other auf das Ergebnis weiterleiten, und speichere.

Aufgabe 10Wie lang ist der Body?

Warum liest der Server den Body mit client.read und der Zahl aus Content-Length und nicht mit client.gets?

Wie lang ist der Body?
Vermutung 11Neu laden

Du hast abgestimmt und siehst die Ergebnisseite. Was passiert, wenn du sie neu lädst? Überleg erst, dann probier es aus.

Neu laden

8. Mit der Klasse teilen

Bisher erreichst nur du deinen Server. Um ihn deiner Klasse zu zeigen, öffne dein Profil und gib unter »Shared Live Apps« Port 8000 frei. Du bekommst eine Adresse, die alle in deiner Gruppe öffnen können, solange dein Server läuft. Lass ein paar Leute /visits aufrufen und unter /poll.html abstimmen: Der Server zählt alle, egal von wo sie kommen.

Aufgabe 12Dein Server

Gib deinen Server ab: den Ordner webserver mit server.rb und public, mit der Umfrage.

9. Wie geht es weiter?

Dein Server antwortet nur, wenn der Browser fragt. Soll er von sich aus etwas schicken, zum Beispiel, wenn jemand anderes etwas getan hat, braucht es eine Verbindung, die offen bleibt. Darum geht es in der Pixelwand: Die ganze Klasse malt gleichzeitig an einem Bild, und jeder sieht sofort, was die anderen malen.

Große Webserver wie nginx oder Ruby-Bibliotheken wie Sinatra machen dasselbe wie deiner, nur mit vielen Fällen mehr: viele Verbindungen gleichzeitig, verschlüsselt mit HTTPS, mit Caching und Schutz gegen Angriffe.