Рефакторинг кода Ruby: от сервисных объектов к объектному дизайну

Рефакторинг кода 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

Служебные объекты лишают нас этих преимуществ и могут привести к другим проблемам с кодом.

  1. Потенциал для объектов-богов. Объекты-службы могут превратиться в «объекты-боги», осведомленные о слишком многих аспектах системы, что приводит к созданию тесно связанного кода, который трудно расширить или изменить, не затрагивая другие части системы. приложение.
  2. 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), улучшает читаемость кода и упрощает обслуживание.

    Простота часто творит чудеса!

    :::информация Также опубликовано здесь.

    :::


    Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE