[RUBY] Practice making a simple chat app with Docker + Sinatra

I often forget how to use Ruby, so I made a simple web application in Ruby for rehabilitation. Also, I will write down how to make it in case I forget it.

It's a fucking chat app called Nopochat. It is an epoch-making chat that you can send difficult-to-speak content while maintaining psychological safety by adding a cute ending of "-mo" to the post.

image.png

The contents introduced in this article are for practice writing programs only, and are not intended to be used in a production environment.

environment

Use Docker. No Ruby installation required.

Launch of Sinatra

First, cut an appropriate directory and start development.

$ mkdir sinatra-chat && cd $_

If you want to manage your project with Git, it's a good idea to fetch and set gitignore on GitHub.

$ git init
$ curl https://raw.githubusercontent.com/github/gitignore/master/Ruby.gitignore -o .gitignore

Get the version of Ruby you want to use from https://hub.docker.com/_/ruby. This time I will use 2.7-slim.

First, initialize Gemfile.

$ docker run --rm --volume $(pwd):/app --workdir /app ruby:2.7-slim bundle init
$ docker run --rm --volume $(pwd):/app --workdir /app ruby:2.7-slim bundle add sinatra

After confirming that Gemfile and Gemfile.lock have been generated, write Dockerfile.

Dockerfile


FROM ruby:2.7-slim
WORKDIR /app

COPY Gemfile ./
COPY Gemfile.lock ./
RUN bundle config --local set path 'vendor/bundle'
RUN bundle install

CMD bundle exec ruby index.rb

docker-compose.yml


version: '3'
services:
  app:
    build: .
    volumes:
      - .:/app
      - /app/vendor/bundle
    ports:
      - 127.0.0.1:4567:4567

Create ʻindex.rb`, which is the main body of the application.

index.rb


require 'sinatra'

configure do
  set :bind, '0.0.0.0'
end

get '/' do
  'Hello Sinatra!'
end

Go to http: // localhost: 4567 / and see Hello Sinatra!

Enable reloading by browser reload

As it is, even if you edit the file, it will not be reflected unless you restart the Sinatra server. It is useful to enable reloading on reload before starting development.

$ docker-compose run --rm app bundle add sinatra-contrib

index.rb


 require 'sinatra'
+require 'sinatra/reloader' if settings.development?

Development of chat function

Since the chat content will be persisted later, we will store it in a class variable called @@ chats for now.

index.rb


get '/' do
  @@chats ||= []
  erb :index, locals: {
    chats: @@chats.map{ |chat| add_suffix(chat) }.reverse
  }
end

post '/' do
  @@chats ||= []
  @@chats.push({ content: params['content'], time: Time.now } )
  redirect back
end

def add_suffix(chat)
  { **chat, content: "#{chat[:content]}Also" }
end

Use erb for HTML template. If you cut the directory views / and store ʻindex.erb there, you can call it with ʻerb: index.

views/index.erb


<form action="/" method="post">
  <input name="content" placeholder="Post" />
  <button type="submit">Send</button>
</form>

<table>
  <% chats.each do |chat| %>
    <tr>
      <td><%= chat[:content] %></td>
      <td><%= chat[:time] %></td>
    </tr>
  <% end %>
</table>

At a minimum, you should be able to chat.

Save to database

Save the chat content to MySQL. Install mysql2 Gem.

$ docker-compose run --rm app bundle add mysql2

Get your favorite version of MySQL from https://hub.docker.com/_/mysql and use it. Also, set the connection information as an environment variable for the app.

docker-compose.yml


 version: '3'
 services:
   app:
     build: .
     volumes:
       - .:/app
       - /app/vendor/bundle
     ports:
       - 127.0.0.1:4567:4567
+    environment:
+      - MYSQL_HOST=db
+      - MYSQL_USER=root
+      - MYSQL_PASS=secret
+      - MYSQL_DATABASE=nopochat_development
+  db:
+    image: mysql:5.7
+    volumes:
+      - .:/app
+      - /var/lib/mysql
+    environment:
+      - MYSQL_ROOT_PASSWORD=secret

index.rb


 require 'sinatra'
 require 'sinatra/reloader' if settings.development?
+require 'mysql2'
(The following is omitted)

Install the packages required to use mysql2 Gem.

Dockerfile


 FROM ruby:2.7-slim
 WORKDIR /app
+RUN apt-get update && apt-get install -y \
+  build-essential \
+  libmariadb-dev \
+  && apt-get clean \
+  && rm -rf /var/lib/apt/lists/*
(The following is omitted)

Define a method to get the database client based on the set environment variables.

index.rb


def db_client()
  Mysql2::Client.default_query_options.merge!(:symbolize_keys => true)
  Mysql2::Client.new(
    :host => ENV['MYSQL_HOST'],
    :username => ENV['MYSQL_USER'],
    :password => ENV['MYSQL_PASS'],
    :database => ENV['MYSQL_DATABASE']
  )
end

This time, the specification is to initialize the database when GET / initialize is accessed (although such a specification is not possible in actual operation ...)

index.rb


get '/initialize' do
  client = Mysql2::Client.new(
    :host => ENV['MYSQL_HOST'],
    :username => ENV['MYSQL_USER'],
    :password => ENV['MYSQL_PASS']
  )
  client.query("DROP DATABASE IF EXISTS #{ENV['MYSQL_DATABASE']}")
  client.query("CREATE DATABASE IF NOT EXISTS #{ENV['MYSQL_DATABASE']} CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci")
  client = db_client
  client.query(<<-EOS
      CREATE TABLE IF NOT EXISTS chats (
        id   INT AUTO_INCREMENT,
        name TEXT,
        content TEXT,
        time DATETIME,
        PRIMARY KEY(id)
    )
    EOS
  )
  redirect '/'
end

Define a method to get data in and out of a table called chats.

index.rb


def chat_push(content, name="Anonymous")
  db_client.prepare(
    "INSERT into chats (name, content, time) VALUES (?, ?, NOW())"
  ).execute(name, content)
end

def chats_fetch()
  db_client.query("SELECT * FROM chats ORDER BY time DESC")
end

Rewrite GET / and POST / using the defined methods.

index.rb


 get '/' do
-  @@chats ||= []
+  chats = chats_fetch
   erb :index, locals: {
-    chats: @@chats.map{ |chat| add_suffix(chat) }.reverse
+    chats: chats.map{ |chat| add_suffix(chat) }
   }
 end

 post '/' do
-  @@chats ||= []
-  @@chats.push({ content: params['content'], time: Time.now } )
+  chat_push(params['content'])
   redirect back
 end

Launch the app and access http: // localhost: 4567 / initialize to launch it. Now, even if you restart the app, the content you chatted with will not disappear.

Login function

DB (MySQL) is used for session storage. Define a ʻusers table with a username and password and a sessions` table to store sessions. Originally, password is encrypted so that it has a hash. You also need to periodically delete sessions.

index.rb


  client.query(<<-EOS
    CREATE TABLE IF NOT EXISTS users (
      id   INT AUTO_INCREMENT,
      name VARCHAR(255) UNIQUE,
      password TEXT,
      PRIMARY KEY(id),
      INDEX key_index (name)
    );
    EOS
  )
  client.query(<<-EOS
    CREATE TABLE IF NOT EXISTS sessions (
      id   INT AUTO_INCREMENT,
      session_id VARCHAR(255) UNIQUE,
      value_json JSON,
      PRIMARY KEY(id),
      INDEX key_index (session_id)
    );
    EOS
  )
  user_push('admin', 'admin')

Defines user addition / authentication processing.

index.rb


def user_push(name, pass)
  db_client.prepare(
    "INSERT into users (name, password) VALUES (?, ?)"
  ).execute(name, pass)
end

def user_fetch(name, pass)
  result = db_client.prepare("SELECT * FROM users WHERE name = ?").execute(name).first
  return unless result
  result[:password] == pass ? result : nil
end

Define the session addition / acquisition process.

index.rb


def session_save(session_id, obj)
  db_client.prepare(
    "INSERT into sessions (session_id, value_json) VALUES (?, ?)"
  ).execute(session_id, JSON.dump(obj))
end

def session_fetch(session_id)
  return if session_id == ""
  result = db_client.prepare("SELECT * FROM sessions WHERE session_id = ?").execute(session_id).first
  return unless result
  JSON.parse(result&.[](:value_json))
end

Add require'sinatra / cookies' to use cookies.

index.rb


 require 'sinatra'
 require 'sinatra/reloader' if settings.development?
+require 'sinatra/cookies'
 require 'mysql2'

Define POST / login and GET / login.

index.rb


post '/login' do
  if user = user_fetch(params['name'], params['pass'])
    cookies[:session_id] = SecureRandom.uuid if cookies[:session_id].nil? || cookies[:session_id] == ""
    session_save(cookies[:session_id], { name: user[:name] })
  end
  redirect back
end

get '/logout' do
  cookies[:session_id] = nil
  redirect back
end

Modify GET / and POST /.

index.rb


 get '/' do
+  name = session_fetch(cookies[:session_id])&.[]("name")
   chats = chats_fetch
   erb :index, locals: {
+    name: name,
     chats: chats.map{ |chat| add_suffix(chat) }
   }
 end

 post '/' do
-  chat_push(params['content'])
+  name = session_fetch(cookies[:session_id])&.[]("name")
+  chat_push(params['content'], name)
   redirect back
 end

Rewrite the form part of View so that the login form is displayed when you are not logged in and the post form is displayed after login.

vieqs/index.erb


<% if name %>
  <p>Hello<%= name %>Mr.</p>
  <a href="/logout">Log out</a>
  <form action="/" method="post">
    <input name="content" placeholder="Post" />
    <button type="submit">Send</button>
  </form>
<% else %>
  <form action="login" method="post">
    <input name="name" placeholder="User name">
    <input name="pass" placeholder="password">
    <button type="submit">Login</button>
  </form>
<% end %>

If you can access http: // localhost: 4567 / initialize and log in as the ʻadmin` user, you are successful.

Multiple App

Modify docker-compose.yml as follows. Make two apps and add a new Nginx container that will be the web server. Close port 4567 in Sinatra and open port 8080 for Nignx.

For Nginx, get your favorite version from https://hub.docker.com/_/nginx and use it.

docker-compose.yml


 version: '3'
 services:
-  app:
+  app1: &app
     build: .
     volumes:
       - .:/app
       - /app/vendor/bundle
-    ports:
-      - 127.0.0.1:4567:4567
     environment:
       - MYSQL_HOST=db
       - MYSQL_USER=root
       - MYSQL_PASS=secret
       - MYSQL_DATABASE=nopochat_development
+  app2:
+    <<: *app
+  web:
+    image: nginx:1.19-alpine
+    volumes:
+      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
+    ports:
+      - 127.0.0.1:8080:80
(The following is omitted)

Place the Nginx configuration file. Try to sort out ʻapp1 and ʻapp2.

nginx/default.conf


upstream apps {
  server app1:4567;
  server app2:4567;
}

server {
  listen 80;
  proxy_set_header Host $host:8080;

  location / {
    proxy_pass http://apps;
  }
}

Go to http: // localhost: 8080 /. If the login process is not successful, the session will switch and you will be logged out each time you access.

Call Rust from Ruby

The process of adding "mo" to the end of the word mentioned at the beginning

def add_suffix(chat)
  { **chat, content: "#{chat[:content]}Also" }
end

Let's write this in Rust and call it from Ruby.

Get your favorite version from https://hub.docker.com/_/rust. This time we will use rust: 1.46-slim. Create a Rust project in a directory called rust_lib with the following command.

$ docker run \
  --rm \
  --volume $(pwd):/app \
  --workdir /app \
  --env USER=root \
  rust:1.46-slim cargo new rust_lib --lib
$ cd rust_lib 

If you like, take it from .gitignore on GitHub and set it.

$ curl https://raw.githubusercontent.com/github/gitignore/master/Rust.gitignore -o .gitignore

Add libc crate in Cargo and specify crate-type to " dylib ".

rust_lib/Cargo.toml


[dependencies]
libc = "0.2.77"

[lib]
name = "rust_lib"
crate-type = ["dylib"]

I will write the process in Rust.

rust_lib/src/lib.rs


extern crate libc;
use libc::*;
use std::ffi::{CStr, CString};

#[no_mangle]
pub extern fn add_suffix(s: *const c_char) -> CString {
    let not_c_s = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
    let not_c_message = format!("{}Also", not_c_s);
    CString::new(not_c_message).unwrap()
}

Build.

$ docker run \
  --rm \
  --volume $(pwd):/app \
  --workdir /app \
  rust:1.46-slim cargo build

If rust_lib / target / release / librust_lib.so is built, it is successful.

$ nm target/release/librust_lib.so | grep add_suffix
00000000000502c0 T add_suffix

Ruby FFI I will write a process to call Rust from Ruby using Gem.

$ docker-compose run --rm app1 bundle add ffi

index.rb


 require 'sinatra'
 require 'sinatra/reloader' if settings.development?
 require 'sinatra/cookies'
 require 'mysql2'
+require 'ffi'

ʻExtend FFI :: Library and load and use rust_lib / target / release / librust_lib.so`. Specify the argument and return type by referring to FFI wiki.

index.rb


#Module settings to call from Rust
module RustLib
  extend FFI::Library
  ffi_lib('rust_lib/target/release/librust_lib.so')
  attach_function(:add_suffix, [:string], :string)
end

Modify GET /.

index.rb


 get '/' do
   name = session_fetch(cookies[:session_id])&.[]("name")
   chats = chats_fetch
   erb :index, locals: {
     name: name,
-    chats: chats.map{ |chat| add_suffix(chat) }
+    chats: chats.map{ |chat| { **chat, content: RustLib::add_suffix(chat[:content]).force_encoding("UTF-8") } }
   }
 end

that's all. The finished product is as follows.

https://github.com/s2terminal/sinatra-chat

reference

Recommended Posts

Practice making a simple chat app with Docker + Sinatra
A simple CRUD app made with Nuxt / Laravel (Docker)
[docker] [nginx] Make a simple ALB with nginx
Create a simple search app with Spring Boot
Create a docker image that runs a simple Java app
Create a Chat app with WebSocket (Tyrus) + libGDX + Kotlin
I made a chat app.
The basics of the process of making a call with an Android app
Create a Vue3 environment with Docker!
[Rails] I made a simple calendar mini app with customized specifications.
Build a Node.js environment with Docker
Make a language! (Making a simple calculator ②)
Creating a timer app with a muddy
Prepare a transcendentally simple PHP & Apache environment on Mac with Docker
Operate a honeypot (Dionaea) with Docker
Make a language! (Making a simple calculator ①)
Decorate your Sinatra app with CSS
Make a family todo list with Sinatra
[Rails6] Create a new app with Rails [Beginner]
Create a simple web application with Dropwizard
Build a PureScript development environment with Docker
Create a simple on-demand batch with Spring Batch
[Rails withdrawal] Create a simple withdrawal function with rails
Create a MySQL environment with Docker from 0-> 1
Draw a graph with Sinatra and Chartkick
Create a simple bar chart with MPAndroidChart
Make a family todo list with Sinatra
Sinatra app with ActiveRecord died in Passenger 6.0.5
Build a Wordpress development environment with Docker
Try making a calculator app in Java
I made a rock-paper-scissors app with kotlin
[Rails 5] Create a new app with Rails [Beginner]
Implement simple CRUD with Go + MySQL + Docker
I made a rock-paper-scissors app with android
Build a simple Docker + Django development environment
I tried to make a machine learning application with Dash (+ Docker) part3 ~ Practice ~
[Memo] Create a CentOS 8 environment easily with Docker
Create a simple bulletin board with Java + MySQL
Build a Laravel / Docker environment with VSCode devcontainer
Build a WordPress development environment quickly with Docker
Build a Kotlin app using OpenJDK's Docker container
Create a team chat with Rails Action Cable
Build a simple Docker Compose + Django development environment
CICS-Run Java application-(1) Run a simple sample app
Prepare a scraping environment with Docker and Java
Show a simple Hello World with SpringBoot + IntelliJ
A simple rock-paper-scissors game with JavaFX and SceneBuilder
Create a Spring Boot development environment with docker