Уже стало традицией, что выход очередной версии .NET становится поводом для выпуска новой версии WebMarkupMin. Обычно подобные выпуски WebMarkupMin сопровождаются обновлением расширений для ASP.NET Core и этот раз не стал исключением. Многие ожидали, что с появлением .NET 9 появится новый модуль WebMarkupMin.AspNetCore9, но этого не произошло. В этой статье я расскажу о причинах такого решения. Кроме того, в этот раз в ASP.NET Core-расширениях довольно много изменений, а поскольку для большинства разработчиков основным источником информации об этих расширениях является уже немного устаревшая статья Эндрю Лока «HTML minification using WebMarkupMin in ASP.NET Core», то я постараюсь разъяснить некоторые неочевидные моменты.
Реорганизация ASP.NET Core-расширений
В .NET 9 нет каких-либо серьезных изменений, которые могли бы затронуть ядро WebMarkupMin. ASP.NET Core 9 тоже не преподнес каких-либо сюрпризов и можно было бы просто создать модуль WebMarkupMin.AspNetCore9 (в данном случае, под модулем я подразумеваю NuGet-пакет), нацеленный на .NET 9 и имеющий другое пространство имен. Похожая ситуация была и на момент выхода .NET 8. Стало совершенно ясно, что это тупиковый путь.
К этому моменту уже существовало 7 модулей для каждой мажорной версии ASP.NET Core, и создавать 8-й казалось просто бессмысленным. Такое большое количество модулей привело к разбуханию кодовой базы WebMarkupMin, которую с каждым годом становилось все тяжелее поддерживать.
Другим минусом такого подхода было, то что пользователи при выходе новой версии ASP.NET Core просто забывали переходить на новый модуль. Из-за этого сохранялась большая доля пользователей, использующих модуль WebMarkupMin.AspNetCore3.
Летом 2015 года, когда я начал работу над 2-й версией WebMarkupMin, такой подход казался оптимальным. На тот момент, новая версия ASP.NET имела номер 5, и никто не мог даже предположить, что со временем мажорные версии ASP.NET будут выпускаться каждый год. Первые проблемы начались ближе к официальному релизу новой версии ASP.NET, когда ее переименовали в ASP.NET Core. Тогда пришлось вместо модуля WebMarkupMin.AspNet5 создавать модуль WebMarkupMin.AspNetCore1. Примерно через год вышел .NET Core 2.0 и из-за изменений, вызванных появлением .NET Standard 2.0, мне пришлось создать модуль WebMarkupMin.AspNetCore2. В 2019 году в ASP.NET Core 3.0 серьезно изменилось API и это привело к необходимости выпуска модуля WebMarkupMin.AspNetCore3. В 2020 году, несмотря на громкие заявления Microsoft, ASP.NET Core 5 не привнес каких-либо серьезных изменений, но все же я выпустил модуль WebMarkupMin.AspNetCore5. Через год в 6-й версии все же появились небольшие изменения, затрагивающие WebMarkupMin, и выпуск модуля WebMarkupMin.AspNetCore6 был оправдан. Потом наступила стабилизация ASP.NET Core и, в принципе, следующие 2 новых модуля можно было не выпускать.
Поэтому я принял решение оставить только модули, содержащие серьезные изменения:
Как бы это не было удивительно, но первые 2 модуля до сих пор продолжают использоваться небольшим количеством пользователей. Но вполне возможно, что через год мне придется отказаться от поддержки .NET Standard 1.X и модуля WebMarkupMin.AspNetCore1. Вообще, я стараюсь как можно дольше поддерживать старые версии .NET в своих продуктах. Тем более, что сейчас нужно учитывать и отечественную специфику: некоторые крупные российские компании стараются пользоваться решениями Microsoft, выпущенными до 2022 года, т.е. они по-прежнему используют .NET 6.
К сожалению, в данном случае без создания нового модуля обойтись нельзя, и поэтому я создал модуль WebMarkupMin.AspNetCoreLatest, который ориентирован только на актуальные версии .NET (на данный момент это 8-я и 9-я версии). Этот модуль будет обновляться по мере выхода новых и снятия с поддержки старых версий .NET.
Для пользователей устаревших модулей, которые не могут перейти на последние версии .NET, я рекомендую следующую стратегию замены модулей:
WebMarkupMin.AspNetCore5 → WebMarkupMin.AspNetCore3
WebMarkupMin.AspNetCore7 → WebMarkupMin.AspNetCore6
WebMarkupMin.AspNetCore8 → WebMarkupMin.AspNetCoreLatest
Более тонкая настройка HTTP-сжатия
Пять лет назад я попросил разработчиков .NET добавить в конструктор класса BrotliStream
возможность указывать 12 уровней сжатия вместо 3 уровней, доступных на тот момент (значения перечисления CompressionLevel
: NoCompression
, Fastest
и Optimal
). Стоит отметить, что тогда существовал хак: можно было привести целочисленное значение уровня сжатия к типу перечисления CompressionLevel
и передать его в конструктор класса. Этот хак работал только для 10 оригинальных уровней сжатия, потому что значение 0 было закреплено за значением перечисления Optimal
(11-й уровень), а 2 за NoCompression
(0-й уровень). В .NET 6 появилось новое значение перечисления - SmallestSize
, которое также, как и Optimal
соответствовало 11-му уровню сжатия, что еще больше осложнило использование этого хака. В .NET 7 эта лазейка была закрыта. В качестве примера реализации всех этих ухищрений вы можете посмотреть исходный код старой версии модуля WebMarkupMin.AspNet.Brotli.
В .NET 9 эта возможность была наконец-то реализована. В пространстве имен System.IO.Compression
появился класс BrotliCompressionOptions
, экземпляр которого можно передавать в конструктор класса BrotliStream
. Класс BrotliCompressionOptions
содержит целочисленное свойство Quality
, с помощью которого можно указывать оригинальный уровень сжатия (с 0-го по 11-й уровень).
Кроме того, похожая возможность появилась и для классов GZipStream
и DeflateStream
. Теперь их конструкторы могут принимать экземпляр класса ZLibCompressionOptions
. Класс ZLibCompressionOptions
содержит целочисленное свойство CompressionLevel
, с помощью которого можно указывать оригинальный уровень сжатия (с 0-го по 9-й уровень). Также это свойство может принимать значение равное -1, что приводит к установке уровня сжатия по умолчанию (6-й уровень).
Все эти нововведения потребовали внесения изменений в модули WebMarkupMin.AspNet.Common и WebMarkupMin.AspNet.Brotli.
В модуле WebMarkupMin.AspNet.Common в классы GZipCompressionSettings
, DeflateCompressionSettings
и BuiltInBrotliCompressionSettings
было добавлено целочисленное свойство AlternativeLevel
(доступно только в сборке для .NET 9). Это свойство выполняет те же самые функции, что и свойства Quality
и CompressionLevel
в оригинальных классах *CompressionOptions
, за исключением того, что оно не может принимать значение равное -1. В ASP.NET Core 9 при настройке WebMarkupMinMiddleware
вы можете использовать как значения из перечисления CompressionLevel
:
services.AddWebMarkupMin(…)
…
.AddHttpCompression(options =>
{
options.CompressorFactories = new List<ICompressorFactory>
{
new BuiltInBrotliCompressorFactory(new BuiltInBrotliCompressionSettings
{
Level = CompressionLevel.Optimal
}),
new DeflateCompressorFactory(new DeflateCompressionSettings
{
Level = CompressionLevel.Optimal
}),
new GZipCompressorFactory(new GZipCompressionSettings
{
Level = CompressionLevel.Optimal
})
};
})
;
Так и целочисленные значения уровней сжатия:
services.AddWebMarkupMin(…)
…
.AddHttpCompression(options =>
{
options.CompressorFactories = new List<ICompressorFactory>
{
new BuiltInBrotliCompressorFactory(new BuiltInBrotliCompressionSettings
{
AlternativeLevel = 4
}),
new DeflateCompressorFactory(new DeflateCompressionSettings
{
AlternativeLevel = 6
}),
new GZipCompressorFactory(new GZipCompressionSettings
{
AlternativeLevel = 6
})
};
})
;
Хотя я обычно не рекомендую использовать модуль WebMarkupMin.AspNet.Brotli в веб-приложениях, ориентированных на ASP.NET Core 3.X и выше, потому что сейчас есть лучшая альтернатива в виде класса BuiltInBrotliCompressor
из модуля WebMarkupMin.AspNet.Common. Но все же я решил добавить в этот модуль поддержку .NET 9. Поддержка новой версии .NET не вносит каких-либо заметных изменений в настройки данного модуля. По-прежнему для настройки уровня сжатия используется целочисленное свойство Level
класса BrotliCompressionSettings
:
…
.AddHttpCompression(options =>
{
options.CompressorFactories = new List<ICompressorFactory>
{
new BrotliCompressorFactory(new BrotliCompressionSettings
{
Level = 4
}),
…
};
})
;
Просто под .NET 9 данная настройка работает правильно и предельно точно (для версий .NET c 3-й по 8-ю использовался хак, описанный в начале данного раздела). Также стоит отметить, что для согласованности с модулем WebMarkupMin.AspNet.Common у свойства Level
было изменено значение по умолчанию с 5 на 4. Кроме того, были удалены сборки для .NET 6 и 7, что привело к изменению логики применения хаков (теперь хаки применяются не во время сборки, а во время выполнения).
Целочисленные уровни сжатия дают вам больше возможностей для тонкой настройки, но, в тоже время, ими нужно пользоваться с осторожностью. Чем больше номер уровня, тем больше времени требуется на сжатие документа. В качестве примера попробуем сжать HTML-код статьи Стивена Тауба «Performance Improvements in .NET 9» с помощью реализаций алгоритмов GZIP и Brotli из .NET 9.
Табл. 1. Результаты сжатия HTML-кода с помощью алгоритма GZIP
Уровень сжатия |
Размер документа |
Продолжительность сжатия |
0 |
957,48 КБ |
2 мс |
1 |
319,52 КБ |
6 мс |
2 |
249,39 КБ |
9 мс |
3 |
228,11 КБ |
14 мс |
4 |
221,59 КБ |
15 мс |
5 |
217,28 КБ |
15 мс |
6 |
215,80 КБ |
17 мс |
7 |
214,29 КБ |
22 мс |
8 |
213,82 КБ |
25 мс |
9 |
214,12 КБ |
32 мс |
Из табл. 1 видно, что для алгоритма GZIP оптимальными уровнями сжатия являются: 5 и 6. При сжатии больших файлов 9-й уровень дает более плохой результат по сравнению с 8-м уровнем, как по размеру файла, так и по скорости сжатия.
Табл. 2. Результаты сжатия HTML-кода с помощью алгоритма Brotli
Уровень сжатия |
Размер документа |
Продолжительность сжатия |
0 |
259,88 КБ |
7 мс |
1 |
248,83 КБ |
9 мс |
2 |
219,86 КБ |
16 мс |
3 |
214,74 КБ |
17 мс |
4 |
207,22 КБ |
23 мс |
5 |
192,96 КБ |
43 мс |
6 |
189,01 КБ |
51 мс |
7 |
185,67 КБ |
67 мс |
8 |
184,24 КБ |
64 мс |
9 |
182,97 КБ |
82 мс |
10 |
169,81 КБ |
809 мс |
11 |
165,79 КБ |
2 241 мс |
Из табл. 2 видно, что 4-й уровень действительно является оптимальным для алгоритма Brotli и дает размер файла, который недостижим даже на самых высоких уровнях сжатия алгоритма GZIP. Также видно, что 0-й уровень все-таки производит сжатие. Причем, в данном случае мы получаем файл, который меньше оригинального почти в 3,5 раза. Но самая важная для нас информация, что 10-й и 11-й уровень совсем не подходят для сжатия на лету и годятся только для сжатия на этапе сборки.
Если вы по-прежнему используете средства HTTP-сжатия WebMarkupMin для статических файлов, размещая вызов метода UseWebMarkupMin
перед вызовом UseStaticFiles
, то рекомендую вам от этого отказаться в пользу использования метода MapStaticAssets
из ASP.NET Core 9. При использовании метода MapStaticAssets
во время публикации проекта для каждого файла, содержащегося в директории wwwroot
, создаются сжатые версии с расширениями .gz
и .br
. Причем сжатие таких файлов производится на максимальном уровне.
Следующие два раздела описывают новые возможности ASP.NET Core-расширений, которые не связаны с выходом .NET 9.
Использование методов TryAdd для регистрации сервисов WebMarkupMin
Начиная с самой первой версии модуля для ASP.NET Core, сервисы, необходимые для WebMarkupMinMiddleware
, регистрировались внутри метода AddWebMarkupMin
следующим образом:
public static WebMarkupMinServicesBuilder AddWebMarkupMin(this IServiceCollection services,
Action<WebMarkupMinOptions> configure)
{
…
services.AddSingleton<ILogger, NullLogger>();
services.AddSingleton<ICssMinifierFactory, KristensenCssMinifierFactory>();
services.AddSingleton<IJsMinifierFactory, CrockfordJsMinifierFactory>();
…
}
Такой подход затруднял переопределение реализаций сервисов по умолчанию. Регистрацию новых реализаций в файле Program.cs
(или Startup.cs
) всегда приходилось проводить после вызова метода AddWebMarkupMin
:
…
using WebMarkupMin.Core;
using WebMarkupMin.NUglify;
…
using IWmmLogger = WebMarkupMin.Core.Loggers.ILogger;
using WmmThrowExceptionLogger = WebMarkupMin.Core.Loggers.ThrowExceptionLogger;
…
// Add WebMarkupMin services to the services container.
services.AddWebMarkupMin(…)
…
// Override the default logger for WebMarkupMin.
services.AddSingleton<IWmmLogger, WmmThrowExceptionLogger>();
// Override the default CSS and JS minifier factories for WebMarkupMin.
services.AddSingleton<ICssMinifierFactory, NUglifyCssMinifierFactory>();
services.AddSingleton<IJsMinifierFactory, NUglifyJsMinifierFactory>();
…
Теперь же в методе AddWebMarkupMin
и других частях модулей для ASP.NET Core используется условная регистрация сервисов с помощью методов TryAdd
:
public static WebMarkupMinServicesBuilder AddWebMarkupMin(this IServiceCollection services,
Action<WebMarkupMinOptions> configure)
{
…
services.TryAddSingleton<ILogger, NullLogger>();
services.TryAddSingleton<ICssMinifierFactory, KristensenCssMinifierFactory>();
services.TryAddSingleton<IJsMinifierFactory, CrockfordJsMinifierFactory>();
…
}
Такой подход позволяет переопределять реализации сервисов по умолчанию до вызова метода AddWebMarkupMin
:
…
// Override the default logger for WebMarkupMin.
services.AddSingleton<IWmmLogger, WmmThrowExceptionLogger>();
// Override the default CSS and JS minifier factories for WebMarkupMin.
services.AddSingleton<ICssMinifierFactory, NUglifyCssMinifierFactory>();
services.AddSingleton<IJsMinifierFactory, NUglifyJsMinifierFactory>();
…
// Add WebMarkupMin services to the services container.
services.AddWebMarkupMin(…)
…
В данный момент, такой стиль переопределения реализаций сервисов является предпочтительным, т.к. предотвращает засорение контейнера внедрения зависимостей неиспользуемыми реализациями сервисов.
Логгер для ASP.NET Core-расширений
Исторически логирование в WebMarkupMin производится на уровне ядра, т.к. возможны различные варианты использования библиотеки (от админок CMS до расширений Visual Studio). В ядре определены простые абстракции в виде интерфейса ILogger
и класса LoggerBase
, которые пользователи библиотеки могут использовать для реализации своих собственных логгеров. Тем не менее в WebMarkupMin есть две реализации логгера:
NullLogger
. Заглушка, которая отключает логирование.ThrowExceptionLogger
. В случае возникновения ошибки выбрасывает исключение типаMarkupMinificationException
.
Изначально в расширениях для всех ASP.NET-фреймворков в качестве логгера по умолчанию использовался ThrowExceptionLogger
. Начиная с версии 2.5.0, из-за многочисленных обращений пользователей, он был заменен на NullLogger
. В итоге это привело к тому, что в багтрекере проекта периодически появляются сообщения, в которых пользователи жалуются на неработающую HTML-минификацию. В большинстве случаев, их HTML-код содержит синтаксические ошибки. Как правило, решаются такие проблемы заменой логгера по умолчанию на ThrowExceptionLogger
. Лишь единицы пишут собственную реализацию логгера.
Чтобы облегчить жизнь пользователям, я написал класс AspNetCoreLogger
, который является оберткой вокруг стандартного логгера из библиотеки Microsoft.Extensions.Logging, и добавил его во все ASP.NET Core-расширения. NullLogger
по-прежнему остается логгером по умолчанию, поэтому пока AspNetCoreLogger
нужно регистрировать вручную в файле Program.cs
(или Startup.cs
):
…
using WebMarkupMin.AspNetCoreLatest;
…
using IWmmLogger = WebMarkupMin.Core.Loggers.ILogger;
using WmmAspNetCoreLogger = WebMarkupMin.AspNetCoreLatest.AspNetCoreLogger;
…
// Override the default logger for WebMarkupMin.
services.AddSingleton<IWmmLogger, WmmAspNetCoreLogger>();
…
// Add WebMarkupMin services to the services container.
services.AddWebMarkupMin(…)
…
AspNetCoreLogger
генерирует компактные сообщения и производит структурное логирование. Ко всем сообщениям логгера прикрепляются три свойства: Category
, Description
и DocumentUrl
. Эти свойства могут использоваться провайдерами, поддерживающими структурное логирование.
При обычных настройках информационное сообщение выглядит следующим образом:
HTML_MINIFICATION_SUCCESS: Minification of the HTML code has been completed successfully.
at /change-log
Если в настройках включить генерацию статистики HTML-минификации:
services.AddWebMarkupMin(…)
.AddHtmlMinification(options =>
{
…
options.GenerateStatistics = true;
…
})
…
;
То оно будет выглядеть следующим образом:
HTML_MINIFICATION_SUCCESS: Minification of the HTML code has been completed successfully.
at /change-log
Original size: 29,891 bytes
Minified size: 25,861 bytes
Saved: 4,030 bytes (13.48%)
Minification duration: 3 ms
Также к информационному сообщению будут добавлены еще 5 свойств: OriginalSize
, MinifiedSize
, SavedInBytes
, SavedInPercent
и MinificationDuration
.
Формат сообщений об ошибках и предупреждений похож на строковое представление ошибок в JavaScript:
HTML_PARSING_ERROR: In the start tag <time> found invalid characters.
at /change-log:116:39 -> <h3>2.14.0 - <time datetime=2023-05-24">May 24, 2023</time></h3>
Также к этим двум типам сообщений добавляются еще 3 свойства, показывающие местоположение проблемы: LineNumber
, ColumnNumber
и SourceFragment
.
В конце я хочу напомнить вам, что вы всегда можете взять исходный код AspNetCoreLogger
и написать на его основе свою собственную реализацию логгера.
Ссылки
Страница проекта WebMarkupMin на GitHub
Видеозапись моего доклада «WebMarkupMin — HTML-минификатор для платформы .NET» на MskDotNet Meetup #25 (15 августа 2018)
Подраздел «What's new in .NET libraries for .NET 9 > System.IO > ZLib and Brotli compression options» из документации .NET
Подраздел «What's new in ASP.NET Core 9.0 > Static asset delivery optimization» из документации ASP.NET Core