Кастомный генератор кода API: структура и методы доработки

Всем привет! Меня зовут Юлия Сладковская, я разработчик в МТС Digital, команда BOPS (Backoffice Portal). Эта статья — про структуру генераторов NSwag для кода клиента и сервера на основе схемы API. Также я расскажу о создании кастомного генератора на базе стандартных генераторов Nswag, методах его настройки и расширения.

Как мы пришли к созданию собственного генератора кода API? Причина такая — сейчас в компании ведется работа по рефакторингу FORIS (комплексная система конвергентного биллинга, управления услугами, автоматизации бизнес-процессов и производственной деятельности) и разделению на группу микросервисов. Система большая, ее разрабатывают несколько команд. Контракты, по которым они взаимодействуют между собой, разрабатывает команда архитекторов. Нужно решать задачу синхронизации этих контрактов между командами, разрабатывающими и потребляющими API, а также архитекторами, которые эти контракты разрабатывают. Синхронизировать их всех вручную трудоемко, нужно средство автоматизации.

Поэтому мы решили разрабатывать API через интерфейс портала управления сервисными контрактами — Apicurio

2accbcc259a0136d92f9805464e640d1.png

Пошагово процесс выглядит так:  

  1. Архитекторы размещают на Apicurio актуальную версию контракта;  

  2. Контракт скачивается с Apicurio и указывается на вход генератора;

  3. Разработчики, которым требуется клиент, используют результаты генерации в своих сервисах. В случае изменения контракта, код перегенерируется и снова будет актуальным;

  4. Разработчики, работающие с серверной частью, разрабатывают имплементацию и, в случае обновления контракта и обновления сгенерированного кода, могут своевременно исправить несоответствия имплементации обновленной версии;

  5. Разработчики дают обратную связь по работе генератора;

  6. Выпуск обновлений с исправлениями и дополнениями.

Архитекторы при разработке правил для написания нового API решили применить все возможные REST-стандарты. Казалось бы, все хорошо, но при разработке командами на основе этих контрактов возникли проблемы с генерацией стандартными генераторами NSwag.

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

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

После краткого описания причин появления генератора и процессов, в которых он участвует, перейдем к рассмотрению генераторов NSwag.

Назначение

Основная задача, решаемая генераторами NSwag — формирование кода клиентской и серверной части на основе схемы API, представленной в формате YAML или JSON, в рассматриваемом случае — на языке C#.  Важный аспект, влияющий на генерацию кода API, — исходный подход, использованный при его создании. Есть два основных подхода к разработке API:

  • Code-First — написание кода API на основе бизнес-требований, затем создание машиночитаемого определения API из этого кода;

  •  API-First — разработка каждого API на основе контракта, написанного на языке описания API, например, на OpenAPI.

При первом подходе в большинстве случаев доработки генератора будут минимальны или не потребуются вовсе, поскольку генераторы, предоставляемые NSwag, имеют гибкую настройку.

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

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

Библиотека NSwag предоставляет генератор для сервера — openApiToCSharpController, а также для клиента — openApiToCSharpClient.

Подключение генераторов

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

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

Создание кастомизированных генераторов

Структура генератора

Сначала для создания генератора необходимо добавить класс, представляющий его. Он может наследоваться от CSharpClientGenerator (класс, представляющий генератор клиента) или CSharpControllerGenerator (класс, представляющий генератор серверной части) — это зависит от его назначения.

В классе определяется свойства с настройками и документ — settings, openApiDocument. Также можно переопределить методы создания модели операций, генерации типов DTO, генерацию основного класса и/или интерфейса и так далее.

Генератор может использовать существующий и соответствующий ему класс настроек — CSharpControllerGeneratorSettings или CSharpClientGeneratorSettings. Можно создать и свой класс, наследника одного из вышеуказанных, со свойствами, через которые пользователи генератора, формируя файл конфигурации, смогут передавать ту или иную информацию в генератор для его настройки. 

Здесь же можно внедрить фабрику шаблонов (пример покажу ниже), а также указать свою реализацию генераторов имен свойств, перечислений и типов. У генераторов NSwag широкий набор настроек, поэтому при необходимости каких-либо изменений в генерации следует изучить возможность решения проблемы доступными настройками.

Исходная схема API преобразуется в экземпляр класса OpenApiDocument, который включает в себя схемы всех описанных компонентов, описание операций, пути и прочие сведения о API.

Сам код при генерации формируется на основе группы шаблонов, написанных на языке liquid. В шаблоны передаются настройки генератора, а также модели, информация из которых может быть применена в логике формирования кода. Подробнее о моделях и их переопределении — ниже.

2c844e2e630c2999890d95d26cacf3aa.png

Методы доработки кодогенерации

Рассмотрим подробно способы, которые могут быть применены для воздействия на процесс кодогенерации.

Доработка моделей и базового класса генератора

В генерации участвуют шаблоны NSwag, а также NJsonSchema. Модели, применяемые в этих шаблонах, могут быть доработаны. Для этого необходимо создать класс-наследник, а также переопределить соответствующий метод, который создает экземпляр данной модели и передает ее в шаблон. Если рассматривать применение наследников моделей из шаблонов NSwag, — нужно переопределять методы, находящиеся в созданном классе генератора. Эти модели позволят влиять на работу с операциями, на модель ответа, параметров и свойств.

d15bee3c5d94c0a743deb184d00f637a.png

Если работа идет с моделями, которые относятся к NJsonSchema, переопределить необходимо методы, относящиеся к классу CSharpGenerator. Создавая наследника от базового класса генерации GeneratorBase, аналогичного классу CSharpGenerator, можно не только заменить модели, используемые шаблонами NJsonSchema, но и добавить прочие доработки, связанные с генерацией типов DTO. Например, на условия генерации тех или иных атрибутов и конвертеров, именование свойств с названием, совпадающим с именем класса и так далее.

04abbec729c7f63609bf6055a53ca381.pngc74c87530fcc5bdb3245c34798f23c3a.png

Изменение шаблонов, внедрение фабрики шаблонов

Для того, чтобы добавить шаблон, необходимо создать наследника для DefaultTemplateFactory, где переопределить метод GetEmbeddedLiquidTemplate, после чего установить для CSharpGeneratorSettings.TemplateFactory в качестве значения новый экземпляр созданной фабрики. Далее — расположить необходимые шаблоны в соответствии с описанной логикой их поиска фабрикой.

internal class TemplateFactory : NSwag.CodeGeneration.DefaultTemplateFactory
{
  public string TemplateFolder { get; set; }
  /// 
  /// Создает новый экземпляр класса .
  /// 
  /// Настройки генератора кода.
  /// Список сборок доступных в шаблоне.
  /// Папка с шаблонами.
  public TemplateFactory(CodeGeneratorSettingsBase settings, Assembly[] assemblies, string templateFolder)
    : base(settings, assemblies)
  {
    TemplateFolder = templateFolder;
  }

  protected override string GetEmbeddedLiquidTemplate(string language, string template)
  {
    var assembly = Assembly.GetExecutingAssembly();
    var ns = GetType().Namespace;
    var resourceName = $"{ns}.{TemplateFolder}.{template}.liquid";

    var resource = assembly.GetManifestResourceStream(resourceName);
    if (resource != null)
    {
      using (var reader = new System.IO.StreamReader(resource))
      {
        return reader.ReadToEnd();
      }
    }

    return base.GetEmbeddedLiquidTemplate(language, template);
  }
}
public class FteClientCodeGeneratorSettings : CSharpClientGeneratorSettings
{
  /// 
  /// Создает новый экземпляр класса .
  /// 
  public FteClientCodeGeneratorSettings()
  {
    CSharpGeneratorSettings.TemplateFactory = new TemplateFactory(
      CodeGeneratorSettings,
      new Assembly[]
      {
        typeof(NJsonSchema.CodeGeneration.CSharp.CSharpGenerator).Assembly,
        typeof(CSharpClientGeneratorSettings).Assembly,
        this.GetType().Assembly,
      },
      templateFolder: "TemplatesClient");
  }
}

В шаблонах можно дописывать любую дополнительную логику и блоки кода, в соответствии с правилами языка lliquid. В шаблоне будет доступна вся информация из настроек генератора, а также из модели, которая была передана в методе фабрики CreateTemplate.

Изменение openApiDocument

Другой способ повлиять на генерацию — внесение изменений в документ, получаемый на вход генератора. Изменения могут быть внесены до или после преобразования схемы, написанной на языке OpenApi, в экземпляр класса OpenApiDocument.

В большинстве случаев достаточно возможности вносить изменения уже в полученный экземпляр OpenApiDocument, с которым работает генератор. Для его изменения можно использовать различные подходы. Наиболее удобный — создание наследника для класса JsonSchemaVisitorBase, в котором будет реализован метод VisitSchema, включающий в себя логику, по которой следует изменять схему. Затем необходимо в конструкторе генератора для этого документа вызвать метод Visit созданного класса до установки свойству openApiDocument в качестве значения документа, пришедшего на вход. Подобные изменения можно провести, не используя паттерн «посетитель», а просто написав логику изменения openApiDocument в конструкторе или отдельном методе.

Иногда нужно повлиять на схему до ее преобразования и возникает необходимость разработки препроцессоров. Препроцессоры — это набор дополнительных обработчиков документа OpenApi, которые будут вызваны до передачи его на вход генератора. В нашем случае они подключались за счет того же фреймворка, который позволяет использовать кастомные генераторы. 

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

Примеры проведенных доработок кодогенерации

В этом блоке я описываю некоторые из проблем и итоговый подход к кастомизации генератора. Все решения — из методов, описанных ранее. 

Одна из доработок — поддержка AnyOf в схемах, итоговым решением был поиск ближайшего общего родителя для всех типов, перечисленных в AnyOf, и установка OneOf от базового класса в схеме свойств генерируемых классов. Задача была решена путем создания статического метода для изменения схем компонентов документа (openApiDocument.Components.Schemas). 

Также проводилась доработка для использования атрибута ProducesResponseType. Задача была решена путем добавления дополнительной настройки, написанием собственной CSharpControllerTemplateModel, переопределением метода GenerateClientTypes в классе генератора и доработкой шаблона NSwag класса контроллера — Controller.liquid.

public class FteControllerCodeGeneratorSettings : CSharpControllerGeneratorSettings
{
  ...
  /// 
  /// Настройка создания GenerateProducesResponseType
  /// 
  public bool? GenerateProducesResponseType { get; set; }  
}

public class FteControllerTemplateModel : CSharpControllerTemplateModel
{
  private readonly FteControllerCodeGeneratorSettings settings;
  ...
  public bool GenerateProducesResponseType => settings.GenerateProducesResponseType ?? true;
}

public class FteControllerCodeGenerator : CSharpControllerGenerator
{
  ...
  protected override IEnumerable GenerateClientTypes(string controllerName, string controllerClassName, IEnumerable operations)
    {
      var modelCast = operations.Cast();
      var cSharpControllerTemplateModel = new Model.FteControllerTemplateModel(settings.ClassName, modelCast, openApiDocument, settings);
      ITemplate template = Settings.CodeGeneratorSettings.TemplateFactory.CreateTemplate("CSharp", "Controller", cSharpControllerTemplateModel);
      yield return new CodeArtifact(cSharpControllerTemplateModel.Class, CodeArtifactType.Class, CodeArtifactLanguage.CSharp, CodeArtifactCategory.Client, template);
    }

}


Controller.liquid
...
{%  if GenerateProducesResponseType -%}
{%      for code in operation.Codes -%}
    [ProducesResponseType({{ code }})]
{%      endfor -%}
{%  endif -%}

Доработка для работы Date Format Converter с типами DateOnly, TimeOnly — было применено изменение шаблона NJsonSchema  с конвертером дат DateFormatConverter.liquid.

[System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "{{ ToolchainVersion }}")]
{%- if UseSystemTextJson -%}
internal class DateFormatConverter : System.Text.Json.Serialization.JsonConverter<{{ DateType }}>
{
    public override {{ DateType }} Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options)
    {
        var dateTime = reader.GetString();
        if (dateTime == null)
        {
            throw new System.Text.Json.JsonException("Unexpected JsonTokenType.Null");
        }

        return {{ DateType }}.Parse(dateTime);
    }

    public override void Write(System.Text.Json.Utf8JsonWriter writer, {{ DateType }} value, System.Text.Json.JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString("yyyy-MM-dd"));
    }
}
{%- else -%}
internal class DateFormatConverter : Newtonsoft.Json.Converters.IsoDateTimeConverter
{
    public DateFormatConverter()
    {
        DateTimeFormat = "yyyy-MM-dd";
    }

    private const string DateFormat = "yyyy-MM-dd";

    private const string TimeFormat = "HH:mm:ss.FFFFFFF";

    public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
    {
        if (typeof(DateOnly?) == objectType || typeof(DateOnly) == objectType)
        {
            return DateOnly.ParseExact((string)reader.Value!, DateFormat, CultureInfo.InvariantCulture);
        }
        else if (typeof(TimeOnly?) == objectType || typeof(TimeOnly) == objectType)
        {
            return TimeOnly.ParseExact((string)reader.Value!, TimeFormat, CultureInfo.InvariantCulture);
        }                               
        else
        {
            return base.ReadJson(reader, objectType, existingValue, serializer);
        }
    }

    public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
    {
        if (value is DateOnly)
        {
            writer.WriteValue(((DateOnly)value).ToString(DateFormat, CultureInfo.InvariantCulture));
        }
        else if (value is TimeOnly)
        {
            writer.WriteValue(((TimeOnly)value).ToString(TimeFormat, CultureInfo.InvariantCulture));
        }
        else
        {
            base.WriteJson(writer, value, serializer);
        }               
    }
}
{%- endif %}

Поскольку у меня была потребность в создании свойств, чьи названия отличаются только символом @, потребовалась доработка для замены @ в начале имени свойства на _ при генерации свойств классов (по умолчанию генератор просто убирает символ @). Для этого был написан класс, реализующий IPropertyNameGenerator, экземпляр которого был указан для свойства CSharpGeneratorSettings.PropertyNameGenerator в конструкторе класса настроек генератора.

public class HandleAtCSharpPropertyNameGenerator : NJsonSchema.CodeGeneration.IPropertyNameGenerator
	{
		/// Generates the property name.
		/// The property.
		/// The new name.
		public virtual string Generate(JsonSchemaProperty property)
		{
			var name = ConversionUtilities.ConvertToUpperCamelCase(
				property.Name
				.Replace("\"", string.Empty),
				true);
			if (name[0] == '@')
			{
				name = "_" + char.ToUpper(name[1]) + name.Substring(2);
			}

			return name
				.Replace("@", "_")
				.Replace("?", string.Empty)
				.Replace("$", string.Empty)
				.Replace("[", string.Empty)
				.Replace("]", string.Empty)
				.Replace("(", "_")
				.Replace(")", string.Empty)
				.Replace(".", "-")
				.Replace("=", "-")
				.Replace("+", "plus")
				.Replace("*", "Star")
				.Replace(":", "_")
				.Replace("-", "_")
				.Replace("#", "_");
		}
	}

public class FteClientCodeGeneratorSettings : CSharpClientGeneratorSettings
{
		/// 
		/// Создает новый экземпляр класса .
		/// 
		public FteClientCodeGeneratorSettings()
		{
			...
			CSharpGeneratorSettings.PropertyNameGenerator = new Helper.HandleAtCSharpPropertyNameGenerator();
}
...
}

Доработка для перечисления, которая  начинает их с единицы (потребовалась, поскольку изначально генератор формирует перечисления со значениями, начинающимися 0) — решено путем создания класса наследника EnumTemplateModel, доработкой метода get свойства Enum. Для использования новой модели перечислений потребовалось создать наследника для GeneratorBase, в котором необходимо переопределить метод GenerateEnum. Также новый класс наследник GeneratorBase нужно указать в переопределенном методе GenerateDtoTypes в классе разрабатываемого генератора.

public class FteEnumTemplateModel : EnumTemplateModel
{
	...
	public IEnumerable Enums
	{
		get
		{...}
	}		
}

internal class FteCSharpGenerator : GeneratorBase
{
	...
private CodeArtifact GenerateEnum(NJsonSchema.JsonSchema schema, string typeName)
{
	FteEnumTemplateModel model = new FteEnumTemplateModel(typeName, schema, Settings);
	ITemplate template = Settings.TemplateFactory.CreateTemplate("CSharp", "Enum", model);
	return new CodeArtifact(typeName, CodeArtifactType.Enum, CodeArtifactLanguage.CSharp, CodeArtifactCategory.Contract, template);
}
}

public class FteControllerCodeGenerator : CSharpControllerGenerator
{
	...
	protected override IEnumerable GenerateDtoTypes()
	{
		FteCSharpGenerator cSharpGenerator = new FteCSharpGenerator(openApiDocument, settings.CSharpGeneratorSettings, (CSharpTypeResolver)Resolver, settings.UseDateFormatConverter);
			return cSharpGenerator.GenerateTypes();
	}

}

И последняя доработка — это изменение класса ответа на класс с двумя дженерик типами, который поставляется вместе с кодом генератора. Этот класс в нашем случае поставляется вместе с пакетом генератора и позволяет удобно передавать информацию как об успешном ответе, так и об ошибке. Решение потребовало переопределения свойства SyncResultType в используемом наследнике класса CSharpOperationModel. После чего это свойство было использовано в Nswag шаблонах клиента и серверной части.

public class FteOperationModel : CSharpOperationModel
{
	...
public new string SyncResultType
	{
		get
		{
			if (settings != null && WrapResponse && UnwrappedResultType != "FileResponse")
			{
				return settings.ResponseClass.Replace("{controller}", ControllerName) + "<" + $"{ CustomResponse}<{ UnwrappedResultType ?? "EmptyBody"}, { ErrorType}>" + ">";
			}

			return $"{CustomResponse}<{(UnwrappedResultType == "void" ? "EmptyBody" : UnwrappedResultType)}, {ErrorType}>";
		}
	}

}

public class FteClientCodeGenerator : CSharpClientGenerator
{
	...
protected override CSharpOperationModel CreateOperationModel(OpenApiOperation operation, ClientGeneratorBaseSettings settings)
	{
		return new Model.FteOperationModel(operation, (FteClientCodeGeneratorSettings)Settings, this, (CSharpTypeResolver)Resolver, ((FteClientCodeGeneratorSettings)Settings).CustomResponseType, ((FteClientCodeGeneratorSettings)Settings).ErrorType);
	}

}

Client.Class.liquid
...
{% for operation in Operations %}
{%     if GenerateOptionalParameters == false -%}
    {% template Client.Method.Documentation %}
    {% template Client.Method.Annotations %}
 {{ operation.MethodAccessModifier }} virtual System.Threading.Tasks.Task<{{ operation.SyncResultType }}> ...
...

Выводы

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

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

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

Появились вопросы или хотите поделиться своим опытом работы с генераторами? Добро пожаловать в комментарии к статье, буду рада с вами пообщаться. Спасибо за уделенное время.

© Habrahabr.ru