Принципы SOLID в PHP

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

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

Давайте рассмотрим эти принципы в действии один за другим, с простыми определениями, описаниями и примерами кода.

Принцип единой ответственности (SRP)

Классы (и, кстати, их методы) должны нести только одну ответственность. Итак, если мы поместим кучу несвязанных задач в один класс, это нарушит принцип, сделав этот класс несчастным. Вместо этого мы можем создать разные классы для разных задач, чтобы следовать данному принципу.

Здесь у нас есть конечная точка API POST /token для создания токенов, TokenController класс с tokenAction() методом для проверки пользователя по учетным данным имени пользователя и пароля с использованием User сущности.

Плохая практика:

class User extends Entity {
  public int $id;
  public string $name, $email;
  public UserSettings $settings;

  // More props & setters/getters.

  // @tofix: Does not belong here.
  public function validatePassword(string $password): bool {
    // Run validation stuff.
  }

  // @tofix: Does not belong here.
  public function sendMail(string $subject, string $body): void {
    // Run mailing stuff.
  }
}


class TokenControler extends Controller {
  // @call POST /token
  public function tokenAction(): Payload {
    [$username, $password]
      = $this->request->post(['username', 'password']);

    // @var User (say it's okay, no 404)
    $user = $this->repository->getUserByUsername($username);

    if ($user->validatePassword($password)) {
      if ($user->settings->isTrue('mailOnAuthSuccess')) {
        $user->sendMail(
         'Success login!',
         'New successful login, IP: ' . $this->request->getIp()
        );
      }

      $token = new Token($user);
      $token->persist();

      return $this->jsonPayload(Status::OK, [
        'token'  => $token->getValue(),
        'expiry' => $token->getExpiry()
      ]);
    }

    if ($user->settings->isTrue('mailOnAuthFailure')) {
      $user->sendMail(
        'Failed login!',
        'Suspicious login attempt, IP: ' . $this->request->getIp()
      );
    }

    return $this->jsonPayload(Status::UNAUTHORIZED, [
      'error' => 'Invalid credentials.'
    ]);
  }
}

Поскольку мы наполняем User сущность двумя несвязанными методами (не принадлежащими к ним), вероятно, со временем будет много причин изменить этот класс.

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

Лучшая практика:

class User extends Entity {
  public int $id;
  public string $name, $email;
  public UserSettings $settings;

  // More props & setters/getters.
}


// Each item is in its own file.
class UserHolder {
  public function __construct(
    protected readonly User $user
  ) {}
}

class UserPasswordValidator extends UserHolder {
  public function validate(string $password): bool {
    // Run validation business using $this->user->password.
  }
}
class UserAuthenticationMailer extends UserHolder {
  public function sendSuccessMail(string $ip): void {
    // Run mailing business using $this->user->email.
  }
  public function sendFailureMail(string $ip): void {
    // Run mailing business using $this->user->email.
  }
}


class TokenControler extends Controller {
  // @call POST /token
  public function tokenAction(): Payload {
    // ...

    $validator = new UserPasswordValidator($user);
    if ($validator->validate($password)) {
      if ($user->settings->isTrue('mailOnAuthSuccess')) {
        $mailer = new UserAuthenticationMailer($user);
        $mailer->sendSuccessMail($this->request->getIp());
      }

      // ...
    }

    if ($user->settings->isTrue('mailOnAuthFailure')) {
      $mailer ??= new UserAuthenticationMailer($user);
      $mailer->sendFailureMail($this->request->getIp());
    }

    // ...
  }
}

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

Но хотите увидеть еще одно преимущество этого подхода? Добавим еще одну проверку на валидность IP в tokenAction() и еще один класс с именем UserIpValidator.

class UserIpValidator extends UserHolder {
  public function validate(string $ip): bool {
    $ips = new IpList($this->user->settings->get('allowedIps'));
    return $ips->blank() || $ips->contains($ip);
  }
}


class TokenControler extends Controller {
  // @call POST /token
  public function tokenAction(): Payload {
    // ...

    $validator = new UserIpValidator($user);
    if (!$validator->validate($this->request->getIp())) {
      return $this->jsonPayload(Status::FORBIDDEN, [
        'error' => 'Non-allowed IP.'
      ]);
    }

    // ...
  }
}

Принцип открытия-закрытия (OCP)

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

Здесь у нас есть конечная точка API POST /payment для приема платежей, PaymentController класс с paymentAction()методом для обработки платежей пользователей по их подпискам и DiscountCalculatorдля применения скидок к общей сумме этих платежей.

Плохая практика:

class DiscountCalculator {
  // @see Spaghetti Pattern.
  public function calculate(User $user, float $amount): float {
    $discount = match ($user->subscription->type) {
      'basic'  => $amount >= 100.0 ? 10.0 : 0,
      'silver' => $amount >= 75.0  ? 15.0 : 0,
      default  => throw new Error('Invalid subscription type!')
    };
    return $discount ? $amount / 100 * $discount : $amount;
  }
}


class PaymentController extends Controller {
  // @call POST /payment
  public function paymentAction(): Payload {
    [$grossTotal, $creditCard]
      = $this->request->post(['grossTotal', 'creditCard']);

    // @var User (say it's okay, no 404)
    $user = $this->repository->getUserByToken($token_ResolvedInSomeWay);

    $calculator = new DiscountCalculator();
    $discount   = $calculator->calculate($user, $grossTotal);
    $netTotal   = $grossTotal - $discount;

    try {
      $payment = new Payment(amount: $netTotal, card: $creditCard);
      $payment->charge();

      if ($payment->okay()) {
        $this->repository->saveUserPayment($user, $payment);
      }

      return $this->jsonPayload(Status::OK, [
        'netTotal'      => $netTotal,
        'transactionId' => $payment->transactionId
      ]);
    } catch (PaymentError $e) {
      $this->logger->logError($e);

      return $this->jsonPayload(Status::INTERNAL, [
        'error'  => 'Payment error.'
        'detail' => $e->getMessage()
      ]);
    } catch (RepositoryError $e) {
        $this->logger->logError($e);

        $payment->cancel();

        return $this->jsonPayload(Status::INTERNAL, [
          'error'  => 'Repository error.',
          'detail' => $e->getMessage()
        ]);
      }
  }
}

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

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

Лучшая практика:

// Each item is in its own file.
abstract class Discount {
  public abstract function calculate(float $amount): float;

  // In respect of DRY principle.
  protected final function calculateBy(
    float $amount, float $threshold, float $discount
  ): float {
    if ($amount >= $threshold) {
      return $amount / 100 * $discount;
    }
    return 0.0;
  }
}

// These classes can have such constants
// like THRESHOLD, DISCOUNT instead, BTW.
class BasicDiscount extends Discount {
  public function calculate(float $amount): float {
    return $this->calculateBy(
      $amount, threshold: 100.0, discount: 10.0
    );
  }
}
class SilverDiscount extends Discount {
  public function calculate(float $amount): float {
    return $this->calculateBy(
      $amount, threshold: 75.0, discount: 15.0
    );
  }
}

class DiscountFactory {
  public static function create(User $user): Discount {
    // Create a Discount instance by $user->subscription->type.
  }
}


class DiscountCalculator {
  // @see Delegation Pattern.
  public function calculate(Discount $discount, float $amount): float {
    return $discount->calculate($amount);
  }
}


class PaymentController extends Controller {
  // @call POST /payment
  public function paymentAction(): Payload {
    // ...

    $calculator = new DiscountCalculator();
    $discount   = $calculator->calculate(
      DiscountFactory::create($user),
      $grossTotal
    );
    $netTotal   = $grossTotal - $discount;

    // ...
  }
}

Теперь DiscountCalculatorкласс использует настоящий калькулятор (фактически становится его делегатом) и соответствует принципу. Таким образом, если в будущем потребуется какое-либо изменение, нам calculate()больше не нужно будет менять метод. Мы можем просто добавить новый связанный класс (например, GoldDiscountдля типа «золотой» подписки) и обновить фабричный класс по этой необходимости.

Принцип подстановки Барбары Лисков (LSP)

Подклассы должны иметь возможность использовать все функции суперклассов, и все подклассы должны использоваться вместо своих суперклассов (по их тем же полям и поведению с родительскими полями и поведением). Таким образом, если подклассы не будут использовать все возможности унаследованных классов, тогда будут ненужные блоки кода, или если подклассы изменят то, как работают их методы суперклассов, то этот код будет более подвержен ошибкам.

Здесь у нас есть конечная точка API POST /file для работы с файлами, FileController класс с writeAction() методом для записи файла и File/ ReadOnlyFile классы для связанных работ.

Плохая практика:

class File {
  public function read(string $name): string {
    // Read file contents & return all read contents.
  }
  public function write(string $name, string $contents): int {
    // Write file contents & return written size in bytes.
  }
}

class ReadOnlyFile extends File {
  // @override Changes parent behavior.
  public function write(string $name, string $contents): int {
    throw new Error('Cannot write read-only file!');
  }
}

class FileFactory {
  public static function create(string $name): File {
    // Create a File instance controlling the name &
    // deciding the instance type in some logic way.
  }
}


class FileController extends Controller {
  // @call POST /file
  public function writeAction(): Payload {
    // Auth / token check here.

    [$name, $contents]
      = $this->request->post(['name', 'contents']);

    // @var File
    $file = FileFactory::create($name);

    // We are blindly relying on write() method here,
    // & not doing any check or try/catch for errors.
    $writtenBytes = $file->write($name, $contents);

    return $this->jsonPayload(Status::OK, [
      'writtenBytes' => $writtenBytes
    ]);
  }
}

Поскольку writeAction(), то есть клиентский код, зависит от File класса и его write() метода, это действие не может работать должным образом, поскольку из-за этой зависимости оно не проверяется на наличие ошибок.

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

Лучшая практика:

// Each item is in its own file.
interface IFile {
  public function isReadable(): bool;
  public function isWritable(): bool;
}
interface IReadableFile {
  public function read(string $name): string;
}
interface IWritableFile {
  public function write(string $name, string $contents): int;
}

// For the sake of DRY.
trait FileTrait {
  public function isReadable(): bool {
    return $this instanceof IReadableFile;
  }
  public function isWritable(): bool {
    return $this instanceof IWritableFile;
  }
}

class File implements IFile, IReadableFile, IWritableFile {
  use FileTrait;
  public function read(string $name): string {
    // Read file contents & return all read contents.
  }
  public function write(string $name, string $contents): int {
    // Write file contents & return written size in bytes.
  }
}

class ReadOnlyFile implements IFile, IReadableFile {
  use FileTrait;
  public function read(string $name): string {
    // Read file contents & return all read contents.
  }
}

class FileFactory {
  public static function create(string $name): IFile {
    // Create a File instance controlling the name &
    // deciding the instance type in some logic way.
  }
}


class FileController extends Controller {
  // @call POST /file
  public function writeAction(): Payload {
    // ...

    // @var IFile
    $file = FileFactory::create($name);

    // Now we have an option to check it,
    // whether file is writable or not.
    $writtenBytes = null;
    if ($file->isWritable()) {
      $writtenBytes = $file->write($name, $contents);
    }

    // ...
  }
}

Сигналы нарушения LSP ;

  • Если подкласс выдает ошибку из-за поведения суперкласса, которое он не может выполнить (например, метод write() File > ReadOnlyFile : ReadOnlyError). Внутренняя (переопределяющая) проблема.
  • Если у подкласса нет реализации поведения суперкласса, он не может выполняться (например, метод write() File > ReadOnlyFile: «Ничего не делать...»). Внутренняя (переопределяющая) проблема.
  • Если метод подкласса всегда возвращает одно и то же (фиксированное или постоянное) значение для переопределенного метода. Это очень тонкое нарушение, которое трудно обнаружить. Внутренняя (переопределяющая) проблема.
  • Если клиенты знают о подтипах, в основном используется ключевое слово «instanceof» (например, метод delete () FileDeleter: «если файл instanceof ReadOnlyFile, то возврат»). Внешняя (клиентская) проблема.

Принцип разделения интерфейсов (ISP)

Интерфейсы не следует заставлять брать на себя больше обязанностей, чем им нужно, а также классы не следует заставлять реализовывать интерфейсы с функциями, которые им не нужны. Этот принцип похож на принцип единой ответственности (SRP), и SRP относится к классам, а ISP — к интерфейсам. Таким образом, мы можем создавать разные интерфейсы для разных задач, чтобы следовать принципу.

Здесь у нас есть конечная точка API POST /notify для уведомления пользователей, NotifyController класс с notifyAction() методом для отправки уведомлений пользователям и Notifier класс для реализации этой работы INotifier, который подробно заполнен методами.

Плохая практика:

// Each item is in its own file.
interface INotifier {
  public function sendSmsNotification(
    string $phone, string $subject, string $message
  ): void;
  public function sendPushNotification(
    string $devid, string $subject, string $message
  ): void;
  public function sendEmailNotification(
    string $email, string $subject, string $message
  ): void;
}

class Notifier implements INotifier {
  public function sendSmsNotification(
    string $phone, string $subject, string $message
  ): void {
    // Send a notification to given phone.
  }
  public function sendPushNotification(
    string $devid, string $subject, string $message
  ): void {
    // Send a notification to given device by id.
  }
  public function sendEmailNotification(
    string $email, string $subject, string $message
  ): void {
    // Send a notification to given email.
  }
}


class NotifyController extends Controller {
  // @call POST /notify
  public function notifyAction(): Payload {
    [$subject, $message]
      = $this->request->post(['subject', 'message']);

    // @var User (say it's okay, no 404)
    $user = $this->repository->getUserByToken($token_ResolvedInSomeWay);

    $notifier = new Notifier();
    if ($user->settings->isTrue('notifyViaSms')) {
      $notifier->sendSmsNotification($user->phone, $subject, $message);
    }
    if ($user->settings->isTrue('notifyViaPush')) {
      $notifier->sendPushNotification($user->devid, $subject, $message);
    }
    if ($user->settings->isTrue('notifyViaEmail')) {
      $notifier->sendEmailNotification($user->email, $subject, $message);
    }

    return $this->jsonPayload(Status::OK);
  }
}

Поскольку мы впихиваем в INotifier интерфейс множество методов, мы далеки от этого девиза: «Множество клиентских (или нишевых ) интерфейсов лучше, чем один универсальный (или просто жирный ) интерфейс».

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

Лучшая практика:

// Each item is in its own file.
interface ISmsNotifier {
  public function send(
    string $phone, string $subject, string $message
  ): void;
}
interface IPushNotifier {
  public function send(
    string $devid, string $subject, string $message
  ): void;
}
interface IEmailNotifier {
  public function send(
    string $email, string $subject, string $message
  ): void;
}

class SmsNotifier implements ISmsNotifier {
  public function send(
    string $phone, string $subject, string $message
  ): void {
    // Send a notification to given phone.
  }
}
class PushNotifier implements IPushNotifier {
  public function send(
    string $devid, string $subject, string $message
  ): void {
    // Send a notification to given device by id.
  }
}
class EmailNotifier implements IEmailNotifier {
  public function send(
    string $email, string $subject, string $message
  ): void {
    // Send a notification to given email.
  }
}


class NotifyController extends Controller {
  // @call POST /notify
  public function notifyAction(): Payload {
    // ...

    if ($user->settings->isTrue('notifyViaSms')) {
      $notifier = new SmsNotifier();
      $notifier->send($user->phone, $subject, $message);
    }
    if ($user->settings->isTrue('notifyViaPush')) {
      $notifier = new PushNotifier();
      $notifier->send($user->devid, $subject, $message);
    }
    if ($user->settings->isTrue('notifyViaEmail')) {
      $notifier = new EmailNotifier();
      $notifier->send($user->email, $subject, $message);
    }

    // ...
  }
}

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

class NotifierFactory {
  public static function generate(User $user): iterable {
    if ($user->settings->isTrue('notifyViaSms')) {
      yield [$user->phone, new SmsNotifier()];
    }
    if ($user->settings->isTrue('notifyViaPush')) {
      yield [$user->devid, new PushNotifier()];
    }
    if ($user->settings->isTrue('notifyViaEmail')) {
      yield [$user->email, new EmailNotifier()];
    }
  }
}


class NotifyController extends Controller {
  // @call POST /notify
  public function notifyAction(): Payload {
    // ...

    // Iterate over available notifier instances & call send() for all.
    foreach (NotifierFactory::generate($user) as [$target, $notifier]) {
      $notifier->send($target, $subject, $message);
    }

    // ...
  }
}

Принцип инверсии зависимостей (DIP)

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

Здесь у нас есть конечная точка API POST /log для регистрации некоторых действий приложения, LogController класс с logAction() методом для регистрации этих действий и Logger класс как сервис для этих работ.

Плохая практика:

// Each item is in its own file.
class FileLogger {
  public function log(string $data): void {
    // Put given log data into file.
  }
}

class Logger {
  public function __construct(
    private readonly FileLogger $logger
  ) {}
}


class LogController extends Controller {
  // @call POST /log
  public function logAction(): Payload {
    // Auth / token check here.

    $logger = new Logger();
    $logger->log($this->request->post('log'));

    return $this->jsonPayload(Status::OK);
  }
}

Поскольку Loggerкласс использует конкретную реализацию регистратора, это совсем не гибкий код, и он вызовет проблемы, если со временем потребуется какая-либо замена или дополнительная служба журнала. Нам нужно будет изменить Loggerкласс всякий раз, когда мы хотим отправить журналы в базу данных или в другие места.

Итак, что может решить эту проблему, удаляя эту конкретную инъекцию класса (подробную реализацию) из Loggerконструктора и используя абстракцию (интерфейс) без изменения клиентского кода.

Лучшая практика:

// Each item is in its own file.
interface ILogger {
  public function log(string $data): void;
}

class FileLogger implements ILogger {
  public function log(string $data): void {
    // Put given log data into file.
  }
}

// For future, maybe.
class DatabaseLogger implements ILogger {
  public function log(string $data): void {
    // Put given log data into database.
  }
}

class Logger {
  public function __construct(
    private readonly ILogger $logger
  ) {}
}

Вывод

В этом посте, мы с вами узнали что такое SOLID, разобрали его основные принципы с примерами на PHP. Спасибо за прочтение.