View the source for this on github.
While working on a start up idea I have an interesting thought occurred to me: could I run Rails and a Crystal framework in parallel?
The "why" behind the idea
My side project idea revolves around a snippet of JS code that triggers a chat bot interaction, so I have two end points this snippet will interact with as part of this. Every time a page loads with this snippet a request gets sent to my app. This means adding a single customer could mean adding hundreds of thousands of requests per month. My first thought was dual-booting Rails with Sinatra or even Roda. However I really want to adopt Crystal as much as possible because I believe the language has enormous potential, so why not try and create a developer experience that is just like working with two ruby frameworks at the same time? Could I use this as a strangler approach to slowly move individual end-point behavior from Rails into Crystal and get the significant performance boost a compiled language has to offer?
For this article I'm going to walk you through step by step how to get Kemal running alongside your Rails app within the same directory to create a developer experience that feels like you're working on a pure Ruby app!
Getting started
Here's what we'll be using for this:
- Rails 7 (latest)
- Ruby 3.1
- Crystal 1.4
- Kemal (latest, currently v1.2)
- Nginx (latest provided by brew)
- Nodemon (an NPM package for watching for file changes)
- Foreman (the Ruby gem!)
There's no special compiling requirements for any of these dependencies, so use whatever package manager you have to install each of these. I highly recommend installing Nodemon globally with npm.
Step 1 Create the Rails app
Let's get our rails app started:
rails new crystal_rails
cd && bundle install
Step 2 Create the Crystal app
crystal init app kemal_api
This initializes a minimal Crystal application inside of our Rails root. There's no issue here because Rails does not autoload any files outside of the /app and /lib directories. Now we have a few clean up steps to do here.
Let's open the new kemal_api/shards.yml
file and add this snippet at the end of the file.
dependencies:
kemal:
github: kemalcr/kemal
You can delete these extra files in the kemal_api
directory: - README
- license
- .editorconfig
You'll also need to remove the git repo that was intialized for this folder. Make sure you've cd
'd into the kemal_api root and run rm -rf .git
and now your version tracking will be the same for the rails app and this sub-directory.
I recommend copying the contents from the new kemal_api/.gitignore into the .gitingnore file in your root, and modify the paths to be relative to the kemal_api directory like this:
/kemal_api/docs/
/kemal_api/lib/
/kemal_api/bin/
/kelam_api/.shards/
/kemal_api/*.dwarf
Crystal compiles all of the apps dependencies into the /lib folder and crystal can auto generate documentation from your code into the /docs/ folder, so this will prevent adding hundreds of unnecessary files into version control.
Now let's install the shards for Kemal:
cd kemal_api
shards install
Now you'll see a shard.lock
file in the crystal apps root directory.
Time to add Kemal and give it a test route. Open the src/kemal_api.cr file and modify it to match:
require "kemal"
# TODO: Write documentation for `KemalApi`
module KemalApi
VERSION = "0.1.0"
get "/api/v1/test" do
# This will be the HTML body in the response.
"Kemal works! This is our API endpoint :)"
end
Kemal.run
end
You'll notice the syntax for declaring a route is nearly identical to Sinatra... because Kemal is basically the Sinatra of Crystal! That's why my mind went to using this framework ;)
Step 3 Configuring Rails
Now we'll need to make a few small modifications in Rails to get both apps booting up at the same time.
When Rails 7 is configured to use importmaps (the default) you won't have the bin/dev
command available. That's alright, we'll go ahead and add a Procfile.dev
Back in the rails root, we need to modify or create our Procfile.dev and add a line for the new Kemal app we just setup.
If you are using jsbundling, you'll already have this file and you'll need to modify the web:
entry and add the api:
entry. Leave the css:
entry alone so you can still get your styling! :)
web: rails s -p 3001
api: cd kemal_api && nodemon --exec crystal run src/kemal_api.cr --watch src
Next let's add a basic root path page to load for our rails app.
rails g controller home
I'm going to keep this path the way it is for now, so we can see error pages and the successful path in both Rails and Kemal.
Step 4 Configuring Nginx
To have both apps co-exist on the same domain and port, we'll be setting up Nginx to act as a reverse proxy just like you would in a prod environment.
We'll need to edit the default Nginx server block and configs. If you don't know where this file is, you can run nginx -t
and Nginx will output the file path for you, along with doing a syntax check (very handy!)
Here is how I've setup my local Nginx:
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
# Paths to the Kemal application
location /api/v1/ {
proxy_pass http://0.0.0.0:3000$request_uri;
}
# Everything else going to Rails
location / {
proxy_set_header Host $http_host;
proxy_pass http://0.0.0.0:3001/$request_uri;
}
}
}
You'll need to restart or reload your Nginx service to get the updated configuration. On a mac with a homebrew installed Nginx this command is brew services restart nginx
.
Step 5 Testing our applications
In your terminal you can run foreman start -f Procfile.dev
and both apps should now boot up.
In your browser go to : http://localhost/home/index and you'll see how auto generated controller view!
Try going to the root path and you'll see the standard rails error page that the route does not exist.
Then visit http://localhost/api/v1/test and you'll see our Kemal message appearing!
Let's make a change in our kemal app by adding a route.
I'm going to add:
get "/api/v1/app-restart" do
"Kemal should now automatically restart"
end
As soon as I save the file, I can check the terminal and see the the change was detected and is now being restarted. Excellent!
Note If you have a firewall enabled on Mac, you will have to allow the outside connections every time it restarts. The way around this has been using the bin/dev
command that comes with Rails when jsbundling is chosen.
Success!
Now you know how easy it is to start building high performance API's with Crystal right alongside your Rails application. If you found this intriguing please leave a comment!
If there is enough interest, I'll try to write more about using an ORM within the Crystal app for interacting with your database.
Top comments (2)
This is pretty sweet! I especially think this could be useful for maybe a team trying to gain a little extra performance without having to migrate an entire Rails stack over to something new. Thanks for writing this up!
Now the next step is to get a Ruby gem that ports Rack functionality in to Crystal somehow so you can just mount a Kemal engine 😂
This approach is more efficient than using an engine because it skips the interpreter process of Ruby entirely, that's why I did it this way. I won't lie though I considered the engine first because it wouldn't require running NGINX locally. I have an ORM working in parallel with AR, so my next article is going to be around how to make that happen. That's where I think a gem may come in handy, and I've considered writing a gem that hooks into Rails so when the Rails generators are used it'll also generate models with the attributes into the crystal folder