Датасеты меняют всё (*в мобильной платформе SimpleUI)

Речь пойдет о новом механизме хранения и манипулирования данными в SimpleUI, который так тесно связан с UI-механизмами платформы что по сути является частью UI/UX. Это многогранный артефакт — это и «визуальная форма данных» (списки, поля ввода, поиск) и резидентное в памяти хранение и примитивное локальное хранение. В целом можно сказать, что «вы наполняете датасет данными, а платформа делает все остальное». Причем в результате выигрыш во всем — в скорости разработки, в прозрачности решения и производительности на любых объемах данных. Да, кроме удобства использования датасеты обнуляют задержки UI — больше никаких прогрессбаров, даже красивых. Кроме того, это удобная работа со ссылочными объектами, что делаем Симпл более удобным для работы с данными внешних систем, а датасеты в свою очередь становятся основным механизмом хранения данных внешних систем (даже для онлайн-решений).

Как датасеты используются?

Для начала чуть теории

Датасет как объект имеет имя, имеет какие то настройки (индексы по полям, настройки полей поиска и т.д.) и содержит в себе данные, которые можно было бы назвать «массив объектов». В опциях можно настроить поведение датасета — как будут искаться данные, как будет отображаться конкретная запись на формах и т.д.

Ссылка на объект любого датасета в SimpleUI имеет вид <имя датасета>$<_id записи>. Это универсальная ссылка в системе. Всегда можно получить запись любого датасета через DataSets.GetObjectStr (<ссылка>) или представление элемента через DataSets.GetView (<ссылка>).

В коде ниже: создадим датасет, заполним данными и сохраним.

datasrv = CreateDataSet("goods") #создаем датасет goods

#указываем hash-индексы, поля поиска по строке, шаблон представления записи
datasrv.setOptions(json_to_str({"hash_keys":["article","barcode"],"search_keys":"name","view_template":"{name} , {article}"})) 

#добавляем записи в датасет
goods_list = []
goods_list.append({"article":"AUD2071","name":"Стол","barcode":"4690626023178"})
goods_list.append({"article":"AUD2075","name":"Стул","barcode":"6924922203797"})
goods_list.append({"article":"AUD2076","name":"Лампа"})
goods_list.append({"article":"AUD2076","name":"Барабан"})
datasrv.put(json_to_str(goods_list))

#записываем датасет на диск
datasrv.save()

Таблицы и списки карточек

Если у вас есть датасет то для того, чтобы разместить на экране список, достаточно просто указать ссылку на него через ~ . Вот пример переменной для Таблицы. Тут в качестве источника указан датасет «goods», а контейнер, в котором задается дизайн элементов списка — «list_3_lines»

j = { "customtable":         {
        "layout": "^list_3_lines",
         "tabledata":"~goods"}
}

hashMap.put("table",json_to_str(j))
Список карточек и Таблица
Список карточек и Таблица

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

j = { "customtable":         {
 "layout": "^AUTO",
 "tabledata":"~goods"}
 }
Список карточек с автогенерацией макета (режим AUTO)
Список карточек с автогенерацией макета (режим AUTO)

При нажатии генерируется событие CardsClick и в стеке переменных в selected_card_key появляется универсальная ссылка (<имя датасета>$<_id записи>) и также в selected_card_data — данные записи датасета в виде строки JSON

О поиске тоже можно не беспокоиться. Для датасета своя секция настроек поиска dataset_search в которой есть method(метод поиска), keys(поля поиска) и min_length (необязательно) минимальная длина с которой начинается поиск. Определяем метод поиска «text» и поля, по которым надо искать. Зачем метод? Потому что позже появятся другие методы для нечеткого поиска.

Кстати теперь появилась опция search_submit. Если она включена то надо нажимать подтверждение ввода (символ поиска на клавиатуре), если не включена то поиск происходит при наборе каждого символа. Для длинных строк и сложных поисковых алгоритмов это более гуманное решение в плане нагрузки.

Пример переменной таблицы с настройкой поиска

j = { "customtable":{
					"options":{
                    "search_enabled":True,
					"search_submit":True,
                    "dataset_search":{"method":"text", "keys":"name"}
                          	},
				 "layout": "^list_3_lines",
                 "tabledata":"~big"
                    }
   }

Про большие датасеты.

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

a0f1644e92023c1cc8a6a8ddbb69c6e7.gif

Поля датасетов

Можно разместить на экране ссылочные поля ввода данных, содержащие ссылки на записи датасетов

423ad5ba2c232d0eb0ef711f341ef798.png

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

Для такого случая у датасета желательно в опциях определить 2 вещи:

  • Представление записи — опция view_template. Можно использовать html. Имена полей указываются в фигурных скобках. Можно разместить в представлении несколько полей. Например {name}, {barcode}. Можно использовать html. Например {name}:{article}

  • Можно указать форму элементов списка list_layout — имя контейнера (по умолчанию AUTO)

Пример создания и указания опций датасета:

datasrv = CreateDataSet("goods")
datasrv.setOptions(json_to_str({"list_layout":"item","view_template":"{name} , {article}"})) 

Можно использовать конструкцию с | чтобы разместить поле с заголовком

Поле датасета с заголовком (без настроек)
Поле датасета с заголовком (без настроек)

Для задания настройки полей есть упрощенный вариант и вариант с настройками. Упрощенный вариант приведен выше, а для настроек необходимо указать JSON-настройки (обычно — через переменную)

  • dataset (обязательно) — имя датасета

  • inline — поиск по строке непосредственно в поле

  • select — кнопка выбора из списка

  • spinner — выбор из списка (аналог выпадающего списка) заменяет опцию inline

  • hint — подсказка

Примеры различной настройки полей датасетов
Примеры различной настройки полей датасетов

Выбранные и предустановленные значения

Везде используется универсальная ссылка — как результат пользовательского выбора, так и для установки предопределенных значений.

Например, создадим датасет nds

datasrv = CreateDataSet("nds")
datasrv.setOptions(json_to_str({"view_template":"Ставка - {name}"})) 

nds_list = []
nds_list .append({"name":"10%","_id":"НДС10"})
nds_list .append({"name":"20%","_id":"НДС20"})
nds_list .append({"name":"0%","_id":"НДС0"})
datasrv.put(json_to_str(nds_list))

И на экране в onStart установим НДС по умолчанию

hashMap.put("nds","nds$НДС20")

Тогда, при открытии, увидим результат:

Значение по умолчанию
Значение по умолчанию

Прямая связь элементов экрана с полями датасета

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

Когда меняешь текст в поле ввода, происходит прямая запись
Когда меняешь текст в поле ввода, происходит прямая запись


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

В current_order храниться ссылка, name - поле записи
В current_order храниться ссылка, name — поле записи

Важно! Для того, чтобы система корректно распознала такие поля, в Значении должна быть ссылка, поэтому, если ссылки пока нет (например при открытии), нужно поместить пустую ссылку <имя датасета>$)

Прямая запись возможна с элементами:

  • Поле ввода строка

  • Поле ввода число

  • Поле ввода пароля

  • Галочка

  • Дата

  • Многострочный текст

  • Надпись

Валидаторы OCR и штрихкодов в ActiveCV

Также датасеты используются как опорные выборки (валидаторы) при распознавании текста (OCR) и для валидации штрихкодов. Дело в том, что они очень быстрые и безусловно, этот вариант работы наиболее предпочтителен при оптическом распознавании.

О новой ActiveCV писал тут https://habr.com/ru/articles/874560/ . Валидаторы — это опорные выборки для OCR или при сканировании штрихкодов. Когда оптическое распознавание находит объект, оно возвращает запись. Все что надо сделать — указать датасет для валидатора (в датасете обязательно нужно указать по каким полям будут индексы). Т.е. да, можно проверять найденный тест или штрихкод в обработчике, но дело в том что через валидатор это происходит в разы быстрее и писать ничего не надо.

Датасет как источник данных

Датасет это хранилище данных резидентное в памяти. И к этим данным можно обратиться. Можно получить все записи датасета all (), страницы записей getPage (from, to). Можно быстро получить запись по _id или индексируемому полю. Например если вы идете по штрихкоду, но не используете ActiveCV, а используете обычный сканер и у вас есть датасет — «список товаров с штрихкодами» то вы можете его использовать как обычную БД:

goods = GetDataSet("goods")
res = goods.get("barcode","4690626023178")
toast(res)

Создание и наполнение датасетов, манипуляции с данными.

Датасет создается командой CreateDataSet(<имя датасета>) либо CreateDataSet (<имя датасета>, <опции>) либо опции можно установить отдельно после создания объекта датасета setOptions(<опции>). Опции это json-объект вида {«hash_keys»:[<ключи>, …], «key»:[<ключи>,]}. Все опции необязательны.

Возможные опции датасета:

  • search_keys — ключи (через запятую) по которым осуществляется поиск в списке

  • view_template — представление элемента в поле датасета. Ключи задаются в формате {<ключ>}, возможно использование html-тегов

  • list_layout — контейнер для списка выбора из полей датасетов

  • hash_keys — массив имен полей, по которым будет создан hash-индекс.

  • key — можно задать список полей из которых будет формироваться ключ id если он не задан, либо можно прjсто указыватьid в записи, либо, если не указан id в запили и не указан key тоid будет сгенерирован автоматически.

Также можно получить датасет копированием из другого датасета, тогда его опции будут скопированы. Команда copy(<имя нового датасета>) либо copy (<имя нового датасета>, <начальная строка>, <конечная строка>)

my = big.copy("my")

Датасеты пополняются командой put где в качестве параметра передается строка с JSON-массивом

А откуда берутся данные для массива в put? Приведу несколько примеров

  • Пример 1. Просто в онлайн-обработчике. Теперь в SimpleUI онлайн-обработчики есть двух видов — через HTTP-запрос (online) и через веб-сокеты + скрипт-шину о чем писал тут. И собственно можно вызвать онлайн обработчик и положить данные в датасет через put. Но данных может быть много за раз и через обработчик они будут передаваться долго. Посмотрим другие примеры.

    В примере ниже язык — Python, но это может быть онлайн-обработчик в бек-системе и язык будет например, 1С:

hashMap.put("CreateDataSets",json_to_str({"goods_online":{"hash_keys":["article","barcode"]}}))
data = {"goods_online":[{"article":"EZ9F34132","name":"SE 32A, 4500", "barcode":"3606480586873"},
{"article":"EZ9F34116","name":"SE 16A, 4500", "barcode":"3606480586842"},
{"article":"EZ9F34110","name":"SE 10A, 4500", "barcode":"3606480586835"}
]}
hashMap.put("PutDataSets",json_to_str(data))
  • Пример 2. Мы выгрузили данные из 1С в CSV и не хотим делать REST или ODATA, просто положили их в файлик на Яндекс-диске. В этом примере задействуется несколько механизмов. Сначала python-обработчик работает с API Яндекса для получения внутренней ссылки, потом запускается воркер для скачивания (котрый докачает файл, даже после перезагрузки девайса и с выключенным приложением), потом, когда файл скачала читаем CSV и пишем наконец в датасет. Бррр… сложно? Ну зато файлик просто лежит на Яндекс — диске, не надо поднимать сервер. В этот же пример можно записать вариации — файл не csv, а сразу JSON-массив, не на яндекс-диске, а на сервере с прямой ссылкой на скачивание. Воркер не обязательно использовать — это для больших файлов.

import requests
from urllib.parse import urlencode
from ru.travelfood.simple_ui import SimpleUtilites as su
import os
import csv



base_url = 'https://cloud-api.yandex.net/v1/disk/public/resources/download?'
public_key = 'https://disk.yandex.ru/d/U6YrMsXQmMbfOA' 

# Получаем загрузочную ссылку
final_url = base_url + urlencode(dict(public_key=public_key))
response = requests.get(final_url)
download_url = response.json()['href']

# Вариант 1 - для маленьких файлов
#download_response = requests.get(download_url)
#with open(su.get_downloads_dir()+os.sep+'p_menu.txt', 'wb') as f:   # Здесь укажите нужный путь к файлу
#    f.write(download_response.content)

# Вариант 2 - для больших файлов
def after_download_1():
    import csv
    with open(hashMap.get("DownloadedFile"), encoding='utf-8-sig') as f:
        reader = csv.DictReader(f, delimiter="\t")
        dataset = list(reader)
        goods =GetDataSet("goods_load")
        goods.put(json_to_str(dataset)) 
        hashMap.put("RefreshScreen","")    
        toast("Загрузили...")
	
postExecute = json_to_str([{"action": "run", "type": "pythonscript","method":get_body(after_download_1) }])
su.download(download_url,None,None,'goods.txt',postExecute)
  • Пример 3. Мы опубликовали из 1С автоматический интерфейс aData в несколько кликов и просто получаем данные из него напрямую. Чувствую, что это будет самый популярный вариант. Просто покажу как в таком случае выглядит заполнение датасета

import requests
from requests.auth import HTTPBasicAuth

orders = GetDataSet("orders_load")
if orders == None:
    orders = CreateDataSet("orders_load")
    orders.setOptions(json_to_str({"view_template":"{Number}", "list_layout":"order", "search_keys":"Number"}))

    url = "http://192.168.1.41:2312/kademo/odata/standard.odata/Document_ЗаказКлиента?$format=json"
    r = requests.get(url,auth=HTTPBasicAuth('usr', ''))
    result = r.json()
    records = []
    for record_1c in result["value"]:
        new_record = record_1c
        new_record["_id"] = record_1c['Ref_Key']
        records.append(new_record)

    orders.put(json_to_str(records)) 
    hashMap.put("RefreshScreen","")    
    toast("Загрузили...")
    

Манипуляция с данными

Копирование

my = big.copy("goods") #копирует полностью 
#или  
my = big.copy("big",0,3) #копирует с 0 по 3 позицию 

Отбор

Датасет можно отфильтровать по условию используя метод filter (<условие>), где условие задается в том же синтаксисе что и для Pelican/SimpleBase или MongoDB https://simplebase.readthedocs.io/en/latest/querys.html

Сортировка

my.sort("-name") #по убыванию по полю name 
my.sort("name") #по возрастанию по полю name 

Обрезка

my.cut(0,3)

Очистка

my.clear()

Хранение/загрузка

У объекта датасета 2 метода без параметров save () и load (). Кроме того у датасета есть метод isSaved () который возвращает Истину если датасет был записан и last_saved (), который возвращает дату последнего сохранения. Да, это был короткий раздел.

Пояснение к использованию датасетов в SimpleUI в аспекте локального хранения. В каких случая от СУБД можно и нужно отказаться в пользу датасета.

Давайте более пристально всмотримся в потоки данных, которые фигурируют в мобильном решении. SimpleUI — это фреймворк для бизнес приложений и так или иначе, решения на нем — это некое приложение или расширение функций бек-систем (ERP, MES, WMS и т.д.) Т.е. это своеобразный фронт. Даже если конфигурация «самостоятельная» и работает локально, она скорее всего в какие то моменты взаимодействует с бек-системой — забирает или отдает данные. Т.е. решение может быть онлайн с бек системой, оффлайн, и то что я называю «псевдо-онлайн» (когда данные пишутся локально и отправляются по возможности так быстро как смогут), но так или иначе его существование имеет смысл только если он обменивается данными с одной или несколькими системами для которых он работает.

Разные классы данных в мобильном приложении
Разные классы данных в мобильном приложении

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

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

И датасеты — это прежде всего данные, которые приходят на мобильное устройство извне, из бек-системы. Это прежде всего справочники, но также и документы, задания, распоряжения. Т.е. то, что в мобильном приложении не меняется, а просто существует для чтения и как правило имеет ссылки на объекты внешних систем. Так ли нужна СУБД для того, что не меняется? Ведь нет перезаписи в критические для производительности моменты — когда с UI работает пользователь, нет удаления. Для этой цели вполне бы подошел просто файл CSV или JSON. Да при 1 млн записей загрузка большого JSON займет 0,5 сек, но эта загрузка происходит в фоне в определенные моменты времени и не мешает работе.

Небольшая ремарка. Когда речь идет о решениях, связанных с товарами или оборудованием/основными средствами, я рекомендую использовать принцип плоской таблицы (1NF) т.е. например если товар идентифицируется по штрихкоду, то и соберите таблицу Штрихкод-Артикул-Название товара-Единица-Ссылка товара-Ссылка единицы. Да, можно сделать несколько таблиц в реляционной СУБД с внешними связями? А зачем? Когда товар сканируется (или ищется по артикулу) то вы мгновенно получаете все данные по товару. На фронте больше ничего и не надо.

Что и как мы выигрываем с датасетами? И за счет каких принципов?

Первый принцип — глубокая интеграция в механизмы платформы. Например, достаточно поместить данные в датасет и мы уже получаем отображение в виде списков с нужным дизайном (дизайн задается контейнерами). Как происходит с любой СУБД — данные надо выбрать, сформировать список записей и поместить в адаптер списка (тут еще надо не забывать про преобразования JSON). А тут не надо ничего этого делать — список на уровне приложения берет данные из датасета. На видео выше видно как работает список с 1 млн. записей. Это следствие принципа интеграции. Тоже самое с OCR — когда в видеопотоке мелькает текст, нужно очень-очень быстро делать get к данным, иначе все будет не плавно.

Второй принцип — отсутствие необходимости поддержания данных. Датасет — это просто put, сохранить/загрузить в основном (нет, есть и get и выборки и манипуляции по желанию). Он может пополняться в режиме upsert по ключу, но по большому счету он существует как просто список который можно сохранить/загрузить. Не нужно Insert/Update/Delete и выборки. Это просто список висящий в памяти, который можно сохранить/загрузить. Это не СУБД.

Дифференцированный подход к хранению

В SimpleUI существует целая палитра СУБД. Под катом, краткая история СУБД в SimpleUI

Скрытый текст

SQL

Первое что было изначально, стандартная СУБД для всех андроид-приложений — SQLite. С ней можно взаимодействовать и из Python-обработчиков Simple и через стек-переменных (т.е. из онлайн-обработчиков, javascript). Для упрощения работы добавился ORM-подход на базе Pony-ORM, а также в связи с нелюбовью к многопользовательской работе SQLite именно для Андроид была добавлена обвязка в виде singleton-класса.

Плюсы: SQL предоставляет надежное хранение, работу с таблицами с большим количеством записей и быструю выборку данных. И в принципе можно было бы остановиться на нем, но есть некоторые проблемы. SQL — это всё-таки язык, который надо знать, а SQLite имеет свои особенности относительно ANSI-SQL (а версия под Андроид, имеет также свои особенности относительно SQLite). Т.е. первая причина — необходимо иметь дополнительные знания. Вторая причина, более существенна — SQL задает жесткую структуру данных и чувствителен к изменениям. Условно, у вас таблица хранит строки документов, но в один день в документ добавляют новые колонки, а часть старых колонок меняет тип. Привет ALTER TABLE что называется. Учитывая, что сейчас никто не разрабатывает по водопаду, что требования бизнеса постоянно меняются — это очень большая проблема. Структура документов в одном цикле использования могут меняться много раз. Для SQL это сложности. Для меня достоинства SQL не перекрывают этот недостаток.

Ключ-значение

Для простых вещей, например для сохранения настроек лучше использовать более простой путь — key-value СУБД. Представьте, что в вашей конфигурации несколько настроек (какие-то строки, галочки, числа). Потом могут добавляться еще настройки, а те что есть иметь другой тип. Попробуйте мысленно решить эту задачу на SQL? И представьте что вы можете просто положить произвольные данные в коллекцию «мои настройки» под своими ключами методом put, а потом забрать методом нужную настройку методом get. Что проще?

JSON-ориентированная СУБД Pelican

Все в Simple так или иначе связано с JSON. Также обработчики событий могут быть python, а могут быть онлайн — на языке вашей бек-системы (*также javascript и еще несколько локальных вариантов) Simple в этом смысле комбайн и у всех видов интерпретаторов должны быть примерно равные возможности и одинаковый подход. Связывает это многообразие также JSON. Поэтому как альтернатива SQL мне нужна была не просто документно-ориентированная NoSQL, а именно JSON-ориентированная SQL. Меся бы полностью устроили MongoDB или Couch будь они локальные. Поэтому пришлось написать свой. Коротко — упор делается на почти мгновенное добавление, изменение, удаление записей и также мгновенную работу с индексами + поддержка транзакций и другие плюшки. И все это в синтаксисе точно таком же как у MongoDB. Сам Pelican тут , GitHub тут Принцип работы (за счет чего достигается скорость) расписан тут

Можно было бы все данные хранить в JSON-ориентированной noSQL Pelican. Она справится. Но я считаю, что разбив данные на классы, можно для каждого класса подобрать более простые инструменты:

  • key-value для констант, настроек, кеширования пользовательского ввода, логов

  • данные внешних систем (справочники, документы, ссылки) — датасеты

  • данные, создаваемые в приложении, документы, объекты локального учета — Pelican, в котором опять же используются универсальные ссылки датасетов например.

Вот такое разделение подходов предлагается разработчику. Например, в поле датасета выбрали товар (получили универсальную ссылку), там же на экране указали количество. И записали это все в СУБД Pelican

db = Pelican("samples_db1")
db["orders"].insert({"sku":"goods$100","qty":10})

Видео к статье

Обзорное видео

Пошаговая работа с объектами

Ссылки к статье

Примеры (конфигурация SimpleUI) и apk — тут

Их можно открыть в онлайн-редакторе https://seditor.ru:1555/ (либо развернуть локальную версию редактора https://github.com/dvdocumentation/web_simple_editor) и посмотреть на своем Android-устройстве

Новостной телеграмм канал проекта (новости разработки, статьи, примеры):  https://t.me/devsimpleui Рекомендую подписаться, там много полезного.

Напоминаю, что вся экосистема продуктов Simple полностью бесплатна (зарабатываю только с проектов внедрения) По всем вопросам со мной можно связаться через форму обратной связи на https://simpleui.ru/

© Habrahabr.ru