Skip to content

Instantly share code, notes, and snippets.

@sirupsen
Created October 15, 2010 15:23
Show Gist options
  • Save sirupsen/628372 to your computer and use it in GitHub Desktop.
Save sirupsen/628372 to your computer and use it in GitHub Desktop.
Simple evented ChatServer in pure Ruby using network I/O with select(2).

Dependencies

Colored for chat colors

gem install colored
# A Ruby chatsever written for the sole purpose of learning to use
# sockets and writing non-blocking I/O in Ruby. Furthermore I've
# written comments for you to understand how it all works. They
# are presented in this awesome format with [Rocco][ro]!
#
# [ro]: http://github.com/rtomayko/rocco
#### Prerequesties
# We'll need a few libraries to get started, these are "socket"
# which is found within the Ruby standard library and [colored][co]
# which is for pretty terminal colors.
#
# Install colored:
#
# $ gem install colored
#
# [co]: http://github.com/defunkt/colored
#
%w{
socket
colored
}.each {|lib| require lib}
# We start by defining our chat Module (or namespace, if you prefer)
module Chat
#### Connection
# Our connections class which is a metaclass of Array that adds a few
# methods that are handy for our application.
class Connections < Array
# Collect all the sockets from the objects.
#
# Example:
#
# clients = Connections.new Client.new(:socket => socket),
# Client.new(:socket => socket)
# sockets = clients.sockets
#
# #=> [TCPSocket, TCPSocket]
#
def sockets
collect {|e| e.socket}
end
# Reverse lookup the socket; find the object the socket belongs to.
#
# Example (continues example for Chat::Connections#sockets):
#
# clients[sockets.first]
#
# #=> Chat::Client
#
def [](socket)
select {|e| e.socket === socket}.first if socket.is_a?(TCPSocket)
end
end
#### Client class
# Client class, each chat client has a corresponding instance of
# this class.
class Client
# We want out clients to have a:
#
# * Username
# - Name of the user
# * Socket
# - Biredirectional connection to the user
# * Channel(s)
# - Channel(s) the user is on
#
attr_accessor :username, :socket, :channel
# We give our client some properties.
#
# Example:
#
# Chat::Client.new :socket => socket,
# :channel => self,
# :username => "Goomba"
#
def initialize(properties)
@socket, @channel = properties[:socket], properties[:channel]
@username = properties[:username]
end
end
#### Channel class
# The channel class handles our chat channel. Currently our app.
# doesn't support multiple channels, however, this is a great
# example of how well structured OO can make something much
# easier in the future.
class Channel
# As with Client we want some information about our channel,
# this includes:
#
# * Name
# - Name of the channel
# * Clients
# - Channel clients
# * Server socket
# - The server socket
#
attr_accessor :name, :clients, :socket
# Specify what port the channel should run on
def initialize(port)
# Start the server socket
@socket = TCPServer.new "localhost", port
# We want a Connections array for our clients
@clients = Connections.new
# We're ready!
puts "Chatserver started on port #{port.to_s.bold}\n"
end
# Accepts a new connection on the server
def accept_new_connection
# Create a new Client with the accepted socket, add our channel
# and default username.
new_client = Client.new :socket => @socket.accept_nonblock,
:channel => self,
:username => "Guest#{@clients.size}"
# Add the new client to the channel's clients
@clients << new_client
# Write to all the channels users that someone has connected
self << "#{new_client.username.bold} has joined!\n"
end
# Send a message to the channel
def send_message(message)
# Send the message to each user in the channel via the socket
@clients.each do |client|
client.socket.write_nonblock message
end
# Print the message to the server log
print message
end
# Makes #<< an alias for #send_message
alias_method :<<, :send_message
end
#### Server class
# The server class handles the chat server
class Server
# What port are we running on?
def initialize(port)
# Channels connections
@channels = Connections.new
# Add a "default" channel
@channels << Channel.new(port)
end
# The run method sets the server in an infinite loop where
# it listens for events.
def run
loop do
# Refresh new clients (someone could have been accepted
# in the last loop)
@clients = refresh_clients
# This is where the magic happens.
# select(2), see `man 2 select` listens on all the sockets passed
# and blocks until I/O is ready on any of the sockets. In this case
# it listens on **all** of our client sockets, and channel server
# sockets for I/O. I/O on a channel server typically means someone
# has connected on the server, and ready I/O on a client socket usually
# means the client has attempted to send a message to the socket or
# has terminated its session.
# When I/O is ready on anything, it returns an array like this:
#
# [[readable_sockets], [write], [errors]]
#
# In this case, we're only interested in the readable_sockets.
read, write = select(@clients.sockets + @channels.sockets)
# For each (readable) socket that is ready for I/O
read.each do |socket|
# Reverse lookup the socket so we get the right client object
# back, so we have access to #username, etc.
client = @clients[socket] unless @clients.empty?
# If the readable socket is a server, someone is trying to connect
if socket.is_a?(TCPServer)
# We accept that connection
accept_new_connection(socket)
# If we're at EOF of the socket, someone has disconnected
elsif socket.eof?
# So we close the connection to the socket
close_connection(socket, client)
else
# Else, someone is sending a message to our socket, and
# we can parse it!
parse(socket, client)
end
end
end
end
private
# Helper method for parsing a message, note that we could
# easily add more to it like the ability to change a users
# username by sending a message like "/nick Newnick", again
# this is free from good design!
def parse(socket, client)
# Read 1024 bytes a time, and strip the output so we don't get
# fancy newlines etc. we want to control this ourselves.
buffer = socket.read_nonblock(1024).strip
# And finally write the message to the server.
client.channel << "#{client.username.bold}: #{buffer}\n"
end
# Helper method for accepting a new connection
def accept_new_connection(socket)
# Reverse lookup the sever to get the server object and
# then accept the new connection.
@channels[socket].accept_new_connection
end
# Helper method for closing a connection
def close_connection(socket)
# Tell people in the channel that the client left
client.channel << "Client left #{client.username.bold}\n"
# Close the socket
client.socket.close
# And delete the client so we don't attempt to listen on
# a closed socket
@clients.delete(sock)
end
# Refresh client list
def refresh_clients
# Basically it just checks all the channels for clients
# and returns them in an array.
clients = Connections.new
@channels.each do |channel|
clients << channel.clients
end
clients.flatten
end
end
end
# Start our server!
Chat::Server.new(1337).run
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment