Простой API gateway на базе PHP и Lumen
Традиционно перед коллекцией микросервисов предлагается дополнительный слой — так называемый API gateway, который решает сразу несколько проблем (они будут перечислены позже). На момент написания этой статьи open source реализаций таких gateway почти нет, поэтому я решил написать свой на PHP с использованием микрофреймворка Lumen (часть Laravel).
В этой статье я покажу насколько это простая задача для современного PHP!
Что такое API gateway?
Если говорить совсем коротко, то API gateway — это умный proxy-сервер между пользователями и любым количеством сервисов (API), отсюда и название.
Необходимость в этом слое появляется сразу же при переходе на паттерн микросервисов:
- Единый адрес намного удобнее сотни (у Netflix их более 600) индивидуальных адресов API;
- Логично проверять данные пользователя (token) в едином месте, на «входе»;
- Удобно реализовывать ограничения на количество запросов в едином месте;
- Вся система становится более гибкой — можно менять внутреннюю структуру хоть каждый день. Поддержка старых версий API становится тривиальным делом;
- Можно кешировать или мутировать ответы;
- Для удобства пользователя (или разработчиков front end) можно объединять ответы от разных сервисов. Facebook давно предлагает такую возможность.
Преимуществ больше — это просто те, что пришли на ум за 10–20 секунд.
Nginx выпустили неплохую бесплатную электронную книгу посвященную микросервисам и API gateway — советую почитать всем, кому интересен этот паттерн.
Существующие варианты
- API Umbrella, Lua;
- Kong, Lua;
- AWS API Gateway — платный сервис от Amazon.
Как я уже сказал выше, вариантов очень мало, да и те появились сравнительно недавно. Многих возможностей в них пока нет.
Почему PHP и Lumen?
С выходом версии 7 PHP стал высокопроизводительным, а с появлением фреймфорков вроде Laravel и Symfony — PHP доказал миру, что может быть красивым и функциональным. Lumen, являясь «очищенной» быстрой версией Laravel здесь идеально подходит, ведь нам не нужны будут сессии, шаблоны и прочие возможности full stack приложений.
Кроме того, у меня просто больше опыта с PHP и Lumen, а разворачивая полученное приложение через Docker — будущим пользователям вообще будет не важен язык, на котором оно написано. Это просто слой, который выполняет свою роль!
Выбранная терминология
Мною предлагается следующая архитектура и соответствующая ей терминология. В коде буду придерживаться этих терминов, чтобы не запутаться:
Само приложение решил назвать Vrata, потому что «врата» на русском это почти «gateway», а ещё миру не хватает приложений с русскими названиями ;-)
Непосредственно за «вратами» находится количество N микросервисов — API сервисов, способных отвечать на web-запросы. У каждого сервиса может быть любое количество экземпляров, поэтому API gateway будет выбирать конкретный экземпляр через так называемый реестр сервисов.
Каждый сервис предлагает какое-то количество ресурсов (на языке REST), а у каждого ресурса может быть несколько возможных действий. Достаточно простая и логичная структура для любого опытного в REST программиста.
Требования к Vrata
Ещё не приступив к коду, можно сразу определить некоторые требования к будущему приложению:
- Шлюз должен масштабироваться горизонтально, потому что на дворе 2016 год и все хотят масштабировать горизонтально. Следовательно — никакого состояния приложения не должно быть;
- Шлюз должен уметь объединять запросы и вызывать микросервисы асинхронно;
- Шлюз должен уметь ограничивать количество запросов в промежуток времени;
- Шлюз должен уметь проверять достоверность токена аутентификации. Традиционно предлагается, что API gateway выполняет аутентификацию, а скрытые под ним микросервисы выполняют авторизацию на свои ресурсы;
- Шлюз должен уметь автоматически импортировать доступные ресурсы с микросервисов. Для начала выберем формат Swagger, как самый популярный в мире на сегодня;
- Шлюз должен уметь менять (мутировать) ответы микросервисов;
- И напоследок: шлюз должен прекрасно запускаться напрямую из образа Docker и конфигурироваться через переменные окружения. Мы не хотим никаких дополнительных репозиториев, скриптов деплоя и так далее!
Скажу сразу, что большая часть пунктов уже работает, а реализовать их было очень просто. Ведь правду говорят — мы живем в лучшую для программиста эпоху!
Реализация
Аутентификация
В этом направлении почти не пришлось работать — достаточно было поправить Laravel Passport под Lumen и мы получили поддержку всех современных OAuth2 фич, включая JWT. Мой маленький пакет-порт опубликован на GitHub/Packagist и кто-то его уже устанавливает.
Маршруты и контроллер
Все низлежащие маршруты с микросервисов импортируются в Vrata из конфигурационного файла в формате JSON. В момент запуска в service provider происходит добавление этих маршрутов:
// Получаем синглетный класс – база данных всех маршрутов
$registry = $this->app->make(RouteRegistry::class);
// Передаем наш Lumen контейнер этой базе, чтобы она могла зарегистрировать маршруты
$registry->bind(app());
А тем временем в базе маршрутов:
/**
* @param Application $app
*/
public function bind(Application $app)
{
// Очень просто - маршрут за маршрутом добавляем в Lumen
// Все запросы пойдут в один и тот же служебный контроллер
// Добавляем middleware для аутентификации OAuth2, а также своего дополнительного помощника
$this->getRoutes()->each(function ($route) use ($app) {
$method = strtolower($route->getMethod());
$app->{$method}($route->getPath(), [
'uses' => 'App\Http\Controllers\GatewayController@' . $method,
'middleware' => [ 'auth', 'helper:' . $route->getId() ]
]);
});
}
Теперь каждому публичному (и разрешенному в конфигах) маршруту с микросервисов соответствует маршрут на API gateway. Кроме того, добавлены также синтетические или объединенные запросы, которые существуют только на этом шлюзе. Все запросы уходят в один и тот же контроллер:
Вот так контроллер обрабатывает любой GET-запрос:
/**
* @param Request $request
* @param RestClient $client
* @return Response
*/
public function get(Request $request, RestClient $client)
{
// Это наша баночка с параметрами, подробнее - позже
$parametersJar = $request->getRouteParams();
// Соберем финальный ответ из N ответов микросервисов
$output = $this->actions->reduce(function($carry, $batch) use (&$parametersJar, $client) {
// Соберем N ответов полученных асинхронно
$responses = $client->asyncRequest($batch, $parametersJar);
// Добавим необходимые новые параметры в баночку параметров
$parametersJar = array_merge($parametersJar, $responses->exportParameters());
// Склеим с текущим состоянием - делаем array reduce
return array_merge($carry, $responses->getResponses()->toArray());
}, []);
// Отдаем ответ классу форматирования. Сейчас это только JSON
return $this->presenter->format($this->rearrangeKeys($output), 200);
}
В качестве HTTP-клиента выбран Guzzle, который прекрасно справляется с async-запросами, а также имеет готовые средства для integration-тестирования.
Составные запросы
Уже работают сложные, составные запросы — это когда одному маршруту на шлюзе соответствует любое количество маршрутов на разных микросервисах. Вот рабочий пример:
// Boolean-флаг, обозначающий сложный маршрут
'aggregate' => true,
'method' => 'GET',
// Любой путь на наш вкус, параметры из него сразу попадут в "jar"
'path' => '/v2/devices/{mac}/extended',
// Массив с низлежащими маршрутами
'actions' => [
'device' => [
// Имя микросервиса из реестра сервисов
'service' => 'core',
'method' => 'GET',
'path' => 'devices/{mac}',
// Компоненты с одинаковым порядком будут запущены параллельно
'sequence' => 0,
// Если в составе есть критичные компоненты и они недоступны - весь маршрут недоступен
'critical' => true
],
'ping' => [
'service' => 'history',
// Вывод никак не участвует в нашем финальном ответе
'output_key' => false,
'method' => 'POST',
'path' => 'ping/{mac}',
'sequence' => 0,
'critical' => false
],
'settings' => [
'service' => 'core',
// Вставляем вывод под альтернативным JSON-ключом
'output_key' => 'network.settings',
'method' => 'GET',
// Используем параметр, добытый ранее в пункте 'device'
'path' => 'networks/{device%network_id}',
'sequence' => 1,
'critical' => false
]
]
Как видим, сложные маршруты уже доступны и обладают неплохим набором фич — можно выделать критически важные из них, можно делать параллельные запросы, можно использовать ответ одного сервиса в запросе к другому и так далее. Помимо всего прочего, на выходе прекрасная производительность — всего 56 миллисекунд на получение суммарного ответа (загрузка Lumen и три фоновых запроса, все микросервисы с базами данных).
Реестр сервисов
Это пока самая слабая часть — реализован только один очень простой метод: DNS. Несмотря на всю его примитивность, он отлично работает в среде вроде Docker Cloud или AWS, где сам провайдер наблюдает за группой сервисов и динамически редактирует DNS-запись.
В настоящий момент Vrata просто берет hostname сервиса, не вникая — облако это или один физический компьютер. Самым популярным реестром на сегодня, пожалуй, является Consul, и именно его стоит добавить следующим.
Суть работы реестра очень проста — надо хранить таблицу живых и мертвых экземпляров сервиса, выдавая адреса конкретных экземпляров когда надо. AWS и Docker Cloud (и многие другие) умеют это делать за вас, предоставляя вам один «волшебный» hostname, который всегда работает.
Образ Docker
Говоря о микросервисах просто нельзя не упомянуть Docker — одну из самых «горячих» технологий последних пары лет. Микросервисы, как правило, тестируются и деплоятся именно как образы Docker — это стало стандартной практикой, поэтому мы быстро подготовили публичный образ в Docker Hub.
Одна команда, введённая в терминале любой OS X, Windows или Linux машины, и у вас работает мой шлюз Vrata:
$ docker run -d -e GATEWAY_SERVICES=... -e GATEWAY_GLOBAL=... -e GATEWAY_ROUTES=... pwred/vrata
Всю конфигурацию можно передать в переменных окружения в формате JSON.
Послесловие
Приложение (шлюз) уже используется на практике в компании, где я работаю. Весь код в репозитории на GitHub. Если кто-либо хочет поучаствовать в разработке — милости просим :)
Так как составные запросы как по идее, так и по реализации очень напоминают продвигаемый Facebook формат запросов GraphQL (в противовес REST), то одна из приоритетных будущих фич — поддержка GraphQL-запросов.