5.11. Рекомендации по разработке на Ruby
Рекомендации по разработке на Ruby
Введение в культуру кода Ruby
Ruby обладает уникальной философией, выраженной в принципе «программист счастлив важнее, чем компьютер счастлив». Эта философия проявляется в синтаксисе языка, стандартной библиотеке и принятых в сообществе практиках. Код на Ruby ценит выразительность, читаемость и элегантность. Хороший Ruby-код читается как естественный язык, минимизирует шаблонные конструкции и предпочитает явные намерения неявным соглашениям.
Сообщество Ruby разработало устоявшиеся соглашения, закреплённые в стилевых гидах (например, от команды GitHub и от сообщества Ruby on Rails). Эти соглашения обеспечивают единообразие кодовой базы в разных проектах и упрощают совместную разработку.
Соглашения об именовании
Основные правила именования
Имена в коде передают смысл и намерения разработчика. Следование единым правилам именования упрощает чтение и понимание кода.
| Элемент языка | Стиль | Примеры |
|---|---|---|
| Переменные, методы | snake_case | user_count, calculate_total |
| Константы | SCREAMING_SNAKE_CASE | MAX_RETRIES, DEFAULT_TIMEOUT |
| Классы, модули | PascalCase | UserService, PaymentGateway |
| Символы | :snake_case | :user_id, :created_at |
Специальные суффиксы методов
Ruby использует соглашения о суффиксах для передачи семантики методов:
-
Методы с вопросительным знаком (
?) возвращают логическое значение:def active?
status == :active
end
def empty?
items.size.zero?
end -
Методы с восклицательным знаком (
!) изменяют объект на месте или могут выбрасывать исключения:# Изменение на месте
names.map!(&:upcase)
# Выбрасывание исключения при ошибке
def save!
raise RecordInvalid unless valid?
save
end -
Методы с суффиксом
=устанавливают значение атрибута:def name=(value)
@name = value.strip
end
Именование параметров блоков
Параметры блоков получают короткие, осмысленные имена, отражающие сущность элемента:
# Хорошо
users.each { |user| process(user) }
orders.map { |order| order.total }
files.select { |file| file.readable? }
# Избегать однобуквенных имён без контекста
items.each { |i| process(i) } # Неясно, что представляет i
Форматирование и оформление кода
Отступы и пробелы
- Используйте два пробела для отступов. Табуляция не применяется.
- Добавляйте один пробел вокруг операторов:
x = 1 + 2
result = items.map { |item| item.value * 2 } - Не добавляйте пробелы внутри скобок:
method(arg1, arg2) # Хорошо
method( arg1, arg2 ) # Избегать - Добавляйте пробел после запятых:
[1, 2, 3]
{ name: "Alice", age: 30 }
Длина строк и переносы
Ограничивайте длину строк 80–100 символами. При необходимости переноса:
-
Выравнивайте аргументы относительно открывающей скобки:
def create_user(
email:,
password:,
first_name:,
last_name:,
role: :user
)
# реализация
end -
Для цепочек методов переносите каждый вызов на новую строку с отступом:
users
.active
.where(region: "EU")
.order(created_at: :desc)
.limit(100)
.to_a -
Для хэшей с несколькими ключами используйте многострочный формат:
config = {
host: "api.example.com",
port: 443,
timeout: 30,
retries: 3
}
Расположение фигурных скобок
Для однострочных блоков допустимы фигурные скобки:
names = users.map { |user| user.name }
Для многострочных блоков используйте ключевые слова do/end:
users.each do |user|
logger.info "Processing user: #{user.id}"
process(user)
update_status(user)
end
Структура проекта
Стандартная структура Ruby-приложения
my_app/
├── bin/ # Исполняемые файлы
├── lib/ # Основной код приложения
│ ├── my_app/
│ │ ├── version.rb
│ │ ├── user.rb
│ │ └── services/
│ └── my_app.rb # Точка входа для загрузки
├── spec/ # Тесты (при использовании RSpec)
│ ├── my_app/
│ │ └── user_spec.rb
│ └── spec_helper.rb
├── Gemfile # Зависимости
├── Gemfile.lock
├── Rakefile # Задачи Rake
└── README.md
Структура Rails-приложения
app/
├── controllers/ # Контроллеры
├── models/ # Модели
├── views/ # Шаблоны представлений
├── helpers/ # Хелперы
├── services/ # Сервисные объекты
├── forms/ # Формы
├── queries/ # Объекты запросов
└── policies/ # Политики авторизации
lib/
├── tasks/ # Rake задачи
└── extensions/ # Расширения стандартной библиотеки
config/
├── initializers/ # Инициализаторы
└── environments/ # Конфигурации окружений
Организация модулей и пространств имён
Группируйте связанные классы в модули, отражающие предметную область:
# lib/billing/invoice.rb
module Billing
class Invoice
# реализация
end
end
# lib/billing/payment_processor.rb
module Billing
class PaymentProcessor
# реализация
end
end
# Использование
invoice = Billing::Invoice.new(order)
Billing::PaymentProcessor.charge(invoice)
Избегайте чрезмерной вложенности модулей. Два–три уровня вложенности обычно достаточны для поддержания чистоты пространства имён.
Проектирование классов и модулей
Принцип единственной ответственности
Каждый класс решает одну задачу. Класс с именем, содержащим союз «и» (UserAndOrderManager), часто нарушает этот принцип.
# Нарушение принципа
class UserAndOrderManager
def create_user(params); end
def create_order(params); end
def send_welcome_email(user); end
end
# Соблюдение принципа
class UserManager
def create(params); end
end
class OrderManager
def create(params); end
end
class WelcomeEmailService
def send(user); end
end
Использование модулей для примесей
Модули предоставляют механизм повторного использования поведения без наследования:
module Loggable
def logger
@logger ||= Logger.new($stdout)
end
def log(message)
logger.info "[#{self.class}] #{message}"
end
end
class PaymentService
include Loggable
def process(order)
log "Processing payment for order ##{order.id}"
# реализация
end
end
Композиция вместо наследования
Предпочитайте композицию наследованию для построения сложного поведения:
# Слишком глубокое наследование
class Vehicle; end
class Car < Vehicle; end
class ElectricCar < Car; end
class LuxuryElectricCar < ElectricCar; end
# Композиция
class Vehicle
attr_reader :engine, :features
def initialize(engine:, features: [])
@engine = engine
@features = features
end
end
electric_engine = ElectricEngine.new(battery_capacity: 100)
luxury_features = [LeatherSeats.new, PremiumSoundSystem.new]
car = Vehicle.new(engine: electric_engine, features: luxury_features)
Инкапсуляция состояния
Скрывайте внутреннее состояние объекта за интерфейсом методов:
class ShoppingCart
def initialize
@items = []
end
def add_item(product, quantity: 1)
existing = @items.find { |item| item.product == product }
if existing
existing.quantity += quantity
else
@items << CartItem.new(product, quantity)
end
end
def total
@items.sum(&:price)
end
private
attr_reader :items
end
Метод items объявлен приватным, предотвращая прямое изменение коллекции извне.
Проектирование методов
Длина методов
Методы должны умещаться на один экран (15–20 строк). Длинные методы декомпозируются на более мелкие с осмысленными именами:
# Слишком длинный метод
def process_order(order)
# 50 строк кода обработки заказа
end
# Декомпозиция
def process_order(order)
validate_order(order)
reserve_inventory(order)
calculate_pricing(order)
create_payment(order)
send_confirmation(order)
end
Количество параметров
Ограничивайте количество параметров метода тремя–четырьмя. Для большего числа параметров используйте хэш или объект параметров:
# Слишком много позиционных параметров
def create_user(email, password, first_name, last_name, role, status, locale)
# реализация
end
# Использование именованных параметров
def create_user(email:, password:, first_name:, last_name:, role: :user, status: :active, locale: "en")
# реализация
end
# Использование объекта параметров
class UserParams
attr_reader :email, :password, :first_name, :last_name, :role, :status, :locale
def initialize(params)
@email = params[:email]
@password = params[:password]
# ...
end
end
def create_user(params)
validated = UserParams.new(params)
# реализация
end
Чистые функции
Стремитесь к созданию чистых функций — методов без побочных эффектов, которые всегда возвращают одинаковый результат для одинаковых входных данных:
# Чистая функция
def calculate_discount(price, percentage)
price * (1 - percentage / 100.0)
end
# Функция с побочным эффектом
def calculate_and_log_discount(price, percentage)
result = price * (1 - percentage / 100.0)
logger.info "Discount calculated: #{result}" # Побочный эффект
result
end
Чистые функции проще тестировать, отлаживать и повторно использовать.
Обработка ошибок
Используйте исключения для обработки исключительных ситуаций, а не для управления нормальным потоком выполнения:
# Плохой подход: исключение для управления потоком
def find_user(id)
raise NotFoundError unless users.include?(id)
users[id]
end
begin
user = find_user(id)
rescue NotFoundError
user = create_default_user
end
# Лучший подход: возврат значения или использование опционального типа
def find_user(id)
users[id]
end
user = find_user(id) || create_default_user
Создавайте специфичные классы исключений для предметной области:
class PaymentError < StandardError; end
class InsufficientFundsError < PaymentError; end
class InvalidCardError < PaymentError; end
begin
payment.process
rescue InsufficientFundsError => e
notify_user_about_insufficient_funds(e.amount)
rescue InvalidCardError => e
request_new_card_details(e.card_type)
end
Работа с коллекциями и блоками
Предпочтение функциональных итераторов
Используйте методы map, select, reduce вместо циклов for или while:
# Императивный подход
result = []
users.each do |user|
result << user.name.upcase if user.active?
end
# Функциональный подход
result = users
.select(&:active?)
.map { |user| user.name.upcase }
Цепочки методов
Выстраивайте цепочки методов вертикально для улучшения читаемости:
# Горизонтальная цепочка (трудно читать)
orders = Order.where(status: :completed).where("created_at > ?", 1.month.ago).order(created_at: :desc).limit(100)
# Вертикальная цепочка
orders = Order
.where(status: :completed)
.where("created_at > ?", 1.month.ago)
.order(created_at: :desc)
.limit(100)
Ленивые вычисления
Используйте lazy для обработки больших коллекций без загрузки всех элементов в память:
File.foreach("large.log")
.lazy
.select { |line| line.include?("ERROR") }
.first(10)
.each { |line| puts line }
Комментарии и документация
Самодокументируемый код
Стремитесь к созданию кода, который не требует комментариев благодаря осмысленным именам и структуре:
# Требует комментария
def m(d)
d + 7
end
# Самодокументируемый
def next_week(date)
date + 7.days
end
Документация публичного интерфейса
Документируйте публичные методы и классы с использованием формата YARD:
# Обрабатывает платеж через шлюз оплаты
#
# @param order [Order] заказ для обработки
# @param payment_method [String] метод оплаты (например, "credit_card")
# @return [PaymentResult] результат обработки платежа
# @raise [InsufficientFundsError] если средств недостаточно
# @raise [InvalidCardError] если данные карты недействительны
def process_payment(order, payment_method:)
# реализация
end
Комментарии для объяснения "почему"
Комментарии должны объяснять причины принятых решений, а не описывать "что" делает код:
# Плохой комментарий: описывает очевидное
# Увеличиваем счётчик на единицу
counter += 1
# Хороший комментарий: объясняет причину
# Используем 3 попытки вместо стандартных 5 из-за ограничений внешнего API
# (см. документацию провайдера, раздел 4.2)
3.times do
break if send_request
sleep 1
end
Тестирование
Структура тестов
Организуйте тесты по структуре, отражающей тестируемый код:
spec/
├── models/
│ └── user_spec.rb
├── services/
│ └── payment_service_spec.rb
├── controllers/
│ └── orders_controller_spec.rb
└── support/
└── shared_examples/
└── authenticatable.rb
Стиль написания тестов
Пишите тесты, которые читаются как спецификация поведения:
RSpec.describe Order do
describe "#total" do
context "with multiple items" do
it "sums prices of all items" do
order = Order.new
order.add_item(Product.new(price: 100), quantity: 2)
order.add_item(Product.new(price: 50), quantity: 1)
expect(order.total).to eq(250)
end
end
context "with discounts" do
it "applies percentage discount correctly" do
order = Order.new(discount_percent: 10)
order.add_item(Product.new(price: 100), quantity: 1)
expect(order.total).to eq(90)
end
end
end
end
Тестирование граничных случаев
Включайте тесты для граничных значений и нестандартных ситуаций:
describe "#discounted_price" do
it "returns zero for 100% discount" do
product = Product.new(price: 100)
expect(product.discounted_price(100)).to eq(0)
end
it "returns original price for 0% discount" do
product = Product.new(price: 100)
expect(product.discounted_price(0)).to eq(100)
end
it "handles negative discount as zero" do
product = Product.new(price: 100)
expect(product.discounted_price(-10)).to eq(100)
end
end
Инструменты обеспечения качества
RuboCop
RuboCop обеспечивает соблюдение стилевых соглашений и выявляет потенциальные ошибки. Настройте файл .rubocop.yml под требования проекта:
# .rubocop.yml
AllCops:
TargetRubyVersion: 3.2
Exclude:
- "vendor/**/*"
- "tmp/**/*"
- "db/**/*"
Layout/LineLength:
Max: 100
Metrics/MethodLength:
Max: 20
Metrics/AbcSize:
Max: 20
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/Documentation:
Enabled: false
EditorConfig
Файл .editorconfig обеспечивает единообразие отступов и кодировки между разными редакторами:
# .editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.rb]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
CI/CD для Ruby
Настройте непрерывную интеграцию для автоматического запуска тестов и проверки стиля:
# .github/workflows/ci.yml
name: Ruby CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2
- name: Install dependencies
run: bundle install
- name: Run RuboCop
run: bundle exec rubocop
- name: Run tests
run: bundle exec rspec
Практические примеры
Рефакторинг длинного метода
Исходный код:
def process_order(params)
user = User.find_by(email: params[:email])
if user.nil?
user = User.create!(
email: params[:email],
name: params[:name],
address: params[:address]
)
end
order = Order.new
order.user = user
order.status = :pending
total = 0
params[:items].each do |item_params|
product = Product.find(item_params[:product_id])
quantity = item_params[:quantity].to_i
item_total = product.price * quantity
total += item_total
order.items << OrderItem.new(
product: product,
quantity: quantity,
unit_price: product.price
)
end
order.total = total
order.tax = total * 0.2
order.grand_total = order.total + order.tax
if order.grand_total > 1000
order.apply_discount(5)
end
order.save!
PaymentService.new.process(order) if params[:payment_method]
NotificationService.new.order_created(order)
order
end
Рефакторинг с декомпозицией:
def process_order(params)
user = find_or_create_user(params)
order = build_order(user, params)
apply_discounts(order)
order.save!
process_payment(order, params) if params[:payment_method]
notify_about_order(order)
order
end
private
def find_or_create_user(params)
User.find_by(email: params[:email]) ||
User.create!(
email: params[:email],
name: params[:name],
address: params[:address]
)
end
def build_order(user, params)
order = Order.new(user: user, status: :pending)
add_items_to_order(order, params[:items])
calculate_totals(order)
order
end
def add_items_to_order(order, items_params)
items_params.each do |item_params|
product = Product.find(item_params[:product_id])
quantity = item_params[:quantity].to_i
order.items << OrderItem.new(
product: product,
quantity: quantity,
unit_price: product.price
)
end
end
def calculate_totals(order)
order.total = order.items.sum(&:total_price)
order.tax = order.total * 0.2
order.grand_total = order.total + order.tax
end
def apply_discounts(order)
order.apply_discount(5) if order.grand_total > 1000
end
def process_payment(order, params)
PaymentService.new.process(order)
end
def notify_about_order(order)
NotificationService.new.order_created(order)
end
Использование сервисных объектов
Сервисные объекты инкапсулируют сложную бизнес-логику, извлекая её из контроллеров и моделей:
# app/services/order_fulfillment_service.rb
class OrderFulfillmentService
def initialize(order)
@order = order
end
def fulfill!
return false unless order.can_be_fulfilled?
ActiveRecord::Base.transaction do
reserve_inventory
charge_customer
schedule_shipping
update_order_status
send_fulfillment_notification
end
true
rescue InventoryUnavailableError => e
handle_inventory_error(e)
false
rescue PaymentDeclinedError => e
handle_payment_error(e)
false
end
private
attr_reader :order
def reserve_inventory
order.items.each do |item|
Inventory.reserve(item.product_id, item.quantity)
end
end
def charge_customer
payment = PaymentProcessor.charge(
customer: order.customer,
amount: order.total,
description: "Order ##{order.id}"
)
order.update!(payment_id: payment.id)
end
# остальные приватные методы...
end
# Использование в контроллере
class OrdersController < ApplicationController
def fulfill
order = Order.find(params[:id])
service = OrderFulfillmentService.new(order)
if service.fulfill!
redirect_to order, notice: "Order fulfilled successfully"
else
redirect_to order, alert: "Failed to fulfill order"
end
end
end