Традиционно перед коллекцией микросервисов предлагается дополнительный слой – так называемый 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-запросов.
Комментарии (12)
boodda
13.11.2016 22:05интересно. у меня вопрос почему в конфигах все значения настроек надо прописывать строками, для чего это. я не хочу помнить их, пишите как константы классов. ide всегда напомнит и дополнит все что я хочу и еще и с комментариями. не пишите строки!
webmoder
14.11.2016 09:55Не думали реализовать проброс «Request ID» в запросах к микросервисам?
dusterio
14.11.2016 12:30Сейчас пробрасывается User ID из токена и оригинальный IP. В принципе, очень просто добавить любые другие заголовки. А как бы Вы использовали Request ID?
webmoder
14.11.2016 13:56Использовал бы для логирования всей цепочки запросов к микросервисам.
т.к не всегда понятно на каком этапе/микросервисе произошла ошибка. А имея идентификатор запроса инициированного на API Gateway, можно гараздо проще разобраться с проблемным местом.
- это дело можно визуализировать
webmoder
14.11.2016 15:13Скорее всего не стоит постоянно логировать каждый request. Достаточно иметь возможность проброса, который будет работать в режиме debug. Хотя это уже зависит от задач.
ruFog
17.11.2016 16:45+1Спасибо за статью! Интересно как вы авторизуете клиентов в микросервисах. Все микросервисы шарят общую БД юзеров? Или отдельный микросервис, от которого зависят другие микросервисы, которым нужна авторизация?
dusterio
18.11.2016 01:20Да, существует одна общая БД юзеров, на основе которой выдаются OAuth2 токены. В каждом запросе к микросервису есть заголовок X-User с идентификатором пользователя, то есть микросервис всегда знает, что любой запрос к нему — прошел аутентификацию. Микросервису остается сделать авторизацию основываясь на любой своей внутренней логике. Это традиционный способ для этой архитектуры — так советуют делать книги.
tzurbaev
18.11.2016 07:46А как быть с изменениями базы? Если у меня N сервисов, которые напрямую работают с таблицей юзеров, ведь потребуется вносить изменения в каждый из них, если будут проводиться серьезные изменения в БД?
Сейчас читаю "Создание микросервисов" Сэма Ньюмана, там нет явного запрета на использование такого подхода, но довольно подробно описываются риски при использовании общей БД — в том числе и изменения структуры. Опыта в этой тематике пока что мало, поэтому для начала решил сделать так: есть основная БД приложения, в которой хранятся все данные пользователей. Микросервисы, которые тоже должны работать с юзерами, просто получают необходимые данные при регистрации пользователя и обновляют их при изменении профиля.
dusterio
18.11.2016 07:49Сервисы не должны работать напрямую с таблицей юзеров — от API gateway передается только сам факт успешной аутентификации и немного дополнительных полей (scopes/права, ID юзера).
Если необходимо реализовать свою (для конкретного сервиса) авторизацию — традиционно нужна _отдельная_ база данных. Это основа философии — один сервис не должен задевать другой, все должно деплоиться по-отдельности без проблем.
Если данные о пользователе используются более чем 1-2 сервисами — их можно хранить в общей базе (которую можно держать «при» API gateway), если данные нужны конкретному сервису — в его местной базе.
zcasper
Мне кажется тут стоило упомянуть о ESB (Enterprise Service Bus)…