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.
The contents introduced in this article are for practice writing programs only, and are not intended to be used in a production environment.
Use Docker. No Ruby installation required.
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!
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?
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 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.
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.
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.
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
Recommended Posts