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



В статье рассказано о моей попытке поиска универсального решения.

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



Стандартный подход обычно состоит из следующих шагов:
  1. На сервере написать SQL-запрос с JOIN-ом
  2. На сервере добавить для него функцию, возвращающую массив объектов, и сделать ее доступной через routes
  3. На клиенте добавить AJAX-вызов к серверу, и отрисовку полученного результата в таблицу


Недостатки стандартного подхода мне видятся в следующем:
  1. SQL-запрос и функция-обертка должны учитывать возможные коллизии имён колонок, т. е. нельзя просто сделать "SELECT *".
  2. Ответ сервера будет содержать большое количество дублирующихся записей из связанных таблиц. В нашем примере запись с ключем «sales» из таблицы departments будет передана два раза.
  3. При связи большого количества таблиц мы или получим длинные ключи, что приведет к увеличению бесполезного расхода памяти и трафика по передаче этих ключей, или имена колонок в SQL-запросе необходимо перечислять вручную, что приведет к дополнительным издержкам при внесении изменений в структуру БД.
  4. Количество функций API для получения данных из связанных таблиц может значительно превысить количество таблиц, что ведет к раздуванию кода, и, как следствие, издержкам.


Нестандартный подход — получить таблицы по отдельности и связать их на клиенте. Иногда это можно сделать легко. Например, в приведенной выше структуре можно загрузить таблицу «departments» в хеш, и осуществлять доступ по «id». Но чаще этого сделать нельзя, и приходится пользоваться различными функциями поиска типа Array.find или Array.indexOf.

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

Подход, при котором сервер выдает нам нормализованные таблицы, а мы потом их связываем в JavaScript-коде, показался мне более привлекательным. Не хватало только инструмента, чтобы их легко связывать. Я отложил все дела и сел его писать.

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

Так появились Strelki.js, и пока единственный в нем класс — IndexedArray.

Итак, создадим новый IndexedArray:

var emp = new StrelkiJS.IndexedArray();

Добавим в него данные:

    emp.put({
	    id: "001",   
	    first_name: "John",     
	    last_name: "Smith",   
	    dep_id: "sales", 
	    address_id: "200"
	});
	emp.put({
		id: "002",   
		first_name: "Ivan",    
		last_name: "Krasonov",   
		dep_id: "sales", 
		address_id: "300"
	});

Посмотрим, что внутри:



Под капотом IndexedArray представляет из себя хеш (this.data), куда сохраняются ссылки на объекты. В качестве ключа хеша используется поле «id» сохраняемого элемента, которое должно быть уникально. Так как многие современные серверные фреймворки также используют поле «id» подобным образом, то это ограничение не должно стать проблемой.

Кроме того, в IndexedArray имеется хеш this.indexData. Ключи этого хеша содержат название индексируемого поля, а значения — хеши с ids соответствующих элементов основного хеша. Пока индексов у нас нет, поэтому this.indexData пуст.

Добавим индекс:

emp.createIndex("dep_id");

Посмотрим this.indexData:



this.indexData теперь содержит ключ «dep_id», который содержит данные индекса в виде вложенных хешей.

Поищем что-нибудь по индексу:

> emp.findIdsByIndex("dep_id","sales")
< ["001", "002"]

В отличие от функций типа Array.find, индексный поиск не использует перебор данных, а только хеши, что позволяет добиться высокой скорости. Замеры, правда, я пока не делал, но должно работать быстро.

Добавим еще данных:

emp.put({
    id: "003",   
    first_name: "George",   
    last_name: "Clooney",    
    dep_id: "hr", 
    address_id: "400"
});
emp.put({
    id: "004",   
    first_name: "Dev",   
    last_name: "Patel",    
    dep_id: "board", 
    address_id: "500"
});


Найдем элементы по индексу, и сформируем из них новый IndexedArray:

var sales_emp = emp.where("dep_id","sales");

Создадим и заполним еще один IndexedArray:

var adr = new StrelkiJS.IndexedArray();
adr.put({  id: "200",  address: "New Orleans, Bourbon street, 100"});
adr.put({  id: "300",  address: "Moscow, Rojdestvensko-Krasnopresnenskaya Naberejnaya"});
adr.put({  id: "500",  address: "Bollywood, India"});

Связывание массивов


Для описания связи данного IndexedArray с любым другим служит объект следующего вида:

{
        from_col: "address_id", // поле в данном IndexedArray
        to_table: adr,          // ссылка на связываемую таблицу
        to_col: "id",           // "id", или другое индексированное поле в связываемой таблице
        type: "outer",          // "outer" для LEFT OUTER JOIN, или null для INNER JOIN
        join:                  // null или ссылка на массив точно таких же объектов описания связи, для построения вложенных JOIN-ов
}

Присоединим adr к emp JOIN-ом:

var res = emp.query([
    {
        from_col: "address_id", // name of the column in "emp" table
        to_table: adr,          // reference to another table
        to_col: "id",           // "id", or other indexed field in "adr" table
        type: "outer",          // "outer" for LEFT OUTER JOIN, or null for INNER JOIN
        //join: [               // optional recursive nested joins of the same structure
        //    {
        //        from_col: ...,
        //        to_table: ...,
        //        to_col: ...,
        //        ...
        //    },
        //    ...
        //],
    }
])

Аналогичный оператор на SQL выглядел бы так:

SELECT ...
FROM emp
LEFT OUTER JOIN adr ON emp.address_id = adr.id

Результат будет выглядеть так:

[
    [
        {"id":"001","first_name":"John","last_name":"Smith","dep_id":"sales","address_id":"200"},
        {"id":"200","address":"New Orleans, Bourbon street, 100"}
    ],
    [
        {"id":"002","first_name":"Ivan","last_name":"Krasonov","dep_id":"sales","address_id":"300"},
        {"id":"300","address":"Moscow, Rojdestvensko-Krasnopresnenskaya Naberejnaya"}
    ],
    [
        {"id":"003","first_name":"George","last_name":"Clooney","dep_id":"hr","address_id":"400"},
        null
    ],
    [
        {"id":"004","first_name":"Dev","last_name":"Patel","dep_id":"board","address_id":"500"},
        {"id":"500","address":"Bollywood, India"}
    ]
]

Более сложный пример связывания 3-х таблиц

var dep = new StrelkiJS.IndexedArray();
dep.createIndex("address");
dep.put({id:"sales", name: "Sales", address_id: "100"});
dep.put({id:"it",    name: "IT",    address_id: "100"});
dep.put({id:"hr",    name: "Human resource",    address_id: "100"});
dep.put({id:"ops",   name: "Operations",    address_id: "100"});
dep.put({id:"warehouse", name: "Warehouse", address_id: "500"});

var emp = new StrelkiJS.IndexedArray();
emp.createIndex("dep_id");
emp.put({id:"001",   first_name: "john",     last_name: "smith",   dep_id: "sales", address_id: "200"});
emp.put({id:"002",   first_name: "Tiger",    last_name: "Woods",   dep_id: "sales", address_id: "300"});
emp.put({id:"003",   first_name: "George",   last_name: "Bush",    dep_id: "sales", address_id: "400"});
emp.put({id:"004",   first_name: "Vlad",     last_name: "Putin",   dep_id: "ops",   address_id: "400"});
emp.put({id:"005",   first_name: "Donald",   last_name: "Trump",   dep_id: "ops",   address_id: "600"});

var userRoles = new StrelkiJS.IndexedArray();
userRoles.createIndex("emp_id");
userRoles.put({id:"601", emp_id: "001", role_id: "worker"});
userRoles.put({id:"602", emp_id: "001", role_id: "picker"});
userRoles.put({id:"603", emp_id: "001", role_id: "cashier"});
userRoles.put({id:"604", emp_id: "002", role_id: "cashier"});

var joinInfo = [
            	{
            		from_col: "id",
            		to_table: emp,
            		to_col: "dep_id",
            		type: "outer",
            		join: [{
            			from_col: "id",
            			to_table: userRoles,
            			to_col: "emp_id",
            			type: "outer",
            		}],
            	},
//            	{
//            		from_col: "id",
//            		to_table: assets,
//            		to_col: "dep_id",
//            	}
            ];
//var js1 = IndexedArray.IndexedArray.doLookups(dep.get("sales"),joinInfo);

var js = dep.where(null,null,function(el) {	return el.id === "sales"}).query(joinInfo);

//  result

[
  [
    {"id":"sales","name":"Sales","address_id":"100"},
    {"id":"001","first_name":"john","last_name":"smith","dep_id":"sales","address_id":"200"},
    {"id":"601","emp_id":"001","role_id":"worker"}
  ],
  [
    {"id":"sales","name":"Sales","address_id":"100"},
    {"id":"001","first_name":"john","last_name":"smith","dep_id":"sales","address_id":"200"},
    {"id":"602","emp_id":"001","role_id":"picker"}
  ],
  [
    {"id":"sales","name":"Sales","address_id":"100"},
    {"id":"001","first_name":"john","last_name":"smith","dep_id":"sales","address_id":"200"},
    {"id":"603","emp_id":"001","role_id":"cashier"}
  ],
  [
    {"id":"sales","name":"Sales","address_id":"100"},
    {"id":"002","first_name":"Tiger","last_name":"Woods","dep_id":"sales","address_id":"300"},
    {"id":"604","emp_id":"002","role_id":"cashier"}
  ],
  [
    {"id":"sales","name":"Sales","address_id":"100"},
    {"id":"003","first_name":"George","last_name":"Bush","dep_id":"sales","address_id":"400"}
    ,null
  ]
]


Как видим, связывание массивов превратилось из функций с циклами и переборами в простую декларацию связей.

Ограничения


IndexedArray не хранит копии объектов, а только лишь указатели на них (отсюда и название Strelki). Поэтому, если объект был помещен в IndexedArray методом put(), а затем изменен, информация в индексах может стать некорректной. Чтобы избежать этой ситуации необходимо удалить объект из IndexedArray методом del() перед изменением.

Связывание может осуществляться только по индексированному полю, либо по полю «id».

Некоторые методы объекта IndexedArray (например length()) требуют построения массива ключей «id». При этом массив ключей сохраняется в объекте для возможного повторного использования. При каждом изменении массива (методы put(), del(), и т.п.) массив ключей обнуляется. Поэтому чередование методов, которые создают и затем обнуляют массив ключей, может привести проблемам производительности на больших наборах данных.

Планы


StrelkiJS создан для облегчения написания основного проекта KidsTrack, о котором я писал на хабре ранее. Поэтому все решения о новом функционале пока диктуются потребностями родительского проекта. В ближайших планах — сделать более удобный доступ к колонкам в результатах JOIN-а,

Где скачать


Github: github.com/amaksr/Strelki.js
Песочница: www.izhforum.info/strelkijs
Поделиться с друзьями
-->

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


  1. malinichev
    20.05.2016 09:43

    Интересное! 100% поюзаю на выходных)


  1. vintage
    20.05.2016 09:47

  1. koceg
    20.05.2016 13:51

    IndexedArray
    Всё-таки неправильно называть эту структуру массивом. IndexedCollection было бы лучше, на мой взгляд.
    Для data я бы использовал Map, а для индексов — Set. Эти структуры как раз идеально подходят под эти задачи. Ну или хотя бы стоит создавать их через Object.create(null), а не {} чтобы не тянуть ненужный прототип.


    1. amaksr
      20.05.2016 16:56

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

      Можно и так, но тут корень проблемы зародился в самом JavaScript-е, который использует одинаковый синтаксис доступа [] и для массивов, и для хешей.

      Для data я бы использовал Map, а для индексов — Set. Эти структуры как раз идеально подходят под эти задачи.

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

      Ну или хотя бы стоит создавать их через Object.create(null), а не {} чтобы не тянуть ненужный прототип.

      Спасибо, исправлю в следующем коммите.


      1. koceg
        20.05.2016 17:08

        Можно и так, но тут корень проблемы зародился в самом JavaScript-е, который использует одинаковый синтаксис доступа [] и для массивов, и для хешей.
        И всё же это не повод смешивать понятия.

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


  1. andrydl
    20.05.2016 16:25

    Видно что человеку нечем заняться и у него масса свободного времени. Вместо того чтоб написать нормальный SQL запрос или хранимую процедуру, написать JS класс который на клиенте занимается тем, чем должен заниматься сервер. Запрос select * считается дурным тоном если в таблице более 2-3 -х полей, а при JOIN и подавно.


    1. amaksr
      20.05.2016 17:19

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

      Вместо того чтоб написать нормальный SQL запрос или хранимую процедуру, написать JS класс который на клиенте занимается тем, чем должен заниматься сервер.
      В статье я перечислил недостатки стандартного подхода, поэтому и решил перенести часть серверной логики на клиент. В моем приложении с 8-ю таблицами это позволило мне избавиться от нескольких API-интерфейсов на сервере, а так же от нескольких проблемных блоков кода с вложенными циклами на клиенте (вернее этот проблемный код теперь реализован классом IndexedArray). Общая сложность кода (сервер+клиент) уменьшилась, что и было целью.


  1. boarder
    20.05.2016 16:25

    1. amaksr
      20.05.2016 19:13

      Функционал StrelkJS частично пересекается с datascripts, но есть и отличия.
      Например datascripts хранит копии объектов, а Strelki — только указатели. Тут уж для каждого конкретного случая надо смотреть, нужа ли immutability (и сопутсвующие расходы памяти и быстродействия), или нет. Для чего-то лучше подойдет datascripts, а где-то можно обойтись и StrelkJS.