Привет, Хабр!
Уже много лет я занимаюсь Swift-разработкой, причем значительная часть моих open-source проектов выросла из желания сделать повседневную работу с кодом максимально простой, красивой и приятной. Когда-то так появился CodyFire, про который я рассказывал здесь в 2018 году, а позднее SwifQL, Bridges и целый набор других библиотек, которые я использую преимущественно в серверных Swift-приложениях.
За время работы на серверной стороне я привык к PostgreSQL, к его мощному SQL и к тому, что с данными можно делать всё необходимое на уровне самой базы. При этом благодаря своим библиотекам я уже мог описывать таблицы обычными Swift-моделями, строить SQL с помощью type-safe key paths и пользоваться декларативным синтаксисом с result builders, который мне особенно нравится в современном Swift.
В результате получился такой способ работы с БД, от которого мне совершенно не хотелось отказываться при переключении на iOS или macOS разработку. В идеале я хотел бы писать модели и запросы практически одинаково независимо от того, выполняется код на сервере или внутри приложения, и при этом иметь на клиенте сопоставимую мощность для работы с данными.
С самим Swift API больших проблем я не видел, потому что уже имел SwifQL (нынешний SwiftStream/SQL), наработки из Bridges и хорошее представление о том, как всё это должно выглядеть. А вот с выбором движка оказалось значительно интереснее.
В поисках PostgreSQL для мобильных приложений
Первое, что приходит в голову при желании получить на клиенте возможности PostgreSQL, это попробовать использовать сам PostgreSQL. Но здесь есть одна существенная проблема: его архитектура рассчитана на работу в качестве серверной СУБД со своим отдельным процессом и управлением соединениями, а мне нужен движок, который можно просто встроить внутрь приложения, как SQLite.
Я поискал существующие решения и проекты, пытавшиеся адаптировать PostgreSQL для мобильных устройств, но готового варианта, который меня бы устроил, не нашел. Чтобы получить полноценную embedded-базу на основе PostgreSQL с нормальным жизненным циклом внутри приложения, пришлось бы серьезно заниматься адаптацией самого движка, а это уже совершенно другой проект, которым я заниматься не собирался.
Среди существующих embedded-баз, конечно, есть SQLite и множество замечательных инструментов вокруг неё, включая GRDB, Core Data, SwiftData и другие способы организации локального хранения. С самим SQLite у меня нет никаких принципиальных проблем, тем более что он прекрасно справляется с огромным количеством задач. Но в данном случае мне хотелось исследовать другой вариант: получить гораздо более богатые возможности самого SQL-движка и при этом не отказываться от привычного PostgreSQL-подобного синтаксиса.
Именно во время этих поисков я познакомился с DuckDB, который оказался удивительно близок к тому, что мне было нужно.
Разработчики DuckDB создали полноценную реляционную СУБД, которая по умолчанию работает прямо внутри процесса приложения и не требует отдельного database server. При этом её SQL dialect основан на PostgreSQL, а возможности движка выходят далеко за рамки простых операций сохранения и чтения отдельных записей: сложные запросы, мощные агрегации, window functions, богатая система типов, транзакции, векторизованное выполнение запросов и эффективная работа с большими объемами данных.
Изначально DuckDB создавался прежде всего для аналитических нагрузок, и это важно понимать: он не является ни портом PostgreSQL, ни полной заменой SQLite для любых сценариев использования. Но для моих целей сочетание embedded-архитектуры, мощного SQL и знакомого синтаксиса оказалось настолько удачным, что захотелось немедленно попробовать перенести на него весь мой серверный подход к работе с базами данных.
У DuckDB уже есть официальный Swift client, но он предоставляет преимущественно базовые возможности взаимодействия с движком. Я же хотел значительно более богатый API, в котором использовались бы современные возможности Swift и который по красоте кода был бы максимально близок к тому, что я уже привык использовать в своих серверных проектах.
Поэтому я взял SwiftStream/SQL, перенес подходы из Bridges, добавил поддержку моделей, Codable, async/await, миграций, транзакций и других привычных вещей и начал постепенно собирать всё это поверх DuckDB.
В результате появилась библиотека Duck, которая недавно добралась до первой стабильной версии. Под капотом используется настоящий DuckDB 2.0.0, а поверх него я постарался реализовать тот самый удобный и красивый способ работы с базами данных на Swift, ради которого всё и затевалось.
Модели и работа с данными
Для начала посмотрим, как может выглядеть самая обычная модель:
import Duck import Foundation struct Todo: Table { @Column("id") var id: UUID @Column("name") var name: String @Column("created_at") var createdAt: Date @Column("done_at") var doneAt: Date? @Column("deleted_at") var deletedAt: Date? init() {} init( id: UUID = UUID(), name: String, createdAt: Date = Date(), doneAt: Date? = nil, deletedAt: Date? = nil ) { self.id = id self.name = name self.createdAt = createdAt self.doneAt = doneAt self.deletedAt = deletedAt } }
Для тех, кто уже знаком с моими SQL-библиотеками, здесь всё должно быть достаточно привычно. Мы описываем обычную Swift-структуру, соответствующую протоколу Table, а @Column связывает её свойства с колонками в базе данных и предоставляет типизированные ссылки для последующих запросов.
Поскольку Duck использует DatabaseIdentifier, нам даже не обязательно явно открывать connection для каждой простой операции. Например, определим идентификатор базы, которая будет храниться в Application Support:
private var appDatabaseDirectory: URL { FileManager.default .urls( for: .applicationSupportDirectory, in: .userDomainMask )[0] } extension DatabaseIdentifier { static var app: Self { Self( path: appDatabaseDirectory .appendingPathComponent("Todos.duckdb") .path ) } }
Сам DatabaseIdentifier не открывает нативное соединение при создании. Он описывает, с какой базой мы собираемся работать, а все необходимые ресурсы будут получены уже при выполнении операций.
Теперь после создания схемы можно выполнить обычный INSERT:
let todo = Todo(name: "Написать статью на Хабр") try await todo.insertNonReturning(on: .app)
Или сразу получить сохраненную модель:
let inserted = try await todo.insert(on: .app)
UPDATE работает с измененными полями модели:
var updated = inserted updated.doneAt = Date() updated = try await updated.update( on: \.$id, on: .app )
А получить все записи из таблицы можно вообще одной строкой:
let todos = try await Todo.all(on: .app)
Всё довольно просто, но именно этого я и добивался. Мне не нужно каждый раз вручную открывать connection, строить INSERT и потом декодировать результат, если речь идет об обычной работе с моделью.
При этом вся эта простота никак не ограничивает возможность писать полноценный SQL.
SQL, который мне хотелось иметь и на клиенте
Одна из моих любимых особенностей Duck заключается в том, что для построения запросов здесь используется непосредственно SwiftStream/SQL, поэтому API для работы с SQL на сервере и на клиенте получается практически одинаковым.
Например, нам нужно получить все неудаленные Todo, отсортированные по дате создания:
let todos = try await Todo .query .where(Todo.$deletedAt == nil) .orderBy(.asc(Todo.$createdAt)) .all(on: .app)
Duck сразу вернет массив [Todo], поскольку тип результата здесь известен из модели.
Но мне особенно нравится другой способ описания того же запроса:
let query = SQL { Select { \Todo.$id \Todo.$name \Todo.$createdAt \Todo.$doneAt \Todo.$deletedAt } From(Todo.table) Where { \Todo.$deletedAt == nil } OrderBy { OrderByItem.asc(\Todo.$createdAt) } } let todos = try await query.all( decoding: Todo.self, on: .app )
Это обычная декларация SQL, только вместо ручного составления строки мы используем Swift Result Builder. Сам запрос по своей структуре остается практически идентичен тому, который я написал бы для PostgreSQL, а Swift позволяет проверить типы колонок и корректно передать значения через prepared bindings.
Такой способ особенно приятен, когда запрос начинает усложняться. Например, можно воспользоваться обычным Swift control flow внутри Where:
let includeCompleted = false let query = SQL { Select { \Todo.$id \Todo.$name \Todo.$createdAt \Todo.$doneAt \Todo.$deletedAt } From(Todo.table) Where { \Todo.$deletedAt == nil if !includeCompleted { \Todo.$doneAt == nil } } OrderBy { OrderByItem.asc(\Todo.$createdAt) } }
Этот же DSL я могу использовать для сложных JOIN'ов, группировок, агрегатов и других SQL-конструкций.
Еще одна моя любимая возможность SwiftStream/SQL заключается в том, что запросы можно разбивать на переиспользуемые компоненты и собирать из них другие запросы, практически не усложняя синтаксис.
Например, вынесем фильтрацию Todo в отдельную структуру:
struct TodoFilter: SQLQuery { let includeCompleted: Bool var query: Query { Where { \Todo.$deletedAt == nil if !includeCompleted { \Todo.$doneAt == nil } } } }
Теперь используем этот фильтр в обычном SELECT:
let query = SQL { Select(\Todo.$id, \Todo.$name) From(Todo.table) TodoFilter(includeCompleted: false) OrderBy(.asc(\Todo.$createdAt)) }
И тот же фильтр в запросе для подсчета записей:
let query = SQL { Select(Fn.count(\Todo.$id) => "count") From(Todo.table) TodoFilter(includeCompleted: false) }
Точно так же можно вынести и целый запрос:
struct TodosQuery: SQLQuery { let includeCompleted: Bool var query: Query { Select { \Todo.$id \Todo.$name \Todo.$createdAt \Todo.$doneAt \Todo.$deletedAt } From(Todo.table) TodoFilter(includeCompleted: includeCompleted) OrderBy(.asc(\Todo.$createdAt)) } }
И теперь использовать его в разных местах приложения, передавая только необходимые параметры:
let todos = try await TodosQuery( includeCompleted: false ).all(decoding: Todo.self, on: .app)
Особенно приятно, что композиция не требует никакой дополнительной инфраструктуры: целые запросы, их отдельные части и обычные SQL-выражения можно описывать как Swift-структуры, а затем свободно комбинировать между собой.
Так же вовсе не обязательно каждый раз выбирать все поля таблицы только ради последующего декодирования в Codable.
Допустим экрану нужны только id и name. Создадим небольшую модель:
struct TodoSummary: Decodable { let id: UUID let name: String }
И выберем ровно нужные колонки:
let summaries = try await Todo .select(\.$id, \.$name) .where(\Todo.$deletedAt == nil) .all( decoding: TodoSummary.self, on: .app )
Тот же принцип работает для совершенно произвольного SQL result. Например, результат агрегирующего запроса можно декодировать в отдельную структуру:
struct TodoCount: Decodable { let count: Int64 } let query = SQL { Select( Fn.cast(Fn.count(\Todo.$id), .bigint) => "count" ) From(Todo.table) Where(\Todo.$deletedAt == nil) } let count = try await query.first( decoding: TodoCount.self, on: .app )?.count ?? 0
Таким образом Codable здесь не пытается диктовать, какой запрос вы должны написать. Мы описываем нужный SQL, выбираем интересующие нас поля и декодируем их в подходящую Swift-модель, а если нужен полный контроль над результатом, всегда остается возможность работать непосредственно с QueryResult и ResultRow.
Миграции и транзакции
Я уже показывал, как можно работать с Todo, но для этого в базе, разумеется, должна существовать соответствующая таблица.
Здесь мне тоже хотелось сохранить подход, к которому я привык на сервере, поэтому в Duck используется декларативная система миграций:
extension Todo { struct Create: Migration { let name = "Todo.Create" var up: MigrationPlan { CreateTable("todo") { NewColumn("id", .uuid).primaryKey() NewColumn("name", .text).notNull() NewColumn("created_at", .timestamptz).notNull() NewColumn("done_at", .timestamptz) NewColumn("deleted_at", .timestamptz) } } var down: MigrationPlan { DropTableBuilder<Todo>() } } }
При первоначальной подготовке приложения создаем директорию для базы и выполняем миграции:
try FileManager.default.createDirectory( at: appDatabaseDirectory, withIntermediateDirectories: true ) _ = try await DatabaseIdentifier.app .migrator([Todo.Create()]) .migrate()
Duck сохраняет историю выполненных миграций и применяет только те, которые еще не были выполнены. Поддерживаются up и down, группировка по batches и выполнение дополнительной работы внутри миграции.
Транзакции тоже получились вполне привычными:
let connection = try await DatabaseIdentifier.app.connect() try await connection.transaction { transaction in let first = Todo(name: "Первый") let second = Todo(name: "Второй") try await first.insertNonReturning(on: transaction) try await second.insertNonReturning(on: transaction) }
При успешном завершении closure выполняется COMMIT, а ошибка или отмена task приведет к ROLLBACK.
Есть и возможность явно открыть транзакцию через connection.transaction() и самостоятельно управлять её завершением. Такой вариант полезен, когда операции невозможно удобно объединить в одну closure, но требует явно вызвать commit() или rollback().
Еще немного возможностей самого DuckDB
Для обычных операций с моделями всех предыдущих примеров вполне достаточно, но наиболее интересные возможности движка проявляются при работе с большими объемами данных и более сложными типами.
Например, Duck поддерживает native LIST, поэтому массив Swift-значений необязательно предварительно сериализовывать в JSON.
Опишем пользователя с набором тегов:
struct User: Table { static let tableName = "users" @Column("id") var id: Int64 @Column("tags") var tags: [String] init() {} init(id: Int64, tags: [String]) { self.id = id self.tags = tags } }
В схеме поле tags можно создать как настоящий DuckDB LIST:
NewColumn("tags", .list(.varchar)).notNull()
Теперь с ним работают обычные model APIs:
let user = User( id: 1, tags: ["swift", "duckdb"] ) let inserted = try await user.insert(on: .app) let loaded = try await User .query .where(\User.$id == inserted.id) .first(on: .app)
В первой версии поддерживаются одномерные LIST и один непосредственный уровень keyed STRUCT. Более сложные рекурсивные комбинации пока оставлены на будущее.
Массовая загрузка данных
DuckDB умеет эффективно загружать большие наборы строк через Appender, и мне хотелось сделать эту возможность доступной как на низком уровне, так и через обычные Swift-модели.
Для коллекции моделей всё довольно просто:
let todos = [ Todo(name: "One"), Todo(name: "Two"), Todo(name: "Three") ] try await todos.batchInsert(on: .app)
Но можно работать и непосредственно с Appender:
let todoTable = DB.Table(Todo.tableName) let connection = try await DatabaseIdentifier.app.connect() try await connection.appender( to: todoTable, columns: ["id", "name", "created_at"] ) { appender in for name in importedTodoNames { try await appender.append( UUID(), name, Date() ) } }
Для атомарной загрузки всех строк можно использовать transaction.appender, тогда финальный COMMIT будет принадлежать внешней транзакции.
Точно так же доступны reusable prepared statements, позволяющие подготовить SQL один раз и многократно выполнять его с разными параметрами.
Реактивные SQL-запросы для SwiftUI и UIKit
Еще одна возможность, которую мне очень хотелось иметь на клиенте, это наблюдение непосредственно за результатом SQL-запроса.
Например, в SwiftUI у нас есть обычный список Todo, и мы хотим, чтобы он автоматически обновлялся при изменениях в базе:
@State private var todos: [Todo] = []
С Duck для этого достаточно создать observation прямо из запроса:
.task { do { for try await result in try Todo .select .where(Todo.$deletedAt == nil) .orderBy(.asc(Todo.$createdAt)) .observe(on: .app) { todos = try result.all(decoding: Todo.self) } } catch is CancellationError { // SwiftUI отменил task } catch { print("Duck error:", error) } }
Всё это работает через обычный AsyncSequence<QueryResult>, поэтому никакой зависимости от SwiftUI внутри самого Duck нет. Такой же sequence можно использовать из UIKit, консольного приложения или любого другого Swift-кода.
При отмене Task отмена передается внутрь observation, его worker завершает работу и освобождает подписку.
При этом я специально не делал из observation поток всех событий базы данных. Его задача состоит в том, чтобы предоставлять последнее актуальное состояние результата запроса, поэтому последовательные invalidations могут объединяться, а одинаковые QueryResult вообще не публикуются повторно.
Есть и важная граница: observation реагирует на изменения, которые выполняются через тот же логический DatabaseIdentifier внутри процесса. Это не файловый мониторинг и не механизм синхронизации с другими процессами.
Эффективность работы observation
После релиза мне задали хороший вопрос: насколько точно Duck определяет, что изменение данных действительно влияет на наблюдаемый запрос?
Сейчас в Duck 1.0 invalidation работает довольно консервативно, поэтому даже изменение совершенно другой таблицы может привести к повторному выполнению запроса. Одинаковые результаты Duck повторно не отправляет, но лишнее выполнение SQL всё равно происходит.
Поскольку я сам планирую использовать Duck в приложении с миллионами строк и огромным количеством операций записи, для версии 1.1 я уже запланировал более точное отслеживание изменений, включая predicate-aware оптимизации, а также полноценные бенчмарки на специально сгенерированных миллионных таблицах под высокой нагрузкой.
Concurrency и Connection Pooling
При работе с несколькими параллельными задачами можно использовать connection pool:
let pool = try await DatabaseIdentifier.app .pool(maximumConnections: 4)
И выполнять запросы на полученном соединении:
let todos = try await pool.connection { connection in try await Todo .query .where(\Todo.$deletedAt == nil) .all(on: connection) }
Connection'ы создаются лениво до указанного лимита, а каждый отдельный Connection является actor'ом и владеет собственным native DuckDB connection.
Таким образом работа внутри одного connection сериализуется, но независимые соединения не обязаны проходить через одну глобальную очередь выполнения.
Транзакции также можно выполнять непосредственно через pool:
try await pool.transaction { transaction in let todo = Todo(name: "Inserted through pool") try await todo.insertNonReturning(on: transaction) }
Мне было важно, чтобы Swift Concurrency здесь не просто присутствовал ради модного API, а действительно корректно отражал ownership, lifetime и ограничения нативного database engine.
Заменит ли Duck SQLite?
Думаю, что нет, и такой задачи я перед собой не ставлю.
SQLite является отличным embedded-движком, а GRDB предоставляет замечательный Swift API для работы с ним. Если приложение занимается преимущественно обычным CRUD и прекрасно решает свои задачи на SQLite, я не вижу причин срочно отказываться от этого стека.
Duck интересен мне прежде всего тогда, когда хочется использовать внутри приложения более мощный SQL engine с возможностями, к которым я привык в серверной разработке. При этом сам DuckDB ориентирован на аналитические запросы и достаточно крупные операции с данными, поэтому для приложения с большим количеством мелких транзакций и постоянными точечными обновлениями производительность обязательно нужно проверять на реальной нагрузке, а не автоматически переносить ожидания от PostgreSQL или SQLite.
Есть и вопрос размера приложения. Я специально собрал два минимальных SwiftUI проекта: пустой получился около 2 MB, а аналогичный проект с подключенным Duck около 42 MB.
Речь идет о размерах локальных сборок, реальный размер загрузки из App Store с учетом сжатия и thinning я отдельно пока не измерял.
Лично меня такие цифры совершенно не пугают, особенно когда современные приложения вполне спокойно могут занимать по 300–700 MB. Но если размер критичен и вам достаточно возможностей SQLite, дополнительный вес DuckDB может быть совершенно неоправданным.
Как попробовать
Duck распространяется через Swift Package Manager, поэтому достаточно добавить зависимость:
.package( url: "https://github.com/SwiftStream/Duck", from: "1.0.0" )
И импортировать библиотеку:
import Duck
SwiftStream/SQL уже реэкспортируется самим Duck, поэтому дополнительно импортировать SQL в обычном приложении не требуется.
Сейчас поддерживается iOS15+ и macOS12+. Сам по себе движок работает и на множестве других платформ, но я их пока не проверял, хотя по-моему всё должно без проблем скомпилироваться и работать.
На Apple платформах используется готовый XCFramework DuckDB, поэтому при обычной сборке приложения не нужно каждый раз компилировать весь C++ движок, а это экономит 2-3 минуты на холодной сборке. Сейчас на холодную компиляция SwiftUI аппки занимает всего 10 секунд.
В репозитории также есть готовые SwiftUI и UIKit примеры, где можно посмотреть работу моделей, миграций, запросов и реактивного обновления интерфейса.
Заключение
Duck пока только в начале своего развития, хотя уже в первой стабильной версии получилось реализовать довольно много того, что мне хотелось получить от современной базы данных на Swift.
Сам DuckDB предоставляет огромное количество интересных возможностей, и помимо производительности reactive observation в планах как минимум остаются более сложные native types, проверка работы на других платформах и другие вещи, до которых со временем доберусь.
Но больше всего я доволен именно тем, что удалось перенести свой привычный подход к моделям и SQL из серверной разработки на клиент, практически не меняя способ написания кода.
В одном месте приложения можно спокойно написать:
let todos = try await Todo.all(on: .app)
а в другом использовать тот же SwiftStream/SQL для сложных запросов с несколькими таблицами, условиями и агрегациями, оставаясь в пределах одного и того же API.
В общем, надеюсь, что Duck будет полезен и другим Swift-разработчикам, которые любят SQL, хотят попробовать возможности DuckDB внутри своих приложений или просто ищут более интересный способ работы с локальными данными.
Буду рад вашим отзывам и идеям. Особенно интересно услышать тех, кто уже использует DuckDB или работает с большими локальными базами на iOS и macOS.
Это проект с открытым исходным кодом и распространяется под лицензией MIT.
Ознакомиться с исходниками и поставить звезду можно в репозитории SwiftStream/Duck
Поддержите проект поделившись ссылкой на эту публикацию с друзьями и коллегами
Будем очень рады вам в нашем Discord и Телеграм
Михаил Исаев
Основатель Swift Stream
