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