Разработка бэкенда сайта на Ruby (Ruby on Rails)
Rails остаётся одним из самых продуктивных фреймворков для команд, которым нужно быстро запустить продукт, не строя каждый слой с нуля. Conventions over configuration здесь не маркетинговый слоган — это буквально описание того, как работает кодогенерация, роутинг, ORM и тестирование.
Где Rails в форме
Контентные платформы, маркетплейсы, SaaS с многоарендностью, административные панели — там, где важнее скорость итерации, чем сырая производительность. Shopify, GitHub, Basecamp — всё это Rails под капотом при огромных нагрузках. На Rails 7 с Puma + Falcon или Unicorn нагрузка 3–5k RPS на инстанс — норма для правильно написанного приложения.
Современный стек
Rails 7.1 + Hotwire (Turbo + Stimulus) + PostgreSQL — стек, который не требует отдельного React-фронтенда для большинства задач. Для API-only режима:
# config/application.rb
module MyApp
class Application < Rails::Application
config.api_only = true
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore
end
end
Active Record и миграции
# db/migrate/20240315_create_orders.rb
class CreateOrders < ActiveRecord::Migration[7.1]
def change
create_table :orders do |t|
t.references :user, null: false, foreign_key: true
t.integer :status, null: false, default: 0
t.decimal :total, precision: 10, scale: 2, null: false
t.jsonb :metadata, default: {}
t.timestamps
end
add_index :orders, :status
add_index :orders, :created_at
add_index :orders, [:user_id, :status]
end
end
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
has_many :items, class_name: 'OrderItem', dependent: :destroy
enum :status, { pending: 0, paid: 1, shipped: 2, delivered: 3, cancelled: 4 }
scope :recent, -> { order(created_at: :desc) }
scope :for_period, ->(from, to) { where(created_at: from..to) }
validates :total, numericality: { greater_than: 0 }
after_update_commit :broadcast_status_change, if: :saved_change_to_status?
private
def broadcast_status_change
ActionCable.server.broadcast("order_#{id}", { status: status })
end
end
Сервисные объекты
Для бизнес-логики, которая не помещается в модель:
# app/services/orders/create_service.rb
module Orders
class CreateService
Result = Data.define(:success, :order, :errors)
def initialize(user:, params:)
@user = user
@params = params
end
def call
ActiveRecord::Base.transaction do
order = @user.orders.build(status: :pending)
items = build_items(order)
order.total = items.sum { |i| i.price * i.quantity }
order.save!
order.items << items
PaymentJob.perform_later(order.id)
Result.new(success: true, order: order, errors: [])
end
rescue ActiveRecord::RecordInvalid => e
Result.new(success: false, order: nil, errors: e.record.errors.full_messages)
end
private
def build_items(order)
@params[:items].map do |item_params|
product = Product.find(item_params[:product_id])
OrderItem.new(
order: order,
product: product,
price: product.current_price,
quantity: item_params[:quantity]
)
end
end
end
end
Контроллер и сериализация
# app/controllers/api/v1/orders_controller.rb
module Api
module V1
class OrdersController < ApplicationController
before_action :authenticate_user!
def index
orders = current_user.orders.recent.page(params[:page]).per(25)
render json: OrderSerializer.new(orders, { meta: pagination_meta(orders) })
end
def create
result = Orders::CreateService.new(
user: current_user,
params: order_params
).call
if result.success
render json: OrderSerializer.new(result.order), status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(items: [:product_id, :quantity])
end
end
end
end
Сериализация через jsonapi-serializer:
class OrderSerializer
include JSONAPI::Serializer
attributes :status, :total, :created_at
has_many :items, serializer: OrderItemSerializer
belongs_to :user, serializer: UserSerializer
end
Фоновые задачи с Sidekiq
# app/jobs/payment_job.rb
class PaymentJob < ApplicationJob
queue_as :payments
sidekiq_options retry: 3, backtrace: 5
def perform(order_id)
order = Order.find(order_id)
return if order.paid?
result = Payments::StripeService.new(order).charge
if result.success?
order.paid!
else
order.cancelled!
raise PaymentFailedError, result.error_message
end
end
end
# config/sidekiq.yml
concurrency: 10
queues:
- [payments, 3]
- [mailers, 2]
- [default, 1]
Кэширование
# Russian-cache через Redis
def cached_categories
Rails.cache.fetch("categories/all", expires_in: 1.hour) do
Category.active.includes(:children).to_a
end
end
# Фрагментное кэширование в API
def index
categories = Rails.cache.fetch_multi(*Category.active.pluck(:id).map { "category/#{_1}" }) do |key|
id = key.split('/').last.to_i
Category.find(id)
end
render json: categories.values
end
Тестирование
# spec/services/orders/create_service_spec.rb
RSpec.describe Orders::CreateService do
let(:user) { create(:user) }
let(:product) { create(:product, price: 99.99) }
describe '#call' do
subject(:result) do
described_class.new(user: user, params: { items: [{ product_id: product.id, quantity: 2 }] }).call
end
it 'creates order with correct total' do
expect(result.success).to be true
expect(result.order.total).to eq(199.98)
end
it 'enqueues payment job' do
expect { result }.to have_enqueued_job(PaymentJob)
end
end
end
Деплой
Puma в кластерном режиме + Nginx как реверс-прокси — стандартная схема. Kamal (от Basecamp) упрощает деплой в Docker без Kubernetes:
# config/deploy.yml (Kamal)
service: myapp
image: registry.example.com/myapp
servers:
web:
hosts: [10.0.0.1, 10.0.0.2]
options:
memory: 512m
workers:
hosts: [10.0.0.3]
cmd: bundle exec sidekiq
env:
secret: [RAILS_MASTER_KEY, DATABASE_URL, REDIS_URL]
Сроки
API для мобильного приложения (аутентификация, 10–15 ресурсов, Sidekiq): 1–2 недели. Полноценный SaaS-бэкенд с многоарендностью, подписками, вебхуками и развитой логикой: 4–6 недель. Рефакторинг Rails 4/5 на 7.1 с обновлением гемов — зависит от объёма, обычно 2–3 недели на аудит и патчинг.







