2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ruby on RailsAdvent Calendar 2024

Day 8

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

Posted at

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

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

rails newできない?

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

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

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

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

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

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

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

# 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
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を使い始められるようになるまでどうすればいいのか?

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

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 がお手軽。(わざわざRailsのDBマイグレーションを使いたい!!って人はsinatra-activerecordにその機能性があるので調べて使うと良い)

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
# 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 で土台も一瞬で出来上がる。

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

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でも当然そう。

bin/setup
#!/usr/bin/env ruby
require './app'
require 'pry'
Pry.start
# 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

CleanShot 2024-12-09 at 00.49.31@2x.png

  〜〜〜略〜〜〜

CleanShot 2024-12-09 at 00.50.06@2x.png

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

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

 

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

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?