Как реализовать на Эликсир 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.

Комментарии (0)