Dive into BerkleyDB JE. Introduction to DPL API

в 14:01, , рубрики: berkley db, kotlin, oracle, tutorial

Введение

Немного о сабже. BerkleyDB — высокопроизводительная встраиваемая СУБД, поставляемая в виде библиотеки для различных языков программирования. Это решение предполагает хранение пар ключ-значение, также поддерживается возможность ставить одному ключу в соответствие несколько значений. BerkleyDB поддерживает работу в многопоточной среде, репликацию, и многое другое. Внимание данной статьи будет обращено в первую очередь в сторону использования библиотеки, предоставленной Sleepycat Software в бородатых 90х. В этой статье будут рассмотрены основные аспекты работы с DPL (Direct Persistence Layer) API.

Примечание: все примеры в данной статье будут приведены на языке Kotlin.

Описание сущностей

Для начала, ознакомимся со способом описания сущностей. К счастью, он весьма схож на JPA. Все сущности отражаются в виде классов с аннотациями @Persistent и @Entity, каждый из которых позволяет указать в явном виде версию описываемой сущности. В рамках этой статьи, мы будем пользоваться только аннотацией @Entity, в последующих — будет пролит свет и на @Persitent

Пример простой сущности

@Entity(version = SampleDBO.schema)
class SampleDBO private constructor() {

    companion object {
        const val schema = 1
    }

    @PrimaryKey
    lateinit var id: String
        private set

    @SecondaryKey(relate = Relationship.MANY_TO_ONE)
    lateinit var name: String
        private set

    constructor(id: String, name: String): this() {
        this.id = id
        this.name = name
    }
}

Примечание: для ключа с аннотацией @PrimaryKey типа java.lang.Long можно также указать параметр sequence, который создаст отдельную последовательность для генерации идентификаторов ваших сущностей. Увы, в Котлине не работает.

Отметить стоит отдельно, что: во-первых, во всех сущностях требуется оставить приватный конструктор по-умолчанию для корректной работы библиотеки, во-вторых — аннотация @SecondaryKey должна присутствовать в каждом поле сущности, по которому мы в дальнейшем хотим осуществлять индексирование. В данном случае, это поле name.

Использование constraints

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

Пример с constraint

@Entity(version = SampleDBO.schema)
class SampleDBO private constructor() {

    companion object {
        const val schema = 1
    }

    @PrimaryKey
    lateinit var id: String
        private set

    @SecondaryKey(relate = Relationship.MANY_TO_ONE)
    var name: String? = null
        private set(value) {
            if(value == null) {
                throw IllegalArgumentException("Illegal name passed: ${value}. Non-null constraint failed")
            }
            
            if(value.length < 4 || value.length > 16) {
                throw IllegalArgumentException("Illegal name passed: ${value}. Expected length in 4..16, but was: ${value.length}")
            }
        }

    constructor(id: String, name: String): this() {
        this.id = id
        this.name = name
    }
}

Отношения между сущностями

BerkleyDB JE поддерживает все типы отношений:

  • 1:1 Relationship.ONE_TO_ONE
  • 1:N Relationship.ONE_TO_MANY
  • N:1 Relationship.MANY_TO_ONE
  • N:M Relationship.MANY_TO_MANY

Для описания отношения между сущностями используется все тот же @SecondaryKey с тремя дополнительными параметрами:

  • relatedEntity — класс сущности, отношение к которой описывается
  • onRelatedEntityDelete — поведение, при удалении сущности (прерывание транзакции, обнуление ссылок, каскадное удаление)
  • name — поле сущности, которое выступает в роли foreign key

Отношения между сущностями на примере примитивного магазина

@Entity(version = CustomerDBO.schema)
class CustomerDBO private constructor() {

    companion object {

        const val schema = 1
    }

    @PrimaryKey()
    var id: String? = null
        private set

    @SecondaryKey(relate = Relationship.ONE_TO_ONE)
    lateinit var email: String
        private set

    var balance: Long = 0L

    constructor(email: String, balance: Long): this() {
        this.email = email
        this.balance = balance
    }

    constructor(id: String, email: String, balance: Long): this(email, balance) {
        this.id = id
    }

    override fun toString(): String {
        return "CustomerDBO(id=$id, email=$email, balance=$balance)"
    }
}

@Entity(version = ProductDBO.schema)
class ProductDBO {

    companion object {

        const val schema = 1
    }

    @PrimaryKey()
    var id: String? = null
        private set

    @SecondaryKey(relate = Relationship.MANY_TO_ONE)
    lateinit var name: String
        private set

    var price: Long = 0L

    var amount: Long = 0L

    private constructor(): super()

    constructor(name: String, price: Long, amount: Long): this() {
        this.name = name
        this.price = price
        this.amount = amount
    }

    constructor(id: String, name: String, price: Long, amount: Long): this(name, price, amount) {
        this.id = id
    }

    override fun toString(): String {
        return "ProductDBO(id=$id, name=$name, price=$price, amount=$amount)"
    }
}

@Entity(version = ProductChunkDBO.schema)
class ProductChunkDBO {

    companion object {

        const val schema = 1
    }

    @PrimaryKey()
    var id: String? = null
        private set

    @SecondaryKey(relate = Relationship.MANY_TO_ONE, relatedEntity = OrderDBO::class, onRelatedEntityDelete = DeleteAction.CASCADE)
    var orderId: String? = null
        private set

    @SecondaryKey(relate = Relationship.MANY_TO_ONE, relatedEntity = ProductDBO::class, onRelatedEntityDelete = DeleteAction.CASCADE)
    var itemId: String? = null
        private set

    var amount: Long = 0L

    private constructor()

    constructor(orderId: String, itemId: String, amount: Long): this() {
        this.orderId = orderId
        this.itemId = itemId
        this.amount = amount
    }

    constructor(id: String, orderId: String, itemId: String, amount: Long): this(orderId, itemId, amount) {
        this.id = id
    }

    override fun toString(): String {
        return "ProductChunkDBO(id=$id, orderId=$orderId, itemId=$itemId, amount=$amount)"
    }
}

@Entity(version = OrderDBO.schema)
class OrderDBO {

    companion object {

        const val schema = 1
    }

    @PrimaryKey()
    var id: String? = null
        private set

    @SecondaryKey(relate = Relationship.MANY_TO_ONE, relatedEntity = CustomerDBO::class, onRelatedEntityDelete = DeleteAction.CASCADE)
    var customerId: String? = null
        private set

    @SecondaryKey(relate = Relationship.ONE_TO_MANY, relatedEntity = ProductChunkDBO::class, onRelatedEntityDelete = DeleteAction.NULLIFY)
    var itemChunkIds: MutableSet<String> = HashSet()
        private set

    var isExecuted: Boolean = false
        private set

    private constructor()

    constructor(customerId: String, itemChunkIds: List<String> = emptyList()): this() {
        this.customerId = customerId
        this.itemChunkIds.addAll(itemChunkIds)
    }

    constructor(id: String, customerId: String, itemChunkIds: List<String> = emptyList()): this(customerId, itemChunkIds) {
        this.id = id
    }

    fun setExecuted() {
        this.isExecuted = true
    }

    override fun toString(): String {
        return "OrderDBO(id=$id, customerId=$customerId, itemChunkIds=$itemChunkIds, isExecuted=$isExecuted)"
    }
}

Конфигурация

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

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

Environment

Настройка работы с окружением предполагает определение стандартных параметров. В самом простом варианте выйдет что-то подобное:

    val environment by lazy {
        Environment(dir, EnvironmentConfig().apply {
            transactional = true
            allowCreate = true
            nodeName = "SampleNode_1"
            cacheSize = Runtime.getRuntime().maxMemory() / 8
            offHeapCacheSize = dir.freeSpace / 8
        })
    }

  • transactional — устанавливаем как true, если хотим использовать транзакции
  • allowCreate — устанавливаем как true, если окружение должно быть создано, если его не будет обнаружено в указанной директории
  • nodeName — устанавливаем название для конфигурируемого Environment; очень приятная опция, в случае, если в приложении будет использоваться несколько Environment, и хочется не прострелить себе ногу иметь читаемые логи
  • cacheSize — количество памяти, которое будет отводиться под in-memory кэш
  • offHeapCacheSize — количество памяти, которое будет отводиться под дисковый кэш

EntityStore

В случае, если в приложении используется DPL API, основным классом для работы с базой данных будет EntityStore. Стандартная конфигурация выглядит следующим образом:

   val store by lazy {
        EntityStore(environment, name, StoreConfig().apply {
            transactional = true
            allowCreate = true
        })
    }

Индексы, доступ к данным

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

SELECT * FROM customers ORDER BY email;

В BerkleyDB JE этот запрос можно осуществить следующим образом: первое, что потребуется, это, собственно, создать два индекса. Первый — основной, он должен соответствовать @PrimaryKey нашей сущности. Второй — вторичный, соответствующий полю, упорядочивание по которому производится (примечание — поле должно, как выше было сказано, быть аннотировано как @SecondaryKey).

    val primaryIndex: PrimaryIndex<String, CustomerDBO> by lazy {
        entityStore.getPrimaryIndex(String::class.java, CustomerDBO::class.java)
    }

    val emailIndex: SecondaryIndex<String, String, CustomerDBO> by lazy {
        entityStore.getSecondaryIndex(primaryIndex, String::class.java, "email")
    }

Получение выборки данных осуществляется привычным способом — используя интерфейс курсора (в нашем случае — EntityCursor)

    fun read(): List<CustomerDBO> = emailIndex.entities().use { cursor ->
        mutableListOf<CustomerDBO>().apply { 
            var currentPosition = 0
            val count = cursor.count()
            add(cursor.first() ?: return@apply)
            currentPosition++
            while(currentPosition < count) {
                add(cursor.next() ?: return@apply)
                currentPosition++
            }
        }
    }

Relations & Conditions

Частой задачей является получение сущностей, используя связь между их таблицами. Рассмотрим этот вопрос на примере следующего SQL запроса:

SELECT * FROM orders WHERE customer_id = ?;

И его представление в рамках Berkley:

    fun readByCustomerId(customerId: String): List<OrderDBO> = 
    customerIdIndex.subIndex(customerId).entities().use { cursor ->
        mutableListOf<OrderDBO>().apply {
            var currentPosition = 0
            val count = cursor.count()
            add(cursor.first() ?: return@apply)
            currentPosition++
            while(currentPosition < count) {
                add(cursor.next() ?: return@apply)
                currentPosition++
            }
        }
    }

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

Модифицированный покупатель

@Entity(version = CustomerDBO.schema)
class CustomerDBO private constructor() {

    companion object {

        const val schema = 1
    }

    @PrimaryKey()
    var id: String? = null
        private set

    @SecondaryKey(relate = Relationship.ONE_TO_ONE)
    lateinit var email: String
        private set

    @SecondaryKey(relate = Relationship.MANY_TO_ONE)
    lateinit var country: String
        private set

    @SecondaryKey(relate = Relationship.MANY_TO_ONE)
    lateinit var city: String
        private set

    var balance: Long = 0L

    constructor(email: String, country: String, city: String, balance: Long): this() {
        this.email = email
        this.country = country
        this.city = city
        this.balance = balance
    }

    constructor(id: String, email: String, country: String, city: String, balance: Long): this(email, country, city, balance) {
        this.id = id
    }
}

Новые индексы

    val countryIndex: SecondaryIndex<String, String, CustomerDBO> by lazy {
        entityStore.getSecondaryIndex(primaryIndex, String::class.java, "country")
    }

    val cityIndex: SecondaryIndex<String, String, CustomerDBO> by lazy {
        entityStore.getSecondaryIndex(primaryIndex, String::class.java, "city")
    }

Пример запроса с двумя условиями (SQL)

SELECT * FROM customers WHERE country = ? AND city = ?;

Пример запроса с двумя условиями

    fun readByCountryAndCity(country: String, city: String): List<CustomerDBO> {
        val join = EntityJoin<String, CustomerDBO>(primaryIndex)
        join.addCondition(countryIndex, country)
        join.addCondition(cityIndex, city)
        return join.entities().use { cursor -> 
            mutableListOf<CustomerDBO>().apply {
                var currentPosition = 0
                val count = cursor.count()
                add(cursor.first() ?: return@apply)
                currentPosition++
                while(currentPosition < count) {
                    add(cursor.next() ?: return@apply)
                    currentPosition++
                }
            }
        }
    }

Как видно из примеров — довольно муторный синтаксис, но жить вполне можно.

Range queries

С данным типом запросов все прозрачно, у индексов есть перегрузка функции fun <E> entities(fromKey: K, fromInclusive: Boolean, toKey: K, toInclusive: Boolean):
EntityCursor<E>
которая предоставляет возможность использовать курсор, итерирующийся по нужной выборке данных. Этот метод вполне быстро работает, так как используются индексы, сравнительно удобен, и, на мой взгляд, не требует отдельных комментариев.

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

Это первая статья из планируемого цикла по BerkleyDB. Основаня ее цель — познакомить читателя с основами работы с Java Edition библиотекой, рассмотреть основные возможности, которые необходимы для рутинных действий. В последующих статьях будут покрыты более интересные детали работы с этой библиотекой, если статья окажется кому-то интересной.

Поскольку опыта работы с Berkley у меня совсем немного — буду признателен за критику и поправки в комментариях, если я где-то допустил огрехи.

Автор: KomarovI

Источник

Поделиться

* - обязательные к заполнению поля