Каковы принципы SOLID? Что означает SOLID? SOLID — это аббревиатура от пяти принципов проектирования классов, представленных Робертом С. Мартином в его статье «Принципы проектирования и шаблоны проектирования»:

  • принцип единой ответственности
  • принцип открыто-закрыто
  • Принцип подстановки Лисков
  • принцип разделения интерфейсов
  • принцип инверсии зависимости

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

В этой статье я сосредоточусь на двух из них: принципе единой ответственности и принципе открытости-закрытости.

TLDR:

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

Принцип единой ответственности. У класса должна быть одна и только одна причина для изменения.

Что это означает?

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

Ответственность класса зависит от того, какой объект мы создали. Что он реализует? Что мы ожидаем от него? Является ли это представлением данных, функциональности или решения какой-то технической проблемы? В большинстве случаев мы думаем об ответственности как о функциональности в контексте функций или бизнес-требований, таких как создание файлов, добавление счетов в систему, создание отчетов и т. д. Иногда мы говорим о более технических требованиях, таких как контроллеры, которые должны контролировать поток данных. данные для нескольких разных запросов (редактирование, добавление, поиск данных). Такой контроллер поддерживает несколько пользовательских историй, но делает одно — управляет потоком. Другим примером может быть шина сообщений, которая может передавать несколько сообщений, связанных с различными бизнес-требованиями, но имеет одну функцию — транспортировку данных.

Единая ответственность на практике

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

class User {
  constructor(name, email) {... }
  sendMessage(message) { ... }
  watchUser() { ... }
  static find(query) { ... }
}

Как видите, класс будет расти вместе с требованиями и будет делать несколько вещей. Часть функций будет зависеть друг от друга и повторно использовать одни и те же атрибуты класса. Вместо этого мы должны разделить код на более мелкие части. Мы можем оставить пользователя в качестве модели с возможностью чтения и записи данных из БД. Отправка сообщения будет отдельной функциональностью, представленной в других классах, то же самое мы можем сказать о подписке на других пользователей. Мы можем пойти еще дальше и разделить User на User и UserRepository. Первый будет отвечать за представление данных. Второй для запроса БД. Мы бы получили что-то вроде этого:

class User {
  constructor(name, email) {...}
}
class UserRepository {
  static find(query) { ... }
}
class Messenger {
   send(receiver, sender, message) { ... }
}
class Watcher {
  watch(watcher, user) { ... }
}

Почему?

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

Более специализированными классами легче управлять сами по себе. Гораздо проще посмотреть на конкретный класс и разобраться, если у него всего несколько функций. Подумайте о классе для управления счетами, допустим, вы создаете только один класс для всего: манипулирование базой данных, форматирование ответа для API, создание файлов, отправка данных в другие системы, включая полную связь с такими системами, доступ для проверки данных, расчеты и т. д. Такой класс был бы обширным и нечитаемым. Одна функция в таком классе может влиять на каждую из этих функций. Если вы разделите такой класс по функциям, его будет намного проще читать и управлять им.

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

Этот принцип также влияет на соглашение об именах. Когда у вас есть классы с одной ответственностью, их легче назвать. Вам не нужно думать, как представить весь спектр функционала, реализованного в классе. Вы только что сказали, какую часть системы он реализует и что он делает, например, AuthController, InvoiceModel, InvoicePdfGenerator и т. д. Это влияет на всю структуру папок/файлов вашего приложения.

Дополнительный комментарий

Этот принцип проще всего понять, но, на мой взгляд, сложнее всего реализовать.

Я считаю, что это самый масштабируемый принцип. Он был представлен в контексте класса, но вы можете перенести его на более низкие уровни абстракции, такие как функции, или на более высокие уровни, такие как модули.

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

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

Принцип открытости-закрытости. Вы должны иметь возможность расширять поведение классов, не изменяя его.

Что это означает?

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

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

Принцип «открыто-закрыто» на практике

Предположим, у нас есть список пользователей. Нам нужно отобразить его на странице для администратора. Мы можем фильтровать и сортировать их по имени и местоположению. Создаем контроллер и класс к полученному списку:

class UserController {
    public function get($filters) {
        $validFilters = SimpleValidator::validate($filters);
        $users =  UserRepository::all($validFilters)
        return {"data": $users}
    }
}
class UserRepository {
   public static all($filters) {...}
}
class SimpleValidator {
    public static validate($filters) { ... }
}

Теперь клиент решает, что он хочет экспортировать такой список в pdf или CSV. Для каждого вида экспорта нам нужны разные возможности фильтрации. Вы можете написать несколько ifs в контроллере, чтобы выбрать конкретную проверку и тип ответа. Но это может нарушить принцип единой ответственности. Вы можете строить новые классы, но часть функционала будет повторяться. Вы также можете создать свой ClassController, чтобы дать вам возможность расширить его с помощью внедрения зависимостей:

class UserController {
    public function get($filters, Validator $validator, ResponseFormatter $responseFormatter) {
        $validFilters = $validator->validate($filters);
        return $responseFormatter->formatResponse(UserRepository::all($validFilters))
    }
}
class UserRepository {
   public static function all($filters) {...}
}
class SimpleValidator extends Validator{
    public function validate($filters) { ... }
}
class CsvValidator extends Validator {
    public function validate($filters) { ... }
}
class PdfValidator extends Validator {
    public function validate($filters) { ... }
}
class ApiResponseFormatter extends ResponseFormatter {
    public function formatResponse($data) { ... }
}
class PdfResponseFormatter extends ResponseFormatter {
    public function formatResponse($data) { ... }
}
class CsvResponseFormatter extends ResponseFormatter {
    public function formatResponse($data) { ... }
}

Почему?

Требования к нашим приложениям постоянно меняются. Нам нужно настроить некоторые детали, изменить конкретный поток, предоставить пользователям новые параметры и т. д. В сложных приложениях мы получаем множество мелких повторяющихся элементов. С принципом единой ответственности мы гарантируем, что можем повторно использовать такие небольшие части без их повторного написания. Что происходит, когда нам нужно изменить какое-то поведение в определенном контексте? Мы можем попробовать написать какой-нибудь switch, ifs и т.д. Но так мы нарушаем отдельные обязанности. Вот где вступает в действие принцип открытого-закрытого. Если мы сможем создать некоторые базовые классы, а затем расширить их в определенных ситуациях, не изменяя сам класс, мы сможем поддерживать чистый код и добавлять любую новую функцию, функциональность или исключение, которые нас просят. Предоставляя возможность расширения класса зависимостями или путем наследования, мы делаем отдельные элементы более гибкими и простыми в обслуживании.

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

Дополнительный комментарий

Этот принцип очень важен для фреймворков, библиотек и более сложных/абстрактных элементов приложений. Это дает разработчикам возможность расширять вашу библиотеку или писать плагины для ваших решений, не касаясь кода, который вы предоставляете. Это делает ваш код повторно используемым в нескольких местах и ​​для разных целей. Подумайте о любой библиотеке, которую вы используете. Сколько раз вам нужно было создать что-то конкретное, чего не было в библиотеке? Наверное много. Сколько раз вам нужно было изменить код библиотеки, чтобы решить вашу проблему? Наверное, такой ситуации не было. Скорее всего, вы расширили определенные классы или внедрили свои собственные зависимости.

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

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

Это пока все. Надеюсь, вам понравилась эта статья, и она помогла вам понять первые два принципа SOLID.

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