Как разработчики 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']
]);
}
}
Это самый простой способ построить интеграцию. Однако давайте рассмотрим, что будет дальше:
- Дублирование логики: Если вам нужно получить данные о погоде в другом месте приложения, вы, скорее всего, скопируете этот код, создав несколько точек отказа при внесении изменений.
- Жесткая связь: Теперь ваш контроллер тесно связан с API сервиса погоды. Если API изменится, вам придется редактировать логику внутри контроллера.
- Сложности тестирования: Как вы начнете писать тесты? Вам нужно будет мокировать HTTP-клиент, мокировать ответ API и убедиться, что логика работает в разных условиях.
- Отсутствие абстракции: Структура ответа не ясна, что затрудняет работу с данными.
- Разрозженная обработка ошибок: Что произойдет, если 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
Преимущества архитектуры модулей сервисов
- Повышенная читабельность кода: Логика API-интеграции изолирована в своем собственном модуле, что делает код понятным и легким для сопровождения.
- Повторное использование: Вызовы API можно использовать в любом месте приложения без дублирования кода.
- Тестируемость: Благодаря использованию интерфейсов, фасадов и DTO, тестировать код стало намного проще.
- Масштабируемость: При необходимости можно легко добавить новые функции (например, кэширование, логирование запросов и т.д.), не нарушая текущую архитектуру.
- Централизованная обработка ошибок: Исключения теперь обрабатываются централизованно, что делает код более устойчивым к ошибкам.
Следующий шаг: Использование пакетов для упрощения
Хотя мы построили архитектуру вручную, существуют пакеты, которые могут облегчить этот процесс. Например:
-
laravel-repository-pattern
: Упрощает создание репозиториев. -
laravel-dto
: Помогает с созданием и управлением DTO. -
laravel-facades
: Автоматизирует создание фасадов.
Заключение
Мы рассмотрели, как можно улучшить API-интеграции в Laravel, используя архитектуру модулей сервисов. Этот подход помогает организовать код, сделать его более масштабируемым, чистым и легким для тестирования.