Рефакторинг кода Ruby: от сервисных объектов к объектному дизайну
15 декабря 2023 г.По мере развития технологий и углубления нашего понимания масштабируемого и поддерживаемого кода оптимизация архитектуры программного обеспечения становится первостепенной задачей. В сфере программирования Ruby концепция сервисных объектов уже давно является краеугольным камнем управления сложной бизнес-логикой. Пару дней назад я получил электронное письмо от AppSignal OneTribe, в котором содержалась информация о проблеме, которую я уже видел несколько раз, но у меня не было достаточно времени и желания работать над ней.
Признаки плохого дизайна
Проблема была относительно небольшой и простой. OneTribe интегрируется со Slack, который включает функцию изменения статуса: когда кто-то берет отпуск, статус пользователя Slack будет обновляться с помощью смайликов и соответствующего текстового сообщения.
Помимо производственного приложения OneTribe, у нас есть еще одно, которое используется для разработки и тестирования; поскольку он не опубликован, он работает только с нашим рабочим пространством, а его токены отзываются каждые Х месяцев (точно не помню), что вызывает исключение, когда мы пытаемся использовать отозванный токен для вызовов API. Это исключение никак не обрабатывается и передается непосредственно в AppSignal.
Ниже приведен отрывок из кода, вызвавшего исключение Slack::Web::Api::Errors::TokenRevoked
. Класс TimeOff реализует объект выходного дня и имеет разные состояния, и в день его запуска он становится «текущим» выходным; в начале этого дня (для часового пояса пользователя) мы обновляем статус пользователя в Slack.
# app/workers/time_off/starts_today_worker.rb
class TimeOff::StartTodayWorker
include ApplicationWorker
urgency :low
sidekiq_options retry: false
def perform
timezones = Utils::TimeZone.all_for_hour(8)
dates = Utils::TimeZone.dates_in_timezones(8)
return if timezones.blank?
TimeOff
.approved
.starts_on(dates)
.member_timezone_in(timezones)
.find_each do |time_off|
time_off.deliver_start_today_notification
time_off.change_slack_status
end
end
end
# app/models/time_off.rb
class TimeOff < ApplicationRecord
# ...
def change_slack_status
if slack_authorization.present? && (type.status_text.present? || type.status_emoji.present?)
::Slack::StatusChangeService.new(authorization: slack_authorization).call(
status: type.status_text,
emoji: type.status_emoji,
expiration: member.time_in_timezone(end_date + 1.day).to_i
)
end
end
end
* Что не так с этим кодом?
Метод change_slack_status
определен в классе TimeOff
.
* Что это значит? * Какой статус Slack изменился?
Тело метода также задает много вопросов. Что такое slack_authorization
и почему это атрибут или метод экземпляра TimeOff
?
* Где мне следует поместить блок восстановления для исключения Slack::Web::Api::Errors::TokenRevoked
? В класс TimeOff
? И что должен делать этот спасательный блок? Вероятно, следует аннулировать токен, который использовался для вызовов API. Но токен принадлежит пользователю, а не время отсутствия. Было бы странно удалять ассоциации пользователя в блоке восстановления внутри класса TimeOff
(это нарушит Закон Деметры).
Но самый большой вопрос касается объекта Slack::StatusChangeService
.
Объекты обслуживания в Ruby
Сильные стороны объектно-ориентированного программирования заключаются в его способности наделять объекты как поведение, так и данные, тем самым снабжая их мощными функциями.
Кроме того, этот подход способствует более согласованному согласованию объектов с основными концепциями модели предметной области, что приводит к более понятному коду для разработчиков.
# app/services/slack/status_change_service.rb
class Slack::StatusChangeService
extend Dry::Initializer[undefined: false]
option :authorization
def call(status:, emoji:, expiration:)
client.users_profile_set(profile: profile_params(status, emoji, expiration))
end
protected
def client
@client ||= ::Slack::Web::Client.new(token: authorization.payload['authed_user']['access_token'])
end
def profile_params(status, emoji, expiration)
params = {
status_text: status,
status_emoji: emoji,
status_expiration: expiration
}
params.to_json
end
end
Служебные объекты лишают нас этих преимуществ и могут привести к другим проблемам с кодом.
- Потенциал для объектов-богов. Объекты-службы могут превратиться в «объекты-боги», осведомленные о слишком многих аспектах системы, что приводит к созданию тесно связанного кода, который трудно расширить или изменить, не затрагивая другие части системы. приложение. ол>
2. Запутывание бизнес-логики. В некоторых случаях чрезмерное использование сервисных объектов может разбросать бизнес-логику по нескольким небольшим классам, что затрудняет понимание всего потока приложения.
3. Накладные расходы на обслуживание. Когда база кода пронизана многочисленными сервисными объектами, их обслуживание, обновление и отладка могут стать сложными. Это может увеличить когнитивную нагрузку на разработчиков, пытающихся понять код.
4. Ограниченная читаемость и доступность. Обилие сервисных объектов может затруднить разработчикам новых проектов понимание того, где найти конкретную функциональность, что влияет на обнаруживаемость и читабельность кода.
Рефакторинг
Мой подход к рефакторингу предполагает определение отдельных обязанностей внутри объекта службы и выделение их в отдельные классы или модули. Приведенный выше класс реализует запрос к Slack API на изменение статуса участника — значок и текст, отображаемые рядом с именем участника. OneTribe использует его для визуального уведомления членов команды о текущих перерывах.
С какими объектами мы работаем?
Отгул — самое очевидное, и мы об этом уже знаем. Выходной принадлежит Участнику, который представляет пользователя из компании и уже реализован. Однако есть еще один тип, который был упущен — SlackStatus.
Попробуем это реализовать.
# app/lib/member/slack_status.rb
# Value object that represents a Slack status to be set for a member.
class Member::SlackStatus < Data.define(:status_text, :status_emoji, :status_expiration)
def initialize(status_text:, status_emoji:, status_expiration: nil)
super
end
def as_json
{
status_text: status_text,
status_emoji: status_emoji,
status_expiration: status_expiration
}.compact
end
end
Теперь мы можем вернуть правильный статус из класса TimeOff.
# app/models/time_off.rb
class TimeOff < ApplciationRecord
# ...
def slack_status
if type.status_text.present? || type.status_emoji.present?
Member::SlackStatus.new(
status_text: type.status_text,
status_emoji: type.status_emoji,
status_expiration: member.time_in_timezone(end_date + 1.day).to_i
)
end
end
end
Таким образом, вместо TimeOff#change_slack_status
, который меняет чей-либо статус Slack, мы получили TimeOff#slack_status
, который возвращает Member::SlackStatus
выбранного времени отдыха. или ноль
. Теперь TimeOff#slack_status
имеет дело с TimeOff
(self) Member::SlackStatus
и NilClass
. Мы можем полностью исключить значения nil
.
Давайте перепишем приведенный выше код.
# app/lib/member/slack_status.rb
# Value object that represents a Slack status to be set for a member.
# It is used in TimeOff::StartTodayWorker to set the status for a member that has a time off starting today.
class Member::SlackStatus < Data.define(:status_text, :status_emoji, :status_expiration)
def initialize(status_text:, status_emoji:, status_expiration: nil)
super(
status_text: status_text || OneTribe::EMPTY_STRING,
status_emoji: status_emoji || OneTribe::EMPTY_STRING,
status_expiration: status_expiration
)
end
# Initialize new Member::SlackStatus we empty string status_text and status_emoji.
def self.default
new(status_text: OneTribe::EMPTY_STRING, status_emoji: OneTribe::EMPTY_STRING)
end
def ==(other)
(status_text == other.status_text) && (status_emoji == other.status_emoji)
end
def default?
self == self.class.default
end
def as_json
{
status_text: status_text,
status_emoji: status_emoji,
status_expiration: status_expiration
}.compact
end
end
С помощью этих последних изменений мы можем упростить TimeOff#slack_status
.
# app/models/time_off.rb
class TimeOff < ApplciationRecord
# ...
def slack_status
Member::SlackStatus.new(
status_text: type.status_text,
status_emoji: type.status_emoji,
status_expiration: member.time_in_timezone(end_date + 1.day).to_i
)
end
end
Наконец, мы можем реализовать методы изменения статуса в классе Member
.
# app/models/member.rb
class Member < ApplicationRecord
# ...
def set_slack_status(status, force: false)
return unless slack_client
return if status.default? && !force
slack_client.users_profile_set(profile: status.to_json)
rescue Slack::Web::Api::Errors::TokenRevoked => _e
slack_authorization.destroy!
false
end
def reset_slack_status
set_slack_status(Member::SlackStatus.default, force: true)
end
# ...
private
def slack_client
if slack_authorization
::Slack::Web::Client.new(
token: slack_authorization.payload['authed_user']['access_token']
)
end
end
end
Последнее изменение будет в работнике, который уже был показан в начале этого текста.
# app/workers/time_off/starts_today_worker.rb
class TimeOff::StartTodayWorker
# ...
def perform
timezones = Utils::TimeZone.all_for_hour(8)
dates = Utils::TimeZone.dates_in_timezones(8)
return if timezones.blank?
TimeOff
.approved
.starts_on(dates)
.member_timezone_in(timezones)
.find_each do |time_off|
time_off.deliver_start_today_notification
time_off.member.set_slack_status(time_off.slack_status)
end
end
end
Давайте пройдемся по списку внесенных изменений:
* Мы реализовали новый тип — Member::SlackStatus
, который теперь используется только для представления статуса, созданного с момента отключения. Однако не имеет значения, какая часть системы создает экземпляр этого объекта; оно по-прежнему будет актуально.
* Slack::StatusChangeService
удален. Самым значительным и заметным изменением является удаление части кода, которая не соответствует принципам и рекомендациям объектно-ориентированного подхода.
* Код, который использовался для изменения статуса, был перенесен из класса TimeOff
в Member
; теперь эта часть кода соответствует Закону Деметры.
* Наконец, мне удалось решить проблему с отозванными токенами Slack.
Выводы
В заключение, рефакторинг сервисных объектов в Ruby предоставляет важнейшую возможность повысить удобство сопровождения, масштабируемость и общее качество кодовой базы. Благодаря тщательному анализу и продуманной реструктуризации разработчики могут эффективно разбивать монолитные сервисные объекты на более мелкие и более целенаправленные классы или модули. Этот процесс позволяет лучше соблюдать принцип единой ответственности (SRP), улучшает читаемость кода и упрощает обслуживание.
Простота часто творит чудеса!
:::информация Также опубликовано здесь.
:::
Оригинал