Универсальные объекты ответов

Laravel Response objects

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

Я разрабатывал новый способ возврата различных форматов откликов, вводя выделенные объекты в мои приложения на Laravel. В значительной степени это было вдохновлено DHH и Adam Wathan на Full Stack Radio Podcast, и я решил поделится с вами своим этапами и идеями.

CRUD контроллер

В рамках моего приложения я обычно, подхожу к своим контроллерам только с точки зрения CRUD. В SandwichController также будут стандартные методы CRUD: index, create, store, show, edit, update и destroy. Каждый из этих методов возвращает ответ, подходящий для моего веб-интерфейса: шаблон или редирект.

Вот пример общей структуры и моего подхода:

class SandwichController
{
    public function index()
    {
        return view('sandwiches.index', ['sandwiches' => Sandwich::paginate()]);
    }

    public function create()
    {
        return view('sandwiches.create', ['sandwich' => new Sandwich]);
    }

    public function store(SandwichRequest $request)
    {
        $sandwich = Sandwich::create($request->validated());

        return redirect()->route('sandwiches.show', $sandwich)->with(['status' => 'Sandwich created successfully']);
    }

    public function show(Sandwich $sandwich)
    {
        return view('sandwiches.show', ['sandwich' => $sandwich]);
    }

    public function edit(Sandwich $sandwich)
    {
        return view('sandwiches.edit', ['sandwich' => $sandwich]);
    }

    public function update(SandwichRequest $request, Sandwich $sandwich)
    {
        $sandwich->update($request->validated());

        return redirect()->route('sandwiches.show', $sandwich)->with(['status' => 'Sandwich updated successfully']);
    }

    public function destroy(Sandwich $sandwich)
    {
        $sandwich->delete();

        return redirect()->route('sandwiches.index')->with(['status' => 'Sandwich deleted successfully']);
    }
}

Ничего необычного там нет. Здесь все методы CRUD и делают они то, что вы ожидаете. Но, как вы видите, это всё HTML-ответы. Что же происходит, когда вы получаете запрос на экспорт всех Sandwich в формате CSV? Раньше я хотел бы использовать контроллер одного действия для обработки этого ответа.

Контроллер одного действия

Было бы неплохо иметь еще один контроллер, управляющий экспортом CSV, так как мне не нравилось зависимость от CsvWriter в основном методе SandwichController@index, когда 99% запросов относятся к ответу веб-интерфейса. Это было неправильно.

Контроллер одного действия — это контроллер, который не придерживается методов CRUD. Вместо этого у него есть только один метод, отсюда и «одно действие». Так что я создам SandwichCsvExportController и он сделает всю работу по экспорту CSV. Я также создам маршрут для этого контроллера. Я всегда мучаюсь с выбором метода маршрута: GET или POST. Всякий раз, когда я выбираю, то чувствую себя не в своей тарелке. Каждый раз хочется выбрать POST и я делаю это… Не совсем уверен, почему в прошлом мне казалось, что экспорт CSV здесь не к месту — но я сделал это.

Заканчиваем со вторым контроллером и маршрутом для сценария, который получает CsvWriter через внедрение зависимости:

Route::post('sandwiches/export', SandwichCsvExportController::class);
class SandwichCsvExportController
{
    public function __invoke(CsvWriter $csvWriter)
    {
        $csvWriter->insertOne($attributes = ['id', 'brand', 'strength']);

        Sandwich::each(function ($sandwich) use ($csvWriter, $attributes) {
            $csvWriter->insertOne($sandwich->only($attributes));
        });

        return response($csvWriter->getContent(), 200, [
            'Content-Encoding' => 'none',
            'Content-Description' => 'File Transfer',
            'Content-Type' => 'text/csv; charset=UTF-8',
            'Content-Disposition' => 'attachment; filename="sandwich-export.csv"',
        ]);
      }
}

И какое-то время все было хорошо.

Но я начал думать, что было немного странно использовать __invoke(). Это выглядело как вызов index(). В конце концов, я просто выдал список Sandwiches… просто в другом формате … верно?!? Кроме того, этот метод POST и структура URL теперь кажутся мне очень странными.

Поэтому я поставил index() вместо __invoke() и перешел на метод GET.

Но мне все еще нужно было добавить еще один контроллер для новых форматов: XML, JSON и т.п. Я чувствовал, что здесь что-то не так и что это не очень хороший метод решению проблемы.

Вижу свет

Здесь меня как ударило: я тоже самое делаю и в SandwichController@index и SandwichCsvExportController@index. Беру модели из базы данных и выдаю их. Должно быть простое решение для объединения этих контроллеров, хоть у них и разные форматы ответов.

Все стало кристально ясно, когда я начал добавлять фильтры. Когда в оба моих метода index() были добавлены скоупы на основе параметров HTTP-запросов — я сразу увидел дублирующий код и понял, что должен быть лучший способ, ведь оба контроллера всегда запускаются одинаково.

public function index(Request $request)
{
    $sandwiches = Sandwich::when($request->distributor, function ($query, $value) {
        $query->whereDistributor($value);
    })-> // код, специфичный для формата, последует...
}

Объекты отклика и расширения

Я слушал, как DHH и Адам говорили о работе с различными форматами ответов, в зависимости от заголовка Accepts, что отлично подходит для API проектов. И видел, как Адам написал в Твиттере, что он сделал для этого макрос. В дополнение к этому я познакомился с интерфейсом Responsable в Laravel.

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

Я обнаружил, что начинаю создавать больше API First архитектур, в то же время думая: если мне нужно будет добавить здесь веб-интерфейс, то придется снова продублировать эти контроллеры. Должен.быть.способ.лучше!

Наконец, у меня появилось время вернуться к идее формата мультиответа и посмотреть, как можно все это исправить, используя новый подход. После того поста в Твиттере был еще один эпизод Full Stack Radio, где Адам и DHH обсуждали идею реагирования на расширения файлов и почему это допустимо — и тут меня озарило! Итак, давайте рассмотрим несколько вещей, которые приведут меня к финалу с объектами ответа.

Расширения файлов

Как веб-разработчик, вы, скорей всего чувствуете неопрятность, добавляя расширение файла к URL. Появляются флэшбеки обо всех этих однофайловых php-приложениях, типа members.php… я прав? Мы перешли от расширений в адресах к приложениям с красивыми URL-адресами, управляемыми извне, и нам стало гораздо легче работать с ними. Но я чувствую, что мы совсем забыли, что расширения файлов тоже имеют смысл.

Я не против изображений, типа /profile.png или аудио-файлов, как /episode.mp3. Ведь расширение показывает тип файла — так почему же это подходит для одних файлов и не подходит для других?

Формат файла задаёт ожидания. Однако расширение .php — это деталь реализации. В нём не указано, какой тип контента возвращается.

С точки зрения разработчиков, расширение .php говорит, что это PHP код. Это две совершенно разные вещи. Что в одном хорошо, то в другом плохо.

Как расширение .json или .csv отличаются от .mp3? Ответ: никак

Почему мы не можем использовать расширения файлов, чтобы указать желаемый формат ответа? Ответ: можем!

Любой, кто работет с API достаточно долго, вероятно сейчас подумал: можно, конечно использовать расширения файлов, но для этого есть заголовок Accept, и он будет верен. Заголовок Accept позволяет конечному пользователю указать формат, который он хотел бы вернуть. Но через формы сайта вы не можете отправить заголовки, и именно поэтому расширений имеют для меня такой смысл. Я хочу предоставить простой способ загрузки CSV с сайта, но таким образом, чтобы это было удобно и для API.

Объекты ответа

Объекты Response — это классы, которые реализуют интерфейс Responsable и могут быть возвращены из контроллера. Контейнер Laravel вызовет метод toResponse($request). Это позволяет перенести все сложности с ответом, из контроллера в выделенный объект. Они действительно классные.

Вы можете реализовать этот интерфейс на любом объекте.

class CSV implements Responsable
{
    protected $file;

    protected $filename;

    public function __construct($file, $filename)
    {
        $this->file = $file;

        $this->filename = $filename;
    }

    public function toResponse($request)
    {
        return response()->download($this->file, "{$this->filename}.csv", [
            'Content-Type' => 'text/csv',
        ]);
    }
}

Если бы вы возвращали экземпляр CSV из контроллера, контейнер вызвал бы метод toResponse и вернул бы результат в браузер. Как видите, метод также принимает экземпляр $request, что дает вам возможность проверить наличие нужных входных данных или других значений в запросе, которые необходимо учитывать при создании ответа.

Один контроллер, чтобы править всеми

Я поэкспериментировал с объектами ответов и создал базовый класс Responsable, который определет ожидаемый формат ответа (HTML, JSON, CSV и т.д.) на основе расширения файла в адресе и возвращет его в заголовке Accepts.

Если ожидаемый формат — HTML, вызовется метод toHtmlResponse(), если JSON — метод toJsonResponse() и т.д. Это позволило мне разбить логику, необходимую для создания ответов, специфичных для формата, на свои собственные методы. Немного магии: эти методы вызываются контейнером, а это означает, что вы можете вводить специфичные для формата зависимости.

Это было как раз то решение, какое я искал для объединения своих контроллеров. Я могу передать все ответы, необходимые для отображения списка Sandwich, с помощью метода SandwichController@index. Я могу расшарить фильтры по всем форматам ответов и отложить создание фактического ответа в выделенный объект. Комбинирование контроллеров HTML и CSV привело к действительно оптимизированному контроллеру:

class SandwichController
{
    public function index()
    {
        $query = Sandwich::when($request->distributor, function ($query, $value) {
            $query->whereDistributor($value);
        });

        return new SandwichIndexResponse($query);
    }
}

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

Вот объект ответа, который расширяет мой базовый класс Responsable и возвращает HTML, CSV и JSON для моих Sandwich:

class SandwichIndexResponse extends Response
{
    protected $query;

    public function __construct($query)
    {
        $this->query = $query;
    }

    protected function toHtmlResponse()
    {
        return view('sandwiches.index', ['sandwiches' => $this->query->paginate()]);
    }

    protected function toCsvResponse(CsvWriter $csvWriter)
    {
        $csvWriter->insertOne($attributes = ['id', 'brand', 'strength']);

        $this->query->each(function ($sandwich) use ($csvWriter, $attributes) {
            $csvWriter->insertOne($sandwich->only($attributes));
        });

        return response($csvWriter->getContent(), 200, [
            'Content-Encoding' => 'none',
            'Content-Description' => 'File Transfer',
            'Content-Type' => 'text/csv; charset=UTF-8',
            'Content-Disposition' => 'attachment; filename="sandwich-export.csv"',
        ]);
    }
}

Маршрутизация

Лучше всего создать список разрешенных расширений, а не пропускать всё подряд. Пусть лучше маршрутизатор выдаст 404, чем передаст его объекту Response, который затем уже выдаст 404. Для достижения этого я использовал следующую маршрутизацию:

Route::get('sandwiches{extension?}', [
    'as' => 'sandwiches.index',
    'uses' => 'SandwichesController@index',
])->where(['extension' => '^(.pdf)|(.csv)|(.json)$']);

Теперь наши адреса с расширениями файлов имеют значение для системы:

// html endpoint
GET: /sandwiches

// csv endpoint
GET: /sandwiches.csv

// PDF endpoint
GET: /sandwiches.pdf

// json endpoint
GET: /sandwiches.json

и мы можем скачивать динамически формируемые файлы с помощью GET запросов по действительно приятным адресам с нашего сайта.

<h2>Downloads</h2>
<ul>
    <li><a href="/sandwiches.csv">CSV</a></li>
    <li><a href="/sandwiches.pdf">PDF</a></li>
</ul>

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

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

Автор: Tim MacDonald
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.