Осторожно: ICacheEntry

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Скрытый построитель в процессе работыё
Думаю, никому здесь не требуется объяснять, что такое кэширование и чем оно полезно, указывать, что кэширование широко используется, и упоминать, что для его реализации во многих средах программирования есть стандартные компоненты. Поэтому приступлю сразу к делу.


В .NET кэширование в оперативной памяти реализует пакет Microsoft.Extensions.Caching.Memory, входящий в набор .NET Extensions. И поводом для написания этой статьи послужили приключения (с успешным концом), связанные с упомянутым в заголовке интерфейсом ICacheEntry из этого пакета, возникшие при попытке его нестандартного использования.


Но рассказать я хочу не только о той недокументированной засаде, в которую я попал, сделав шаг в сторону от примеров использования из документации. И не только о том, как я из нее выбрался. Дело в том, что при выяснении правильного способа работы с ICacheEntry я наткнулся на довольно необычный приём программирования (он же Design Pattern), который я для себя назвал "Скрытый построитель". И наткнулся я на него в коде библиотек .NET не в первый раз. И я раньше нигде не читал про подобный приём. А потому я решил включить в статью ещё и описание этого приёма. А так как этот приём не специфичен для C#, и его вполне можно использовать и на других языках, то он может быть интересен и тем, кто не работает с C# и .NET.


Введение


Для той задачи, которую мне понадобилось решить — хранения в кэше произвольных объектов — в пакете Microsoft.Extensions.Caching.Memory есть интерфейс IMemoryCache и доступный публично реализующий его класс MemoryCache. В пакете также есть довольно много методов расширения для интерфейса IMemoryCache, которые позволяют добавлять в кэш объекты с разными связанными с ними свойствами, такими, например, как время жизни объекта в кэше. Использование этих методов неплохо документировано, эти методы позволяют решать многие задачи, связанные с кэшированием. Многие — но все. Кроме того, для использования некоторых из этих методов требуется код, кажущийся излишне громоздким.


Для решения нестандартных задач и уменьшения громозкости у меня возникла мысль использовать методы интерфейса IMemoryCache напрямую. И интерфейс это, вроде бы, позволяет. В этом интерфейсе определен метод CreateEntry, который возвращает интерфейс ICacheEntry к вновь созданному элементу кэша, через который возможен доступ к ключу, значению и прочим свойствам создаваемого элемента. Таким образом, кажется возможным создать элемент кэша и установить его свойства вручную. Но при попытке так сделать меня поджидала, буквально, засада. Причём, в документации о ней нет ни слова.


История


Все началось с того, что при написании некой библиотеки, которой я занимаюсь на досуге, возникла необходимость сохранять в кэше определенные объекты. Для чего был написан такой код (примерно такой: для краткости обработка исключений здесь убрана; на правую часть — справа от знака присваивания, с неизвестными именами переменных и с вызовами конструкторов неизвестных типов — внимание можно не обращать: для темы статьи интересна только левая часть, а правая — только для иллюстрации):


   ICacheEntry new_entry = _memoryCache.CreateEntry(key);
   new_entry.SlidingExpiration=_idleTimeout;
   new_entry.AbsoluteExpirationRelativeToNow=_maxLifetime;
   new_entry.Size=1; 
   new_entry.Value=result=new ActiveSession(_rootServiceProvider.CreateScope(), this, Session, _logger, trace_identifier);
   PostEvictionCallbackRegistration end_activesession = new PostEvictionCallbackRegistration();
   end_activesession.EvictionCallback=EndActiveSessionCallback;
   end_activesession.State=trace_identifier;
   new_entry.PostEvictionCallbacks.Add(end_activesession);

В документации подробности работы метода IMemoryCache.CreateEntry не описаны, поэтому код выше основывался на предположении. Предположение состояло в том, что этот метод создает элемент в кэше и возвращает интерфейс ICacheEntry, позволяющий установить затем в нем нужные свойства. Это предположение казалось логичным, даже чуть ли не единственно верным, потому что специального метода сохранения установленных свойств (в том числе — кэшируемого значения) для элемента кэша в интерфейсе ICacheEntry нет. Однако этот код не работает: после вызова CreateEntry счётчик элементов в кэше и не думает увеличиваться, объект в кэше не сохраняется и, соответственно, потом не находится. А так как документация совсем не описывает самостоятельное использование ICaheEntry, пришлось лезть в исходный код пакета.


В поисках того, как же всё-таки добавлять элементы в кэш, я заглянул в код одного из таких методов расширения, который реально работает. И увидел там нечто, на первый взгляд, странное.


public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value)
{
    using ICacheEntry entry = cache.CreateEntry(key);
    entry.Value = value;

    return value;
}

Странное — это оператор using: его наличие в начале метода позволяет добавить в конце этого метода в кэш полученный элемент, на который ссылается ICacheEntry. Чудеса? Не совсем. Но чтобы понять, как это работает, нужно вспомнить, что собой представляет оператор using в C#.
Думаю, все, сколь-нибудь активно использующие C#, знакомы с идиомой очищаемых (disposable) объектов и используемым в ней методом Dispose() интерфейса IDisposable (от этого интерфейса, кстати, унаследован интерфейс ICacheEntry, на что я сразу и не обратил внимания).


Вкратце, однако, напомню...

… зачем и почему он используется. Идея тут в том, что если объект содержит ссылки на неуправляемые ресурсы, которые сборщик мусора самостоятельно, без финализатора, освободить не может — например, описатели (handle) ресурсов операционной системы — или эксклюзивно используемые дочерние объекты, содержащие такие ссылки, то в таких объектах принято реализовывать интерфейс IDisposable. Для скорейшего освобождения таких ресурсов после того, как использование закончено, следует вызвать метод Dispose(), в задачу которого входит освобождение неуправляемых ресурсов и/или вызов методов Dispose() дочерних объектов. Конечно, можно не заморачиваться очищением всего дерева таких объектов через Dispose(), а положиться на сборщик мусора и финализаторы для объектов, непосредственно владеющих неуправляемыми ресурсами, освобождающие эти ресурсы: такие объекты в любом случае обязаны реализовать финализаторы. Но это заметно замедляет работу сборщика мусора. Так что идиома очищаемых объектов — это вещь полезная.


Эта идиома в C# используется настолько часто, что уже давно разработчики языка добавили в него оператор using, который скрывает детали её использования от глаз пользователя.


Подробности

конструкция


using (var variable=expression/*выражение_создающее_объект*/) { 
    /*действия_с_созданным_объектом*/ 
} 

эквивалентна последовательности операторов


var variable=expression/*выражение_создающее_объект*/;
try {
  /*действия_с_созданным_объектом*/ 
}
finally {
  variable.Dispose();
}

То есть, оператор using не только избавляет программиста от необходимости знать про идиому очистки объектов и интерфейс IDisposable, но и экономит немалое количество нажатий на клавиши. Тем самым — увеличивает ключевой ( ;-) ) показатель нынешнего процесса разработки — скорость написания кода. Ну, а в дальнейшем разработчики языка C# пошли ещё дальше по пути сокращения числа нажатий на клавиши и сэкономили ещё пару: сделали фигурные скобки вокруг тела оператора using необязательными — в нынешнем C# считается, что оператор using без фигурных скобок действует до конца включающего его блока. И пусть код выглядит для непривычного человека загадочным, но повышение скорости написания кода достигнуто.


Короче, загадочный using в начале блока с телом метода — это вызов метода Dispose() в конце этого блока для переменной entry из using. Ловко придумано, запутывает замечательно ;-). Осталось только разобраться, как этот вызов Dispose(), предназначенный, вообще-то, для очистки более не используемого объекта, помогает решать задачу сохранения элемента в кэше.


Начать расследование стоит с метода создания элемента кэша MemoryCache.CreateEntry: как оказалось, этот метод создает независимый экземпляр класса CacheEntry — внутреннего класса, реализующего публичный интерфейс ICacheEntry — и возвращает этот интерфейс. Слово "независимый" выше означает, что вновь созданный элемент в список элементов кэша на этой стадии пока что не добавлется.


Подробности создания

В конструктор CacheEntry передается ключ элемента и ссылка на объект кэша. Именно она будет использована впоследствии для добавления этого элемента в кэш, о чём — ниже. Кроме того, вновь созданный элемент кэша может быть добавлен (а в версиях .NET до 6.0 включительно — обязательно добавляется) в список связанных записей кэша (подробнее об этом механизме — в следующем спойлере).


А просмотр исходного кода метода CacheEntry.Dispose() показывает, что этот метод действительно содержит вызов внутреннего метода MemoryCahe.SetEntry(). Метод же SetEntry(), в полном соответствии со своим названием, добавляет в кэш этот независимый элемент CacheEntry. А если элемент с таким же ключом в кэше уже был, то этот старый элемент удаляется (evict) из кэша с кодом причины удаления EvictionReason.Replaced.


А также Dispose() делает ещё кое-что нетривиальное: обрабатывает связанные элементы

В логике создания независимых экземпляров CacheEntry и сохранения их в кэше есть дополнительная возможность — создание связанных элементов. Я не видел, чтобы она где-то была документирована для использования в приложениях (раздел "Cache Dependencies" в документации по "In-memory caching" — это не о том), и чтобы к ней давал доступ какой-то из методов расширения. Однако, судя по тому, что создатели .NET уделили много времени оптимизации этого механизма (это видно по коду и комментариям в нем), им пользуется какой-то внутренний код библиотеки .NET, и, причем — достаточно часто используемый. Но, в принципе, возможность эта вполне доступна снаружи.


Смысл этой возможности в том, что можно создать несколько независимых элементов, не записывая их в кэш, и установить ряд свойств только для последнего элемента — и эти свойства будут скопированы на все такие элементы при поочередном сохранении элементов в кэше (уже упомянутым методом Dispose()). В число этих свойств входят абсолютное время устаревания элемента и список маркеров изменения (реализаций интерфейса IChangeToken), срабатывание которых приводит к удалению элемента из кэша. При этом, метод Dispose() для нескольких элементов кэша должен вызываться обязательно в порядке, обратном порядку их создания вызовом MemoryCache.CreateEntry, иначе механизм работать не будет (в режиме отладки правильность порядка проверяется с помощью Debug.Assert).
Реализована связь независимых элементов CacheEntry в виде стека: голова стека — текущий создаваемый элемент — записывается в конструкторе в переменную _current типа AsyncLocal<CacheEntry> (т.е., своего рода "статическую" в рамках потока асинхронного выполнения: она привязана к контексту выполнения, а потому процесс создания связанных элементов можно выполнять и в async-методе: продолжения после await увидят то же самое значение). А каждый элемент CacheEntry содержит поле CacheEntry _previous, ссылающееся на предыдущий созданный независимый элемент: в него в конструкторе копируется значение _current.Value, при этом первый созданный элемент (а также — все элементы, уже сохраненные в кэше) будет содержать в нем null. Именно поэтому методы Dispose() нескольких независимых элементов надо вызывать в порядке, обратном порядку их создания.
В .NET Core/.NET до версии 7.0 этот механизм был включен всегда. Но, начиная с .NET 7.0, разработчики сделали этот механизм отключенным по умолчанию из соображений производительности, а для его включения добавили свойство MemoryCacheOptions.TrackLinkedCacheEntries. Так что, если захотите пользоваться — имейте это в виду.


И действительно, добавление в конец написанного выше кода вызова метода Dispose() успешно сохраняет нужный элемент в кэше. Итак, загадка решена.


Вроде бы, загадка решена, но...

Решение наводит на некие (хорошие или нет — не знаю) мысли, которые лучше всего выражаются словами "а что это за...?". Программисты, работающие с .NET, привыкли, что IDisposable.Dispose() — это очистка, освобождение не управляемых CLR ресурсов, которые GC(сборщик мусора) сам освободить не в состоянии (точнее — не в состоянии освободить без помощи метода завершения (finalizer), который весьма заметно нагружает GC лишней работой).


Но здесь-то никаких неуправляемых ресурсов нет — ведь MemoryCache использует только управляемую память. Вместо этого на ICacheEntry.Dispose() была навешена функциональность, никак не предусмотренная в его предке — IDisposable. Конечно, похожее поведение встречается и с другими объектами: при работе с файлами, запросами к БД и т.п. метод Dispose() производит завершающие операции ввода-вывода. Но там это — часть очистки, а тут — совершенно отдельная функциональность. А это как-то не очень бьется с рекомендациями теоретиков.


Как к этому относиться? С одной стороны, чистота кода — соответствие неким теоретическим принципам — меня особо не волнует: со своим многолетним опытом я привык относиться к ним философски. Ну, плачет там где-то буква L в акрониме SOLID от того, что не соблюдается принцип подстановки, который она олицетворяет — а мне-то что с того? Но, с другой стороны, конкретно с ICacheEntry все-таки была неожиданная засада, и я потратил несколько часов, чтобы ее обойти. Я понимаю, что в проекте .NET Core кому-то когда-то захотелось пооригинальничать, а теперь это уже не исправишь (это было признано более 5 лет назад в давнем, закрытом issue в GitHub). Но можно было бы хотя бы такое поведение описать в документации: мне бы, например, это помогло, так как я имею привычку ее читать заранее. Но нет, последний issue с просьбой об этом (возможно, есть и более раннние, не искал) в GitHub с предложением документировать такое неожиданное поведение ICacheEntry висит уже больше года без движения. Вообще-то, и в мире Open Source в целом с документацией не слишком хорошо. Например, общедоступная документация по широко применяемому и имеющему довольно много разных возможностей фреймворку имитации объектов Moq — это одна страничка на GiHub с примерами применения, и не более того. И только после довольно длительных поисков я разыскал хотя бы документацию по классам и методам Moq (похоже, сделанную на основе XML-документации непосредственно в коде). Но для Open Source такое простительно: разработчики Open Source обычно не имеют ни достаточных ресурсов, ни достаточной мотивации, чтобы делать хорошую документацию, а кому очень надо — вот код, пусть ищут ответы там. Но .NET, хоть его исходный код и открыт — это все-таки разработка Microsoft, крупной корпорации, у которой явно есть ресурсы и возможности замотивировать своих работнков. Но нет, чувствуется в этом некое снисходительное отношение Microsoft к разработчикам, как к людям ограниченым: пусть используют продукт только так, как предусмотрено, а шаг влево, шаг вправо — не нужен, ибо нефиг. Но, собственно, Microsoft всегда была такой, самодеятельность и левшизм с кулибинщиной она никогда не поощряла, так что к этому можно было бы и привывкнуть(я привык, если что).


Но это ещё не всё, о чём я хотел рассказать.


Шаблон проектирования "Скрытый построитель"


Я уже несколько раз встречал в системных библитеках .NET код, в котором инициализация объекта после его конфигурирования происходит внезапно, в неочевидном месте и неочевидным способом. Например, точно так же в неожиданном месте — при первом обращении к их списку — инициализуются и экземпляры объекта конечной точки (тип которого — класс-наследник Endpoint) в подсистеме маршрутизации ASP.NET Core.


Немного подробностей про конфигурирование и инициализацию Endpoint

Для добавления конечной точки подсистема маршрутизации ASP.NET Core предоставляет интерфейс IEndpointRouteBuilder. Для приложений на базе WebHost/GenericHost этот интерфейс передается как единственный параметр в делегат, передаваемый как аргумент в метод расширения UseEndpoints(праный методу UseRouting) интерфейса IApplicationBuilder. А интерфейс IApplicationBuilder передается как параметр в метод Configure startup-класса. Для приложений на появившемся в .NET 6.0 шаблоне веб-приложения, базирующемся на WebApplication, где методы расширения UseRouting/UseEndpoints явно использовать не требуется (нужные для работы маршрутизации компоненты-обработчики, которые добавляют эти методы, добавляются в конвейер объектов-обработчиков неявно), интерфейс IEndpointRouteBuilderреализован самим классом WebApplication.
Конечные точки маршрутизации добавляются через методы расширения интерфейса IEndpointRouteBuilder. Существует довольно много таких методов, как в базовом фреймворке, так и в его расширениях: MVC, Razor Pages, Blazor и т.д. Такой метод расширения интерфейса IEndpointRouteBuilder добавляет в соответствующий им источник данных маршрутизации специфичный для источника данных внутренний класс-построитель конечной точки. Этот класс обычно реализует интерфейс IEndpointConventionBuilder. Именно этот интерфейс IEndpointConventionBuilder возвращается методом расширения и используется для конфигурирования свойств конечной точки. Кроме того класс-построитель использует класс-наследник абстрактного класса EndpointBuilder для создания конечной точки нужного типа. Публично доступен только один класс-наследник EndpointBuilder — RouteEndpointBuilder, создающий экземпляр конеченой точки RouteEndpoint. Создание же всех объектов конечной точки происходит, как и упоминалось выше, при первом обращении к списку конечных точек в источнике данных уже внутри класса EndpointRoutingMiddleware (этот класс создается и устанавливается в конвейер компонентов-обработчиков, чтобы находить конечную току маршрутизации для запроса). Но это — в норме, потому что список конечных точек, в принципе, доступен приложению пользователя через IEndpointRouteBuilder, и плохо сконструированное приложение может преждевременно обратиться к списку конечных точек в источнике данных маршрутизации, вызвав преждевременное построение объектов конечных точек.
PS А в целом подсистема маршрутизации ASP.NET Core и как она работает внутри себя — это очень интересная и довольно большая тема. В отдаленных планах у меня есть намерения написать одну или несколько статей об этом, но пока что это — только планы.


И я увидел в таких примерах ранее не встречавшийся мне прием программирования, он же — шаблон проектирования (Design pattern), который я назвал для себя "Скрытый построитель". Он похож на общеизвестный шаблон "Построитель" (Builder), предназначенный для создания сложных объектов, имеющих сложный набор компонентов-свойств, которые могут быть установлены по-разному. В рамках шаблона "Построитель" для этого создается специальный объект построителя, через который конфигурируются компоненты создаваемого сложного объекта, после чего вызывается метод, создающий этот сложный объект. Такой подход достаточно широко используется в .NET: например, в ASP.NET веб-приложение во всех вариантах его построения (Web Host, Generic Host, WebApplication) строится именно по этому шаблону: создаётся объект построителя (соответственно, WebHostBuilder/HostBuilder/WebApplicationBuilder), производится настройка этого объекта и, наконец, создаётся объект приложения вызовом метода Build построителя.


Так вот, в шаблоне "Скрытый построитель" построение сложного объекта начинается примерно так же: создаётся объект построителя (в нашем случае — независимый CacheEntry) и производится его конфигурирование (в нашем случае — через реализуемый им интерфейс ICacheEntry). А вот само создание объекта производится не явным вызовом специального метода, а некой операцией, от которого такое действие совершенно не ожидается (в нашем случае создание элемента в кэше выполняется, как описано выше, методом Dispose, предназначенным, по идее, совсем для другого).


Где можно использовать приём "Скрытый построитель"? Если осознанно — то, разве что, для написания "чистого непонятного кода": кода, формально соответствующего критериям "чистого кода" от теоретиков, но, при этом, все равно ускользающего от понимания. Такая необходимость возникает, например, при обеспечении job security. Или — для защиты интеллектуальной собственности в открытом коде: применение нетривиального приёма программирования безо всяких намеков на него в документации вполне может быть эффективно использовано для того, чтобы отбить охоту лезть в этот код у желающих странного и пытающихся не ограничиваться небольшим набором рекомендуемых приемов использования, документированных в примерах. Но, вообще-то, код, соответствующий приёму "Скрытый построитель", может быть и дикорастущим — получаться сам по себе в процессе написания: например, потому что так короче. Как обстоит дело в данном случае, я точно не знаю: я не готов тут настаивать на обвинении Microsoft в недобрых намерениях, но я не первый раз наблюдаю подобные сомнительные действия у Microsoft, так что от подозрений избавиться не могу.


Является ли шаблон "Скрытый построитель" "антипаттерном"? Об этом пусть спорят теоретики, меня же такой вопрос не волнует. Но лично для себя я решил избегать использовать этот приём — кроме как в случаях, когда действительно требуется написать именно непонятный код. Но я надеюсь, что мне это делать не придется.


Заключение


В статье рассмотрен правильный (и нетривиальный) способ непосредственного, т.е., без помощи методов расширения, использования итерфейса ICacheEntry для настройки и добавления элементов в кэш.


В процессе поиска этого способа был обнаружен ранее неизвестный мне приём программирования (иначе шаблон проектирования) "Скрытый построитель" (рискну преревести его как "Disguised Builder" design pattern). Рассмотрены аргументы за и против его использования.


PS. Если бы у меня был свой телеграмм-канал, то здесь, как модно у современных авторов Хабра, я оставил бы ссылку на него. Но у меня нет своего телеграмм-канала.
А картинка к посту — это как видит скрытый построитель в процессе работы нейросеть "Кандинский".

Источник: https://habr.com/ru/articles/755778/


Интересные статьи

Интересные статьи

Вы когда-нибудь задумывались о том, какие компании разрабатывают софт для государственных заказчиков? Например, кто разрабатывает проекты для генеральной прокуратуры , МЧС или кто развивает АСУ диспет...
Если вы читаете этот текст, значит он не про вас. По крайней мере – пока. Я искренне за вас рад. А за ребят из провинции, к коим и сам отношусь, уже немного переживаю.Наши местные руководители и HR то...
О произношении английских слов есть много шуток. Потому что большинство лексем произносится как попало и одна и та же буква может даже в одном слове обозначать несколько ...
Преподаватели Skyeng не сразу попадают «на передовую» — для начала они проходят отбор и обучение. Направление найма и онбординга преподавателей в Skyeng появилось в 2015 ...
В этой статье я расскажу про свое решение текстовой части задачи SNA Hackathon 2019. Какие-то из предложенных идей будут полезны участникам очной части хакатона, которая пройдет в московском ...