Ruby on Rails остаётся одним из самых продуктивных web-фреймворков для быстрого создания full-stack приложений. Его философия convention over configuration в сочетании с богатой экосистемой gems делает его идеальным как для стартапов, так и для крупных компаний. Это руководство посвящено созданию готовых к production Rails-приложений с точки зрения senior-разработчика.
Почему Ruby on Rails
Rails особенно силён в нескольких областях:
- Продуктивность разработчика: генерация boilerplate одной командой
- Convention Over Configuration: разумные значения по умолчанию снижают усталость от принятия решений
- Зрелая экосистема: gems почти для каждого типового сценария
- Full-Stack интеграция: backend, frontend и база данных в одном фреймворке
- Active Record: интуитивный ORM с мощным интерфейсом запросов
Настройка Rails-проекта
Установите Ruby и Rails:
# Использование rbenv (рекомендуется)
rbenv install 3.2.0
rbenv global 3.2.0
gem install rails
Создайте новый проект:
rails new my_app
cd my_app
rails server
Откройте http://localhost:3000, чтобы увидеть приветственную страницу.
Структура проекта
Rails следует понятной MVC-структуре:
my_app/
├── app/
│ ├── controllers/ # Обработка запросов
│ ├── models/ # Бизнес-логика и данные
│ ├── views/ # Шаблоны
│ ├── helpers/ # Хелперы представлений
│ ├── assets/ # CSS, JS, изображения
│ └── jobs/ # Фоновые задачи
├── config/
│ ├── routes.rb # Маршрутизация URL
│ └── database.yml # Конфигурация базы данных
├── db/
│ ├── migrate/ # Миграции базы данных
│ └── schema.rb # Текущая схема
├── lib/ # Пользовательские библиотеки
├── spec/ or test/ # Тесты
└── Gemfile # Зависимости
Миграции базы данных
Создание таблиц
Сгенерируйте миграцию:
rails generate migration CreatePosts
Отредактируйте db/migrate/[timestamp]createposts.rb:
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.string :title, null: false
t.text :body
t.boolean :published, default: false
t.references :user, null: false, foreign_key: true
t.timestamps
end
add_index :posts, :published
end
end
Запустите миграции:
rails db:migrate
Добавление столбцов
class AddViewCountToPosts < ActiveRecord::Migration[7.0]
def change
add_column :posts, :view_count, :integer, default: 0
end
end
Модели и Active Record
Определение моделей
Создайте app/models/post.rb:
class Post < ApplicationRecord
# Связи
belongs_to :user
has_many :comments, dependent: :destroy
has_many :post_tags
has_many :tags, through: :post_tags
# Валидации
validates :title, presence: true, length: { minimum: 3, maximum: 255 }
validates :body, presence: true
validates_uniqueness_of :slug
# Scopes
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_author, ->(user) { where(user: user) }
# Callbacks
before_save :generate_slug
private
def generate_slug
self.slug = title.parameterize if slug.blank?
end
end
Связи many-to-many
# app/models/post.rb
class Post < ApplicationRecord
has_many :post_tags
has_many :tags, through: :post_tags
end
# app/models/tag.rb
class Tag < ApplicationRecord
has_many :post_tags
has_many :posts, through: :post_tags
end
# app/models/post_tag.rb
class PostTag < ApplicationRecord
belongs_to :post
belongs_to :tag
end
Запросы к данным
# Найти все записи
Post.all
# Найти по ID
Post.find(1)
# Найти по условиям
Post.where(published: true)
Post.where("created_at > ?", 1.week.ago)
# Цепочка scopes
Post.published.recent.limit(10)
# Eager loading для предотвращения N+1 запросов
Post.includes(:user, :comments).where(published: true)
# Сложные запросы
Post.joins(:user)
.where(users: { role: 'admin' })
.select('posts.*, users.name as author_name')
# Агрегации
Post.group(:user_id).count
Post.average(:view_count)
Контроллеры
RESTful-контроллеры
Создайте app/controllers/posts_controller.rb:
class PostsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :authorize_author!, only: [:edit, :update, :destroy]
def index
@posts = Post.published.recent.includes(:user).page(params[:page])
end
def show
@post.increment!(:view_count)
@comments = @post.comments.includes(:user).order(created_at: :desc)
end
def new
@post = current_user.posts.build
end
def create
@post = current_user.posts.build(post_params)
if @post.save
redirect_to @post, notice: 'Post created successfully.'
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @post.update(post_params)
redirect_to @post, notice: 'Post updated successfully.'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@post.destroy
redirect_to posts_path, notice: 'Post deleted.'
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :body, :published, tag_ids: [])
end
def authorize_author!
unless @post.user == current_user
redirect_to @post, alert: 'You can only edit your own posts.'
end
end
end
Маршрутизация
Настройте маршруты в config/routes.rb:
Rails.application.routes.draw do
# RESTful-ресурсы
resources :posts do
resources :comments, only: [:create, :destroy]
member do
post :publish
post :unpublish
end
collection do
get :search
end
end
# Вложенные ресурсы
resources :users do
resources :posts, only: [:index]
end
# Пользовательские маршруты
get 'about', to: 'pages#about'
get 'contact', to: 'pages#contact'
# Пространство имён API
namespace :api do
namespace :v1 do
resources :posts, only: [:index, :show, :create]
end
end
# Корневой маршрут
root 'posts#index'
end
Представления и шаблоны
ERB-шаблоны
Создайте app/views/posts/index.html.erb:
<h1>Posts</h1>
<div class="posts">
<% @posts.each do |post| %>
<article class="post">
<h2><%= link_to post.title, post %></h2>
<p class="meta">
By <%= post.user.name %> on <%= post.created_at.strftime("%B %d, %Y") %>
</p>
<p><%= truncate(post.body, length: 200) %></p>
<% if current_user == post.user %>
<div class="actions">
<%= link_to 'Edit', edit_post_path(post) %>
<%= link_to 'Delete', post, method: :delete,
data: { confirm: 'Are you sure?' } %>
</div>
<% end %>
</article>
<% end %>
</div>
<%= paginate @posts %>
<% if user_signed_in? %>
<%= link_to 'New Post', new_post_path, class: 'btn btn-primary' %>
<% end %>
Partials
Создайте app/views/posts/_form.html.erb:
<%= form_with model: @post, local: true do |f| %>
<% if @post.errors.any? %>
<div class="alert alert-danger">
<h4><%= pluralize(@post.errors.count, "error") %> prevented saving:</h4>
<ul>
<% @post.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<%= f.label :title %>
<%= f.text_field :title, class: 'form-control' %>
</div>
<div class="form-group">
<%= f.label :body %>
<%= f.text_area :body, class: 'form-control', rows: 10 %>
</div>
<div class="form-check">
<%= f.check_box :published, class: 'form-check-input' %>
<%= f.label :published, class: 'form-check-label' %>
</div>
<div class="form-group">
<%= f.label :tags %>
<%= f.collection_check_boxes :tag_ids, Tag.all, :id, :name %>
</div>
<%= f.submit class: 'btn btn-primary' %>
<% end %>
Аутентификация с bcrypt
Настройка модели User
Добавьте в Gemfile:
gem 'bcrypt', '~> 3.1.7'
Сгенерируйте миграцию для пользователя:
rails generate migration CreateUsers
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :name, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email, unique: true
end
end
Создайте app/models/user.rb:
class User < ApplicationRecord
has_secure_password
has_many :posts, dependent: :destroy
validates :email, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true
validates :password, length: { minimum: 8 }, allow_nil: true
end
Контроллер сессий
Создайте app/controllers/sessions_controller.rb:
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:email])
if user && user.authenticate(params[:password])
session[:user_id] = user.id
redirect_to root_path, notice: 'Signed in successfully.'
else
flash.now[:alert] = 'Invalid email or password.'
render :new, status: :unprocessable_entity
end
end
def destroy
session[:user_id] = nil
redirect_to root_path, notice: 'Signed out.'
end
end
Хелперы Application Controller
Добавьте в app/controllers/application_controller.rb:
class ApplicationController < ActionController::Base
helper_method :current_user, :user_signed_in?
def current_user
@current_user ||= User.find(session[:user_id]) if session[:user_id]
end
def user_signed_in?
!!current_user
end
def authenticate_user!
unless user_signed_in?
redirect_to new_session_path, alert: 'Please sign in to continue.'
end
end
end
Добавление frontend с React или Vue
Rails с React
Добавьте в Gemfile:
gem 'react-rails'
Сгенерируйте установку React:
rails generate react:install
Создайте React-компонент в app/javascript/components/PostList.jsx:
import React, { useState, useEffect } from 'react';
const PostList = ({ initialPosts }) => {
const [posts, setPosts] = useState(initialPosts || []);
const [loading, setLoading] = useState(!initialPosts);
useEffect(() => {
if (!initialPosts) {
fetch('/api/v1/posts')
.then(response => response.json())
.then(data => {
setPosts(data);
setLoading(false);
});
}
}, []);
if (loading) return <div>Loading...</div>;
return (
<div className="post-list">
{posts.map(post => (
<article key={post.id} className="post">
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
};
export default PostList;
Используйте в ERB-представлении:
<%= react_component("PostList", { initialPosts: @posts.as_json }) %>
Фоновые задачи
Создайте job:
rails generate job ProcessImage
Отредактируйте app/jobs/processimagejob.rb:
class ProcessImageJob < ApplicationJob
queue_as :default
retry_on ActiveStorage::IntegrityError, wait: 5.seconds, attempts: 3
discard_on ActiveRecord::RecordNotFound
def perform(post_id)
post = Post.find(post_id)
# Обработать изображение
post.featured_image.variant(resize_to_limit: [800, 600]).processed
# Обновить post
post.update(image_processed: true)
end
end
Поставьте job в очередь:
ProcessImageJob.perform_later(@post.id)
# С задержкой
ProcessImageJob.set(wait: 5.minutes).perform_later(@post.id)
Тестирование
Тесты моделей
Создайте spec/models/post_spec.rb:
require 'rails_helper'
RSpec.describe Post, type: :model do
describe 'validations' do
it { should validate_presence_of(:title) }
it { should validate_length_of(:title).is_at_least(3) }
end
describe 'associations' do
it { should belong_to(:user) }
it { should have_many(:comments).dependent(:destroy) }
end
describe 'scopes' do
let!(:published_post) { create(:post, published: true) }
let!(:draft_post) { create(:post, published: false) }
it 'returns only published posts' do
expect(Post.published).to include(published_post)
expect(Post.published).not_to include(draft_post)
end
end
end
Ключевые выводы
- Convention over configuration: следуйте соглашениям Rails для повышения продуктивности
- Fat models, skinny controllers: бизнес-логика должна находиться в моделях
- Используйте scopes: делайте запросы переиспользуемыми и читаемыми
- Eager loading: предотвращайте N+1 запросы с помощью
includes - Strong parameters: всегда явно разрешайте допустимые атрибуты
- Background jobs: выносите медленные задачи в фон для лучшего UX
Rails по-прежнему отлично подходит для быстрой разработки полнофункциональных web-приложений, особенно когда важна скорость вывода на рынок.