Статьи

Архитектура в «Pit of Doom»: зло уровня абстракции хранилища


Это часть серии постов о проекте
Whiteboard Chat . В этом посте я собираюсь исследовать методологию доступа к данным и почему с ней так больно работать. В частности, я собираюсь сосредоточиться на проблемах, возникающих в этом методе:

[Transaction]
[Authorize]
[HttpPost]
public ActionResult GetLatestPost(int boardId, string lastPost)
{
DateTime lastPostDateTime = DateTime.Parse(lastPost);

IList<Post> posts =
_postRepository
.FindAll(new GetPostLastestForBoardById(lastPostDateTime, boardId))
.OrderBy(x=>x.Id).ToList();

//update the latest known post
string lastKnownPost = posts.Count > 0 ?
posts.Max(x => x.Time).ToString()
: lastPost; //no updates

Mapper.CreateMap<Post, PostViewModel>()
.ForMember(dest => dest.Time, opt => opt.MapFrom(src => src.Time.ToString()))
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.Owner.Name));

UpdatePostViewModel update = new UpdatePostViewModel();
update.Time = lastKnownPost;
Mapper.Map(posts, update.Posts);

return Json(update);
}

Вы можете ясно видеть, что было много, хотя и посвященных тому, чтобы убедиться, что архитектура была сделана
правильно . Вот общий подход к доступу к данным:

образ

Поэтому мы используем шаблоны спецификаций и репозиториев, что на первый взгляд кажется нормальным. За исключением того, что вся система серьезно перегружена и не дает вам никаких преимуществ. Вся цель репозитария является , чтобы обеспечить в коллекции памяти интерфейса к источнику данных, но эта схема была создана в то время , когда новая вещь на блоке была сырье вызовов SQL. Рассмотрим, что делает NHibernate, и вы можете видеть, что здесь одна реализация интерфейса сбора данных в памяти поверх хранилища данных обернута в другую, и она ничего не делает для нас, кроме добавления дополнительного кода и сложности.

Кроме того, даже игнорируя здесь бесполезность шаблона репозитория, мне действительно не нравится, что метод FindOne () возвращает IQueryable <T>, и я действительно не вижу причины для этого.

Тогда есть проблема самой Спецификации. Это не совсем тянет собственный вес. Чтобы быть откровенным, это фактически опускает весь корабль, а не помогает любому. Например, давайте посмотрим на реализацию GetPostLastestForBoardById:

public class GetPostLastestForBoardById : Specification<Post>
{
private readonly DateTime _lastPost;
private readonly int _boardId;

public GetPostLastestForBoardById(DateTime lastPost, int boardId)
{
_lastPost = lastPost;
_boardId = boardId;
}

public override Expression<Func<Post, bool>> MatchingCriteria
{
get
{
return x =>
x.Time > _lastPost &&
x.Board.Id == _boardId;
}
}
}

Ты что, шутишь , всю эту инфраструктуру, все эти вложенные дженерики за что-то вроде этого? Это действительно то, что заслуживает своего собственного класса? Я так не думаю.

Хуже того, такой дизайн подразумевает, что есть смысл в совместном использовании запросов между различными частями приложения. И проблема с таким типом мышления в том, что эта предпосылка обычно ложна. За исключением запросов по идентификатору, большинство запросов в приложении имеют тенденцию быть довольно уникальными для своего источника, и даже когда они являются фактическим физическим запросом, они обычно имеют различное логическое значение. Например, «выбрать все поздние сроки аренды» — это запрос, который мы можем использовать как часть главного экрана системы аренды, а также как часть бизнес-логики для взимания платы за позднюю аренду. Проблема в том, что, хотя на первый взгляд этот запрос один и тот же, значение того, что мы делаем, обычно существенно отличается. И из-за этого попытка повторного использования этого запроса на самом деле вызовет у нас проблемы, У нас есть один кусок кода, который теперь имеет два мастера и две причины для изменений. Это не хорошее место, чтобы быть. Возможно, вам придется слишком часто прикасаться к этому куску кода, и вам придется усложнять его, потому что он имеет двойную цель.

Вы можете увидеть пример того, как это проблематично в методе выше. В своем последнем посте я указал, что в коде есть проблема SELECT N + 1. Для каждого из загруженных постов мы также загружаем имя владельца. NHibernate предоставляет простые способы решения этой проблемы, просто указав, какие пути выборки мы хотим использовать.

За исключением того, что этой архитектуре значительно сложнее создать что-то подобное. Слой абстракции хранилища скрывает API NHibernate, который позволяет указывать пути выборки. Таким образом, у вас есть несколько вариантов. Наиболее распространенный подход к этой проблеме обычно заключается в изменении архитектуры спецификаций, чтобы они также могли предоставить путь выборки для запроса. Это выглядит примерно так:

public class GetPostLastestForBoardById : Specification<Post>
{
private readonly DateTime _lastPost;
private readonly int _boardId;

public GetPostLastestForBoardById(DateTime lastPost, int boardId)
{
_lastPost = lastPost;
_boardId = boardId;
}

public override Expression<Func<Post, bool>> MatchingCriteria
{
get
{
return x =>
x.Time > _lastPost &&
x.Board.Id == _boardId;
}
}

public override IEnumerable<Expression<Func<Post, bool>>> FetchPaths
{
get
{
yield return x => x.Owner;
}
}
}

 

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

О, и мы даже не говорили о необходимости обрабатывать прогнозы.

Итак, мы можем сделать это по-другому, мы можем дать разработчику возможность настроить пути выборки для каждого запроса в том месте, где этот запрос сделан. Конечно, тогда вы получите следующую архитектуру:

образ

Если вы думаете, что это смешно, я согласен. Если вы так не думаете … ну, не берите в голову, мне сказали, что я на самом деле не могу отозвать чью-то лицензию Touch-The-Keyboard.

Но позвольте нам сказать, что вы как-то решили даже эту проблему, возможно, добавив еще один уровень абстракции при указании путей извлечения. В этот момент ваш код настолько абстрактен, что заслуживает того, чтобы его поместили в Музей современного искусства, но я позволю вам сойти с рук.

Проблема в том, что пути выборки на самом деле являются лишь частью решения для таких вещей, как Select N + 1. Одна из очень мощных опций, которую мы имеем с NHibernate, — это использование Future Queries, и мы можем использовать эту функцию, чтобы значительно сократить как количество времени, которое мы должны обращаться к базе данных, так и размер данных, из которых мы должны прочитать Это.

За исключением … этот дизайн означает, что я в значительной степени не в состоянии реализовать такого рода поведение. Мне придется кардинально изменить дизайн системы, прежде чем я смогу реально улучшить производительность.

Я вижу такую ​​проблему все время, когда делаю обзоры кода. И проблема в том, что это, как правило, проблема остановки корабля, потому что мы буквально не можем исправить вещи, не делая много очень болезненных изменений.

Теперь позвольте мне объяснить основную логику этого поста. Чтение из базы данных является обычной операцией, и ее следует рассматривать как таковую. Там должно быть очень мало абстракций участие, потому что не так много , чтобы абстрактно. В подавляющем большинстве случаев в операциях чтения нет логики, и даже когда она есть, чаще всего это сквозная логика. Ответ, который я даю для таких вещей, как «ну, как я могу применить фильтрацию безопасности», заключается в том, что вы добавляете это в метод расширения и делаете что-то вроде этого:

var query = 
(
from post in session.Query<Post>()
where post.DueDate > DateTime.Now
select post
)
.FilterAccordingToUserPermissions(CurrentUser)
.ToList();

 

Это позволяет очень легко просматривать такие запросы и видеть, что соответствующие действия были предприняты, и этот тип действия требуется только тогда, когда вы не можете обработать это как действительно часть инфраструктуры.

Также важно, что я говорю конкретно об операциях чтения здесь. Для операций записи ситуация (немного) иная. Операции записи обычно имеют бизнес-логику, и вы хотите сохранить это отдельно. Я все еще хотел бы избегать делать что-то слишком абстрактное, и я бы, конечно, не представил репозиторий для записей.

То, что я хотел бы сделать, это либо создать сервисный слой для записей, которые обрабатывают реальную задачу создания записей, но (и это важно ), я бы сделал это только в том случае, если на самом деле есть логика, которая должна быть выполнена. Если это просто простая операция CUD, которую мы выполняем, на самом деле очень мало смысла использовать сервис. Да, это означает использование NHibernate непосредственно из метода контроллера для сохранения того, что вы редактируете, при условии, что там нет ничего, что требует бизнес-логики.

Честно говоря, для частей, которые действительно требуют бизнес-логики, я бы, вероятно, предпочел бы избегать явной реализации сервиса и перейти к использованию внутренней публикации / подписки (издателя событий), чтобы иметь более хорошую внутреннюю архитектуру. Но в этот момент это больше предпочтение, чем все остальное.

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

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

Это уже не так, и настало время, чтобы наш код начал признавать этот факт.