diff --git a/.gitignore b/.gitignore index c12adce325..1dac93310b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ [Bb]in/ .vs/ .idea* +BenchmarkDotNet.Artifacts*/ coverage coverage.* node_modules/ diff --git a/Directory.Packages.props b/Directory.Packages.props index a3b73e2eb2..1e60a82420 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,7 @@ + diff --git a/Swashbuckle.AspNetCore.sln b/Swashbuckle.AspNetCore.sln index 98b1fc2db9..db6317604f 100644 --- a/Swashbuckle.AspNetCore.sln +++ b/Swashbuckle.AspNetCore.sln @@ -10,6 +10,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig .gitattributes = .gitattributes .gitignore = .gitignore + benchmark.ps1 = benchmark.ps1 CONTRIBUTING.md = CONTRIBUTING.md Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets @@ -117,6 +118,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinimalAppWithHostedService EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MvcWithNullable", "test\WebSites\MvcWithNullable\MvcWithNullable.csproj", "{F88B6070-BE3C-45F9-978C-2ECBA9518C24}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{0C7326F1-F731-4CF9-8A98-80F39541D28F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Swashbuckle.AspNetCore.Benchmarks", "perf\Swashbuckle.AspNetCore.Benchmarks\Swashbuckle.AspNetCore.Benchmarks.csproj", "{28F5840B-AE87-46DB-9D83-C3C8C77C6A13}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -271,6 +276,10 @@ Global {F88B6070-BE3C-45F9-978C-2ECBA9518C24}.Debug|Any CPU.Build.0 = Debug|Any CPU {F88B6070-BE3C-45F9-978C-2ECBA9518C24}.Release|Any CPU.ActiveCfg = Release|Any CPU {F88B6070-BE3C-45F9-978C-2ECBA9518C24}.Release|Any CPU.Build.0 = Release|Any CPU + {28F5840B-AE87-46DB-9D83-C3C8C77C6A13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28F5840B-AE87-46DB-9D83-C3C8C77C6A13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28F5840B-AE87-46DB-9D83-C3C8C77C6A13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28F5840B-AE87-46DB-9D83-C3C8C77C6A13}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -316,6 +325,7 @@ Global {07BB09CF-6C6F-4D00-A459-93586345C921} = {DB3F57FC-1472-4F03-B551-43394DA3C5EB} {D06A88E8-6F42-4F40-943A-E266C0AE6EC9} = {DB3F57FC-1472-4F03-B551-43394DA3C5EB} {F88B6070-BE3C-45F9-978C-2ECBA9518C24} = {DB3F57FC-1472-4F03-B551-43394DA3C5EB} + {28F5840B-AE87-46DB-9D83-C3C8C77C6A13} = {0C7326F1-F731-4CF9-8A98-80F39541D28F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {36FC6A67-247D-4149-8EDD-79FFD1A75F51} diff --git a/benchmark.ps1 b/benchmark.ps1 new file mode 100644 index 0000000000..bba5f35365 --- /dev/null +++ b/benchmark.ps1 @@ -0,0 +1,28 @@ +#! /usr/bin/env pwsh + +#Requires -PSEdition Core +#Requires -Version 7 + +param( + [Parameter(Mandatory = $false)][string] $Framework = "net8.0", + [Parameter(Mandatory = $false)][string] $Job = "" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$benchmarks = (Join-Path $PSScriptRoot "perf" "Swashbuckle.AspNetCore.Benchmarks" "Swashbuckle.AspNetCore.Benchmarks.csproj") + +$additionalArgs = @() + +if (-Not [string]::IsNullOrEmpty($Job)) { + $additionalArgs += "--job" + $additionalArgs += $Job +} + +if (-Not [string]::IsNullOrEmpty(${env:GITHUB_SHA})) { + $additionalArgs += "--exporters" + $additionalArgs += "json" +} + +dotnet run --project $benchmarks --configuration "Release" --framework $Framework -- $additionalArgs --% --filter * diff --git a/perf/Swashbuckle.AspNetCore.Benchmarks/Program.cs b/perf/Swashbuckle.AspNetCore.Benchmarks/Program.cs new file mode 100644 index 0000000000..a71f913cbb --- /dev/null +++ b/perf/Swashbuckle.AspNetCore.Benchmarks/Program.cs @@ -0,0 +1,4 @@ +using BenchmarkDotNet.Running; + +var switcher = new BenchmarkSwitcher(typeof(Program).Assembly); +switcher.Run(args); diff --git a/perf/Swashbuckle.AspNetCore.Benchmarks/Swashbuckle.AspNetCore.Benchmarks.csproj b/perf/Swashbuckle.AspNetCore.Benchmarks/Swashbuckle.AspNetCore.Benchmarks.csproj new file mode 100644 index 0000000000..2a844fa0aa --- /dev/null +++ b/perf/Swashbuckle.AspNetCore.Benchmarks/Swashbuckle.AspNetCore.Benchmarks.csproj @@ -0,0 +1,16 @@ + + + false + Exe + net8.0 + + + + + + + + + + + diff --git a/perf/Swashbuckle.AspNetCore.Benchmarks/XmlCommentsBenchmark.cs b/perf/Swashbuckle.AspNetCore.Benchmarks/XmlCommentsBenchmark.cs new file mode 100644 index 0000000000..380b55d7bc --- /dev/null +++ b/perf/Swashbuckle.AspNetCore.Benchmarks/XmlCommentsBenchmark.cs @@ -0,0 +1,155 @@ +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Xml; +using System.Xml.XPath; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Swashbuckle.AspNetCore.SwaggerGen.Test; +using Swashbuckle.AspNetCore.TestSupport; + +namespace Swashbuckle.AspNetCore.Benchmarks; + +[MemoryDiagnoser] +public class XmlCommentsBenchmark +{ + private XmlCommentsDocumentFilter _documentFilter; + private OpenApiDocument _document; + private DocumentFilterContext _documentFilterContext; + + private XmlCommentsOperationFilter _operationFilter; + private OpenApiOperation _operation; + private OperationFilterContext _operationFilterContext; + + private XmlCommentsParameterFilter _parameterFilter; + private OpenApiParameter _parameter; + private ParameterFilterContext _parameterFilterContext; + + private XmlCommentsRequestBodyFilter _requestBodyFilter; + private OpenApiRequestBody _requestBody; + private RequestBodyFilterContext _requestBodyFilterContext; + + private const int AddMemberCount = 10_000; + + [GlobalSetup] + public void Setup() + { + // Load XML + XmlDocument xmlDocument; + using (var xmlComments = File.OpenText($"{typeof(FakeControllerWithXmlComments).Assembly.GetName().Name}.xml")) + { + xmlDocument = new XmlDocument(); + xmlDocument.Load(xmlComments); + } + + // Append dummy members to XML document + XPathNavigator navigator = xmlDocument.CreateNavigator()!; + navigator.MoveToRoot(); + navigator.MoveToChild("doc", string.Empty); + navigator.MoveToChild("members", string.Empty); + + for (int i = 0; i < AddMemberCount; i++) + { + navigator.PrependChild(@$""); + } + + using var xmlStream = new MemoryStream(); + xmlDocument.Save(xmlStream); + xmlStream.Seek(0, SeekOrigin.Begin); + var xPathDocument = new XPathDocument(xmlStream); + + // Document + _document = new OpenApiDocument(); + _documentFilterContext = new DocumentFilterContext( + [ + new ApiDescription + { + ActionDescriptor = new ControllerActionDescriptor + { + ControllerTypeInfo = typeof(FakeControllerWithXmlComments).GetTypeInfo(), + ControllerName = nameof(FakeControllerWithXmlComments), + }, + }, + new ApiDescription + { + ActionDescriptor = new ControllerActionDescriptor + { + ControllerTypeInfo = typeof(FakeControllerWithXmlComments).GetTypeInfo(), + ControllerName = nameof(FakeControllerWithXmlComments), + }, + }, + ], + null, + null); + + _documentFilter = new XmlCommentsDocumentFilter(xPathDocument); + + // Operation + _operation = new OpenApiOperation(); + var methodInfo = typeof(FakeConstructedControllerWithXmlComments) + .GetMethod(nameof(FakeConstructedControllerWithXmlComments.ActionWithSummaryAndResponseTags)); + + var apiDescription = ApiDescriptionFactory.Create(methodInfo: methodInfo, groupName: "v1", httpMethod: "POST", relativePath: "resource"); + _operationFilterContext = new OperationFilterContext(apiDescription, null, null, methodInfo); + _operationFilter = new XmlCommentsOperationFilter(xPathDocument); + + // Parameter + _parameter = new() + { + Schema = new() + { + Type = "string", + Description = "schema-level description", + }, + }; + + var propertyInfo = typeof(XmlAnnotatedType).GetProperty(nameof(XmlAnnotatedType.StringProperty)); + var apiParameterDescription = new ApiParameterDescription(); + _parameterFilterContext = new ParameterFilterContext(apiParameterDescription, null, null, propertyInfo: propertyInfo); + _parameterFilter = new XmlCommentsParameterFilter(xPathDocument); + + // Request Body + _requestBody = new OpenApiRequestBody + { + Content = new Dictionary() + { + ["application/json"] = new() + { + Schema = new() + { + Type = "string", + }, + }, + }, + }; + var parameterInfo = typeof(FakeControllerWithXmlComments) + .GetMethod(nameof(FakeControllerWithXmlComments.ActionWithParamTags))! + .GetParameters()[0]; + + var bodyParameterDescription = new ApiParameterDescription + { + ParameterDescriptor = new ControllerParameterDescriptor { ParameterInfo = parameterInfo } + }; + _requestBodyFilterContext = new RequestBodyFilterContext(bodyParameterDescription, null, null, null); + _requestBodyFilter = new XmlCommentsRequestBodyFilter(xPathDocument); + } + + [Benchmark] + public void Document() + => _documentFilter.Apply(_document, _documentFilterContext); + + [Benchmark] + public void Operation() + => _operationFilter.Apply(_operation, _operationFilterContext); + + [Benchmark] + public void Parameter() + => _parameterFilter.Apply(_parameter, _parameterFilterContext); + + [Benchmark] + public void RequestBody() + => _requestBodyFilter.Apply(_requestBody, _requestBodyFilterContext); +}