Поддерживая существующие уже какое-то время Web API проекты, мы нередко сталкиваемся с проблемой устаревания логики методов контроллеров и необходимостью ее изменения в соответствии с новыми требованиями. Но, как правило, на момент возникновения такой необходимости, уже существует определенное число сервисов, использующих текущую реализацию наших API, и не нуждающихся в ее модернизации. Более того, такие сервисы могут легко «сломаться» при изменении используемых ими API.

Для решения такого рода проблем в ASP.Net Core существует механизм версионирования API – когда контроллеры и их методы могут существовать одновременно в разных версиях. В таком случае, те сервисы, которым достаточно существующего состояния используемых ими API, могут продолжать использовать определенные версии этих API, а для сервисов, которые требуют модернизации логики контроллеров, мы можем создавать новые параллельные версии, и все эти версии могут работать в нашем проекте одновременно.

  1. В Visual Studio создаем новый проект ASP.NET Core Web API:

  1. В новый проект добавляем NuGet пакет: Microsoft.AspNetCore.Mvc.Versioning

Для этого в SolutionExplorer (Обозреватель решений) правой кнопкой мыши жмем по названию рабочего проекта и выбираем Manage NuGet Packages… (Управление пакетами Nuget).

 Далее переходим на крайнюю левую вкладку Browse, и в строку поиска вводим название устанавливаемого пакета NuGet.

В левом окне выбираем нужный нам пакет, а в правом жмем кнопку Install.

  1. В метод ConfigureServices класса Startup.cs добавляем строку "services.AddApiVersioning();":

public void ConfigureServices(IServiceCollection services)
{
  // другой код
  services.AddControllers();
  services.AddApiVersioning();
  // другой код
}
  1. В папке ‘Controllers’ создаем новую папку “V2”.

  2. В созданную папку добавляем новый API контроллер:

И называем его WeatherForecastController, так же, как и созданный по умолчанию контроллер.

В новый контроллер добавляем логику уже существующего контроллера, и немного
изменяем ее. Также добавляем к контроллеру атрибут [ApiVersion("2.0")]
и в атрибуте Route изменяем маршрут:

[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("2.0")]
public class WeatherForecastController : ControllerBase
{
	private static readonly string[] Summaries = new[]
	{
		"Сильный мороз", "Мороз", "Холодно", "Прохладно", "Свежо", "Тепло", "Духота", "Жара", "Сильная жара"
	};
			
	[HttpGet]
	public IEnumerable<WeatherForecast> Get()
	{
		var rng = new Random();
		return Enumerable.Range(1, 5).Select(index => new WeatherForecast
		{
			Date = DateTime.Now.AddDays(index),
			TemperatureC = rng.Next(-40, 40),
			Summary = Summaries[rng.Next(Summaries.Length)]
		})
		.ToArray();
	}
}

Маршрут нового контроллера теперь будет: "/api/v2/WeatherForecast".

  1. К автоматически созданному с проектом контроллеру добавим атрибут [ApiVersion("1.0")]:

[ApiController]
[Route("[controller]")]
[ApiVersion("1.0")]
public class WeatherForecastController : ControllerBase
{
	// созданная по умолчанию логика
}

Таким образом, мы имеем две версии по сути одного контроллера – 1 и 2.

Маршрут вызова первого контроллера остается прежним, но теперь мы обязаны сообщить маршрутизатору нужную нам версию.

 Передать версию контроллера можно несколькими способами:

  • в параметрах запроса: https://localhost:44335/WeatherForecast?api-version=1.0

  • в HTTP заголовках запроса. Для этого нужно видоизменить метод AddApiVersioning в методе ConfigureServices класса Startup.cs:

services.AddApiVersioning(config =>
{
      config.ApiVersionReader = new HeaderApiVersionReader("api-version");
});

 и добавить в запрос соответствующий заголовок:

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

Запустим наш проект, и в браузере или инструменте для работы с API (Postman, Insomnia и т.д.) введем Url: “https://localhost:44335/WeatherForecast?api-version=1.0”. Разумеется, порт нужно указывать соответствующий вашему приложению.

Получим результат:

[
   {
      "date":"2022-02-06T00:22:54.6248567+06:00",
      "temperatureC":-13,
      "temperatureF":9,
      "summary":"Scorching"
   },
   {
      "date":"2022-02-07T00:22:54.6261864+06:00",
      "temperatureC":24,
      "temperatureF":75,
      "summary":"Freezing"
   },
   {
      "date":"2022-02-08T00:22:54.6261919+06:00",
      "temperatureC":-12,
      "temperatureF":11,
      "summary":"Freezing"
   },
   {
      "date":"2022-02-09T00:22:54.6261927+06:00",
      "temperatureC":40,
      "temperatureF":103,
      "summary":"Sweltering"
   },
   {
      "date":"2022-02-10T00:22:54.6261931+06:00",
      "temperatureC":27,
      "temperatureF":80,
      "summary":"Balmy"
   }
]

8.    Вызовем метод второго контроллера. Для этого воспользуемся способом передачи версии в Url запроса, так как мы задали его с помощью атрибута [Route("api/v{version:apiVersion}/[controller]")] и должны теперь ему следовать.

Запустим наш проект, и в браузере или инструменте для работы с API (Postman, Insomnia и т.д.) введем Url: “https://localhost:44335/api/v2/WeatherForecast”.

Получим результат:

[
   {
      "date":"2022-02-06T00:32:43.5572436+06:00",
      "temperatureC":27,
      "temperatureF":80,
      "summary":"Жара"
   },
   {
      "date":"2022-02-07T00:32:43.5577678+06:00",
      "temperatureC":14,
      "temperatureF":57,
      "summary":"Сильная жара"
   },
   {
      "date":"2022-02-08T00:32:43.5577702+06:00",
      "temperatureC":15,
      "temperatureF":58,
      "summary":"Тепло"
   },
   {
      "date":"2022-02-09T00:32:43.5577706+06:00",
      "temperatureC":0,
      "temperatureF":32,
      "summary":"Духота"
   },
   {
      "date":"2022-02-10T00:32:43.5577707+06:00",
      "temperatureC":-14,
      "temperatureF":7,
      "summary":"Тепло"
   }
]
  1. Вызов первого контроллера по его первоначальному маршруту без указания версии - https://localhost:44335/WeatherForecast

Предположим, что первый контроллер уже используется несколькими другими проектами (сервисами) в его первоначальном виде, каким он был до добавления в проект версионирования, и никто не собирается рефакторить эти проекты и менять Url для вызова. Они по-прежнему обращаются к нашему API по маршруту “https://localhost:44335/WeatherForecast”.

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

Для этого изменим метод AddApiVersioning в методе ConfigureServices класса Startup.cs:

services.AddApiVersioning(config =>
{
   config.DefaultApiVersion = new ApiVersion(1, 0);
   config.AssumeDefaultVersionWhenUnspecified = true;
});

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

  1. Мы можем отметить первую версию как устаревшую, немного изменив атрибут версии: [ApiVersion("1.0", Deprecated = true)]

  2. С помощью атрибута MapToApiVersion мы имеем возможность управлять версиями на уровне методов:

[MapToApiVersion("3.0")]
[HttpGet]
public int DoSomething()
{
	// логика метода
}

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


  1. foal
    05.02.2022 00:08

    Очень похоже на то что мы используем для Java. Есть enum с набором версий

    @ApiVersionList
    public enum RestApiVersion {
        v1_0,
        /* initial version */
        v2_0,
        /* - add new endpoint (DtoV1x0) */
        v3_0,
        /* - change endpoint (DtoV2x0) */
    }

    А в котроллерах методы с аннотациями

    @Service
    public class ExampleController {
    
        @GetMapping("/isalive/check")
        public static boolean isAlive() {
            return true;
        }
      
        @ApiVersion(value = "v2_0", deleted = "v3_0")
        @GetMapping("/someService")
        public static DtoV2x0 getDtoV2() {
            return null;
        }
       
        @ApiVersion("v3_0")
        @GetMapping("/someService")
        public static DtoV3x0 getDtoV3() {
            return null;
        }  
    
    }

    isAlive - без аннотации - есть во всех версиях

    getDtoV2 только во 2

    getDtoV3 только с 3 и далее

    Дальше annotation processor сгенерирует три реальных контроллера, каждый со своим набором методов.

    Версии методов и DTO могут быть любые, но для удобства мы их синхронизируем с версией API где они появились впервые.


  1. KislyFan
    05.02.2022 12:05
    +3

    До боли напоминает массу сущиствующих туторов, в том числе из msdn. В чем новизна конкретно этого howto?


  1. heisil
    05.02.2022 14:17

    Как быть с post/put ендпойнтами, если в новой версии v2 нужно внести изменения в схему БД? Допустим, в v2 приложения добавили новое not null поле в схему таблицы, и значение должно выбираться осознанно (не дефолтное), которое будет влиять на остальной процесс бизнес логики.


    1. dkshibekov Автор
      05.02.2022 14:31

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


      1. heisil
        05.02.2022 16:11

        На практике не удавалось сталкиваться с версионированием api, так как была возможность повлиять на клиентскую часть сервиса. Но всегда интересовал этот вопрос, особенно по самому чувствительному месту - данным в базе. Я так понимаю, все v1,v2,v3,..,vN должны соблюдать обратную совместимость. И вся идея версионирования api - это по сути инкремент минорной версии y в семантическом версионировании x.y.z, а не x. И если у нас случится изменение x, то надо инвалидировать все пердыдущие версии api, т.е. объявить даже не deprecated, a obsolete?


        1. dkshibekov Автор
          05.02.2022 18:04

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


    1. KislyFan
      05.02.2022 14:49

      В устаревшем методе надо возвращать код ошибки HTTP (например 500) и или кастомный код (в 200 OK). В одном из проектов спечциально для этого, я делаю обьект { success, message, payload } контейнером для всех своих ответов по операциям, и в случае ошибки пишу success=false, message='error_i_am_your_father'. Так как REST фактически не регламентирован, то вокруг того какой подход верен идут ожесточенные споры)


      1. s207883
        06.02.2022 09:44
        +1

        По принципу наименьшего удивления устаревший метод не должен отвечать 500, так как это непредвиденная ошибка. И 200 тоже, так как фактически сервер не отработал. Success false и код 200 это вообще бред.


        1. KislyFan
          06.02.2022 17:47

          Бред не бред, а использование только GET/POST запросов с контейнером вместо CRUD like GET/PUT/POST/DELETE - это общепринятая практика. Потому что стандарта на REST нет и каждый пишет как хочет.

          Да и сервер фактически отработал. Потому что неуспешность, как и отсутствие ответа - это тоже ответ. Если у вас есть решение как более корректно уведомить сторону клиента в публином api и не сломать его, то прошу к коллайдеру )


          1. s207883
            06.02.2022 21:52

            Может быть слишком резко выразился, просто лично я не воспринимаю контейнерный вариант.

            Имхо, то, как будет развиваться апи и что делать с устаревшими методами должно быть заранее обговорено или описано в каком-либо документе. Там же можно обозначить код ошибки, который мы будем отсылать на устаревшие методы (405, 418, не важно). Вместе с ним отвечаем, что метод устарел, обнови кодовую базу.
            Само обновление мне видится в 2-х вариантах:
            1-й вариант (научнофантастический) - мы заранее предупреждаем о "ломающих изменениях", далее кто успел обновить кодовую базу, тот сам себе молодец.
            2-й - версионирование в пределах возможного. (все-таки некоторые изменения логики могут быть полностью несовместимы с устаревшими методами). Опционально описать время поддержки старых версии апи.