fsxNet Wiki

BBS Development & Resources

User Tools

Site Tools


tutorials:crystal_bbs:part_one

Crystal BBS - Part One

Getting the ball rolling

OK, so let's start with some actual BBS programming! I've chosen Crystal for this tutorial, so you'll need to install it. At the moment this is being written, the current version is 0.21.1. As the language is still in alpha, a lot may break until it reaches 1.0 (scheduled for later in 2017) and I'll try to keep this updated.

I won't cover installation, since the official docs are pretty good at that. You will need a macOS or Linux setup, either native or in a virtual machine.

Once you have it installed, it's time to get going. Crystal has a “skeleton” too built-in that creates an initial app and creates a local Git repository as well. Git is not part of the tutorial, but you should always keep your code in check with a source code management system, so have a look at it when you can.

To create the initial skeleton, type

crystal init app bbs

This will create the skeleton inside the bbs subfolder of the current directory. Jump into it and have a look around at the files created.

One thing the app creator leaves out is an easy way to compile the project - we can just type crystal build src/bbs.cr –release -o bbs_release every time we want to compile, but that would get old real quick. So instead we'll create a Makefile and use good old make to perform all these repetitive tasks on the project. Here's how it could look:

.PHONY: clean
 
run: bbs
	./bbs_release
clean:
	rm -f bbs_release
 
all: clean
	crystal build src/bbs.cr --release -o bbs_release
 
bbs:
	rm -f bbs_release
	crystal build src/bbs.cr --release -o bbs_release

Make is also out of scope for this tutorial, but it won't get more complicated than this. From now on, just type make in the project's root directory to erase the existing binary and compile a new one. If you type it now, you should see:

$ make
rm -f bbs_release
crystal build src/bbs.cr --release -o bbs_release
./bbs_release

That last line was our binary running, but of course it doesn't do anything yet!

Let's get the basics out of the way before we go into the meat of the problem. We want to store configuration settings, so let's do that first, and then move on to the main network loop.

Your src/bbs.cr file should look like this now:

require "./bbs/*"
 
module Bbs
  # TODO Put your code here
end

Change it to:

require "./bbs/*"
 
BBS::Main.new.go!

This will call a go! method on the BBS module's Main class. Let's create it, but on a separate file. Create a main.cr file inside the src/bbs/ directory with:

require "yaml"
 
module BBS
  class Main
    @config : YAML::Any
 
    def initialize
      @config = load_config
    end
 
    def go!
      puts @config.inspect
    end
 
    private def load_config
      YAML.parse(File.read("config.yml"))
    end
  end
end

This file will be required by src/bbs.cr (as mandated by its line 1) and will add the class to the BBS module. Let's create a basic config file as config.yml on the root directory of the project (more info on YAML here:

settings:
  port: 2023

If you type make now, you should see:

$ make
rm -f bbs_release
crystal build src/bbs.cr --release -o bbs_release
./bbs_release
{"settings" => {"port" => "2023"}}

Yay, we're live with the basics and our settings are being read! Let's recap for a bit, since this class introduces a few important concepts.

require "yaml"

This line requires in Crystal's built in YAML handling functions.

@config : YAML::Any

This specifies the type of the @config instance variable for the Main class - this is a requirement in Crystal in order to generate runtime code for the variable. You'll notice that Crystal is very much like Ruby, but this is where the line is drawn - Crystal needs some type annotations in the code. You might think this is a step back from Ruby or even Python, but as a long time dynamic languages coder I can assure you that once a codebase exceeds a certain size, type safety being enforced by a compiler is a life saver. Also, it makes Crystal a bazillion times faster than Ruby or Python! :D

def initialize
  @config = load_config
end

As in Ruby, the constructor method for a class is called initialize - in our case, the constructor just assigns the result of the load_config method to the @config instance variable.

def go!
  puts @config.inspect
end

This is the method called in bbs.cr (our main entry point for the whole project) after instantiating a new instance of the Main class. At this stage, it just prints out the hash parsed from the config.yml file that we created.

private def load_config
  YAML.parse(File.read("config.yml"))
end

Finally, the method that parses the configuration file. It's marked as private since there's no real need for it being called outside of the Main class. It just returns the output of YAML.parse. Like in Ruby, there's “implicit return” so we don't need to write return YAML.parse(File.read(“config.yml”)) here - the last value evaluated in a method is automatically returned. Note that there's no error handling whatsoever, as this is just a bare bones example.

So far, so good. Now let's make the project do something a bit more interesting.

Let's replace the go! method with our BBS server main loop. This means that while the program is running, it will be doing an infinite loop always listening on a socket, and assigning new connections to a handler function.

First, we need to require more Crystal built-in functions. This time it will be Socket, which gives us a generic TCP server for free. So let's add to the top:

require "socket"

Now, in the main go! method, let's get the config setting for our server port:

telnet_port = @config["settings"]["port"].to_s.to_i

The weird .to_s.to_i first converts the YAML::Any value into a String, then into a Int32 (a number, which is what we need). It's not very good looking, so we'll optimize it later before we wrap-up the tutorial, but for now roll with it. Now, let's start the server itself:

server = TCPServer.new("localhost", telnet_port)
puts "now listening in port #{telnet_port}"
loop do
  if socket = server.accept?
    spawn handle_connection(socket)
  end
end

So we start a new TCPServer bound to localhost, on the port stored in the telnet_port variable. We then print a little info message, and go into the meat of this method - the main loop. The loop halts on the server.accept? blocking call, until a connection is made to the server. Once a connection is accepted, the socket variable will be assigned all the remote endpoint's details to it. With the details in hand, we will spawn a method call to handle_connection. Spawning is a concept unique to Crystal, and you can read an intro to it in the official docs on concurrency, but think of it as creating a new thread specifically to handle the connection. So the main loop here will continue immediately and be ready to accept new connections while the handle_connection method handles the connection we just got.

Finally, the new handler method:

private def handle_connection(socket)
  socket << "hello world\n"
  socket.close
end

This method will receive the socket info, write a string to it, and then close it. Let's try it out by running make - you should see:

$ make
... (make output omitted)
now listening in port 2023

Now with a telnet client (the normal command line one in macOS or Linux will do), you can connect to your running server:

$ telnet localhost 2023
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello world
Connection closed by foreign host.

Success! As expected, you can see the hello world message, and then the socket is closed!

This part of the tutorial is running a bit long, so let's just optimize the YAML settings code, since it looks ugly now, and ugly code is not our style.

Right now the issue is that Crystal had no idea what type each key or value of the file can be, so we always have to convert it to a type manually. Luckily, Crystal has a notion of a mapping that basically will do all the type converting work for us. The downside is that each new setting added will have to be present on the mapping, but think of it as documentation for your settings!

Let's add a new class within the main module in main.cr:

class Config
  YAML.mapping(
    settings: Hash(String, Int32)
  )
end

This will tell Crystal the Config class is a mapping from YAML that features a settings key. That key is a Hash that has String keys and Int32 values. If in the future we want to add settings with String values, we can change this to Hash(String, Int32 | String) but for now number values will suffice.

With this in place, we need to change the type annotation for the @config instance variable.

@config : Config

And the parser method needs to be adjusted too:

private def load_config
  Config.from_yaml(File.read("config.yml"))
end

Finally, we can access the settings in a less ugly way:

telnet_port = @config.settings["port"]

Much better! This concludes part one of the tutorial, tune in soon for part two where we will be negotiating features with the client! For reference, here's the whole code for the src/bbs/main.cr file:

require "yaml"
require "socket"
 
module BBS
  class Config
    YAML.mapping(
      settings: Hash(String, Int32)
    )
  end
 
  class Main
    @config : Config
 
    def initialize
      @config = load_config
    end
 
    def go!
      telnet_port = @config.settings["port"]
 
      server = TCPServer.new("localhost", telnet_port)
      puts "now listening in port #{telnet_port}"
      loop do
        if socket = server.accept?
          spawn handle_connection(socket)
        end
      end
    end
 
    private def handle_connection(socket)
      socket << "hello world\n"
      socket.close
    end
 
    private def load_config
      Config.from_yaml(File.read("config.yml"))
    end
  end
end
tutorials/crystal_bbs/part_one.txt · Last modified: 2018/03/29 01:58 (external edit)