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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50391/c8f7b969-035b-fb82-804b-088e09efd619.png)

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

## 環境
Dockerを利用します。Rubyのインストールは必要ありません。

- Docker version 19.03.12
- docker-compose version 1.26.2

## Sinatraの立ち上げ
まず適当なディレクトリを切って、開発を始めます。

```bash
$ mkdir sinatra-chat && cd $_
```

プロジェクトをGitで管理する場合は、[GitHubのgitignore](https://github.com/github/gitignore/blob/master/Ruby.gitignore)を取ってきてセットするのが良いと思います。

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

https://hub.docker.com/_/ruby から使いたいバージョンのRubyイメージを取ってきてください。今回は`2.7-slim`を使います。

まず`Gemfile`を初期化します。

```bash
$ 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`を記述していきます。

```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サーバを再起動しなければ反映されません。
開発を始める前に、リロードでの再読み込みを有効にすると便利です。

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

```diff: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`で呼び出すことができます。

```erb: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](https://github.com/brianmario/mysql2)をインストールします。

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

https://hub.docker.com/_/mysql からMySQLの好きなバージョンを取ってきて使います。またappの方にも接続情報を環境変数としてセットします。

```diff: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
```

```diff:index.rb
 require 'sinatra'
 require 'sinatra/reloader' if settings.development?
+require 'mysql2'
(以下略)
```

mysql2 Gemの利用に必要なパッケージをインストールします。

```diff: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 /`を書き換えます。

```diff: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'`を追加します。

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

`POST /login`と`GET /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 /`を修正します。

```diff: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のフォーム部分を書き換え、ログインしていない時はログインフォームが、ログイン後には投稿フォームが表示されるようにします。

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

```diff: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の設定ファイルを配置します。`app1`と`app2`を振り分けるようにします。

```conf: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を呼び出す
序盤に挙げた、語尾に「も」を付与する処理

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

これをRustで書いてRubyから呼び出してみます。

https://hub.docker.com/_/rust から好きなバージョンを取ってきます。今回は`rust:1.46-slim`を使います。
下記コマンドで、`rust_lib`というディレクトリにRustのプロジェクトを作成します。

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

```bash
$ cd rust_lib 
```

お好みで[GitHubの.gitignore](https://github.com/github/gitignore/blob/master/Rust.gitignore)から取ってきてセットします。

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

Cargoで[libc crate](https://crates.io/crates/libc)を追加、crate-typeを`"dylib"`に指定します。

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

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

Rustで処理を書いていきます。

```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()
}
```

ビルドします。

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

`rust_lib/target/release/librust_lib.so`がビルドされていれば成功です。

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

[Ruby FFI](https://github.com/ffi/ffi) Gemを使って、RubyからRustを呼び出す処理を書いていきます。

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

```diff: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](https://github.com/ffi/ffi/wiki/Types)を参考に、引数と返り値の型を指定します。

```ruby: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 /`を修正します。

```diff: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
```

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

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

## 参考
- [Sinatra: README \(Japanese\)](http://sinatrarb.com/intro-ja.html)
- [brianmario/mysql2: A modern, simple and very fast Mysql library for Ruby \- binding to libmysql](https://github.com/brianmario/mysql2)
- [RubyからRustの関数をつかう → はやい \- Qiita](https://qiita.com/rejasupotaro/items/2aa57a67f4a97101950c)
- [ffi/ffi: Ruby FFI](https://github.com/ffi/ffi)
- [Linkage \- The Rust Reference](https://doc.rust-lang.org/reference/linkage.html)
- [How do I convert a C string into a Rust string and back via FFI? \- Stack Overflow](https://stackoverflow.com/questions/24145823/how-do-i-convert-a-c-string-into-a-rust-string-and-back-via-ffi/24148033#24148033)