Спойлер

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

Итак, знакомьтесь. Таск-трекер "Яга", целимся импортозаместить Atlassian Jira. Система управления знаниями "Кощей" - закрывает нишу Confluence. "Колобок" (как первый релиз) - вариант более простого трекера (не всем нужна функциональность Jir'ы) - по мотивам Trello.

Я пишу в основном для разработчиков и архитекторов систем. "Бизнесовых" смыслов мы также немного коснемся (мне помогут мои коллеги). Дизайн, CI/CD - как пойдёт. В статье будет некоторый объем рассуждений, оговорюсь сразу, что это нормально, если вы с какими то из них не согласитесь - это будет прекрасно, обсудим в комментариях.

Вступление и про старт разработки

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

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

Старт разработки формализован чуть менее.

Мы начали с разработки документа по верхнеуровневой архитектуре - это несколько страниц описания, концепций и схема "из квадратиков" - очень укрупненно, но зато понятна примерно всем участникам забега.

Системная архитектура, какой она виделась на старте проекта
Системная архитектура, какой она виделась на старте проекта

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

Выбор инструментов, стеков

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

В нашей команде используется стек JVM, классикой здесь является, конечно - Java, но есть и другие "игроки" - Scala, Kotlin.

Мы взяли Kotlin.

База данных - сильно любимая нами в последнее время PostgreSQL.

Kotlin - хоть и производит, с одной стороны, впечатление продвинутого варианта Java, но на самом деле это не совсем так - у него есть своя философия, собственные фреймворки, разработанные под него - мы присматривались к Ktor, Kodein, Exposed. Но в итоге побоялись (все таки, в проекте должен быть некоторый баланс между опробованными "проверенными" технологиями и "новыми перспективными") и приняли решение, все-таки, пойти более традиционной дорогой и взяли Spring (и нисколько не пожалели).

На фронте мы взяли TypeScript, React (большое комьюнити, развитая инфраструктура), MobX в качестве стейт менеджера (думали над effector и mobx, но командным решением выбор пал на последний).

Про сервисы, микросервисы

На старте проекта мы достаточно горячо обсуждали - должна ли быть система монолитной (но нет, сейчас не модно) или микросервисной (как раз модно). В конце концов пришли к тому, что грань достаточно тонка и как определить, какой сервис можно назвать “микро”, а какой - не очень… Ну ее нет. Чем должна обладать сервисная система? Стандартным набором артефактов типа registry и всем таким прочим? Окей, но это точно “микро”? или каждый микросервис должен работать со своей БД и реализовывать одну функцию, по типу “unix way”? 

Не суть, мы пошли в историю, когда сервис не один, но их не очень много, но они достаточно детерминированы. Нам нравится гейтвей и все такое прочее из мира java/spring, но наши сервисы - умные, хорошие, самодостаточные сервисы. Назовем эту модель - среднесервисы.

Так как у нас Kotlin, мы взяли систему сборки Gradle в варианте Kotlin DSL.

Документирование

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

  • Документирование кода. В Java стандартом является javadoc, в kotlin - примерно то же самое, но dokka. Генерирует красивые страницы, включается так:

plugins {
	...
	id("org.jetbrains.dokka") version "1.6.10" apply false
	...
}
  • Документирование REST. Вот эта самая могучая штука, на мой взгляд. Суть проста - вы пишете контроллеры и эндпоинты и они сразу (в момент запуска приложения) самодокументируются в виде интерактивной страницы - которая показывает все-все-все - контроллеры, модели, примеры вызовов через curl.

Так выглядит Swagger
Так выглядит Swagger

Это чрезвычайно удобно для тестировщиков, команд, с которыми мы интегрируемся и даже аналитики периодически заходят. Дарит все это великолепие библиотека springdoc-openapi (swagger). Подключение:

dependencies {
	....
	implementation("org.springdoc:springdoc-openapi-ui:[version]")
	implementation("org.springdoc:springdoc-openapi-kotlin:[version]")
	....
}

Фронты, например, просто импортируют описание эндпоинтов и создают заголовки процедур у себя - для генерации типов используется swagger-typescript-api, достаточно удобно подтягивать типы исходные типы а не прописывать вручную.

  • Потратьте (тратьте) время на внесение соответствующих аннотаций, например следующий код:

data class TaskDto(
    @Schema(nullable = true)
    val id: Long? = null,
    val title: String,
    @Schema(description = "Идентификатор из таблицы [Project](<#/ProjectController/findAll>)")
    val projectId: Long,
    @Schema(description = "Идентификатор из таблицы [UserProfile](<#/UserProfileController/findUserProfiles>) проставляется системой при создании в дальнейших изменениях должен быть передан",
        nullable = true
    )
    val creatorId: Long? = null,
    @Schema(description = "Идентификатор из таблицы [TaskStatusRef](<#/TaskStatusRefController/findAllTaskStatusRef>)")
    val statusId: Long,
    @Schema(description = "Модификатор статуса (TaskStatusModifierRef.id) 1 - Сделать, 2 - Сделано")
    val statusModifierId: Long,
    @Schema(description = "Идентификатор из таблицы [TaskTypeRef](<#/TaskTypeRefController/findAllTaskTypes>)")
....

Создает следующее описание:

Пример описания модели / Dto по аннотациям в коде
Пример описания модели / Dto по аннотациям в коде

Обратите внимание, что есть решение даже по авторизации, пример ниже.

решение по авторизации (в визуальном интерфейсе swagger)
@Configuration
@SecurityScheme(
    name = "bearerAuth",
    type = SecuritySchemeType.HTTP,
    bearerFormat = "JWT",
    scheme = "bearer"
)
class SwaggerConfig {
}

// В контроллерах:
@Operation(summary = "create comment",
        description = "Создание комментария",
        security = [SecurityRequirement(name = "bearerAuth")]
    )
    @PostMapping
fun ....

Есть решение по реализации версионирования api, но мы его пока не использовали.

  • Банально, но скажу - важна полная провязка артефактов - постановка в confluence, тикет в jira, commit, merge request - из любой точки процесса должна быть возможность дотянуться до любой другой. Даже в необычных ситуациях, когда, например, вы разрабатывете ETL процесс в виде диаграммы - вставьте текстовый блок с номером задачи, описанием, комментариями;

  • А что по БД? Немного сложнее, но нашли достойное решение - генерирует описание в виде html, рисует схему БД, встраивается в процесс сборки знакомьтесь, SchemaCrawler. Встраивается так:

task("dbdoc") {
	doLast {
		for (schema in listOf("перечень", "схем", "для", "документации")) {
			for (format in listOf("htmlx", "html")) {
				exec {
					workingDir("$projectDir/../schemacrawler/bin")
					val prefix = if (format == "htmlx") "g_" else ""
					commandLine(listOf("$projectDir/../jaga-db/schemacrwl/schemacrwl.cmd",
						schema, format, "$buildDir/$prefix$schema.html", jagaDBPassw))
				}
			}
		}
	}
}

Сразу доступно для просмотра (аналитиками и проч.), пример:

Описание schemacrawler
Описание schemacrawler

Контракты

Контракты, они же договоренности. Больших стыков у нас два - коммуникация фронта и бэка и взаимодействие бэка и БД. Первое о чем стоит договориться - соответствие типов данных на всех трех подсистемах. Это лучше закрепить отдельной статьей в системе знаний. Второе - даты и время. Дополнительно нужно договориться о формате и временных зонах. Например так:

  • На сервере БД и на сервере приложений выставить одно и то же время, например UTC;

  • В БД дату и время (таймштампы) хранить всегда в этой же временной зоне;

  • Маршрут следования даты от фронта до БД следующий:

    • Фронт отображает даты и время в браузере пользователя в соответствии с текущими настройками в операционной системе;

    • Фронт отправляет дату на бэк преобразовывая ее в UTC и передает с обязательным признаком временной зоны (это важно!) - в формате ISO-8601 (можно, конечно, не преобразовывать, но так "ровнее");

    • Бэк проверяет, что дата пришла с указанием временной зоны и зона = UTC. Если не UTC (но так быть не должно), то преобразовывает ее в UTC самостоятельно. Если зона не указана - не принимать такое значение, вызывать исключение;

    • Бэк передает в БД, БД сохраняет значение;

  • Обратный маршрут - в целом такой же.

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

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

Про API

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

API фронта и бэка

Здесь все просто, мы взяли REST, в каком-то роде это классика. В случаях, требующих большей интерактивности (чаты, комментарии к задачам) подумываем использовать ещё WebSocket (вернее, почти уверены, что придется). Думали в сторону GraphQL, но все-таки решили остаться на REST (хотя, в будущем может и пересмотрим подход - все таки это больше про транспорт, а не про логику, так что технический рефакторинг возможен) - из-за большого опыта работы с ним, хороших инструментов документирования. Для межсервисного взаимодействия хотим попробовать gRPC - нам это интересно. Для взаимодействия с другими "подсистемами", а также, когда межсервисное взаимодействие предполагается асинхронным - используем RabbitMQ.

Отдельно остановимся на таком нюансе, как "версионирование API". С течением времени API эволюционирует - появляются новые эндпоинты, контроллеры. В целом это нормально, но возникают и ситуации, когда вносятся изменения в существующие контракты и здесь появляется первая боль - либо мы вносим изменения синхронно (бэк-фронт) либо что-нибудь начинает отваливаться. А нередко одно и то же API используется для "внешних потребителей". Как сделать "не больно"? Версионировать, т.е. поддерживать несколько версий "для тех, кто уже перешел и для тех,кто еще нет". Окей, рассмотрим технически. Предположим, все API у нас будет с префиксом, например "/v1/project/put". Swagger умеет поддерживать версии, делается это примерно так:

Пример кода
    @Bean
    fun v1Api(): GroupedOpenApi? {
        return GroupedOpenApi.builder()
            .group("v1")
            .pathsToMatch("/v1/**")
            .build()
    }
    @Bean
    fun v4Api(): GroupedOpenApi? {
        return GroupedOpenApi.builder()
            .group("v4")
            .pathsToMatch("/v{4}/**")
            .build()
    }
    @Bean
    fun v2Api(): GroupedOpenApi? {
        return GroupedOpenApi.builder()
            .group("v2")
            .pathsToMatch("/v2/**")
            .build()


    }

Погодите, т.е. для того, чтобы завести "вторую версию" нам надо сначала повторить все контроллеры "из первой"? Нет, так не хотим. Нам скорее подойдёт, когда каждый эндпоинт имеет "время жизни" в виде, например "с первой версии и до 3й" или "со второй версии и по настоящий момент". Тогда, выбирая версию в сваггере мы всегда будем иметь актуальный и полный срез контроллеров и их эндпоинтов, которые актуальны для данной версии. При этом, дублирования кода не будет. В итоге немного модифицировали решение выше, работает

Более продвинутый вариант
object API {
    const val ANY = "v{v:1|2}" // любая версия
    const val TILL1 = "v{v:1}" // любая версия ДО указанной
    const val FROM_V2 = "v{v:2}" // версия, начинающаяся С указанной
}

...

@PostMapping("/${API.ANY}/project/{projectId}/generatePutObjectUrl")

...

@Bean
fun v1Api(): GroupedOpenApi? {
    return GroupedOpenApi.builder()
        .group("v1")
        .addOpenApiMethodFilter(Filter("v1"))
        .build()
}

@Bean
fun v2Api(): GroupedOpenApi? {
    return GroupedOpenApi.builder()
        .group("v2")
        .addOpenApiMethodFilter(Filter("v2"))
        .build()
}

class Filter(val version: String) : OpenApiMethodFilter {

    override fun isMethodToInclude(method: Method): Boolean {
        return if (
            method.getAnnotation(GetMapping::class.java)?.value?.any { match(version, it) } ?: false
            || method.getAnnotation(PostMapping::class.java)?.value?.any { match(version, it) } ?: false
            || method.getAnnotation(PutMapping::class.java)?.value?.any { match(version, it) } ?: false
            || method.getAnnotation(DeleteMapping::class.java)?.value?.any { match(version, it) } ?: false
        ) {
            true
        } else {
            false
        }
    }

    fun match(target: String, path: String): Boolean {
        val splited = path.split("/")
        val firstToken = splited.getOrNull(1)
        firstToken?.let {
            val matcher = AntPathMatcher()
            return matcher.match(firstToken, target)
        }

        return false
    }

API БД

А вот это очень "холиварная" тема, постараюсь раскрыть ее. В современном мире JVM (и многих других) разработки очень часто почти всегда используют ORM (Object Relational Mapping), который собственно и делает всю работу по сохранению объектов в БД, делает весьма неплохо, кстати. Что это дает:

  1. (Почти) Не нужно самому писать запросы к БД - "хибер" (hibernate) сделает все за нас;

  2. Мы пишем все на одном любимом языке (Java/Kotlin);

  3. А значит в команде меньше потребностей в специалистах БД;

  4. Хорошая переносимость решения между разными БД (если не использовать особенности БД);

  5. В целом неплохо решаются задачи горизонтального масштабирования (когда вся логика выносится на слой сервисов).

Пойдем к минусам, их немало:

  1. Все таки ни один ORM не напишет запросы лучше хорошего специалиста. Причём тут речь даже не про запросы, просто разработчик БД продумает оптимальные структуры хранения, уровни агрегации, сделает нужное партиционирование, предложит индексы, а уже только потом запросы напишет;

  2. Как следствие - если использовать только ORM из команды уходят компетенции БД. Нет, бэкендщики классные умные ребята, просто фокус интереса в этом случае смещается по естественным причинам. А если чем то не заниматься - знание уходит;

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

  4. Нередко "pure-ORM" решения требуют "перезапуска", вернее оптимизации. Запускаются они, как правило, без больших объемов данных (если это новые приложения), в этот момент все достаточно шустро шевелится. Проходит некоторое время (месяцы, годы), объемы данных в БД начинают подрастать, некоторые запросы начинают выполняться медленнее, чем хотелось бы - производительность системы снижается. В этот момент как правило зовут БДшника с просьбой "посмотреть, пооптимизировать", собственно он переходит к п.1. данного списка ;). А давайте постараемся этого избежать?

Анализ был бы неполным, если не взглянуть на проблему с “зеркальной” стороны - глазами разработчика БД (такие разговоры, как правило, нередки на “чисто БДшных” конференциях, например на pgConf). Все получается диаметрально:

  1. Никакой ORM не нужен, он только мешает, лучше мы напишем чистые и красивые SQL запросы;

  2. И вообще он достаточно “убогий”, все делается автоматически:

    1. Без дополнительных “приседаний” управление транзакциями не прозрачно;

    2. “Commit to savepoint” - так и вообще нету (это кстати правда, как минимум для PostgreSQL этого нет - мы проверяли по исходникам hibernate);

    3. Запросы с пагинацией тоже негибкие (и это правда, hibernate генерирует только запрос с “limit X offset Y” и нет, например возможности, сделать стандартный Pageable, передавая параметры Limit и Offset, например, в параметры функции - и это мы тоже проверяли по исходникам);

    4. … и можно продолжать;

  3. Современные СУБД предлагают массу дополнительных возможностей, тогда как ORM использует только базовые из них. Это все равно как, например, приобрести “порше”, но использовать его только в объеме “жигулей” - в том смысле едет то он конечно все равно лучше, но у него есть куча дополнительных возможностей - системы ABS, помощи спуска с горы, кондиционер, круиз контроль, которые как раз ORM и не использует.

Ну и к чему мы пришли, постаравшись “никого не обидеть” и в то же время максимально изящно решить свои задачи:

  1. Хайбернейтом пользуемся, но:

  2. Выделяем четкий слой БД API - вплоть до отдельных схем. Например схема проектов у нас proj, а схема API - proj_API;

  3. Все запросы на получение данных от бэкэнда к БД идут через представления (view) и функции (возвращающие resultset). Это удобно, так как view можно гораздо более гибко менять, если возникает необходимость не меняя приложений бэкенда;

  4. Все запросы на изменения данных от бэкэнда к БД идут через API функции;

  5. Все представления и функции БД API документируются в базе знаний;

  6. API - легковесное, т.е. служит только “оберткой” и интерфейсом для вызовов БД, сами расчеты и вычисления остаются на слое приложений.

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

Выбор БД

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

  1. Импортозамещение, реестр российского ПО. Понятно, что про это говорится "из каждого утюга", но это действительно веская причина и БД PostgreSQL становится "целевой" не только в нашей организации;

  2. Потому что мы уже хорошо его знаем и умеем. В том числе и мигрировать на него, например с Oracle (но это все же не тема этой статьи, об этом мы писали тут);

  3. Хороший, богатый SQL - один из лучших;

  4. Хорошие, богатые типы данных - и массивы и json и даже типы для работы с гео-объектами, диапазоны. И особенно (второй раз пишу) json;

  5. Максимально бурно развивается;

  6. Очень открыта для разработчиков, например, расширений (и не только конечно же). К Oracle, наример, через FDW подключиться можно без проблем, к MSSQL тоже. Через "триграммы" мы сделали нечеткий полнотекстовый поиск;

  7. "Взрослые" фичи, такие как секции (партиции), шардирование (можно горизонтально масштабировать узкие места);

  8. Язык функций и процедур - на выбор, хоть "обычный", хоть Python или JS (и не только);

  9. Отличная "атмосферная" тусовка на pgConf (ежегодная);

  10. Дружелюбный инструментарий, но о нем отдельно.

Поддержка нюансов и специфических типов данных

На самом деле не такие уж они и специфические, но стоит поговорить про них отдельно через призму взаимодействия БД и бэка через ORM. Здесь у нас есть:

  1. Null в параметрах функций. Это скорее некоторый нюанс, но про него нельзя не сказать. Дело в том, что Postgres очень трепетно относится к типам данных (намного строже, чем Oracle, например). Ситуацию усложняет тот факт, что функции в Postgres могут быть перегруженные (называться одинаково, но с разным числом параметров) и выбор нужной функции для конкретного вызова происходит также по набору типов параметров. 

Теперь посмотрим что происходит, если параметр объявлен (:param), но не передан. По логике мы ожидаем передачи Null’а, но этого не происходит, а вернее происходит, но он считается типом bytea. Postgres, в отличие от Oracle ведёт себя как настоящий граммар наци и рапортует, что функции с таким набором параметров у него нет. Приходится использовать следующие обертки (многословно, но работает):

пример вызова функции с nullable параметрами
@Query(
        """select * from jgproj_api.f_search_project_by_name(
            p_search_text => cast(:search_text as text),
            p_user_id => cast(cast(:user_id as text) as bigint),
            p_offset=> :offset,
            p_limit=> :limit,
            p_sort=> cast(:sort as text))""",
        nativeQuery = true
    )

  1. Массив. Вроде бы ничего особенного, но явление в БД не такое уж и частое, а в Postgres они есть давно и это очень удобно (хоть и несколько в стороне от классического реляционного подхода). Чтобы поддержать массив нам пришлось писать свой собственный класс-обертку, иначе Hibernate отказывался с ним работать. Есть нюанс при описании “массивного” типа в swagger.

использование массивов
... 
// описание атрибута в модели
@JsonProperty("user_project_authorities")
@Type(type = "com.rit.crossdev.jaga.util.StringArrayUserType")
@Column(name = "user_project_authorities")
val userProjectAuthorities: Array<String>?
...

// нюанс описания null - не null для самого массива и для элементов массива
@ArraySchema(
    schema = Schema(description = "Роли пользователей", nullable = false),
    arraySchema = Schema(description = "Роли пользователей", required = false, nullable = true)
)
val userProjectRoles: Array<String>? = null
...

Класс, описывающий пользовательский тип - массив строк
package com.rit.crossdev.jaga.util

import org.hibernate.HibernateException
import org.hibernate.engine.spi.SharedSessionContractImplementor
import org.hibernate.usertype.UserType
import java.io.Serializable
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types

class StringArrayUserType : UserType {

    private val typeParameterClass: Class<Array<String?>>? = null

    override fun assemble(cached: Serializable, owner: Any): Any? {
        return deepCopy(cached)
    }

    override fun deepCopy(value: Any?): Any? {
        return value
    }

    override fun disassemble(value: Any?): Array<String>? {
        return deepCopy(value) as Array<String>?
    }

    @Throws(HibernateException::class)
    override fun equals(x: Any?, y: Any?): Boolean {
        return if (x == null) {
            y == null
        } else x == y
    }

    override fun hashCode(x: Any): Int {
        return x.hashCode()
    }

    override fun nullSafeGet(
        resultSet: ResultSet,
        names: Array<String>,
        sharedSessionContractImplementor: SharedSessionContractImplementor,
        o: Any
    ): Array<String>? {
        val array = resultSet.getArray(names[0])
        return if (array != null) {
            array.array as Array<String>
        } else {
            arrayOf<String>()
        }
    }

    override fun nullSafeSet(
        statement: PreparedStatement,
        value: Any?,
        index: Int,
        sharedSessionContractImplementor: SharedSessionContractImplementor
    ) {
        val connection = statement.connection
        if (value == null) {
            statement.setNull(index, SQL_TYPES[0])
        } else {
            val castObject = value as Array<String>?
            val array = connection.createArrayOf("integer", castObject)
            statement.setArray(index, array)
        }
    }

    override fun isMutable(): Boolean {
        return true
    }

    override fun replace(original: Any, target: Any, owner: Any): Any {
        return original
    }

    override fun returnedClass(): Class<Array<String?>> {
        return typeParameterClass!!
    }

    override fun sqlTypes(): IntArray {
        return intArrayOf(Types.ARRAY)
    }

    companion object {

        protected val SQL_TYPES = intArrayOf(Types.ARRAY)
    }
}

  1. JSON. О, это отдельная тема, тем более в современных версиях Postgres сделана огромная работа по качественной поддержке этого типа (вернее типов этих два - json и jsonb). Обязательно почитайте доклад Олега Бартунова про json на pgConf;

По факту, JSON это отход от 3 нормальной формы, но позволяет хранить нерегулярные объекты в одной таблице. То есть его хорошо использовать тогда, когда мы заранее не знаем набор передаваемых в функцию параметров или набор возвращаемых столбцов. Либо знаем, но они так разнообразны, что классическое описание было бы заметной тратой времени на boilerplate код. Нюансы кроются в этом же: для передачи бэк-бд-бэк в бэке нужно делать сериализацию/десереализацию данных в нужный тип объекта.

В бд можно собирать данные в одно поле, а потом использовать разнообразные операторы работы с JSON (->, ->>, #>, конкатенацию jsonb).

Вместо заключения

Как, всё!? На самом деле далеко нет, но закончим на этом первую часть. В следующий раз поговорим о:

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

  2. Валидация значений - о том, где правильнее ее делать (фронт, бэк, БД) и как сделали мы;

  3. Обработка ошибок;

  4. Поддержка нескольких языков;

  5. Ролевая модель;

  6. Мигратор, или зачем мы написали свой аналог Liquibase/Flyway;

  7. И о чем-нибудь еще :)

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


  1. Doctorrr
    00.00.0000 00:00
    +1

    Стек одобряю, молодца!


  1. oldd
    00.00.0000 00:00

    @Query(
    """select * from jgproj_api.f_search_project_by_name(
    p_search_text => cast(:search_text as text),
    p_user_id => cast(cast(:user_id as text) as bigint),
    p_offset=> :offset,
    p_limit=> :limit,
    p_sort=> cast(:sort as text))""",
    nativeQuery = true
    )

    Господа, но ведь это жесть неподдерживаемая! У exposed-а ведь есть мощная dsl, которая сильно помогает не ошибиться в названии поля, и в случае каких-либо правок в слое базы, весь ошибочны код даже не скомпилится. Почему здесь вы такой подход не использовали?


    1. plotn1 Автор
      00.00.0000 00:00
      +1

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

      Ну и плюс тесты то все равно же, если они написаны, отработают ситуацию. А мы их пишем, у нас много тестов.

      Но спасибо, обратим внимание на exposed dsl, а может и придумаем что-то подобное, применительно к нашему решению.


      1. left_sok
        00.00.0000 00:00

        Нет, в exposed есть вполне себе типобезопасная обёртка entity. Так что с полями трудно ошибиться, т.к. они являются полями entity. А api entity максимально приближён к sql запросу. Собственно как и весь dsl exposed. Но есть минусы: нет поддержки хранимых процедур и при большом объёме запросов лучше пользоваться api Table вместо Entity (Всё ещё типобезопасно, но накосячить проще). Из плюсов - инициализация схемы базы данных очень даже минималистичная.