という記事を随分前に投稿していたが、ここ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) あたりを足せばいったん良い。
# frozen_string_literal: true
source "https://rubygems.org"
gem 'rackup'
gem 'sinatra'
gem 'webrick'
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だと自分で作らないといけない。
ただ、これもほぼほぼコピペでいい。
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" ]
#!/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のときと同様に。
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にその機能性があるので調べて使うと良い)
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
class User < ActiveRecord::Base
end
class Profile < ActiveRecord::Base
belongs_to :user
end
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が増えても無敵になる。
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を導入できる。
# 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アプリケーションとして独立なクラスが必要だから。
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まわりのごにょごにょを少し追記。
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を開始するといい。
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でも当然そう。
#!/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つのファイルに集約されている。
〜〜〜略〜〜〜
今回、この記事で書いたようなことは、ChatGPTのような生成AIに頼めば、ものの数分で理想の形のテンプレートは作ることができるだろう。
もちろん、同じようなことはRailsでもできるにはできる。ただ、MVCそれぞれのファイルをコピペするよりも、記述量が少ないSinatraの1ファイル構成のほうがはるかに生成AIとのインタラクションには効率的だ。
今後は「ちょっとAPI作りたいだけ」といった場合にはとくに生成AIをフル活用していくことになるだろうから、今回の記事で紹介したような「RailsのアレはSinatraだとどう書くんだっけ」というのはぜひマスターしておくとよい。