воскресенье, 5 июня 2016 г.

LIghtDataTable

В заметке "Оптимизация DataTable по памяти" я упоминал, что класс DataTable из BCL содержит довольно увесистую внутреннюю структуру для хранения данных. Нельзя сказать, что она избыточна, однако, для некоторых сценариев использования многие её возможности просто излишни. Вообще проблема с объёмом DataTable обычно не возникает. Класс сильно связан с получением данных из БД, а т.к. чистый ADO.NET редко кто использует напрямую без ORM, то и с DataTable`ом в чистом видел мы сталкиваемся не так часто.

Проблематика

Не так давно пришлось участвовать в проекте, частью которого был мощный табличный процессор (схожий с MS Excel). Данная система позволяла производить пересчёт ячеек и их контроль по установленным пользователем формулам. Обработка данных производилась на клиенте, а не в БД в силу различных причин. В какой-то момент встал вопрос потребления памяти. Основной объём в памяти занимали данные из справочных и смежных таблиц, которые подгружались в память частично и только для чтения. Использовать для хранения таких данных абстракции до уровня ячеек - довольно расточительно. Поэтому данные хранились в чистых DataTable`ах с небольшой примесью метаинформации.
После изучения внутреннего устройства и пары часов в обнимку с профилировщиком я установил, что DataTable в зависимости от содержимого съедает ещё 15-20% памяти. Пустой DataTable в .Net 4.5 вообще занимает почти 2Kb в памяти. Возникло желание раскулачить его структуру для получения чего-то более легковесного под мою задачу.

Внутри DataTable

Для эффективного хранения данных в исходном DataTable применено несколько интересных решений.
Так, например, данные хранятся в колонках, а не в строках, как может показаться при использовании (ведь доступ происходит именно через DataRow). Это сделано для более эффективного использования памяти: значения лежат в типизированных массивах (значения в колонках одного типа). Т.е. в int колонка значения лежат в int[]. Такой подход позволяет сэкономить не только на памяти, но ещё и на скорости (вспомним, что стоимость доступа в массиве по индексу равна o(1)). DataRow при этом имеет ссылку на саму таблицу, а через неё и на нужную колонку к своему значению (свой индекс строка, как ни странно, тоже знает). Для возможности хранения null`ов в ячейках, DataColumn (а точнее Storage, на который ссылается колонка) имеет битовую маску BitArray.
Эти идеи неплохо подойдут для написания простого контейнера, позволяющего эффективно хранить табличные данные.
Сэкономить по сравнению с DataTable можно на функционале редактирования и отслеживания состояния отдельных строк.

Реализация

Начнём с того, что новый контейнер должен уметь конструироваться из DataTable. Для этого обеспечим его специальным конструктором:


public LightDataTable(DataTable table)

Далее необходимо инициализировать внутреннюю структуру контейнера: создать нужные таблицы и строки, а также переложить в себя данные:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
        /// <summary>
        /// Constructor
        /// </summary>
        /// <param name="table"><see cref="DataTable"/> that should be wrapped to <see cref="LightDataTable"/></param>
        public LightDataTable(DataTable table)
        {
            TableName = table.TableName;
            RowsCount = table.Rows.Count;

            var columns = new List<LightDataColumn>(table.Columns.Count);
            foreach (var column in table.Columns)
                columns.Add(CreateColumn((DataColumn)column));
            Columns = new LightDataColumnCollection(columns);
        }

Создание колонки выглядит следующим образом:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
        /// <summary>
        /// Light column factory
        /// </summary>
        /// <param name="column"><see cref="DataColumn"/> that should be wrapped</param>
        /// <returns>Light column</returns>
        private LightDataColumn CreateColumn(DataColumn column)
        {
            var type = column.DataType;
            if (type == typeof(string))
                return new LightStringDataColumn(column, this);
            if (type == typeof(Guid))
                return new LightGuidDataColumn(column, this);
            if (type == typeof(Int16))
                return new LightInt16DataColumn(column, this);
            if (type == typeof(Int32))
                return new LightInt32DataColumn(column, this);
            if (type == typeof(Int64))
                return new LightInt64DataColumn(column, this);
            if (type == typeof(Decimal))
                return new LightDecimalDataColumn(column, this);
            if (type == typeof(bool))
                return new LightBooleanDataColumn(column, this);
            if (type == typeof(DateTime))
                return new LightDateTimeDataColumn(column, this);
            if (type == typeof(byte))
                return new LightByteDataColumn(column, this);
            if (type == typeof(byte[]))
                return new LightByteArrayDataColumn(column, this);
            if (type == typeof(double))
                return new LightDoubleDataColumn(column, this);

            throw new NotImplementedException($"Type {type} is not supported.");
        }

Как видно, для всех основных типов написаны типизированные столбцы, каждый из которых внутри себя хранит данные в типизированных массивах.
Тут вступила в дело моя природная лень и опасение писать велосипеды. Поэтому я использовал для хранения те же storage классы, что и DataTable. Для этого пришлось немного замарать руки:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
        /// <summary>
        /// Initialize <see cref="LightDataColumn"/> from <see cref="DataColumn"/>
        /// </summary>
        /// <param name="column">Source column</param>
        /// <param name="storage">Existing storage</param>
        protected override void Initialize(DataColumn column, object storage)
        {
            if (Table.RowsCount > 0)
                Array.Copy((string[])ValuesField.GetValue(storage), _values = new string[Table.RowsCount], Table.RowsCount);
        }

        /// <summary>
        /// Provides access to <see cref="System.Data.Common.StringStorage"/>
        /// </summary>
        private static readonly FieldInfo ValuesField = typeof(DataTable).Assembly.GetType("System.Data.Common.StringStorage").GetField("values", BindingFlags.Instance | BindingFlags.NonPublic);

Полную реализацию можно посмотреть на github.

Что имеем?

Тесты по памяти показали, что данный контейнер позволяет сэкономить до 15% памяти по сравнению с DataTable. В купе с подходом по принудительному интернированию строк, описанному ранее, выигрыш иногда достигал 60% в зависимости от характера данных.
Хотя спору нет - задача довольно специфичная. И если вы полезли работать напрямую с DataTable в обход современных ORM, то в 95% случаев вы что-то делаете не так :P