Как мы из CRUD-движка сервис делали

в 10:55, , рубрики: .net, C#, SaaS / S+S, архитектура, кеширование метаданных, конструктор учётных систем, оптимизация, разграничение прав

Мы создаём онлайн-конструктор учетно-отчетных систем. Конструктор позволяет без программирования создать учётное веб-приложение со “стандартной” логикой. Под стандартной логикой имеется ввиду то, что в приложении не будет кнопок в виде бананов, которые делают ровно то, о чём вы подумали. Хотя при желании, логика приложения может быть расширена использованием языков программирования JavaScript (client side, server side), SQL (и вот тогда уже эти кнопки можно сделать).

В статье будут рассмотрены вопросы:

  • Выбора архитектуры приложения при переходе на сервисную модель. Точнее шардинг веб-сервера и бд между пользователями.
  • Оптимизация выполнения динамического кода. Т.е. того кода, который не знает к чему он обращается. Кода использующего метаданные для работы.
  • “Безопасная” архитектура (конечно относительно, как и всё связанное с этой темой) разграничения прав пользователей.
  • Сохранность данных пользователей.


Суть конструктора в том, что администратор системы определяет набор сущностей, задает набор полей (атрибутов) каждой сущности. Для каждой сущности на уровне БД будет сгенерирована таблица. И отдельно задает права доступа к ней в зависимости от множества факторов. В результате система сгенерирует интерфейсы CRUD для всех пользователей с учетом их прав доступа. Администратор также может настроить граф допустимых состояний (статусов) для сущности и переходов между состояниями. Такой граф по сути будет описывать бизнес-процесс движения сущности. Все интерфейсы по ведению бизнес-процессов тоже генерируются автоматически.

Изображения базового интерфейса системы

Как мы из CRUD-движка сервис делали - 1

Как мы из CRUD-движка сервис делали - 2

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

Изначально данный конструктор создавался “для себя”. Нам очень не хотелось для очередного заказчика создавать сотни таблиц, страниц CRUD, кучу разных отчётов, возможность разделения данных (строки и столбцы). При этом мы хотели каждый проект держать под своим тотальным контролем. Если нас просили сделать почти всё, что угодно, мы могли это сделать. Конструктор обеспечивал нам скорость разработки и ее дешевизну (разработчики ядра, системы почти не вникали в бизнес процессы), прикладную логику делали аналитики. То, что конструктор был своим, давало возможность сделать всё, что только душе угодно. При этом развивалось ядро системы.

Раньше для каждого нового потенциального заказчика мы разворачивали копию проекта. Устанавливали эту копию на его или наши сервера. Настраивали приложение в конструкторе под заказчика. Когда в очередной раз менеджер попросил меня создать еще десять экземпляров приложения (с разными URL адресами), чтобы показывать их разным клиентам, IIS занял всю оперативную память сервера. Скорость работы всех приложений ощутимо просела. Было ясно, что при грамотном администрировании IIS и при допиливании архитектуры конструктора можно выиграть какое то (может быть даже большое) количество ресурсов. Но мы поняли, что идём совсем не в том направлении.

Выбор архитектуры

Подумав детально о проблемах мы выделили три основных:

  1. Менеджерам хотелось создавать приложения, не обращаясь к программистам. А еще лучше, чтобы клиенты сами создавали приложения, не обращаясь к менеджерам.
  2. Сервер должен был выдерживать на порядок большие нагрузки при тех же ресурсах.
  3. Обновление системы должно происходить с меньшей болью. У нас было множество несвязанных проектов, которые приходилось сопровождать и обновлять по отдельности.

Далее мы устроили мозговой штурм. На нём были описано три основных варианта реализации.

Вариант 1:

Всё сделать в одном супер-приложении. Завести дополнительную сущность ApplicationEntity, все сущности системы (таблицы метаданных в БД) должны так или иначе ссылаться на свой ApplicationEntity. На веб сервере делать обработку по ограничению прав (кто что видит, кто что правит и т.п.).

Плюсы варианта 1:

  • Идеологически очень простая реализация. Из того, что было, сделать такую реализацию архитектуры наиболее просто. Для этого нужно воткнуть пару сотен if по всему проекту.
  • Простое развертывание новой системы. При создании системы в несколько таблиц метаданных вставляется некоторое количество строк, быстро и просто.
  • Систему очень просто обновлять. Один веб-сервер, одна база, обычный деплой.

Минусы варианта 1:

  • Решение выглядит очень неустойчивым (с точки зрения безопасности). Даже если реализовать правильно и грамотно всё это разделение прав между приложениями, то при любой модификации кода есть риск, что новая проверка будет неверной. В результате один пользователь получит доступ к части админки чужой системы.
  • Полученная супер-база будет крайне плохо масштабироваться. Если у нас появится очень много клиентов, то разделить базу на 2+ сервера можно будет только средствами шардинга/репликации СУБД. Это весьма ограничено, непрозрачно. Гораздо приятнее иметь разные базы для разных клиентов.

Вариант 2:

Из последнего минуса предыдущего пункта родилось следующее решение. Веб сервер сделать одним приложением, баз данных сделать множество (одну на клиента). Веб сервер ловит все запросы вида *.getreport.pro, по домену третьего уровня в мини БД маппере ищет connectionstring до нужной базы данных клиента. Далее система работает по старому, т.к. В базе, куда мы приконнектились лежат только данные по клиентской системе.

Плюсы варианта 2:

  • Разные базы для разных клиентов. Это безопасно. Веб сервер не может сделать ничего с чужой базой, если не знает connectionstring до неё. А если знает, то в штатном механизме конструктора идёт проверка прав пользователя внутри своей базы (это хорошо отлаженный инструмент, основа ядра системы).
  • Разные базы для разных клиентов. Это масштабируемо. При необходимости их можно разделить на разные машины без большой головной боли. Веб сервер масштабируется средствами IIS.
  • Систему достаточно просто обновлять. Один веб-сервер, обычный деплой. Code-first с включенными авто миграциями обновляет все базы до последней версии. Однако это менее прозрачный вариант обновлений, чем вариант с одной базой.
  • Возможность дать конкретному клиенту connectionstring до его БД, чтобы клиент чувствовал, что его база под его контролем, что он может делать с ней всё что угодно.

Минусы варианта 2:

  • За большим количеством баз сложнее следить. Надо проверять что все миграции на все базы накатились.
  • Достаточно хитрое по коду и продолжительное по времени развертывание новых приложений. Необходимо создавать целую новую БД (либо через code first, либо через restore эталонной бд), нового пользователя СУБД, давать права на новую базу новому юзеру и т.п.

Вариант 3:

Сервис-коробочный вариант. На сервере развертывается множество виртуальных машин. На каждой виртуальной машине развернуто одно отдельное веб-приложение со своей БД. Главный сервер занимается только пробросом запросов.

Плюсы варианта 3:

  • Полная изоляция приложений. Максимальная безопасность. Каждое приложение работает в своей песочнице.
  • При необходимости мы можем передать клиенту рабочую коробку со всеми его данными. Просто даем ему файл виртуалки.
  • Возможность масштабировать приложения почти бесконечно линейно. Т.е. В два раза больше серверов — в два раза выше производительность.
  • Простая развертка нового приложения — создать клон виртуалки и начать перебрасывать туда запросы.
  • Возможность дать конкретному клиенту connectionstring до его БД, чтобы клиент чувствовал что его база под его контролем, что он может делать с ней всё что угодно.

Минусы варианта 3:

  • Главный минус — производительность. Ранее мы создавали отдельное веб-приложение на проект, сейчас надо создавать целую виртуальную машину, со своей ОС и СУБД. CPU в принципе не должно проседать, но оперативной памяти надо действительно много. Обычно оперативная память на сервере — основной параметр при расчете цены.
  • Очень сложный деплой новой версии. Необходимо создавать отдельный скрипт «обновлятор». Который будет в цикле бегать по виртуалкам, останавливать веб-сервера, обновлять базу и бины. Либо делать такой «обновлятор» сервисом на каждой виртуалке, который сам следит за версией и обновляет веб-сервер беря данные по FTP (или как то так). В любом случае обновление версии системы с такой архитектурой — дело довольно весёлое.

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

Архитектура нашего варианта

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

Клиент делает запрос к серверу с URL адресом myapplication.getreport.pro, веб сервер обращается к БД, содержащей маппинги между URL адресами запросов и connectionstring до базы клиента. Каждый connectionstring основан на уникальном пользователе СУБД, у которого есть права только на свою БД.

Для минимизации запросов к базе этот маппинг помещен в кэш веб-сервера в структуру типа key-value (Dictionary на C#). Кэш постоянно обновляется раз в несколько секунд.

В коде и конфигах приложения отсутствуют другие connectionstring. При создании нового коннекта к БД приложению неоткуда взять данные для коннекта, кроме как из данного кеша. Так мы исключаем возможность промаха SQL запроса в чужую БД другого клиента.

Конструктор по умолчанию EntityFramework контекста делаем private. В итоге при любом обращении к базе мы должны явно указывать к какой базе делаем запрос. А поскольку в любом контексте доступен только один connectionstring (из метода GetClientConnectionString), то нам просто некуда промахнуться. Ниже приведена реализация метода GetClientConnectionString.

Реализация GetClientConnectionString

public static string GetClientConnectionString(this HttpContext context)
{
    #if DEBUG 
    if (Debugger.IsAttached && ConfigurationManager.ConnectionStrings[SERVICE_CONNECTION_STRING_NAME] == null)
    {
/*если мы в дебаге и приаттачены и не указан коннекшинстринг до базы сервиса (с маппингом URL в connectionstring), то мы берём имя базы для коннекта из конфига. Это удобно при разработке, чтобы каждый сидел в своей локальной базе*/
        return ConfigurationManager.AppSettings["DeveloperConnection-" + Environment.MachineName];
    }
    #endif

/*иногда в приложении необходимо в параллели выполнять несколько действий с БД. Например, при вытягивании большого количества данных в кеш, отправке уведомлений на почту и т.п. Для этого создаются отдельные Task, но у них нет HttpContext, мы не знаем URL с которого был подан запрос, либо запроса от клиента вообще не было. Данный код позволяет привязать к таску определённый connectionstring, и использовать его при доступе к данным. Структуры для передачи этих данных описаны ниже.*/
    if(context == null && TaskContext.Current != null)
    {
        //если мы в дочернем потоке, то он должен быть со стейтом!
        return TaskContext.Current.ConnectionString;
    }

/*если мы в режиме сервиса, то возьмём connectionstring из кеша*/
    if (context != null && ConfigurationManager.ConnectionStrings[SERVICE_CONNECTION_STRING_NAME] != null)
    {
        ServiceAccount account = context.GetServiceAccount();
        if (account == null)
        {
/*если мы обратились по адресу, приложения по которому не найдено, - connectionstring вернётся пустой, запросы сделать не получиться*/
            return null;
        }
        if (account.GetCurrentPurchases().Any() == false)
        {
/*если по запрошенному адресу приложение просрочено (не оплачено на текущий момент), connectionstring вернётся пустой, запросы сделать не получиться*/
            return null;
        }
        return account.ConnectionString;
    }
    else if (ConfigurationManager.ConnectionStrings[DEFAULT_CONNECTION_STRING_NAME] != null)
    {
/*если мы в режиме коробки, то возьмём connectionstring из конфига*/
        return ConfigurationManager.ConnectionStrings[DEFAULT_CONNECTION_STRING_NAME].ConnectionString;
    }
    else
    {
        return null;
    }
}

Реализация многопоточности для GetClientConnectionString (не всегда есть HttpContext)

/*сделано на основе http://stackoverflow.com/a/32459724*/

public sealed class TaskContext
{
    private static readonly string contextKey = Guid.NewGuid().ToString();

    public TaskContext(string connectionString)
    {
        this.ConnectionString = connectionString;
    }

    public string ConnectionString { get; private set; }

    public static TaskContext Current
    {
        get { return (TaskContext)CallContext.LogicalGetData(contextKey); }
        internal set
        {
            if (value == null)
            {
                CallContext.FreeNamedDataSlot(contextKey);
            }
            else
            {
                CallContext.LogicalSetData(contextKey, value);
            }
        }
    }
}

public static class TaskFactoryExtensions
{
    public static Task<T> StartNewWithContext<T>(this TaskFactory factory, Func<T> action, string connectionString)
    {
        Task<T> task = new Task<T>(() =>
        {
            T result;
            TaskContext.Current = new TaskContext(connectionString);
            try
            {
                result = action();
            }
            finally
            {
                TaskContext.Current = null;
            }
            return result;
        });

        task.Start();

        return task;
    }

    public static Task StartNewWithContext(this TaskFactory factory, Action action, string connectionString)
    {
        Task task = new Task(() =>
        {
            TaskContext.Current = new TaskContext(connectionString);
            try
            {
                action();
            }
            finally
            {
                TaskContext.Current = null;
            }
        });

        task.Start();

        return task;
    }
}

При входе пользователя в систему, она выдает браузеру клиента accessToken. Все запросы к системе, кроме запроса login происходят с использованием этого accessToken. Данные токены хранятся в клиентской БД, соответственно, токены от одного клиента не подойдут к другому. Токены представляют собой GUID, генерируемый при каждом login заново. Таким образом, подбор токена не представляется возможным. Именно такое разделение токенов на уровне разных БД и определение connectionstring из запроса и делает этот вариант архитектуры более безопасным, чем вариант с единой супер базой.

При запросе от клиента к серверу, после того как connectionstring определён, существует несколько уровней проверки безопасности. На первом уровне мы определяем класс AuthHandler: DelegatingHandler. У него есть метод SendAsync, который будет вызываться при каждом вызове любого метода API. Если при этом в хедерах запроса отсутствует поле accessToken, либо пользователь с таким accessToken не найден, то система выдаст ошибку даже не вызвав метод API контроллера.

Реализация AuthHandler

public class AuthHandler : DelegatingHandler
{
	protected async override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (request.Method.Method == "OPTIONS")
        {
            var res = base.SendAsync(request, cancellationToken).Result;
            return res;
        }

	//ищем все API контроллеры
        string classSuffix = "Controller";
        var inheritors = Assembly.GetAssembly(typeof(BaseApiController)).GetTypes().Where(t => t.IsSubclassOf(typeof(ApiController)))
            .Where(inheritor => inheritor.Name.EndsWith(classSuffix));
			
	//ищем все методы с атрибутом UnautorizedMethod. например "белая ссылка на файл" (по GUID файла)
        var methods = inheritors.SelectMany(inheritor => inheritor.GetMethods().Select(methodInfo => new {
            Inheritor = inheritor,
            MethodInfo = methodInfo,
            Attribute = methodInfo.GetCustomAttribute(typeof(System.Web.Http.ActionNameAttribute)) as System.Web.Http.ActionNameAttribute
        })).Where(method => method.MethodInfo.GetCustomAttribute(typeof(UnautorizedMethodAttribute)) != null);

	//определяем текущий ConnectionString пользователя до БД
        string connection = System.Web.HttpContext.Current.GetClientConnectionString();
        if (string.IsNullOrEmpty(connection))
        {
            var res = new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
            return res;
        }

	//делаем ли мы запрос к методу, который не надо проверять на авторизацию
        if (!request.RequestUri.LocalPath.EndsWith("/api/login/login") && 
            methods.All(method => !request.RequestUri.LocalPath.ToLower().EndsWith($"/api/{method.Inheritor.Name.Substring(0, method.Inheritor.Name.Length - classSuffix.Length)}/{method.Attribute?.Name ?? method.MethodInfo.Name}".ToLower())))
        {
            //если метод не login или любой другой без аттрибута UnautorizedMethod
            //данный код роутозависим, это плохо.. но роуты менять вроде не собираемся, да и UnautorizedMethod - метод разрешающий.
            //в хучшем случае метод отпадёт без авторизации
            var accessToken = request.GetAccessToken();
            
            var accountDbWorker = new AccountDbWorker(connection, null, DbWorkerCacheManager.GetCacheProvider(connection));
            var checkAccountExists = accountDbWorker.CheckAccountExists(accessToken);
            if (checkAccountExists == false)
            {
                var res = new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized);
                return res;
            }
            else
            {
		//продлить время жизни токена юзера в кеше. раз в пару минут кеш свапнет данные в БД
                AuthTokenManager.Instance.Refresh(request.GetTokenHeader());
            }
        }
        else
        {
            //methods without any auth
        }

	//стандартный обработчки события
        var response = await base.SendAsync(request, cancellationToken);
        return response;
    }
}

Далее вызывается код нужного контроллера, который вызывает соответствующий метод ядра DAL. Любой критически важный метод ядра (который меняет или просматривает данные) первым делом ищет пользователя в списке пользователей по его accessToken. Далее идёт проверка прав конкретного пользователя и выполнение основного кода метода. Тут всё стандартно.

Обновление версии

Т.к. приложение состоит из единого веб-приложения, то обновление веб части происходит быстро и просто. Любопытнее дело обстоит с базами данных. Мы используем подход Code First, так что при любом запросе пользователя на его БД будут накачены все миграции.

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

Скорость работы

Результатом всей этой работы стало то, что количество требуемой памяти в IIS для работы со всеми приложениями значительно снизилось. Не могу назвать точных цифр, но на сервер с 4ГБ оперативы легко влезает сотня бизнес-приложений пользователей (т.е. баз данных), и ещё влезет значительно больше. Система перестала проседать по памяти.

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

Далее нам стало интересно, можно ли как-то сократить количество запросов к схеме метаданных. Ответ мы получили отрицательный. Для построения запроса нужно знать название таблицы в БД, названия всех столбцов таблицы, роли текущего пользователя. Для каждой роли надо знать столбцы, которые ей позволено видеть. Множество дополнительных данных для определения строк, которые ей позволено видеть, не буду вдаваться в подробности, но тут реально много разных запросов к разным таблицам метаданных. И все эти метаданные нужны. Получение метаданных было почти на два порядка более длительно чем выполнение самого запроса.

Таких мест было не мало. Для проверки любых прав, нужны были те или иные метаданные. Подумав, мы решили закешировать все эти метаданные на сервере. Но это решение идейно сложнее, чем может показаться на первый взгляд. Предыдущие кеши, что я описывал, содержат очень маленькие наборы несвязных данных. По сути, они содержат простые мапы (URL -> connectionstring, accessToken -> account + Date). А этот кэш большой, сильно связанный, долго перестраиваемый, сложной структуры.

Если реализовать систему кеширования в лоб, то мы получим хитрую структуру (чтобы был быстрый доступ к данным через Dictionary по ключам), и сложный код по её заполнению. Во всех методах, которые модифицируют метаданные, придётся вызывать метод RefreshCache, а таких методов очень не мало. Было бы очень неприятно забыть вызвать такой метод. Сам код RefreshCache с реализацией в лоб тоже очень сурово большой и некрасивый. Это вызвано тем что нельзя написать всю структуру, что мы хотим получить в гигантский Include, затем запросить все данные из БД. Это работает нереально медленно. В итоге приходится разбивать запрос на кучу мелких запросов, затем склеивать NavigationProp в коде, это уже работает быстрее. В итоге, при добавлении новой сущности метаданных в систему, возникнет приличная головная боль: для всех CRUD методов вызывать RefreshCache, запилить новую сущность в этот метод, засунуть её во все NavigationProp у всех сущностей. Вообще говоря, нас это всё не радовало, но это бы помогло. Если видишь хороший ход — ищи ход получше.

Мы выделили две несвязные проблемы:

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

Первая проблема решилась путем внедрения в кишки EntityFramework. Мы переопределили метод SaveChanges у EntityFramework. Внутри него мы проверяем, изменили ли мы интересующие нас сущности. Если изменили — перестраиваем кеш.

Код автоматического трегкинга изменений нужных сущностей EntityFramework

/*типы данных, которые мы трекаем. String[] - поля, которые мы НЕ трекаем (неважные поля, которые часто изменяются)*/
static readonly Dictionary<Type, string[]> _trackedTypes = new Dictionary<Type, string[]>()
{
    { typeof(Account), new string[] { GetPropertyName((Account a) => a.Settings) } },
//..
    { typeof(Table), new string[0] },
};

//получаем название проперти, чтобы не хардкодить
static string GetPropertyName<T, P>(Expression<Func<T, P>> action)
{
    var expression = (MemberExpression)action.Body;
    string name = expression.Member.Name;
    return name;
}

/*проверяем, какие поля поменяли*/
string[] GetChangedValues(DbEntityEntry entry)
{
    return entry.CurrentValues
            .PropertyNames
            .Where(n => entry.Property(n).IsModified)
            .ToArray();
}

/*проверяем поменяли ли поля вне списка не интересующих полей*/
bool IsInterestingChange(DbEntityEntry entry)
{
    Type entityType = ObjectContext.GetObjectType(entry.Entity.GetType());
    if (_trackedTypes.Keys.Contains(entityType))
    {
        switch (entry.State)
        {
            case System.Data.Entity.EntityState.Added:
            case System.Data.Entity.EntityState.Deleted:
                return true;
            case System.Data.Entity.EntityState.Detached:
            case System.Data.Entity.EntityState.Unchanged:
                return false;
            case System.Data.Entity.EntityState.Modified:
                return GetChangedValues(entry).Any(v => !_trackedTypes[entityType].Contains(v));
            default:
                throw new NotImplementedException();
        }
    }
    else
        return false;
}
public override int SaveChanges()
{
    bool needRefreshCache = ChangeTracker
                            .Entries()
                            .Any(e => IsInterestingChange(e));

    int answer = base.SaveChanges();

    if (needRefreshCache)
    {
        if (Transaction.Current == null)
        {
            RefreshCache(null, null);
        }
        else
        {
            Transaction.Current.TransactionCompleted -= RefreshCache;
            Transaction.Current.TransactionCompleted += RefreshCache;
        }
    }
    return answer;
}

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

Пример 'плохого' кода кеширования

//Пример кода
var taskColumns = Task.Factory.StartNewWithContext(() =>
{
/*у нас свой конструктор контекста, в isReadOnly режиме мы убираем все авто подтягивания свойств, трекинг изменений и т.п.*/
    using (var entities = new Context(connectionString, isReadOnly: true))
    {
        return entities.Columns.ToList();
    }
}, connectionString);

var taskTables = Task.Factory.StartNewWithContext(() =>
{
    using (var entities = new Context(connectionString, isReadOnly: true ))
    {
        return entities.Tables.ToList();
    }
}, connectionString);

var columns = taskColumns.Result;
var tables = taskTables.Result;

/*сворачиваем в Dictionary, чтобы в цикле обращение было более быстрым (это сильно решает)*/
var columnsDictByTableId = columns
    .GroupBy(c => c.TableId)
    .ToDictionary(c => c.Key, c => c.ToList());

foreach(var table in tables) {
	table.Columns = columnsDictByTableId[table.Id];
}

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

Обобщённый кешировщик

//загрузить список сущностей
private Task<List<T>> GetEntitiesAsync<T>(string connectionString, Func<SokolDWEntities, List<T>> getter)
{
    var task = Task.Factory.StartNewWithContext(() =>
    {
        using (var entities = new SokolDWEntities(connectionString, isReadOnly: true))
        {
            return getter(entities);
        }
    }, connectionString);

    return task;
}

/// <summary>
/// принимает на вход список списков сущностей, в списке список каждого типа встречается только один раз
/// для всех свойств всех сущностей ищет в исходном списке списков сущность для привязки NavigationProp
/// и выполняет эту привязку. В общем делает тоже самое, что Include в EF, только быстрее.
/// Скорость обусловлена тем, что все данные уже подтянуты, мы точно знаем, что там есть всё что нам надо.
/// Мы немного завязаны на структуру привязки. Все сущности, на которые можно ссылаться, наследуются от класса BaseEntity.
/// BaseEntity содержит поле [Required][Key][DatabaseGenerated(DatabaseGeneratedOption.Identity)] public long Id { get; set; }
/// таким образом большинство Dictionary можно построить без использования рефлексии (что заметно быстрее)
/// </summary>
/// <param name="entities">список списков сущностей, которые надо связать между собой</param>
private void SetProperties(params object[] entities)
{
	//тип сущности -> список сущностей #entitiesByTypes[typeof(Account)] -> список аккаунтов
    var entitiesByTypes = new Dictionary<Type, object>();
	//dictsById[typeof(Account)][1] -> аккаунт с номером 1
    var dictsById = new Dictionary<Type, Dictionary<long, BaseEntity>>();
	//dictsByCustomAttr[typeof(Column)]["TableId"][1] -> все колонки, которые принадлежат таблице 1
    var dictsByCustomAttr = new Dictionary<Type, Dictionary<string, Dictionary<long, object>>>();
	//pluralFksMetadata[typeof(Table)] для сущности Table хранит список Свойств обратных ссылок
    var pluralFksMetadata = new Dictionary<Type, Dictionary<PropertyInfo, ForeignKeyAttribute>>();
	//needGroupByPropList[typeof(Column)] хранит список свойств, по которым надо делать Dictionary (по ним идёт ссылка на другую сущность)
    var needGroupByPropList = new Dictionary<Type, List<string>>();

    //заполняем entitiesByTypes и dictsById
    foreach (var entitySetObject in entities)
    {
        var listType = entitySetObject.GetType();
        if (listType.IsGenericType && (listType.GetGenericTypeDefinition() == typeof(List<>)))
        {
            Type entityType = listType.GetGenericArguments().Single();
            entitiesByTypes.Add(entityType, entitySetObject);

            if (typeof(BaseEntity).IsAssignableFrom(entityType))
            {
                //dictsById заполняем только для BaseEntity (только на такие сущьности можно ссылаться)
                var entitySetList = entitySetObject as IEnumerable<BaseEntity>;
                var dictById = entitySetList.ToDictionary(o => o.Id);
                dictsById.Add(entityType, dictById);
            }
        }
        else
        {
            throw new ArgumentException();
        }
    }

    //заполняем pluralFksMetadata и needGroupByPropList
    foreach (var entitySet in entitiesByTypes)
    {
        Type entityType = entitySet.Key;
        var virtualProps = entityType
            .GetProperties()
            .Where(p => p.GetCustomAttributes(true).Any(attr => attr.GetType() == typeof(ForeignKeyAttribute)))
            .Where(p => p.GetGetMethod().IsVirtual)
            .ToList();

        //обратные NavigationProp
        var pluralFKs = virtualProps
            .Where(p => typeof(IEnumerable).IsAssignableFrom(p.PropertyType))
            .Where(p => entitiesByTypes.Keys.Contains(p.PropertyType.GetGenericArguments().Single()))
            .ToDictionary(p => p, p => (p.GetCustomAttributes(true).Single(attr => attr.GetType() == typeof(ForeignKeyAttribute)) as ForeignKeyAttribute));
        pluralFksMetadata.Add(entityType, pluralFKs);
        foreach (var pluralFK in pluralFKs)
        {
            Type pluralPropertyType = pluralFK.Key.PropertyType.GetGenericArguments().Single();
            if (!needGroupByPropList.ContainsKey(pluralPropertyType))
            {
                needGroupByPropList.Add(pluralPropertyType, new List<string>());
            }
            if (!needGroupByPropList[pluralPropertyType].Contains(pluralFK.Value.Name))
            {
                needGroupByPropList[pluralPropertyType].Add(pluralFK.Value.Name);
            }
        }

        //прямые NavigationProp
        var singularFKsDictWithAttribute = virtualProps
            .Where(p => entitiesByTypes.Keys.Contains(p.PropertyType))
            .ToDictionary(p => p, p => entityType.GetProperty((p.GetCustomAttributes(true).Single(attr => attr.GetType() == typeof(ForeignKeyAttribute)) as ForeignKeyAttribute).Name));

        var entitySetList = entitySet.Value as IEnumerable<object>;
		//заносим данные в прямые внешние ключи (NavigationProp)
        foreach (var entity in entitySetList)
        {
            foreach (var singularFK in singularFKsDictWithAttribute)
            {
                var dictById = dictsById[singularFK.Key.PropertyType];
                long? value = (long?)singularFK.Value.GetValue(entity);
                if (value.HasValue && dictById.ContainsKey(value.Value))
                {
                    singularFK.Key.SetValue(entity, dictById[value.Value]);
                }
            }
        }
    }

    MethodInfo castMethod = typeof(Enumerable).GetMethod("Cast");
    MethodInfo toListMethod = typeof(Enumerable).GetMethod("ToList");

    //заполняем dictsByCustomAttr
    foreach (var needGroupByPropType in needGroupByPropList)
    {
        var entityList = entitiesByTypes[needGroupByPropType.Key] as IEnumerable<object>;
        foreach (var propName in needGroupByPropType.Value)
        {
            var prop = needGroupByPropType.Key.GetProperty(propName);
            var groupPropValues = entityList
                .ToDictionary(e => e, e => (long?)prop.GetValue(e));

            var castMethodSpecific = castMethod.MakeGenericMethod(new Type[] { needGroupByPropType.Key });
            var toListMethodSpecific = toListMethod.MakeGenericMethod(new Type[] { needGroupByPropType.Key });

            var groupByValues = entityList
                .GroupBy(e => groupPropValues[e], e => e)
                .Where(e => e.Key != null)
                .ToDictionary(e => e.Key.Value, e => toListMethodSpecific.Invoke(null, new object[] { castMethodSpecific.Invoke(null, new object[] { e }) }));
            if (!dictsByCustomAttr.ContainsKey(needGroupByPropType.Key))
            {
                dictsByCustomAttr.Add(needGroupByPropType.Key, new Dictionary<string, Dictionary<long, object>>());
            }
            dictsByCustomAttr[needGroupByPropType.Key].Add(propName, groupByValues);
        }
    }

    //заносим данные в обратные внешние ключи (NavigationProp)
    foreach (var pluralFkMetadata in pluralFksMetadata)
    {
        if (!dictsById.ContainsKey(pluralFkMetadata.Key))
            continue;
        var entityList = entitiesByTypes[pluralFkMetadata.Key] as IEnumerable<object>;
        foreach (var entity in entityList)
        {
            var baseEntity = (BaseEntity)entity;
            foreach (var fkProp in pluralFkMetadata.Value)
            {
                var dictByCustomAttr = dictsByCustomAttr[fkProp.Key.PropertyType.GetGenericArguments().Single()];
                if (dictByCustomAttr.ContainsKey(fkProp.Value.Name))
                {
                    if(dictByCustomAttr[fkProp.Value.Name].ContainsKey(baseEntity.Id))
                    fkProp.Key.SetValue(entity, dictByCustomAttr[fkProp.Value.Name][baseEntity.Id]);
                }
            }
        }
    }
}

//Without locking
private DbWorkerCacheData RefreshNotSafe(string connectionString)
{
    GlobalHost.ConnectionManager.GetHubContext<AdminHub>().Clients.All.cacheRefreshStart();

    //get data parallel
    var accountsTask = GetEntitiesAsync(connectionString, e => e.Accounts.ToList());
	//..
	var tablesTask = GetEntitiesAsync(connectionString, e => e.Tables.ToList());

    //wait data
    var accounts = accountsTask.Result;
	//..
	var tables = tablesTask.Result;
	
	DAL.DbWorkers.Code.Extensions.SetProperties
    (
        accounts, /*..*/ tables
    );
	
	/*тут уже из кеша строим "удобный кеш", который будет за O(1) получать нужные данные в нужном контексте*/
}

До написания данного гибкого метода кэширования, время выполнения RefreshCache на самой большой БД, которая у нас есть (600+ пользовательских таблиц, 100+ ролей), занимало порядка 10 секунд. После внедрения данного метода — 11.5 секунд. По сути всё дополнительное время занимает обращение к полям через рефлексию, но приост времени невелик. Возможно часть времени стало занимать то, что теперь все NavigationProp у всех сущностей инициируются, а раньше далеко не все. Преимущество данного подхода в том, что теперь можно написать table.Columns.First().Table.Columns… Т.е. Все ссылки внутри данного набора сущностей проанализированы, можно писать код не опасаясь внезапного NullReferenceException.

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

Сохранность данных

При переводе клиентов на сервис более остро встал вопрос сохранности их данных. Когда мы поставляли коробочные решения (отдавали bin), то вопросов по сохранности данных не возникало. У клиентов стояли свои сервера, свои админы, свои бекапы.

В облачной версии мы поступаем очень просто. У нас есть всего два типа сущностей, которые клиент боится потерять. Это его данные в БД, которые пользователь вводит, и файлы, которые он прикрепляет. И то и другое мы храним в Amazon S3. Файлы в принципе хранятся сразу там, заливаются туда через веб сервер, не хранятся на локальном диске. Когда пользователь скачивает файл, веб-сервер сам скачивает его из Amazon и отдает его клиенту. Каждую ночь бекапы всех оплаченных БД загружаются в Amazon. Раз в месяц я сам скачиваю все бекапы БД на локальную машину.

С точки зрения файлов, потеря данных возможна при их утере в Amazon S3. Данные из базы же должны пропасть из Amazon S3, веб-сервера и моей локальной машины.

Выводы

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

Зачем я написал этот текст

Это самая сложная и интересная система, которую лично я когда либо делал. Здесь описан лишь небольшой, но важный ее аспект (шаринг ресурсов между приложениями пользователей). На самом деле система достаточно велика и многофункциональна. Написание этого поста помогло систематизировать и осознать, что именно мы сделали и почему. Я думаю, что этот текст будет интересен тем, кто думает обобщить свои наработки до уровня сервиса. Полагаю что наши проблемы и решения не уникальны. Возможно кто то покритикует часть решений, предложит что то более правильное/быстрое/гибкое. В конечном итоге это может сделать нас сильнее. Ну и, конечно, мы хотели, чтобы о нас услышали. Вдруг, после прочтения этого поста, вы захотите зайти к нам в систему и посмотреть на неё. Сейчас мы запускаем партнерскую программу для разработчиков и ИТ-интеграторов (в моём профиле указан сайт нашей компании). Мы хотим чтобы наш конструктор учетных систем был полезен другим.

Варианты для дальнейших статей

  1. Не надо больше таких статей.
  2. Подсистема прав доступа (архитектура доступа объектам, работа с accessToken).
  3. Как мы сделали системы расширяемой (архитектура модулей программирования на системе).
  4. Сравнение с Zoho Creator.
  5. Ваши предложения.

Автор: hommforever

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js