
Эта статья является попыткой автора свести воедино в виде небольшого руководства несколько тем, с которыми, так или иначе, сталкиваются практически все разработчики веб-приложений, а именно – работа со статическими файлами, конфигурациями и доставкой приложений на сервер. На момент написания этого текста, последней стабильной веткой 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, потому, в случае его отсутствия, его надо создать самому с примерно следующий содержанием:
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:
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'а и дать какое-то более-менее цельное представление о жизненном цикле приложений, написанных на этом замечательном фреймворке. Очень много осталось за кадром, есть куча тем, достойная отдельных статей, например, способы доставки релизных пакетов на сервер и т.п.
Я, как автор, прекрасно понимаю, что могу ошибаться, потому заранее извиняюсь за ошибки или неточности и прошу писать о таковых в комментариях.
