Привет, 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:
В качестве инструмента кодогенерации мы выбрали кодогенерацию с использованием компилятора Roslyn.
В качестве прикладной инструментальной библиотеки кодогенерации был выбран NSwag.
В качестве реализации была разработана служебная библиотека, которая использовала Source Generators и NSwag.
В качестве реализации была разработана служебная библиотека, которая использовала Incremental Generators и NSwag.
Библиотека кодогенерации на 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
НО! Возможны дополнительные временные издержки, связанные с доработкой кодогенерации — смотрите опыт команды МТС.
Заключение
По результатам проверки возможности реализации технического решения (фух!) мы пришли к следующим выводам:
Для более общего понимания и принятия решения стоит рассмотреть использование Swagger вместо NSwag в качестве прикладного инструмента кодогенерации.
Также следует рассмотреть альтернативу кодогенераторам Roslyn (например, в виде шаблонизатора T4).
К моменту написания этого материала мы еще не попробовали использовать связку Roslyn+Swagger, а вот с реализацией кодогенерации на основе шаблонизатора T4 поработали. Что из этого получилось — расскажем подробнее в следующей статье.
Спасибо за внимание!
Авторские материалы для разработчиков и архитекторов мы также публикуем в наших соцсетях – ВКонтакте и Telegram.
Комментарии (4)
dimkus
27.09.2023 05:17Использую Swagger/OpenAPI при разработке backend на Java. Использую подход API First. Что для себя усвоил. При описании paths
использую теги, чтоб методы были сгенерированы в правильные интерфейсы
всегда указываю operationId, чтоб был верно смапирован метод контроллера при имплементации
всегда описываю request/response схему, чтоб были сгенерированы DTOшки
при описании компонентов не использую точки и различные не стандартные знаки, иначе могут быть проблемы в сгенерированом коде. Например вы оперируете названием File.Response.SuccessDownloadFile , я бы использовал название FileDownloadSuccessResponse
в Java я получаю на выходе сгенерированные DTOшки и интерфейсы endpointов с дефаултной имплементацией
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
ну в принципе и всё. Т.е. для корректной работы реалезую имплементацию endpointов (методов) интерфейсов и DTOшки через мапперы далее использую модель данных в программе.
Далее в статье упоминаете о технологическом долге, что надо что-то там обновлять на нескольких сервисах. Тут думаю есть немного недопонимание. Swagger в данной ситуации является API контрактом (Interface Agreement), и у него есть версионость. Так вот, вы этим самым говорите, что такая-то версия вашего приложения общается вот таким-то языком и обозначаете версию вашего swaggera. Другие приложения, которые с вами общаются, должны поддерживать понятный вашему приложению язык общения, т.е. должны присылать запросы в соответствии API контрактом, но и ответ им будет понятен. Но если вы вносите правки в swagger, вы по сути делаете новую версию, меняете язык общения с вашим приложением, и другие должны у себя внести этим самые правки. т.е. это не является технологическим долгом, я этим вы наводите порядок и исключаете непонятные несогласованности в общении приложений между собой. Необходимо в идеале обеспечить обратную совместимость API. Чтоб при его обновлении не нужно было сразу везде всё менять. Тут уже вступает логика семантической версионности.
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 (как вариант решения) нам тоже не очень подходит. Гранулярность контрактов в итоге может оказаться слишком малой (множество мелких спецификаций), контексты предметной области или определенных бизнес-процессов будут размазаны по нескольким спецификациям, сопровождение и проектирование затруднено.
seQ
Не обязательно заставлять генератор в режиме проектирования обрабатывать изменения и создавать код на лету (параллельно перезапуская IDE, т.к. сгенерированные файлы отказывались то меняться на лету, то вовсе быть видимыми/доступными в реалтайме).
Вариант: генерировать файлы при сборке проекта вполне рабочий и разумный - пробовали?
ArchitectSimbirSoft Автор
Собственно, в этом и проблема проявлялась у нас. При сборке целевого проекта API, изменения контракта в yaml файле не применяются на генерируемые файлы в подключаемой библиотеке с артефактами кодогенерации. Причем эта проблема плавающая (не всегда проявляется).