Статьи

Храните все с эликсиром и мнезией

В одной из моих предыдущих статей я писал о таблицах Erlang Term Storage (или просто ETS), которые позволяют хранить кортежи произвольных данных в памяти. Мы также обсудили дисковые ETS (DETS), которые предоставляют немного более ограниченную функциональность, но позволяют сохранять содержимое в файл.

Однако иногда вам может потребоваться еще более мощное решение для хранения данных. Познакомьтесь с Mnesia — системой управления распределенными базами данных в реальном времени, которая впервые была представлена ​​в Erlang. Mnesia имеет реляционную / объектную гибридную модель данных и обладает множеством полезных функций, включая репликацию и быстрый поиск данных.

В этой статье вы узнаете:

  • Как создать схему Mnesia и запустить всю систему.
  • Какие типы таблиц доступны и как их создавать.
  • Как выполнять CRUD-операции и в чем разница между «грязными» и «транзакционными» функциями.
  • Как изменить таблицы и добавить вторичные индексы.
  • Как использовать пакет Amnesia для упрощения работы с базами данных и таблицами.

Давайте начнем, не так ли?

Итак, как уже упоминалось выше, Mnesia — это объектно-реляционная модель данных, которая очень хорошо масштабируется. Он имеет язык запросов DMBS и поддерживает атомарные транзакции, как и любое другое популярное решение (например, Postgres или MySQL). Таблицы Mnesia могут храниться на диске и в памяти, но программы могут быть написаны без знания фактического расположения данных. Кроме того, вы можете реплицировать свои данные на несколько узлов. Также обратите внимание, что Mnesia работает в том же экземпляре BEAM, что и весь другой код.

Поскольку Mnesia является модулем Erlang, вы должны получить к нему доступ с помощью атома:

1
:mnesia

Хотя возможно создать псевдоним:

1
alias :mnesia, as: Mnesia

Данные в Mnesia организованы в таблицы , имена которых представлены в виде атомов (что очень похоже на ETS). Таблицы могут иметь один из следующих типов:

  • :set — тип по умолчанию. Вы не можете иметь несколько строк с одним и тем же первичным ключом (через минуту мы увидим, как определить первичный ключ). Строки не упорядочены каким-либо определенным образом.
  • :ordered_set то же, что и :set , но данные упорядочены по первичному ключу. Позже мы увидим, что некоторые операции чтения будут вести себя по-разному с таблицами :ordered_set .
  • :bag несколько строк могут иметь один и тот же ключ, но строки по-прежнему не могут быть полностью идентичными.

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

Чтобы создать новую схему, мы будем использовать метод с совершенно неожиданным именем: create_schema/1 . По сути, он собирается создать для нас новую базу данных на диске. Он принимает узел в качестве аргумента:

1
:mnesia.create_schema([node()])

Узел — это виртуальная машина Erlang, которая управляет связью, памятью и другими вещами. Узлы могут соединяться друг с другом, и они не ограничены одним ПК — вы также можете подключаться к другим узлам через Интернет.

После того, как вы запустите приведенный выше код, будет создан новый каталог с именем Mnesia.nonode@nohost , который будет содержать вашу базу данных. nonode @ nohost — это имя узла здесь. Однако прежде чем мы сможем создать какие-либо таблицы, Mnesia должна быть запущена. Это так же просто, как вызов функции start/0 :

1
:mnesia.start()

Mnesia должна быть запущена на всех участвующих узлах, каждый из которых обычно имеет папку, в которую будут записываться файлы (в нашем случае эта папка называется Mnesia.nonode@nohost ). Все узлы, составляющие систему Mnesia, записываются в схему, и позже вы можете добавить или удалить отдельные узлы. Более того, при запуске узлы обмениваются информацией о схеме, чтобы убедиться, что все в порядке.

Если Mnesia запустилась успешно, в результате будет возвращен атом :ok . Позже вы можете остановить систему, вызвав stop/0 :

1
:mnesia.stop() # => :stopped

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

1
2
:mnesia.create_table(:user, [attributes: [:id, :name, :surname]])
# => {:atomic, :ok}

Если система не работает, таблица не будет создана, и {:aborted, {:node_not_running, :nonode@nohost}} будет возвращена ошибка {:aborted, {:node_not_running, :nonode@nohost}} . Кроме того, если таблица уже существует, вы получите ошибку {:aborted, {:already_exists, :user}} .

Итак, наша новая таблица называется :user , и у нее есть три атрибута :id :name и :surname . Обратите внимание, что первый атрибут в списке всегда используется в качестве первичного ключа, и мы можем использовать его для быстрого поиска записи. Позже в статье мы увидим, как писать сложные запросы и добавлять вторичные индексы.

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

1
2
3
4
:mnesia.create_table(:user, [
  attributes: [:id, :name, :surname],
  type: :bag
])

Вы даже можете сделать свою таблицу :access_mode для чтения, установив :access_mode в :read_only:

1
2
3
4
5
:mnesia.create_table(:user, [
  attributes: [:id, :name, :surname],
  type: :bag,
  access_mode: read_only
])

После создания схемы и таблицы в каталоге будет находиться файл schema.DAT, а также некоторые файлы .log . Давайте теперь перейдем к следующему разделу и вставим некоторые данные в нашу новую таблицу!

Чтобы сохранить некоторые данные в таблице, вам нужно использовать функцию write/1 . Например, давайте добавим нового пользователя по имени Джон Доу:

1
:mnesia.write({:user, 1, «John», «Doe»})

Обратите внимание, что мы указали имя таблицы и все атрибуты пользователя для хранения. Попробуйте запустить код … и он с треском провалится с ошибкой {:aborted, :no_transaction} . Почему это происходит? Ну, это потому, что функция write/1 должна выполняться в транзакции . Если по какой-то причине вы не хотите придерживаться транзакции, операция записи может быть выполнена «грязным способом» с использованием dirty_write/1 :

1
:mnesia.dirty_write({:user, 1, «John», «Doe»}) # => :ok

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

1
2
3
:mnesia.transaction(fn ->
  :mnesia.write({:user, 1, «John», «Doe»})
end) # => {:atomic, :ok}

transaction принимает анонимную функцию, которая имеет одну или несколько сгруппированных операций. Обратите внимание, что в этом случае результатом является {:atomic, :ok} dirty_write {:atomic, :ok} , а не просто :ok как это было с функцией dirty_write . Основным преимуществом здесь является то, что если что-то пойдет не так во время транзакции, все операции откатятся.

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

Функция transaction может иметь столько операций записи, сколько необходимо:

1
2
3
4
5
6
write_data = fn ->
  :mnesia.write({:user, 2, «Kate», «Brown»})
  :mnesia.write({:user, 3, «Will», «Smith»})
end
 
:mnesia.transaction(write_data) # => {:atomic, :ok}

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

1
2
3
4
5
6
update_data = fn ->
  :mnesia.write({:user, 2, «Kate», «Smith»})
  :mnesia.write({:user, 3, «Will», «Brown»})
end
 
:mnesia.transaction(update_data)

Обратите внимание, что это не будет работать для таблиц типа :bag . Поскольку такие таблицы позволяют нескольким записям иметь один и тот же ключ, вы просто получите две записи: [{:user, 2, "Kate", "Brown"}, {:user, 2, "Kate", "Smith"}] . Тем не менее, таблицы :bag не позволяют существовать полностью идентичным записям.

Хорошо, теперь, когда у нас есть некоторые данные в нашей таблице, почему бы нам не попробовать их прочитать? Как и в случае операций записи, вы можете выполнять чтение «грязным» или «транзакционным» способом. «Грязный путь», конечно, проще (но это темная сторона Силы, Люк!):

1
:mnesia.dirty_read({:user, 2}) # => [{:user, 2, «Kate», «Smith»}]

Поэтому dirty_read возвращает список найденных записей на основе предоставленного ключа. Если таблица: a :set или a :ordered_set , список будет иметь только один элемент. Для таблиц :bag список может, конечно, состоять из нескольких элементов. Если записи не найдены, список будет пустым.

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

1
2
3
4
5
read_data = fn ->
  :mnesia.read({:user, 2})
end
 
:mnesia.transaction(read_data) => {:atomic, [{:user, 2, «Kate», «Brown»}]}

Большой!

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

1
2
:mnesia.dirty_first(:user) # => 2
:mnesia.dirty_last(:user) # => 2

Как dirty_first и dirty_last имеют свои транзакционные аналоги, а именно first и last , которые должны быть включены в транзакцию. Все эти функции возвращают ключ записи, но обратите внимание, что в обоих случаях мы получаем 2 в результате, хотя у нас есть две записи с ключами 2 и 3 . Почему это происходит?

Похоже, что для таблиц :set и :bag функции dirty_first и dirty_last (а также first и last ) являются синонимами, поскольку данные не сортируются в каком-либо определенном порядке. Однако, если у вас есть таблица :ordered_set , записи будут отсортированы по их ключам, и результат будет:

1
2
:mnesia.dirty_first(:user) # => 2
:mnesia.dirty_last(:user) # => 3

Также возможно получить следующий или предыдущий ключ, используя dirty_next и dirty_prev (или next и prev ):

1
2
:mnesia.dirty_next(:user, 2) => 3
:mnesia.dirty_next(:user, 3) => :»$end_of_table»

Если записей больше нет, возвращается специальный атом :"$end_of_table" . Кроме того, если таблица представляет собой :set или :bag , dirty_next и dirty_prev являются синонимами.

Наконец, вы можете получить все ключи из таблицы, используя dirty_all_keys/1 или all_keys/1 :

1
:mnesia.dirty_all_keys(:user) # => [3, 2]

Чтобы удалить запись из таблицы, используйте dirty_delete или delete :

1
:mnesia.dirty_delete({:user, 2}) # => :ok

Это собирается удалить все записи с данным ключом.

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

1
:mnesia.delete_table(:user)

Для этого метода нет «грязного» аналога. Очевидно, что после удаления таблицы вы ничего не можете в нее записать, и вместо {:aborted, {:no_exists, :user}} будет возвращена ошибка {:aborted, {:no_exists, :user}} .

Наконец, если вы действительно настроены на удаление, всю схему можно удалить с помощью delete_schema/1 :

1
:mnesia.delete_schema([node()])

Эта операция вернет {:error, {'Mnesia is not stopped everywhere', [:nonode@nohost]}} если Mnesia не остановлена, поэтому не забудьте сделать это:

1
2
:mnesia.stop()
:mnesia.delete_schema([node()])

Теперь, когда мы познакомились с основами работы с Mnesia, давайте немного углубимся и посмотрим, как писать сложные запросы. Во-первых, есть функции match_object и dirty_match_object которые можно использовать для поиска записи на основе одного из предоставленных атрибутов:

1
2
:mnesia.dirty_match_object({:user, :_, «Kate», «Brown»})
# => [{:user, 2, «Kate», «Brown»}]

Атрибуты, которые вам не нужны, помечены атомом :_ . Вы можете установить только фамилию, например:

1
2
:mnesia.dirty_match_object({:user, :_, :_, «Brown»})
# => [{:user, 2, «Kate», «Brown»}]

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

1
2
3
4
5
6
7
8
write_data = fn ->
  :mnesia.write({:user, 2, «Kate», «Brown»})
  :mnesia.write({:user, 3, «Will», «Smith»})
  :mnesia.write({:user, 4, «Will», «Smoth»})
  :mnesia.write({:user, 5, «Will», «Smath»})
end
 
:mnesia.transaction(write_data)

Теперь я хочу найти все записи с именем Will и ключами которых меньше 5 , что означает, что полученный список должен содержать только «Will Smith» и «Will Smoth». Вот соответствующий код:

01
02
03
04
05
06
07
08
09
10
11
:mnesia.dirty_select(
  :user,
  [{
    {:user, :»$1″, :»$2″, :»$3″},
    [
      {:<, :»$1″, 5},
      {:==, :»$2″, «Will»}
    ],
    [:»$$»]
  }]
) # => [[3, «Will», «Smith»], [4, «Will», «Smoth»]]

Здесь все немного сложнее, поэтому давайте обсудим этот фрагмент шаг за шагом.

  • Во-первых, у нас есть часть {:user, :"$1", :"$2", :"$3"} . Здесь мы предоставляем имя таблицы и список позиционных параметров. Они должны быть написаны в этой странной форме, чтобы мы могли использовать их позже. $1 соответствует :id , $2 — это name , а $3surname .
  • Далее приведен список защитных функций, которые должны применяться к заданным параметрам. {:<, :"$1", 5} означает, что мы хотели бы выбрать только те записи, атрибут которых помечен как $1 (то есть :id ) меньше 5 . {:==, :"$2", "Will"} , в свою очередь, означает, что мы выбираем записи, для которых :name установлено значение "Will" .
  • Наконец, [:"$$"] означает, что мы хотели бы включить все поля в результат. Вы можете сказать [:"$2"] чтобы отобразить только имя. Заметьте, кстати, что результат содержит список списков: [[3, "Will", "Smith"], [4, "Will", "Smoth"]] .

Вы также можете пометить некоторые атрибуты как те, которые вам не нужны, используя атом :_ . Например, давайте проигнорируем фамилию:

01
02
03
04
05
06
07
08
09
10
11
:mnesia.dirty_select(
  :user,
  [{
    {:user, :»$1″, :»$2″, :_},
    [
      {:<, :»$1″, 5},
      {:==, :»$2″, «Will»}
    ],
    [:»$$»]
  }]
) # => [[3, «Will»], [4, «Will»]]

В этом случае, однако, фамилия не будет включена в результат.

Предположим теперь, что мы хотели бы изменить нашу таблицу, добавив новое поле. Это можно сделать с помощью функции transform_table , которая принимает имя таблицы, функцию, которая применяется ко всем записям, и список новых атрибутов:

1
2
3
4
5
6
7
:mnesia.transform_table(
  :user,
  fn ({:user, id, name, surname}) ->
    {:user, id, name, surname, :rand.uniform(1000)}
  end,
  [:id, :name, :surname, :salary]
)

В этом примере мы добавляем новый атрибут с именем :salary (он указан в последнем аргументе). Что касается функции преобразования (второй аргумент), мы устанавливаем этот новый атрибут на случайное значение. Вы также можете изменить любой другой атрибут внутри этой функции преобразования. Этот процесс изменения данных известен как «миграция», и эта концепция должна быть знакома разработчикам из мира Rails.

Теперь вы можете просто получить информацию об атрибутах таблицы, используя table_info :

1
:mnesia.table_info(:user, :attributes) # => [:id, :name, :surname, :salary]

Атрибут :salary есть! И, конечно же, ваши данные также на месте:

1
:mnesia.dirty_read({:user, 2}) # => [{:user, 2, «Kate», «Brown», 778}]

Вы можете найти чуть более сложный пример использования функций create_table и transform_table на веб-сайте ElixirSchool .

Mnesia позволяет создавать любые атрибуты, проиндексированные с помощью функции add_table_index . Например, давайте сделаем наш атрибут :surname проиндексированным:

1
:mnesia.add_table_index(:user, :surname) # => {:atomic, :ok}

Если индекс уже существует, вы получите сообщение об ошибке {:aborted, {:already_exists, :user, 4}} .

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

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

1
2
:mnesia.dirty_index_read(:user, «Smith», :surname)
# => [{:user, 3, «Will», «Smith»}]

Здесь мы используем вторичный индекс :surname для поиска пользователя.

Может быть несколько утомительно работать напрямую с модулем Mnesia, но, к счастью, есть сторонний пакет под названием Amnesia (да!), Который позволяет вам выполнять простые операции с большей легкостью.

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

1
2
3
4
5
6
use Amnesia
 
defdatabase Demo do
   deftable User, [{ :id, autoincrement }, :name, :surname, :email], index: [:email] do
   end
end

Это определит базу данных под названием Demo с таблицей User . Пользователь собирается назвать имя, фамилию, адрес электронной почты (индексированное поле) и идентификатор (первичный ключ, установленный в автоинкремент).

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

1
mix amnesia.create -d Demo —disk

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

1
mix amnesia.drop -d Demo

Можно уничтожить как базу данных, так и схему:

1
mix amnesia.drop -d Demo —schema

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

1
2
3
Amnesia.transaction do
  will_smith = %User{name: «Will», surname: «Smith», email: «will@smith.com»} |> User.write
end

Или получить пользователя по идентификатору:

1
2
3
Amnesia.transaction do
  will_smith = User.read(1)
end

Кроме того, вы можете определить таблицу Message при установлении связи с таблицей User с помощью user_id в качестве внешнего ключа:

1
2
deftable Message, [:user_id, :content] do
end

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

1
2
3
4
5
6
7
8
9
deftable User, [{ :id, autoincrement }, :name, :surname, :email], index: [:email] do
    def add_message(self, content) do
      %Message{user_id: self.id, content: content} |> Message.write
    end
     
    def messages(self) do
      Message.read(self.id)
    end
end

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

1
2
3
4
5
6
7
Amnesia.transaction do
  will_smith = User.read(1)
   
  will_smith |> User.add_message «hi!»
    
  will_smith |> User.messages
end

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

В этой статье мы говорили о системе управления базами данных Mnesia, доступной для Erlang и Elixir. Мы обсудили основные концепции этой СУБД и увидели, как создать схему, базу данных и таблицы, а также выполнить все основные операции: создание, чтение, обновление и уничтожение. Кроме того, вы узнали, как работать с индексами, как преобразовывать таблицы и как использовать пакет Amnesia для упрощения работы с базами данных.

Я действительно надеюсь, что эта статья была полезна, и вы тоже хотите попробовать Mnesia в действии. Как всегда, я благодарю вас за то, что вы остались со мной и до следующего раза!