Отказ от ответственности: я не утверждаю, что следующие слова соответствуют первоначальному значению автора SOLID, но они помогают мне сделать мой код намного лучше, и я считаю, что им стоит поделиться. Если кто-то найдет здесь что-то полезное для себя, то цель этого поста буду считать выполненной.

SOLID всегда был сложной вещью для меня. С одной стороны, аббревиатура состоит из простых и рациональных идей, и их легко понимает каждый, даже новичок. С другой стороны, каждый раз, когда теория переходит на практику, нет единого соглашения о том, как применять эти принципы к конкретному случаю.

Говоря о «S», принципе единственной ответственности, первая часть довольно ясна. Одна ответственность, похожая на реальный мир, преимущества и мотивация очевидны. Однако вторая часть вызывает вопросы. Что такое «единая ответственность»? Обработка событий от пользователя, это одна обязанность? Или каждое событие должно обрабатываться отдельным объектом? Что такое «одна из причин измениться»? По моему опыту, люди часто имеют собственное понимание этих терминов.

Чтобы проиллюстрировать мое объяснение, давайте рассмотрим один пример. Я использую Swift, однако он работает так же и на других императивных языках.

Существует класс, которому нужно что-то получить из Интернета.

class Model {
  func startLoading() {
    let url = URL(string: "https://test.com/data")!
  }
}

Для выполнения своей работы модели может потребоваться создать авторизованный запрос с токеном авторизации из текущего сеанса, выполнить построенный запрос, а затем проанализировать данные в DTO. Обычно люди (и я тоже) создают новые протоколы (или интерфейсы на других языках) для каждой из упомянутых вещей.

/// Represents a session for an authorized user
protocol Session {
  var authorizationToken: String { get }
}

/// Service responsible for executing network requests
protocol NetworkEngine {
  func execute(_: URLRequest, completion: @escaping (Data) -> Void)
}

/// Factory responsible for mapping raw data into models 
protocol ResponseParser {
  func parse<Model>(_: Model.Type, from: Data) throws -> Model
}

Похоже, мы ищем отдельные обязанности, верно? Я бы сказал да. Затем пришло время завершить метод модели с использованием добавленных протоколов.

class Model {
  private let session: Session
  private let networkEngine: NetworkEngine
  private let parser: ResponseParser
  
  init(session: Session, networkEngine: NetworkEngine, parser: ResponseParser) {
    self.session = session
    self.networkEngine = networkEngine
    self.parser = parser
  }
  
  func startLoading() {
    let url = URL(string: "https://test.com/data")!
    let request = buildRequest(with: url, token: session.authorizationToken)
    networkEngine.execute(request) { [parser] data in
      let model = try! parser.parse(MyModel.self, from: data)
    }
  }
}

Класс получил несколько зависимостей, которые помогают в выполнении его работы. Код выглядит великолепно? Не совсем… и я не имею в виду именование, принудительное try или отсутствие какой-либо обработки ошибок. Проблема с добавленными зависимостями.

В первую очередь, чтобы получить данные, класс должен знать о тонкостях работы остального вашего приложения, о сессиях, формате общения с бэкендом и т. д. Представьте, что для того, чтобы купить бургер, нужно знать точный рецепт об этом напомните персоналу приготовить котлету, а также принесите кетчуп, потому что он входит в рецепт. И чем сложнее работа, тем больше вещей вам нужно знать, чтобы заставить кого-то что-то сделать для вас.

Далее, каждый раз, когда я добавляю еще один класс, который тоже что-то загружает, мне придется копировать этот блок кода, без изменений, потому что код не содержит конкретной конфигурации для определенного случая, а заставляет работать все компоненты.

Кроме того, если API добавит новый обязательный заголовок для всех авторизованных запросов, мне придется внести изменения во все модели, хотя их логика останется прежней.

Проблема, на мой взгляд, в том, что я создал несколько объектов и придумал возложенные на них обязанности, но не определил, что Model на самом деле нужно. Для выполнения своей работы Model нужны были некоторые данные, загруженные из бэкенда приложения, вот и все. И должен быть кто-то, кто позаботится обо всем процессе получения этих данных. Не создавайте запрос и не помогайте в анализе необработанных данных в DTO. Просто дайте ссылку и получите информацию, хранящуюся там, это то, что хочет моя модель. В приведенном выше коде ни один из этих объектов не может удовлетворить эту потребность.

И вот моя идея «единой ответственности»:

Ответственность — это проблема, которую класс хочет делегировать. Иными словами, ответственность декларируют те, кому что-то нужно, а не те, кто может выполнить какую-то работу.

Инициатива декларирования ответственности должна перейти от «работников» к их «клиентам». По сравнению с реальной жизнью компания нанимает сотрудников для собственных нужд, а не иначе.

Исправим пример. Если модель имеет ссылку и хочет получить доступ к хранящимся там данным, я должен разработать объект, который будет принимать URL-адрес и возвращать запрошенный объект.

/// A service responsible for loading models from a remote source
protocol WebService {
  func load<Model>(_: Model.Type, from: URL, completion: @escaping (Model) -> Void)
}

Этот протокол требует от своих клиентов как можно меньше. Чтобы решить мою проблему в Model, мне нужно знать URL-адрес и тип DTO, описывающий ответ, вот и все. Я больше ничего не спрашиваю у объектов, как авторизуется приложение, какие шаги необходимы для выполнения запроса, и Model не изменится, если однажды мы добавим дополнительное шифрование (например).

Чтобы предоставить больше примеров, давайте применим эту идею к реализации WebService. Ему есть что делегировать? Конечно. Он хочет, чтобы кто-то получил доступ к данным авторизации, кто-то выполнил сетевой запрос, парсер построил DTO по мере загрузки. Похоже, есть классы, которые могут выполнять эти обязанности.

class AuthorizedAreaWebService {
  private let session: Session
  private let networkEngine: NetworkEngine
  private let parser: ResponseParser
  
  init(session: Session, networkEngine: NetworkEngine, parser: ResponseParser) {
    self.session = session
    self.networkEngine = networkEngine
    self.parser = parser
  }
  
  func load<Model>(_: Model.Type, from url: URL, completion: @escaping (Model) -> Void) {
    let request = buildRequest(with: url, token: session.authorizationToken)
    networkEngine.execute(request) { [parser] data in
      let model = try! parser.parse(Model.self, from: data)
      completion(model)
    }
  }
}

Что было достигнуто? Модели сейчас проще, потому что им не важен алгоритм загрузки данных, они не будут меняться со временем из-за изменения сетевой логики, количество шаблонного кода серьезно уменьшено (мне так кажется, по крайней мере). Кроме того, теперь эти классы проще поместить в тестовую или фиктивную среду. И это только потому, что мы перешли от изобретения обязанностей к определению «потребностей» наших объектов.

Не существует простого пошагового алгоритма применения этого принципа к коду, потому что я считаю, что это полностью зависит от конкретного случая. Тем не менее, я собрал несколько советов, которые должны помочь.

  1. Попробуйте начать проектирование вашей системы с высокоуровневых компонентов и перейти к более низким уровням. Как и в примере выше, я сначала определяю потребности Model, создаю классы (или протоколы) для выполнения обязанностей Model, а затем повторяю алгоритм для вновь созданных классов.
  2. Чтобы проверить, имеет ли класс единую ответственность или нет, попробуйте что-то изменить в текущих действиях, выполняемых для выполнения своей работы. Например, говоря о примере выше, его можно изменить, чтобы анализировать полученные данные из XML или добавить повторную попытку для неудачного запроса. Если его клиент (в примере Model является таким клиентом) должен быть изменен, чтобы продолжить работу с классом, то это может быть признаком того, что он не забирает работу полностью.
  3. Иногда лучше посмотреть со стороны. Решая проблему (например, сохраняя дату в постоянном хранилище), вы можете попробовать «уменьшить масштаб изображения» и поискать похожие проблемы в других местах. Если другому классу нужно сохранить число в in-memory хранилище, а третьему хранить что-то еще в зашифрованном хранилище, то все эти проблемы может решить один тип, который что-то где-то хранит и читает, в зависимости от текущей реализации, и, решая свое текущее дело, вы можете создать основу для решения других подобных проблем.
  4. Быть «эгоистичным». ООП упрощает код, потому что, находясь в рамках класса, вы абстрагируетесь от остальной части вашей системы и, следовательно, вам нужно меньше помнить о том, как это работает. Вы хотите, чтобы регистратор регистрировал ваше сообщение, независимо от того, как он выполняет эту работу, вы ожидаете, что сетевой уровень будет выполнять все операции авторизации/ожидания/анализа/повторных попыток. Это работает и с вашими абстракциями: чем меньше деталей вам нужно учитывать при работе с вашим классом, тем проще ваш класс, тем он полезнее. Спросите себя, могу ли я избавиться от этого знания здесь? Важно ли для вашего класса знать, где хранится информация о фильме? Вы хотите знать, какое окно использовать для отображения оповещения для пользователя, или вы просто хотите запросить оповещение с требуемым сообщением и позволить механизму оповещения сделать всю работу?
  5. Будьте ленивы тоже. Не пытайтесь разбить вашу систему на атомы. Другой вопрос, с которым я сталкиваюсь на работе, — это когда остановить процесс декомпозиции, когда классы становятся маленькими и достаточно «соответствующими SRP». Мое мнение таково, что вы должны делать это итеративно и делать это только в том случае, если вы субъективно чувствуете, что ваш класс сложен. Как это работает: вы разбиваете свой класс на более мелкие компоненты (извлекая какую-то работу в зависимости класса), и теперь этот класс выглядит для вас нормально. Этого уже достаточно, потому что текущая проблема решена. Проект компилируется и работает, новые классы для него — черные ящики, от их потенциальной сложности не болит. Когда анализировать эти вновь созданные классы? Например, когда у вас есть время. Или когда вы устали от другой задачи и хотите потратить некоторое время на рефакторинг своего проекта. Или когда в проект вносятся изменения, и вы чувствуете, что некоторые места стали слишком сложными. Благодаря ООП такую ​​декомпозицию можно делать постепенно, в течение длительного времени.

Надеюсь, мне удалось объяснить идею и примеры кода достаточно понятны. Однако, если у вас есть вопросы или проблемы, пожалуйста, оставьте свой отзыв в комментариях, и спасибо за чтение!