Как реализовать на Эликсир JSON API endpoint без каких либо фреймворков?
От переводчика:
В статье приведён пример очень простого веб-приложения, которое можно рассматривать как Hello, World! в создании простейшего API на Эликсире.
Код примера незначительно изменён для того, чтобы соответствовать текущим версиям библиотек.
Полный код примера с изменениями можно увидеть на GitHub.
Проблемы нового языка
Многие разработчики приходят в Эликсир из мира Ruby. Это очень зрелая среда с точки зрения количества доступных библиотек и фреймворков. И такой зрелости мне иногда не хватает в Эликсире. Когда мне нужна сторонняя служба, результат поисков подходящей может быть следующим:
- есть официальная хорошо поддерживаемая библиотека (очень редко);
- есть официальная, но устаревшая или глючная библиотека (иногда случается);
- существует хорошо поддерживаемая библиотека, разработанная кем-то из сообщества (бывает время от времени);
- есть библиотека, разработанная кем-то из сообщества, но больше не поддерживаемая (очень частый случай);
- существует несколько библиотек, каждая из которых написана кем-то для собственных нужд, и в ней отсутствуют нужные возможности (самый популярный вариант);
- существует моя собственная библиотека, объединяющая все лучшее из вышеперечисленного… (встречается излишне часто).
Простое JSON API на Эликсире
Вы, возможно, удивитесь, но Ruby не всегда на рельсах (Ruby on Rails, помните? — прим. переводчика). Связь с веб тоже не всегда обязана присутствовать. Хотя в данном конкретном случае давайте поговорим именно о вебе.
Когда дело доходит до реализации одной конечной точки RESTful (single RESTful endpoint), обычно есть множество вариантов:
Это примеры инструментов, которыми я лично пользовался. Мои коллеги — довольные пользователи Sinatra. Они успели пробовать и Hanami. Я могу выбрать любой устраивающий меня вариант даже в зависимости от моего текущего настроения.
Но когда я переключился на Эликсир оказалось, что выбор ограничен. Хотя существует несколько альтернативных “фреймворков” (названия которых по очевидным причинам я не буду здесь упоминать), использовать их почти невозможно!
Я провел весь день, разбираясь с каждой библиотекой, когда-либо упоминавшейся в Интернете. Действуя как Slack-бот, я пытался развернуть на Heroku простой сервер HTTP2, но к концу дня сдался. Буквально ни один из вариантов, что я нашел, не смог реализовать базовые требования.
Не всегда решение — Phoenix
Phoenix — мой самый любимый веб-фреймворк, просто иногда он избыточен. Не хотелось его использовать, подтягивая в проект весь фреймворк исключительно ради одной конечной точки; и неважно, что сделать это очень просто.
Не смог я воспользоваться и готовыми библиотеками, поскольку, как уже сказал, все найденные либо не подошли для моих нужд (требовалась базовая маршрутизация и поддержка JSON), либо не были достаточно удобны для легкого и быстрого развертывания на Heroku. "Сделаем шаг назад", — подумал я.
Но вообще-то и сам Phoenix построен на базе чего-то, не так ли?
Plug & Cowboy приходят на помощь
Если необходимо создать на Ruby истинно минималистичный сервер, то можно просто воспользоваться rack
— модульным интерфейсом для веб-серверов на Ruby.
К счастью, нечто подобное доступно и в Эликсире. В данном случае мы воспользуемся следующими элементами:
- cowboy — небольшой и быстрый HTTP-сервер для Erlang/OTP, реализующий полный стек HTTP и маршрутизацию, оптимизированный для минимизации задержек и использования памяти;
- plug — набор адаптеров для различных веб-серверов, работающих в Erlang VM; каждый адаптер предоставляет прямой интерфейс к расположенному за ним веб-серверу;
- poison — библиотека для обработки JSON на Эликсире.
Реализация
Я хочу реализовать компоненты вроде Endpoint (конечная точка), Router (маршрутизатор) и JSON Parser (обработчик JSON). Затем я хотел бы развернуть получившееся на Heroku и иметь возможность обрабатывать входящие запросы. Посмотрим, как этого можно достичь.
Приложение
Убедитесь, что ваш проект на Эликсир содержит супервизор. Для этого проект нужно создать так:
mix new minimal_server --sup
Убедитесь, что mix.exs содержит:
def application do
[
extra_applications: [:logger],
mod: {MinimalServer.Application, []}
]
end
и создайте файл lib/minimal_server/application.ex
:
defmodule MinimalServer.Application do
use Application
def start(_type, _args),
do: Supervisor.start_link(children(), opts())
defp children do
[]
end
defp opts do
[
strategy: :one_for_one,
name: MinimalServer.Supervisor
]
end
end
Библиотеки
В mix.exs
необходимо указать следующие библиотеки:
defp deps do
[
{:poison, "~> 4.0"},
{:plug, "~> 1.7"},
{:cowboy, "~> 2.5"},
{:plug_cowboy, "~> 2.0"}
]
end
Затем скачайте и скомпилируйте зависимости:
mix do deps.get, deps.compile, compile
Endpoint
Теперь всё готово для создания точки входа на сервер. Давайте создадим файл lib/minimal_server/endpoint.ex
со следующим содержимым:
defmodule MinimalServer.Endpoint do
use Plug.Router
plug(:match)
plug(Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
json_decoder: Poison
)
plug(:dispatch)
match _ do
send_resp(conn, 404, "Requested page not found!")
end
end
Модуль Plug
содержит Plug.Router
для перенаправления входящих запросов в зависимости от использованного пути и HTTP-метода. При получении запроса маршрутизатор вызовет модуль :match
, представленный функцией match/2
, отвечающей за поиск соответствующего маршрута, а затем перенаправит его в модуль :dispatch
, который выполнит соответствующий код.
Поскольку мы хотим, чтобы наш API был JSON-совместимым, необходимо реализовать Plug.Parsers
. Так как он обрабатывает запросы application/json
с заданным :json_decoder
, воспользуемся им для анализа тела запроса.
В результате мы создали временный маршрут "любой запрос", который соответствует всем запросам и отвечает кодом HTTP not found (404).
Маршрутизатор
Реализация маршрутизатора будет последним шагом в создании нашего приложения. Это последний элемент всего конвейера, который мы создали: начиная с получения запроса от веб-браузера и заканчивая формированием ответа.
Маршрутизатор будет обрабатывать входящий запрос от клиента и отправлять назад какое-нибудь сообщение в нужном формате (добавьте приведённый код в файл lib/minimal_server/router.ex
— прим. переводчика):
defmodule MinimalServer.Router do
use Plug.Router
plug(:match)
plug(:dispatch)
get "/" do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Poison.encode!(message()))
end
defp message do
%{
response_type: "in_channel",
text: "Hello from BOT :)"
}
end
end
В приведённом выше модуле Router
запрос будет обработан только если он отправлен методом GET
и направлен по маршруту /
. Модуль Router ответит с заголовком Content-Type
, содержащим application/json
и телом:
{
"response_type": "in_channel",
"text": "Hello from BOT :)"
}
Соберём всё вместе
Теперь настало время изменить модуль Endpoint
для пересылки запросов маршрутизатору и доработать Application
для запуска самого модуля Endpoint
.
Первое можно сделать, добавив в MinimalServer.Endpoint
[перед правилом match _ do ... end
— прим. переводчика] строку
forward("/bot", to: MinimalServer.Router)
Это гарантирует, что все запросы к /bot
будут направлены в модуль Router
и обработаны им.
Второе можно реализовать, добавив в файл endpoint.ex
функции child_spec/1
и start_link/1
:
defmodule MinimalServer.Endpoint do
# ...
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]}
}
end
def start_link(_opts),
do: Plug.Cowboy.http(__MODULE__, [])
end
Теперь можно изменить application.ex
, добавив MinimalServer.Endpoint
в список, возвращаемый функцией children/0
.
defmodule MinimalServer.Application do
# ...
defp children do
[
MinimalServer.Endpoint
]
end
end
Чтобы запустить сервер, достаточно выполнить:
mix run --no-halt
Наконец-то вы можете посетить адрес http://localhost:4000/bot и увидеть наше сообщение :)
Развертывание
Конфиг
Чаще всего в локальной среде и для эксплуатации сервер настраивается по-разному. Поэтому нам нужно ввести отдельные настройки для каждого из этих режимов. Прежде всего изменим наш config.exs
, добавив:
config :minimal_server, MinimalServer.Endpoint, port: 4000
В этом случае при запуске приложения в режиме test
, prod
и dev
оно получит порт 4000, если эти настройки не изменить.
В этом месте автор оригинального текста забыл упомянуть, как доработать config.exs так, чтобы можно было использовать разные опции для разных режимов. Для этого необходимо в config/config.exs
последней строкой добавить import_config "#{Mix.env()}.exs"
; в результате получится что-то вроде:
use Mix.Config
config :minimal_server, MinimalServer.Endpoint, port: 4000
import_config "#{Mix.env()}.exs"
После этого в директории config
создать файлы prod.exs
, test.exs
, dev.exs
, поместив в каждый строку:
use Mix.Config
В продакшене мы обычно не хотим задавать номер порта жестко, а полагаемся на некоторую системную переменную окружающей среды, например:
config :minimal_server, MinimalServer.Endpoint,
port: "PORT" |> System.get_env() |> String.to_integer()
Добавьте текст выше в конец config/prod.exs
— прим. переводчика
После этого локально использоваться будет фиксированное значение, а в рабочей эксплуатации — конфигурация из переменных среды.
Давайте внедрим эту схему в endpoint.ex
, (заменив функцию start_link/1 — прим. переводчика):
defmodule MinimalServer.Endpoint do
# ...
require Logger
def start_link(_opts) do
with {:ok, [port: port] = config} <- Application.fetch_env(:minimal_server, __MODULE__) do
Logger.info("Starting server at http://localhost:#{port}/")
Plug.Adapters.Cowboy2.http(__MODULE__, [], config)
end
end
end
Heroku
Heroku предлагает наипростейшее развертывание "в один клик" без какой-либо сложной настройки. Чтобы развернуть наш проект нужно подготовить пару простых файлов и создать удалённое приложение.
После установки Heroku CLI можно создать новое приложение следующим образом:
$ heroku create minimal-server-habr
Creating ? minimal-server-habr... done
https://minimal-server-habr.herokuapp.com/ | https://git.heroku.com/minimal-server-habr.git
Теперь добавьте к своему приложению набор для сборки Эликсира:
heroku buildpacks:set https://github.com/HashNuke/heroku-buildpack-elixir.git
На момент создания этого перевода текущими версиями Elixir и Erlang являются (плюс-минус):
erlang_version=21.1
elixir_version=1.8.1
Чтобы настроить сам набор для сборки добавьте строки выше в файл elixir_buildpack.config
.
Последний шаг — создание Procfile, и, опять же, он очень прост:
web: mix run --no-halt
Примечание переводчика: чтобы избежать ошибки во время сборки на Heroku необходимо установить значение переменных окружения, которые используются в приложении:
$ heroku config:set PORT=4000
Setting PORT and restarting ? minimal-server-habr... done, v5
PORT: 4000
Как только вы закоммитите новые файлы [с помощью git — прим. переводчика], можно выгрузить их на Heroku:
$ git push heroku master
Initializing repository, done.
updating 'refs/heads/master'
...
И это все! Приложение доступно по адресу https://minimal-server-habr.herokuapp.com.
Резюме
К этому моменту вы уже поняли, как реализовать простейшее JSON RESTful API и HTTP-cервер на Эликсир без применения каких либо фреймворков, используя лишь 3 (4 — прим. переводчика) библиотеки.
Когда нужно обеспечить доступ к простым конечным точкам вам совершенно не нужно каждый раз использовать Phoenix, вне зависимости от того, насколько он клёвый, равно как и любой другой фреймворк.
Любопытно, почему отсутствуют надёжные, хорошо протестированные и поддерживаемые фреймворки где-то между plug
+ cowboy
и Phoenix? Может быть, нет реальной необходимости реализовывать простые вещи? Может быть, каждая компания использует свою библиотеку? Или, возможно, все используют либо Phoenix, либо представленный подход?
Репозиторий, как всегда, доступен на моем GitHub.