Building a Simple Web Server with Ruby 2.0+
11 Oct 2015The Ruby language can be utilized for a variety of different purposes. The most popular of these is as a web scripting language. The entry point to these applications, the web server, is not something that is usually built from the ground up. Most applications have the lines gem 'puma'
or gem 'unicorn'
in their Gemfile
. Those are high quality web servers that were built and are maintained by very talented people, but how do they work?
The aforementioned web servers are just Ruby gems written by Rubyists. While they are awesome gems, they are not magic. A basic web server can be written to satisfy some very simple requests.
GET
Basic
The job of a web server is to take an incoming request, decide what it means and respond accordingly. Essentially, we want to read some text, process it, and send some other text as response. To create a very simple server, the built in Socket
library will be utilized.
The goal of this tiny server will be to listen to localhost
requests on port 8080
.
require 'socket'
server = TCPServer.new(8080)
loop do
request = server.accept
request.puts 'hello world'
request.close
end
And there it is! A ridiculously tiny web server written in Ruby. Dissecting this code, we see how simple the Socket
library makes this task.
server = TCPServer.new(8080)
This line makes a new TCPServer
instance that will accept requests to port 8080
and defaults to localhost
as the host. The important method on the TCPServer
instance is accept
.
loop do
Since this server is meant to respond to any request on port 8080
, an infinite loop is created so that more than one request can be fulfilled before the process exits.
request = server.accept
request.puts 'hello world'
request.close
Here is the real logic of the server. The accept
method returns a new instance of TCPSocket
which can be used to communicate with a client. A response string, 'hello world'
, is then printed to the client and the connection is closed.
Saving this code to example_server.rb
, the server can be started with ruby example_server.rb
in a console.
Using curl
to test this server results in:
$: curl localhost:8080
hello world
curl: (56) Recv failure: Connection reset by peer
Alright, alright, the 'hello world'
has been printed to the console but it looks like there might be an issue. Connection reset by peer
, what the heck does that mean? It means that the server did not send enough information to the client, particularly about when to close the connection. The client is expecting more data than this very naive server is providing. The client expects header data.
Look Ma, no Head(er)!
An HTTP header consists of many colon-separated key-value pairs deliminated by a return and newline combination. Everything from a status code to response size can be returned in an HTTP header.
For this simple example, a status code, Content-Type
, Content-Length
, and Connection
will be added to the response header.
require 'socket'
server = TCPServer.new(8080)
loop do
request = server.accept
response = 'hello world'
header = "HTTP/1.1 200 OK\r\n"
header += "Content-Type: text/plain\r\n"
header += "Content-Length: #{ response.bytesize }\r\n"
header += "Connection: close\r\n"
request.puts header
request.puts "\r\n"
request.puts response
request.close
end
With this additional information, a client using this server will know the type, size, and connection status of its request. This will enable the client to respect the content type, parse the result intelligently and close the connection.
An interesting detail about HTTP headers is the way they end. A single line consisting of a return newline, request.puts "\r\n"
, is how the client knows that the HTTP header is finished.
Note: If a server like this is created for any real requests, hard-coded response headers is not the best idea.
Head’s up
Now, the same curl localhost:8080
test to the new and improved server returns no error:
$: curl localhost:8080
hello world
The new headers are visible via curl localhost:8080 -I
:
$: curl localhost:8080 -I
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 11
Connection: close
The server is now responding in a sensible way, but a problem still exists. If more than one request is made, the first request will be processed and all others will wait.
This implementation is not adequate for concurrent request handling, but another built in solution is readily available.
Threads the Needle
An easy way to achieve basic concurrency with this server is via Threads
. Out of the box Ruby uses the Matz’s Ruby Interpreter or MRI. MRI is not capable of true concurrency (due to the Global Interpreter Lock) but built in threads will be just fine for this small server.
This example can be extended to use threads by wrapping the responsibility of the request in a block:
require 'socket'
server = TCPServer.new(8080)
loop do
Thread.new(server.accept) do |request|
response = 'hello world'
header = "HTTP/1.1 200 OK\r\n"
header += "Content-Type: text/plain\r\n"
header += "Content-Length: #{ response.bytesize }\r\n"
header += "Connection: close\r\n"
request.puts header
request.puts "\r\n"
request.puts response
request.close
end
end
Voila! A simple threaded Ruby server!
Note: As with the previous solution, this might not be fit for a production environment since every single request spawns a new thread within a single process.
What now?
Now, this simple server will fulfill concurrent requests in a way that will make sense to its consumers. However, it always responds with 'hello world'
, regardless of the request data. To fetch and parse the incoming data, request.gets
is available:
require 'socket'
server = TCPServer.new(8080)
loop do
Thread.new(server.accept) do |request|
response = 'hello world'
request_input = request.gets
# handle request information
header = "HTTP/1.1 200 OK\r\n"
header += "Content-Type: text/plain\r\n"
header += "Content-Length: #{ response.bytesize }\r\n"
header += "Connection: close\r\n"
request.puts header
request.puts "\r\n"
request.puts response
request.close
end
end
Other improvements would be header configuration, incoming request parsing, and thread pooling.
Spending a thousand hours iterating on this code will certainly improve its quality, but it still might not allow this simple server stack up to the leaders in the space. I would recommend using a more battle tested web server for an actual production application. At least some of those awesome servers’ mysteries have been explained.
To see even more improvements, check out part 2!