A multiclient TCP server for Ruby
Motivation
Writing a reliable client-server system from scratch is difficult.
Lots of sample code can be found in the web,
but most of it is undercomplex;
in particular, many example do not properly handle hangup,
loss of connection, and similar anomalous events.
Here I present code for a TCP server that
has proved actually useful in a spectrometer control system.
Protocol
Client and server should be able to exchange messages of arbitrary length.
Therefore messages must conform to a convention that determines how
to recognize the end of message.
For instance,
one could start each message with a header that contains the number
of characters that follow.
Here, an even simpler protocol is chosen: each message must end
with the terminator character sequence \r\n.
The server class
### This is free software
### Placed in the public domain by Joachim Wuttke 2012
require 'socket'
class MulticlientTCPServer
# A nonblocking TCP server
# - that serves several clients
# - that is very efficient thanks to the 'select' system call
# - that does _not_ use Ruby threads
def initialize( port, timeout, verbose )
@port = port # the server listens on this port
@timeout = timeout # in seconds
@verbose = verbose # a boolean
@connections = []
@server =
begin
TCPServer.new( @port )
rescue SystemCallError => ex
raise "cannot initialize tcp server for port #{@port}: #{ex}"
end
end
def get_socket
# Process incoming connections and messages.
# When a message has arrived, we return the connection's TcpSocket.
# Applications can read from this socket with gets(),
# and they can respond with write().
# one select call for three different purposes -> saves timeouts
ios = select( [@server]+@connections, nil, @connections, @timeout ) or
return nil
# disconnect any clients with errors
ios[2].each do |sock|
sock.close
@connections.delete( sock )
raise "socket #{sock.peeraddr.join(':')} had error"
end
# accept new clients
ios[0].each do |s|
# loop runs over server and connections; here we look for the former
s==@server or next
client = @server.accept or
raise "server: incoming connection, but no client"
@connections << client
@verbose and
puts "server: incoming connection no. #{@connections.size} from #{client.peeraddr.join(':')}"
# give the new connection a chance to be immediately served
ios = select( @connections, nil, nil, @timeout )
end
# process input from existing client
ios[0].each do |s|
# loop runs over server and connections; here we look for the latter
s==@server and next
# since s is an element of @connections, it is a client created
# by @server.accept, hence a TcpSocket < IPSocket < BaseSocket
if s.eof?
# client has closed connection
@verbose and
puts "server: client closed #{s.peeraddr.join(':')}"
@connections.delete(s)
next
end
@verbose and
puts "server: incoming message from #{s.peeraddr.join(':')}"
return s # message can be read from this
end
return nil # no message arrived
end
end # class MulticlientTCPServer
A simple server
require 'multiclient_tcp_server'
srv = MulticlientTCPServer.new( 2000, 1, true )
loop do
if sock = srv.get_socket
# a message has arrived, it must be read from sock
message = sock.gets( "\r\n" ).chomp( "\r\n" )
# arbitrary examples how to handle incoming messages:
if message == 'quit'
raise SystemExit
elsif message =~ /^puts (.*)$/
puts "message from #{sock.peeraddr.join(':')}: '#{$1}'"
elsif message =~ /^echo (.*)$/
# send something back to the client
sock.write( "server echo: '#{$1}'\r\n" )
else
puts "unexpected message from #{sock.peeraddr}: '#{$1}'"
end
else
sleep 0.01 # free CPU for other jobs, humans won't notice this latency
end
end
A simple client
require 'socket'
require 'timeout'
# connect to server
sock = begin
Timeout::timeout( 1 ) { TCPSocket.open( 'localhost', 2000 ) }
rescue StandardError, RuntimeError => ex
raise "cannot connect to server: #{ex}"
end
# send sample messages:
puts "sending one-way message"
sock.write( "puts This is a one-way message\r\n" )
sleep( 2 )
puts "sending a request that should be answered"
sock.write( "echo This message should be echoed\r\n" )
response = begin
Timeout::timeout( 1 ) { sock.gets( "\r\n" ).chomp( "\r\n" ) }
rescue StandardError, RuntimeError => ex
raise "no response from server: #{ex}"
end
puts "received response: '#{response}'"
sleep( 2 )
puts "sending a goodbye message"
sock.write( "puts bye\r\n" )
sock.close
Licence
The code samples are released into the public domain.
This web page is published under the Creative Commons license
CC-BY-SA.
Changelog
Developed 2007-12.
08jan12 first published online.