Привет, Habr! С вами Антон, руководитель Архитектурного комитета компании SimbirSoft. Мы продолжаем цикл статей, посвященных практическому внедрению подхода Design API First в разработку наших проектов. Прежде чем погрузиться в новую тему, напомню о предыдущих материалах:

  • общий взгляд на Design API First, особенности его применения;

  • разбор практического использования подхода на примере сервера аутентификации;

  • наш опыт подготовки и планирования интеграции Design API First в наш конвейер разработки ПО;

  • ‘how-to’ для генерации моделей frontend по спецификации OpenAPI.

Настало время поделиться практическим опытом использования спецификаций OpenAPI для кодогенерации контрактов backend.

Дисклеймер:

Материал публикации в первую очередь передает практический опыт работы системных аналитиков и практикующих архитекторов при интеграции Design API First с непосредственным процессом разработки. Некоторые технические детали реализации будут описаны не полностью.

С чего мы начинали

В качестве приложения мы использовали сервис файлового хранилища. Backend написан на .Net Core 6. Реализация REST API на стороне backend была проведена разработчиками (Code First). Мы сделали так намеренно для проверки сценария перехода c Code First на Design First. Подробнее об этом описали в статье «Интеграция паттерна Design API First в конвейер разработки ПО: наш опыт».

Итак, что было сделано для поддержки подхода Design API First на стороне backend:

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

  2. В качестве прикладной инструментальной библиотеки кодогенерации был выбран NSwag.

  3. В качестве реализации была разработана служебная библиотека, которая использовала Source Generators и NSwag.

  4. В качестве реализации была разработана служебная библиотека, которая использовала Incremental Generators и NSwag.

  5. Библиотека кодогенерации на Incremental Generators была протестирована для REST API Backend.

По результатам проделанных работ мы провели оценку удобства использования кодогенераторов Roslyn для создания «заглушек» кода по спецификации OpenAPI. Но об этом расскажем в конце статьи. А сейчас приведем основные этапы проверки технологического решения.

Использование Roslyn

Мы решили проверить актуальные возможности платформы разработки .Net 6 и обнаружили, что компилятор Roslyn и его анализаторы предоставляют готовые механизмы для генерации исходных программных кодов. Для проверки этих возможностей, очевидно, было необходимо разработать непосредственно генератор для спецификации OpenAPI.

В рамках задачи генерации исходных кодов по спецификации OpenAPI мы также не стали ничего изобретать. В качестве инструмента выбрали NSwag — самое популярное решение среди наших backend-команд разработки.

Кодогенератор на основе Source Generators был разработан довольно быстро и оказался вполне работоспособным. Привожу листинг его реализации:

[Generator]
	public class Generator : ISourceGenerator
	{
    	/// <inheritdoc />
    	public void Execute(GeneratorExecutionContext context)
    	{
        	var openApiContext = OpenApiContext.CreateFromExecutionContext(context);
 
        	foreach (var openApiFileContext in openApiContext.Context)
        	{
            	var generatorStrategy = GeneratorStrategyFactory.GetStrategy(openApiFileContext);
            	var result = generatorStrategy.GenerateCode(openApiFileContext).Result;
 
            	if (result.IsSuccess)
            	{
                	context.AddSource(openApiFileContext.FileName, result.GeneratedCode);
            	}
            	else
            	{
                	context.ReportDiagnostic(Diagnostic.Create(result.Diagnostic.DiagnosticDescriptor, null, result.Diagnostic.Message));
            	}            	
        	}
 
    	}
 
    	/// <inheritdoc />
    	public void Initialize(GeneratorInitializationContext context)
    	{
//#if DEBUG
//        	if (!Debugger.IsAttached)
//        	{
//            	Debugger.Launch();
//        	}
//#endif
      	
    	}
	}

Код генератора довольно прост. Под «капотом» мы использовали стандартный вызов из библиотеки NSwag.Core: OpenApiDocument.FromFileAsync(filePath).

Настройки NSwag для генерации были следующими:

new CSharpControllerGeneratorSettings
    	{
        	ClassName = className,
        	CSharpGeneratorSettings =
        	{
            	Namespace = classesNamespace,
            	SchemaType = NJsonSchema.SchemaType.OpenApi3,
            	GenerateDefaultValues = true,
            	GenerateDataAnnotations = false
        	},
        	ControllerStyle = NSwag.CodeGeneration.CSharp.Models.CSharpControllerStyle.Abstract,
        	ControllerTarget = NSwag.CodeGeneration.CSharp.Models.CSharpControllerTarget.AspNetCore,
        	GenerateOptionalParameters = true,
        	GenerateModelValidationAttributes = true,
        	RouteNamingStrategy = NSwag.CodeGeneration.CSharp.Models.CSharpControllerRouteNamingStrategy.None,
        	UseActionResultType = true
    	};

Фактически мы использовали контекст GeneratorExecutionContext context нашего генератора при вызове ISourceGenerator.Execute для поиска спецификаций OpenAPI в заданном проекте. Затем мы передавали найденные спецификации для обработки в NSwag. На выходе получали исходники контроллеров и моделей предметной области согласно исходным спецификациям OpenAPI.

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

Наверняка те, кто с ними уже знаком, понимают, о чем речь. Всем остальным вкратце расскажу (информация общедоступна и не является темой нашей статьи): у Source Generators на Roslyn есть существенные проблемы с производительностью, что хорошо проявляется на больших решениях и при частых изменениях источников кодогенерации (в нашем случае это спецификации OpenAPI). Работать, по факту, крайне неудобно. Например, при внесении изменений в спецификации часто приходится перезагружать проект или IDE целиком для запуска перегенерации исходных кодов.

Решения от Microsoft

Для решения указанных и довольно очевидных проблем мы пошли на поводу у обещаний «мелкомягких» и уверовали в предлагаемые Incremental Generators.

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

Но обо всем по порядку.

Немного кода. Так сейчас выглядит наш IncrementalGenerator: 

[Generator]
public class OpenApiGenerator : IIncrementalGenerator
{
    static IncrementalValueProvider<Compilation> _compilationlValueProvider;
	static IncrementalValueProvider<AnalyzerConfigOptionsProvider> _analyzerConfigOptionsProvider;
	...
	public void Initialize(IncrementalGeneratorInitializationContext context)
	{
//#if DEBUG
//    	if (!Debugger.IsAttached)
//    	{
//        	Debugger.Launch();
//    	}
//#endif
    	_compilationlValueProvider = context.CompilationProvider;
    	_analyzerConfigOptionsProvider =  context.AnalyzerConfigOptionsProvider;
    	context.AdditionalTextsProvider
        	.Where(static text => IsOpenApiSchemeExtension(text.Path))
        	.Combine(context.AnalyzerConfigOptionsProvider
            	.Select(static (x, _) => bool.Parse(x.GetGlobalOption("UseCache", prefix: Name) ?? bool.FalseString)))
        	.SelectAndReportExceptions(GetCacheValue, context, Id)
        	.Combine(context.CompilationProvider.Select(static (x, _) => x.AssemblyName))
        	.SelectAndReportExceptions(GetSourceCode, context, Id)
        	.AddSource(context);
	}
	...
}

В приведенной выше реализации помимо обработки спецификаций в вызове .SelectAndReportExceptions(GetSourceCode, context, Id) мы используем также вызов  .Combine(context.CompilationProvider.Select(static (x, _) => x.AssemblyName)) для передачи в генератор соответствующего пространства имен, которое задает namespaces исходных кодов, созданных по спецификации OpenAPI.

Пример результатов работы кодогенерации в структуре проекта приведен на рисунке ниже:

Листинг спецификации OpenApi для работы с файлами:
openapi: 3.0.3
info:
  title: File REST API для Samples
  version: 0.3.0
  description: REST API для File Storage Template в формате OpenAPI v3
 
servers:
  - url: http://localhost:8080
	description: Dev Server
 
 
paths:
  /api/v3/process:
	post:
  	summary: Загрузить файл в локальное хранилище
  	description: Загружает объект в хранилище
  	operationId: uploadFile
  	tags:
    	- Upload
  	
  	requestBody:
    	content:
      	multipart/form-data:
        	schema:
          	$ref: "#/components/schemas/File.Request.UploadData"
        	encoding:
          	file:
            	style: form
  	responses:
    	'201': # файл успешно загружен
      	$ref: "#/components/responses/File.Response.SuccessUploadFile"
    	"500":
      	$ref: "#/components/responses/App.Response.Error5XX"
     	
  /api/v3/download/{bucket}/{docId}.{ext}:
	get:
  	description: Скачать файл из локального хранилища
  	operationId: downloadFileFromBucket
  	tags:
    	- Download
  	parameters:
    	- $ref: "#/components/parameters/File.Download.docId"
    	- $ref: "#/components/parameters/File.Download.bucket"
    	- $ref: "#/components/parameters/File.Download.ext"
  	responses:
    	'200': # файл успешно скачен
      	$ref: "#/components/responses/File.Response.SuccessDownloadFile"
    	"500":
      	$ref: "#/components/responses/App.Response.Error5XX"
      	
  /api/v3/download/{docId}.{ext}:
	get:
  	description: Скачать файл из локального хранилища
  	operationId: downloadFile
  	tags:
    	- Download
  	parameters:
    	- $ref: "#/components/parameters/File.Download.docId"
    	- $ref: "#/components/parameters/File.Download.ext"
    	- $ref: "#/components/parameters/File.Download.bucketQuery"
  	responses:
    	'200': # файл успешно скачен
      	$ref: "#/components/responses/File.Response.SuccessDownloadFile"
    	"500":
      	$ref: "#/components/responses/App.Response.Error5XX"
      	
  /api/v3/download/doc:
  	get:
    	tags:
      	- Download
    	summary: Скачать файл из локального хранилища
    	parameters:
      	- $ref: "#/components/parameters/File.Download.docIdQuery"
      	- $ref: "#/components/parameters/File.Download.bucketQuery"
      	- $ref: "#/components/parameters/File.Download.extQuery"
    	responses:
      	'200': # файл успешно скачен
        	$ref: "#/components/responses/File.Response.SuccessDownloadFile"
      	"500":
        	$ref: "#/components/responses/App.Response.Error5XX"
  /api/v3/file/list:
  	get:
    	tags:
      	- FileManage
    	summary: Получить список файлов, размещенных в локальном хранилище
    	parameters:
      	- $ref: "#/components/parameters/File.Manage.page"
      	- $ref: "#/components/parameters/File.Manage.pageLength"
      	- $ref: "#/components/parameters/File.Manage.isMine"
    	responses:
      	'200':
        	$ref: "#/components/responses/File.Response.FilesList"
      	'500':
        	$ref: "#/components/responses/App.Response.Error5XX"
      	
components:
  parameters:
	File.Download.docId:
  	name: docId
  	in: path
  	description: Идентификатор файла
  	required: true
  	schema:
    	type: string
    	default: ''
	File.Download.docIdQuery:
  	name: docId
  	in: query
  	description: Идентификатор файла
  	required: true
  	schema:
    	type: string
    	default: ''
	File.Download.bucket:
  	name: bucket
  	in: path
  	required: true
  	schema:
    	type: string
    	default: ''
	File.Download.bucketQuery:
  	name: bucket
  	in: query
  	required: false
  	schema:
    	type: string
    	default: ''
	File.Download.ext:
  	name: ext
  	in: path
  	required: true
  	schema:
    	type: string
    	default: ''
	File.Download.extQuery:
  	name: ext
  	in: query
  	required: true
  	schema:
    	type: string
    	default: ''
	File.Manage.page:
  	name: page
  	in: query
  	schema:
    	type: integer
    	format: int32
    	default: 0
	File.Manage.pageLength:
  	name: pageLength
  	in: query
  	schema:
    	type: integer
    	format: int32
    	default: 10
	File.Manage.isMine:
  	name: isMine
  	in: query
  	schema:
    	type: boolean
    	default: false
 
  schemas:
	File.Manage.FileItem:
    	required:
      	- createdTimestamp
      	- donwloadsCount
      	- fileType
      	- guid
      	- id
      	- name
      	- ownerId
      	- sizeKb
    	type: object
    	properties:
      	id:
        	type: integer
        	format: int32
      	ownerId:
        	type: string
      	guid:
        	type: string
      	fileType:
        	type: string
      	name:
        	type: string
      	createdTimestamp:
        	type: integer
        	format: int64
      	donwloadsCount:
        	type: integer
        	format: int32
      	sizeKb:
        	type: integer
        	format: int32
        	readOnly: true
    	additionalProperties: false
 
	App.Response.Model.Error: # RFC 7807 (Problem Details for HTTP APIs)
    	type: object
    	required:
      	- title
      	- detail
      	- request
      	- time
      	- errorTraceId
    	properties:
      	title:
        	description: Краткое описание проблемы, понятное человеку
        	type: string
        	example: "Entity not found"
      	detail:
        	description: Описание конкретно возникшей ошибки, понятное человеку
        	type: string
        	example: "Entity [User] with id = [123456] not found. You MUST use PUT to add entity instead of GET"
      	request:
        	description: Метод и URL запроса
        	type: string
        	example: "PUT /users/123456"
      	time:
        	description: Время возникновения ошибки с точностью до миллисекунд
        	type: string
        	format: date-time
        	example: "2023-01-01T12:00:00.000+02:00"
      	errorTraceId:
        	description: Идентификатор конкретного возникновения ошибки
        	type: string
        	example: "5add1be1-90ab5d42-02fa8b1f-672503f2"
	File.Request.UploadData:
  	type: object
  	properties:
    	file:
      	type: string
      	format: binary
      	
  securitySchemes:
	Bearer:
  	type: http
  	description: Enter JWT Bearer token
  	scheme: bearer
  	bearerFormat: JWT
  responses:
	File.Response.SuccessUploadFile:
  	description: Файл загружен успешно. Возвращается идентификатор загруженного файла
  	content:
        	text/plain:
          	schema:
            	type: string
        	application/json:
          	schema:
            	type: string
        	text/json:
          	schema:
            	type: string
	File.Response.SuccessDownloadFile:
  	description: Файл cкачен успешно. Возвращается файл в формате двоичных данных
  	content:
        	text/plain:
          	schema:
            	type: string
            	format: binary
        	application/json:
          	schema:
            	type: string
            	format: binary
        	text/json:
          	schema:
            	type: string
            	format: binary
	File.Response.FilesList:
  	description: Success
  	content:
      	text/plain:
        	schema:
          	type: array
          	items:
            	$ref: '#/components/schemas/File.Manage.FileItem'
      	application/json:
        	schema:
          	type: array
          	items:
            	$ref: '#/components/schemas/File.Manage.FileItem'
      	text/json:
        	schema:
          	type: array
          	items:
            	$ref: '#/components/schemas/File.Manage.FileItem'
	App.Response.Error5XX:
  	description: Внутренняя ошибка сервера
  	content:
    	application/problem+json:
      	schema:
        	$ref: "#/components/schemas/App.Response.Model.Error"
security:
  - Bearer: []

Кодогенерация для спецификации FileApi:
[System.CodeDom.Compiler.GeneratedCode("NSwag", "13.19.0.0 (NJsonSchema v10.9.0.0 (Newtonsoft.Json v13.0.3.0))")]
	public abstract class FileApiControllerBase : Microsoft.AspNetCore.Mvc.ControllerBase
	{
    	/// <summary>
    	/// Загрузить файл в локальное хранилище
    	/// </summary>
    	/// <remarks>
    	/// Загружает объект в хранилище
    	/// </remarks>
    	/// <returns>Файл загружен успешно. Возвращается идентификатор загруженного файла</returns>
    	[Microsoft.AspNetCore.Mvc.HttpPost, Microsoft.AspNetCore.Mvc.Route("api/v3/process")]
    	public abstract System.Threading.Tasks.Task<Microsoft.AspNetCore.Mvc.ActionResult<string>> UploadFile(Microsoft.AspNetCore.Http.IFormFile body = null);
    	/// <remarks>
    	/// Скачать файл из локального хранилища
    	/// </remarks>
    	/// <param name = "docId">Идентификатор файла</param>
    	/// <returns>Файл cкачен успешно. Возвращается файл в формате двоичных данных</returns>
    	[Microsoft.AspNetCore.Mvc.HttpGet, Microsoft.AspNetCore.Mvc.Route("api/v3/download/{bucket}/{docId}.{ext}")]
    	public abstract System.Threading.Tasks.Task<Microsoft.AspNetCore.Mvc.IActionResult> DownloadFileFromBucket([Microsoft.AspNetCore.Mvc.ModelBinding.BindRequired] string docId, [Microsoft.AspNetCore.Mvc.ModelBinding.BindRequired] string bucket, [Microsoft.AspNetCore.Mvc.ModelBinding.BindRequired] string ext);
    	/// <remarks>
    	/// Скачать файл из локального хранилища
    	/// </remarks>
    	/// <param name = "docId">Идентификатор файла</param>
    	/// <returns>Файл скачен успешно. Возвращается файл в формате двоичных данных</returns>
    	[Microsoft.AspNetCore.Mvc.HttpGet, Microsoft.AspNetCore.Mvc.Route("api/v3/download/{docId}.{ext}")]
    	public abstract System.Threading.Tasks.Task<Microsoft.AspNetCore.Mvc.IActionResult> DownloadFile([Microsoft.AspNetCore.Mvc.ModelBinding.BindRequired] string docId, [Microsoft.AspNetCore.Mvc.ModelBinding.BindRequired] string ext, [Microsoft.AspNetCore.Mvc.FromQuery] string bucket = "");
    	/// <summary>
    	/// Скачать файл из локального хранилища
    	/// </summary>
    	/// <param name = "docId">Идентификатор файла</param>
    	/// <returns>Файл скачен успешно. Возвращается файл в формате двоичных данных</returns>
    	[Microsoft.AspNetCore.Mvc.HttpGet, Microsoft.AspNetCore.Mvc.Route("api/v3/download/doc")]
    	public abstract System.Threading.Tasks.Task<Microsoft.AspNetCore.Mvc.IActionResult> Doc([Microsoft.AspNetCore.Mvc.FromQuery][Microsoft.AspNetCore.Mvc.ModelBinding.BindRequired] string docId, [Microsoft.AspNetCore.Mvc.FromQuery][Microsoft.AspNetCore.Mvc.ModelBinding.BindRequired] string ext, [Microsoft.AspNetCore.Mvc.FromQuery] string bucket = "");
    	/// <summary>
    	/// Получить список файлов, размещенных в локальном хранилище
    	/// </summary>
    	/// <returns>Success</returns>
    	[Microsoft.AspNetCore.Mvc.HttpGet, Microsoft.AspNetCore.Mvc.Route("api/v3/file/list")]
    	public abstract System.Threading.Tasks.Task<Microsoft.AspNetCore.Mvc.ActionResult<System.Collections.Generic.ICollection<FileItem>>> List([Microsoft.AspNetCore.Mvc.FromQuery] int? page = 0, [Microsoft.AspNetCore.Mvc.FromQuery] int? pageLength = 10, [Microsoft.AspNetCore.Mvc.FromQuery] bool? isMine = false);
	}
 
	[System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "13.19.0.0 (NJsonSchema v10.9.0.0 (Newtonsoft.Json v13.0.3.0))")]
	public partial class FileItem
	{
    	[Newtonsoft.Json.JsonProperty("id", Required = Newtonsoft.Json.Required.Always)]
    	public int Id { get; set; }
 
    	[Newtonsoft.Json.JsonProperty("ownerId", Required = Newtonsoft.Json.Required.Always)]
    	public string OwnerId { get; set; }
 
    	[Newtonsoft.Json.JsonProperty("guid", Required = Newtonsoft.Json.Required.Always)]
    	public string Guid { get; set; }
 
    	[Newtonsoft.Json.JsonProperty("fileType", Required = Newtonsoft.Json.Required.Always)]
    	public string FileType { get; set; }
 
    	[Newtonsoft.Json.JsonProperty("name", Required = Newtonsoft.Json.Required.Always)]
    	public string Name { get; set; }
 
    	[Newtonsoft.Json.JsonProperty("createdTimestamp", Required = Newtonsoft.Json.Required.Always)]
    	public long CreatedTimestamp { get; set; }
 
    	[Newtonsoft.Json.JsonProperty("donwloadsCount", Required = Newtonsoft.Json.Required.Always)]
    	public int DonwloadsCount { get; set; }
 
    	[Newtonsoft.Json.JsonProperty("sizeKb", Required = Newtonsoft.Json.Required.Always)]
    	public int SizeKb { get; set; }
	}
 
	[System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "13.19.0.0 (NJsonSchema v10.9.0.0 (Newtonsoft.Json v13.0.3.0))")]
	public partial class Error
	{
    	/// <summary>
    	/// Краткое описание проблемы, понятное человеку
    	/// </summary>
    	[Newtonsoft.Json.JsonProperty("title", Required = Newtonsoft.Json.Required.Always)]
    	public string Title { get; set; }
 
    	/// <summary>
    	/// Описание конкретно возникшей ошибки, понятное человеку
    	/// </summary>
    	[Newtonsoft.Json.JsonProperty("detail", Required = Newtonsoft.Json.Required.Always)]
    	public string Detail { get; set; }
 
    	/// <summary>
    	/// Метод и URL запроса
    	/// </summary>
    	[Newtonsoft.Json.JsonProperty("request", Required = Newtonsoft.Json.Required.Always)]
    	public string Request { get; set; }
 
    	/// <summary>
    	/// Время возникновения ошибки с точностью до миллисекунд
    	/// </summary>
    	[Newtonsoft.Json.JsonProperty("time", Required = Newtonsoft.Json.Required.Always)]
    	public System.DateTimeOffset Time { get; set; }
 
    	/// <summary>
    	/// Идентификатор конкретного возникновения ошибки
    	/// </summary>
    	[Newtonsoft.Json.JsonProperty("errorTraceId", Required = Newtonsoft.Json.Required.Always)]
    	public string ErrorTraceId { get; set; }
 
    	private System.Collections.Generic.IDictionary<string, object> _additionalProperties;
    	[Newtonsoft.Json.JsonExtensionData]
    	public System.Collections.Generic.IDictionary<string, object> AdditionalProperties
    	{
        	get
        	{
            	return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary<string, object>());
        	}
 
        	set
        	{
            	_additionalProperties = value;
        	}
    	}
	}
 
	[System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "13.19.0.0 (NJsonSchema v10.9.0.0 (Newtonsoft.Json v13.0.3.0))")]
	public partial class UploadData
	{
    	[Newtonsoft.Json.JsonProperty("file", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
    	public byte[] File { get; set; }
 
    	private System.Collections.Generic.IDictionary<string, object> _additionalProperties;
    	[Newtonsoft.Json.JsonExtensionData]
    	public System.Collections.Generic.IDictionary<string, object> AdditionalProperties
    	{
        	get
        	{
            	return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary<string, object>());
        	}
 
        	set
        	{
            	_additionalProperties = value;
        	}
    	}
	}
 
	[System.CodeDom.Compiler.GeneratedCode("NSwag", "13.19.0.0 (NJsonSchema v10.9.0.0 (Newtonsoft.Json v13.0.3.0))")]
	public partial class FileResponse : System.IDisposable
	{
    	private System.IDisposable _client;
    	private System.IDisposable _response;
    	public int StatusCode { get; private set; }
    	public System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> Headers { get; private set; }
    	public System.IO.Stream Stream { get; private set; }
 
    	public bool IsPartial
    	{
        	get
        	{
            	return StatusCode == 206;
        	}
    	}
 
    	public FileResponse(int statusCode, System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> headers, System.IO.Stream stream, System.IDisposable client, System.IDisposable response)
    	{
        	StatusCode = statusCode;
        	Headers = headers;
        	Stream = stream;
        	_client = client;
        	_response = response;
    	}
 
    	public void Dispose()
    	{
        	Stream.Dispose();
        	if (_response != null)
            	_response.Dispose();
        	if (_client != null)
            	_client.Dispose();
    	}
	}

Что касается проблем с производительностью кодогенерации на основе IncrementalGenerators, в нашем случае проблемы полностью не исчезли, но работать стало возможно. Зависания IDE не наблюдалось, однако изменения в спецификации OpenAPI все так же не всегда отображались в генерируемых кодах без перезагрузки проекта IDE. На этом этапе также выяснились базовые ограничения кодогенераторов Roslyn. Они могут быть созданы только в проектах, у которых в качестве целевого фреймворка указан netstandard2.0. Изначально мы не придали значения этому ограничению, так как все работало и исходные коды на языке C# генерировались по заданным спецификациям OpenAPI.

Главные проблемы и неудобства (впрочем, не критичные) возникли при использовании результатов кодогенерации непосредственно в проектах (projects) конечных API. Чтобы вам было легче представить ситуацию и состояние решения к этому моменту, приведу простую схему backend в части использования моделей кодогенерации:

С какими проблемами мы столкнулись 

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

Проблема зависимостей

Сгенерированные модели из проекта OpenAPI spec Project отказывались «подхватываться» при их использовании в FileUploadAPI, FileDownloadAPI, FileManageAPI. Причем ошибка (error CS0246: The type or namespace name ‘...’ could not be found) возникала временно, в момент сборки. В остальное время ссылки на модели из OpenAPI specs Project доступны и ошибки в IDE не отображаются. Проблема связана с форматом их подключения. При подключении служебной библиотеки кодогенерации в виде nuget-пакета ошибка при сборке API не возникает. Мы были ограничены во времени для дальнейшего изучения и решения проблемы зависимостей. Если вы сталкивались с таким — расскажите в комментариях, какое решение вам удалось найти.

Проблема технического долга

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

Проблема доработок 

Ряд атрибутов из OpenAPI-спецификации не добавляется в классы кодогенерации.

Как мы решали проблемы 

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

1. Упаковка OpenAPI spec Project в nuget-пакет

НО! Только как временное решение для возможности проверки всего процесса. Поэтому необходимо время на понимание проблемы.

2. Разделение базовой спецификации на отдельные (посервисно)

НО! Это неудобно с точки зрения целостности доменной области. Есть необходимость нарезать спецификации на уровне реализации сервисов.

3. Настройка или доработка NSwag

НО! Возможны дополнительные временные издержки, связанные с доработкой кодогенерации — смотрите опыт команды МТС.

Заключение

По результатам проверки возможности реализации технического решения (фух!) мы пришли к следующим выводам:

  1. Для более общего понимания и принятия решения стоит рассмотреть использование Swagger вместо NSwag в качестве прикладного инструмента кодогенерации.

  2. Также следует рассмотреть альтернативу кодогенераторам Roslyn (например, в виде шаблонизатора T4).

К моменту написания этого материала мы еще не попробовали использовать связку Roslyn+Swagger, а вот с реализацией кодогенерации на основе шаблонизатора T4 поработали. Что из этого получилось — расскажем подробнее в следующей статье.

Спасибо за внимание!

Авторские материалы для разработчиков и архитекторов мы также публикуем в наших соцсетях – ВКонтакте и Telegram.

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


  1. seQ
    27.09.2023 05:17

    Не обязательно заставлять генератор в режиме проектирования обрабатывать изменения и создавать код на лету (параллельно перезапуская IDE, т.к. сгенерированные файлы отказывались то меняться на лету, то вовсе быть видимыми/доступными в реалтайме).

    Вариант: генерировать файлы при сборке проекта вполне рабочий и разумный - пробовали?


    1. ArchitectSimbirSoft Автор
      27.09.2023 05:17

      Собственно, в этом и проблема проявлялась у нас. При сборке целевого проекта API, изменения контракта в yaml файле не применяются на генерируемые файлы в подключаемой библиотеке с артефактами кодогенерации. Причем эта проблема плавающая (не всегда проявляется).


  1. dimkus
    27.09.2023 05:17

    Использую Swagger/OpenAPI при разработке backend на Java. Использую подход API First. Что для себя усвоил. При описании paths

    1. использую теги, чтоб методы были сгенерированы в правильные интерфейсы

    2. всегда указываю operationId, чтоб был верно смапирован метод контроллера при имплементации

    3. всегда описываю request/response схему, чтоб были сгенерированы DTOшки

    4. при описании компонентов не использую точки и различные не стандартные знаки, иначе могут быть проблемы в сгенерированом коде. Например вы оперируете названием File.Response.SuccessDownloadFile , я бы использовал название FileDownloadSuccessResponse

    в Java я получаю на выходе сгенерированные DTOшки и интерфейсы endpointов с дефаултной имплементацией return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    ну в принципе и всё. Т.е. для корректной работы реалезую имплементацию endpointов (методов) интерфейсов и DTOшки через мапперы далее использую модель данных в программе.

    Далее в статье упоминаете о технологическом долге, что надо что-то там обновлять на нескольких сервисах. Тут думаю есть немного недопонимание. Swagger в данной ситуации является API контрактом (Interface Agreement), и у него есть версионость. Так вот, вы этим самым говорите, что такая-то версия вашего приложения общается вот таким-то языком и обозначаете версию вашего swaggera. Другие приложения, которые с вами общаются, должны поддерживать понятный вашему приложению язык общения, т.е. должны присылать запросы в соответствии API контрактом, но и ответ им будет понятен. Но если вы вносите правки в swagger, вы по сути делаете новую версию, меняете язык общения с вашим приложением, и другие должны у себя внести этим самые правки. т.е. это не является технологическим долгом, я этим вы наводите порядок и исключаете непонятные несогласованности в общении приложений между собой. Необходимо в идеале обеспечить обратную совместимость API. Чтоб при его обновлении не нужно было сразу везде всё менять. Тут уже вступает логика семантической версионности.


    1. ArchitectSimbirSoft Автор
      27.09.2023 05:17

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


      Для понимания ситуации ниже приведем листинг одного из контроллеров. В нем используются только три метода из спецификации OpenAPI (Swagger), а оставшаяся часть этой спецификации используется в других сервисах.

      Поэтому нам необходимо пометить неиспользуемую реализацию заглушек атрибутом [NonAction], чтобы закрыть к ним доступ для этого контроллера (сервиса).

      /// <summary>
      /// API для скачивания файлов
      /// </summary>
      [ApiVersion("3.0")]
      [Route("api/v{version:apiVersion}/[controller]")]
      [ApiController]
      public class DownloadController : FileApiControllerBase
      {
          private static readonly string _defaultBucket = Guid.Empty.ToString("N");
          private readonly IStorageClient _storageClient;
          private readonly IGrpcFileClientWrapper _grpcClientWrapper;
      
          public DownloadController(IStorageClient storageClient, IGrpcFileClientWrapper grpcClientWrapper)
          {
              _storageClient = storageClient;
              _grpcClientWrapper = grpcClientWrapper;
          }
      
          /// <summary>
          /// Скачать файл из локального хранилища
          /// </summary>
          /// <param name="docId">Идентификатор файла</param>
          /// <param name="ext">Расширение файла</param>
          /// <param name="bucket">Контейнер файла</param>
          /// <returns>Файл для скачивания</returns>
          [MapToApiVersion("3.0")]
          [HttpGet("{docId}.{ext}")]
          [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileStreamResult))]
          [ProducesResponseType(StatusCodes.Status500InternalServerError, Type = typeof(Error))]
          public override async Task<IActionResult> DownloadFile(string docId, string ext, string bucket = "")
          {
               return  await Task.FromResult(RedirectToAction("DownloadFileFromBucket", new { docId = docId, ext = ext, bucket = bucket }));
          }
      
          /// <summary>
          /// Скачать файл из локального хранилища
          /// </summary>
          /// <param name="docId">Идентификатор файла</param>
          /// <param name="ext">Расширение файла</param>
          /// <param name="bucket">Контейнер файла</param>
          /// <returns>Файл для скачивания</returns>
          [MapToApiVersion("3.0")]
          [HttpGet("{bucket}/{docId}.{ext}")]
          [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileStreamResult))]
          [ProducesResponseType(StatusCodes.Status500InternalServerError, Type = typeof(Error))]
          public override  async Task<IActionResult> Doc(string docId, string ext, string bucket = "")
          {
              return await Task.FromResult(RedirectToAction("DownloadFileFromBucket", new { docId = docId, ext = ext, bucket = bucket }));
          }
      
          /// <summary>
          /// Скачать файл из локального хранилища
          /// </summary>
          /// <param name="docId">Идентификатор файла</param>
          /// <param name="ext">Расширение файла</param>
          /// <param name="bucket">Контейнер файла</param>
          /// <returns>Файл для скачивания</returns>
          [MapToApiVersion("3.0")]
          [HttpGet("doc")]
          [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileStreamResult))]
          [ProducesResponseType(StatusCodes.Status500InternalServerError, Type = typeof(Error))]
      
          //  [ResponseCache(Location = ResponseCacheLocation.Any, Duration = 16800, VaryByQueryKeys = new string[] { "bucket", "docId" })]
          public override async Task<IActionResult> DownloadFileFromBucket(string docId, string? bucket = "", string ext="")
          {
              if (string.IsNullOrEmpty(bucket))
                  bucket = _defaultBucket;
              if (docId.Contains('-'))
                  docId = docId.Replace("-", string.Empty);
      
              var prefix = new Uri(Path.GetDirectoryName(Assembly.GetExecutingAssembly().GetName().CodeBase)).LocalPath;
              var path = Path.Combine(prefix, "doc", bucket, docId);
              Task task = null;
              Stream stream = new MemoryStream();
      
              if (System.IO.File.Exists(path))
              {
                  stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
              }
              else
              {
                  var tryLoad = false;
                  try
                  {
                      var byteArray = await _storageClient.DownloadFileAsync(docId);
                      stream.Write(byteArray, 0, byteArray.Length);
                      stream.Seek(0, SeekOrigin.Begin);
                      tryLoad = byteArray.Length > 0;
      #pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
                      Task.Run(() => {
                          try
                          {
                              var dir = Path.GetDirectoryName(path);
                              if (!Directory.Exists(dir))
                              {
                                  Directory.CreateDirectory(dir);
                                  System.IO.File.WriteAllBytes(path, byteArray);
                              }
                          }
                          catch (Exception ex)
                          {
                              //todo log
                          }
                      });
      #pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
                  }
                  catch (Exception ex)
                  {
                      //todo log
                  }
                  if (!tryLoad)
                  {
                      //check tempStorage
                  }
                  if (stream.Length == 0)
                  {
                      stream.Close();
                      System.IO.File.Delete(path);
                  }
              }
      
      #pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
              _grpcClientWrapper.RegisterDownloadFileAsync(docId, this.GetHeader("UserId"));
      #pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
            
              var contentType =string.IsNullOrEmpty(ext)? "application/octet-stream" : ext.Contains('/')? ext: GetMimeFromExt(ext);
              //todo replace stubs to real content type
              return File(stream, contentType);
          }
      
          private static string GetMimeFromExt(string ext)
          {
              //todo switch with predefinded mimes
              return $"application/{ext}";
          }
      
          [NonAction]
          public override Task<ActionResult<string>> UploadFile(IFormFile body = null)
          {
              throw new NotImplementedException();
          }
          [NonAction]
          public override Task<ActionResult<ICollection<FileItem>>> List([FromQuery] int? page = 0, [FromQuery] int? pageLength = 10, [FromQuery] bool? isMine = false)
          {
              throw new NotImplementedException();
          }
      }

      То есть при выпуске новой версии, в случае разделения реализации (implementation) между разными контроллерами или сервисами API, нам требуется исключить неиспользуемые определения спецификации (paths), которые предоставляются кодогенерацией, для каждого конкретного случая частичного использования. Это и определяет наш технический долг, который не возникает при посервисной реализации спецификаций OpenAPI.

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