atom1c.ru

Упрощение внешних API-интеграций в Laravel с помощью сервисных модулей

Как разработчики Laravel, мы часто сталкиваемся с необходимостью интеграции с другими сервисами, будь то публичные или приватные. В основном, для общения мы используем API. Обычно все начинается просто: получение данных или отправка информации в сервис. Но по мере роста проекта все становится хаотичным — сложным в поддержке и особенно сложным в тестировании.

Это происходит чаще всего, особенно если вы не начали с чистой архитектуры.

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


Архитектура модулей сервисов

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

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


Проблема: неструктурированные API-интеграции

Давайте интегрируемся с простым сервисом погоды через API. Наиболее типичный и стандартный способ — вызвать его в контроллере, когда это необходимо, например, так:

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Http;

class WeatherController extends Controller
{
    public function getWeather($city)
    {
        // Прямой вызов API внутри контроллера
        $response = Http::get('https://api.weatherapi.com/v1/current.json', [
            'key' => config('services.weatherapi.key'),
            'q' => $city,
        ]);

        if ($response->failed()) {
            return response()->json(['error' => 'Unable to fetch weather data'], 500);
        }

        $weather = $response->json();
        return response()->json([
            'city' => $weather['location']['name'],
            'temperature' => $weather['current']['temp_c'],
            'condition' => $weather['current']['condition']['text']
        ]);
    }
}

Это самый простой способ построить интеграцию. Однако давайте рассмотрим, что будет дальше:

  1. Дублирование логики: Если вам нужно получить данные о погоде в другом месте приложения, вы, скорее всего, скопируете этот код, создав несколько точек отказа при внесении изменений.
  2. Жесткая связь: Теперь ваш контроллер тесно связан с API сервиса погоды. Если API изменится, вам придется редактировать логику внутри контроллера.
  3. Сложности тестирования: Как вы начнете писать тесты? Вам нужно будет мокировать HTTP-клиент, мокировать ответ API и убедиться, что логика работает в разных условиях.
  4. Отсутствие абстракции: Структура ответа не ясна, что затрудняет работу с данными.
  5. Разрозженная обработка ошибок: Что произойдет, если API выйдет из строя? У вас есть базовая обработка ошибок, но по мере роста интеграций управление всеми потенциальными ошибками в каждом контроллере становится громоздким и несогласованным.

Как видите, это основные проблемы такого подхода к построению интеграции.


Решение

Шаг 1: Создание структуры папок

Сначала я создаю новую папку в директории app/ под названием Services. Вы можете назвать ее как угодно. Внутри этой папки я создаю папку для сервиса, например, Weather.

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

Структура сервиса


Шаг 2: Создание репозиториев

Теперь, когда мы настроили структуру сервиса, давайте воспользуемся шаблоном проектирования Repository.

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

В папке Repositories создадим файл WeatherRepository.php. Этот класс будет обрабатывать все взаимодействия с API погоды, чтобы нам не приходилось вызывать API напрямую из контроллера каждый раз.

// WeatherRepository.php
namespace App\Services\Weather\Repositories;

use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;

class WeatherRepository
{
    private PendingRequest $http;

    public function __construct()
    {
        // Глобально задаем базовый URL и формат JSON
        $this->http = Http::baseUrl('https://api.weatherapi.com')
            ->acceptJson();
    }

    public function getByCity(string $city): array
    {
        $response = $this->http->get('v1/current.json', [
            'key' => config('services.weather.key'),
            'q' => $city,
        ]);

        if ($response->failed()) {
            return ['error' => 'Unable to fetch weather data'];
        }

        return $response->json();
    }
}

Перенеся вызовы API в репозиторий, мы теперь имеем сервис, слабо связанный с остальной системой. Вместо прямого вызова API из контроллера мы можем использовать внедрение зависимости (Dependency Injection), чтобы получить доступ к репозиторию и обработать логику следующим образом:

// WeatherController.php
namespace App\Http\Controllers;

use App\Services\Weather\Repositories\WeatherRepository;

class WeatherController extends Controller
{
    public function getWeather($city, WeatherRepository $weatherRepository)
    {
        $weather = $weatherRepository->getByCity($city);

        if (isset($weather['error'])) {
            return response()->json($weather, 500);
        }

        return response()->json([
            'city' => $weather['location']['name'],
            'temperature' => $weather['current']['temp_c'],
            'condition' => $weather['current']['condition']['text']
        ]);
    }
}

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


Шаг 3: Использование фасада Laravel

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

Создадим фасад для нашего сервиса погоды:

// Weather.php
namespace App\Services\Weather\Facades;

use Illuminate\Support\Facades\Facade;
use App\Services\Weather\Repositories\WeatherRepository;

/**
 * @see \App\Services\Weather\Repositories\WeatherRepository
 */
class Weather extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return WeatherRepository::class;
    }
}

Теперь мы можем вызывать сервис через фасад:

// WeatherController.php
namespace App\Http\Controllers;

use App\Services\Weather\Facades\Weather;

class WeatherController extends Controller
{
    public function getWeather($city)
    {
        $weather = Weather::getByCity($city);

        if (isset($weather['error'])) {
            return response()->json($weather, 500);
        }

        return response()->json([
            'city' => $weather['location']['name'],
            'temperature' => $weather['current']['temp_c'],
            'condition' => $weather['current']['condition']['text']
        ]);
    }
}

Теперь код стал еще чище! Кроме того, тестировать его стало проще:

Weather::shouldReceive('getByCity')->andReturn([]);

Шаг 4: Создание класса исключений

Для централизованной обработки ошибок создадим класс исключений WeatherException.

// WeatherException.php
namespace App\Services\Weather\Exceptions;

use Exception;

class WeatherException extends Exception
{
    //
}

Теперь обновим репозиторий, чтобы выбрасывать исключение:

// WeatherRepository.php
throw_if($response->failed(), WeatherException::class, 'Unable to fetch weather data');

Шаг 5: Использование паттерна Factory

Для гибкости в разных окружениях (например, кэширование в продакшене) используем Factory. Создадим интерфейс WeatherInterface и реализуем его в WeatherRepository.

// WeatherInterface.php
namespace App\Services\Weather\Repositories;

interface WeatherInterface
{
    public function getByCity(string $city): array;
}
// WeatherRepository.php
class WeatherRepository implements WeatherInterface { ... }

Далее свяжем интерфейс с реализацией через провайдер:

// WeatherProvider.php
namespace App\Services\Weather\Providers;

use Illuminate\Support\ServiceProvider;
use App\Services\Weather\Repositories\WeatherInterface;
use App\Services\Weather\Repositories\WeatherRepository;

class WeatherProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(WeatherInterface::class, WeatherRepository::class);
    }
}

Теперь приложение выбирает реализацию в зависимости от окружения.

Этот подход обеспечивает чистоту, масштабируемость и тестируемость ваших интеграций.

Шаг 6: Использование DTO (Data Transfer Object)

Чтобы обеспечить единообразие и четкость данных, возвращаемых сервисом, мы можем использовать шаблон Data Transfer Object (DTO). DTO помогает структурировать данные и сделать их более предсказуемыми, что особенно полезно в больших проектах.

Создадим DTO для сервиса погоды. В папке DTOs создадим файл WeatherDTO.php:

// WeatherDTO.php
namespace App\Services\Weather\DTOs;

class WeatherDTO
{
    public string $city;
    public float $temperature;
    public string $condition;

    public function __construct(string $city, float $temperature, string $condition)
    {
        $this->city = $city;
        $this->temperature = $temperature;
        $this->condition = $condition;
    }

    public static function fromArray(array $data): self
    {
        return new self(
            $data['location']['name'],
            $data['current']['temp_c'],
            $data['current']['condition']['text']
        );
    }
}

Теперь мы можем обновить WeatherRepository, чтобы возвращать объект WeatherDTO вместо необработанного массива:

// WeatherRepository.php
namespace App\Services\Weather\Repositories;

use App\Services\Weather\DTOs\WeatherDTO;
use App\Services\Weather\Exceptions\WeatherException;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;

class WeatherRepository implements WeatherInterface
{
    private PendingRequest $http;

    public function __construct()
    {
        $this->http = Http::baseUrl('https://api.weatherapi.com')
            ->acceptJson();
    }

    public function getByCity(string $city): WeatherDTO
    {
        $response = $this->http->get('v1/current.json', [
            'key' => config('services.weather.key'),
            'q' => $city,
        ]);

        throw_if($response->failed(), WeatherException::class, 'Unable to fetch weather data');

        return WeatherDTO::fromArray($response->json());
    }
}

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

Обновим контроллер для использования DTO:

// WeatherController.php
namespace App\Http\Controllers;

use App\Services\Weather\Facades\Weather;

class WeatherController extends Controller
{
    public function getWeather($city)
    {
        try {
            $weather = Weather::getByCity($city);

            return response()->json([
                'city' => $weather->city,
                'temperature' => $weather->temperature,
                'condition' => $weather->condition,
            ]);
        } catch (\Exception $e) {
            return response()->json(['error' => $e->getMessage()], 500);
        }
    }
}

Итоговая структура сервиса

После всех шагов структура сервиса будет выглядеть следующим образом:

app/
├── Services/
│   ├── Weather/
│   │   ├── DTOs/
│   │   │   └── WeatherDTO.php
│   │   ├── Exceptions/
│   │   │   └── WeatherException.php
│   │   ├── Facades/
│   │   │   └── Weather.php
│   │   ├── Providers/
│   │   │   └── WeatherProvider.php
│   │   ├── Repositories/
│   │   │   ├── WeatherInterface.php
│   │   │   └── WeatherRepository.php

Преимущества архитектуры модулей сервисов

  1. Повышенная читабельность кода: Логика API-интеграции изолирована в своем собственном модуле, что делает код понятным и легким для сопровождения.
  2. Повторное использование: Вызовы API можно использовать в любом месте приложения без дублирования кода.
  3. Тестируемость: Благодаря использованию интерфейсов, фасадов и DTO, тестировать код стало намного проще.
  4. Масштабируемость: При необходимости можно легко добавить новые функции (например, кэширование, логирование запросов и т.д.), не нарушая текущую архитектуру.
  5. Централизованная обработка ошибок: Исключения теперь обрабатываются централизованно, что делает код более устойчивым к ошибкам.

Следующий шаг: Использование пакетов для упрощения

Хотя мы построили архитектуру вручную, существуют пакеты, которые могут облегчить этот процесс. Например:

  1. laravel-repository-pattern: Упрощает создание репозиториев.
  2. laravel-dto: Помогает с созданием и управлением DTO.
  3. laravel-facades: Автоматизирует создание фасадов.

Заключение

Мы рассмотрели, как можно улучшить API-интеграции в Laravel, используя архитектуру модулей сервисов. Этот подход помогает организовать код, сделать его более масштабируемым, чистым и легким для тестирования.