Last weekend I learned how to create a chat application in the command line. The idea was to open at least 3 tabs in the terminal: one for the server that will handle the connexion between the users, and X number of tabs for the users to communicate, at least 2.
This project made me discover two built-in classes in Ruby that we don't really use when we do a full stack application with Rails:
- Socket
- Thread
- ARGV
I will talk about them later.
Let's talk about the server
So what do we need exactly to create this chat? We talked about a server, so we will need a file for this that will be called server.rb
What do we need to do here? We need to establish the connexion through the port of our choice. For this we will need the class Socket, and we have to require it at the beginning of the file.
To understand what this method offers, make sure to understand what a socket is. Here is a definition provided by Oracle docs:
"A socket is one endpoint of a two-way communication link between two programs running on the network. A socket is bound to a port number so that the TCP layer can identify the application that data is destined to be sent to."
Requiring Socket will give us access to other classes and methods to create our server (see the Ruby documentation for Socket).
Our first line of command will be to create a TCP server on the port we want, and to make sure it's launched I will print a confirmation text:
require 'socket'
server = TCPServer.new 2000 # Server bound to port 2000
puts "I'm ready to accept new clients on port 2000"
Our server is working, now we need it to listen to whatever connexion is coming up from a client, and we need to do it constantly. So we need to create a loop. We also want the server to handle multiple clients, and give information to users. So we will create an array to store them, and append every new client to the array.
clients = []
loop do
# we wait for a client to connect, and assign it to client
client = server.accept
clients << client
end
It is now time to introduce Thread. We use a thread to split a program and have a tasks that will run simultaneously (or pseudo-simultaneously). Creating a thread will allow our program to run two processes in the same time (see documentation for Thread)
We need it because while the server is waiting for a client to connect in the loop, we also need to constantly handle the messaging between the clients already connected.
We do that by writing Thread.new {}
. The block will be used to tell the program what to do with this thread.
So what do we need from it? We need to know who connects to it, to receive and display text to others. We also need to detect when a client disconnect and remove it from the clients
array.
It's a lot, so let's create a method for this.
def handle_client(clients, client)
# for this method I need the new client, and the list of existing clients.
# client_name will take whatever name the client put when it will connect to the server. We will see later how it's sent from the client perspective.
client_name = client.gets.chomp
# here we will display a welcome message and show how many clients are already connected
client.puts "Hello #{client_name}! Clients connected: #{clients.count}"
# this method is described below. It announces to all clients who is the new client.
announce_to_everyone(clients, "#{client_name} joined!")
# this is another loop. gets will take any text coming from the client...
while line = client.gets
incoming_data_from_client = line.chomp
#... and this text will be shared to all the clients. A little bit of formatting to indicate who said what.
announce_to_everyone clients, "#{client_name}: #{incoming_data_from_client}"
end
# it will close the client connexion and remove it from the clients array. And other clients will receive a notification.
client.close
clients.delete(client)
announce_to_everyone(clients, "#{client_name} left!")
end
# this method takes the text sent by a client, and the clients connected. For each client from clients, the text will be displayed
def announce_to_everyone(clients, text)
clients.each { |client| client.puts text }
end
We are done with server.rb
. The server is running, and waiting to get clients connexions. It will receive messages and display them to all connected clients, and give them cool info for a better user experience (well, from the terminal... for what it's worth).
Let's talk about the client
The idea here is to open let say 3 terminal tabs to simulate 3 clients / users, we will launch the client's program from each tab.
For that we need to create a file that we will call client.rb
.
First we need to make sure our client will create a connexion on the server. Previously, on server side, we used TCPServer.new
. This time, we will create an instance of TCPSocket
.
require 'socket'
socket = TCPSocket.new 'localhost', 2000
# bound to port 2000, like the server
When we connect to the chat, we want a way to enter the user name. There is a simple way to do that.
From the terminal, we can write $ruby client.rb Aurelie
, where Aurelie
is an argument passed to the script.
In our file, first thing to do is to grab this argument and assign it to a variable. For that we need to use the ARGV built-in class.
name = ARGV.shift
ARGV takes all the arguments you pass to a script, and put them into an array in order of apparition. And #shift will use the first one of the array.
Do you remember how we assigned the client's name in server.rb
above?
Here we will use name
, and do a puts
applied to the socket. The first connexion to the server through the socket will be to send the name to the server, and that's how the server will pick-up name
with its gets
and assign it to client_name
socket.puts "#{name}"
Now we need a way to take what the user types in the client and send it to the server, but we also need to keep receiving whatever messages other clients send to us. Two processes at a time, it's time to use Thread again!
We will create one thread for the local typing, and one for what the client receives from the server.
local_typing_thread = Thread.new { local_typing(socket) }
receive_from_server_thread = Thread.new { receive_from_server(socket) }
Two things here, we assign each thread to a variable for a later use, and we pass in each thread a method to handle our processes.
Let's talk about the local_typing
method.
This method needs to know which socket to use to give information to the server. It also needs to constantly check for what to send, so we need a loop.
def local_typing(socket)
loop do
# when a user sends a message, this message will appear in the user client preceded by a little string that shows it's the user's message. For example: (me)> Hey it's me!
print "(me)> "
text_to_send = gets.chomp
socket.puts text_to_send
end
end
Then we need to tackle the receive_from_server
method.
Again, it needs to know about the socket. And again, we want it to constantly capture all the messages sent to it:
def receive_from_server(socket)
# read the lines coming from the socket, and write them in the terminal
while line = socket.gets
puts line
end
end
Finally, since we created two separate threads, we need to make them join, and we need to close the socket when we are done
local_typing_thread.join
receive_from_server_thread.join
socket.close
Tadaa! We created a little chat in the terminal, on localhost.
See what it looks like:
You can find the files in my repo here
Top comments (2)
Neat! I've been meaning to check out Ruby, I might give this project a shot once I've learned some of the basics.
One thing, I think the
Thread.new
block is missing from yourserver.rb
code block (I peeked at the source in your GH repo to check 🙃)Thank you for your comment!
In server.rb the thread is created inside the loop. We need one thread per client.
Many things can be perfected, for example what the user types is seen two times since the method
announce_to_everyone
sends the message to all clients. Also there is no exception handling when a client quits the chat or when the server is stopped, it can be a great addition.