Введение
Немного о сабже. 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-ов в сущностях, создатели предлагают вполне прямолинейный способ — осуществлять верификацию внутри аксессоров. Модифицируем пример выше для наглядности.
@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")
}
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