Продолжаем рассматривать технологии Julia. И сегодня речь пойдёт о пакетах, предназначенных для построения веб-сервисов. Не секрет, что основная ниша языка Julia — высокопроизводительные вычисления. Поэтому, довольно логичным шагом является непосредственное создание веб-сервисов, способных выполнять эти вычисления по запросу. Безусловно, веб-сервисы — не единственный способ коммуникации в сетевой среде. Но, поскольку, именно они сейчас наиболее широко используются в распределённых системах, то рассмотрим именно создание сервисов, обслуживающих HTTP-запросы.
Отметим, что в силу молодости Julia, имеется набор конкурирующих пакетов. Поэтому попробуем разобраться как и для чего их использовать. Попутно сравним реализации одного и того же JSON-веб-сервиса с их помощью.
Инфраструктура Julia активно развивается в последние год-два. И, в данном случае, это не просто дежурная фраза, вписанная для красивого начала текста, а акцентирование того факта, что всё интенсивно меняется, а то, что было актуальным пару лет назад, сейчас уже устарело. Однако, попытаемся выделить стабильные пакеты и дать рекомендации по тому, как реализовать с их помощью веб-сервисы. Для определённости, будем создавать веб-сервис, принимающий POST-запрос с JSON-данными следующего формата:
{ "title": "something", "body": "something" }
Будем считать, что создаваемый нами сервис не является RESTful. Основная наша задача — рассмотреть именно способы описания маршрутов и обработчиков запросов.
Пакет HTTP.jl
Этот пакет является основной реализацией протокола HTTP в Julia и постепенно обрастает новыми функциями. Помимо реализации типовых структур и функций для выполнения клиентских HTTP-запросов, этот пакет реализует функции для создания серверов HTTP. При этом, по мере развития, пакет получил функции, позволяющие довольно комфортно для программиста регистрировать обработчики и, таким образом, строить типовые сервисы. Также, в последних версиях, появилась встроенная поддержка протокола WebSocket, реализация которого ранее была сделана в рамках отдельного пакета WebSocket.jl. То есть, HTTP.jl, в настоящее время, может удовлетворить большинство потребностей программиста. Рассмотрим пару примеров более подробно.
Клиент HTTP
Начнём реализацию с кода клиента, который будем использовать для проверки работоспособности.
#!/usr/bin/env julia --project=@.
import HTTP
import JSON.json
const PORT = "8080"
const HOST = "127.0.0.1"
const NAME = "Jemand"
# Зададим формат документа
struct Document
title::String
body::String
end
# Метод для печати тела отклика или кода ошибки
Base.show(r::HTTP.Messages.Response) =
println(r.status == 200 ? String(r.body) : "Error: " * r.status)
# запрашиваем корневой маршрут
r = HTTP.get("http://$(HOST):$(PORT)")
show(r)
# запрашиваем маршрут /user/:name
r = HTTP.get("http://$(HOST):$(PORT)/user/$(NAME)"; verbose=1)
show(r)
# отправляем JSON-документ POST-запросом
doc = Document("Some document", "Test document with some content.")
r = HTTP.post(
"http://$(HOST):$(PORT)/resource/process",
[("Content-Type" => "application/json")],
json(doc);
verbose=3)
show(r)
Пакет HTTP предоставляет методы, соответствующие именам команд протокола HTTP. В данном случае используем get
и post
. Опциональный именованный аргумент verbose
позволяет установить объем выводимой отладочной информации. Так, например, verbose=1
выдаст:
GET /user/Jemand HTTP/1.1
HTTP/1.1 200 OK <= (GET /user/Jemand HTTP/1.1)
А в случае verbose=3
мы уже получим полный набор переданных и принятых данных:
DEBUG: 2019-04-21T22:40:40.961 eb4f ️-> "POST /resource/process HTTP/1.1\r\n" (write)
DEBUG: 2019-04-21T22:40:40.961 eb4f ️-> "Content-Type: application/json\r\n" (write)
DEBUG: 2019-04-21T22:40:40.961 eb4f ️-> "Host: 127.0.0.1\r\n" (write)
DEBUG: 2019-04-21T22:40:40.961 eb4f ️-> "Content-Length: 67\r\n" (write)
DEBUG: 2019-04-21T22:40:40.961 eb4f ️-> "\r\n" (write)
DEBUG: 2019-04-21T22:40:40.961 e1c6 ️-> "{\"title\":\"Some document\",\"body\":\"Test document with some content.\"}" (unsafe_write)
DEBUG: 2019-04-21T22:40:40.963 eb4f ️<- "HTTP/1.1 200 OK\r\n" (readuntil)
DEBUG: "Content-Type: application/json\r\n"
DEBUG: "Transfer-Encoding: chunked\r\n"
DEBUG: "\r\n"
DEBUG: 2019-04-21T22:40:40.963 eb4f ️<- "5d\r\n" (readuntil)
DEBUG: 2019-04-21T22:40:40.963 eb4f ️<- "{\"body\":\"Test document with some content.\",\"server_mark\":\"confirmed\",\"title\":\"Some document\"}" (unsafe_read)
DEBUG: 2019-04-21T22:40:40.968 eb4f ️<- "\r\n" (readuntil)
DEBUG: "0\r\n"
DEBUG: 2019-04-21T22:40:40.968 eb4f ️<- "\r\n" (readuntil)
В дальнейшем, будем пользоваться только verbose=1
с тем, чтобы видеть только минимальную информацию о том, что происходит.
Несколько комментариев относительно кода.
doc = Document("Some document", "Test document with some content.")
Так как ранее мы объявили структуру Document (причём, неизменяемую), то для неё по-умолчанию доступен конструктор, аргументы которого соответствуют задекларированным полям структуры. Для того, чтобы преобразовать её в JSON, пользуемся пакетом JSON.jl
и его методом json(doc)
.
Обратите внимание на фрагмент:
r = HTTP.post(
"http://$(HOST):$(PORT)/resource/process",
[("Content-Type" => "application/json")],
json(doc);
verbose=3)
Поскольку мы передаём JSON, необходимо явно указать в заголовке Content-Type
тип application/json
. Заголовки передаются в метод HTTP.post
(впрочем, как и во все другие) при помощи массива (тип Vector, но не Dict), содержащего пары имя заголовка — значение.
Для теста работоспособности будем выполнять три запроса:
- GET-запрос к корневому маршруту;
- GET-запрос в формате /user/name, где name — передаваемое имя;
- POST-запрос /resource/process с передачей JSON-объекта. Ожидаем получение того же документа, но с добавленным полем
server_mark
.
Этот клиентский код будем использовать для тестирования всех вариантов реализации сервера.
Сервер HTTP
После того, как разобрались с клиентом, пора приступить к реализации сервера. Для начала, сделаем сервис только с помощью HTTP.jl
, чтобы держать его как базовый вариант, не требующий установки других пакетов. Напоминаем, что все остальные пакеты в любом случае используют HTTP.jl
#!/usr/bin/env julia --project=@.
import Sockets
import HTTP
import JSON
# декларируем обработчики маршрутов
# выдаём строку приветствия
index(req::HTTP.Request) =
HTTP.Response(200, "Hello World")
# выдаём приветствие конкретного пользователя
function welcome_user(req::HTTP.Request)
# dump(req)
user = ""
if (m = match( r".*/user/([[:alpha:]]+)", req.target)) != nothing
user = m[1]
end
return HTTP.Response(200, "Hello " * user)
end
# обрабатываем JSON
function process_resource(req::HTTP.Request)
# dump(req)
message = JSON.parse(String(req.body))
@info message
message["server_mark"] = "confirmed"
return HTTP.Response(200, JSON.json(message))
end
# Регистрируем маршруты и их обработчики
const ROUTER = HTTP.Router()
HTTP.@register(ROUTER, "GET", "/", index)
HTTP.@register(ROUTER, "GET", "/user/*", welcome_user)
HTTP.@register(ROUTER, "POST", "/resource/process", process_resource)
HTTP.serve(ROUTER, Sockets.localhost, 8080)
В примере следует обратить внимание на следующий код:
dump(req)
распечатывает в консоль всё, что известно по объекту. Включая типы данных, значения, а также все вложенные поля и их значения. Этот метод полезен как для исследования библиотек, так и для отладки.
Строка
(m = match( r".*/user/([[:alpha:]]+)", req.target))
представляет собой регулярное выражение, разбирающее маршрут, на который зарегистрирован обработчик. Автоматических способов выявления шаблона в маршруте пакет HTTP.jl
не предоставляет.
Внутри обработчика process_resource
у нас происходит разбор JSON, который принят сервисом.
message = JSON.parse(String(req.body))
Доступ к данным выполняется через поле req.body
. Обратите внимание на то, что данные приходят в формате массива байт. Поэтому для работы с ними как со строкой, выполняется явное преобразование в строку. Метод JSON.parse
— это метод пакета JSON.jl
, который выполняет десериализацию данных и строит объект. Поскольку объектом в данном случае будет ассоциативный массив (Dict), то можем легко добавить ему новый ключ. Строка
message["server_mark"] = "confirmed"
добавляет ключ server_mark
со значением confirmed
.
Запуск сервиса происходит при выполнении строки HTTP.serve(ROUTER, Sockets.localhost, 8080)
.
Контрольный отклик для сервиса на базе HTTP.jl (получено при запуске клиентского кода с verbose=1
):
GET / HTTP/1.1
HTTP/1.1 200 OK <= (GET / HTTP/1.1)
Hello World
GET /user/Jemand HTTP/1.1
HTTP/1.1 200 OK <= (GET /user/Jemand HTTP/1.1)
Hello Jemand
POST /resource/process HTTP/1.1
HTTP/1.1 200 OK <= (POST /resource/process HTTP/1.1)
{"body":"Test document with some content.","server_mark":"confirmed","title":"Some document"}
На фоне отладочной информации с verbose=1
, явно видим строки: Hello World
, Hello Jemand
, "server_mark":"confirmed"
.
После просмотра кода сервиса, возникает естественный вопрос — зачем нужны все остальные пакеты, если всё так просто в HTTP. На это есть очень простой ответ. HTTP — позволяет регистрировать динамические обработчики, но, даже элементарная реализация чтения статического файла картинки из директории, требует отдельно реализации. Поэтому также рассмотрим пакеты, которые ориентированы на создание веб-приложений.
Пакет Mux.jl
Этот пакет позиционируется как промежуточный слой для веб-приложений, реализуемых на Julia. Его реализация весьма легковесна. Основное назначение — предоставить простой способ описания обработчиков. Нельзя сказать, что проект не развивается, но развивается он медленно. Однако, посмотрим на код нашего сервиса, обслуживающего те же маршруты.
#!/usr/bin/env julia --project=@.
using Mux
using JSON
@app test = (
Mux.defaults,
page(respond("<h1>Hello World!</h1>")),
page("/user/:user", req -> "<h1>Hello, $(req[:params][:user])!</h1>"),
route("/resource/process", req -> begin
message = JSON.parse(String(req[:data]))
@info message
message["server_mark"] = "confirmed"
return Dict(
:body => JSON.json(message),
:headers => [("Content-Type" => "application/json")]
)
end),
Mux.notfound()
)
serve(test, 8080)
Base.JLOptions().isinteractive == 0 && wait()
Здесь маршруты описываются при помощи метода page
. Веб-приложение декларируется при помощи макроса @app
. Аргументы метода page
— маршрут и обработчик. Обработчик может быть задан как функция, принимающая на вход запрос, так и указан как лямбда-функция по месту. Из дополнительных полезных функций присутствует Mux.notfound()
для отправки заданного отклика Not found
. А результат, который следует отправить клиенту, не надо упаковывать в HTTP.Response
, как мы это делали в предыдущем примере, поскольку Mux сделает это сам. Однако разбор JSON всё равно приходится делать самим, как и сериализацию объекта для ответа — JSON.json(message)
.
message = JSON.parse(String(req[:data]))
message["server_mark"] = "confirmed"
return Dict(
:body => JSON.json(message),
:headers => [("Content-Type" => "application/json")]
)
Ответ отправляем как ассоциативный массив с полями :body
, :headers
.
Запуск сервера методом serve(test, 8080)
является асинхронным, поэтому один из вариантов в Julia организовать ожидание завершения — вызвать код:
Base.JLOptions().isinteractive == 0 && wait()
В остальном, сервис делает то же, что и предыдущий вариант на HTTP.jl
.
Контрольный отклик для сервиса:
GET / HTTP/1.1
HTTP/1.1 200 OK <= (GET / HTTP/1.1)
<h1>Hello World!</h1>
GET /user/Jemand HTTP/1.1
HTTP/1.1 200 OK <= (GET /user/Jemand HTTP/1.1)
<h1>Hello, Jemand!</h1>
POST /resource/process HTTP/1.1
HTTP/1.1 200 OK <= (POST /resource/process HTTP/1.1)
{"body":"Test document with some content.","server_mark":"confirmed","title":"Some document"}
Пакет Bukdu.jl
Пакет разработан под влиянием Phoenix framework, который, в свою очередь, реализован на Elixir и является реализацией идей веб-строительства от Ruby-сообщества в проекции на Elixir. Проект развивается довольно активно и позиционируется как средство для создания RESTful API и легковесных веб-приложений. Имеются функции, упрощающие JSON сериализацию и десериализация. Этого не хватает в HTTP.jl
и Mux.jl
. Посмотрим на реализацию нашего веб-сервиса.
#!/usr/bin/env julia --project=@.
using Bukdu
using JSON
# декларируем контроллер
struct WelcomeController <: ApplicationController
conn::Conn
end
# методы контроллера
index(c::WelcomeController) =
render(JSON, "Hello World")
welcome_user(c::WelcomeController) =
render(JSON, "Hello " * c.params.user)
function process_resource(c::WelcomeController)
message = JSON.parse(String(c.conn.request.body))
@info message
message["server_mark"] = "confirmed"
render(JSON, message)
end
# декларируем маршруты
routes() do
get("/", WelcomeController, index)
get("/user/:user", WelcomeController, welcome_user, :user => String)
post("/resource/process", WelcomeController, process_resource)
end
# запускаем сервер
Bukdu.start(8080)
Base.JLOptions().isinteractive == 0 && wait()
Первое, на что следует обратить внимание — декларация структуры для хранения состояния контроллера.
struct WelcomeController <: ApplicationController
conn::Conn
end
В данном случае, она является конкретным типом, созданным как потомок абстрактного типа ApplicationController
.
Методы для контроллера декларируются сходным образом по отношению к предыдущим реализациям. Небольшое различие есть в обработчике нашего JSON-объекта.
function process_resource(c::WelcomeController)
message = JSON.parse(String(c.conn.request.body))
@info message
message["server_mark"] = "confirmed"
render(JSON, message)
end
Как видим, десериализация выполняется также самостоятельно при помощи метода JSON.parse
, а вот для сериализации отклика используется метод render(JSON, message)
, предоставляемый Bukdu.
Декларация маршрутов осуществляется в традиционном для рубистов стиле, включая использование блока do...end
.
routes() do
get("/", WelcomeController, index)
get("/user/:user", WelcomeController, welcome_user, :user => String)
post("/resource/process", WelcomeController, process_resource)
end
Также, традиционным для рубистов способом, декларируется сегмент в строке маршрута /user/:user
. Иными словами — переменная часть выражения, доступ к которой может быть выполнен по указанному в шаблоне имени. Синтаксически обозначается как представитель типа Symbol
. К слову, для Julia тип Symbol
означает, в сущности, то же, что и для Ruby — это неизменяемая строка, представленная в памяти единственным экземпляром.
Соответственно, после того, как мы задекларировали маршрут с переменной частью, а также указали тип этой переменной части, можем обратиться по назначенному имени к уже разобранным данным. В методе, осуществляющем обработку запроса, просто обращаемся к полю через точку в форме c.params.user
.
welcome_user(c::WelcomeController) =
render(JSON, "Hello " * c.params.user)
Контрольный отклик для сервиса:
GET / HTTP/1.1
HTTP/1.1 200 OK <= (GET / HTTP/1.1)
"Hello World"
GET /user/Jemand HTTP/1.1
HTTP/1.1 200 OK <= (GET /user/Jemand HTTP/1.1)
"Hello Jemand"
POST /resource/process HTTP/1.1
HTTP/1.1 200 OK <= (POST /resource/process HTTP/1.1)
{"body":"Test document with some content.","server_mark":"confirmed","title":"Some document"}
Вывод сервиса в консоль:
>./bukdu_json.jl
INFO: Bukdu Listening on 127.0.0.1:8080
INFO: GET WelcomeController index 200 /
INFO: GET WelcomeController welcome_user 200 /user/Jemand
INFO: Dict{String,Any}("body"=>"Test document with some content.","title"=>"Some document")
INFO: POST WelcomeController process_resource200 /resource/process
Пакет Genie.jl
Амбициозный проект, позиционируемый как MVC web framework. В его подходе довольно явно просматриваются «Рельсы» на Julia, включая структуру директорий, создаваемую генератором. Проект развивается, однако по неизвестным причинам, этот пакет не входит в репозиторий пакетов Julia. То есть его установка возможна лишь из git-репозитория командой:
julia>] # switch to pkg> mode
pkg> add https://github.com/essenciary/Genie.jl
Код нашего сервиса в Genie выглядит следующим образом (генераторами не пользуемся):
#!/usr/bin/env julia --project=@.
# импортируем методы и макросы
import Genie
import Genie.Router: route, @params, POST
import Genie.Requests: jsonpayload, rawpayload
import Genie.Renderer: json!
# декларируем маршруты и код обработчиков
route("/") do
"Hello World!"
end
route("/user/:user") do
"Hello " * @params(:user)
end
route("/resource/process", method = POST) do
message = jsonpayload()
# if message == nothing
# dump(Genie.Requests.rawpayload())
# end
message["server_mark"] = "confirmed"
return message |> json!
end
# запускаем сервер
Genie.AppServer.startup(8080)
Base.JLOptions().isinteractive == 0 && wait()
Здесь следует обратить внимание на формат декларации.
route("/") do
"Hello World!"
end
Этот код весьма привычен для Ruby-программистов. Блок do...end
как обработчик и маршрут в качестве аргумента метода. Отметим, что для Julia этот код может быть переписан в форме:
route(req -> "Hello World!", "/")
То есть, функция обработчика — на первом месте, маршрут на втором. Но для нашего случая оставим руби-стиль.
Genie выполняет автоматическую упаковку результата выполнения в HTTP-отклик. В минимальном случае, нам необходимо лишь вернуть результат правильного типа, например String. Из дополнительных удобств, реализована автоматическая проверка входного формата и его разбор. Например, для JSON надо всего лишь вызвать метод jsonpayload()
.
route("/resource/process", method = POST) do
message = jsonpayload()
# if message == nothing
# dump(Genie.Requests.rawpayload())
# end
message["server_mark"] = "confirmed"
return message |> json!
end
Обратите внимание на закомментированный здесь фрагмент кода. Метод jsonpayload()
возвращает nothing
в том случае, если входной формат по каким-то причинам не распознан как JSON. Заметим, что именно для этого, в нашем клиенте HTTP добавлен заголовок [("Content-Type" => "application/json")]
, поскольку иначе Genie даже не начнёт разбирать данные как JSON. В случае, если пришло что-то непонятное, полезно посмотреть rawpayload()
на предмет того, что же это. Однако, поскольку это всего лишь отладочный этап, то в коде его оставлять не стоит.
Также, следует обратить внимание на возврат результата в формате message |> json!
. Метод json!(str)
здесь поставлен последним в конвейере. Он обеспечивает сериализацию данных в JSON-формат, а также гарантирует, что Genie добавит корректный Content-Type
. Также, обратим внимание на то, что слово return
в большинстве случаев в приведённых выше примерах является избыточным. Julia, как и, например, Ruby, всегда возвращает результат последней операции или значение последнего указанного выражения. То есть, слово return
является опциональным.
Возможности Genie на этом не заканчиваются, но для реализации веб-сервиса они нам не нужны.
Контрольный отклик для сервиса:
GET / HTTP/1.1
HTTP/1.1 200 OK <= (GET / HTTP/1.1)
Hello World!
GET /user/Jemand HTTP/1.1
HTTP/1.1 200 OK <= (GET /user/Jemand HTTP/1.1)
Hello Jemand
POST /resource/process HTTP/1.1
HTTP/1.1 200 OK <= (POST /resource/process HTTP/1.1)
{"body":"Test document with some content.","server_mark":"confirmed","title":"Some document"}
Вывод сервиса в консоль:
>./genie_json.jl
[ Info: Ready!
2019-04-24 17:18:51:DEBUG:Main: Web Server starting at http://127.0.0.1:8080
2019-04-24 17:18:51:DEBUG:Main: Web Server running at http://127.0.0.1:8080
2019-04-24 17:19:21:INFO:Main: / 200
2019-04-24 17:19:21:INFO:Main: /user/Jemand 200
2019-04-24 17:19:22:INFO:Main: /resource/process 200
Пакет JuliaWebAPI.jl
Этот пакет позиционировался как промежуточный слой для создания веб-приложений в те времена, когда HTTP.jl был лишь библиотекой, реализующей протокол. Автор этого пакета также реализовал генератор кода сервера на основании Swagger-спецификации (OpenAPI и http://editor.swagger.io/ ) — см. проект https://github.com/JuliaComputing/Swagger.jl, а этот генератор использовал JuliaWebAPI.jl. Однако проблема JuliaWebAPI.jl заключается в том, что не реализована возможность обработки тела запроса (например JSON), переданного серверу через POST-запрос. Автор считал, что передача параметров в формате ключ-значение подходит для всех случаев жизни… Будущее этого пакета не ясно. Все его функции уже реализованы во многих других пакетах, включая HTTP.jl. Пакет Swagger.jl также его уже не использует.
WebSockets.jl
Ранняя реализация протокола WebSocket. Пакет использовался долгое время как основная реализация этого протокола, однако, в настоящее время, его реализация включена в пакет HTTP.jl. Пакет WebSockets.jl и сам использует HTTP.jl для установления соединения, однако сейчас, использовать его в новых разработках не стоит. Его следует рассматривать как пакет для совместимости.
Заключение
В приведённом обзоре продемонстрированы различные способы реализации веб-сервиса на Julia. Наиболее простой и универсальный способ — непосредственное использование пакета HTTP.jl. Также, весьма полезны пакеты Bukdu.jl и Genie.jl. Как минимум, следует следить за их развитием. Касаемо пакета Mux.jl, его достоинства сейчас растворяются на фоне HTTP.jl. Поэтому, личное мнение — не использовать его. Genie.jl — весьма многообещающий фреймворк. Однако до того, как его начинать использовать, надо, как минимум, понять, почему автор не регистрирует его как официальный пакет.
Обратим внимание на то, что код десериализации JSON в примерах был использован без обработки ошибок. Во всех случаях, кроме Genie, необходимо обрабатывать ошибки разбора и информировать об этом пользователя. Пример такого кода для HTTP.jl:
local message = nothing
local body = IOBuffer(HTTP.payload(req))
try
message = JSON.parse(body)
catch err
@error err.msg
return HTTP.Response(400, string(err.msg))
end
В целом, можно сказать, что средств для создания веб-сервисов в Julia уже достаточно. То есть, "изобретать велосипед" для их написания не надо. Следующий шаг — оценить то, как Julia выдерживает нагрузку в рамках существующих бенчмарков, если кто-то готов за это взяться. Впрочем, пока остановимся на этом обзоре.