小規模なアプリケーションは、RailsではなくSinatraで作ってみる？

https://qiita.com/YusukeIwaki/items/b9a483f2617cd61d38f9

という記事を随分前に投稿していたが、ここ2年ほど、積極的にSinatraを使ってきたりしているので、少しだけノウハウを共有しておこうと思う。

まずは「RailsのアレはSinatraだとどうするの？」形式でいくつか雑にかいていく。

## rails newできない？

小規模なアプリケーションであっても、Ruby on Railsを使いたくなる理由の一つに、rails newしたら、DBのセットアップも、ログも、オートロードも、統合テスト用のテストも土台ができあがるという安心感は大きいだろう。

ただ、実はSinatraでもrails new相当のコマンドこそ無いが、土台となるプロジェクトを"コピペ"するというダサい運用をすると、わりと使い回しができる。

自分の場合には、PostgreSQL用とMySQL用の2つのテンプレートを持っていて、だいたいどちらかをコピペして使っている。

* PostgreSQL用
    * https://github.com/YusukeIwaki/sinatra-boilerplate-pg-api
    * プロトタイプのアプリケーションをつくってささっとHerokuデプロイするときとかはこっち
* MySQL用
    * https://github.com/YusukeIwaki/sinatra-boilerplate-mysql-api-with-rspec
    * 会社がMySQLを標準的に使っているので、会社のサービスに組み込むときは必然的にこっち

別に「これを使えばいいよ」とかそんな偉そうなものでもなく、後述するように、RailsのエッセンスをSinatraに組み込んでいくと、だいたいこういう形に落ち着く、というだけだ。

人のリポジトリのものをコピペする気持ち悪さがある人も多いでしょうから、そういう人はぜひ自分用のSinatraテンプレートを持っておくと良いと思う。

### 0からつくるときのstep by step

`bundle init` して、rackup, sinatra, webrick (またはpuma) あたりを足せばいったん良い。

```Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem 'rackup'
gem 'sinatra'
gem 'webrick'
```

```app.rb
require 'sinatra'
require 'json'

get '/ping' do
  content_type 'application/json'
  { success: true }.to_json
end
```

```
$ bundle exec ruby app.rb
[2024-12-08 23:13:25] INFO  WEBrick 1.9.1
[2024-12-08 23:13:25] INFO  ruby 3.2.4 (2024-04-23) [arm64-darwin24]
== Sinatra (v4.1.1) has taken the stage on 4567 for development with backup from WEBrick
```

```
http GET http://localhost:4567/ping
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 16
Content-Type: application/json
Date: Sun, 08 Dec 2024 14:15:52 GMT
Server: WEBrick/1.9.1 (Ruby/3.2.4/2024-04-23)
X-Content-Type-Options: nosniff

{
    "success": true
}
```

## Docker環境で開発したい？

最近のRailsにはDockerfileが組み込まれているが、Sinatraだと自分で作らないといけない。
ただ、これもほぼほぼコピペでいい。

```docker-compose.yml
version: "3"
services:
  web:
    build: .
    command: bundle exec rackup --host 0.0.0.0 --port 3000
    volumes:
      - .:/app
      - bundle-data:/usr/local/bundle
    working_dir: /app
    depends_on:
      - postgres
    environment:
      - POSTGRES_HOST=postgres
      - POSTGRES_USER=pguser
      - POSTGRES_PASSWORD=pgpassword
      - POSTGRES_DB=pguser
    tty: true
    stdin_open: true
    ports:
      - 4567:4567

  postgres:
    image: postgres:17-alpine
    restart: always
    volumes:
      - pg-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: pguser
      POSTGRES_PASSWORD: pgpassword
      POSTGRES_DB: pguser

volumes:
  bundle-data:
    driver: local
  pg-data:
    driver: local
```

```Dockerfile
FROM ruby:3.2-alpine

RUN apk add --no-cache \
    bash \
    build-base \
    git \
    less \
    libxml2-dev \
    libxslt-dev \
    postgresql-dev \
    shared-mime-info \
    tzdata

COPY --chmod=0755 docker_entrypoint.sh /
ENTRYPOINT [ "/docker_entrypoint.sh" ]
```

```docker_entrypoint.sh
#!/bin/sh -ex

bundle config set force_ruby_platform true
bundle install
rm -f tmp/pids/server.pid
exec "$@"
```

```
docker compose run --service-ports web sh

/app # bundle exec ruby app.rb -o 0.0.0.0
[2024-12-08 14:27:05] INFO  WEBrick 1.9.1
[2024-12-08 14:27:05] INFO  ruby 3.2.4 (2024-04-23) [aarch64-linux-musl]
== Sinatra (v4.1.1) has taken the stage on 4567 for development with backup from WEBrick
[2024-12-08 14:27:05] INFO  WEBrick::HTTPServer#start: pid=12 port=4567
```

```
http GET http://localhost:4567/ping
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 16
Content-Type: application/json
Date: Sun, 08 Dec 2024 14:27:08 GMT
Server: WEBrick/1.9.1 (Ruby/3.2.4/2024-04-23)
X-Content-Type-Options: nosniff

{
    "success": true
}
```

`-o 0.0.0.0` をつけ忘れると、127.0.0.1しかバインドされておらずDockerの外からやってきた通信に対して反応してくれず、以下のようなエラーになる。

```
http GET http://localhost:4567/ping

http: error: ConnectionError: ('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer')) while doing a GET request to URL: http://localhost:4567/ping
```

## DBのセットアップは？

config/database.ymlだったり、ActiveRecordを使い始められるようになるまでどうすればいいのか？

https://github.com/sinatra-activerecord/sinatra-activerecord

自分で書きたいなら書けなくもないが、ライブラリがあるのでほとんどの人はとりあえずこれを使えばいい。

config/database.ymlは、Railsのときと同様に。

```config/database.yml
default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: <%= ENV.fetch("POSTGRES_HOST") { '127.0.0.1' } %>
  port: 5432
  username: <%= ENV.fetch("POSTGRES_USER") { 'pguser' } %>
  password: <%= ENV.fetch("POSTGRES_PASSWORD") { 'pgpassword' } %>
  database: <%= ENV.fetch('POSTGRES_DB') { "pguser" } %>

development:
  <<: *default
```

DBマイグレーションは、[ridgepole](https://github.com/ridgepole/ridgepole) がお手軽。（わざわざRailsのDBマイグレーションを使いたい！！って人はsinatra-activerecordにその機能性があるので調べて使うと良い）

```rb:Schemafile
create_table :users, force: :cascade do |t|
  t.datetime :created_at, null: false
end

create_table :profiles, force: :cascade do |t|
  t.bigint :user_id, null: false
  t.string :display_name, null: false
  t.string :email, null: false
  t.datetime :created_at, null: false
  t.datetime :updated_at, null: false
end

add_index :profiles, %w[user_id],
  name: 'profiles_user_id',
  unique: true,
  using: :btree
```

```Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem 'rackup'
gem 'sinatra'
gem 'webrick'

gem 'pg'
gem 'ridgepole'
gem 'sinatra-activerecord'
```

pg, ridgepole, sinatra-activerecordが追記されていればOK。

DBマイグレートして

```
/app # bundle exec ridgepole -c config/database.yml -a
Apply `Schemafile`
-- create_table("users")
   -> 0.0048s
-- create_table("profiles")
   -> 0.0017s
-- add_index("profiles", ["user_id"], {:name=>"profiles_user_id", :unique=>true, :using=>:btree})
   -> 0.0006s
```

```models/user.rb
class User < ActiveRecord::Base
end
```

```models/profile.rb
class Profile < ActiveRecord::Base
  belongs_to :user
end
```

```app.rb
require 'sinatra'
require 'json'

require 'sinatra/activerecord'
require 'active_record'
require './models/user'
require './models/profile'


get '/ping' do
  content_type 'application/json'
  { success: true }.to_json
end

post '/users' do
  content_type 'application/json'

  user = User.create!

  {
    id: user.id,
    created_at: user.created_at.to_i,
  }.to_json
end

patch '/users/:id' do
  content_type 'application/json'

  user = User.find(params[:id])

  request_json = JSON.parse(request.body.read)
  display_name = request_json['display_name']
  email = request_json['email']

  profile = Profile.find_or_initialize_by(user: user)
  profile.update!(display_name: display_name, email: email)

  {
    id: user.id,
    profile: {
      display_name: profile.display_name,
      email: profile.email,
    }
  }.to_json
end

```

```
$ http POST http://localhost:4567/users
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 32
Content-Type: application/json
Date: Sun, 08 Dec 2024 14:47:47 GMT
Server: WEBrick/1.9.1 (Ruby/3.2.4/2024-04-23)
X-Content-Type-Options: nosniff

{
    "created_at": 1733669267,
    "id": 1
}


$ echo '{"display_name":"YusukeIwaki","email":"iwaki@example.com"}' | http PATCH http://localhost:4567/users/1
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 77
Content-Type: application/json
Date: Sun, 08 Dec 2024 14:50:04 GMT
Server: WEBrick/1.9.1 (Ruby/3.2.4/2024-04-23)
X-Content-Type-Options: nosniff

{
    "id": 1,
    "profile": {
        "display_name": "YusukeIwaki",
        "email": "iwaki@example.com"
    }
}
```

若干、app.rbの長さに嫌気がさしてきた。

## オートロードは？

Railsであれば、app/models の下にファイルを追加して、明示的にrequireなんてしていなくてもアプリケーション開始時にロードされる。

Sinatraでもzeitwerkを入れれば普通にできる。

`gem 'zeitwerk'` を追加して `bundle install` したら、app.rbの冒頭のrequireはバッサリ消すことができる。modelsが増えても無敵になる。

```app.rb
require 'bundler'
Bundler.require :default, (ENV['RACK_ENV'] || :development).to_sym

loader = Zeitwerk::Loader.new
loader.push_dir('./models')
loader.setup


get '/ping' do
  content_type 'application/json'
  { success: true }.to_json
end

# 後略...
```

```
echo '{"display_name":"YusukeIwaki","email":"iwaki@example.com"}' | http PATCH http://localhost:4567/users/1
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 77
Content-Type: application/json
Date: Sun, 08 Dec 2024 14:56:24 GMT
Server: WEBrick/1.9.1 (Ruby/3.2.4/2024-04-23)
X-Content-Type-Options: nosniff

{
    "id": 1,
    "profile": {
        "display_name": "YusukeIwaki",
        "email": "iwaki@example.com"
    }
}
```

## リグレッション確認のためのRSpecいれたい

Railsであれば、最初からMiniTestもしくはRSpecを追加できる。そして、RSpecに関して言えば [rspec-rails](https://github.com/rspec/rspec-rails) で土台も一瞬で出来上がる。

Sinatraだと、Rackアプリケーションのテストライブラリである rack-test を使えば、比較的かんたんにRSpecを導入できる。

```rb:Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem 'rackup'
gem 'sinatra', require: false # ここも変更!
gem 'webrick'

gem 'pg'
gem 'ridgepole'
gem 'sinatra-activerecord'

gem 'zeitwerk'

group :test do
  gem 'rspec'
  gem 'rack-test'
end
```

`bundle install` して、まずはRSpecの初期設定。

```
/app # bundle exec rspec --init
  create   .rspec
  create   spec/spec_helper.rb
```

そして、Sinatraアプリケーションをモジュラーモードに変更する。理由はあとの方にでてくるとおり、Rackアプリケーションとして独立なクラスが必要だから。

```app.rb
require 'bundler'
Bundler.require :default, (ENV['RACK_ENV'] || :development).to_sym

loader = Zeitwerk::Loader.new
loader.push_dir('./models')
loader.setup

require 'sinatra/base'

class App < Sinatra::Base
  configure :development do
    set :host_authorization, { permitted_hosts: [] }
  end

  get '/ping' do
    content_type 'application/json'
    { success: true }.to_json
  end

  post '/users' do
# 後略...
```

`rspec --init` で自動生成されたspec_helper.rbには、Rack testまわりのごにょごにょを少し追記。

```spec/spec_helper.rb
require './app'
require 'rack/test'

# This file was generated by the `rspec --init` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause
# this file to always be loaded, without a need to explicitly require it in any
# files.
#
# Given that it is always loaded, you are encouraged to keep this file as
# light-weight as possible. Requiring heavyweight dependencies from this file
# will add to the boot time of your test suite on EVERY test run, even for an
# individual file that may not need all of that loaded. Instead, consider making
# a separate helper file that requires the additional dependencies and performs
# the additional setup, and require it from the spec files that actually need
# it.
#
# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|

###### (中略) ########

  # Seed global randomization in this process using the `--seed` CLI option.
  # Setting this allows you to use `--seed` to deterministically reproduce
  # test failures related to randomization by passing the same `--seed` value
  # as the one that triggered the failure.
  Kernel.srand config.seed


  config.include Rack::Test::Methods
  config.around do |example|
    ActiveRecord::Base.transaction do
      example.run
      raise ActiveRecord::Rollback
    end
  end
end
```

あとは、Railsのrequest specに割と近いものを書くことができる。

今回紹介しているような手抜き構成だと、developmentとtestでDBを共用しているので、itの冒頭で、関係するテーブルはdelete_allしてからspecを開始するといい。


```spec/app_spec.rb
require 'spec_helper'

RSpec.describe 'app' do
  let(:app) { App }

  it 'should create user and update profile' do
    User.delete_all
    Profile.delete_all

    expect { post '/users' }.to change { User.count }.by(1)
    expect(last_response.status).to eq(200)
    user = User.last

    response = JSON.parse(last_response.body)
    user_id = response['id']
    expect(user_id).to eq(user.id)

    expect {
      patch "/users/#{user_id}", {
        display_name: 'YusukeIwaki',
        email: 'iwaki@example.com',
      }.to_json, 'CONTENT_TYPE' => 'application/json'
    }.to change { Profile.where(user: user).count }.by(1)
    profile = Profile.where(user: user).last

    expect(last_response.status).to eq(200)
    response = JSON.parse(last_response.body)
    expect(response['id']).to eq(user.id)
    expect(response['profile']['display_name']).to eq(profile.display_name)
    expect(response['profile']['email']).to eq(profile.email)

    patch "/users/#{user_id}", {
      display_name: 'YusukeIwaki2',
      email: 'iwaki2@example.com',
    }.to_json, 'CONTENT_TYPE' => 'application/json'

    profile.reload
    expect(profile.display_name).to eq('YusukeIwaki2')
    expect(profile.email).to eq('iwaki2@example.com')
  end
end
```

```
/app # bundle exec rspec spec/app_spec.rb 
D, [2024-12-08T15:25:13.531499 #330] DEBUG -- :   TRANSACTION (0.1ms)  BEGIN
D, [2024-12-08T15:25:13.533601 #330] DEBUG -- :   User Delete All (0.2ms)  DELETE FROM "users"
D, [2024-12-08T15:25:13.536296 #330] DEBUG -- :   Profile Delete All (0.2ms)  DELETE FROM "profiles"
D, [2024-12-08T15:25:13.537428 #330] DEBUG -- :   User Count (0.1ms)  SELECT COUNT(*) FROM "users"
D, [2024-12-08T15:25:13.543259 #330] DEBUG -- :   User Create (0.2ms)  INSERT INTO "users" ("created_at") VALUES ($1) RETURNING "id"  [["created_at", "2024-12-08 15:25:13.542835"]]
D, [2024-12-08T15:25:13.544166 #330] DEBUG -- :   User Count (0.1ms)  SELECT COUNT(*) FROM "users"
D, [2024-12-08T15:25:13.544968 #330] DEBUG -- :   User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT $1  [["LIMIT", 1]]
D, [2024-12-08T15:25:13.546498 #330] DEBUG -- :   Profile Count (0.1ms)  SELECT COUNT(*) FROM "profiles" WHERE "profiles"."user_id" = $1  [["user_id", 13]]
D, [2024-12-08T15:25:13.547454 #330] DEBUG -- :   User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 13], ["LIMIT", 1]]
D, [2024-12-08T15:25:13.547928 #330] DEBUG -- :   Profile Load (0.3ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = $1 LIMIT $2  [["user_id", 13], ["LIMIT", 1]]
D, [2024-12-08T15:25:13.551259 #330] DEBUG -- :   Profile Create (0.2ms)  INSERT INTO "profiles" ("user_id", "display_name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["user_id", 13], ["display_name", "YusukeIwaki"], ["email", "iwaki@example.com"], ["created_at", "2024-12-08 15:25:13.550901"], ["updated_at", "2024-12-08 15:25:13.550901"]]
D, [2024-12-08T15:25:13.551708 #330] DEBUG -- :   Profile Count (0.2ms)  SELECT COUNT(*) FROM "profiles" WHERE "profiles"."user_id" = $1  [["user_id", 13]]
D, [2024-12-08T15:25:13.552177 #330] DEBUG -- :   Profile Load (0.3ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = $1 ORDER BY "profiles"."id" DESC LIMIT $2  [["user_id", 13], ["LIMIT", 1]]
D, [2024-12-08T15:25:13.552649 #330] DEBUG -- :   User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 13], ["LIMIT", 1]]
D, [2024-12-08T15:25:13.553000 #330] DEBUG -- :   Profile Load (0.2ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = $1 LIMIT $2  [["user_id", 13], ["LIMIT", 1]]
D, [2024-12-08T15:25:13.553478 #330] DEBUG -- :   Profile Update (0.1ms)  UPDATE "profiles" SET "display_name" = $1, "email" = $2, "updated_at" = $3 WHERE "profiles"."id" = $4  [["display_name", "YusukeIwaki2"], ["email", "iwaki2@example.com"], ["updated_at", "2024-12-08 15:25:13.553137"], ["id", 4]]
D, [2024-12-08T15:25:13.553938 #330] DEBUG -- :   Profile Load (0.2ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
D, [2024-12-08T15:25:13.554281 #330] DEBUG -- :   TRANSACTION (0.2ms)  ROLLBACK
.

Finished in 0.05797 seconds (files took 0.32816 seconds to load)
1 example, 0 failures
```

## Railsコンソールぽいものがほしい？

`bin/rails c` でデバッグができないとしんどい、というのはSinatraでも当然そう。

```rb:bin/setup
#!/usr/bin/env ruby
require './app'
require 'pry'
Pry.start
```

```Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem 'rackup'
gem 'sinatra', require: false
gem 'webrick'

gem 'pg'
gem 'ridgepole'
gem 'sinatra-activerecord'

gem 'zeitwerk'

group :test do
  gem 'rspec'
  gem 'rack-test'
end

group :development do
  gem 'pry'
end
```

こうすれば、 `bin/console` でそれっぽいコンソール操作ができるようになる。

```
/app # bin/console
[1] pry(main)> User.count
D, [2024-12-08T15:33:49.212543 #347] DEBUG -- :   User Count (15.0ms)  SELECT COUNT(*) FROM "users"
=> 1
[2] pry(main)> User.last.attributes
D, [2024-12-08T15:33:54.805400 #347] DEBUG -- :   User Load (1.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT $1  [["LIMIT", 1]]
=> {"id"=>1, "created_at"=>2024-12-08 14:47:47.597221 UTC}
```

## ...なるほどわかった。でもなんでそこまでSinatraがいいんだ？

本題。

Railsでいいじゃん、というのは私もそう思う。ただ、ここ数年、Sinatraのメリットは増えてきている。

### Railsバージョンアップの回避

まずは、Railsというのは1年に1度新しいバージョンが出る。当然ながら、その追従が必要だということ。MVCで開発したいだけなのに、大規模なフレームワーク部分をよくわからないけど毎年バージョンアップしないといけない。RSpecがあれば余裕だけど、社内アプリケーションだったらそこまでやる？というと微妙だろう。

Sinatraも最近ちょいちょいアップデートされて、「あら、こんな項目ふえたのね...」的なものが多いのだが、Railsに比べるとまぁ最悪ソースコード読めばわかるレベル。アップデート頻度があまりないけど、ずっと動いていてほしいアプリケーションであれば、Railsバージョンアップの手間が省けるSinatraは候補に入れてみてほしい。

### 生成AIフレンドリー!!

もうひとつ、Railsバージョンアップするのも億劫ではないという人にとっても、Sinatraのメリットを伝えておきたいのが、近年とてもホットな生成AIの活用だ。

SinatraはRailsと違って、config/routes.rbもなければコントローラもなく、処理が1つのファイルに集約されている。

![CleanShot 2024-12-09 at 00.48.39@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/97c09006-765a-32a1-1942-df3a00df1ce8.png)

![CleanShot 2024-12-09 at 00.49.31@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/ff973a43-240c-1ef3-475f-ea659d322005.png)

　　〜〜〜略〜〜〜

![CleanShot 2024-12-09 at 00.50.06@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/7cc0e162-c82a-e62f-7ddb-dca0f3f74384.png)

今回、この記事で書いたようなことは、ChatGPTのような生成AIに頼めば、ものの数分で理想の形のテンプレートは作ることができるだろう。

もちろん、同じようなことはRailsでもできるにはできる。ただ、MVCそれぞれのファイルをコピペするよりも、記述量が少ないSinatraの1ファイル構成のほうがはるかに生成AIとのインタラクションには効率的だ。

　
 
今後は「ちょっとAPI作りたいだけ」といった場合にはとくに生成AIをフル活用していくことになるだろうから、今回の記事で紹介したような「RailsのアレはSinatraだとどう書くんだっけ」というのはぜひマスターしておくとよい。