В этом посте мы расскажем, как передавать данные из базы данных через HTTP в формате JSON. Crystal позволяет нам создать реализацию, которая не сильно нагружает ресурсы, избегая ненужного выделения памяти промежуточным объектам, когда это возможно. Этого можно добиться благодаря API этих модулей: JSON, Http :: Server :: Response и DB.

Наша цель - создать две конечные точки HTTP:

  1. GET /, который перечислит все таблицы в БД
  2. GET /:table_name, который ответит содержимым таблицы как JSON (на самом деле NDJSON)

Мы будем использовать Кемаль, чтобы не заниматься маршрутизацией вручную.

Скелет

Следующие шаги будут отображать базовый скелет приложения, состоящий из двух пустых конечных точек и точки доступа к базе данных:

  1. укажите database_url,
  2. открыть пул соединений,
  3. настроить обработчики маршрутизации Kemal,
  4. запустить сервер Кемаль.
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 г.