Как стать автором
Обновить

WebMarkupMin: Обновления в ASP.NET Core-расширениях, вызванные выходом .NET 9 и не только

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров2.7K

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

Теги:
Хабы:
Всего голосов 4: ↑3 и ↓1+2
Комментарии6

Публикации

Работа

.NET разработчик
47 вакансий

Ближайшие события