Создаем простой API-шлюз в ASP.NET Core

Привет, Хабр! Представляю вашему вниманию перевод статьи "Creating a simple API Gateway in ASP.NET Core".


Время чтения: ~10 минут


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


Диаграма архитектуры микросервиса


Другой жизненно необходимый компонент для работы продукта это API-шлюз — система между приложением и бэкэндом, которая, во-первых, маршрутизирует входящие запросы на соответствующий микросервис, и во-вторых, авторизует пользователя.


Существует много фреймворков которые могут быть использованы для создания API-шлюза, например, Ocelot в .NET core или Netflix Zuul в Java. Тем не менее, в этой статье я опишу процесс создания простого API-шлюза с нуля в .NET Core.


Создание проекта


Для начала создадим новое приложение, выбрав ASP.NET Core Web Application в окне создания проекта и Empty в качестве шаблона.




В проекте будут лежать классы Startup и Program. Для нас самой важной частью является метод Configure класса Startup. Здесь мы можем обработать входящий HTTP-запрос и ответить на него. Возможно, в методе Configure будет находится следующий код:


app.Run(async (context) =>
        {
            await context.Response.WriteAsync("Hello, World!");
        });

Написание маршрутизатора


Так как именно в методе Configure мы будем обрабатывать запросы, напишем необходимую логику:


Router router = new Router("routes.json");
            app.Run(async (context) =>
            {
                var content = await router.RouteRequest(context.Request);
                await context.Response.WriteAsync(await content.Content.ReadAsStringAsync());
            });

Сначала мы создаем объект типа Router. В его задачи входит хранение существующих маршрутов, валидация и отправка запросов согласно маршрутам. Чтобы сделать код более чистым, будем подгружать маршруты из JSON-файла.


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


Перед тем как писать класс Router, создадим файл routes.json. В этом файле укажем список маршрутов, каждый из которых будет содержать внешний адрес (endpoint) и адрес назначения (destination). Также, мы добавим флаг, сигнализирующий о необходимости авторизации пользователя перед перенаправлением.


Вот как примерно может выглядеть такой файл:


{
  "routes": [
    {
      "endpoint": "/movies",
      "destination": {
        "uri": "http://localhost:8090/movies/",
        "requiresAuthentication": "true"
      }
    },
    {
      "endpoint": "/songs",
      "destination": {
        "uri": "http://localhost:8091/songs/",
        "requiresAuthentication": "false"
      }
    }
  ],
  "authenticationService": {
    "uri": "http://localhost:8080/api/auth/"
  }
}

Создаем класс Destination


Мы теперь знаем, что каждый Route должен иметь endpoint и destination, а каждый Destination должен иметь поля uri и requiresAuthentication.


Теперь напишем класс Destination, помня о том. Я добавлю два поля, два конструктора и приватный конструктор без параметров для JSON-десериализации.


public class Destination
    {
        public string Uri { get; set; }
        public bool RequiresAuthentication { get; set; }

        public Destination(string uri, bool requiresAuthentication)
        {
            Uri = path;
            RequiresAuthentication = requiresAuthentication;
        }

        public Destination(string uri)
            :this(uri, false)
        {
        }

        private Destination()
        {
            Uri = "/";
            RequiresAuthentication = false;
        }
}

Также, будет правильно написать в этом классе метод SendRequest. Этим мы покажем, что каждый объект класса Destination будет ответственнен за отправку запроса. Этот метод будет принимать объект типа HttpRequest, который описывает входящий запрос, вынимать оттуда всю необходимую информацию и отправлять запрос на целевой URI. Для этого напишем вспомогательный метод CreateDestinationUri, который будет соединять строки с адресом и параметры адресной строки (query string) от клиента.


  private string CreateDestinationUri(HttpRequest request)
        {
            string requestPath = request.Path.ToString();
            string queryString = request.QueryString.ToString();

            string endpoint = "";
            string[] endpointSplit = requestPath.Substring(1).Split('/');

            if (endpointSplit.Length > 1)
                endpoint = endpointSplit[1];

            return Uri + endpoint + queryString;
        }

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


public async Task<HttpResponseMessage> SendRequest(HttpRequest request)
        {
            string requestContent;
            using (Stream receiveStream = request.Body)
            {
                using (StreamReader readStream = new StreamReader(receiveStream, Encoding.UTF8))
                {
                    requestContent = readStream.ReadToEnd();
                }
            }

            HttpClient client = new HttpClient();
            HttpRequestMessage newRequest = new HttpRequestMessage(new HttpMethod(request.Method), CreateDestinationUri(request));
            HttpResponseMessage response = await client.SendAsync(newRequest);

            return response;
        }

Создаем JSON-парсер.


Перед написанием класса Router, нам нужно создать логику для десериализации JSON-файла с маршрутами. Я создам для этого вспомогательный класс, в котором будет два метода: один для создания объекта из JSON-файла, а другой для десериализации.


public class JsonLoader
    {
        public static T LoadFromFile<T>(string filePath)
        {
            using (StreamReader reader = new StreamReader(filePath))
            {
                string json = reader.ReadToEnd();
                T result = JsonConvert.DeserializeObject<T>(json);
                return result;
            }
        }

        public static T Deserialize<T>(object jsonObject)
        {
            return JsonConvert.DeserializeObject<T>(Convert.ToString(jsonObject));
        }

    }

Класс Router.


Последнее, что мы сделаем перед написанием Router — опишем модель маршрута:


    public class Route
    {
        public string Endpoint { get; set; }
        public Destination Destination { get; set; }
    }

Теперь напишем класс Router, добавив туда поля и констурктор.


public class Router {

    public List<Route> Routes { get; set; }
    public Destination AuthenticationService { get; set; }

    public Router(string routeConfigFilePath)
    {
        dynamic router = JsonLoader.LoadFromFile<dynamic>(routeConfigFilePath);

        Routes = JsonLoader.Deserialize<List<Route>>(
            Convert.ToString(router.routes)
        );

        AuthenticationService = JsonLoader.Deserialize<Destination>(
            Convert.ToString(router.authenticationService)
        );
    }
}

Я использую динамический тип (dynamic type) для чтения из JSON и записи в него свойств объекта.


Теперь все готово для описания главной фнукциональности API-шлюза: маршрутизация и авторизация пользователя, которая будет происходить в методе RouteRequest. Нам нужно распаковать базовую часть внешнего адреса (base endpoint) из объекта запроса. Например, для адреса /movies/add базой будет /movies/. После этого, нам нужно проверить, есть ли описание данного маршрута. Если да, то авторизуем пользователя и отправляем запрос, иначе возвращаем ошибку. Я также создал класс ConstructErrorMessage для удобства.


Для авторизации, я предпочел следующий путь: извлекаем токен из заголовка запроса и отправляем как параметр запроса. Возможен и другой вариант: оставить токен в заголовке, тогда извлекать его должен уже микросервис, которому предназначается запрос.


public async Task<HttpResponseMessage> RouteRequest(HttpRequest request)
        {
            string path = request.Path.ToString();
            string basePath = '/' + path.Split('/')[1];

            Destination destination;
            try
            {
                destination = Routes.First(r => r.Endpoint.Equals(basePath)).Destination;
            }
            catch
            {
                return ConstructErrorMessage("The path could not be found.");
            }

            if (destination.RequiresAuthentication)
            {
                string token = request.Headers["token"];
                request.Query.Append(new KeyValuePair<string, StringValues>("token", new StringValues(token)));
                HttpResponseMessage authResponse = await AuthenticationService.SendRequest(request);
                if (!authResponse.IsSuccessStatusCode) return ConstructErrorMessage("Authentication failed.");
            }

            return await destination.SendRequest(request);
        }

        private HttpResponseMessage ConstructErrorMessage(string error)
        {
            HttpResponseMessage errorMessage = new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.NotFound,
                Content = new StringContent(error)
            };
            return errorMessage;
        }

Заключение


Создание базового API-шлюза не требует много усилий, но он не предоставит должной фукнциональности. Если вам нужен балансировщик нагрузки, вы можете посмотреть на уже существующие фреймворки или платформы, которые предлагают библиотеки для маршрутизации запросов.


Весь код из этой статьи доступен в репозитории на GitHub

Поделиться публикацией

Комментарии 10

    0

    Хотел я возмутиться с каких это пор Newtonsoft.Json поддерживает dynamic — и внезапно обнаружил что с сентября 2010 года...


    Хотя все равно перегонять JToken в строку, а потом разбирать обратно нахожу неправильным. Вот так же можно:


    var router = JsonLoader.LoadFromFile<JObject>(routeConfigFilePath);
    Routes = router["routes"].ToObject<List<Route>>();
    AuthenticationService = router["authenticationService"].ToObject<Destination>();
      0
      То есть, это отдельный сервис-маршрутизатор, который распределяет запросы по микросервисам?
        –1
        Да, именно так.
          +1

          а зачем это унжно, если любой микросервис самостоятельно может зарегистрировать свой личный url в http.sys драйвере?

            0
            Хотя бы затем что микросервис может быть на другой ноде кластера. Да и нету http.sys под линуксом…
              +1

              Микросервис может быть под слегка другим урлом. Вообще это вроде называется reverse proxy, и можно использовать готовые, типа ngnix или traefik

          +2

          Осталось прикрутить X-Forwarded-*, Load Balancer, кеширование и… тогда легче настроить nginx или HAProxy.

            +2
            HttpClient client = new HttpClient();

            С такми подходом ваш шлюз встанет колом очень быстро.
            Тут либо использовать HttpClientFactory, либо статический HttpClient с MaxConnectionsPerServer.
            Эта история уже 1000 раз описана на всех dotnet ресурсах.
              0
              зачем придумывать свой велосипед если есть готовые решения с богатым функционалом: Ocelot (это если принципиально .net) но есть всем хорошо известные и проверенные временем Traefik i Kong
                0
                в конструкторе класса Destination — ошибка:

                Uri = path;

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

                Самое читаемое