Sinatra + Dockerで簡単なToDoアプリ

## はじめに

今回、Dockerを勉強するついでに同じRubyのWEBアプリケーションフレームワークであるSinatraを触ってみたいと思い、Docker+Sinatraで簡単なToDoアプリの開発を行いました。

開発環境での動作しか確認していませんが、開発にあたって学んだこと、失敗したことなどを備忘録的に書き記そうと思います。

## 開発環境

- M2 MacbookAir
- ターミナル：Alacritty
- エディタ：Vim
- Docker：23.0.5, build bc4487a
    
```ruby:.ruby-version
3.1.4
```    
    
```ruby:Gemfile.lock
GEM
  remote: https://rubygems.org/
  specs:
    mustermann (3.0.0)
      ruby2_keywords (~> 0.0.1)
    nio4r (2.5.9)
    pg (1.5.4)
    puma (6.4.0)
      nio4r (~> 2.0)
    rack (2.2.8)
    rack-protection (3.1.0)
      rack (~> 2.2, >= 2.2.4)
    ruby2_keywords (0.0.5)
    sinatra (3.1.0)
      mustermann (~> 3.0)
      rack (~> 2.2, >= 2.2.4)
      rack-protection (= 3.1.0)
      tilt (~> 2.0)
    tilt (2.3.0)

PLATFORMS
  aarch64-linux

DEPENDENCIES
  pg
  puma
  sinatra

BUNDLED WITH
   2.4.21
```
    

## 導入

### 環境構築

1. 作業用ディレクトリの作成
    
    ```zsh
    $ mkdir sinatra_test && cd $_
    $ mkdir app
    ```
    
    ※ `$_`は直前の引数を取得するコマンド。今回でいう`sinatra_test`。
    <br>
    
2. Gitの監視下に置き、.gitignoreを作成する
    
    ```zsh
    $ git init
    $ curl https://raw.githubusercontent.com/github/gitignore/main/Ruby.gitignore -o .gitignore
    ```
    :::note info
    **curlコマンドで行っている処理の解説**
            
    `curl`コマンドの`-o`オプションはcurlで指定したURLの実行結果をファイルに出力することができる。つまり、今回のコマンドではgithub上のファイルをコピーして.gitignoreに出力する。
    
    .gitignoreはテンプレートが用意されており、[A collection of .gitignore templates](https://github.com/github/gitignore)から対象の言語やフレームワークに合わせてテンプレートをコピーすることができる。
    
    今回はRuby.gitignoreを選択
    
    ![スクリーンショット 2023-10-26 22.44.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3326737/aa337e3c-6a0b-ad06-7bb3-9d77c8711b3a.png)
    
    コード右上の`Raw`をクリックし、URLをコピーして`curl`コマンドを実行する
    
    ![スクリーンショット 2023-10-26 22.50.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3326737/bc039e9c-bfb3-b7c6-8301-a241421a743b.png)
    
        :::

3. Dockerfileとcompose.ymlを作成する

    ```zsh
    $ touch Dockerfile compose.yml
    ```
    <br>
    
4. Gemfileを生成する
    
    ```zsh
    $ gem install bundler
    
    # 私のフォルダ構成に合わせるためにapp/に移動していますが、移動する必要はありません。
    # ただし移動しない場合Dockerfileの記述が変わります。
    $ cd app
    
    $ bundle init
    ```
    

## Dockerの設定

`Dockerコンテナ`（作業環境）を作成する為の指示を記載した`Dockerイメージ`の設計図。
`docker build`コマンドでDockerfileを元にDockerイメージを作成できる。

```dockerfile:Dockerfile
    # syntax=docker/dockerfile:1
    
    FROM ruby:3.1.4
    
    RUN apt-get update -qq && \
        apt-get install -y build-essential libpq-dev && \
        apt-get clean && \
        rm -rf /var/lib/apt/lists/*
    
    WORKDIR /app
    COPY app/Gemfile* ./
    RUN gem install bundler && bundle install
    COPY app/ .
    EXPOSE 3000
    CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
```
    
### Dockerfileについて
        
`CMD`コマンド以外のコマンドはイメージ作成時（build）に実行される
    
```dockerfile
# syntax=docker/dockerfile:1
```

Dockerのreferenceで**`docker/dockerfile:1`** の使用を推奨されているため記述。

```dockerfile
FROM ruby:3.1.4
```

`FROM`コマンドはビルドに用いるベースイメージを公開リポジトリから取得するためのコマンド。今回はローカルの環境に合わせてRubyの3.1.4を取得しているが、環境に応じて複数のベースイメージを取得することもできる。

```dockerfile
RUN apt-get update -qq && \
    apt-get install -y build-essential libpq-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
```

`apt-get update -qq`
    
パッケージのリストをアップデートしてインストール可能な最新パッケージのリストを取得。`-qq`オプションをつけることでエラーメッセージ以外のメッセージを抑制できる。
    
`apt-get install`
    
先ほど取得した最新パッケージリストに従ってインストールを行なっており、`-y`オプションで全ての問いに自動でyを入力する様にしている。
    
`build-essential`
    
 開発に必須のビルドツールを提供しているパッケージで、Ruby自体のコンパイルや、CやC++で書かれた部分を含むgemのコンパイルを行うことができる。
    
`libpq-dev`
    
PostgreSQLクライアントのヘッダやライブラリを含むパッケージで、Linuxベースの開発環境でPostgreSQLを利用する際に必要になる可能性が高いパッケージ。
    
`apt-get clean`,`rm -rf /var/lib/apt/lists/*`
    
後処理で、`apt-get clean`はダウンロードしたパッケージファイルを削除しており、`rm -rf /var/lib/apt/lists/*`はリポジトリのパッケージリストを削除している。
    
後処理がない場合はビルドが高速化されるが、イメージファイルが大きくなってしまう。

```dockerfile
WORKDIR /app
COPY app/Gemfile* ./
RUN gem install bundler && bundle install
COPY app/ .
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
```

`WORKDIR /app`
   
コンテナ内での作業ディレクトリの設定。このコマンドは指定したディレクトリが存在しない場合、新規作成する。今回の場合、appというフォルダをルートフォルダの直下に作成している。
    
`COPY app/Gemfile* ./`

ローカルのapp直下にあるGemfileと名のつくファイル（GemfileとGemfile.lock）を作業ディレクトリの直下にコピー。
    
`RUN gem install bundler && bundle install`
    
コンテナ内でbundlerのインストールとgemのインストールを実行。
    
`COPY app/ .`
    
ローカルのappフォルダにあるファイルを全て作業ディレクトリにコピー。
    
`EXPOSE 3000`
    
コンテナの公開ポートを3000に設定。
    
- `CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]`
    
`RUN`コマンドと違い、コンテナ開始時(start)あるいはコンテナ作成時(run）に実行され、Gemfile.lockに書かれたバージョンのpumaを起動する。`-C`オプションで、設定ファイルにconfig/puma.rbを読み込む。

### compose.yamlについて
    
複数のコンテナを使うDockerアプリケーションを定義・実行するためのツール。以前はPythonで開発されたものがDockerと別ツールとして用意されていたが、Goで再開発されDockerコマンドに統合された。（`docker-compose`から`docker compose`になった）

また、以前は`docker-compose.yml`というファイル名だったが、`compose.yaml`が推奨になり`version`の記載も非推奨になった。
    
```docker:compose.yaml
services:
  web:
    build: .
    ports:
      - "3000:3000"
    depends_on:
      - db
    environment:
      - DATABASE_URL=postgresql://user:password@db:5432/mydb
      - TZ=Asia/Tokyo
    volumes:
      - ./app:/app
  db:
    image: postgres:latest
    environment:
      - POSTGRES_DB=mydb
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
    volumes:
      - pg-data:/var/lib/postgresql/data

volumes:
  pg-data:
```

- `services:`
    アプリケーションを動かすための各要素を`Service`と呼ぶ。各コンテナの設定について定義。
    - `web:`（コンテナ名）
        アプリケーションを動かすために使っている各要素の名前を書く。dockerのログに表示されるので、`app:`,`web:`,`db:`など分かりやすい名前が望ましい。
        - `build .`
            コンテナイメージを作成するための構築情報を指定する。Dockerfileの格納場所を指す。
        - `ports`
            今回は`ホスト:コンテナ`の形で指定。プロトコルを指定することもできる。
        - `depends_on`
            サービス間の起動順と終了順の依存関係を書く。今回の例では`web`の前に`db`が作成される（終了は逆順）。
        - `environment`
            コンテナ内の環境変数を定義する。
            `DATABASE_URL`：(user_name):(password)@db:(DB_port)/(DB_name)
            `TZ`：Asia/Tokyo
        - `volumes`
            サービスコンテナがアクセスできる様にする必要があるホスト上のパスか、名前付きのボリュームを定義する。
            
    - `db:`
        PostgreSQLを動かすためのコンテナ。
        - `image`
            コンテナの下になるベースイメージを指定する。
            今回はDockerfileのFROMと同様、公式で用意されたpostgreSQLのイメージ
            
    - `volumes`
        コンテナ間で共有されるデータの保存場所を定義する。
        今回はdb:のvolumes:で定義したpg-dataがwebとdbで共有するデータの保存場所としている。
                

## Sinatra/Puma/PostgreSQLの導入
    
```ruby:Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem "sinatra"
gem "puma"
gem "pg"
```
    

## Pumaについて

Pumaはマルチスレッドで動作するコンカレントなWEBサーバを提供するgem。
Railsには標準で導入されており`rails s`で動作するWEBサーバはデフォルトではPuma。

Sinatraはリクエストを1つずつしか処理できないため、DBを操作する様なレスポンスに時間がかかる処理の合間に別のリクエストが来た場合処理できないという弱点がある。

PumaはWEBサーバだが、アプリケーションサーバとしても動作することができ、nginxなど他のWEBサーバを介さなくてもHTMLリクエストを受け取りSinatraの処理をすることができる。

- Rackのサーバ起動設定・Pumaの設定（config.ru・config/puma.rb）
    
    ```ruby
    # config.ru
    require './app'
    run Sinatra::Application
    
    # config/puma.rb
    workers 3
    preload_app!
    
    port ENV.fetch("PORT") { 3000 }
    ```
    
    config.ruはPuma公式ドキュメントのFrameworksを参照
    config/puma.rbに関して、`workers`は複数のプロセスを1つの単位にまとめたもの。
    Pumaでは`threads`でスレッド数を設定する。
    スレッドは1つにつき1つのリクエストを処理し、1つのワーカーは複数のスレッド（デフォルトでは最大で5つ）を持つことができる。
    `preload_app!`はfork前にアプリをロードすることで、メモリ消費量を減らせる
    `port`でlocal環境のport番号を3000に変更
    

## Sinatraのコード（タスク管理アプリ）

Sinatraではルーティングテーブルに

Sinatraではルーティングテーブルにビジネスロジックが直接関連づけられる。
つまり、ルーティングテーブルとして作成するファイル（今回はapp.rb）によってControllerなどを介さずにデータの処理が行われ、Viewファイルに受け渡す。

```ruby:app.rb
# frozen_string_literal: true

require 'sinatra'
require 'pg'

enable :method_override

get '/' do
  conn = db_connection
  @tasks = conn.exec('SELECT * FROM tasks')
  conn.close

  erb :"tasks/index"
end

post '/tasks/create' do
  data = JSON.parse(request.body.read)
  title = data['title']

  conn = db_connection
  conn.exec_params('INSERT INTO tasks (title) VALUES ($1)', [title])
  conn.close

  content_type :json
  { status: 'success', message: 'Task added successfully' }.to_json
end

delete '/tasks/:taskId' do
  task_id = params[:taskId]

  conn = db_connection
  conn.exec_params('DELETE FROM tasks WHERE id = ($1)', [task_id])
  conn.close

  content_type :json
  { status: 'success', message: 'Task deleted successfully' }.to_json
end

def db_connection
  PG.connect(
    dbname: 'mydb',
    user: 'user',
    password: 'password',
    host: 'db'
  )
end
```
    
```ruby:views/tasks/index.erb
<!DOCTYPE html>
<html>
  <head>
    <meta charset='UTF-8'>
    <title>Sinatra_test</title>
    <script src='/javascripts/app.js'></script>
  </head>
  <body>
    <h1>タスク管理アプリ</h1>
    <input type='text' id='input-task-form'>
    <button id='add-task-button'>追加</button>
    <table>
      <thead>
        <tr>
          <th>タイトル</th>
          <th>作成日時</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        <% @tasks.each do |task| %>
          <tr>
            <td><%= task['title'] %></td>
            <td><%= Time.parse(task['created_at']).strftime('%Y-%m-%d %H:%M:%S') %></td>
            <td><button id=<%= "delete-task-button#{task['id']}" %> data-task-id=<%= task['id'] %>>削除</button></td>
          </tr>
        <% end %>
      </tbody>
    </table>
  </body>
</html>
```
    
```ruby:public/javascripts/app.js
document.addEventListener('DOMContentLoaded', () => {
  const inputForm     = document.getElementById('input-task-form');
  const addButton     = document.getElementById('add-task-button');
  const deleteButtons = document.querySelectorAll('[id^="delete-task-button"]');

  addButton.addEventListener('click', async () => {
    const taskContent = inputForm.value;
    try {
      const response = await fetch('/tasks/create', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({title: taskContent})
      });
      const data = await response.json();
      location.reload();
    } catch (error) {
      console.error('Error:', error);
    }
  });

  document.body.addEventListener('click', async (event) => {
    if (event.target.matches('[id^="delete-task-button"]')) {
      const button = event.target;
      const taskId = button.getAttribute('data-task-id');
      try {
        const response = await fetch(`/tasks/${taskId}`, {
          method: 'DELETE'
        });
        const data = await response.json();
        location.reload();
      } catch (error) {
        console.error('Error:', error);
      }
    }
  });
});
```
    

## 困ったこと・直面したエラー

- `docker compose up`した後（＝Pumaサーバを起動した後）の変更が、一度buildしなおさないと反映されない
⇒　`compose.yamlのwebにvolumesを追加していなかったため。`
    
    volumesを追加すると、指定したローカルのフォルダとコンテナ上のフォルダがボリュームマウントされる。ボリュームマウントとは、コンテナ内の特定のパスをホストマシン（コンテナ外）のファイルシステムにマッピングする機能で、ホストマシン上でのファイルの変更がリアルタイムでコンテナ内に反映される。
    
- Error: SyntaxError: Unexpected token 'a', "application/json" is not valid JSON
    
    ⇒ `app.rbで戻り値がJSON形式でなかったため`
    
    app.jsで、await response.json()としているので、JavaScriptはJSON形式のデータが返ってくるまで待機している。一方でapp.rbに下記コードが無かったため、JSON以外のデータが返ってきているというエラーが出ていた。
    
    ```ruby
    content_type :json
      { status: 'success', message: 'Task added successfully' }.to_json
    ```
    
- アプリ上でタスクを作成したときにIDが重複しているため作成できないエラーが出た
    
    ⇒ `ダミーデータを作成する際にPostgreSQLで直接作成し、その際にIDを手打ちで作成したため`
    
    IDはSERIALなど自動で一意な番号が振られる型にするので原則手打ちで入力しない。今回はPostgreSQLにアクセスし、ダミーデータを全て削除してからアプリ上でタスクを作成したら問題なく作成できた。
    

## 参考にしたサイト

### Docker関係

基本的には[Dockerドキュメント](https://docs.docker.jp/index.html)と[Sinatra公式](https://sinatrarb.com/)を参照

- **Dockerの使い方・概念の解説・コマンド**
    - [【入門】Dockerイメージ（images）の仕組みとコマンド一覧まとめ](https://www.kagoya.jp/howto/cloud/container/dockerimage/)
    - [最低限これさえ知ってればいいDockerのコマンドまとめ](https://zenn.dev/jpn_asane/articles/feb6ca2b3cc715)
    - [docker docs:ボリュームの利用](https://matsuand.github.io/docs.docker.jp.onthefly/storage/volumes/)
    - [dockerのコンテナの中に入る](https://qiita.com/Keitaroooo/items/a30bf4eb9310d7b3f7cd)
- **Dockerfileについての解説**
    - [【Docker】Dockerfileで記述するRUNとCMDとENTRYPOINTの違いについて](https://www.guri2o1667.work/entry/2021/06/08/%E3%80%90Docker%E3%80%91Dockerfile%E3%81%A7%E8%A8%98%E8%BF%B0%E3%81%99%E3%82%8BRUN%E3%81%A8CMD%E3%81%A8ENTRYPOINT%E3%81%AE%E9%81%95%E3%81%84%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)
    - [【保存版】DockerfileのFROMの書き方](https://papakoletter.com/dockerfile_from/)
    - [Dockerfileのapt-getでよく見るあの呪文について](https://dev.classmethod.jp/articles/apt-get-magic-spell-in-docker/)
    - [【保存版】DockerfileのWORKDIR の書き方](https://papakoletter.com/dockerfile_workdir/)
- **composeについて**
    - [Docker composeファイルについて](https://note.com/ueponx/n/n080eb82e9974)
    - [気づいたらdocker-composeが色々変わっていた件](https://kojinjigyou.org/48322/)
    - [Docker Compose の depends_on の使い方まとめ](https://gotohayato.com/content/533/)
    - [docker-compose.ymlでよく見るワードについてまとめてみた](https://qiita.com/kouki_o9/items/1475555b31db0d689ac9)
    - [docker-compose up -d --buildとは](https://qiita.com/HorikawaTokiya/items/fbe5b7c27ea1c0209e3c)
    - [docker-compose.ymlの書き方について解説してみた](https://qiita.com/yuta-ushijima/items/d3d98177e1b28f736f04)

### Sinatra関係

- **Sinatra + Dockerの開発について**
    - [Docker ComposeでSinatraの開発環境を構築する](https://takagi.blog/building-sinatra-development-environment-with-docker-compose/)
    - [sinatraの開発環境をDockerfileを使って構築する。](https://ikdrocket.hatenablog.jp/entry/2021/03/21/231858)
    - [Dockerで最小構成のSinatraを起動する](https://blog.ingage.jp/entry/2020/11/20/151851)
- **Sinatra自体について**
    - [Sinatraを簡単に紹介](https://qiita.com/Indianeagleowl/items/f3ae7c62b1c2e1c35e67)
    - [Sinatraで社内サービスを作るためのいろいろ](https://qiita.com/YusukeIwaki/items/b9a483f2617cd61d38f9)
    - [【Sinatra】ルーティング](https://qiita.com/gussan-dayo/items/412f424660e0fba14c05)
    - [Sinatraアプリ構築手順](http://ruby-lecture.s3-website-us-east-1.amazonaws.com/tips/how-to-create-sinatra-app/)
    - [RESTful詐欺にあった～sinatraでdeleteを送る方法～](https://mcommit.hatenadiary.com/entry/2016/04/04/164509)

### その他（Puma/PostgreSQL/JavaScript/汎用コマンド）

- **Puma**
    - [Puma: A Ruby Web Server Built For Parallelism](https://github.com/puma/puma#puma-a-ruby-web-server-built-for-parallelism)
    - [【Rails】puma.rbの気になるあれこれ](https://qiita.com/redrabbit1104/items/9292b5891dc46eb64f3a)
    - [【Rails】Pumaの使い方を忘れた人、必見！？](https://study-infra.com/rails-puma/#toc4)
- **PostgreSQL**
    - [PostgreSQL Show Tables](https://www.postgresqltutorial.com/postgresql-administration/postgresql-show-tables/)
    - [PostgreSQLでDBとテーブルの作成と削除を行う](https://karlley.hatenablog.jp/entry/2022/07/27/041051)
- **汎用コマンド**
    - [よく使うcurlコマンドのオプション](https://qiita.com/ryuichi1208/items/e4e1b27ff7d54a66dcd9)