Уже стало традицией, что выход очередной версии .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 новых модуля можно было не выпускать.

Поэтому я принял решение оставить только модули, содержащие серьезные изменения:

  1. WebMarkupMin.AspNetCore1

  2. WebMarkupMin.AspNetCore2

  3. WebMarkupMin.AspNetCore3

  4. WebMarkupMin.AspNetCore6

Как бы это не было удивительно, но первые 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 есть две реализации логгера:

  1. NullLogger. Заглушка, которая отключает логирование.

  2. 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 и написать на его основе свою собственную реализацию логгера.

Ссылки

  1. Страница проекта WebMarkupMin на GitHub

  2. Документация WebMarkupMin

  3. Видеозапись моего доклада «WebMarkupMin — HTML-минификатор для платформы .NET» на MskDotNet Meetup #25 (15 августа 2018)

  4. Подраздел «What's new in .NET libraries for .NET 9 > System.IO > ZLib and Brotli compression options» из документации .NET

  5. Подраздел «What's new in ASP.NET Core 9.0 > Static asset delivery optimization» из документации ASP.NET Core

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