Elixir и Angular 2 безо всяких Hello, world!, или Реализуем работу с древовидным справочником, часть 1

КПДВ
Функциональный язык программирования Elixir набирает популярность, а один из последних фреймворков для создания одностраничных приложений — Angular 2 — недавно вышел в релиз. Давайте познакомимся с ними в паре статей, создав с нуля полноценный back-end на Elixir и Phoenix Framework, снабжающий данными клиентское приложение-frontend на базе Angular 2.


Hello, world — не наш вариант, поэтому сделанное при необходимости можно будет применить в реальных проектах: весь представленный код выложен под лицензией MIT.


Объем статьи большой огромный! Надеюсь на столь же огромное количество комментариев — любых. Не раз замечал, что из комментариев получаешь не меньше, чем от основной статьи, а иногда и больше.


В первой статье будет несколько вступительных слов и работа над back-end. Поехали!


Введение


Несколько месяцев назад мне предложили в качестве субподрядчика реализовать в очень сжатые сроки прототип веб-приложения; из требований присутствовали только функционал, крайняя дата окончания и небходимость использования исключительно open source инструментов. «Отлично», — подумал я — «это прекрасный повод применить на практике связку Elixir/Phoenix Framework и Angular 2», благо последний незадолго до того вышел в релиз. Проект в результате был реализован вовремя, заказчик остался доволен, опыт пополнился реализацией новых задач.


Одной из таких задач оказалась необходимость отображения справочников ГРНТИ и OECD FOS с возможностью выбора нескольких значений. Так как готовых решений для вывода древовидного справочника нормальной степени готовности не нашлось, пришлось изобретать свой велосипед. Кроме того, это дало тему для настоящего цикла «обучающих» статей для знакомства одновременно и с Elixir/Phoenix Framework, и с Angular 2.


Итак, по окончанию этого цикла у нас появится рабочий back-end на Elixir и Phoenix Framework, отдающий с помощью API содержание справочников ГРНТИ и OECD FOS на независимый front-end на Angular 2, на котором можно будет вызывать форму вывода полученных данных со множественным выбором разделов\подразделов, сохранить (получить за пределы окна выбора) и восстановить выбранное при открытии. Внешний вид нам обеспечит Twitter Bootstrap. Реализацию справочника на front-end мы оформим в виде отдельного модуля, который можно будет использовать в дальнейшем с любыми проектами.


Некоторые пояснения по реализации


Справочник ГРНТИ представляет из себя трёхуровневую (максимум) структуру, каждая запись которой имеет код в десятичной классификации, состоящий из трех групп чисел от 00 до 99, разделённых точкой, а так же название. Справочник на данный момент включает около 8 000 записей разделов и подразделов, объем в плоском текстовом виде — более 400 кб. С содержанием справочника можно ознакомиться на grnti.ru (не имею никакого отношения к этому ресурсу).


OECD FOS также имет трёхуровневую структуру с иерархическим кодом, разделённым точкой, однако, в отличие от предыдущего варианта, в данном случае последняя группа кода является сочетаением двух латинских букв. Записей в справочнике существенно меньше — чуть менее 300, а общий объем — около 8 кб. К сожалению, в онлайне мне не удалось найти хоть насколько-то актуальных версий этого справочника, так что воспользуемся тем, что нашлось по другим каналам.


Из-за объёма справочника ГРНТИ при работе с ним мы будем запрашивать с back-end только те разделы, которые нам нужны в настоящий момент, а вот OECD FOS можно отдавать целиком и обрабатывать структуру уже на клиенте.


Сразу хочу уточнить: задача несколько вырожденная, она являлась лишь частью более широкого функционала. Естественно, если задача состоит только в выводе справочников (например, в информационных целях), то ни back-end, ни SPA не нужны.


Также нисколько не претендую на звание гуру, и если предложите более эффективную реализацию любой части, буду благодарен за науку: учиться я люблю.


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


Выбор стека технологий


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


Elixir — довольно молодой, компилируемый в байт-код функциональный язык, написанный на Erlang и исполняющийся в его виртуальной машине Beam. Язык наследует все достоинства Erlang:


  • функциональность,
  • иммутабельность,
  • «всё — процесс»,
  • процессы изолированы друг от друга,
  • очень низкую стоимость создания и уничтожения процессов,
  • каждый процесс имеет уникальный идентификатор,
  • нет разделения ресурсов между процессами, можно отправить сообщение из любого процесса в любой процесс, если вы знаете его имя,
  • обмен сообщениями — единственный способ межпроцессных коммуникаций,
  • pattern matching (сопоставление с образцом),
  • идеологию «делай то, что требуется, или просто умри»,
  • лёгкость создания распределённых систем;

и при этом имеет более простой синтаксис, в чём-то похожий на Ruby, полиморфизм через механизм протоколов, очень богатые возможности мета-программирования, возможности для лёгкого создания документации с Markdown-разметкой и реальным тестированием примеров (!!!) прямо в коде модулей. Важно, что все функции Erlang и написанных для него библиотек могут быть вызваны напрямую из кода Elixir без какой-либо потери производительности.


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


Много значит и активное участие автора языка — José Valim — в жизни комьюнити. Он с удовольствием подробно отвечает на вопросы и при необходимости вносит недостающую функциональность в язык\библиотеки (к примеру, в Ecto, о которой пойдёт речь дальше — есть личный положительный опыт).


Phoenix Framework — наиболее популярный веб-фреймворк для Elixir, реализующий шаблон MVC и значительно упрощающий разработку веб-приложений. Кроме того, Phoenix имеет Channels — возможность realtime-коммуникаций с приложением через websockets, и это реально killer feature. Существует JavaScript-компонент для использования с браузерами, а так же реализации для некоторых других языков, например, для Java под Android.


Angular 2 — фреймворк для разработки клиентской части одностраничных (single page) веб-приложений, в основном поддерживаемый Google. Версия два была полностью переписана с учётом опыта, полученного во время разработки и эксплуатации AngularJS. Релиз был выпущен в сентябре 2016 года.


Back-end на Elixir и Phoenix Framework


Пара слов об Elixir

Если вы до этого не сталкивались с Elixir, очень рекомендую начать с изучения языка. Планирую давать подробные объяснения используемым подходам и особенностям, но полноценно в рамках небольшого цикла статей охватить всё невозможно (это если не считать того, что и я сам постоянно нахожу что-то новое). Для изучения есть как базовое введение на сайте проекта, так и множество других ресурсов в Интернет. Очень полезен форум, где с огромным удовольствием отвечает и создатель языка. Кстати, отличный ответ-пример на вопрос «стоит ли сначала изучить Elixir или сразу броситься на амбразуру» можно увидеть в этом треде на Reddit. В качестве резюме могу сказать, что автор топика был разочарован незначительной разницей в производительности тестового примера, написанного на Ruby и «на Elixir» (как он считал). Код на Ruby выполнялся за 4.221 секунды, на Elixir — 5.923 cекунды. После того, как код переписали, используя особенности языка (а не просто портировав один-в-один с Ruby), он начал работать в три (!!!) раза быстрее.


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


Установка инструментария и начало проекта


Для управления версиями Erlang, Elixir и node (который в дальнейшем понадобится для работы с front-end) я использую менеджер пакетов asdf. Существует прекрасный gist, в котором очень подробно описан процесс установки зависимостей под Fedora и Ubuntu, asdf, Erlang и Elixir, поэтому повторяться не буду. Он на английском, но там достаточно копипаста. Последние версии на момент написания статьи: Erlang — 19.2, Elixir — 1.4.1.


Так же вам понадобится PostgreSQL одной из последних версий (использую 9.6 на данный момент), который вы можете установить с помощью стандарных менеджеров пакетов вашего дистрибутива\ОС.


После установки Erlang и Elixir нужно установить Phoenix Framework.


В Elixir для создания, компиляции, тестирования проектов, а так же управления их зависимостями существует специальная утилита автоматизации — Mix (стоит обратить внимание ещё и на эту часть документации). Утилита mix — она как make, только удобнее.


Для начала с её помощью установим (очередной) менеджер пакетов Hex командой


$ mix local.hex

а затем — архив Phoenix Framework:


$ mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez

Документация упоминает о необходимости node.js, однако так как в данном случае Phoenix нам будет обеспечивать только API, то node нам понадобится позже, когда мы перейдём к Angular 2.


В момент написания статьи актуальным был Phoenix Framework версии 1.2.1.


Стоит сказать пару слов и о Hex. Существует единый репозиторий https://hex.pm, в котором публикуются библиотеки для Elixir/Erlang. По умолчанию все зависимости проектов на Elixir ищутся именно там.


Установив необходимое ПО, перейдём в нужную нам директорию и создадим новый проект Phoenix, запустив:


mix phoenix.new atv_api --no-brunch --no-html
$ mix phoenix.new atv_api --no-brunch --no-html
* creating atv_api/config/config.exs
* creating atv_api/config/dev.exs
* creating atv_api/config/prod.exs
...
* creating atv_api/priv/static/images/phoenix.png
* creating atv_api/priv/static/favicon.ico

Fetch and install dependencies? [Yn] y
* running mix deps.get

We are all set! Run your Phoenix application:

    $ cd atv_api
    $ mix phoenix.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phoenix.server

Before moving on, configure your database in config/dev.exs and run:

    $ mix ecto.create

$

Мы создаём новый проект без шаблонов html и без поддержки brunch, так как клиентская часть будет в отдельном проекте.


Советую сразу же изменить настройки подключения к базе данных в конфигурационных файлах config/prod.exs, config/dev.exs и config/test.exs для режимов production, разработки и тестирования соответственно.


Кроме того, если вы используете Elixir версии 1.4 и выше и текущая версия Phoenix всё ещё 1.2.1, я рекомендую несколько изменить файл mix.exs. Elixir 1.4 принёс несколько новых возможностей, в частности, упростил добавление зависимостей, имеющих собственные деревья процессов, требующих запуска при старте проекта. Если раньше такие зависимости (а их большинство) было необходимо добавить и в список зависимостей (deps), и в список приложений для запуска (applications), то сейчас достаточно только первого: mix сам разберётся, является ли зависимость приложением, и запустит его. Указывать необходимо только приложения, не перечисленные в зависимостях. Давайте приведём метод, возвращающией описание приложений, к следующему виду:


  # mix.exs
  ...
  # Configuration for the OTP application.
  #
  # Type `mix help compile.app` for more information.
  def application do
    [mod: {AtvApi, []},
     extra_applications: [:logger]]
  end
  ...

Если вы сравните с тем, что было раньше, то увидите, что исчез список, имевший ключ :applications и добавился новый — :extra_applications, в котором остался только :logger, а всё, что перечислено в зависимостях — исключено.


Сделав это, запустите создание базы данных с помощью mix ecto.create. Окружение по-умолчанию — dev, соответственно, будет создана база данных для этой среды, стандартно называться которая будет atv_api_dev.


В любой момент вы сможете удалить базу данных, запустив задачу mix ecto.drop. При этом mix ecto.reset удалит базу данных, создаст новую, запустит миграции и выполнит содержимое seeds.exs для первоначального заполнения данных (подробнее о последнем далее).


Добавив перед mix переменную, инициализированную нужным значением — MIX_ENV=prod, MIX_ENV=dev (по-умолчанию) или MIX_ENV=test, — вы сможете выполнять требуемые задачи в соответствующей среде.


Справочник OECD FOS


Так как справочник OECD FOS более прост, начнём с него.


Для работы с данными в Phoenix используется библиотека Ecto. Ecto — это DSL для работы с таблицами баз данных через представления (модели) и для написания запросов к базе данных. Ecto, в отличие от Rails ActiveRecords, очень простой (берёт на себя минимум), но в то же время мощный инструмент.


Генераторы кода


В Phoenix Framework существуют генераторы кода разного вида, способные создать полный комплект из миграции, модели, контроллера, реализующего CRUD, модулей представления (view), генерирующих json, а так же базовых тестов. В случае обоих справочников нам не нужен полноценный CRUD, однако для OECD FOS можно начать со сгенерированного кода и убрать лишнее.


В таблице справочника OECD FOS у нас будет два поля: id и title, оба с типом text. (Почему text? Потому что если нет разницы, то зачем распыляться?)


Воспользуемся генератором и ведём:


mix phoenix.gen.json Fos fos title: text
$ mix phoenix.gen.json Fos fos title:text
* creating web/controllers/fos_controller.ex
* creating web/views/fos_view.ex
* creating test/controllers/fos_controller_test.exs
* creating web/views/changeset_view.ex
* creating web/models/fos.ex
* creating test/models/fos_test.exs
* creating priv/repo/migrations/20170215194144_create_fos.exs

Add the resource to your api scope in web/router.ex:

    resources "/fos", FosController, except: [:new, :edit]

Remember to update your repository by running migrations:

    $ mix ecto.migrate

Здесь phoenix.gen.json — это задача утилиты mix (mix task), Fos — название модели в единственном числе, fos — название для таблицы, по конвенции тут должно быть название модели с маленькой буквы во множественном числе, а далее идёт описание полей. В данном случае мы хотим видеть в модели поле с именем title и типом text (это тип данных PostgreSQL). По поводу поля id поговорим чуть позже. Список задач mix можно получить, запустив команду mix help, а подробнее о задачах phoenix расказано в документации.


После завершения выполнения команды нам будет предложено добавить строку resources "/fos", FosController, except: [:new, :edit] в файл web/router.ex. Давайте пока сделаем это (в дальнейшем мы это изменим):


web/router.ex
defmodule AtvApi.Router do
  use AtvApi.Web, :router

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/api", AtvApi do
    pipe_through :api

    resources "/fos", FosController, except: [:new, :edit]
  end
end

Также будет предложено запустить процесс миграции, но не стоит спешить. По-умолчанию Ecto генерирует модель и миграцию (скрипт для создания таблицы в базе данных) с автоинкрементным полем id типа integer в качестве первичного ключа, однако нам не нужно поле такого типа, так как в качестве ключа мы будем использовать код раздела. Изменим это поведение для нашей модели.


Начнём с файла миграции. Генератор моделей создаёт миграции в директории priv/repo/migrations. Откройте файл, который заканчивается на _create_fos.exs и приведите его к следующему виду:


priv/repo/migrations/20170215194144_create_fos.exs
defmodule AtvApi.Repo.Migrations.CreateFos do
  use Ecto.Migration

  def change do
    create table(:fos, primary_key: false) do
      add :id, :text, null: false, primary_key: true
      add :title, :text

      timestamps()
    end

  end
end

Код в Elixir организован в виде модулей и функций. Каждый модуль определяется макросом defmodule, описание функций — макросом def или defp. Пока не обращайте внимание на use, мы вернёмся к этому позже. Данный модуль миграции имеет название AtvApi.Repo.Migrations.CreateFos, причём оно сформировано для удобства исходя из конвенции. Язык не заставляет давать именно такие названия, и язык не обязывает вас иметь всю цепочку с «родительскими» модулями типа AtvApi.Repo.Migrations и AtvApi.Repo.


Мы добавили опцию primary_key: false к макросу создания таблицы create/2. Этим мы отменяем создание стандартного поля id и ниже добавляем вручную поле с тем же именем, но c типом text, которое станет первичным ключом.


Поправим описание модели, расположеное в директории web/models:


web/models/fos.ex
defmodule AtvApi.Fos do
  use AtvApi.Web, :model

  @primary_key {:id, :string, autogenerate: false}

  schema "fos" do
    field :title, :string

    timestamps()
  end

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:id, :title])
    |> validate_required([:id, :title])
  end
end

Обратите внимание, мы добавили константу @primary_key с описанием первичного ключа. Также мы добавили атом с именем поля :id в список разрешённых к изменению (см. описание функции cast/3, последний параметр allowed) — в противном случае мы не сможем добавить к набору изменений (changeset) поле с заданным нами кодом; этот же атом добавлен в список функции-валидатора validate_required/2, которая, как понятно из названия, проверяет наличие соответствующего поля в наборе изменений (changeset) и в случае его отсутствия помечает набор как ошибочный.


Стоит отметить вызов макроса timestamps/1, который добавляет в схему модели поля inserted_at и updated_at, имеющие тип timestamp. Первое поле инициализируется текущим временем в момент создания, второе — при каждом изменении записи функциями Ecto.


Что такое модель под капотом

Здесь также нужно сказать пару слов о том, что же из себя представляет модель.


В Elixir существует понятие «структуры» (struct). Структура — это расширение ассоциативного массива (т.е. хранилища пар ключ-значение, стандартно обозначаемого как %{ key => value, ...}, а в случае, если ключ является атомом, то %{ key: value, ...}); структура имеет дополнительный ключ __struct__, значение которого содержит её имя, и ограничена только теми полями, которые заданы в коде на момент компиляции. При попытке добавить в структуру значение с ключом, не описанным в ней на момент компиляции, будет сгенерирована ошибка. Определяется структура с помощью конструкта defstruct и получает имя модуля, в котором описана:


iex> defmodule User do
...>   defstruct title: "John", age: 27
...> end

Список ключевых слов, использованных с defstruct, определяет поля, которые может и будет содержать эта структура, вместе с их значениями по умолчанию. В примере выше структура получит имя %User{}.


Как было сказано, структура — это расширение ассоциативного массива, поэтому все функции модуля Map будут с ними работать. Однако при этом протокол Enumerable для структур не реализован, поэтому модуль Enum с ними работать не будет.


Остаётся добавить, что модель — это структура, получаемая с помощью мета-программирования (об этом ниже) из описания, заключённого в блок do ... end макроса scheme. Наша модель, описанная в модуле AtvApi.Fos, будет иметь тип %Fos{} и содержать поля-ключи :id (по умолчанию) и :title (определённый явно).


За более подробной информацией о структурах добро пожаловать в документацию.


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


mix test test/models/fos_test.exs
$ mix test test/models/fos_test.exs 
Compiling 7 files (.ex)
Generated atv_api app

  1) test changeset with valid attributes (AtvApi.FosTest)
     test/models/fos_test.exs:9
     Expected truthy, got false
     code: changeset.valid?()
     stacktrace:
       test/models/fos_test.exs:11: (test)

.

Finished in 0.05 seconds
2 tests, 1 failure

Randomized with seed 166025

Если вы откроете файл с автоматически сгенерированным тестом test/models/fos_test.exs, то заметите, что константе под названием @valid_attrs соответствует ассоциативный массив, в котором отсутствует ключ id. Как мы помним, по-умолчанию поле id целочисленное автоинкрементное, и не должно изменяться программой. Однако не в нашем случае — не зря мы включили в модель проверку на его наличие. Давайте изменим константу следующим образом:


  @valid_attrs %{title: "Humanities, multidisciplinary", id: "0605BQ"}

и снова запустим тест:


mix test test/models/fos_test.exs
$ mix test test/models/fos_test.exs 
..

Finished in 0.04 seconds
2 tests, 0 failures

Randomized with seed 892257

Убедишись на тестовой базе, что модель работает так, как нужно, можно запустить процесс миграции:


$ mix ecto.migrate

17:54:26.080 [info]  == Running AtvApi.Repo.Migrations.CreateFos.change/0 forward

17:54:26.080 [info]  create table fos

17:54:26.097 [info]  == Migrated in 0.0s

Кстати, последнюю на данный момент времени выполненную миграцию можно откатить командой mix ecto.rollback.


Заполнение таблицы данными справочника


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


Стандартно заполнение таблицы первоначальными данными происходит из файла priv/repo/seeds.exs. Скачайте текстовый файл oecd_fos.txt и файл grnti.txt (он пригодится нам позднее) со справочниками и поместите их в ту же директорию priv/repo. Теперь создадим код для парсинга и записи данных в базу. Добавьте после имеющихся в файле комментариев следующий код:


priv/repo/seeds.exs
require Logger
alias AtvApi.Repo
import Ecto.Query

### OECD FOS dictionary ###
alias AtvApi.Fos

unless Repo.one!(from f in Fos, select: count(f.id)) > 0 do

  multi = File.read!("priv/repo/oecd_fos.txt")
          |> String.split("\n")
          |> Enum.reject(fn(row) -> byte_size(row) < 1 end)
          |> Enum.sort
          |> Enum.dedup
          |> Enum.reduce(Ecto.Multi.new, fn(row, multi) ->

                [id, title] = row
                               |> String.trim
                               |> String.split(";")

                changeset = Fos.changeset(%Fos{}, %{id: id, title: title})

                Ecto.Multi.insert(multi, id, changeset)

             end)

  Repo.transaction(multi)

  Logger.info "OECD FOS load complete"

end
### OECD FOS dictionary ###

Давайте последовательно пройдёмся по коду. Для начала мы требуем (require) наличия модуля Logger в момент компиляции.


Приведу цитату из документации о require

В Elixir макросы релизуют механизм мета-программирования (т.е. написания кода, который генерирует код). Макрос — это фрагмент кода, который выполняется и раскрывается (т.е. подменяется результатом исполнения) непосредственно перед компиляцией. Это означает, что чтобы воспользоваться макросами, необходимо гарантировать, что содержащий их модуль и реализация доступны в момент компиляции. Директива require служит именно для этого.


Директива alias позволяет сократить имя модуля, в данном случае использовать Repo вместо AtvApi.Repo. Следующая директива — import — добавляет в текущее пространство имён функции из соответствующего модуля (в нашем случае — Ecto.Query, что позволяет строить запросы к базе данных прямо в коде программы). При желании (а, согласно рекомендациям разработчиков, это желание должно возникать всегда) можно ограничить импортируемые функции, добавив only: [function_title: arity], например, так: import Ecto.Query, only: [from: 2] (arity — это число аргументов у функции). Предназначена эта возможность для удобства — если вы часто вызываете какую-то функцию другого модуля, в некоторых случаях удобно её импортировать. Обратите внимание — импортируются только определения (имена) функций, но не их реализация, она остаётся в импортируемом модуле, и все вызовы из импортированных функций будут осуществляться внутри модуля, в котором функции реализованы. Более подробно об этих и других директивах сказано в документации.


Чтобы не добавить данные ещё раз в случае повторного запуска seeds.exs, введём проверку на наличие записей в таблице. Для этого с помощью функци Repo.one!/2 получим результат запроса к базе данных, который должен транформироваться в SQL вида SELECT COUNT(f.id) FROM fos AS f, и если он не больше нуля, выполним блок кода.


«Опасные» и «безопасные» функции»

Согласно конвенции, в языке зачастую принято иметь пару функций с [почти] одним именем, одна из которых выдаёт кортеж (tuple) типа {:ok, result} или {:error, description}, а вторая, оканчивающаяся восклицательным знаком, в случае успеха возвращает результат, а в случае ошибки вызывает исключение.
Например:


iex> File.read("file.txt")
{:ok, "file contents"}
iex> File.read("no_such_file.txt")
{:error, :enoent}

iex> File.read!("file.txt")
"file contents"
iex> File.read!("no_such_file.txt")
** (File.Error) could not read file no_such_file.txt: no such file or directory

Далее присутствует блок кода, использующий оператор конвейера (pipe operator). Этот оператор берёт результат выполнения предыдущей функции и передаёт его следующей функции в качестве первого аргумента. Например, записи (2) и (3) аналогичны, равно как и (4) и (5), при этом вторые варианты более читабельны:


пример оператора конвейера
iex(1)> some_map = %{one: 1}
%{one: 1}
iex(2)> Enum.count(some_map)
1
iex(3)> some_map |> Enum.count()
1
iex(4)> Enum.count(Map.put(some_map, :two, 2))
2
iex(5)> some_map |> Map.put(:two, 2) |> Enum.count()
2

Таким образом, мы:


  • читаем содержимое файла (File.read!/1), получая строковое значение,
  • создаём из этого значения список (List) из элементов-строк с помощью функции String.split/3, используя в качестве разделителя перевод строки \n,
  • убираем из списка элементы, размер которых меньше единицы, с помощью функции Enum.reject/2, возвращающей новый список из элементов, для которых переданная в качестве второго параметра функция вернула false,
  • сортируем список с помощью функции Enum.sort/1,
  • убираем дубликаты повторяющихся элементов с помощью функции Enum.dedup/1,
  • и, наконец, передаём отфильтрованный список функции Enum.reduce/3, на которой остановимся подробнее.

Enum.reduce(enumerable, acc, fun), как и все функции модуля Enum, получает на вход элемент, реализующий протокол Enumerable (перечисляемое), и начальное значение аккумулятора, который будет собирать результаты работы функции, передаваемой третьим параметром. Эта функция в качестве параметров получает следующий элемент перечисляемого и текущее значение аккумулятора, совершает действия с элементом и аккумулятором и в конце возвращает новое состояние последнего. После перебора всех элементов списка Enum.reduce/3 возвращает конечное состояние аккумулятора.


В данном случае в качестве аккумулятора выступает инициализированная структура Ecto.Multi для объединения операций с базой данных.


В коде анонимной функции в правой части первого выражения значение полученного ей элемента подаётся через оператор конвейера в качестве первого параметра в функцию String.trim/1, удаляющую white-space из начала и окончания строки; результат её работы подаётся на вход функции String.split/3, о которой мы говорили чуть раньше. Файл oecd_fos.txt содержит строки с кодом и наименованием разделов, разделённые точкой с запятой. String.split/3 с такой строкой вернёт список, состоящий из двух элементов. Первый элемент этого списка благодаря сопоставлению с образцом (pattern matching) будет присвоен переменной id, второй — переменной title.


Небольшое отступление по поводу сопоставления с образцом

Сопоставление с образцом (pattern matching) — очень важная и полезная особенность Elixir. Примеры его использования мы ещё рассмотрим в дальнейшем, пока же следует знать, что = (знак равно) — не оператор присваивания, а оператор сопоставления (match operator). Единственная переменная слева может быть сопоставлена результату любого выражения в правой части, поэтому в этом случае он выступает аналогом оператора присваивания:


iex> x = 1
1
iex> x
1

Однако если сделаем немного иначе, то получим ошибку:


iex> 1 = x
1
iex> 2 = x
** (MatchError) no match of right hand side value: 1

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


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


iex> {a, b, c} = {:hello, "world", 42}
{:hello, "world", 42}
iex> a
:hello
iex> b
"world"

Или более сложный вариант:


iex> {a, b, {d, e} = c} = {:hello, "world", {:grey, "hole"}} 
{:hello, "world", {:grey, "hole"}}
iex> a
:hello
iex> b
"world"
iex> c
{:grey, "hole"}
iex> d
:grey
iex> e
"hole"

При этом если части не соответствуют друг другу, будет сгенерирована ошибка. К примеру, если если кортежи имеют разные размеры:


iex> {a, b, c} = {:hello, "world"}
** (MatchError) no match of right hand side value: {:hello, "world"}

То же самое произойдёт и в случае, если по разные стороны оператора соответствия мы поместим разные типы данных:


iex> {a, b, c} = [:hello, "world", 42]
** (MatchError) no match of right hand side value: [:hello, "world", 42]

Однако ещё более интересным является то, что можно делать сопоставление с конкретным значением. В примере ниже левая часть будет соответствовать правой только в том случае, если первым элементом кортежа будет атом :ok:


iex> {:ok, result} = {:ok, 13}
{:ok, 13}
iex> result
13

iex> {:ok, result} = {:error, :oops}
** (MatchError) no match of right hand side value: {:error, :oops}

Больше информации и примеров можно найти в документации, ссылка на которую есть выше.


Дополнительно пока стоит упомянуть только о наличии оператора-булавки (pin operator). Думаю, вы обратили внимание на то, что, в отличие от некоторых других функциональных языков, Elixir имеет локальные переменные, и их значения действительно могут меняться. Однако что же делать, если нам нужно провести операцию сопоставления со значением переменной в левой части выражения? Как раз тут и придёт на помощь pin operator:


iex> x = 1
1
iex> ^x = 2
** (MatchError) no match of right hand side value: 2
iex> {y, ^x} = {2, 1}
{2, 1}
iex> y
2
iex> {y, ^x} = {2, 2}
** (MatchError) no match of right hand side value: {2, 2}

Так как мы присвоили переменной x значение 1, последний пример можно переписать так:


iex> {y, 1} = {2, 2}
** (MatchError) no match of right hand side value: {2, 2}

Далее мы создаём набор изменений (changeset) с помощью функции AtvApi.Fos.changeset/2, которую для этого реализовали в модуле нашей модели таблицы справочника OECD FOS (точнее, сформировал её генератор, а мы поправили, добавив в списки полей :id). В результате выполнения этой функции будет получен результат с типом данных Ecto.Changeset.


Давайте остановимся на том, что такое 'набор измнений' `changeset` и зачем он нужен

Как можно понять из документации, модуль Ecto.Changeset позволяет произвести фильтрацию, преобразования, проверку на корректность (валидировать) и определить (описать) ограничения (constraints) при работе со структурами (а мы уже выяснили выше, что модели — это структуры). Результатом выполнения любой функции из модуля Ecto.Changeset будет являться «набор изменений», т.е. changeset. Для создания changeset обычно используются функция cast/3 и функция change/2. Первая служит для преобразования и проверки внешних параметров, таких, как данные, полученные из форм, через API, из коммандной строки и т.д., а вторая — для изменения данных непосредственно из приложения. Остальные функции модуля для валидации, проверки ограничений, управления ассоциациями (связями с другими моделями) служат для работы с наборами изменений.


Если мы посмотрим на функцию AtvApi.Fos.changeset/2, то увидим там ряд функций из модуля Ecto.Changeset, первая из которых — cast/3 — получает на вход структуру (struct), набор данных (params) и создаёт набор изменений, вставив из набора данных значения тех полей структуры, которые перечисленны в списке, переданном третьим параметром ([:id, :title]). Следующая функция проверяет, что набор изменений содержит значения для полей, пререданных вторым параметром (тот же список, что и выше). Результирующий набор изменений можно передать и далее, например, добавив проверку минимальной и длины поля :id:


|> validate_length(:id, min: 6)

Обратите внимание, что функции валидации не генерируют исключение, они добавляют описание ошибки в набор изменений и выставляют значение ключа valid? в false. Ошибка будет выдана при попытке выполнить операцию добавления или изменения записи.
Например:


iex> valid = AtvApi.Fos.changeset(%AtvApi.Fos{}, %{id: "123", title: "Some title"})
#Ecto.Changeset, valid?: true>
iex> invalid = valid |> Ecto.Changeset.validate_length(:id, min: 6)   
#Ecto.Changeset,
 valid?: false>
iex> AtvApi.Repo.insert!(invalid) # "опасная" функция, ошибка времени исполнения
** (Ecto.InvalidChangesetError) could not perform insert because changeset is invalid.

Applied changes

    %{id: "123", title: "Some title"}

Params

    %{"id" => "123", "title" => "Some title"}

Errors

    %{id: [{"should be at least %{count} character(s)",
        [count: 6, validation: :length, min: 6]}]}

Changeset

    #Ecto.Changeset,
     valid?: false>

    (ecto) lib/ecto/repo/schema.ex:134: Ecto.Repo.Schema.insert!/4
iex> AtvApi.Repo.insert(invalid)  # "безопасная" функция, возвращает кортеж {:error, description}
{:error,
 #Ecto.Changeset,
  valid?: false>}

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


Непосредственно добавить записи можно одну за другой с помощью функций insert* модуля Ecto.Repo. Кстати, этот модуль используется (use) в модуле AtvApi.Repo вместе с параметрами базы данных, поэтому фактически в приложении мы будем пользоваться вызовами вроде AtvApi.Repo.insert ..., но описание этих функций находится по ссылке выше. Так вот, мы могли бы прямо из Enum.reduce/3 добавлять записи по одной (только тогда мы воспользовались бы чуть другой функцией — Enum.each/2), однако что, если где-нибудь в середине процесса возникнет ошибка? В этом случае мы бы остались с неполноценными (неконсистентными) данными. Чтобы этого избежать, можно обернуть процесс в транзакцию. Для этого есть функция Ecto.Repo.transaction/2, которая в качестве первого параметра принимает либо функцию, которая как раз и будет обёрнута в транзакцию, либо структуру Ecto.Multi, аккумулирующую операции. Так как в данном случае нам не требуется особой логики, проще воспользоваться Ecto.Multi, что мы и делаем последней строкой анонимной функции, переданной в Enum.reduce/3. Так как из любой функции в Elixir возвращается результат последнего действия (выражения) в ней, она вернёт новую версию структуры Ecto.Multi, которая поступит [как аккумулятор] на вход очередного вызова

© Habrahabr.ru