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

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

Если позволите, начну с небольшого исторического экскурса на 30 секунд, о том, как эта система появилась.

История

Общие концепции этой ORM появились в 2008 году, когда я только-только познакомился с новым для меня языком программирования C# и мне потребовалась база данных. Конечно, никаких ORM я писать не собирался, мне просто нужно было хранить данные и работать с ними. Удобнее всего, конечно, было бы разложить колонки таблиц по полям класса и потом уже работать с логикой класса.

Поначалу я вручную раскладывал данные из ридера, а затем также составлял из этих полей INSERT или UPDATE, когда приходило время сохранять объект. Мороки было много и хотелось делать это автоматически. К тому же иногда возникали досадные ошибки/опечатки, когда не то поле отправлялось не туда. Или же я просто забывал добавить поле во все места, взаимодействующие с БД, что приводило к "отложенным" ошибкам. И я их решил генерировать из структуры самой базы.

Много позже, когда я познакомился с концепцией ORM, я узнал, что этот подход называется database-first, в противовес code-first. При работе приложения с базой данных в любом случае возникает вопрос согласования структуры данных приложения и структуры самой БД. Это вопрос фундаментальный и он никуда за годы работы не делся. Тут варианта ровно два: или брать за основу структуру базы (что я и сделал), либо считать оригиналом структуры код. В любом случае что-то одно должно быть сгенерировано. Ах, да, есть ещё подход model-first, но о нём постараюсь рассказать в следующей статье.

Я решил распарсить структуру таблиц MySql и сгенерировать data-классы для C#. Помимо самих классов, генерировались структуры, отвечающие за получение объектов из ридера и за составление insert/update команд. Это уже было проще и надёжнее. Я правил руками структуру БД и запускал кодогенератор. Кодогенератор полностью переписывал файл с определением классов, сопоставляя типы данных MySql типам данных шарпа. Благодаря partial классам можно было держать ручками написанную логику в другом файле, оставив под переписывание только акцессоры.

Следующая проблема, которую нужно было решить - это синхронизация данных между клиентами одной БД. Это сейчас я знаю, что в этом случае надо писать сервер приложений и делать все изменения данных через него. Тогда такого понимания не было. Зато был MyISAM, у которого "из коробки" был таймстемп обновления каждой таблицы. Что меня подтолкнуло им воспользоваться.

Задача была простая: вот Петя вносит данные в своём клиенте, нужно, чтобы клиент Васи увидел, что данные изменились и показал их. И это было сделано максимально тупо и топорно. Каждые 10 секунд опрашивалась та самая "метатаблица" MySql, где хранилась информация о таблицах MyISAM. Дата последнего изменения сравнивалась с предыдущей и в случае отличия, давалась команда перерисовать список чего-то там. Даже если это была не таблица чего-то там, а связанных сущностей, например, сотрудников.

И оно прекрасно работало! До тех пор, пока данных было мало и информационная система была несложной. Дальше стало понятно, что перечитывать таблицу целиком - это, мягко говоря, не лучшая затея. Особенно, когда данные прибывают с каждым днём. Да и задержка обновления в 10 секунд - это много. Позже ещё наступит на хвост переход с MyISAM на InnoDB, но это будет потом. А в этот момент и так было ясно, что так дело не пойдёт, надо что-то делать.

Решение: качать только изменённые данные. Для этого те самые данные придётся закешировать. Ничего умнее, чем держать их в словаре типа Dictionary<int,T> я не придумал (позже Dictionary поменяется на List ради существенной экономии памяти). Конечно, тут уже появились ограничения. Таблица должна была начинаться с поля "ID" типа int. Оно ещё должно быть primary key и auto_increment. Новые данные можно выгребать, используя максимальный ID объекта, а изменённые?

Пришлось добавить ещё обязательный таймстемп в каждую таблицу. Теперь по таймеру проверялась "метатаблица" и по изменённым таблицам пробегал select вида "WHERE ID > %maxId% OR Updated > %maxUpdated%". За счёт резкого снижения трафика можно было запрашивать изменения раз в секунду, что вполне приемлемо.

Правда выползла ещё одна проблема. Перед тем, как клиент может приступить к синхронизации, данные нужно закешировать. Выползла эта проблема тогда, когда во вью начали попадать данные, ссылающиеся на другие данные, которые ещё не загрузились. Конечно, сначала был "KeyNotFoundException", но он был решён моим "инновационным" подходом, который впоследствии оказался банальным "null object pattern".

Я начал запрашивать данные не у словаря напрямую, а у специального репозитория. И если репозиторий не находил объект с таким айди, он отдавал "null object" - созданный пустой объект этого типа. Так я научился не волноваться и полюбил NULL, так как null object позволял обращаться ко всем полям, не втыкая проверки перед каждым обращением. Я мог строить цепочки зависимостей, переходить от заказа к заказчику и от заказчика к его данным, не переживая про то, что где-то может быть null. Его просто не могло быть, я мог только получить пустую строку вместо имени.

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

Примерно на этом этапе я познакомился с концепцией ORM. Немного поизучал Enity Framework и для себя решил

нам ваш ORM совсем не нужон
нам ваш ORM совсем не нужон

Самое забавное, конечно, что к этому моменту у меня уже был написан свой ORM, хоть и весьма специфический, но удовлетворявший мои потребности в работе с данными. Но я упорно считал, что все эти заумности не нужны в реальной разработке. И вообще, "некогда пилу точить, пилить надо". Ради справедливости, надо отметить факт, что синтаксис доступа к данным в моей ORM был намного проще, чем в EF. У меня был статик репозиторий с поддержкой IEnumerable<T>, что давало возможность использовать linq to objects прямо из репозитория. У EF надо было объявлять контекст.

Синтаксис использования ORM был такой, как будто "базы данных не существует". Это и был принцип построения информационной системы. Я просто брал данные из репозитория. Как хотел и сколько было нужно. Это было быстро. Очень быстро. Безумно быстро. Что не удивительно - данные были уже в памяти, не было никаких запросов к базе и, как следствие - сетевых задержек. Доступ по ID - O(1). Выборка по условию - O(N). Не было проблемы N+1. Если вдруг нужен был джоин, то джоин был не нужен (простите за каламбур). Взятая сущность уже ссылалась на уже существующую. Нужно было добавить объект - я его добавлял в репозиторий и у этого объекта волшебным образом появлялся ID (да, надо сделать ремарку, что результат инсерта не был мгновенным, это был действительно инсерт с селектом last_insert_id). Надо было поменять поле - я просто менял поле. Да, вот так, просто присваивал полю новое значение. Оно менялось на всех клиентах в ту же секунду.

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

Для этого у меня был создан низкоуровневый класс, который ещё не умел кэшировать, но уже умел маппить данные. В параметрах запроса просто указывался текстом sql фильтр на данные вида "WHERE YEAR(Orders.Created) > 2012", а контейнер уже собирал select целиком, получал данные и раскладывал по полям класса. IEnumerable природа контейнера прямо таки подстёгивала сделать этот механизм через yield return и получилась довольно-таки шустрая конструкция "конвеерного" типа, которая передавала только что полученные данные в клиентский код бизнес-логики по мере получения их из ридера.

Но после знакомства с EF мне очень понравилась концепция linq2db. Это когда к репозиторию пишешь запрос вполне себе как к IEnumerable, а он внутри себя этот запрос транслирует в sql и возвращает тебе массив нужных тебе объектов. Я честно пытался расковырять DbLinq и вытащить из него то, что надо, но быстро запутался в хитросплетении кода. Решил, что проще самому написать транслятор, что и было сделано.

Транслятор парсил expression tree и то, что понимал, транслировал в sql. Понимал он, к слову, не так уж и много. Он мог собрать простенький WHERE, отсортировать по ORDER BY (даже понимал DESC) и добавить LIMIT. Соответственно, транслировать он мог со стороны шарпа Where, Order/OrderByDesc, Take/Skip, а также First/Last/FirstOrDefault/LastOrDefault. Я не буду приводить код транслятора, потому что он состоит преимущественно из навоза и валежника. Прелесть его была в том, что он был маленький. Очень компактный, буквально 2-3 экрана кода.

Ещё он умел сугубо специфические штуки, вроде трансляции enum и некоторых специфичных для приложения типов. То есть если я запрашивал "Sql.Calculations.Where(c => c.State == CalculationState.New)", то в sql появлялось "WHERE c.State = 1". Что мне очень нравилось, я боролся за читаемость кода.

Устройство

Это был уже четвёртый тип репозитория, который я внедрил в информационную систему. У трёх из них синтаксис был примерно одинаковый. Приведу типы репозиториев явно, чтобы не запутаться:

  • Table<T>. Низкоуровневый механизм доступа с маппингом. Работает с "сырым" sql, но в результате выдаёт типизированные объекты.

  • CachedContainer<T> - ленивый репозиторий. При первом обращении запрашивает все данные из таблицы. При последующих проверяет актуальность данных путём сверки last_updated. Идеален для крохотных таблиц, которые крайне редко меняются и нужны в быстром доступе. Работает поверх Table<T>.

  • TrackedContainer<T>. "Горячий" репозиторий. Загружается единожды при старте клиента и далее только поддерживается актуальность данных. Основная "рабочая лошадь" информационной системы. Работает поверх Table<T>.

  • LinqContainer<T>. "Холодный" репозиторий. Транслирует expression tree в sql запрос, получает данные, маппит на сущности и выдаёт результат. Работает поверх Table<T>.

Кодогенератор создавал заново файл с генерированными классами сущностей, не забывая даже перетягивать комментарии из БД. Выглядело это примерно так:

Генерированная сущность
public partial class Brand : sqlobject
{
   	#region Private members

    /// <summary>;
    /// ID организации заказчика
    /// </summary>;
    private int customerID;
    
    /// <summary>;
    /// Категория бренда (Keywords)
    /// </summary>;
    private int categoryID = 0;
    
    /// <summary>;
    /// Люксовый бренд
    /// </summary>;
    private bool luxury;
    
    #endregion
    
    #region Properties
    
    /// <summary>;
    /// ID организации заказчика
    /// </summary>;
    public int CustomerID
    {
    	get { return customerID; }
    	set { if (customerID != value){ customerID = value; Update("CustomerID", value);}}
    }
    
    /// <summary>;
    /// Категория бренда (Keywords)
    /// </summary>;
    public int CategoryID
    {
    	get { return categoryID; }
    	set { if (categoryID != value){ categoryID = value; Update("CategoryID", value);}}
    }
    
    /// <summary>;
    /// Люксовый бренд
    /// </summary>;
    public bool Luxury
    {
    	get { return luxury; }
    	set { if (luxury != value){ luxury = value; Update("Luxury", value);}}
    }
    
    #endregion
    
    #region Methods

    public override bool Download(FieldReader fieldReader)
    {
    	bool changed = false;
    	if (fieldReader.Read(ref customerID, "CustomerID")) { changed = true; OnPropertyChanged("CustomerID"); }
    	if (fieldReader.Read(ref categoryID, "CategoryID")) { changed = true; OnPropertyChanged("CategoryID"); }
    	if (fieldReader.Read(ref luxury, "Luxury")) { changed = true; OnPropertyChanged("Luxury"); }
    	return changed;
    }
    
    public Brand CopySqlFields()
    {
    	var copy = new Brand();
    	copy.Name = Name;
    	copy.CustomerID = CustomerID;
    	copy.CategoryID = CategoryID;
    	copy.Luxury = Luxury;
    	return copy;
    }

    #endregion
}

Кто открывал спойлер, заметил, что есть некие странности с булом в методе Download и вызове Read. Это просто поддержка INotifyPropertyChanged, встроенная в сам ORM. Не знаю, насколько это правильно, но меня всё устраивало )

Update
protected void Update(string column, object value)
{
	if (Updates == null) Updates = new Dictionary<string, object>();
	Updates[column] = value;
	OnPropertyChanged(column);
	sql.ItemChanged(GetType(), this);
}

Собственно, сам апдейт. Он складывает изменённые значения поля обжектами в нетипизированный словарь, откуда изменения полетят в базу данных через 30мс. Типизация тут не очень нужна, потому что AddParameter коннектора всё равно на входе принимает object.

Может ещё будет интересно, как работает FieldReader. Ничего сверхъестественного, просто перегрузка метода Read под каждый тип:

FieldReader
public class FieldReader
{
	public HashSet<string> Fields = new HashSet<string>();
	private MySqlDataReader dataReader;
    public FieldReader(MySqlDataReader dataReader)
    {
    	this.dataReader = dataReader;
    	for (int i = 0; i < dataReader.FieldCount; i++) Fields.Add(dataReader.GetName(i));
    }
    
    public int GetId()
    {
    	if (Fields.Contains("ID"))  return dataReader.GetInt32("ID");
    	return 0;
    }
    
    public bool Read(ref int field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	var val = dataReader.GetInt32(column);
    	if (field == val) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref int? field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	int index = dataReader.GetOrdinal(column);
    
    	int? val = null;
    	if (!dataReader.IsDBNull(index))
    		val = dataReader.GetInt32(index);
    
    	if (field == val) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref bool field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	var val = dataReader.GetBoolean(column);
    	if (field == val) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref DateTime field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	int index = dataReader.GetOrdinal(column);
    
    	//nullable DateTime converting to minvalue
    	DateTime val = DateTime.MinValue;
    	if (!dataReader.IsDBNull(index))
    		val = dataReader.GetDateTime(column);
    	if (field == val) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref string field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	var val = dataReader.GetString(column);
    	if (field == val) return false;
    	field = string.Intern(val);
    	return true;
    }

    public bool Read(ref Guid field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	var val = dataReader.GetGuid(column);
    	if (field == val) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref decimal field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	var val = dataReader.GetDecimal(column);
    	if (field == val) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref Money field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	var val = Money.FromDecimal(dataReader.GetDecimal(column));
    	if (field == val) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref float field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	var val = dataReader.GetFloat(column);
    	if (Math.Abs(field - val) &lt; float.Epsilon) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref long field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	var val = dataReader.GetInt64(column);
    	if (field == val) return false;
    	field = val;
    	return true;
    }
    
    public bool Read(ref long? field, string column)
    {
    	if (!Fields.Contains(column)) return false;
    	int index = dataReader.GetOrdinal(column);
    
    	long? val = null;
    	if (!dataReader.IsDBNull(index))
    		val = dataReader.GetInt64(index);
    
    	if (field == val) return false;
    	field = val;
    	return true;
    }

}

Из интересного можно заметить, что FieldReader толерантно относится к отсутствующим полям. То есть исчезновение колонки в таблице не приведёт к падению всех работающих клиентов.

Окей, окей. Выложу транслятор запросов в sql. Всё-таки довольно мощный функционал в таком небольшом куске кода

QueryBuilder
public class QueryBuilder
{
	private readonly string tableName;
	private string where = "";
	private string orderby = "";
	private string orderbydesc = "";
	private int limit = 0;
	private string select = "*";
    public bool DefaultNull = false;
    public string Select =>; select;
    
    public string Conditions
    {
    	get
    	{
    		var cond = new List<string>();
    		if (where != "") cond.Add("WHERE " + where);
    		if (orderby != "") cond.Add("ORDER BY " + orderby);
    		if (orderbydesc != "") cond.Add($"ORDER BY {orderbydesc} DESC");
    		if (limit > 0) cond.Add("LIMIT " + limit);
    		return string.Join(" ", cond);
    	}
    }
    
    public QueryBuilder(string tableName)
    {
    	this.tableName = tableName;
    }
    
    public void AddExpression(Expression expression)
    {
    	var method = expression as MethodCallExpression;
    	var name = method.Method.Name;
    	var args = method.Arguments;
    	ParseCallExpression(args, name);
    }
    
    
    private void AddExpression(ref string expr, ReadOnlyCollection<Expression> args)
    {
    	if (!args.Any()) return;
    
    	var arg = args.Last();
    
    	if (!(arg is UnaryExpression lambda)) return;
    	if (arg.NodeType != ExpressionType.Quote) return;
    
    	var toadd = GetSqlExpression(lambda.Operand);
    	if (expr == "") expr = toadd;
    	else expr = expr + " AND " + toadd;
    }
    
    private void ParseCallExpression(ReadOnlyCollection<Expression> args, string name)
    {
    	switch (name)
    	{
    		case "Where":
    			AddExpression(ref where, args);
    			return;
    		case "OrderBy":
    			AddExpression(ref orderby, args);
    			return;
    		case "OrderByDescending":
    			AddExpression(ref orderbydesc, args);
    			return;
    		case "First":
    			AddExpression(ref where, args);
    			limit = 1;
    			DefaultNull = false;
    			return;
    		case "FirstOrDefault":
    			AddExpression(ref where, args);
    			limit = 1;
    			DefaultNull = true;
    			return;
    		case "LastOrDefault":
    			AddExpression(ref where, args);
    			limit = 1;
    			DefaultNull = true;
    			return;
    		default:
    			throw new NotImplementedException(name);
    	}
    }
    
    private string GetSqlExpression(Expression expr)
    {
    	if (expr is LambdaExpression predicate) return GetSqlExpression(predicate.Body);
    	if (expr is BinaryExpression bin)
    	{
    		var left = GetSqlExpression(bin.Left);
    		var right = GetSqlExpression(bin.Right);
    
    		if (bin.NodeType == ExpressionType.AndAlso) return left + " AND " + right;
    		if (bin.NodeType == ExpressionType.OrElse) return left + " OR " + right;
    		if (bin.NodeType == ExpressionType.Equal) return left + " = " + right;
    		if (bin.NodeType == ExpressionType.GreaterThan) return left + " < " + right;
    
    		throw new NotImplementedException(bin.NodeType.ToString());
    	}
    
    	if (expr is UnaryExpression un)
    	{
    		if (un.NodeType == ExpressionType.Not)
    		{
    			return "NOT " + GetSqlExpression(un.Operand);
    		}
    		if (un.NodeType == ExpressionType.Convert)
    			return GetSqlExpression(un.Operand);
    
    
    		throw new NotImplementedException(un.NodeType.ToString());
    	}
    
    	if (expr is MemberExpression ex)
    	{
    		if (ex.Expression.NodeType == ExpressionType.Parameter)
    			return tableName + ".`" + ex.Member.Name + "`";
    
    		//https://stackoverflow.com/questions/2616638/access-the-value-of-a-member-expression
    		var objectMember = Expression.Convert(ex, typeof(object));
    		var getterLambda = Expression.Lambda<Func<object>>(objectMember);
    		var getter = getterLambda.Compile();
    		var value = getter();
    		return GetValue(value);
    	}
    
    	if (expr is ConstantExpression c)
    	{
    		return GetValue(c.Value);
    	}
    
    	throw new NotImplementedException(expr.ToString());
    }
    
    private string GetValue(object obj)
    {
    	if (obj is string s)  return "\"" + s + "\"";
    	if (obj is int i) return i.ToString();
    	if (obj is Enum) return ((int)obj).ToString();
    	if (obj is DateTime dt) return $"'{dt:G}'";
    	throw new NotImplementedException("Unsupported argument type " + obj.GetType());
    }

}

Вот и вся реализация LINQ to DB. Есть, конечно, ещё и сам LinqContainer с поддержкой IQueryable<T>, но его роль весьма прозаична.

Зачем это всё

Давайте перейдём к практике. Что вообще даёт применение ORM в проекте?

В первую очередь, бизнес-логика отделяется от слоя доступа к данным. Бизнес-код становится высокоуровнее, короче, надёжнее и проще. Мы маппим данные да объект со своими методами, полями, а значит состоянием и поведением. Если вы заметили, то класс Brand был сгенерирован как partial. Это означает, что в иерархии объектов (я предпочитаю следование DDD) существует другой Brand, в котором находится бизнес-логика.

Механизм запросов к IEnumerable и IQueryable выглядит максимально коротко и ёмко. Выборки по условию не смешивают слои приложения, максимально снижая когнитивную нагрузку при чтении. Это свойственно вообще практически всем ORM.

В моём случае подавляющее количество репозиториев были типа TrackedContainer. Только самые "бодипозитивные" таблицы использовали LinqContainer. Впрочем, с точки зрения синтаксиса они почти ничем не отличались. Я мог поменять тип контейнера по своему желанию и получить рабочую программу с другим поведением.

Конечно, есть нюансы. Тупость linq не позволяла использовать в качестве критерия выборки метод бизнес-логики. Собственно, так не умеет даже EF. Только прямое сравнение. Но так можно было делать с IEnumerable<T>.

У маппинга есть ещё неочевидный плюс. Использование памяти структурами вроде DataTable. Я иногда видел, что программисты выкачивали содержимое таблицы в подобную структуру и работали как с экселем потом. Есть номера строк и есть номера столбцов - чего ещё надо?

Боксинг. Вот что происходит в подобном случае. Банальный int32, будучи в подобной структуре занимает 8 байт на стеке, плюс 24 байта в куче (значение указано для архитектуры x64). Немножко оверхеда, да? ORM раскладывает данные по типизированным полям, сокращая в разы потребление памяти. DataTable в размере сотен тысяч строк и десятка-двух полей способен подъедать совершенно невменяемое количество памяти и сваливать машину в OOM. Не делайте так, пожалуйста.

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

Сама концепция full-cache ORM позволяет использовать персистентные объекты. Это значит, что однажды забрав пачку объектов из репозитория, мы получаем не копию данных, а копию, тщательно имитирующую оригинал. Можно подписаться на PropertyChanged взятого объекта и получать события об изменениях сделанных в другой части системы или даже в другом клиентском приложении. TrackedContainer по одному и тому же айди всегда выдавал ссылку на один и тот же объект.

Не очень синтетический пример

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

Казалось бы, что проще? Надо взять пришедшую на счёт сумму и вычесть все расходы. Orders.Payments - Order.Costs пишем и расходимся )

Помните про камаз под водой? Вот тут примерно та же ситуация. Давайте по порядку узнавать, как наш камаз с песком оказался в -40 под слоем льда и при чём тут ORM.

Во-первых надо учитывать НДС. Это ещё один камазик внутри камаза подо льдом, потому что:

  • Не все заказчики платят НДС. Это можно узнать из карточки заказчика

  • С 18 года процент внезапно изменился на 20%. Но прибыль должна считаться правильно для всех заказов с учётом ретроспективы, а в 2013 году при проектировании никто не закладывал такой подлянки

  • Не все подрядчики платят НДС. Не все поставщики платят НДС. Не все поставщики платят 18%. Это можно узнать из карточки поставщика.

Во-вторых все расходы учесть невозможно. Например, всё, что связано с собственным производством. Тут расходы "виртуальные" и взяты по внутреннему прайсу компании. Конечно, НДС они в себе не содержат. Кроме того, нужно хотя-бы чуть-чуть учитывать непрямые расходы, вроде дизайна, КБ и препресс.

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

Давайте сначала разберёмся с НДС. Добавим в класс Order метод вычисления процента.

public int NdsPercent
{
    get
    {
        if (PurchaseOrderID != 0 && !PurchaseOrder.HaveNDS) return 0;
        if (Created.Year > 2018) return 20;
        if (RequiredShipping.IsSet())
            if (RequiredShipping.AddDays(RequiredShippingDays).Year > 2018)
                return 20;
        return 18;
    }
}

Получается, я вам наврал. Всё ещё сложнее. У нас появился некий PurchaseOrder. В котором уже указано, есть ли там НДС. Просто потому что налогообложение у одного заказчика может поменяться. Правдивую информацию на тот момент нужно фиксировать в PurchaseOrder. Если что, это отдельная сущность, связанная с заказом по PurchaseOrderID.

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

Теперь производственные расходы. Тут всё просто. Наделаем прайсов под каждый вид работы и внедрим калькулятор. Стоимость по прайсу будем фиксировать по окончанию работ.

Осторожно, много тупого кода!
public Money GetCosts(IProductionStage stage, int contractorId)
{
  switch (stage.StageTypeID)
  {
      case AssemblySpmID:
      case AssemblyNspmID:
      case PrintingID:
      case PalletingID:
      case HandmadeID:
      case PackingID:
      case PrepareMaterialForBackingID:
      case PrepareMaterialForCuttingID:
          return stage.Amount * stage.Cost;
      case AnnealingID:
          return stage.Amount * PriceSingle.Annealing.Price;
      case BendingID:
          return Prices.Bending.GetCost((decimal) stage.Thickness, stage.MaterialID, stage.BendingLength) * stage.Amount * stage.AmountMultiplier;
      case NumerationID:
          return PriceFormat.NumerationStart.GetPrice(stage.FormatID)
                 + PriceFormat.Numeration.GetPrice(stage.FormatID) * stage.Amount;
      case CuttingStandartID:
      case NotchingID:
          return GetCuttingCost(stage, 1.0m);
      case CuttingBackedID:
          return GetCuttingCost(stage, 1.5m);
      case CuttingPlasticID:
          return GetCuttingCost(stage, 2.0m);
    
      case BackingSlimID:
          return GetBackingCosts(stage, 1.25m);
      case BackingT23ID:
          return GetBackingCosts(stage, 1.20m);
      case BackingKappaID:
          return GetBackingCosts(stage, 1.20m);
      case BackingPolystyreneID:
          return GetBackingCosts(stage, 3.00m);
      case BackingA0ID:
          return GetBackingCosts(stage, 1.00m);
      case BackingStandartID:
          return GetBackingCosts(stage, 1.00m);
    
      case RollPrintID:
          return PriceSingle.WideFormatPrint.Price * stage.Amount;
      case RollLaminationID:
          return PriceSingle.Lamination.Price * stage.Amount;
      case CncCuttingID:
      {
          var material = sql.materials[stage.MaterialID];
          if (material.PID == Material.PlasticID)
              return PriceSingle.CncCuttingPvc.Price * stage.Amount;
          return PriceSingle.CncCutting.Price * stage.Amount;
      }
      case PackageAssemblyID:
          return stage.Amount * PriceSingle.PackageAssembly.Price;
      case PalletBoardPrintID:
          return stage.Amount * PriceSingle.PalletBoardPrint.Price;
      case MillingCutID:
          return stage.Amount * MillingPrice.GetPrice(stage.Amount, stage.MaterialID, (decimal)stage.Thickness);
      case MillingEngravingID:
          return PriceSingle.MillingEngraving.Price * stage.Amount;
      case LaserCutID:
          return stage.Amount * LaserPrice.GetCutPrice(stage.Amount, stage.MaterialID, (decimal)stage.Thickness);
      case LaserEngravingID:
          return stage.Amount * LaserPrice.GetEngravingPrice(stage.Amount, stage.MaterialID);
      case LaminationID:
          Money sum = PriceFormat.LaminationStart.GetPrice(stage.FormatID);
          if (stage.IsGloss) sum += PriceFormat.LaminationGloss.GetPrice(stage.FormatID) * stage.Amount;
          else sum += PriceFormat.LaminationMat.GetPrice(stage.FormatID) * stage.Amount;
          return sum;
      case VinylBackingID:
          return PriceSingle.VinilBacking.Price * stage.Amount;
      case VinylBackingOnPalletBoardID:
          return PriceSingle.VinilBackingSpecial.Price * stage.Amount;
      case MillingGroovesID:
          return PriceSingle.MillingGrooves.Price * stage.Amount;
      case ColdLaminationID:
          return PriceSingle.ColdLamination.Price * stage.Amount;
      case HoleCuttingID:
          return GetHoleCuttingPrice(stage);
    
      case AutoLidAssemblyID:
          return GetAutoLidAssemblyCost(stage);
      case AutoTrayAssemblyID:
          return GetAutoTrayAssemblyCost(stage);
      default: return stage.Amount * stage.Cost;
  }			
  return stage.Cost * stage.Amount;
}

При чём тут ORM? Обратите внимание на конструкции типа PriceSingle, PriceFormat, LaserPrice. Это данные из БД. Но выглядит это так, как будто никакой БД нет, как будто данные берутся из статической переменной. Что естественно не так и в любой момент прайс может быть изменён.

Сами типы работ - это тоже данные из базы. Существует некая таблица с типами работ, у которой есть специальное поле - StaticKey. В этом поле я указывал наименование работ, примерно так:

Это информация для кодогенератора. То, что в коде выглядит как BackingSlimID, кодогенератором разворачивается в это:

    /// <summary>
    /// Каширование слим
    /// </summary>
    public const int BackingSlimID = 65;

Это такой специальный механизм в ORM для работы логики с конкретными сущностями таблицы. Точно также сделано со "специальными" заказчиками, с одним лишь отличием - там ещё генерируется акцессор на саму сущность. Ну, чтобы сразу получить контрагента, не заморачиваясь с ID.

Хорошо. Расходы по производству внесли, давайте разбираться с постоплатой и кредитами.

public double CreditPercent
{
    get
    {
        if (Created < new DateTime(2022, 03, 05))
            return 11.5f;
        return 16.5;
    }
}

/// <summary>
/// Количество дней на отсрочку платежа для этого конкретно заказа (если было переопределено в карте заказа)
/// </summary>
public int GetCreditDays()
{
    if (CreditDays == 0) return Customer.CreditDays;
    return CreditDays;
}

/// <summary>
/// Расчитывается стоимость обслуживания кредита исходя из отсрочки платежа и процента банка
/// </summary>
private void AddCreditCost()
{
    var costs = sql.costs.Where(c => c.OrderID == ID && !c.Deleted).ToList();
    var creditCost = costs.FirstOrDefault(c => c.Purpose == CostPurpose.Credit) ??
    Cost.AddVirtualCost(ID, CostPurpose.Credit, 0, Money.Zero);
    CalculateCreditCost(creditCost);
}

public void CalculateCreditCost(Cost creditCost)
{
    var netCost = ProductNetCost;
    var costs = sql.costs.Where(c => c.OrderID == ID && !c.Deleted).ToList();
    var costSum = costs.Where(c => c.Purpose != CostPurpose.Credit).Sum(c => c.Sum);
    decimal sum = 0m;
    var purpose = "";
    
    if (netCost &gt; costSum)
    {
        sum = (decimal)netCost;
        purpose = "по себестоимости в";
    }
    else
    {
        sum = (decimal)costSum;
        purpose = "по расходам в";
    }
    
    int days = GetRealCreditDays();
    var creditCostSum = Money.FromDecimal(days / 365m * sum * (decimal)CreditPercent / 100m);
    if (creditCostSum != Money.Zero)
    {
        creditCost.Sum = creditCostSum;
        creditCost.Name = $"{Units.Days.AmountAndName(days)} кредита на сумму {purpose} {sum} руб. под {CreditPercent}% годовых";
    }
}

/// <summary>
/// Разница между последней оплатой и последней отгрузкой в днях
/// </summary>
public int GetRealCreditDays()
{
    var collector = new RealCreditDaysCollector();
    return collector.GetRealCreditDays(ID);
}

Получилась целая пачка методов. Первый считает процент, второй дни, третий вносит в расходы, четвёртый считает сумму (да, тут как видите, может по расходам или расчётной себестоимости быть взят процент). И ещё какой-то коллектор появился. Сложна, сложна, пачиму так сложна!

Окей. Теперь нам надо вывести список заказов с подсчётом прибыли по каждому индивидуально. И желательно, чтобы значения пересчитывались по мере заполнения БД.

Сделаем вспомогательный класс для подсчёта прибыльности сразу множества заказов:

/// <summary>
/// Калькулятор прибыльности заказов
/// </summary>
public class OrderProfit
{
    private readonly MoneyDictionary orderPayments = new MoneyDictionary();
    private readonly MoneyDictionary orderCosts = new MoneyDictionary();
    private readonly MoneyDictionary[] orderCostsByPurpose = new MoneyDictionary[CostPurpose.All.Length];
    private readonly MoneyDictionary ndsCosts = new MoneyDictionary();
    private readonly MoneyDictionary ndsPayments = new MoneyDictionary();
    //Планируемая себестоимость всех заказов по объектам
    public MoneyDictionary plannedNetCost = new MoneyDictionary();
    //Планируемая стоимость всех изделий
    public MoneyDictionary plannedCosts = new MoneyDictionary();
    
    public void Update()
    {
        orderCosts.Clear();
        ndsCosts.Clear();
    
        for (var i = 0; i < orderCostsByPurpose.Length; i++)
            orderCostsByPurpose[i] = new MoneyDictionary();
    
        foreach (Cost cost in sql.costs.Where(c => !c.Deleted))
        {
            orderCosts.Add(cost.OrderID, cost.Sum);
            ndsCosts.Add(cost.OrderID, cost.Nds);
    
            var purposeCost = orderCostsByPurpose[cost.Purpose];
            purposeCost.Add(cost.OrderID, cost.Sum);
        }
    
        orderPayments.Clear();
        ndsPayments.Clear();
        foreach (var pay in sql.payments)
        {
            orderPayments.Add(pay.OrderID, pay.Sum);
            ndsPayments.Add(pay.OrderID, pay.Nds);
        }
    
        plannedNetCost.Clear();
        plannedCosts.Clear();
        foreach (var product in sql.products)
        {
            plannedNetCost.Add(product.OrderID, product.NetCost);
            plannedCosts.Add(product.OrderID, product.CostWithNds * product.Amount);
        }
    }
    
    public bool ContainsCost(Order order)
    {
        return orderCosts.ContainsKey(order.ID);
    }
    
    public bool ContainsPayments(Order order)
    {
        return orderPayments.ContainsKey(order.ID);
    }
    
    public Money PlannedProfit(Order o)
    {
        if (o.State == OrderState.Cancelled) return Money.Zero;
        return o.AddNds(o.ProductCost) - plannedNetCost[o.ID];
    }
    
    /// <summary>
    /// Сумма оплат по заказу
    /// </summary>
    public Money Payments(Order order) => orderPayments[order.ID];
    
    /// <summary>
    /// Сумма расходов по заказу, включая НДС
    /// </summary>
    public Money CostsWithNds(Order order) => TotalNds(order) + orderCosts[order.ID];
    
    //Ндс оплат отдельно от оплат
    public Money PaymentNds(Order order)
    {
        return ndsPayments[order.ID];
    }
    
    /// <summary>
    /// НДС заказа с учётом НДС расходов
    /// </summary>
    public Money TotalNds(Order order) => ndsPayments[order.ID] - ndsCosts[order.ID];
    
    /// <summary>
    /// Чистый расход по заказу
    /// </summary>
    public Money Costs(Order order)
    {
        return orderCosts[order.ID];
    }
    
    public Money CostByPurpose(CostPurpose purpose, Order order)
    {
        var dict = orderCostsByPurpose[purpose];
        return dict[order.ID];
    }
    
    /// <summary>
    /// Чистая прибыль заказа, учитывая НДС
    /// </summary>
    public Money Profit(Order order)
    {
        return orderPayments[order.ID] - CostsWithNds(order);
    }
    
    public Money TheoreticalProfit(Order order)
    {
        if (order.Paid || order.State != OrderState.Ready) return Profit(order);
        return order.AddNds(order.ProductCost) - CostsWithNds(order);
    }
    
    public Money Economy(Order order)
    {
        return Profit(order) - PlannedProfit(order);
    }
    
    /// <summary>
    /// Аналитический коэффициент прибыльности заказа.
    /// Предназначен для сравнения с расчётным коэффициентом #490
    /// </summary>
    public float ProfitRate(Order order)
    {
        if (!order.CostsChecked || !order.Paid || !orderPayments.ContainsKey(order.ID) || !orderCosts.ContainsKey(order.ID)) return 0.0f;
        var payment = (decimal)orderPayments[order.ID];
        var cost = (decimal)orderCosts[order.ID];
        var nds = (decimal)FictiveNds(order);
        decimal rate = payment / (cost + nds);
        return (float)Math.Round(rate,2);
    }
    
    public float PlannedProfitRate(Order o)
    {
        if (o.State == OrderState.Cancelled) return 0.0f;
        if (plannedNetCost[o.ID] == Money.Zero) return 0.0f;
        var rate = o.AddNds(o.ProductCost) / plannedNetCost[o.ID];
        return (float)Math.Round(rate, 2);
    }
    
    public Color GetProfitRateColor(Order order)
    {
        float rate = ProfitRate(order);
        if (Math.Abs(rate) < float.Epsilon) return Color.DarkSlateGray;
        if (rate < 1.0d) return Color.Red;
        if (rate <= 1.2d) return Color.DarkRed;
        if (rate <= 1.5d) return Color.Black;
        return Color.DarkBlue;
    }
}

Фух, было сложно. Вроде всё? Или нет?

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

А ещё из-за лага в оплате заказа нужно учитывать не полностью оплаченные заказы. И расходы тоже могут быть не полностью учтены. На это надо время, а бизнесу надо планировать прибыль ближайших заказов, пусть и с потерей точности. Поэтому с "определившихся" заказов берём фактические данные, с остальных - расчётные. Код я приводить не буду. Его много, очень много.

Зачем я всё это рассказываю. И при чём тут ORM?

Я лишь хотел показать, насколько сложными бывают требования к реальным информационным системам. Насколько далеко может зайти бизнес в своих требованиях. Мне иногда кажется, что никакого "достаточно" у бизнеса нет.

ORM как хорошее правительство - чем менее заметен в повседневной жизни, тем лучше. Концепция "базы данных не существует" для меня показала очень хороший результат.

Признаюсь честно. Сначала я хотел синтетический тест сделать на двух языках. На C# и на sql. Но по мере написания я понял, что я слишком тупой, чтобы осилить написание такого количества логики на sql. Я просто не представляю, как это сделать. И тем более не представляю, как это обслуживать. Объектно-ориентированный язык даёт множество инструментов управления сложностью: я могу промежуточные вычисления оформлять в именованные методы, могу пользоваться интерфейсами, могу изобретать кэши и даже чуть-чуть синтаксиса. Возможно, я просто привык, но я действительно не представляю, как делать запросы такого уровня сложности на чистом sql.

Для меня работа через ORM является единственно возможным вариантом управления сложными системами "вдолгую".

Заключение

Эта система как ORM перестала развиваться после внедрения в неё linq в 2014 году. Это не значит, что она умерла, это значит, что её возможностей хватало для дальнейшей работы с ней. ORM не развивалась, а вот ERP на её основе - вполне. Она до сих пор работает, в ней работают люди (100-150 скромных человек одновременно). Ей пользуются для сбора данных, аналитики, прогнозирования и всего прочего, для чего нужны вообще ERP системы.

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

Эта система кажется тупой и неправильной. Я знаю. Но это работает. И делает всё, что от неё требовалось.

Стал бы я сейчас делать такое? Скорее нет. Я бы лучше дописал трекинг к EF.

Стал бы я переделывать ту систему на EF? Однозначно нет. Она хороша по-своему.

Спасибо за внимание, в следующий раз постараюсь рассказать о другой своей экстремальной ORM, уже из 2023 года. На трёхзвенку через gRPC/protobuf.

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


  1. ky0
    23.06.2024 12:09
    +24

    Бизнес-код становится высокоуровнее, короче, надёжнее и проще.

    Главное - не заглядывать в получающиеся SQL-запросы. А на претензии "почему всё так медленно" рассказывать, что базе нужен процессор помощнее, памяти побольше, СХД с NVMe и вообще - в облако её. Зато код бизнес-логики выглядит офигенно.


    1. Kerman Автор
      23.06.2024 12:09

      Ну давайте, расскажите, как сложно разобраться с запросом, в котором есть WHERE, ORDER BY и LIMIT. У вас же код QueryBuilder перед глазами.

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

      Или Вы просто не читали статью?


    1. Tomasina
      23.06.2024 12:09

      Вот только объем приложений и дистрибутивов растет чуть ли не в геометрической прогрессии.


    1. mayorovp
      23.06.2024 12:09

      А какие именно у вас претензии к SQL-запросам, сгенерированными кодом автора?


      1. ky0
        23.06.2024 12:09
        +1

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


        1. Kerman Автор
          23.06.2024 12:09

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

          Относительно недавно был курьёзный случай. Эксплуатация решила, что серверу хватит 16гб памяти, потому что для 1С 80гб стало маловато. И MySql начал тормозить. При том, что в ERP сидит 100-150 человек, а в 1С от силы 15.

          Соответственно, в БД лежит накопленная информация за примерно полторы тысячи человеко-лет.

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


  1. Tzimie
    23.06.2024 12:09
    +12

    А вот можно с данной стороны пойти. Загнать всю логику в stored procedure. Считается некошерно, архитекторы морщат носы, но работает быстрее всего


    1. SpiderEkb
      23.06.2024 12:09
      +1

      Работает до тех пор, пока не появится несколько десятков тысяч бизенс-процессов, каждый со своей логикой, и каждый из них работает не с одной, а тремя, пятью и более таблицами (а общее количество таблиц исчисляется тысячами)


      1. Tzimie
        23.06.2024 12:09
        +8

        Вот я недавно в одном банке работал, 100 терабайт, тысячи таблиц, и все на stored proc.


        1. Kerman Автор
          23.06.2024 12:09

          Давайте я угадаю? Это Oracle.

          Пошло ещё с 70х годов, когда кроме stored procedures альтернатив написанию кода для банковских транзакций не существовало. На сегодняшний день ситуация такая, что практически по всем банкам расползлась одна и та же кодовая база.

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

          Банки кушают этот кактус, потому что у них выбора нет. Зачем приводить их в пример, не учитывая историю айти в банках?

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


          1. Tzimie
            23.06.2024 12:09

            А вот и не угадали! MSSQL. Системе лет 15,-20 но начинать писали, говорят, на Дельфи)


            1. Aspie
              23.06.2024 12:09

              BSS?)


              1. Tzimie
                23.06.2024 12:09

                Нет. В личку напишу


          1. Tzimie
            23.06.2024 12:09
            +7

            Но stored procedures красивы. Во всяком случае, куда стройнее, чем обертки и CRM. Если исходные данные в базе и результат в базе, что сделать это лучше, чем SQL? (Если не брать обработку изображений, искусственный интеллект итд).

            Но я развивался от баз. Я впитал SQL с молоком матери) А вот автор статьи, судя по всему, наоборот, постарался максимально от базы отгородиться


            1. sshikov
              23.06.2024 12:09
              +6

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

              Если исходные данные в базе и результат в базе

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

              И попробуйте вот такое написать на T-SQL? Вам сразу приходится тащить другой язык, скажем c# или java или питон, а как только вы его притащили - писать на нем сильно проще, чем на том, что умеет типовая СУБД.


              1. Tzimie
                23.06.2024 12:09
                +3

                Это да, безусловно. Но стек типа 'пришло ушло считаем баланс' и 'обработаем данные и построем куб olap' по прежнему очень большой


                1. sshikov
                  23.06.2024 12:09

                  Так я не говорю что он маленький. Я скорее утверждаю, что других задач становится все больше.


                  1. Tzimie
                    23.06.2024 12:09

                    Это да, это так


              1. Tzimie
                23.06.2024 12:09
                +1

                И да, в том же tsql куча мест где "запомните дети, потому что ... так сложилось". Но "все равно его не брошу, потому что он хороший"


                1. sshikov
                  23.06.2024 12:09

                  Я знаю. Мне пришлось 3.5 года совмещать :)