LoginSignup
10
9

More than 3 years have passed since last update.

Docker+Sinatraで簡単なチャットアプリをつくる練習

Last updated at Posted at 2020-09-16

Rubyの使い方をよく忘れるので、リハビリ用にRubyで簡単なWebアプリを作りました。また忘れた時のために、作る方法を書き残しておきます。

つくるのはNopochatというクソチャットアプリです。
投稿に「~も」というかわいらしい語尾が付くことで、言いづらい内容も心理的安全性を保ったまま送ることができる画期的なチャットですも。

image.png

なお本稿で紹介する内容はあくまでプログラムを書く練習のためのものであり、本番環境で運用することは想定していません。

環境

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

GemfileGemfile.lockが生成されたのを確認したら、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

アプリケーションの本体となるindex.rbを作成します。

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
index.rb
 require 'sinatra'
+require 'sinatra/reloader' if settings.development?

チャット機能の開発

チャット内容は後で永続化するので、とりあえず今は@@chatsというクラス変数に格納していきます。

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]}も" }
end

HTMLテンプレートにはerbを使います。views/というディレクトリを切って、そこにindex.erbを格納すると、erb :indexで呼び出すことができます。

views/index.erb
<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の方にも接続情報を環境変数としてセットします。

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'
(以下略)

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/*
(以下略)

セットした環境変数に基づいてデータベースクライアントを取得するメソッドを定義します。

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

今回はGET /initializeにアクセスしたらデータベースを初期化する仕様とします(実際の運用ではこのような仕様はあり得ませんが...)

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

chatsというテーブルからデータを出し入れするメソッドを定義します。

index.rb
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 /を書き換えます。

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

アプリを起動して http://localhost:4567/initialize にアクセスすると、立ち上がります。
これでアプリを再起動しても、いちどチャットした内容が消えることはありません。

ログイン機能

セッションストレージにはDB(MySQL)を利用します。
ユーザ名とパスワードを持つusersテーブルと、セッションを保存するsessionsテーブルを定義します。なお本来passwordは暗号化してハッシュを持つようにします。また、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')

ユーザの追加・認証処理を定義します。

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

セッションの追加・取得処理を定義します。

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

cookieを使うためrequire 'sinatra/cookies'を追加します。

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

POST /loginGET /logoutを定義します。

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

GET /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

Viewのフォーム部分を書き換え、ログインしていない時はログインフォームが、ログイン後には投稿フォームが表示されるようにします。

vieqs/index.erb
<% 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 から好きなバージョンを取ってきて使います。

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
(以下略)

Nginxの設定ファイルを配置します。app1app2を振り分けるようにします。

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;
  }
}

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"に指定します。

rust_lib/Cargo.toml
[dependencies]
libc = "0.2.77"

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

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!("{}も", 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
index.rb
 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を参考に、引数と返り値の型を指定します。

index.rb
# Rustから呼び出すモジュールの設定
module RustLib
  extend FFI::Library
  ffi_lib('rust_lib/target/release/librust_lib.so')
  attach_function(:add_suffix, [:string], :string)
end

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

以上です。完成品は下記になります。

参考

10
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
9