Русский
Русский
English
Статистика
Реклама

Перевод Руководство по работе с фреймворком Kotlin Exposed

Перевод подготовлен в рамках набора учащихся на курс "Kotlin Backend Developer".

Также приглашаем всех желающих на демо-урок Объектно-ориентированное программирование в Kotlin. Цели занятия:
- узнать про элементы объектной модели Kotlin;
- создавать различные классы и объекты;
- выполнять наследование и делегирование;
- пользоваться геттерами и сеттерами.


1. Введение

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

Exposed это открытая библиотека, разработанная компанией JetBrains. Она распространяется по лицензии Apache и позволяет использовать идиоматический API Kotlin для реализации некоторых реляционных баз данных от различных поставщиков.

Exposed можно использовать как в качестве высокоуровневого языка DSL в SQL, так и в качестве облегченной технологии ORM (объектно-реляционного отображения). В этом руководстве мы рассмотрим оба варианта использования.

2. Установка

Фреймворк Exposed еще не опубликован в Maven Central, потому нам придется использовать отдельный репозиторий:

<repositories>    <repository>        <id>exposed</id>        <name>exposed</name>        <url>https://dl.bintray.com/kotlin/exposed</url>    </repository></repositories>

Теперь можно подключить библиотеку:

<dependency>    <groupId>org.jetbrains.exposed</groupId>    <artifactId>exposed</artifactId>    <version>0.10.4</version></dependency>

Ниже мы приведем несколько примеров использования базы данных H2 в памяти:

<dependency>    <groupId>com.h2database</groupId>    <artifactId>h2</artifactId>    <version>1.4.197</version></dependency>

Последняя версия Exposed доступна на Bintray, а последняя версия H2 на Maven Central.

3. Соединение с базой данных

Для того чтобы установить соединение с базой данных, будем использовать класс Database:

Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")

Мы можем также указать пользователя (user) и пароль (password) в качестве именованных параметров:

Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver",user = "myself", password = "secret")

Обратите внимание: вызов метода connectне устанавливает соединения с БД сразу. Соединение будет установлено позже с использованием сохраненных параметров.

3.1. Дополнительные параметры

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

Database.connect({ DriverManager.getConnection("jdbc:h2:mem:test;MODE=MySQL") })

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

3.2. Подключение через DataSource

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

Database.connect(datasource)

4. Открытие транзакции

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

Метод transactionпринимает замыкание и вызывает его в активной транзакции.

transaction {//Do cool stuff}

Метод transactionвозвращает значение, которое вернуло замыкание.После выполнения блока Exposed автоматически закрывает транзакцию.

4.1. Подтверждение и откат транзакций

После успешного выполнения блока с помощью метода transaction Exposed подтверждает транзакцию. Если же замыкание генерирует исключение, фреймворк откатывает транзакцию.

Подтверждение и откат транзакции можно выполнить в ручном режиме. В Kotlin замыкание, которое мы передали в методе transaction, фактически является экземпляром класса Transaction.

Таким образом, мы можем использовать метод commitилиrollback:

transaction {//Do some stuffcommit()//Do other stuff}

4.2. Запись инструкций в журнал

При изучении фреймворка или отладке кода не лишним будет отследить инструкции и запросы SQL, которые Exposed отправляет в базу данных.

Для этого добавим регистратор к активной транзакции:

transaction {    addLogger(StdOutSqlLogger)    //Do stuff}

5. Определение таблиц

Как правило, Exposed не используется для работы с неформатированными строками и именами SQL. Мы определяем таблицы, столбцы, ключи, связи и т.д. с помощью высокоуровневого DSL.

AD

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

object StarWarsFilms : Table()

Exposed автоматически присваивает таблице имя на основании имени класса, но мы можем задать имя самостоятельно:

object StarWarsFilms : Table("STAR_WARS_FILMS")

5.1. Столбцы

Без столбцов таблица работать не будет. Определим столбцы как свойства класса Table:

object StarWarsFilms : Table() {val id = integer("id").autoIncrement().primaryKey()val sequelId = integer("sequel_id").uniqueIndex()val name = varchar("name", 50)val director = varchar("director", 50)}

Для краткости мы не указали типы Kotlin определит их автоматически. В любом случае каждая колонка относится к классу Column<T>, у нее есть имя, тип и, возможно, параметры типа.

5.2. Первичные ключи

В предыдущем разделе мы рассмотрели пример, где индексы и первичные ключи определяются с помощью текучего API.

Однако, если в таблице в качестве первичного ключа используется целое число, мы можем использовать встроенные в Exposed классы IntIdTableи LongIdTable для определения ключей:

object StarWarsFilms : IntIdTable() {val sequelId = integer("sequel_id").uniqueIndex()val name = varchar("name", 50)val director = varchar("director", 50)}

Есть также класс UUIDTable, а еще мы можем определить собственные варианты, выделив подклассы в классе IdTable.

5.3. Внешние ключи

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

Предположим, что нам нужно узнать имена актеров, которые снимались в каждом фильме:

object Players : Table() {val sequelId = integer("sequel_id").uniqueIndex().references(StarWarsFilms.sequelId)val name = varchar("name", 50)}

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

val sequelId = reference("sequel_id", StarWarsFilms.sequelId).uniqueIndex()

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

val filmId = reference("film_id", StarWarsFilms)

5.4. Создание таблиц

Таблицы можно создавать программно, как указано выше:

transaction {SchemaUtils.create(StarWarsFilms, Players)//Do stuff}

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

6. Запросы

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

6.1. Выбор всех объектов

Для того чтобы извлечь данные из базы, будем использовать объекты Query, созданные на основе классов таблиц. Самый простой запрос будет возвращать все строки заданной таблицы:

val query = StarWarsFilms.selectAll()

Запрос является итерируемым и поддерживает циклы forEach:

query.forEach {assertTrue { it[StarWarsFilms.sequelId] >= 7 }}

Параметр замыкания, которому в нашем примере присвоено имя it, это экземпляр класса ResultRow. В результате столбцам присваиваются ключи.

6.2. Выбор подмножества столбцов

Мы можем также выбрать подмножество столбцов таблицы, тоесть выполнить проекцию, с помощью метода slice:

StarWarsFilms.slice(StarWarsFilms.name, StarWarsFilms.director).selectAll().forEach {assertTrue { it[StarWarsFilms.name].startsWith("The") }}

Этот метод позволяет применить функцию к столбцу:

StarWarsFilms.slice(StarWarsFilms.name.countDistinct())

Часто при использовании агрегатных функций, например countи avg, направляя запрос, мы используем группировку по оператору. О группах мы поговорим в разделе 6.5.

6.3. Фильтрация с помощью выражения where

В Exposed для выражений where, которые используются для фильтрации запросов и других типов инструкций, используется специальный DSL. В основе этого мини-языка лежат свойства столбцов, с которыми мы познакомились ранее, и серия логических операторов.

Вот пример выражения where:

{ (StarWarsFilms.director like "J.J.%") and (StarWarsFilms.sequelId eq 7) }

Оно относится к комплексному типу и является подклассом SqlExpressionBuilder, который определяет такие операторы, как like, eq, and. Как видим, это последовательность операций сравнения, соединенных операторами andи or.

Мы можем передать такое выражение в метод select, который вернет очередной запрос:

val select = StarWarsFilms.select { ... }assertEquals(1, select.count())

Поскольку тип может быть выведен из контекста, нам не обязательно указывать тип complex для выражения where, когда оно передается непосредственно в метод select, как в рассмотренном примере.

В Kotlin выражения с where являются объектами, поэтому специальных параметров для запросов нет. Мы используем переменные:

val sequelNo = 7StarWarsFilms.select { StarWarsFilms.sequelId >= sequelNo }

6.4. Дополнительная фильтрация

Существует несколько методов для уточнения запросов, которые возвращает метод selectи его эквиваленты.

Например, можно удалить повторяющиеся строки:

query.withDistinct(true).forEach { ... }

Или вернуть только подмножество строк, например в случае нумерации страниц с результатами при работе над пользовательским интерфейсом:

query.limit(20, offset = 40).forEach { ... }

Эти методы будут возвращать новые объекты Query, поэтому мы можем выстроить их вызовы в цепочку.

6.5.Методы orderByи groupBy

Метод Query.orderByпринимает список столбцов, связанных со значением SortOrder, которое задает тип сортировки элементов по возрастанию или по убыванию:

query.orderBy(StarWarsFilms.name to SortOrder.ASC)

Группировка по одному или нескольким столбцам будет особенно полезна при использовании агрегатной функции (см. раздел 6.2). Для этого воспользуемся методом groupBy:

StarWarsFilms.slice(StarWarsFilms.sequelId.count(), StarWarsFilms.director).selectAll().groupBy(StarWarsFilms.director)

6.6. Соединения

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

(StarWarsFilms innerJoin Players).selectAll()

В этом примере мы использовали оператор innerJoin, но по этому же принципу можно использовать операторы LEFT JOIN, RIGHT JOIN и CROSS JOIN.

Затем можно добавить условия соединения, используя выражение where; например, если у нас нет внешнего ключа, придется указать явную операцию соединения:

(StarWarsFilms innerJoin Players).select { StarWarsFilms.sequelId eq Players.sequelId }

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

val complexJoin = Join(StarWarsFilms, Players,onColumn = StarWarsFilms.sequelId, otherColumn = Players.sequelId,joinType = JoinType.INNER,additionalConstraint = { StarWarsFilms.sequelId eq 8 })complexJoin.selectAll()

6.7. Псевдонимы

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

(StarWarsFilms innerJoin Players).selectAll().forEach {assertEquals(it[StarWarsFilms.sequelId], it[Players.sequelId])}

На самом деле в этом примере StarWarsFilms.sequelIdи Players.sequelId это разные столбцы.

Однако, если в запросе одна и та же таблица появляется несколько раз, можно присвоить ей псевдоним. Для этого воспользуемся функцией alias:

val sequel = StarWarsFilms.alias("sequel")

Псевдоним можно указывать в качестве названия таблицы:

Join(StarWarsFilms, sequel,additionalConstraint = {sequel[StarWarsFilms.sequelId] eq StarWarsFilms.sequelId + 1}).selectAll().forEach {assertEquals(it[sequel[StarWarsFilms.sequelId]], it[StarWarsFilms.sequelId] + 1)}

В этом примере sequel это таблица, которая участвует в операции соединения. Чтобы обратиться к столбцу, в качестве ключа будем использовать столбец таблицы, представленной псевдонимом:

sequel[StarWarsFilms.sequelId]

7. Инструкции

Мы рассмотрели, как выполнять запросы к базе данных. Теперь разберемся с DML-инструкциями.

7.1. Вставка данных

Для того чтобы вставить данные, вызовем функцию, эквивалентную функции insert. Все они принимают замыкание:

StarWarsFilms.insert {it[name] = "The Last Jedi"it[sequelId] = 8it[director] = "Rian Johnson"}

В этом замыкании используются два объекта:

  • this(само замыкание) это экземпляр класса StarWarsFilms; именно этот объект позволяет нам обращаться к столбцам, которые являются свойствами, по неуточненному имени;

  • it(параметр замыкания) это InsertStatement; это структура, аналогичная коллекции ключ/значение, в которой есть слоты для вставки столбцов.

7.2. Извлечение автоинкрементного значения столбцов

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

В типичном сценарии есть только одно сгенерированное значение. Воспользуемся методом insertAndGetId:

val id = StarWarsFilms.insertAndGetId {it[name] = "The Last Jedi"it[sequelId] = 8it[director] = "Rian Johnson"}assertEquals(1, id.value)

Если у нас несколько сгенерированных значений, их можно считывать по имени:

val insert = StarWarsFilms.insert {it[name] = "The Force Awakens"it[sequelId] = 7it[director] = "J.J. Abrams"}assertEquals(2, insert[StarWarsFilms.id]?.value)

7.3. Обновление данных

Мы научились выполнять запросы и вставлять данные. Теперь перейдем к обновлению данных, которые содержатся в базе. Самый простой способ обновления похож на комбинацию методов select и insert:

StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {it[name] = "Episode VIII  The Last Jedi"}

В этом примере выражение where используется вместе с замыканием UpdateStatement. UpdateStatementи InsertStatement это потомки класса UpdateBuilder, поэтому в них используется один и тот же API и одна и та же логика. Родительский класс позволяет задать значение столбца с помощью квадратных скобок.

Если для обновления столбца нам нужно вычислять новое значение из старого, воспользуемся SqlExpressionBuilder:

StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {with(SqlExpressionBuilder) {it.update(StarWarsFilms.sequelId, StarWarsFilms.sequelId + 1)}}

Этот объект позволяет использовать инфиксный оператор (например, plus,minusи т. д.) для создания инструкции обновления.

7.4. Удаление данных

И наконец, мы можем удалить данные с помощью метода deleteWhere:

StarWarsFilms.deleteWhere ({ StarWarsFilms.sequelId eq 8 })

8. API DAO, облегченная технология ORM

Мы использовали Exposed для того, чтобы связать операции над объектами Kotlin с запросами и инструкциями SQL напрямую. Такие методы, как insert, update, select и т. д., немедленно отправляют строку SQL в базу данных.

Однако в Exposed есть высокоуровневый API DAO, который представляет собой простую технологию ORM. Давайте рассмотрим его подробнее.

8.1. Сущности

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

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

class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {companion object : EntityClass<Int, StarWarsFilm>(StarWarsFilms)var sequelId by StarWarsFilms.sequelIdvar name   by StarWarsFilms.namevar director by StarWarsFilms.director}

Давайте подробно проанализируем это определение.

Из первой строки видно, что сущность это класс, расширяющий Entity. У нее есть ID специфического типа, в нашем случае Int.

class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {

Затем определяем объект-компаньон.Объект-компаньон это класс сущности, то есть статические метаданные, которые определяют сущность и операции, которые мы можем выполнять над ней.

Объявляя объект-компаньон, мы соединяем сущность с именем StarWarsFilm(в единственном числе), которая представляет собой одну строку, с таблицей с именем StarWarsFilms (во множественном числе), которая представляет собой коллекцию всех строк.

companion object : EntityClass<Int, StarWarsFilm>(StarWarsFilms)

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

var sequelId by StarWarsFilms.sequelIdvar name   by StarWarsFilms.namevar director by StarWarsFilms.director

Обратите внимание, что раньше мы объявляли столбцы с помощью val, поскольку они являются неизменяемыми метаданными. Теперь же мы объявляем свойства сущности с помощью var, поскольку они являются изменяемыми слотами в строке базы данных.

8.2. Вставка данных

Чтобы вставить строку в таблицу, нам нужно просто создать экземпляр класса сущности с использованием статического фабричного метода newв транзакции:

val theLastJedi = StarWarsFilm.new {name = "The Last Jedi"sequelId = 8director = "Rian Johnson"}

Обратите внимание: с базой данных выполняются отложенные операции, которые запускаются только после выполнения warm cache. В Hibernate, например, теплый кэш привязан к сессии (session).

Операция выполняется автоматически. Например, когда мы в первый раз считываем сгенерированный идентификатор, Exposed выполняет инструкцию insert:

assertEquals(1, theLastJedi.id.value) //Reading the ID causes a flush

Сравните это поведение с методом insert, который мы рассматривали в разделе 7.1, в этом примере метод сразу же выполняет инструкцию в базе данных. Здесь же мы работаем на более высоком уровне абстракции.

8.3. Обновление и удаление объектов

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

theLastJedi.name = "Episode VIII  The Last Jedi"

Для удаления объекта вызовем метод delete этого объекта:

theLastJedi.delete()

Так же, как при использовании метода new, обновление и операции выполняются в отложенном режиме.

Обновить и удалить можно только ранее загруженный объект. В этом фреймворке нет API для обновления и удаления нескольких объектов, и нам придется использовать API более низкого уровня (см. раздел 7). Тем не менее в одной транзакции можно одновременно использовать и тот и другой API.

8.4. Запросы

API DAO позволяет выполнять три типа запросов.

Для загрузки всех объектов, для которых не заданы условия, будем использовать статический метод all:

val movies = StarWarsFilm.all()

Для загрузки одного объекта по ID воспользуемся методом findById:

val theLastJedi = StarWarsFilm.findById(1)

Если объекта с таким ID нет, findByIdвернет значение null.

В самом общем случае мы можем использовать метод findс выражением where:

val movies = StarWarsFilm.find { StarWarsFilms.sequelId eq 8 }

8.5. Связь многие к одному

В ORM соотнесение соединений со ссылками так же важно, как соединения в реляционных базах данных. Посмотрим, какие возможности предлагает Exposed.

Предположим, что нам нужно узнать пользовательский рейтинг каждого фильма. Сначала определим две дополнительные таблицы:

object Users: IntIdTable() {val name = varchar("name", 50)}object UserRatings: IntIdTable() {val value = long("value")val film = reference("film", StarWarsFilms)val user = reference("user", Users)}

Затем создадим соответствующие сущности. Опустим сущность User (это очевидно) и перейдем сразу к классу UserRating:

class UserRating(id: EntityID<Int>): IntEntity(id) {companion object : IntEntityClass<UserRating>(UserRatings)var value by UserRatings.valuevar film by StarWarsFilm referencedOn UserRatings.filmvar user by User     referencedOn UserRatings.user}

Обратите внимание: инфиксный метод referencedOn вызывает свойства, которые представляют собой связи.Модель следующая: объявляем переменную varчерез сущность (by) со ссылкой на соответствующий столбец (referencedOn).

Свойства, объявленные таким образом, ведут себя как обычные свойства, но их значением является связанный объект:

val someUser = User.new {name = "Some User"}val rating = UserRating.new {value = 9user = someUserfilm = theLastJedi}assertEquals(theLastJedi, rating.film)

8.6. Дополнительные связи

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

Чтобы установить дополнительные связи, нам нужно сначала разрешить столбцу таблицы принимать значение null:

val user = reference("user", Users).nullable()

Вместо метода referencedOnбудем использовать optionalReferencedOn:

var user by User optionalReferencedOn UserRatings.user

Таким образом, свойство user сможет принимать значение null.

8.7. Связь один ко многим

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

Для отображения рейтингов нужно добавить свойство к той стороне связи, где используется один объект. В нашем случае это сущность film:

class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {//Other properties elidedval ratings by UserRating referrersOn UserRatings.film}

Модель похожа на модель связи многие к одному, но сейчас мы используем метод referrersOn.Свойство, определенное таким образом, является итерируемым, поэтому мы можем выполнить обход с помощью цикла forEach:

theLastJedi.ratings.forEach { ... }

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

У значения свойства тоже нет API для изменения. Поэтому для добавления нового рейтинга нам нужно создать его, указав ссылку на фильм:

UserRating.new {value = 8user = someUserfilm = theLastJedi}

Теперь новый рейтинг появится в перечне рейтингов для этого фильма.

8.8. Связь многие ко многим

Иногда требуется установить связь многие ко многим. Предположим, что нам нужно связать класс StarWarsFilm с таблицей Actors:

object Actors: IntIdTable() {val firstname = varchar("firstname", 50)val lastname = varchar("lastname", 50)}class Actor(id: EntityID<Int>): IntEntity(id) {companion object : IntEntityClass<Actor>(Actors)var firstname by Actors.firstnamevar lastname by Actors.lastname}

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

object StarWarsFilmActors : Table() {val starWarsFilm = reference("starWarsFilm", StarWarsFilms).primaryKey(0)val actor = reference("actor", Actors).primaryKey(1)}

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

Теперь можно подключить таблицу связей к сущности StarWarsFilm:

class StarWarsFilm(id: EntityID<Int>) : IntEntity(id) {companion object : IntEntityClass<StarWarsFilm>(StarWarsFilms)//Other properties elidedvar actors by Actor via StarWarsFilmActors}

На момент написания статьи создать сущность с генерируемым идентификатором и использовать ее в связи многие ко многим в одной транзакции нельзя.

Нам приходится выполнять несколько транзакций:

//First, create the filmval film = transaction {   StarWarsFilm.new {    name = "The Last Jedi"    sequelId = 8    director = "Rian Johnson"r  }}//Then, create the actorval actor = transaction {  Actor.new {    firstname = "Daisy"    lastname = "Ridley"  }}//Finally, link the two togethertransaction {  film.actors = SizedCollection(listOf(actor))}

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

9. Заключение

В этой статье мы подробно рассмотрели фреймворк Kotlin Exposed. Дополнительную информацию и примеры можно найти в вики-учебнике по Exposed.

Варианты реализации рассмотренных примеров и фрагменты кода можно найти на GitHub.


Узнать подробнее о курсе "Kotlin Backend Developer".

Смотреть вебинар Объектно-ориентированное программирование в Kotlin.

Источник: habr.com
К списку статей
Опубликовано: 29.04.2021 20:16:38
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании otus

Программирование

Kotlin

Ооп

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru