В моих предыдущих статьях описывалось, как я реализовал шаблон MVC в серверном приложении Swift HTTP.

  1. Swift на стороне сервера - MVC
  2. Swift на стороне сервера - MVC (модульные тесты)
  3. Swift на стороне сервера - файлы конфигурации

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

Все данные, которые мы собираем в нашем приложении, должны храниться в каком-то месте. Самый распространенный способ - использовать сервер базы данных. В зависимости от приложения (проблема, которую мы должны решить) мы можем выбрать базу данных SQL или NoSQL. Здесь я остановлюсь на (реляционных) базах данных SQL. Наиболее известные базы данных SQL: Microsoft SQL Server, Oracle, MySQL, MariaDB, PostgreSQL, Firebird.

В нашем приложении мы можем запускать обычные SQL-запросы для чтения / записи на SQL-сервер. Однако в нашем случае это не оптимальное решение (и в большинстве случаев не оптимальное). У нас будут объекты, которые можно просто сопоставить с таблицами базы данных. Когда мы будем использовать SQL, большая часть кода будет очень похожа, и мы скопируем и вставим большую часть из них. Вместо этого мы можем использовать специальные библиотеки: фреймворки реляционного сопоставления объектов, которые автоматически сопоставляют наши объекты с таблицами базы данных.

Возможности, реализованные в современном ORM shoud:

  • Сопоставление между объектами и таблицами базы данных с поддержкой CRUD - это очевидно, это самая важная особенность каждого ORM, благодаря чему мы можем читать / добавлять / обновлять / удалять данные объекта с / на сервер базы данных.
  • Запросы данных - инфраструктура ORM должна обеспечивать решение для создания запросов с использованием синтаксиса определенного языка (мы не должны использовать простой синтаксис SQL в нашем исходном коде). В C # мы можем использовать Linq для подготовки запросов, под капотом которых запросы переводятся Entity Framework в соответствующие запросы SQL.
  • Отношения - определение отношений между объектами, благодаря этой базе данных можно создавать внешние ключи (или даже разделять таблицы, когда у нас есть отношения "много ко многим"). Фреймворк ORM может использовать эту информацию также для таких функций, как отложенная загрузка или активная загрузка.
  • Кеш на уровне сеанса - это функция, которая сильно влияет на производительность. В течение одного сеанса (во многих реализациях сеанс создается для одного HTTP-запроса) мы можем кэшировать объекты, полученные из базы данных. Благодаря этому ORM уменьшит количество петлевых запросов к серверу базы данных.
  • Выполнение пользовательских запросов - иногда возникает необходимость в выполнении пользовательских запросов SQL. Например, когда мы хотели бы запускать пакетные запросы, такие как изменение состояния для значительного количества строк. ORM должен предоставлять методы для этих целей.
  • Поддержка транзакций - платформа должна предоставлять API для использования транзакций базы данных. Это очень полезно, когда мы хотим сохранить несколько разных объектов во время одного HTTP-запроса, и база данных должна быть согласованной.
  • Поддерживается множество баз данных - ORM должен поддерживать наиболее важные базы данных. Благодаря этому у наших клиентов есть возможность выбрать сервер базы данных, который им известен / нравится.
  • Миграции - ORM должен предоставлять нам механизм, который мы можем использовать для подготовки миграции между версиями модели.

В мире .NET у нас есть как минимум два очень мощных ORM-фреймворка: NHibernate и Entity Framework. Оба соответствуют всем вышеперечисленным требованиям. К сожалению, в мире Swift все не так красочно.

Я нашел фреймворки:

В StORM и Fluent мне не понравилось, как реализована ORM. Мы должны унаследовать классы нашей модели от специальных классов, предоставляемых ORM. В StORM это, например, PostgresStORM или SQLiteStORM и т. Д. (В зависимости от поставщика базы данных). В Fluent мы должны унаследовать от Model. Далее доступ к базе данных осуществляется нашим классом. Сохранение данных в обоих фреймворках очень похоже.

Гроза:

let obj         = User()
obj.firstname   = "Joe"
obj.lastname    = "Smith"
try obj.save {
    id in obj.id = id as! Int
}

Беглый:

let dog = Pet(name: "Spud", age: 5)
try dog.save()
print(dog.id) // the newly saved pet's id

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

Гроза:

let obj = User()
try obj.find([("firstname", "Joe")])
print("Find Record:  \(obj.id), \(obj.firstname), \(obj.lastname)")

Беглый:

let dogs = try Pet.makeQuery().filter("age", .greaterThan, 2).all()
print(dogs) // all dogs older than 2

Как видите, доступ к базе данных осуществляется классами моделей. В StORM, когда я хочу сначала получить коллекцию некоторых сущностей, мне нужно создать экземпляр объекта. Таким образом, когда я хочу сначала получить список users, я должен создать объект User(). Для меня это действительно странное решение, которое, вероятно, было создано из-за ограничений предыдущих версий Swift.

Fluent кажется более мощным, чем StORM. Он поддерживает (простые) миграции, транзакции, отношения. Все эти функции отсутствуют в StORM.

Perfect CRUD реализован иначе. Мы разделили логику между функциями ORM и нашей моделью. CRUD предоставляет класс Database, который обеспечивает уровень абстракции между нашей моделью и базой данных (мы можем сравнить его с DBContext из Entity Framework). Мы можем использовать этот класс для создания таблиц в базе данных (дополнительно у нас есть возможность добавлять / удалять отдельные столбцы в таблице). Также мы можем использовать класс Database для получения ссылок на класс Table (который немного похож на DBSet из Entity Framework). Мы можем использовать этот класс, когда, например, хотим создавать запросы.

Ниже приведен простой пример того, что нам нужно сделать, если мы хотим использовать Perfect CRUD.

struct PhoneNumber: Codable {
	let id: UUID
	let personId: UUID
	let code: Int
	let number: String
}
struct Person: Codable {
    let id: UUID
    let name: String
    let phoneNumbers: [PhoneNumber]?
}
let sqlite = try SQLiteDatabaseConfiguration(dbName)
let db = Database(configuration: sqlite)
try db.create(Person.self, policy: .reconcileTable)
let personTable = db.table(Person.self)
// Create.
let john = Person(id: 1, name: "John Doe")
let victoria = Person(id: 2, name: "Victoria Doe")
try personTable.insert([john, victoria])
// Queries.
let query = try personTable
    .order(by: \.name)
    .join(\.phoneNumbers, on: \.id, equals: \.personId)
    .order(descending: \.code)
    .where(\Person.name == "John" && \PhoneNumber.code == 12)
    .select()

Более сложные примеры вы можете найти на странице Perfect CRUD на GitHub.

В дальнейшем содержании статьи я расскажу, как я включил в свой проект фреймворк Perfect CRUD. Я могу использовать эту структуру, потому что мое приложение не создано для производственных целей (пока). Я надеюсь, что этот фреймворк будет готов к производству как можно скорее, потому что его концепции и синтаксис мне больше всего подходят.

Зависимости

Мы должны добавить Perfect CRUD (я буду использовать базу данных SQLite) в необходимые пакеты и в соответствующие зависимости.

Когда мы устанавливаем все на свои места, мы должны выполнить следующие команды:

$ swift build
$ swift package generate-xcodeproj

Благодаря этому загружаются соответствующие библиотеки и создается новый файл xcodeproj.

DatabaseContext

EntityFramework предоставляет класс DbContext, который является основной конечной точкой для доступа к базе данных. Для каждой таблицы мы должны добавить соответствующиеDbSet. Также мы можем настроить в DbContext модель базы данных (например, имена таблиц, индексы, внешние ключи и т. Д.). Я хотел бы ввести аналогичный класс в свое приложение. И затем я хотел бы внедрить этот класс в свои репозитории.

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

У меня здесь две простые функции:

  • executeMigrations отвечает за создание базы данных со всеми таблицами (также, если мы добавим свойство net к классу, новый столбец будет добавлен в базу данных)
  • set<T: Codable> - это общая функция, которая может возвращать нам объект, который мы можем использовать для доступа к конкретной таблице.

Моя реализация DatabaseContextProtocol выглядит так:

Как видите, здесь нет никакой магии. Может стоит обратить внимание на функцию validateConnection(). Я вызываю эту функцию перед любым запросом к базе данных. Если соединение с базой данных потеряно, мы должны снова подключиться к базе данных (мы должны использовать блокировки, если мы хотим иметь потокобезопасность этого класса).

Я вводю объект DatabaseContext, который отвечает за поддержание реального соединения с базой данных. У нас должен быть только один экземпляр этого класса в приложении (поэтому этот объект зарегистрирован в контейнере DI как синглтон). DatabaseContext объект может быть создан (разрешен) несколько раз.

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

Внедрение зависимости

Теперь нам нужно добавить два наших новых класса в контейнер внедрения зависимостей. Я делаю это в своем DependencyContainer расширении. Помимо этого, мы должны внедрить DatanaseContext в наши репозитории.

Миграции

Теперь в нашем main.swift классе мы можем выполнять миграции. Мы можем сделать это, выполнив следующий код.

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

[INFO] Starting HTTP server www.example.com on :::8181
[czw., 22 lut 2018 18:01:29 +0100] [QUERY] CREATE TABLE IF NOT EXISTS "Task" (
 id INT PRIMARY KEY,
 name TEXT NOT NULL,
 isFinished INT NOT NULL
)
[czw., 22 lut 2018 18:01:29 +0100] [QUERY] CREATE TABLE IF NOT EXISTS "User" (
 id INT PRIMARY KEY,
 name TEXT NOT NULL,
 email TEXT NOT NULL,
 isLocked INT NOT NULL
)

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

[INFO] Starting HTTP server www.example.com on :::8181
[czw., 22 lut 2018 18:02:51 +0100] [QUERY] ALTER TABLE "Task" ADD COLUMN details TEXT

Таким образом, мы видим, что Perfect CRUD выполняет инструкцию ALTER TABLE. Немного другая ситуация выглядит, когда мы удаляем свойство из таблицы.

[INFO] Starting HTTP server www.example.com on :::8181
[czw., 22 lut 2018 18:03:29 +0100] [QUERY] ALTER TABLE "Task" RENAME TO "temp_Task_temp"
[czw., 22 lut 2018 18:03:29 +0100] [QUERY] CREATE TABLE IF NOT EXISTS "Task" (
id INT PRIMARY KEY,
 name TEXT NOT NULL,
 isFinished INT NOT NULL
)
[czw., 22 lut 2018 18:03:29 +0100] [QUERY] INSERT INTO "Task" (id,name,isFinished)
SELECT id,name,isFinished
FROM "temp_Task_temp"
[czw., 22 lut 2018 18:03:29 +0100] [QUERY] DROP TABLE "temp_Task_temp"

Как видите, наша таблица переименовывается, а затем создается новая таблица (с новой схемой). Затем все данные из временной таблицы копируются в новую таблицу (а временная таблица удаляется).

Благодаря этим утверждениям наша модель и таблицы в базе данных всегда согласованы.

Репозитории

Последнее, что нужно сделать, это заменить старую реализацию репозиториев (с жестко запрограммированным списком) на имплементацию, использующую DatabaseContext.

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

Это стало возможным, потому что Perfect CRUD предоставляет действительно хороший API, который мы можем легко использовать. Я надеюсь, что этот фреймворк скоро будет готов к производству.

Все исходные коды вы можете найти в моем проекте GitHub (ветка orm):