• Главная
  • Контакты
Подписаться:
  • Twitter
  • Facebook
  • RSS
  • VK
  • PushAll

logo

  • Все
    • Положительные
    • Отрицательные
  • За сегодня
    • Положительные
    • Отрицательные
  • За вчера
    • Положительные
    • Отрицательные
  • За 3 дня
    • Положительные
    • Отрицательные
  • За неделю
    • Положительные
    • Отрицательные
  • За месяц
    • Положительные
    • Отрицательные
  • За год
    • Положительные
    • Отрицательные
  • Сортировка
    • По дате (возр)
    • По дате (убыв)
    • По рейтингу (возр)
    • По рейтингу (убыв)
    • По комментам (возр)
    • По комментам (убыв)
    • По просмотрам (возр)
    • По просмотрам (убыв)
Главная
  • Все
    • Положительные
    • Отрицательные
  • За сегодня
    • Положительные
    • Отрицательные
  • За вчера
    • Положительные
    • Отрицательные
  • За 3 дня
    • Положительные
    • Отрицательные
  • За неделю
    • Положительные
    • Отрицательные
  • За месяц
    • Положительные
    • Отрицательные
  • Главная
  • Как мы из CRUD-движка сервис делали

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

24.04.2017 10:55
hommforever 27 4700 Источник
SaaS / S+S*, C#*, .NET*
Мы создаём онлайн-конструктор учетно-отчетных систем. Конструктор позволяет без программирования создать учётное веб-приложение со “стандартной” логикой. Под стандартной логикой имеется ввиду то, что в приложении не будет кнопок в виде бананов, которые делают ровно то, о чём вы подумали. Хотя при желании, логика приложения может быть расширена использованием языков программирования JavaScript (client side, server side), SQL (и вот тогда уже эти кнопки можно сделать).

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

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

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

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




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

Изначально данный конструктор создавался “для себя”. Нам очень не хотелось для очередного заказчика создавать сотни таблиц, страниц 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, веб-сервера и моей локальной машины.

Выводы


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

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


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

UPD 1. Добавил опросник (в песочнице такой опции не было).
Варианты для дальнейших статей

Проголосовало 57 человек. Воздержалось 36 человек.

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

Поделиться с друзьями
-->

Комментарии (27)


  1. AndreySu
    24.04.2017 14:42
    #10188622

    Подсистема прав доступа (архитектура доступа объектам, работа с accessToken).
    Как мы сделали системы расширяемой (архитектура модулей программирования на системе).


  1. PQR
    24.04.2017 14:51
    #10188640

    Вопрос не по теме статьи непосредственно, а по самому сервису: расскажите из вашего опыта, что конкретно клиенты чаще всего строят с помощью такого конструктора? В целом всё выглядит достаточно универсально, можно придумать миллион вариантов приложений, но интересно узнать про реальные истории, кем востребовано? Например, CRM систем на рынке полно и все они гибкие и настраиваемые, так что мне кажется не очень эффективно было бы собирать свой наколеночный вариант в подобном конструкторе. Но уверен, есть ниши и задачи, где «CRUD as a service» отлично подходит — каковы они?

    В качестве дальнейших статей проголосую за:
    3. Как мы сделали системы расширяемой (архитектура модулей программирования на системе).
    4. Сравнение с Zoho Creator.


    1. hommforever
      24.04.2017 15:20
      #10188708

      Пока у нас несколько классов клиентов.
      1) На предприятиях где раньше использовался Access. Когда необходимо сделать быстро БД с интерфейсом и вести учёт.
      2) В организациях, в которых есть сбор данных с подотчётных учреждений. Обычно сбор осуществляется через Excel. Потом данные сводятся в единый отчёт. Во время свода обнаруживается множество ошибок (общая сумма не сходится, форматы чисел и дат различаются, данные занесены в неверных ячейках, опечатки).
      Системы подобно нашей могут заменить Excel с помощью конструктора отчётных форм. Данные попадают в единую базу. А доступ к ней разграничивается правами.
      3) На предприятиях со сложной и переменчивой структурой бизнес-процессов. Каждый человек должен видеть то, что должен по сложным правилам. Помимо CRUD у нас есть большое количество другого функционала (конструктор бизнес процессов, email уведомления, различные отчёты).


  1. VVizard
    24.04.2017 15:21
    #10188712
    +1

    Со стороны похоже на первые версии платформы 1С, в те далекие времена платформа задумывалась как система в которой бухгалтеры могли бы работать не привлекая программистов.

    Вообще все что описано в статье реализовано в 1С. Там реализованы базовые объекты (Справочники, Документы, Регистры). Мощная система управления доступом (RLS) которая состоит условно из 2х уровней:
    1. Ограничение прав на уровне ролей и объектов. (Более простой, позволяет задать правила вроде того что «Пользователю А доступен справочник Контрагенты для просмотра. Пользователю Б доступен справочник Контрагенты для просмотра и изменения».
    2. Ограничение на уровне данных. Позволяет задавать права а зависимости от данных в объекте, т.е.
    «Пользователю А доступен справочник Контрагенты для просмотра только если значение реквизита „Реквизит1“ равно „Значение1“. Причем выражения доступа могут быть сложными.

    Ну и само собой доступно кэшировавние метаданных и данных.
    Плюс поддержка множества разных СУБД, клиенты под Linux, MacOS, Android, iOS, WEB.
    Дополнительно мощная система отчетов.

    Из минусов только цена…


    1. hommforever
      24.04.2017 15:46
      #10188764
      +1

      Со стороны похоже на первые версии платформы 1С, в те далекие времена платформа задумывалась как система в которой бухгалтеры могли бы работать не привлекая программистов.

      Да, правда очень похоже. Мы хотим сделать тоже самое, но не для бухгалтеров, а для аналитиков и прикладных разработчиков.

      Вообще все что описано в статье реализовано в 1С. Там реализованы базовые объекты (Справочники, Документы, Регистры). Мощная система управления доступом (RLS) которая состоит условно из 2х уровней:
      1. Ограничение прав на уровне ролей и объектов. (Более простой, позволяет задать правила вроде того что «Пользователю А доступен справочник Контрагенты для просмотра. Пользователю Б доступен справочник Контрагенты для просмотра и изменения».
      2. Ограничение на уровне данных. Позволяет задавать права а зависимости от данных в объекте, т.е.
      «Пользователю А доступен справочник Контрагенты для просмотра только если значение реквизита „Реквизит1“ равно „Значение1“. Причем выражения доступа могут быть сложными.

      Наша система прав также содержит указанные уровни и покрывает описанные шаблоны. Мы так же сделали уровень 1.5. У каждого объекта создали особое поле, назвали его «Статус». И в простой версии конструктора даём права доступа ролям на статусы. В большинстве случаев это значительно упрощает раздачу прав (по сравнению с обычными условиями «Поле» = «Значение»). Хотя указанные условия мы тоже сделали.

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

      Вообще мы частично пересекаемся с 1С. Однако, не могу сказать что 1С наш основной конкурент. ZohoCreator, QuickBase, Caspio и другие low code платформы больше подходят на эту роль.


  1. hommforever
    24.04.2017 15:45
    #10188762

    del


  1. ostapbender
    24.04.2017 17:49
    #10188980
    +1

    Код очень не айс.


    GetClientConnectionString:


    • Извращение с "DeveloperConnection-" можно и нужно заменить на хранение строк соединения в переменных окружения
    • Обработка ситуации с httpContext == null — это нечто. Вы говорите про отдельные Task'и, но кто в здравом уме внутри таска будет получать строку соединения используя конструкцию, по смыслу похожую на ((HttpContext)null).GetClientConnectionString() ?
    • Куча ненужной логики: "режим сервиса", контроль оплаты, режим коробки

    TaskContext и прочее — это грубейшее нарушение SRP.


    AuthHandler на каждый запрос обходит Reflection'ом все типы, потом проходит по методам, выковыривает атрибуты… Да и остальная логика не блещет.


    Код "обобщенного кэшировщика" физически больно видеть.


    Архитектурные же решения — отправка писем, обновление схемы БД, кэш на "несколько секунд", Access Token'ы — чад и угар.


    1. hommforever
      25.04.2017 08:41
      #10189644

      Давайте по порядку.

      Извращение с «DeveloperConnection-» можно и нужно заменить на хранение строк соединения в переменных окружения

      У данного подхода есть недостатки перед переменными окружения, это правда. То, что все коннекшины всех девелоперов лежат в общем конфиге может стать проблемой. Если бы разработчиков было (или когда их будет) 50, конечно решение будет другим. Нас четверо. Имена машин у всех разные. Коннекшины почти никогда не меняются. Они лежат в одном месте. Могут быть скопированы одним разрабом от другого. В целом Вы правы что в переменные окружения вынести коннекшины лучше. Однако сейчас я не вижу критичных проблем (либо даже неудобств), связанных с этим.
      Обработка ситуации с httpContext == null — это нечто. Вы говорите про отдельные Task'и, но кто в здравом уме внутри таска будет получать строку соединения используя конструкцию, по смыслу похожую на ((HttpContext)null).GetClientConnectionString() ?

      Конечно вызов идёт не так! Семантически правильный вызов. Extensions.GetClientConnectionString(context: null). Это по Русски можно прочесть «получить ConnectionString без HttpContext». Логику того, что любой ConnectionString в системе получается с помощью единого метода я готов отстаивать. Пока не вижу контраргументов. Могли бы Вы подробнее написать чем Вам не нравится данный подход и чем он грозит.
      Куча ненужной логики: «режим сервиса», контроль оплаты, режим коробки

      Повторю мысль. Это единственный метод, который используется для получения ConnectionString. Для коробочных версий используется абсолютно тот же код. Если оплата просрочена – система не работает. Остальная часть системы просто получает ConnectionString, и не знает «режим работы системы». Всё это сделано фактически без if программирования во всех API методах проекта. Я не понимаю Вашего предложения, как Вы видите изменение его архитектуры?
      TaskContext и прочее — это грубейшее нарушение SRP.

      Вы про принцип единственной ответственности ? Поясните пожалуйста подробнее что именно нарушено, мне очень хочется стать сильнее и сделать сильнее проект. TaskContext используется только для запоминания ConnectionString. GetClientConnectionString используется только для получения ConnectionString в любом контексте.
      AuthHandler на каждый запрос обходит Reflection'ом все типы, потом проходит по методам, выковыривает атрибуты… Да и остальная логика не блещет.

      Про Reflection согласен, пропустили ошибку. Точнее потенциальный «тормоз». Не заметили потому что пока это себя пока не проявило, но конечно эту часть мы перепишем (на AppStart). Про остальную логику не понял, могли бы Вы пояснить подробнее.

      Код «обобщенного кэшировщика» физически больно видеть.

      Он сложный, тут спора нет. Он не длинный. Он «атомарный», в том смысле что он делает одну понятную вещь (маппит проперти между полученными списками). Он достаточно быстрый. Объясните пожалуйста, что именно Вам в нём не нравится, как на уровне идеи Вы предлагаете его исправить. Может быть Вам было бы приятнее читать его с комментариями? Это поправимо. Лично мне нравится то, что его сложность скрыта в нём самом. Фактический же код самого кешировщика стал очень простой.
                  DAL.DbWorkers.Code.Extensions.SetProperties
                  (
                      accounts, account2AccountGroups, accountGroups, rightsForTable, 
                      transfersStatus, statuses, columnGroups, tables, 
                      columns, fks, transitions, cellTemplates, triggersCodes,
      /*…………………………………………………*/
                  );
      

      Там не мало сущностей, вручную выставлять им навигейшины – это действительно больно. При добавлении нового навигейшина его надо тоже замаппить.
      Архитектурные же решения — отправка писем, обновление схемы БД, кэш на «несколько секунд», Access Token'ы — чад и угар.

      Давайте по порядку. Архитектура отправки писем по сути это джоба FluentScheduler . Каждая такая джоба пишется прикладным разработчиком, на серверном языке программирования. Указывается периодичность выполнения. Для каждой БД джоба ищет задачи на выполнение, выполняет нужные в рамках ConnectionString нужной БД. В коде джобы получает письма, которые надо послать и пишет их в БД. Другая джоба ищет письма для отсылки и рассылает их. Что тут сделано некорректно по Вашему мнению?

      Обновление схемы БД происходит вообще само, через CodeFirst. Баз у нас потенциально очень много. Происходит по первому запросу к конкретной БД. В чём Вы видите проблему этого решения?

      Про AccessToken’ы я практически ничего не писал. Кроме того, что она представляют собой GUID, генерируются при логине в систему, имеют срок жизни и имеют несколько уровней проверки. Кода я практически не предоставил по этой теме. Почему этих данных Вам хватило чтобы архитектура решения Вам не понравилась?
      Напишите пожалуйста подробнее что плохого и чем оно грозит. Потенциально тема крайне важная.

      Вообще я совсем не против критики, она сделает сильнее меня и мой проект. Однако я бы хотел лучше её понять, в чём проблема, чем грозит. Почему надо делать так, а не так. Заранее Спасибо!


      1. ostapbender
        25.04.2017 12:06
        #10190014

        У данного подхода есть недостатки перед переменными окружения, это правда.… Они лежат в одном месте.

        Где хранится строка соединения с боевой БД? Как устроен процесс деплоя и кто подменяет строку соединения?


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

        Я не против единого метода. Я против того, чтобы этот метод был как-то связан с HttpContext'ом — хотя бы из соображений тестируемости. Ну и вызов Extensions.GetClientConnectionString(context: null) выглядит дико.


        Повторю мысль. Это единственный метод, который используется для получения ConnectionString. Для коробочных версий используется абсолютно тот же код. Если оплата просрочена – система не работает. Остальная часть системы просто получает ConnectionString, и не знает «режим работы системы».

        Повторюсь: это плохой метод (GetServiceAccount(), скорее всего, не лучше). Если метод занимается получением строки соединения, то проверять режим работы системы и контроль оплаты — не его ответственность. В кровавом энтерпрайзе это называется Cross-Cutting Concern.


        Я не знаю всей специфики предметной области, но могу предположить, что, например, веб-часть должна как-то сообщать пользователю о том, что доступ в систему не оплачен. Такое поведение гораздо красивше сделать Action Filter'ами.


        Режим же "сервиса", скорее всего, разительно отличается от режима работы веб-части, и поэтому к нему нужен особый подход. Опять же, не зная специфики я деталей не скажу, но чую, что что-то у вас нечисто.


        TaskContext используется только для запоминания ConnectionString.

        Не надо нигде его запоминать. Глобальное состояние — зло. И, скорее всего, вам нужна не строка соединения, а DbContext (или, простихосспади, репозиторий, шоб он был здоров) — а за ними сходите в соответствующий сервис или попросите DI-контейнер вам всё подсунуть.


        Про остальную логику не понял, могли бы Вы пояснить подробнее.

        Я, честно говоря, не знаю, откуда начинать. AuthHandler знает очень много пикантных деталей реализации (типа необходимости вызывать GetCacheProvider(), умения формировать URL'ы, логики обработки null'овых результатов вызова GetClientConnectionString() и т.д.). Очень грязный и запутанный код.


        Код «обобщенного кэшировщика»... Объясните пожалуйста, что именно Вам в нём не нравится, как на уровне идеи Вы предлагаете его исправить.

        Прежде всего, не уверен, что он вообще нужен. Больше похоже на попытку прикрыть низкую производительность Entity Framework. Хотя, например, если бы сделать нормальный batch'инг и за один roundtrip к БД загружать как можно больший граф объектов, то кэша, может быть, и не потребовалось бы. Или он был бы в разы проще и хранил только иммутабельные справочные данные.


        … Другая джоба ищет письма для отсылки и рассылает их.

        Давайте хотя бы об этом. Письма правильно рассылать не джобами, а через очередь. Фронтенд публикует "сообщения" типа "пользователь добавил комментарий к документу", это сообщение получает модуль, ответственный за формирование писем, компонует письмо — с правильным текстом, вложениями и т.д. — и публикует сообщение типа "электронное письмо подготовлено к отправке". Это сообщение получает "отправлятор" и медленно и неспешно отправляет письмо в SMTP-сервер. При этом фронтенд вообще не занимается долгоиграющими задачами.


        Обновление схемы БД происходит вообще само, через CodeFirst. Баз у нас потенциально очень много. Происходит по первому запросу к конкретной БД. В чём Вы видите проблему этого решения?

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


        1. hommforever
          25.04.2017 13:07
          #10190142

          Некоторые пункты пропущу, т.к. считаю их субъективными.

          Где хранится строка соединения с боевой БД? Как устроен процесс деплоя и кто подменяет строку соединения?

          Есть боевая БД сервиса. К ней доступ лежит в конфиге. Если есть ConnectionString до базы сервиса (т.е. мы в режиме сервиса), то нужно получить ConnectionString до реальной базы клиента. Реальных баз на сервисе много, это физически разные базы данных с разными юзерами на уровне СУБД. У нас есть куча разных методов API, которые могут вызываться с клиентской части. Есть множество клиентов c приложениями по адресам client1.getreport.pro и client2.getreport.pro. В зависимости от URL адреса запроса надо взять правильный ConnectionString до базы клиента. GetServiceAccount берёт context.Request.Url.Host и ищет по нему ConnectionString до базы клиента в базе сервиса.
          Если базы сервиса нет, то мы в режиме коробки, ConnectionString надо вернуть из конфига напрямую. В одном единственном месте есть проверка на просроченную лицензию и на существование приложения чтобы вернуть пользователю понятную страницу об ошибке. Остальные методы API, джобы и т.п. работать не должны, их просто некому вызывать (в теории), но если они будут вызваны – они не сработают. Главное понять, что нет одной боевой БД, их множество. Базы создаются «на лету» и умирают «на лету» .
          Процесс деплоя был описан. Там не мало шагов, но в основном – бекапятся все БД, заливаются новые бины. Базы накатываются сразу при рестарте IIS автоматом.
          Повторюсь: это плохой метод (GetServiceAccount(), скорее всего, не лучше). Если метод занимается получением строки соединения, то проверять режим работы системы и контроль оплаты — не его ответственность. В кровавом энтерпрайзе это называется Cross-Cutting Concern.

          Не понимаю, выше Вы написали что не против единого метода. Но что если в режиме коробки ConnectionString берётся из одного беста, а в режиме сервиса из другого. Притом код получения занимает пару десятков строк.
          Режим же «сервиса», скорее всего, разительно отличается от режима работы веб-части, и поэтому к нему нужен особый подход. Опять же, не зная специфики я деталей не скажу, но чую, что что-то у вас нечисто.

          Режим сервиса ничем (совершенно ничем!) не отличается от режима коробки. Абсолютно идентичный исходный код. Он не знает в каком он режиме. Работа идёт то с одной локальной БД, то с одной из кучи подменяемых (в зависимости от URL запроса).
          Прежде всего, не уверен, что он вообще нужен. Больше похоже на попытку прикрыть низкую производительность Entity Framework.

          За этим нужен абсолютно любой кешировщик. Суть в том, чтобы не тянуть данные, не писать Include по всем навигейшинам. Притом, что делать с инклудами в случае если мне надо данные более чем на один уровень? Вложенные инклуды – не очень хорошая идея. Кеш нужен даже на уровне идеи. Один запрос от пользователя должен быть одним запросом к БД, иначе производительность всегда будет ниже. Так уж вышло, что пользователь может посмотреть одну строку из БД, но чтобы её достать без кеша нужно гораздо больше данных, чем лежат в самой строке.
          Давайте хотя бы об этом. Письма правильно рассылать не джобами, а через очередь. Фронтенд публикует «сообщения» типа «пользователь добавил комментарий к документу», это сообщение получает модуль, ответственный за формирование писем, компонует письмо — с правильным текстом, вложениями и т.д. — и публикует сообщение типа «электронное письмо подготовлено к отправке». Это сообщение получает «отправлятор» и медленно и неспешно отправляет письмо в SMTP-сервер. При этом фронтенд вообще не занимается долгоиграющими задачами.

          Именно так и сделано. Джобы по факту две. Отправлятор именно такой как Вы описали, через очередь. Фронтэнд может публиковать сообщения. Но есть ещё одна джоба, которая тоже пишет письма. Т.к. юзеры хотят получать письма о том, что их подчинённого неделю НЕ было на работе. Заявка должна быть обработана сегодня к вечеру и т.п. Т.е. часто надо отсылать письма о бездействии или о скором событии в случае если в БД лежат «особые данные».
          Проблема как минимум в наличии прав на DDL у пользователя. И вообще: обновление схемы БД — это задача процесса развертывания, но никак не нормального режима работы.

          Только к своей БД. У всех клиентов свой инстанс БД, свой аккаунт доступа к ней, свой ConnectionString. Внутри своей БД они полные админы. Админ в системе может написать любой SQL запрос и выполнить его. При необходимости, мы даём этот ConnectionString клиентам. Самый базовый пример зачем оно может быть надо (хоть и крайне редко). При нажатии на кнопку в приложении удалить таблицу. Выглядит странно, но система позволяет запрограммировать такую кнопку через админку. То что Вам не понравиться такой функционал — возможно. Но это бизнес-требование “тотальный контроль БД админом системы, в хучшем случае через SQL он может сделать абсолютно любую кнопку”.


          1. Kolay_Net
            25.04.2017 14:33
            #10190334

            Можете рассказать как обстоит дело с защитой учетной записи администратора (так понимаю в вашем случае он может быть и разработчиком), используете ли вы двухфакторную аутентификацию или еще что-то?
            Столкнулся с такой проблемой, что стащив логин и пароль администратора злоумышленник может получить доступ ко всем важным данным и что еще хуже управлять ими.


            1. hommforever
              25.04.2017 14:43
              #10190366

              Да, планируем это ввести в будущем. Двухфакторная аутентификацию — вещь хорошая. Пока что ещё не успели.

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


              1. Kolay_Net
                25.04.2017 14:50
                #10190382

                Спасибо за ответ. Хорошая идея.


    1. rickytack
      25.04.2017 09:16
      #10189676

      Прелести хабра — бесплатный code review


      1. SbWereWolf
        26.04.2017 00:39
        #10191186
        -1

        +1

        часто этим злоупоребляю, и пофиг что карма в минусе


  1. SbWereWolf
    25.04.2017 02:42
    #10189536

    больше таких статей! :)
    увлекательное чтение.


    1. hommforever
      25.04.2017 08:54
      #10189652

      Спасибо. Я очень старался:)


  1. alexs0ff
    25.04.2017 09:19
    #10189686
    +1

    Про бэк понятно, а на чем написан frontend?


    1. hommforever
      25.04.2017 09:38
      #10189714

      AngularJS. Когда наш проект стартовал AngularJS показался нам самым перспективным фреймворком такого типа. Вообще, я им доволен (по сравнению со старым добрым JQuery и/или ASP.Net MVC), печалит только отсутствие возможности перехода на Angular 4. Возможно эмберами, бекбонами, беконами, бананами и прочими я был бы доволен больше, этого не знаю.


      1. alexs0ff
        25.04.2017 10:23
        #10189774

        На самом деле — круто, у самого несколько раз были мысли насчет такого проекта в мою прошлую организацию — «Конструктор Вэб форм для доступа к данным». Но потом как-то не срослось.


      1. Quilin
        25.04.2017 11:46
        #10189972

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


        1. hommforever
          25.04.2017 11:56
          #10189990

          Да, я постоянно об этом думаю. Разработчика на AngularJS, думаю, найти можно будет ещё очень долго. Особенно с учётом того что умный и опытный разработчик быстро въедет в любой адекватный фреймворк. А вот потенциальный конец поддержки экосистемы пугает сильнее.

          Пока останавливает то, что переход на новый фреймворк сейчас или через год не изменит стоимости перехода (ну или не очень сильно). Всё равно по сути заново переписывать. К примеру переход на .Net Core в этом плане, возможно, более критичен. Представляю переход на Angular 4, а через год выходит Angular 9000 Ember edition:)


          1. Quilin
            25.04.2017 14:31
            #10190322

            Как человек, который последние полгода пишет проект на .netcore — дико советую не спешить с этим делом, дождаться выхода netstandard2.0 и переезда ключевых игроков с RC на полноценные релизы. Лично меня очень пока нервирует необходимость сидеть на XUnit (хотя это конечно очень субъективно, но я поклонник NUnit), ThreadPool — только в отдельной зависимости, которая буквально месяц назад из беты вылезла, не всегда стабильная работа решарпера (он например ну очень любит подключать 4.5 в netstandard/netcoreapp проекты в виде рефренсов прямо в локальную ProgramFiles) на случайное нажатие Alt+Enter.

            Что касается версий ангуляра — это да. Поэтому я никогда и никому не посоветую его использовать. React+MobX или для меньшего хардкору — Vue.js, для которого куча вкусных webpack-loader'ов написано, что хоть на тайпскрипте из коробки пиши, хоть на jsx, если очень нравится.

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


            1. hommforever
              25.04.2017 14:57
              #10190404

              Про .netcore. Сам не работал, но общее мнение у меня схоже с Вашим. Хотя, полагаю ввиду неопытности, более оптимистичное. Спасибо за предупреждение.

              Что касается версий ангуляра — это да. Поэтому я никогда и никому не посоветую его использовать. React+MobX или для меньшего хардкору — Vue.js, для которого куча вкусных webpack-loader'ов написано, что хоть на тайпскрипте из коробки пиши, хоть на jsx, если очень нравится.

              Явно Вы не выразили мысль о том, что AngularJS был ошибкой, но похоже что Вы это имели ввиду.
              Если это так, то с этим я не согласен. Дело в том, что только знание задним числом позволяет так думать. Я реально считал, что на тот момент, AngularJS имеет наибольшие перспективы, огромное комьюнити и т. п. Вы не застрахованы с Vue.js от того, что создатели решать выпустить Vue2.js. Он будет с совсем другой архитектурой, и вообще всем лучше первого.

              Про фронтэнда разработчика Вы безусловно правы. Наверняка со временем придётся думать в сторону другого фронтэнда.


              1. Quilin
                25.04.2017 15:59
                #10190506
                +1

                Да, не хотелось быть столь категоричным насчет Angular. Старые раны, знаете ли, вырвалось =)


  1. ecto
    26.04.2017 19:25
    #10192754

    Раскажите, пожалуйста, насколько гибок функционал графа состояний?
    Как искали баланс между функциональностью и простотой управления/обучения.
    Что он позволяет делать, а какие задачи, например из известных вам сейчас, невозможно там реализовать.


    1. hommforever
      27.04.2017 18:34
      #10194416

      Ой, ну это целая статья наверное:). Попробую очень кратко изложить.

      Раскажите, пожалуйста, насколько гибок функционал графа состояний?

      Очень гибок. В принципе любой «железный» техпроцесс легко реализовать графом состояний. Под «железностью» имеется ввиду то, что набор состояний конечен и определён заранее. Мы можем задавать кто и когда может перевести состояние откуда и куда, а также мы можем задавать условия, при которых это возможно.
      Как искали баланс между функциональностью и простотой управления/обучения.

      Думаю, что ответ на этот вопрос кроется в том, что мы сами пользовались тем, что создавали. Часть ребят (аналитики) говорила нам о том, что им неудобно и почему. Мы думали, как это исправить. Мы смотрели на то, куда аналитики тратили своё время во время работы с системой. Места, на которые тратилось много времени, мы оптимизировали в первую очередь. Т.е. если что то не слишком удобно, но используется редко, то мы не очень сильно переживали по этому поводу. Если что то более менее удобно, но может быть улучшено и используется ежедневно и постоянно — этому уделяется значительное внимание.
      Что он позволяет делать, а какие задачи, например из известных вам сейчас, невозможно там реализовать.

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

      Если говорить не про права.

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

      Сложно делать интеграции с другими системами. Это возможно, но каждая интеграция — это отдельная история. Бывают классы систем, которые состоят чуть ли не из одних только интеграций — это долго, дорого, ну дальше вы знаете :). Интеграции мы делаем через методы на серверном коде (httpRequest, executeSQL, sendEmail). Про эту тему лучше написать в отдельной статье про «программирование на системе».

МЕТКИ

  • Хабы
  • Теги

SaaS / S+S

C#

.NET

оптимизация

архитектура

кеширование метаданных

конструктор учётных систем

разграничение прав

СЕРВИСЫ
  • logo

    CloudLogs.ru - Облачное логирование

    • Храните логи вашего сервиса или приложения в облаке. Удобно просматривайте и анализируйте их.
Все публикации автора
  • Как мы из CRUD-движка сервис делали +15

    • 24.04.2017 10:55

Подписка


ЛУЧШЕЕ

  • Сегодня
  • Вчера
  • Позавчера
05:26

One-shot промптинг. Как я начал вайбкодить в 10? раз быстрее +77

08:05

Самодельная паяльная станция с цифровой индикацией температуры на жесткой логике +47

08:00

Всё везде и сразу +41

08:34

Лабиринты текста как игровая механика, или как неэкранизируемая литература становится источником геймдизайна +34

06:54

Picodata: вторая жизнь in-memory баз данных +27

14:45

Переходим от legacy к построению Feature Store +26

05:15

Как я разработал расширение для браузера за 3 дня — и получил первого платного пользователя уже на следующий день +25

13:00

Массовые увольнения в российском IT: что на самом деле происходит в компаниях — взгляд CEO +24

09:01

ЦОД 2050: три реалистичные концепции развития дата-центров +24

07:58

Я нашёл огромную дыру в дейтинг-приложении, а разработчики попытались её скрыть +24

13:01

Введение в RawTherapee +21

09:34

Криптография эпохи Ренессанса: классика не стареет +18

07:00

LLM as a Judge: опыт оптимизации генератора описаний Pull Request +18

10:57

Вселенная дистрибутивов Linux: От Ubuntu до Arch, от Mint до Fedora – подробный гид по выбору +15

09:18

Распродажа в издательстве «Питер» +15

14:12

Как я сменил лопату на клавиатуру: мой путь в IT после 30 +14

07:00

Электрокэбы, полный привод и гонки: история дореволюционного автопрома +14

04:56

Кто выполняет функции системного аналитика в США? +14

06:09

Left Shift Testing: как выстроить процесс, чтобы тесты реально помогали +13

14:45

Интервью про ИИ, которое меня выбесило +12

14:22

Ещё 10 ошибок авторов Хабра +137

07:43

Больше нет входа в IT. Только выход +70

14:15

Мое производство электрощитов приносит 40 млн в год. Спасибо нейросетям и СССР за конструкторскую школу +63

05:16

Дело о Транзитроне — или Ламповый тьюториал для любопытных +48

08:01

Трамплин в интернет: как мы ускорили запуск Яндекс Браузера +44

13:06

Введение в Angie: краткая история и отличия от Nginx +42

03:22

Как за один вечер создать репутацию вашего стартапа в поисковой выдаче: 20 бесплатных площадок для быстрого буста +38

08:35

Распределённый инференс и шардирование LLM. Часть 1: настройка GPU, проброс в Proxmox и настройка Kubernetes +37

16:15

Как я написал эмулятор Nintendo Gameboy на C++ за две недели +33

07:00

DevOps без боли: 8 инструментов для мониторинга, автоматизации и стабильной работы команд +33

13:01

Как создавались вокальные эффекты Daft Punk +32

12:00

Story Points не работают? И другие мифы про оценку задач, в которые мы почему-то верим +32

06:53

Важное обновление BatteryTest 2 +32

04:53

Баффет наконец накопил достаточно для выхода на пенсию, а в OpenAI выкатили новый хитрый план +27

10:26

От релиз-менеджера до разработчика: почему я ушел из QA и не жалею +25

10:09

Как настраивать сети: готовые решения Selectel для максимальной отказоустойчивости +25

09:01

Почему из технологий делают культы +24

09:31

Как ESLint помогает управлять архитектурой проекта +23

15:16

Бирюзовые компании в РФ: как не посинеть в найме +22

12:08

Эффект душа: почему отдых и переключение на хобби помогают принимать крутые решения +22

ОБСУЖДАЕМОЕ

  • Больше нет входа в IT. Только выход +70

    • 482   120000

    Мое производство электрощитов приносит 40 млн в год. Спасибо нейросетям и СССР за конструкторскую школу +63

    • 162   62000

    Массовые увольнения в российском IT: что на самом деле происходит в компаниях — взгляд CEO +24

    • 157   34000

    Ещё 10 ошибок авторов Хабра +137

    • 125   7100

    Дело о Транзитроне — или Ламповый тьюториал для любопытных +48

    • 61   4800

    One-shot промптинг. Как я начал вайбкодить в 10? раз быстрее +77

    • 51   17000

    Бирюзовые компании в РФ: как не посинеть в найме +22

    • 49   46000

    Трамплин в интернет: как мы ускорили запуск Яндекс Браузера +44

    • 46   4100

    Быстрый алгоритм fulltext-поиска без токенизации +15

    • 41   2300

    Шесть лет на диване: мои выводы об удалённой работе -3

    • 38   13000

    Самодельная паяльная станция с цифровой индикацией температуры на жесткой логике +47

    • 36   4100

    Разбираем архитектуру. Часть 2. Чистая архитектура на примере FastAPI приложения +4

    • 32   3500

    Личный VPN: юзер ликует, VLESS смеётся, а РКН плачет +2

    • 31   16000

    Почему «Agile» и особенно Scrum ужасны +2

    • 31   3200

    Почему рекрутеры игнорят отклики? +1

    • 28   2200
  • Главная
  • Контакты
© 2025. Все публикации принадлежат авторам.