[Перевод] Простейший JSON RESTful API на Эликсир

Как реализовать на Эликсир JSON API endpoint без каких либо фреймворков?

От переводчика:
В статье приведён пример очень простого веб-приложения, которое можно рассматривать как Hello, World! в создании простейшего API на Эликсире.
Код примера незначительно изменён для того, чтобы соответствовать текущим версиям библиотек.
Полный код примера с изменениями можно увидеть на GitHub.

4uezx7-azvbu2fk6bbbkj1veqdw.jpeg


Проблемы нового языка

Многие разработчики приходят в Эликсир из мира Ruby. Это очень зрелая среда с точки зрения количества доступных библиотек и фреймворков. И такой зрелости мне иногда не хватает в Эликсире. Когда мне нужна сторонняя служба, результат поисков подходящей может быть следующим:


  • есть официальная хорошо поддерживаемая библиотека (очень редко);
  • есть официальная, но устаревшая или глючная библиотека (иногда случается);
  • существует хорошо поддерживаемая библиотека, разработанная кем-то из сообщества (бывает время от времени);
  • есть библиотека, разработанная кем-то из сообщества, но больше не поддерживаемая (очень частый случай);
  • существует несколько библиотек, каждая из которых написана кем-то для собственных нужд, и в ней отсутствуют нужные возможности (самый популярный вариант);
  • существует моя собственная библиотека, объединяющая все лучшее из вышеперечисленного… (встречается излишне часто).


Простое JSON API на Эликсире

3k3b7ugydkdw_7bcsz_hk3-ytdq.jpeg

Вы, возможно, удивитесь, но Ruby не всегда на рельсах (Ruby on Rails, помните? — прим. переводчика). Связь с веб тоже не всегда обязана присутствовать. Хотя в данном конкретном случае давайте поговорим именно о вебе.

Когда дело доходит до реализации одной конечной точки RESTful (single RESTful endpoint), обычно есть множество вариантов:

Это примеры инструментов, которыми я лично пользовался. Мои коллеги — довольные пользователи Sinatra. Они успели пробовать и Hanami. Я могу выбрать любой устраивающий меня вариант даже в зависимости от моего текущего настроения.

Но когда я переключился на Эликсир оказалось, что выбор ограничен. Хотя существует несколько альтернативных «фреймворков» (названия которых по очевидным причинам я не буду здесь упоминать), использовать их почти невозможно!

Я провел весь день, разбираясь с каждой библиотекой, когда-либо упоминавшейся в Интернете. Действуя как Slack-бот, я пытался развернуть на Heroku простой сервер HTTP2, но к концу дня сдался. Буквально ни один из вариантов, что я нашел, не смог реализовать базовые требования.


Не всегда решение — Phoenix

Phoenix — мой самый любимый веб-фреймворк, просто иногда он избыточен. Не хотелось его использовать, подтягивая в проект весь фреймворк исключительно ради одной конечной точки; и неважно, что сделать это очень просто.

Не смог я воспользоваться и готовыми библиотеками, поскольку, как уже сказал, все найденные либо не подошли для моих нужд (требовалась базовая маршрутизация и поддержка JSON), либо не были достаточно удобны для легкого и быстрого развертывания на Heroku. «Сделаем шаг назад», — подумал я.

jfqgylkj7urywy09ic2tqtmlnic.jpeg

Но вообще-то и сам 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 и увидеть наше сообщение :)

kf8jcbh1srxmbcpbxl_9yhnkckg.jpeg


Конфиг

Чаще всего в локальной среде и для эксплуатации сервер настраивается по-разному. Поэтому нам нужно ввести отдельные настройки для каждого из этих режимов. Прежде всего изменим наш 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 предлагает наипростейшее развертывание «в один клик» без какой-либо сложной настройки. Чтобы развернуть наш проект нужно подготовить пару простых файлов и создать удалённое приложение.

rox_dhu_pwqqvdetmn-hwtjasb0.png

После установки 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, либо представленный подход?

lrbbw-w-ulfd0eatm5yabyl-sma.jpeg

Репозиторий, как всегда, доступен на моем GitHub.

© Habrahabr.ru