Rubyの使い方をよく忘れるので、リハビリ用にRubyで簡単なWebアプリを作りました。また忘れた時のために、作る方法を書き残しておきます。
つくるのはNopochatというクソチャットアプリです。
投稿に「~も」というかわいらしい語尾が付くことで、言いづらい内容も心理的安全性を保ったまま送ることができる画期的なチャットですも。
なお本稿で紹介する内容はあくまでプログラムを書く練習のためのものであり、本番環境で運用することは想定していません。
環境
Dockerを利用します。Rubyのインストールは必要ありません。
- Docker version 19.03.12
- docker-compose version 1.26.2
Sinatraの立ち上げ
まず適当なディレクトリを切って、開発を始めます。
$ mkdir sinatra-chat && cd $_
プロジェクトをGitで管理する場合は、GitHubのgitignoreを取ってきてセットするのが良いと思います。
$ git init
$ curl https://raw.githubusercontent.com/github/gitignore/master/Ruby.gitignore -o .gitignore
https://hub.docker.com/_/ruby から使いたいバージョンのRubyイメージを取ってきてください。今回は2.7-slim
を使います。
まず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
Gemfile
とGemfile.lock
が生成されたのを確認したら、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
version: '3'
services:
app:
build: .
volumes:
- .:/app
- /app/vendor/bundle
ports:
- 127.0.0.1:4567:4567
アプリケーションの本体となるindex.rb
を作成します。
require 'sinatra'
configure do
set :bind, '0.0.0.0'
end
get '/' do
'Hello Sinatra!'
end
http://localhost:4567/ にアクセスし、Hello Sinatra!
と表示されたら成功です
ブラウザリロードによる再読み込みの有効化
そのままでは、ファイルを編集してもSinatraサーバを再起動しなければ反映されません。
開発を始める前に、リロードでの再読み込みを有効にすると便利です。
$ docker-compose run --rm app bundle add sinatra-contrib
require 'sinatra'
+require 'sinatra/reloader' if settings.development?
チャット機能の開発
チャット内容は後で永続化するので、とりあえず今は@@chats
というクラス変数に格納していきます。
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]}も" }
end
HTMLテンプレートにはerbを使います。views/
というディレクトリを切って、そこにindex.erb
を格納すると、erb :index
で呼び出すことができます。
<form action="/" method="post">
<input name="content" placeholder="投稿" />
<button type="submit">送信</button>
</form>
<table>
<% chats.each do |chat| %>
<tr>
<td><%= chat[:content] %></td>
<td><%= chat[:time] %></td>
</tr>
<% end %>
</table>
これで最低限、チャットができるようになります。
データベースへの保存
チャット内容をMySQLに保存します。mysql2 Gemをインストールします。
$ docker-compose run --rm app bundle add mysql2
https://hub.docker.com/_/mysql からMySQLの好きなバージョンを取ってきて使います。またappの方にも接続情報を環境変数としてセットします。
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
require 'sinatra'
require 'sinatra/reloader' if settings.development?
+require 'mysql2'
(以下略)
mysql2 Gemの利用に必要なパッケージをインストールします。
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/*
(以下略)
セットした環境変数に基づいてデータベースクライアントを取得するメソッドを定義します。
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
今回はGET /initialize
にアクセスしたらデータベースを初期化する仕様とします(実際の運用ではこのような仕様はあり得ませんが...)
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
chatsというテーブルからデータを出し入れするメソッドを定義します。
def chat_push(content, name="名無し")
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
定義したメソッドを使って、GET /
とPOST /
を書き換えます。
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
アプリを起動して http://localhost:4567/initialize にアクセスすると、立ち上がります。
これでアプリを再起動しても、いちどチャットした内容が消えることはありません。
ログイン機能
セッションストレージにはDB(MySQL)を利用します。
ユーザ名とパスワードを持つusers
テーブルと、セッションを保存するsessions
テーブルを定義します。なお本来passwordは暗号化してハッシュを持つようにします。また、sessionsの定期的な削除処理も必要です。
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')
ユーザの追加・認証処理を定義します。
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
セッションの追加・取得処理を定義します。
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
cookieを使うためrequire 'sinatra/cookies'
を追加します。
require 'sinatra'
require 'sinatra/reloader' if settings.development?
+require 'sinatra/cookies'
require 'mysql2'
POST /login
とGET /logout
を定義します。
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
GET /
とPOST /
を修正します。
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
Viewのフォーム部分を書き換え、ログインしていない時はログインフォームが、ログイン後には投稿フォームが表示されるようにします。
<% if name %>
<p>こんにちは<%= name %>さん</p>
<a href="/logout">ログアウト</a>
<form action="/" method="post">
<input name="content" placeholder="投稿" />
<button type="submit">送信</button>
</form>
<% else %>
<form action="login" method="post">
<input name="name" placeholder="ユーザ名">
<input name="pass" placeholder="パスワード">
<button type="submit">ログイン</button>
</form>
<% end %>
http://localhost:4567/initialize にアクセスし、admin
ユーザでログインできたら成功です。
Appの複数台化
docker-compose.yml
を下記のように修正します。
appを2つにして、あらたにWebサーバとなるNginxのコンテナを追加します。
Sinatraの4567ポートを閉じて、Nignx用の8080ポートを開けます。
Nginxは https://hub.docker.com/_/nginx から好きなバージョンを取ってきて使います。
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
(以下略)
Nginxの設定ファイルを配置します。app1
とapp2
を振り分けるようにします。
upstream apps {
server app1:4567;
server app2:4567;
}
server {
listen 80;
proxy_set_header Host $host:8080;
location / {
proxy_pass http://apps;
}
}
http://localhost:8080/ にアクセスします。もしログイン処理がうまくできていないと、アクセスするたびにセッションが切り替わってログアウトしたりします。
RubyからRustを呼び出す
序盤に挙げた、語尾に「も」を付与する処理
def add_suffix(chat)
{ **chat, content: "#{chat[:content]}も" }
end
これをRustで書いてRubyから呼び出してみます。
https://hub.docker.com/_/rust から好きなバージョンを取ってきます。今回はrust:1.46-slim
を使います。
下記コマンドで、rust_lib
というディレクトリにRustのプロジェクトを作成します。
$ docker run \
--rm \
--volume $(pwd):/app \
--workdir /app \
--env USER=root \
rust:1.46-slim cargo new rust_lib --lib
$ cd rust_lib
お好みでGitHubの.gitignoreから取ってきてセットします。
$ curl https://raw.githubusercontent.com/github/gitignore/master/Rust.gitignore -o .gitignore
Cargoでlibc crateを追加、crate-typeを"dylib"
に指定します。
[dependencies]
libc = "0.2.77"
[lib]
name = "rust_lib"
crate-type = ["dylib"]
Rustで処理を書いていきます。
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!("{}も", not_c_s);
CString::new(not_c_message).unwrap()
}
ビルドします。
$ docker run \
--rm \
--volume $(pwd):/app \
--workdir /app \
rust:1.46-slim cargo build
rust_lib/target/release/librust_lib.so
がビルドされていれば成功です。
$ nm target/release/librust_lib.so | grep add_suffix
00000000000502c0 T add_suffix
Ruby FFI Gemを使って、RubyからRustを呼び出す処理を書いていきます。
$ docker-compose run --rm app1 bundle add ffi
require 'sinatra'
require 'sinatra/reloader' if settings.development?
require 'sinatra/cookies'
require 'mysql2'
+require 'ffi'
extend FFI::Library
して、先程のrust_lib/target/release/librust_lib.so
を読み込んで使います。FFIのwikiを参考に、引数と返り値の型を指定します。
# Rustから呼び出すモジュールの設定
module RustLib
extend FFI::Library
ffi_lib('rust_lib/target/release/librust_lib.so')
attach_function(:add_suffix, [:string], :string)
end
GET /
を修正します。
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
以上です。完成品は下記になります。