Phoenix Framework – Webpack вместо Brunch, деплой с помощью Distillery и немного systemd

в 22:02, , рубрики: deployment, Elixir, Elixir/Phoenix, erlang, npm, phoenix, phoenix framework, webpack, Разработка веб-сайтов

logo

Эта статья является попыткой автора свести воедино в виде небольшого руководства несколько тем, с которыми, так или иначе, сталкиваются практически все разработчики веб-приложений, а именно – работа со статическими файлами, конфигурациями и доставкой приложений на сервер. На момент написания этого текста, последней стабильной веткой Phoenix Framework была ветка 1.2.х.

Кому интересно, почему не Brunch и как совместить миграции с Distillery – прошу под кат.

Phoenix для работы с JS-кодом и ассетами по-умолчанию использует Brunch – возможно, очень простой и быстрый бандлер, но уж точно не самый распространенный и не самый мощный по возможностям и размеру экосистемы (и ответам на StackOverflow, конечно же). Поэтому и произошла замена Brunch на Webpack, де-факто – бандлер номер один в текущем мире фронт-энда.

А вот для решения вопросов деплоя, фреймворк не предоставляет практически ничего, кроме возможности подложить разные конфигурации для разных окружений. Судя по ответам разных разработчиков на форумах и прочих площадках, многие из них разворачивают свои приложения путем установки инструментов разработки прямо на боевом сервере и компилируя и запуская приложение с помощью Mix. По ряду причин, считаю такой подход неприемлемым, потому, перепробовав несколько вариантов упаковки приложения в self-contained пакет, я остановился на Distillery.

Т.к. статья является туториалом, то в качестве примера будет разработано абсолютно ненужное приложение, отображающее некий список неких пользователей. Весь код доступен на GitHub, каждый шаг зафиксирован в виде отдельного коммита, потому рекомендую смотреть историю изменений. Также, я буду давать ссылки на коммиты на определенных шагах, чтобы, с одной стороны, хорошо было видно по diff'у, какие изменения были сделаны, а с другой – чтобы не загромождать текст листингами.

Подготовка

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

$ mix phoenix.new userlist --no-brunch

Тут ничего интересного не происходит. Надо зайти внутрь нового проекта, поправить настройки базы данных в файле config/dev.exs, запустить создание репозитория Ecto и миграций (коммит):

$ mix ecto.create && mix ecto.migrate

Для того, чтобы сделать пример хоть немного нагляднее, я добавил модель сущности User, содержащую два поля – имя и бинарный признак, активен ли пользователь или нет (коммит):

$ mix phoenix.gen.model User users name active:boolean

Далее, чтобы наполнить БД хоть какими-то данными, я добавил три экземпляра "пользователей" в файл priv/repo/seeds.exs, который и служит для таких целей. После этого можно выполнить миграцию и вставить данные в БД:

$ mix ecto.migrate && mix run priv/repo/seeds.exs

Теперь у нас есть миграция в priv/repo/migrations/ – она нам пригодится в дальнейшем, а пока, надо еще добавить http API, по которому приложение сможет забрать список пользователей в формате JSON-объекта (коммит). Не буду загромождать текст листингами, diff на ГитХабе будет более нагляден, скажу лишь, что был добавлен контроллер, вью и изменен роутинг так, что у нас появилась "http-ручка" по пути /api/users, которая будет возвращать JSON с пользователями.

На этом все с приготовлениями, и на данном этапе приложение можно запустить командой

$ mix phoenix.server

и убедится, что все работает, как задумано.

Статические файлы и JS

Теперь обратим внимание на структуру каталогов проекта, а именно, на два из них – priv/static/ и web/static/. В первом из них уже лежат файлы, которые нужны для отображения фениксовской "Hello, World!" страницы, и именно этот каталог используется приложением, когда оно запущенно, для отдачи статических файлов. Второй каталог, web/static/, по-умолчанию задействован при разработке, и Brunch (в проектах с ним), грубо говоря, перекладывает файлы из него в priv/static, попутно обрабатывая их (статья в официальной документации об этом).

Оба вышеозначенных каталога находятся под управлением системы контроля версий, в оба из них можно добавлять файлы, вот только если вы добавите файлы сразу в priv/static/, то Brunch'ем они обработаны не будут, а если в web/static/, то будут, но если вы положите файл в web/static/assets/, то снова не будут… Мне кажется, что тут что-то пошло не так, потому я предлагаю более строгий подход, а именно:

  • содержимое каталога priv/static/ никогда не оказывается там в результате неких ручных действий, только в результате работы какого-то пайплайна. Более того, этот каталог выносится из VCS и добавляется в .gitignore;
  • каталог web/static/ содержит статические файлы, которые без изменений будут скопированы в /priv/static соответствующим пайплайном при компиляции, сборке релизного пакета и т.д.
  • все остальное, что должно оказаться в priv/static/ (js, например) лежит где-то в другом месте в дереве исходников и попадает в результирующий каталог только через соответствующий пайплайн бандлера.

Итак, следующим шагом я почистил priv/static от ненужных файлов, а robots.txt и favicon.ico перенес в web/static/ – вернемся к ним позже. Также, почистил html разметку главной страницы и ее шаблона (коммит).

Перед тем, как добавлять Webpack, надо инициализировать сам NPM:

$ npm init

Получившийся package.json я почистил, оставив в нем только самое главное (коммит):

{
  "name": "userlist",
  "version": "1.0.0",
  "description": "Phoenix example application",
  "scripts": {
  },
  "license": "MIT"
}

И после этого добавляем сам Webpack (коммит):

$ npm install --save-dev webpack

Теперь давайте добавим какой-то минимально возможный JS код к проекту, например, такой:

console.log("App js loaded.");

Для JS-файлов я создал каталог web/js/, куда и положил файл app.js с кодом выше. Подключим его в шаблоне web/templates/layout/app.html.eex, вставив перед закрывающим тегом </body>:

<script src="<%= static_path(@conn, "/js/app.js") %>"></script>

Очень важно использовать макрос static_path, иначе вы потеряете возможность загружать ресурсы с digest-меткой, что приведет к проблемам с инвалидацией кешей у клиентов и вообще, так не по правилам.

Создаем конфигурацию Webpack'а – файл webpack.config.js в корне проекта:

module.exports = {
  entry: __dirname + "/web/js/app.js",
  output: {
    path: __dirname + "/priv/static",
    filename: "js/app.js"
  }
};

Из кода видно, что результирующий файл app.js будет находится в каталоге priv/static/js/ как и задумывалось. На данном этапе можно запустить Webpack вручную, но это не очень удобно, так что добавим автоматизации, благо фреймворк это позволяет. Первое, что надо сделать, это добавить шорткат watch в секцию scripts файла package.json:

"scripts": {
  "watch": "webpack --watch-stdin --progress --color"
},

Теперь Webpack можно запускать командой

$ npm run watch

Но и этого делать не надо, пускай этим занимается Phoenix, тем более, что у эндпоинта вашего приложения есть опция watchers, как раз и предназначенная для запуска подобных внешних утилит. Изменим файл config/dev.exs, добавив вызов npm:

watchers: [npm: ["run", "watch"]]

После этого, Webpack в режиме слежения за изменениями в каталогах и файлах будет запускаться каждый раз вместе с основным приложением командой

$ mix phoenix.server

Коммит со всеми вышеозначенными изменениями тут.

C JS кодом немного разобрались, но еще остаются файлы в web/static/. Задачу по их копированию я тоже возложил на Webpack, добавив в него расширение copy:

$ npm install --save-dev copy-webpack-plugin

Сконфигурируем плагин в в файле webpack.config.js(коммит):

var CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  entry: __dirname + "/web/js/app.js",
  output: {
    path: __dirname + "/priv/static",
    filename: "js/app.js"
  },
  plugins: [
    new CopyWebpackPlugin([{ from: __dirname + "/web/static" }])
  ]
};

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

Может возникнуть вопрос – что делать, если надо производить пред-обработку, например, CSS. Ответ банален – выносить CSS в отдельный каталог, добавлять в Webpack соответствующие плагины и настраивать пайплайн, аналогичный используемому для JS. Либо использовать css-loader'ы, но это отдельная история.

Сборка релизного пакета. Distillery.

Distillery это второй заход автора Exrm в попытке сделать хороший инструмент для пакетирования и создания релизных пакетов для проектов на Elixir. Ошибки первого были учтены, многое исправлено, пользоваться Distillery удобно. Добавим его в проект, указав как зависимость в mix.exs:

{:distillery, "~> 1.4"}

Обновим зависимости и создадим шаблон релизной конфигурации (коммит):

$  mix deps.get && mix release.init

Последняя команда создаст файл rel/config.exs примерно такого содержания:

Много кода

Path.join(["rel", "plugins", "*.exs"])
|> Path.wildcard()
|> Enum.map(&Code.eval_file(&1))

use Mix.Releases.Config,
    # This sets the default release built by `mix release`
    default_release: :default,
    # This sets the default environment used by `mix release`
    default_environment: Mix.env()

environment :dev do
  set dev_mode: true
  set include_erts: false
  set cookie: :"Mp@oK==RSu$@QW.`F9(oYks&xDCzAWCpS*?jkSC?Zo{p5m9Qq!pKD8!;Cl~gTC?k"
end

environment :prod do
  set include_erts: true
  set include_src: false
  set cookie: :"/s[5Vq9hW(*IA>grelN4p*NjBHTH~[gfl;vD;:kc}qAShL$MtAI1es!VzyYFcC%p"
end

release :userlist do
  set version: current_version(:userlist)
  set applications: [
    :runtime_tools
  ]
end

Предлагаю оставить его пока таким, как он есть. Указанного в конфигурации вполне достаточно: один релиз :userlist, он же :default, т.к. первый и единственный в списке релизов, а так же два окружения :dev и :prod. Под релизом здесь понимается OTP Release – набор приложений, который войдет в результирующий пакет, версию ERTS. В данном случае, наш релиз соответствует приложению :userlist, чего нам достаточно. Но, мы можем иметь несколько релизов и несколько окружений и комбинировать их по необходимости.

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

Подготовим приложение к релизу. В первую очередь, надо отредактировать файл config/prod.secret.exs, поправим в нем настройки БД. Этот файл не добавляется в VCS, потому, в случае его отсутствия, его надо создать самому с примерно следующий содержанием:

prod.secret.exs

use Mix.Config

config :userlist, Userlist.Endpoint,
  secret_key_base: "uE1oi7t7E/mH1OWo/vpYf0JLqwnBa7bTztVPZvEarv9VTbPMALRnqXKykzaESfMo"

# Configure your database
config :userlist, Userlist.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: "phoenix",
  password: "",
  database: "userlist_prod",
  pool_size: 20

Следующим важным этапом будет поправить конфигурацию Userlist.Endpoint в файле config/prod.exs. Прежде всего, заменить хост на нужный, а порт с 80 на читаемый из окружения параметр PORT и добавить важнейшую опцию server, которая является признаком того, что именно этот эндпоинт запустит Cowboy:

url: [host: "localhost", port: {:system, "PORT"}],
...
server: true

Далее, я добавил Babel к пайплайну обработки JS кода, т.к. UglifyJS, используемый по-умолчанию в Webpack, не обучен обращению с ES6:

$ npm install --save-dev babel-loader babel-core babel-preset-es2015

И секция настройки Babel в webpack.config.js после plugins:

webpack.config.js

module: {
  loaders: [
    {
      test: /.js$/,
      exclude: /node_modules/,
      loader: "babel-loader",
      query: {
        presets: ["es2015"]
      }
    }
  ]
}

И последнее – добавляем шорткат deploy в конфигурацию NPM (коммит):

"scripts": {
  "watch": "webpack --watch-stdin --progress --color",
  "deploy": "webpack -p"
},

На данном этапе можно попробовать собрать и запустить релиз:

$ npm run deploy
$ MIX_ENV=prod mix phoenix.digest
$ MIX_ENV=prod mix release
$ PORT=8080 _build/prod/rel/userlist/bin/userlist console

Первой командой мы подготавливаем JS (минификация и т.п.), копируем static-файлы; вторая генерирует для всех файлов дайджест; третья непосредственно собирает релиз для соответствующего окружения. Ну и в конце – запуск приложения в интерактивном режиме, с консолью.

После релиза в каталоге _build будет находится распакованная (exploded) версия пакета, а архив будет лежать по пути _build/prod/rel/userlist/releases/0.0.1/userlist.tar.gz.

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

Миграции

После сборки, исполняемый файл приложения предоставляет нам одну из опций, которая называется command:

command <mod> <fun> [<args..>] # execute the given MFA

Это очень похоже на rpc, с разницей в том, что command выполнится и на не запущенном приложении – что нам и надо. Создадим модуль с функцией миграции, помня о том, что приложение запущенно не будет. Я разместил этот файл по пути lib/userlist/release_tasks.ex (коммит):

defmodule Release.Tasks do
  alias Userlist.Repo

  def migrate do
    Application.load(:userlist)
    {:ok, _} = Application.ensure_all_started(:ecto)
    {:ok, _} = Repo.__adapter__.ensure_all_started(Repo, :temporary)
    {:ok, _} = Repo.start_link(pool_size: 1)

    path = Application.app_dir(:userlist, "priv/repo/migrations")

    Ecto.Migrator.run(Repo, path, :up, all: true)

    :init.stop()
  end
end

Как видно из кода, мы загружаем, а потом запускаем не все приложения, а ровно необходимые – в данном случае, это только Ecto. Теперь все, что осталось, это пересобрать релиз (только Elixir, т.к. остальное не менялось):

$ MIX_ENV=prod mix release

запустить миграции:

$ _build/prod/rel/userlist/bin/userlist command 'Elixir.Release.Tasks' migrate

и запустить приложение:

$ PORT=8080 _build/prod/rel/userlist/bin/userlist console

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

Хуки и команды Distillery

Концепция хуков и команд проста – это обычные shell-скрипты, которые вызываются на определенном этапе жизни приложения (хуки), либо вручную (команды) и которые являются расширением главного исполняемого boot-скрипта. Хуки могут быть четырех видов: pre/post_start и pre/post_stop.

Я добавил пример двух хуков в проект, смотрите код, он лучше всего объяснит, как это сделать.

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

$ _build/prod/rel/userlist/bin/userlist migrate

Если вам нужен manifest.json

При сборке релиза, после выполнения команды phoenix.digest, все статические файлы получают хеш-сумму в свое имя (плюс добавляются сжатые версии), и генерируется таблица соответствия между исходным именем файла и новым, которая находится в файле priv/static/manifest.json, если вы не меняли его положение в конфигурации. Если вдруг вам понадобится информация из него во время выполнения приложения, то у вас два варианта:

  • добавить его в список файлов, которые отдаются из каталога со статикой в lib/userlist/endpoint.ex:

    only: ~w(css fonts images js favicon.ico robots.txt manifest.json)

    после чего, его можно будет забрать Ajax'ом, например;

  • если он нужен на бекенде, или если вы хотите рендерить его в шаблоне (я не знаю, зачем, но вдруг надо), то можно расширить LayoutView до такого:

    defmodule Userlist.LayoutView do
    use Userlist.Web, :view
    
    def digest do
      manifest =
        Application.get_env(:userlist, Userlist.Endpoint, %{})[:cache_static_manifest]
        || "priv/static/manifest.json"
    
      manifest_file = Application.app_dir(:userlist, manifest)
    
      if File.exists?(manifest_file) do
        manifest_file
        |> File.read!
      else
        %{}
      end
    end
    end

    чтобы потом, где-то в шаблоне, написать следующее:

    <script>
    var digest = <%= raw digest() %>
    </script>

Коммит с эти безумием тут.

systemd

Последнее, о чем хотелось бы упомянуть, это запуск приложения на боевом сервере. С тех пор, как у нас появился systemd, написание init-скриптов не то, что улучшилось, а стало просто элементарным.

Допустим, что мы будем разворачивать архив с приложением в /opt/userlist/ и запускать от имени пользователя userlist. Создадим файл userlist.service следующего содержания (коммит):

# Userlis is a Phoenix, Webpack and Distillery demo application

[Unit]
Description=Userlist application
After=network.target

[Service]
Type=simple
User=userlist
RemainAfterExit=yes
Environment=PORT=8080
WorkingDirectory=/opt/userlist
ExecStart=/opt/userlist/bin/userlist start
ExecStop=/opt/userlist/bin/userlist stop
Restart=on-failure
TimeoutSec=300

[Install]
WantedBy=multi-user.target

После чего, все, что надо сделать, это скопировать его в /etc/systemd/system/:

$ sudo cp userlist.service /etc/systemd/system

Включить в "автозагрузку":

$ sudo systemctl enable userlist.service

И запустить приложение:

$ sudo systemctl start userlist

Заключение

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

Я, как автор, прекрасно понимаю, что могу ошибаться, потому заранее извиняюсь за ошибки или неточности и прошу писать о таковых в комментариях.

Автор: Дмитрий Стропалов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js