Skip to content

Разработка Lingvodoc

Ivan Beloborodov edited this page Dec 25, 2024 · 1 revision

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

Данные системы

База данных самая простая часть, реализована в виде БД на PostgreSQL, на данный момент версии 13. При разработке используются локальные копии БД которые поднимаются из дампа, перед работой необходимо один раз создать БД, например через консоль с помощью команды psql как

drop database if exists lingvodoc;
create database lingvodoc with owner postgres encoding 'UTF8' LC_COLLATE = 'ru_RU.UTF-8' LC_CTYPE = 'ru_RU.UTF-8' template template0;

и после это можно когда нужно неограниченное число раз восстанавливать конкретное состояние БД из любого имеющегося дампа как

xzcat lingvodoc-2024-12-24.06-51.sql.xz | sudo -u postgres psql lingvodoc

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

Заходить в БД, чтобы например посмотреть общую схему или какие-то конкретные данные, также можно через консоль с psql, или любым другим удобным способом, например через pgAdmin.

Сущности системы

В системе следующая иерархия сущностей (в скобках названия таблиц БД): язык (language) -> ... -> язык -> словарь (dictionary) -> перспектива (dictionaryperspective) -> лексический вход (lexicalentry) -> сущность данных в узком смысле (entity). Сущности идентифицируются айдишниками, сущности разных уровней связаны друг с другом через ссылки по айдишнику родительской сущности, начинается иерархия с корневых языков у которых родителей нет и айдишники родительского языка не выставлены.

Посмотреть структуру таблиц БД можно в psql-консоли через команду \d+, \d+ language например. По идее в pgAdmin тоже должно быть можно структуру таблиц смотреть.

Айдишники составные, из двух значений, client_id и object_id, соответственно ссылки на родительские сущности идут через составной внешний ключ parent_client_id и parent_object_id, к примеру у дочернего языка в parent_client_id и parent_object_id хранятся значения client_id и object_id родительского языка. У корневых языков parent_client_id и parent_object_id — null.

При этом client_id — айдишник (простой bigint, не составной) записи в БД-таблице client, в которой навая запись появляется при каждом новом логине любого пользователя, а object_id — значения простого последовательный счетчика, привязанного к этой client-записи как поле counter. Одно из авторизационных значений которые появляются в куках в браузере при логине это именно client_id логина пользователя.

Если интересно, всю иерархию языков от корневых к дочерним можно посмотреть на https://lingvodoc.ispras.ru/languages. У языков системы также есть еще одно явное дополнительное свойство-пометка, некоторые языки помечены как такие так сказать мощные/крупные, а другие, не помеченные, интерпретируются как менее крупные, скорее диалекты, эти два типа языков могут обрабатываться по-разному в зависимости от используемых инструментов системы.

В языках могут быть словари (таблица БД dictionary), словарь привязывается к языку через свой внешний ключ parent_client_id и parent_object_id. У словарей есть название и другие свойства, важно что собственно словарные данные хранятся не в словарях а в так называемых перспективах (таблица БД dictionaryperspective) этих словарей, на случай если в словаре может быть больше одного типа словарных записей, возможно связанных с друг другом. И да, по тому же принципу перспектива привязывается к словарю через parent_client_id и parent_object_id.

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

Тем не менее в системе достаточно много словарей где просто одна перспектива.

В перспективах данные хранятся в виде записей, называемых лексическими входами (таблица БД lexicalentry), опять-таки лексический вход привязывается к перспективе через внешний ключ parent_client_id, parent_object_id. Кстати в примере выше для распространненого типа словарей с двумя перспективами хотя одна перспектива называется "Парадигмы", а другая "Лексические входы", на уровне сущностей системы и в той и другой хранятся лексическое входы, одно с другим не связано.

В самих объектах БД лексических входов данные не хранятся, лексические входы как бы являются единицами хранения, обозначающими одну запись в перспективе. Данные входов хранятся в сущностях (таблица БД entity), привязанных к своим входам так же через parent_client_id, parent_object_id. Тут имеются в виду сущности в узком смысле, сущности данных, в противоположность когда говорим о сущностях системы в широком смысле, языках, словарях и т.д.

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

Тип сущности опеределяется ее полем, записью в таблице БД field на которую сущность ссылается через внешний ключ field_client_id, field_object_id. Какие вообще поля у сущностей могут быть в перспективе определяется набором записей в таблице БД dictionaryperspectivetofield, привязанных к перспективе через свои parent_client_id, parent_object_id.

Бэкенд

Бэкенд https://github.com/ispras/lingvodoc/tree/heavy_refactor реализован на Python с использованием фреймворка Pyramid в качестве базы и для предоставления REST API, с использованием библиотеки SQLAlchemy для работой с базой данных и с использованием библиотеки Graphene для предоставления GraphQL API.

Версии используемых библиотек не самые последние, текущая политика насчет версий зависимостей — жесткая привязка к конкретным версиям, выбранные версии зависимостей см. в файле server-requirements-1.txt.

См. как запускать https://github.com/ispras/lingvodoc?tab=readme-ov-file#running-the-project-for-development, или в README.md в склонированном репозитории.

REST API сейчас большей частью не используется и не поддерживается, некоторое время назад даже было принято решениe от неиспользованных частей постепенно избавляться, по пока неиспользуемые части еще в коде есть. Используется из REST API часть, относящаяся к авторизации пользователей, которая в будущем должна быть заменена, и один-единственный API-интерфейс через который идет доступ к GraphQL API.

GraphQL API определяется в файле lingvodoc/schema/query.py, состоит как обычно из запросов и мутаций, набор доступных в API запросов задается в классе Query в этом файле, набор доступных в API мутаций — в классе MyMutations. С общими принципа реализации запросов и мутаций можно ознакомиться в документации библиотеки Graphene, конкретику их применения можно посмотреть на примере реализаций в существующем коде.

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

При собственно реализации GraphQL запросов, мутаций и типов доступ к данным сущностей системы, хранящимся в БД, осуществляется средствами SQLAlchemy; таблицы БД в большинстве своем отражаются классами в файле lingvodoc/models.py через ORM-средства SQLAlchemy, см. ее документацию.

Для большинства сущностей системы имеет место быть отношение отражения вида: Таблица БД <-> ORM-класс в models.py <-> класс GraphQL-тип. Например, таблица БД language, в которой хранятся данные языков, отражается в ORM-класс Language в models.py и в соответствующий GraphQL-тип, представляемый классом Language из файла lingvodoc/schema/gql_language.py.

При необходимости использования одновременно соответствующих ORM-классов и классов GraphQL-типов для дизамбигуации используется соглашение об импорте, в соответствии с которым ORM-классы импортируются с префиксом db, например:

from lingvodoc.models import Language as dbLangauge
from lingvodoc.schema.gql_language import Language

Реализация полей GraphQL-типов, соответствующих сущностям системы, требует доступа к соответствующим объектам ORM-классов, и для этого каждый объект класса GraphQL-типа должен при создании получить либо идентификатор соответствующей БД-сущности, либо сам объект ORM-класса. Для того чтобы гарантировать наличие доступа к соответствующему объекту ORM-класса, используется декоратор fetch_object().

К примеру, GraphQL-запрос верхнего уровня language(id: LingvodocID) реализуется в файле query.py как

def resolve_language(self, info, id):
    return Language(id=id)

и если в этом запросе для выбираемого языка выбирается поле locale_exists, например при запросе

language(id: $id) {
  locale_exists
}

то его реализация обеспечит себе доступ к ORM-объекту языка для вычисления значения поля на основе БД-данных языка следущем образом, см. файл gql_language.py:

@fetch_object()
def resolve_locale_exists(self):
    return self.dbObject.locale

Пример инициализации GraphQL-объекта прямо через ORM-объект, см. файл gql_language.py, реализация мутации create_language:

return (

    CreateLanguage(
        language = Language(db_language),
        triumph = True))

В коде также встречаются устаревшие варианты с явным внешним заданием ORM-объекта при создании GraphQL-объекта, например в реализации поля languages дочерних языков, в методе resolve_languages() в файле gql_language.py:

gql_language = Language(id=[language.client_id, language.object_id])
gql_language.dbObject = language

Такой способ допустим, но лучше пользоваться более новым, более коротким и понятным способом:

gql_language = Language(language)

Декоратор fetch_object() также имеет дополнительное применение, он может использоваться для оптимизации вычисления значений GraphQL-полей. Технические подробности и детали см. в реализации этого декоратора в файле gql_holders.py, но общий принцип его использования для такой оптимизации следующий:

При создании GraphQL-объекта мы явно задаем ему значение поля, и при присутствии декоратора fetch_object() с дополнительным аргументом вычисление значения это поля будет заключатся просто в возвращении этого явно заданного значения.

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

Например, при оптимизированном получении данных сразу большого количества языков вместе с их словарями с помощью класса Language_Resolver в файле gql_language.py происходит оптимизированные вычисление числа словарей в языках, и эти значения явно сохраняются в GraphQL-объектах языков при их создании:

gql_language = (
   Language(id = language_id))

gql_language.dbObject = language

...

if dictionary_count_f:

    gql_language.dictionary_count = (
    dictionary_count_f(language_id))

После этого, если в обрабатываемом GraphQL-запросе будет присутствовать извлечение поля dictionary_count, при получении значения этого поля для нашего GraphQL-объекта с явно сохраненным значением в атрибуте dictionary_count, с помощью стандартного resolve-метода

@fetch_object('dictionary_count')
def resolve_dictionary_count(
    self,
    info,
    recursive = False,
    category = None,
    published = None):

	...

с соответствующим вызовом декоратора @fetch_object('dictionary_count'), см. файл gql_language.py, сам метод resolve_dictionary_count() даже не будет запущен, функционал декоратора просто вернет значение которое было явно сохранено в объекте присваиванием gql_language.dictionary_count = ... из кода выше.

Разработка GraphQL API

Для добавления мутации в GraphQL API бэкенда нужно в файле lingvodoc/schema/query.py определить класс мутации и добавить его в главный класс мутаций MyMutations, см. для аналогии класс мутации BidirectionalLinks. В классе мутации должен быть метод mutate(), обычно еще бывает объявление аргументов в виде только для этого и используемого внутреннего класса Arguments, но в случаях когда у мутации аргментов нет можно без него.

Если явно нет причин так не делать, все что внутри метода mutate() лучше оборачивать в try/except, см. по аналогии с mutate() в BidirectionalLinks, естественно с соответствующими изменениями в сообщениях.

В процессе отладки при разработке GraphQL API практически всегда бывает необходимость логиниться в Лингводок, под стандартным пользователем или под админом. Обычно в качестве стандартного пользователя при отладке используется логин julianor, админский логин — modis.

При незнании реального паролей можно их подменить апдейтом в БД. В БД можно заходить как удобно, прямо из консоли через psql, или через pgAdmin, или вообще любым другим подходящим способом. Если под Linux и с глобальной системной установкой БД, как оно на продакшен-сервере и как в целом может быть удобнее для разработки, то из консоли заходится как sudo -u postgres psql lingvodoc. Если БД в докере, то там нужно еще указывать дополнительно хост, порт и пароль, может еще что-то, нужно разбираться. Если под Windows то можно проконсультироваться с Владимиром Монаховым.

В БД для замены паролей пользователя julianor и админа можно использовать стандартный апдейт-запрос

update passhash set hash='$2a$12$tNxWXqi4Puyc5RX77Vqtpu31h1riDWpTtqCjN1hZFtXalKGzPv3fa' where user_id=5 or user_id=1 or user_id=141;

он используется на тестовых ВМ в дженкинсе ровно для этого. После выполнения этого запроса пароль у админа, у пользователя julianor и еще у деактивированного пользователя test становится G59TVXu2unvbtg.

Для отладки частей бэка в изоляции от фронта можно прямо делать соответствующие GraphQL-запросы к запущенному бэку. Можно это делать из консоли через curl или другими удобными способами, например есть такая достаточно удобная штука Insomnia. Пример curl-запроса к бэку, запущенному стандартным образом через pserve development.ini, см. также в BidirectionalLinks.

Если запрос для выполнения требует авторизации из-под пользователя, обычного или админа, нужны будут авторизационные куки. Простейший способ их получить это запустить в дополнение к бэку еще и фронт, залогиниться в браузере и посмотреть в соответствующей вкладке development tools. В зависимости от предпочтений может еще даже проще будет зайти на допустим подстраницу фронта /corpora_all и во вкладке network браузерных development tools на первый же graphql-запрос (подадрес graphql, тип json) кликнуть правой кнопкой мыши и выбрать copy as curl, после чего вставить в допустим текстовый редактор и скопировать оттуда всю необходимую инфу.

Для прямой отладки запросов есть как минимум один способ. В нужном месте в коде запроса добавляется import pdb; pdb.set_trace(), бэк запускается как обычно из консоли через pserve development.ini, запрос делается через curl или другим удобным способом, и в консоли где запущен бэк попадаем в том месте где добавили pdb.set_trace() в питоновский стандартный дебаггер pdb, он хоть и командной строки но все что надо там есть.

Фронтенд

Фронтенд https://github.com/ispras/lingvodoc-react/tree/staging реализован как React-приложение.

Для запуска нужна 14-я версия Node.js, разными версиями Node.js можно консольно управлять скриптом nvm, см. в Google.

Для запуска локально для разработки в директории исходников (важно что с текущей главной ветки staging) нужно один раз выполнить

npm install

после чего при запущенном бэке можно запускать фронт как:

PROD=http://localhost:6543 npm start

Фронтенд поддерживает возможность перевода интерфейса на различные языки и возможность переключения между переводами.