В этом посте мы расскажем, как передавать данные из базы данных через HTTP в формате JSON. Crystal позволяет нам создать реализацию, которая не сильно нагружает ресурсы, избегая ненужного выделения памяти промежуточным объектам, когда это возможно. Этого можно добиться благодаря API этих модулей: JSON, Http :: Server :: Response и DB.
Наша цель - создать две конечные точки HTTP:
GET /
, который перечислит все таблицы в БДGET /:table_name
, который ответит содержимым таблицы как JSON (на самом деле NDJSON)
Мы будем использовать Кемаль, чтобы не заниматься маршрутизацией вручную.
Скелет
Следующие шаги будут отображать базовый скелет приложения, состоящий из двух пустых конечных точек и точки доступа к базе данных:
- укажите database_url,
- открыть пул соединений,
- настроить обработчики маршрутизации Kemal,
- запустить сервер Кемаль.
require "kemal" database_url = "sqlite3://...." db = DB.open database_url get "/" do |end| # ... End get "/:table_name" do |env| # ... end Kemal.run db.close
Для удобства мы сосредоточимся на SQLite, но в конце статьи расширим решение для других драйверов. Теперь самое время проверить, есть ли в вашем shard.yml
как минимум:
dependencies: kemal: github: kemalcr/kemal sqlite3: github: crystal-lang/crystal-sqlite3
Примечание: вы можете быстро взять образец базы данных с chinook.
Получите столы
Предположим, что метод table_names(db)
вернет имена таблиц в виде массива String
, тогда результат в кодировке JSON можно запрограммировать следующим образом:
get "/" do |env| env.response.content_type = "application/json" table_names(db).to_json end def table_names(db) # return an array of strings end
Обычно в базе данных нет большого количества таблиц. Таким образом, хранение массива всех имен таблиц не кажется слишком тяжелым. Метод #to_json
создаст строку с представлением JSON и отправит ее клиенту. Мы рассмотрим более сложные методы генерации JSON позже в этом посте.
Мы можем получить имена таблиц из SQLite с помощью:
def table_names(db) sql = "SELECT name FROM sqlite_master WHERE type='table';" db.query_all(sql, as: String) end
#query_all
возвращает массив со всеми строками результата запроса. Дружественное напоминание: используйте его с осторожностью, потому что массив всех возвращаемых строк может быть довольно большим.
Получите строки
Предположим, что метод write_ndjson(io, col_names, rs)
запишет запись NDJSON для текущей строки в io
с указанными именами столбцов. Мы могли бы запрограммировать ответ всей таблицы следующим образом:
get "/:table_name" do |env| env.response.content_type = "application/x-ndjson" table_name = env.params.url["table_name"] unless table_names(db).includes?(table_name) # ignore if the requested table does not exist. env.response.status_code = 404 else db.query "select * from #{table_name}" do |rs| col_names = rs.column_names rs.each do write_ndjson(env.response.output, col_names, rs) end end end end def write_ndjson(io, col_names, rs) # ... end
Прежде чем переходить к деталям того, как мы можем кодировать write_ndjson
, давайте проанализируем HTTP-ответ.
Обычно сервер создает динамический ответ в буфере памяти перед его отправкой, помещая заголовки до начала фактического содержимого, такого как информация Content-Length
. Вот как работает список имен таблиц, и вы можете убедиться в этом сами с $ curl -v http://0.0.0.0:3000
.
В этом случае, поскольку каждая строка является автономным json, мы могли бы передавать по одной строке за раз, и это минимизирует использование памяти на стороне сервера. Клиент сможет обрабатывать данные по мере их поступления. Этот механизм называется фрагментированный ответ, если вы хотите вдаваться в подробности.
Чтобы разрешить потоковую передачу ответа, нам нужно очистить выходной буфер сразу после того, как каждая строка будет закодирована как json.
# ... rs.each do write_ndjson(env.response.output, col_names, rs) # force chunked response env.response.output.flush end # ...
Следующий шаг - как связать данные, поступающие из БД, с ответным вводом-выводом без создания множества промежуточных структур. В некоторых библиотеках БД подход заключается в получении массива или хэша полной строки. Crystal-db разработан для обеспечения максимально простого и быстрого доступа без посредников или множественных объектов. Данные уже ожидают чтения в соединении с базой данных. У него есть определенный фиксированный порядок, если соединение осуществляется через сокет (например, в MySQL или PostgreSQL), но это не проблема, поскольку мы создаем объект JSON.
def write_ndjson(io, col_names, rs) JSON.build(io) do |json| json.object do col_names.each do |col| json_encode_field json, col, rs.read end end end io << "\n" end
С JSON.build
мы запускаем JSON :: Builder, который предоставит удобные методы для передачи JSON прямо в IO.
База данных может возвращать значения многих типов. Большинство из них можно закодировать непосредственно в JSON: String, Ints и т. Д. Но некоторые из них не могут, например Slice(UInt8)
(он же Bytes
). Таким образом, json_encode_field(json, col, value)
используется для эффективного кодирования значения.
def json_encode_field(json, col, value) case value when Bytes # custom json encoding. Avoid extra allocations. json.field col do json.array do value.each do |e| json.scalar e end end end else # encode the value as their built in json format. json.field col do value.to_json(json) end end end
Обратите внимание, что этот метод передает массив json прямо из Bytes
, но фактического массива в памяти нет. Bytes
может быть огромным BLOB, поэтому было бы желательно избежать создания временного объекта только для того, чтобы закодировать его как JSON. Данные уже находятся в памяти, давайте просто воспользуемся ими.
Закрытие
Удивительно, сколько концепций можно уместить менее чем в 50 строк кода.
Не забудьте проверить полный исходный код, который можно использовать с любым из драйверов, поддерживаемых crystal-db: MySQL, PostgreSQL или SQLite!
Первоначально опубликовано на сайте manas.tech 16 января 2017 г.