Околоархитектурные рассуждения или результаты одного спора

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


    Волевой путь, ведущий в бездну достижения нирваны

    Имеем набор сервисов, которые занимаются обработкой данных, используют репозитории для хранения данных. Т.к. репозитории зачастую источники медленные, то некоторые сервисы используют временные хранилища, такие как Session и Cache. А т.к. сервисы используются не только из web-приложений, то напрямую эти временные хранилища использовать нельзя (например, консольное приложение не имеет сессии вообще), поэтому работу с ними реализуют тоже сервисы. (То есть нет четкого понятия, что такое сервис в нашей архитектуре и его высшее предназначение не обозначено. По сути дела, при таком подходе — сервис это класс, который что-то делает)



    рис.1 Иерархия классов первого подхода
    Пример реализации всего этого счастья можно скачать на Git Hub

    Что мне здесь не нравится:

    Во-первых, мне не нравится отсутствие четких соглашений о делегировании полномочий: и то сервис, и другое, а занимаются совсем разными вещами. Один занимается обработкой данных, другой их временным хранением.

    Во-вторых, мне не нравится класс SessionDataServiceBase, а именно метод T Get(string key, Func getData), который не только работает с сессией, но и занимается обработкой данных. Предполагается по названию ISessionDataService (да и вообще как это изначально задумывалось), что он является только оберткой над Session и максимум, что может, так это возвращать значение по умолчанию. Если обратится к истории развития этого объекта, то изначально метод T Get(string key, Func getData) (или его аналог) не был описан в интерфейсе. Он был реализован в каждом классе, который использовал реализацию данного интерфейса (и с одной стороны, по моему мнению — это правильно). У данного подхода был минус — это повторяемость кода. Это было мною замечено, когда в одном из сервисов я решил добавить использование сессии. После краткого исследования было обнаружено большое количество дублированного кода, точнее, это дублирование было во ВСЕХ сервисах. Это нарушило мою целостность восприятия вселенной и не позволило жить мне в гармонии с ней. Делегировать сессионному сервису какую-то дополнительную работу с данными мне показалось неправильным, и было принято решение вынести это в какой-то базовый класс, чтобы убрать этот диссонанс из моей души (подробности в описании второго пути к истине).

    Третья проблема, которая мне была явлена Господом нашим Богом, его святейшеством двоичным кодом — это ключи. Им присваивали значения (не побоюсь признаться, и за мной одно время был грех) в стиле “кто в лес, кто по дрова”. И метод void Clear(string[] sessionKeyPrefix) давал иногда совершенно неожиданные результаты. Были и другие проблемы, но мы их не будем касаться, решение этих проблем тоже давало грамотное наследование.

    Проблема четвертая — тестирование. После перевода волевым решением на данную структуру, все юнит-тесты методов использующих под капотом T Get(string key, Func getData) упали. Мокнуть я их по быстрому не смог, даже с помощью нашего гуру юнит-тестирования, и мне было предложено на них вообще забить, что не есть хорошо на мой взгляд.

    А теперь о преимуществах — о них мы не можем ничего сказать, то есть, в смысле я, потому что я за второй путь, исполненный благочестия и совершенства, ведущий в совершеннейшую нирвану, при которой код преобразуется напрямую в двоичные кода, минуя унизительный и скучный процесс преобразования в CIL.

    Путь осмеянный современниками, как и все истинно великие вещи

    Этот подход основывается прежде всего на соглашениях и ограничениях. Предполагается, что сервис — это класс, который обрабатывает полученные данные и сохраняет результаты этой обработки в постоянный хранилищах, с которыми работает через репозиторий, причем репозиторий у него один, и сервис ничего не знает о том, как он хранит данные. Ему все равно, база данных, файл, сторонний сервис на просторах интернета, etс.
    По-хорошему, тут было бы правильно сделать какой-то базовый класс или интерфейс, который бы показывал, что этот класс отвечает именно за обработку и пересылку данных между хранилищем и конечным потребителем. Но, к сожалению, это не сделано, и, как видим в первом подходе, у нас появляется класс (HttpContextBasedSessionDataService), названный сервисом, но не имеющий репозитория, и не обрабатывающий данные. Поэтому, будем предполагать, что мы все же имеем базовую сущность для сервисов, будет она интерфейсом IService

    Теперь о HttpContextBasedSessionDataService и подобный ему классах. Появился он по причине того, что репозитории — медленные источники данных, во-первых, и их нужно использовать как можно реже, так как это всегда узкое место, во-вторых. Поэтому, не плохо бы некоторые данные хранить под рукой — и тут появляется новый вид классов, они не обрабатывают данные, не имеют репозитория, всего лишь обеспечивают доступ ко временным хранилищам. В приципе, это ближе к репозиториям, чем к сервисам, только репозиториям временного хранилища, таких, например, как Application, Cache, Session. Назовем базовую сущность IShorttermStore и примем то, что в названиях подобных классов не будет упоминаться слово Service.

    А теперь попробуем на основании этих выкладок построить такую иерархию классов, которая устранит недостатки предыдущей реализации, и вынесем часть функциональности по логике работы с временными хранилищами в базовый сервис класс, а именно — работу с ключами и логику ленивой инициализации.
    И вот теперь взглянем на диаграмму классов, построенной согласно этой концепции:



    рис.2 Иерархия классов второго подхода
    Пример реализации всего этого счастья можно скачать на Git Hub

    Здесь устранены недостатки, которыми, по моему мнению, грешит первый подход. Юнит-тесты работают без каких-то дополнительных моков.
    Теперь о мнимых недостатках.
    Если вспомнить аргументы спорщиков, то тут есть недостаток в том, что если класс хочет также использовать кроме Session так же и Cache, то возникают проблемы. Но на самом деле проблем в этом нет: если возникла такая потребность, то может стоит подумать о SOLID, в частности о букве I в этой аббревиатуре (Interface segregation principle) и не делать монстров, способных “и вышивать, и на машинке тоже”.
    Больше о недостатках я ничего пока не могу сказать, так как память избирательна и запоминает только светлое и хорошее, а вовсе не критику. Прошу начать разбивать меня в пух и прах, а то что-то очень все хорошо получается.

    Михайличенко Алексей, Software .Net Developer, Tech Lead
    Zfort Group
    0,00
    Компания
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 24

      +1
      Осталось к SOLID добавить DI, и получите свой идеал архитектуры, наверное. И ещё кэш реализовать не в виде сервиса, а в виде аспекта протокола (как в REST/HTTP, например). Потом выложить на TodoMVC, и в мире родится очередная серебряная пуля для архитектуры.
        0
        мне не нравится класс SessionDataServiceBase, а именно метод T Get(string key, Func getData), который не только работает с сессией, но и занимается обработкой данных.

        Зря вы так. Какая там обработка данных? Вот код из одного моего проекта
        public class InMemoryCache : ICache
            {
                public const int CacheMinutes = 10;
                public T GetOrSet<T>(string cacheKey, Func<T> getItemCallback) where T : class
                {
                    T item = MemoryCache.Default.Get(cacheKey) as T;
                    if (item == null)
                    {
                        item = getItemCallback();
                        MemoryCache.Default.Add(cacheKey, item, DateTime.Now.AddMinutes(CacheMinutes));
                    }
                    return item;
                }
        

        Вся логика обработки данных остаётся в соответствующих сервисах, а в этом тупо получение/сохранение данных в кэше. Это очень удобно.
          0
          (например, консольное приложение не имеет сессии вообще), поэтому работу с ними реализуют тоже сервисы

          А зачем вообще использовать сессии, особенно на уровне бизнес логики? Если хочется закэшировать данные только для конкретного пользователя, используйте составной ключ cacheKey = cacheKeyPrefix + separator + user.Id.
          Я ещё могу понять редкие случаи использования сессий на уровне web-приложения, но тащить их ниже на уровень бизнесс-логики — это ошибка протекающих абстракций.
            0
            Есть данные конкретного пользователя. которые генерируются из каких то общих данных и данных конкретного пользователя. Обработка этих данных бывает весьма витиевата и ей самое место в бизнес логике. Каждых раз доставать общие данные из базы, даже если учитывать, что сам сиквел их кеширует, не есть хорошо, так как это самое узкое место в проекте.

            А по поводу генерации ключа — он и генерируется по подобным правилам. Это можно сделать как базовый класс и все будет генерироваться автоматически. А можно вручную и тогда за этим нужно следить, чтобы все придерживались определенных правил и не косячили. А человеческий фактор не косячить к сожелению не может. Вот во втором случае и есть попытка уйти от этого фактора
            +5
            По-хорошему, кэширование, как и прочие cross-cutting concerns, должно жить «снаружи» класса, который поставляет данные. Проще говоря, есть у нас ISomeDataProvider, от которого зависит бизнес-код. В какой-то момент мы поняли, что этот провайдер — медленный, и надо бы сделать кэш — создаем новый класс, реализующий кэширование, заставляем его реализовать тот же ISomeDataProvider, а за данными из него ходим в «старую» реализацию. На уровне composition root заменяем старую реализацию на новую — и все.

            (особо хитрые/ленивые люди делают автоматическую генерацию таких оберток, но тут, как всегда, есть нюансы)
              0
              Это называется декоратор :)
                +1
                Я, в общем-то, знаю.
                  +1
                  Я не сомневаюсь, просто дополнил ваш комментарий полезным словом.
                –1
                А там реализованна паттерн стратегия. Который тоже неплохо справился с поставленой задачей.
                  +2
                  Из описания не понятно, где именно там стратегия.
                  +1
                  Мне кажется, это самое удачное решение.
                    0
                    Какое из двух?
                      0
                      То, что в комменте. :)
                    0
                    У вас есть публичное апи, которое предоставляет ISomeDataProvider. А теперь расскажите, пожалуйста, как вы будете кэширование делать и как замените старую реализацию новой? Даже в композишн рут. А если в композишн руте он юзается много где. Вы его везде будете менять? ИДЕ и идеевский рефакторинг в расчет не берем. Если мы заговорили уже о композинш руте и ДИ, то датапровайдер должен быть интерефейсом, а реализация инжекстится. И если у нас зависит бизнес код от провайдера, то тут композишн рутом не отделаешься и придется менять еще и бизнес код и лезть в чужие модули, которые делает другая команда. Либо делаете интерфейс и реализуете сколько хотите имплементаций с различными кэшами и без, а если по религиозным причинам этого сделать нельзя, то остается делать кэш только внутри класса и никак иначе.
                      +1
                      Зачем что то менять?
                      Как я понял, значально есть реализация ISomeDataProvider (например, MyDataProvider). Далее, делаем вторую реализацию CachedDataProvider. Без IoC контейнера первое выглядело бы как
                      ISomeDataProvider provider = new MyDataProvider();
                      

                      подмена с кешированием станет выглядеть как
                      ISomeDataProvider provider = new CachedDataProvider(new MyDataProvider());
                      

                      К примеру, такое декорирование Castle Windsor поддерживает из коробки, достаточно указать, что для резолва ISomeDataProvider нужен не только MyDataProvider, но и CachedDataProvider.
                        0
                        как вы будете кэширование делать?

                        tym32167 все более-менее правильно описал. Предположим, у нас есть MyDataProvider: ISomeDataProvider. А еще, поскольку мы уже знаем, что будем работать в разных средах, у нас есть ICache (WebCache: ICache и ConsoleCache: ICache).

                        Осталось это только собрать:

                        class MyCachedDataProvider: ISomeDataProvider
                        {
                          public MyCachedDataProvider(ISomeDataProvider provider, ICache cache)
                          {
                            //..
                          }
                        }
                        


                        Дальше те методы ISomeDataProvider, которые нуждаются в кэшировании, оборачиваются (внутри MyCachedDataProvider) в кэширующую логику, все остальные проксируются как есть.

                        как замените старую реализацию новой

                        Если у меня dependency injection или service locator, то в момент регистрации сервиса для ISomeDataProvider я укажу MyCachedDataProvider, а дальше все взлетит само (реальное количество конфигурации зависит от используемого контейнера, но суть одна и та же). Если у вас фабрики — придется править фабрики. Если у вас прямое создание — тогда вы в беде, но вы в беде безотносительно кэширования.

                        (повторюсь, если у вас сервисов, нуждающихся в кэшировании, много, а логика кэширования везде одинаковая, то можно перестать писать декораторы вручную и взять контейнер, позволяющий генерить интерцепторы на лету)
                          –1
                          Ну по сути это можно же сделать и интерфейсом. Почему вы выбрали именно наследование от класса, а не имплементацию интерфейса? В датапровайдере я так понимаю все-равно кэширование будет происходить в методе гетДата. И кэш будет юзаться именно там. Допустим у нас есть инмемори кэш и кэш из базы (в случае сложных систем, которые лезут по всу в другие за данными. опускаем проверку актуальности кэша в этом случае для примера). Как вы совместите коммон метод гетДата из суперкласса с предковым? Или вы оверрайдить будете гетдату? Если оверрайдить, то в любом случае по-моему лучше интерфейс сделать. Налседуются от классов обычно, когда есть общие методы для предков, которые не меняются либо для расширения функционала для консьюмеров.
                            0
                            Почему вы выбрали именно наследование от класса, а не имплементацию интерфейса?

                            Простите, а где вы видите наследование от класса? У меня везде именно реализация интерфейсов.

                            (остальное пока обсуждать не вижу смысла, есть подозрение, что вы не поняли схему)
                              –2
                              Тогда это не снаружи класса, а одна из имплементаций.
                              ISomeDataProvider provider = new CachedDataProvider(new MyDataProvider()) вот это вот не выглядит, как интерфейc. Совсем.
                                0
                                Тогда это не снаружи класса, а одна из имплементаций.

                                Это снаружи. Потому что сам класс — это MyDataProvider, он ничего не знает про кэширование. А для декоратора класса может и не быть, если мы интерцепторы используем.

                                ISomeDataProvider provider = new CachedDataProvider(new MyDataProvider()) вот это вот не выглядит, как интерфейc

                                Ну так не надо так делать (я вроде и не предлагал нигде). DI, SL, фабрики. Все потребители зависят от интерфейса и только от него.
                                  0
                                  ISomeDataProvider provider = new CachedDataProvider(new MyDataProvider())

                                  Я это написал только, чтобы можно было лучше понять суть. Писать в проде так не надо, конечно.
                      0
                      Если выбирать из двух — то второе конечно лучше. Т.к. доступ к кешу реализован в отдельной абстракции.
                      Но все таки мне кажется не совсем правильно выделать абстрактный класс BaseUseCacheStoredService только ради простоты переиспользования удобных методов работы с кешем. Я бы тут наследование, заменил композицией — методы из этого сервиса перенес в IShorttemStore и дергал бы их напрямую. А IShorttemStore инжектил бы только в тот сервис где оно нужно. Это решает проблему, что если нужно несколько видов кешей в одном сервисе, или разные виды кеша в зависимости от окружения, для одного и того же сервиса. Наследование в данном случае не гибко т.к. предполагает только один вид кеша. С реализациями методов в IShorttemStore можно допустим для консольного приложения поднять один и тот же сервис с реализацией кеша в памяти, а в веб приложении, с реализацией кеша в сессии и за счет полиморфизма реализации сервиса будет все равно с каким кешем она работает.
                        0
                        Второй вариант я бы назвал — overengineering.
                        Как по мне, так ваш пример не раскрывает вариантов использования, поэтому не ясно нужно ли все так усложнять.
                        Я бы просто враппер для ICurrentMemberService сделал и не стал ничего выдумывать. Я больше люблю простые решения, ибо часто встречаю в проектах FizzBuzzEnterprise.

                        Кстати, есть вопрос, зачем вам пустой интерфейс IService?
                          0
                          Мне кажется не очень хорошей идеей выносить перегрузки методов в интерфейс. Чаще всего реальная логика содержится только в одном из методов, а остальные делегируют ему. В итоге приходится копипастить эту тривиальную реализацию в каждый класс, реализующий интерфейс, или городить базовый класс. Лучше оставить только самую общую перегрузку в интерфейсе, а остальные сделать методами расширения. Плюс такого подхода — перегрузки достаточно протестировать один раз, минус — невозможность подсунуть оптимизированную реализацию или залезть во внутренности класса.

                          А для кэширования в последнем проекте я применил аспектно-ориентированное программирование (для начала в рантайме через Castle.DynamicProxy, при необходимости можно генерировать обёртки и на этапе компиляции через Fody или PostSharp). Ключи назначаются автоматически из сигнатуры метода, что избавляет от проблем с забытой частью ключа или коллизиями. Выглядит довольно лаконично:

                          class CommentService : ICommentService
                          {
                              private readonly ICurrentUser _user;
                              private readonly ICommentRepository _repository;
                          
                              // ...
                          
                              [Cache(IsUserScope = true)]
                              public virtual IReadOnlyList<Comment> GetComments(int postId)
                              {
                                  var comments = _repository.GetComments(postId);
                                  MarkCurrentUserComments(comments);
                                  return comments;
                              }
                          }

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое