Перейти к основному содержимому

5.11. Рекомендации по разработке на Ruby

Разработчику Архитектору

Рекомендации по разработке на Ruby

Введение в культуру кода Ruby

Ruby обладает уникальной философией, выраженной в принципе «программист счастлив важнее, чем компьютер счастлив». Эта философия проявляется в синтаксисе языка, стандартной библиотеке и принятых в сообществе практиках. Код на Ruby ценит выразительность, читаемость и элегантность. Хороший Ruby-код читается как естественный язык, минимизирует шаблонные конструкции и предпочитает явные намерения неявным соглашениям.

Сообщество Ruby разработало устоявшиеся соглашения, закреплённые в стилевых гидах (например, от команды GitHub и от сообщества Ruby on Rails). Эти соглашения обеспечивают единообразие кодовой базы в разных проектах и упрощают совместную разработку.

Соглашения об именовании

Основные правила именования

Имена в коде передают смысл и намерения разработчика. Следование единым правилам именования упрощает чтение и понимание кода.

Элемент языкаСтильПримеры
Переменные, методыsnake_caseuser_count, calculate_total
КонстантыSCREAMING_SNAKE_CASEMAX_RETRIES, DEFAULT_TIMEOUT
Классы, модулиPascalCaseUserService, 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