Глава 44 · От основ к архитектуре
Интерфейсы: общий договор для оплаты, хранения и тестов
Учим сервис работать с возможностью, а не с названием конкретного провайдера. Ошибки, версии контракта, фейки и ограничения интерфейсов.
Страница заказа просит действие, а не конкретного исполнителя
Сегодня заказ выдаётся за наличные, завтра нужен счёт. Страница оформления должна получить понятную инструкцию оплаты одним вызовом. Интерфейс описывает обязательную операцию, чтобы разные исполнители подходили к одному месту программы.
Что получится. Один checkout вернёт Счёт на 25900 коп. или Получить при выдаче: 25900 коп.
- checkout получает Payment и сумму 25900
- Тип Payment обещает request(int): string
- Конкретный объект формирует свою инструкцию
- Страница получает строку одинаковым способом
Объект и метод; параметры и возвращаемый тип.
<?php
declare(strict_types=1);
interface Payment
{
public function request(int $kopecks): string;
}
class InvoicePayment implements Payment
{
public function request(int $kopecks): string
{
return 'Счёт на ' . $kopecks . ' коп.';
}
}
$payment = new InvoicePayment();
echo $payment->request(25900), "\n";
Ожидаемый результат
Счёт на 25900 коп.
interface объявляет договор; implements связывает класс с ним. В интерфейсе после сигнатуры стоит ;, тела метода нет. Класс предоставляет тело. Этот пример формирует инструкцию и не списывает деньги.
Что PHP проверяет и чего договору ещё не хватает
Сценарий знает договор, адаптер знает провайдера
Оформлению заказа нужен результат оплаты. Детали HTTP и конкретного банка не должны попадать в каждый вызов checkout.
- checkout передаёт ключ и сумму
order-8-attempt-1 и 15000 копеек уходят через зависимость типа PaymentGateway.
- PaymentGateway: charge → ChargeResult
Договор задаёт общий вызов. Конкретный объект выбирается при сборке сценария.
- Место для банковского адаптера
Его реализация должна перевести договор в запрос банка и проверить ответ.
- FakePaymentGateway в примере
Локально возвращает accepted=true или false; реального платежа не выполняет.
- Сценарий читает единый результат
operationId и accepted доступны независимо от внутреннего устройства выбранного адаптера.
Банковский адаптер здесь обозначает место расширения, а исполняемый пример использует два режима fake. Интерфейс задаёт типовой договор; безопасность повторов и смысл ошибок требуют реализации и тестов.
| Запись в примере | Что делает каждый элемент |
|---|---|
interface Payment | Имя способности, которую потребитель требует от объекта. |
request(int $kopecks): string; | Обязательная публичная операция: целые копейки на вход, строка на выход. |
implements Payment | Класс обязан предоставить совместимые методы, иначе его объявление недопустимо. |
Payment $payment | Можно передать любой подходящий объект, не только заранее выбранный InvoicePayment. |
PHP проверяет наличие и типы, но не смысл: договору нужны единицы, допустимые значения и поведение при отказе. Наш request готовит инструкцию оплаты и не подтверждает поступление денег. Интерфейс не создают через new, и он не хранит обычное состояние экземпляра. Кроме методов он может задавать константы, а с PHP 8.4 — требования к свойствам; они разобраны ниже.
Изменилось требование: тот же checkout принимает другой способ
<?php
declare(strict_types=1);
interface Payment
{
public function request(int $kopecks): string;
}
class InvoicePayment implements Payment
{
public function request(int $kopecks): string
{
return 'Счёт на ' . $kopecks . ' коп.';
}
}
class CashOnDelivery implements Payment
{
public function request(int $kopecks): string
{
return 'Получить при выдаче: ' . $kopecks . ' коп.';
}
}
function checkout(Payment $payment, int $kopecks): string
{
if ($kopecks < 1) {
throw new InvalidArgumentException('Сумма должна быть положительной');
}
return $payment->request($kopecks);
}
echo checkout(new InvoicePayment(), 25900), "\n";
echo checkout(new CashOnDelivery(), 25900), "\n";
Ожидаемый результат
Счёт на 25900 коп.
Получить при выдаче: 25900 коп.
checkout знает только Payment. При смене исполнителя меняется аргумент в месте сборки, а проверка суммы и вызов request остаются прежними. Никакой общий родитель со случайными полями не требуется.
Не требовать возврат денег от способа, который их ещё не получил
Счёт и наличные при выдаче пока лишь инструкции. Если добавить refund в Payment, часть классов вынуждена будет отвечать «не поддерживается». Лучше отделить способность возврата в RefundsPayments и передавать её только сценарию возврата. Такое разделение небольших договоров называют разделением интерфейсов.
| Потребитель | Необходимый договор | Что ему не нужно |
|---|---|---|
| Оформление заказа | Подготовить инструкцию оплаты | Возврат денег, банковская выписка |
| Сверка поступлений | Найти подтверждённые платежи | Печать ценника |
| Возврат оплаты | Вернуть конкретную подтверждённую операцию | Настройки доставки |
Класс может implements несколько интерфейсов через запятую. Интерфейс может extends несколько интерфейсов. При совпадении методов требования должны быть совместимы; пустой метод ради устранения ошибки загрузки договор не выполняет по смыслу.
Встроенный потребитель: JSON просит представление объекта
Интерфейс бывает нужен даже без собственного checkout. json_encode знает встроенный JsonSerializable и вызывает jsonSerialize, чтобы получить данные для кодирования. На странице отдаём название и цену, а внутреннюю пометку поставщика исключаем.
<?php
declare(strict_types=1);
class ProductCard implements JsonSerializable
{
public function __construct(
private string $title,
private int $priceKopecks,
private string $supplierNote
) {}
public function jsonSerialize(): array
{
return ['title' => $this->title, 'price_kopecks' => $this->priceKopecks];
}
}
$card = new ProductCard('Чай', 25900, 'Проверить упаковку');
echo json_encode($card, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), "\n";
Ожидаемый результат
{"title":"Чай","price_kopecks":25900}
Метод возвращает массив, не готовую JSON-строку: кодирует json_encode. JSON_UNESCAPED_UNICODE оставляет читаемую кириллицу, JSON_THROW_ON_ERROR превращает ошибку кодирования в исключение. Такой контроль представления не означает, что весь объект безопасно выдавать автоматически.
Замена транспорта CRM и границы абстракции
Для Bitrix24 интерфейс Transport может обещать request(method, payload): array. HTTP-адаптер отправляет сеть, локальный FixtureTransport возвращает известную запись. B24Client использует один договор и не знает, каким способом получены байты. Важно сохранить одинаковые правила ответа и отказа: fake, который всегда возвращает успех, не проверит обработку тайм-аута.
Попробуйте. Добавьте BankTransfer, возвращающий Перевод: 25900 коп., и используйте существующий checkout.
Подменить отказ и увидеть результат для пользователя
<?php
declare(strict_types=1);
interface Payment
{
public function request(int $kopecks): string;
}
class InvoicePayment implements Payment
{
public function request(int $kopecks): string
{
return 'Счёт на ' . $kopecks . ' коп.';
}
}
class CashOnDelivery implements Payment
{
public function request(int $kopecks): string
{
return 'Получить при выдаче: ' . $kopecks . ' коп.';
}
}
function checkout(Payment $payment, int $kopecks): string
{
if ($kopecks < 1) {
throw new InvalidArgumentException('Сумма должна быть положительной');
}
return $payment->request($kopecks);
}
class UnavailablePayment implements Payment
{
public function request(int $kopecks): string
{
throw new RuntimeException('Оплата временно недоступна');
}
}
try {
echo checkout(new UnavailablePayment(), 25900), "\n";
} catch (RuntimeException $error) {
echo 'Заказ не подтверждён. ', $error->getMessage(), "\n";
}
Ожидаемый результат
Заказ не подтверждён. Оплата временно недоступна
Этот исполнитель намеренно моделирует отказ. Сценарий реально вызывает тот же checkout и получает исключение; это проверяет путь отказа без обращения к банку. Исключения в PHP не перечисляются в сигнатуре, поэтому договор ошибок надо описывать рядом с интерфейсом.
Дальше: полный договор языка и специальные случаи
После замены исполнителя можно уточнить интерфейсные свойства PHP 8.4, наследование нескольких договоров и конфликты констант. Сохраняйте имена параметров для именованных вызовов; методы интерфейса публичны.
Разделы ниже сохраняют специальные правила официального PHP Manual: Интерфейс обещает доступное поле, а не способ хранения — PHP 8.4+, Интерфейс может собрать несколько договоров и объявить константы, Два интерфейса принесли одно имя константы: PHP не выбирает за нас.
Интерфейс обещает доступное поле, а не способ хранения — PHP 8.4+
Карточки из CRM и локального каталога должны показывать подпись в одном списке. Ранее мы обещали метод title(); PHP 8.4 также позволяет обещать чтение свойства. Сначала разберите implements и договор методов и виртуальный get.
- Шаблону обещан HasCaption с get caption
- LocalCaption хранит Магазин
- CrmCaption вычисляет CRM: Заявка 42
- Один printCaption печатает оба представления
<?php
declare(strict_types=1);
interface HasCaption
{
public string $caption { get; }
}
class LocalCaption implements HasCaption
{
public function __construct(public string $caption) {}
}
class CrmCaption implements HasCaption
{
public string $caption {
get => 'CRM: ' . $this->title;
}
public function __construct(private string $title) {}
}
function printCaption(HasCaption $item): void
{
echo $item->caption, "\n";
}
printCaption(new LocalCaption('Магазин'));
printCaption(new CrmCaption('Заявка 42'));
Ожидаемый результат
Магазин
CRM: Заявка 42
Get; в интерфейсе означает требование публичного чтения и не содержит реализации. LocalCaption выполняет его обычным публичным свойством, CrmCaption — хуком. Интерфейс не утверждает, что поле нельзя записывать: конкретный LocalCaption дополнительно разрешает запись.
Договор может требовать set или одновременно get и set. Нельзя выполнить публичное требование записи private(set)-свойством: вызывающий код потеряет обещанную возможность. Выбирайте метод, если действие имеет параметры, заметную стоимость или побочные эффекты; интерфейс свойств не заменяет все методы.
В библиотеке с поддержкой PHP 8.2 оставьте caption(): string. Более короткая запись не оправдывает незаметное повышение минимальной версии приложения.
Интерфейс может собрать несколько договоров и объявить константы
Печатной карточке нужны номер и итог. Другой потребитель использует только номер. Два маленьких интерфейса можно сохранить отдельными, а для карточки собрать третий через extends. Это наследование требований, а не копирование реализации.
- HasOrderNumber требует number()
- HasOrderTotal требует total()
- OrderDocument extends оба требования
- PrintableOrder предоставляет обе реализации
<?php
declare(strict_types=1);
interface HasOrderNumber
{
public function number(): string;
}
interface HasOrderTotal
{
public function total(): int;
}
interface OrderDocument extends HasOrderNumber, HasOrderTotal
{
public const FORMAT = 'text';
final public const SCHEMA = 'order-v1';
}
class PrintableOrder implements OrderDocument
{
public const FORMAT = 'csv';
public function number(): string { return 'ORD-42'; }
public function total(): int { return 240; }
}
function documentLine(OrderDocument $document): string
{
return $document->number() . ';' . $document->total();
}
echo documentLine(new PrintableOrder()), "\n";
echo OrderDocument::FORMAT, ', ', PrintableOrder::FORMAT, "\n";
echo PrintableOrder::SCHEMA, "\n";
Ожидаемый результат
ORD-42;240
text, csv
order-v1
Extends у интерфейса перечисляет родительские договоры через запятую. Implements у класса означает выполнение требований. С PHP 8.1 обычную интерфейсную константу можно переопределить; final закрепляет SCHEMA. Константы не хранят уникальное состояние заказа.
Класс может написать implements HasOrderNumber, HasOrderTotal без составного интерфейса. Выделяйте OrderDocument только если у этого набора есть самостоятельный смысл для потребителя. Если два интерфейса объявили одинаковый метод, одна реализация должна быть совместима с обоими договорами, а не произвольно выбрать один.
Методы интерфейса публичны. Имена параметров сохраняйте согласованными: именованный вызов зависит от них, даже если проверка сигнатуры допускает переименование. Конструктор можно объявить в интерфейсе, но PHP.net не рекомендует без необходимости ограничивать этим способ сборки реализаций; чаще потребителю важна работа уже готового объекта.
Два интерфейса принесли одно имя константы: PHP не выбирает за нас
Имплементировать несколько возможностей можно. Но если два независимых договора определили FORMAT с разными значениями, нельзя молча считать, что победил первый в списке. Такое объявление неоднозначно и отклоняется при составлении класса.
- CsvRule обещает FORMAT = csv
- JsonRule обещает FORMAT = json
- Класс пытается унаследовать оба одноимённых значения
- PHP отвергает неоднозначное объявление до создания отчёта
<?php
interface CsvRule { public const FORMAT = 'csv'; }
interface JsonRule { public const FORMAT = 'json'; }
class AmbiguousReport implements CsvRule, JsonRule {}
Этот файл специально неверен. Он не предназначен для включения в рабочую программу. Следующий локальный проверочный запуск получает диагностический отказ отдельного PHP-процесса.
<?php
declare(strict_types=1);
$checks = [
['BadInterface.php', 'inherits both', 'Конфликт констант подтверждён']
];
foreach ($checks as [$file, $diagnostic, $label]) {
$process = proc_open(
[PHP_BINARY, '-n', '-d', 'display_errors=stderr', __DIR__ . '/' . $file],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes
);
if (!is_resource($process)) {
throw new RuntimeException('Не удалось запустить учебную проверку');
}
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
if ($exitCode === 0 || !str_contains($output . $errors, $diagnostic)) {
throw new RuntimeException('Ожидаемая ошибка объявления не обнаружена');
}
echo $label, "\n";
}
Ожидаемый результат
Конфликт констант подтверждён
Нужен доступный proc_open и файл BadInterface.php рядом с main.php. Запускается только текущий PHP_BINARY и известный учебный файл: shell и пользовательские команды не участвуют. Родитель проверяет ненулевой код завершения и характерную диагностику; обычный try/catch внутри несовместимого класса такую ошибку объявления не исправляет.
Исправлять нужно сам договор. Если это действительно разные характеристики, дайте разные имена константам. Если это выбор формата реализации, один общий интерфейс с методом format() позволит каждому объекту вернуть свой вариант без наследования двух противоречивых констант.
В PHP 8.1+ переопределение обычной унаследованной константы допускается, но не следует случайным значением прятать смысловой конфликт двух интерфейсов. Final-константа запрещает переопределение вовсе. Проверяйте семантику, а не только возможность собрать класс.
Источники и обсуждения
- Интерфейсы
- Countable
- JsonSerializable
- Интерфейс или абстрактный класс — обсуждение проблемы
- Реализации свойств
- Наследование констант
- Зачем нужны интерфейс и абстрактный класс на реальной задаче — обсуждение проблемы