Многие разработчики на Ruby знают как обстоят дела с асинхронным выполнением кода на имеющихся серверах. Либо вы используете что-то на EventMachine, либо колдуете с Ruby::Concurrent, Celluloid.


В любом случае, это работает не сильно эффективно из-за GIL (ждем, надеемся и верим в Ruby 3).
Но есть реализации свободные от этой проблемы, одна из них поверх JVM — JRuby, где теже самые библиотеки будут чувствовать себя гораздо комфортней.


Много расписывать не буду, думаю все как минимум слышали про него. Главной особенностью данной реализации является легкая интеграция с любой библиотекой на JVM. Это открывает большой простор в выборе библиотек и готовых инструментов.


Так в мире Java есть библиотека, избавляющая нас от использования стандартной конкурентной Java модели на Executor, реализуя ее на акторах. Звать библиотеку Netty. Позже на ее основе были разработаны другие, например Ratpack.


Ratpack асинхронный веб сервер, под капотом находится Netty, следовательно достаточно эффективно работает с подключениями и в целом с IO, содержит в себе все необходимое для построения производительного сервера.


Поэтому используя возможности Ratpack, гибкость и простоту Ruby (JRuby), сделаем простейший сервис, который будет разворачивать нам короткие ссылки. В интернете есть ряд примеров, но они заканчиваются на том как запустить все это и получить простой ответ.


Есть еще пример с подключением метрик (ссылка в конце), способ из документации абсолютно не пригоден для JRuby, так как приводится только для Groovy.


В данном примере рассмотрим:


  • подключение библиотек
  • создание сервера
  • подключение метрик
  • асинхронное выполнение запросов к внешним ресурсам
  • тестирование нашего сервиса
  • и зальем все это на heroku

Подключение библиотек


Каждый Ruby программист пользуется bundler, жизнь без него была грустна и полна плясок, грабель и других приключений.


В мире Java есть различные сборщики, которые подтянут указанные зависимости и соберут приложение, но это не Ruby way.


Так появился jbundler. Выполняет туже самую функцию что и bundler, но для Java библиотек, после чего при загрузке они становятся доступны из JRuby. Красота!


И так, нам нужно подключить Ratpack к нашему приложению. Достаточно будет только core, остальное мы пока не используем.


Gemfile:


source 'https://rubygems.org'

ruby '2.3.0', :engine => 'jruby', :engine_version => '9.1.2.0'

gem 'rake'
gem 'activesupport', '4.2.5'
gem 'jruby-openssl'

gem 'jbundler', '0.9.2'
gem 'jrjackson'

group :test, :development do
  gem 'pry'
end

group :test do
  gem 'rspec'
  gem 'simplecov', require: false
end

Jarfile:


jar 'io.ratpack:ratpack-core', '1.4.2'
jar 'org.slf4j:slf4j-simple', '1.7.10'

В консоли выполняем


bundle install
bundle exec jbundle install

В дальнейшем добавим еще пару библиотек, но пока остановимся на этом.


Создание сервера


Загрузив все зависимости, создаем базовый сервер, проверим что все работает. Так как у нас нет Rack, то маршрутизацию будем делать используя штатные средства.


Для начала импортируем необходимые Java классы.


require 'java'

java_import 'ratpack.server.RatpackServer'
java_import 'ratpack.server.ServerConfig'
java_import 'java.net.InetAddress'

И объявим наш класс сервера:


module UrlExpander
  class Server
    attr_reader :port, :host
    def self.run
      new('0.0.0.0', ENV['PORT'] || 3000).tap(&:run)
    end

    def initializer(host, port)
      @host = host
      @port = port
    end
    def run
      @server  = RatpackServer.of do |s|
        s.serverConfig(config)
        s.handlers do |chain|
          chain.get  'status', Handler::Status
          chain.all            Handler::Default
        end
      end
      @server.start
    end

    def shutdown
      @server.stop
    end

    private

    def config
      ServerConfig.embedded
                  .port(port.to_i)
                  .address(InetAddress.getByName(host))
                  .development(ENV['RACK_ENV'] == 'development')
                  .base_dir(BaseDir.find)
                  .props("application.properties")
    end
  end
end

Для нашего сервиса создали endpoint status, он позволит проверить жив ли сервер в принципе.
Метод handlers, принимает блок, в которой передается интерфейс Chain, определяющий маршрутизацию. Для объявления status испольуем метод get, эквивалентный HTTP методу.


Вторым аргументом передается объект реализующий интерфейс Handler. В нашем случае это модуль, в котором объявлен метод handle, принимающий текущий контекст. Как видите все достаточно просто и понятно. Никаких трехэтажных фабрик, или чего-то подобного.


Собственно сам обработчик, просто ответим что все OK:


module UrlExpander
  module Handler
    class Status
      def self.handle(ctx)
        ctx.render 'OK'
      end
    end
  end
end

Так же в Ratpack есть своя собственная реализация health check, но для нашего примера она избыточна.


Подключение метрик


Отслеживать статус нашего сервиса мы теперь можем, но хорошо бы еще узнать что с ним внутри, время ответа, количество запросов и другие показатели.


Для этого нам нужны метрики. Ratpack имеет интеграцию с Dropwizard, для этого нужно добавить в наш Jarfile пару пакетов и установить их


jar 'io.ratpack:ratpack-guice', '1.4.2'
jar 'io.ratpack:ratpack-dropwizard-metrics', '1.4.2'

Далее подключим его к нашему серверу. Выполняется это достаточно просто, достаточно лишь модифицировать несколько участков.


java_import 'ratpack.guice.Guice'
java_import 'ratpack.dropwizard.metrics.DropwizardMetricsConfig'
java_import 'ratpack.dropwizard.metrics.DropwizardMetricsModule'
java_import 'ratpack.dropwizard.metrics.MetricsWebsocketBroadcastHandler'

Зарегистрируем модуль в нашем Registry:


        s.serverConfig(config)

        s.registry(Guice.registry { |g|
                     g.module(DropwizardMetricsModule.new)
                   })

И загрузим его конфигурацию:


    def config
      ServerConfig.embedded
                  .port(port.to_i)
                  .address(InetAddress.getByName(host))
                  .development(ENV['RACK_ENV'] == 'development')
                  .base_dir(BaseDir.find)
                  .props("application.properties")
                  .require("/metrics", DropwizardMetricsConfig.java_class)
    end

А еще мы хотим получать наши метрики через WebSocket, добавим handler для этого:


        s.handlers do |chain|
          chain.get  'status', Handler::Status
          chain.get 'metrics-report', MetricsWebsocketBroadcastHandler.new
          chain.all  Handler::Default
        end

Готово, так же можно подключить выгрузку метрик в консоль либо в StatsD. Так как для вывода у нас теперь есть WebSocket, добавим и страницу для отображения.


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


        s.handlers do |chain|
          chain.files do |f|
            f.dir('public').indexFiles('index.html')
          end       
          chain.get  'status', Handler::Status
          chain.get 'metrics-report', MetricsWebsocketBroadcastHandler.new
          chain.all  Handler::Default
        end

Асинхронное выполнение запросов к внешним ресурсам


Сервер у нас заводится, слушает указанный порт и отвечает на запросы. Далее добавим endpoint, который будет возвращать нам все url, через которые проходит наша короткая ссылка. Алгоритм простейший, на каждом redirect сохраняем новый Location в массив, после чего возвращаем его.


s.handlers do |chain|
  chain.get 'status',  Handler::Status
  chain.path 'expand', Handler::Expander
  chain.all            Handler::Default
end

Добавленный endpoint будет принимать как POST, так и GET запросы.


Если бы у нас был только блокирующий API, каждый запрос обрабатывался в своем потоке, ну как обрабатывался, 90% времени он бы ждал ответа от сервера, т.к. полезных вычислений у нас минимум. Но к нашему счастью, Ratpack является асинхронным сервером и предоставляет полный набор компонентов, в том числе асинхронный http клиент и Promise, какая асинхронщина без них.
И так, создадим для каждой исходной ссылки Promise, который при удачном завершении вернет нам массив Location.


Внутри же, запускаем GET по нашему URL и вешаем callback на получение нового Location от сервера.


Таким образом поместим в наш массив целевой URL и все промежуточные.


module UrlExpander
  module Handler
    class Expander < Base
      java_import 'ratpack.exec.util.ParallelBatch'
      java_import 'ratpack.http.client.HttpClient'

      def execute
        data = request.present? ? JrJackson::Json.load(request) : {}

Создаем HttpClient, которым будем собирать наши ссылки


        httpClient = ctx.get HttpClient.java_class

Собираем все URL, переданные нам и если ничего нет, то сразу возвращаем Promise с пустой мапой.


        urls = [*data['urls'], *data['url'], *params['url']].compact
        unless urls.present?
          return Promise.value({})
        end

Создаем параллельные запросы по всем переданным ссылкам:


        tasks = urls.map do |url|
          Promise.async do |down|
            uri = Java.java.net.URI.new(url)
            locations = [url]
            httpClient.get(uri) do |spec|
              spec.onRedirect do |resposne, action|
                locations << resposne.getHeaders.get('Location')
                action
              end
            end .then do |_resp|
              down.success(locations);
            end
          end
        end

Дождавшись их выполнения собираем результат и возвращаем его:


        ParallelBatch.of(tasks).yieldAll.flatMap do |results|
          response = results.each_with_object({}) do |result, locations|
            result.value.try do |list|
              locations[list.first] = list[1..-1]
            end
          end

          Promise.value response
        end
      end
    end
  end
end

В итоге получили цепочку из Promise, которая асинхронно выполнит наш код.


Тестирование нашего сервиса


Настало врем протестировать то, что написали. Тестировать будем через старый добрый rspec, но с нюансами. Т.к. мы используем Ratpack + Promise, то тестировать в отрыве от библиотеки не получится, как-то эти Promise надо выполнять, т.е. нужен рабочий eventloop. Для этого подключим дополнительную JAR библиотеку из комплекта:


jar 'io.ratpack:ratpack-test', '1.4.2'

Данная библиотека позволяет организовать как тестирование запросов (создание тестового сервера), так и просто выполнение Promise. Для последнего используется класс ExecHarness, в документации он подробно описан и примеры легко переносятся на JRuby.


Мы же будем тестировать как выполняется наш GET запрос и воспользуемся EmbeddedApp, который позволяет запустить тестовый сервер. Есть различные статические методы, для упрощения создания
под определенные случаи. Мы тестировать будем только наш обработчик, независимо от пути, поэтому создадим его следующим образом:


describe UrlExpander::Handler::Expander do
  let(:server) do
    EmbeddedApp.fromHandlers do |chain|
      chain.all(described_class)
    end
  end
  #...
end

И выполним проверку, что все работает как надо:


    let(:url) { 'http://bit.ly/1bh0k2I' }

    context 'get request' do
      it do
        server.test do |client|
          response = client.params do |builder|
            builder.put('url', url)
          end .getText
          response  = JrJackson::Json.load(response)
          expect(response).to be_present
          expect(response).to be_key url
          expect(response[url].last).to match /\/ya\.ru/
        end
      end
    end

Метод test запускает выполнение и передает в блок экземпляр TestHTTPClient, с помощью которого выполняется запрос. Далее проверяем получаенный ответ. Как видите все достаточно просто.
В отличие от ExecHarness, EmbeddedApp на каждую проверку пересоздает сервер, в то время как ExecHarness запускает EventLoop лишь раз.


Поэтому лучше максимально отделять код от работы с Ratpack Context, чтобы можно было его независимо тестировать.


Запуск на Heroku


После того как все готово запустим наш проект на heroku. Данная процедура практически ничем не отличается от запуска обычного ruby сервиса.


Единственное отличие связано с тем, что нужно установить JAR библиотеки, а heroku не выполняет данную операцию автоматически.


Для этого делается маленький хак. В принципе он везде описан, но для целостности повторю его и здесь. В процессе сборки выполняется сборки статики в том числе, поэтому воспользуемся этим и добавим следующй rake task:


task "assets:precompile" do
  require 'jbundler'
  config = JBundler::Config.new
  JBundler::LockDown.new( config ).lock_down
  JBundler::LockDown.new( config ).lock_down("--vendor")
end

Все, теперь при сборке, также будут установлены и указанные в Jarfile библиотеки.


Заключение


Как видите использовать Ratpack в связке с JRuby не так сложно, в то же время дает доступ ко всем возможностям JVM и Netty в частности. На его основе можно собрать высокопроизводительный асинхронный сервер. Все это production ready, тестирование на Hello World показывает до 25k rps на EC2 c4.large в docker контейнере после прогрева. Для прогрева выполялось порядка 30К запросов, на старте время плавает, но к окончанию уже стабильно. При этом даже с достаточно сложной логикой время выполнения запроса составляет считанные милисекунды. Это конечно зависит от задач, но даже просто замена Puma на Ratpack (тестировали для оценки времени), дало значительный прирост. После полного рефакторинга и переосмысления кода и плотной оптимизацией на JVM, время сократилось на порядки. Так что кто ищет производительность Java и гибкость, скорость разработки Ruby, при этом есть наработанный код рекомендую посмотреть на эту пару.


Ссылки


Поделиться с друзьями
-->

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


  1. crmMaster
    13.11.2016 19:18

    Это ваша разработка?
    Что за дичь вы устраиваете, серьезно.

    Если вы хотите сделать реализацию сервера, которую будет использовать сообщество, то
    1. Используйте rack как интерфейс сервера (https://rack.github.io/)
    Хорошую его реализацию для jruby можно найти в puma (https://github.com/puma/puma/blob/master/lib/rack/handler/puma.rb) — как видите, очень близко к вашему UrlExpander

    2. Асинхронные запросы — это отлично, но помимо простого проксирования хотелось бы видеть нормальную систему async/await или хотя бы минимальное API для преобразования результата. Опять же не потребует сильных переделок

    3. DSL в синатра-стиле а не чудеса с handlers и chain

    4. Конфигурация через YML

    А так подобное хоть и работает, но абсолютно неюзабельно. А в руби-сообществе неюзабельные вещи очень быстро умирают.

    Как эксперимент пойдет, как продакшен решение — не катит и требует изменений API


    1. fuCtor
      13.11.2016 19:32

      Извините конечно, но по последней ссылке сходить не пробовали? Ratpack не имеет никакого отношения к Ruby, это быблиотека для JAVA. Собственно в самом анчале про это и указываю.
      1) Зачем мне Rack если я не использую rack совместимый сервер + для реаьлной задачи где он используется это лишняя прослойка. UrlExpander это просто пример чуть сложнее HelloWorld, чтобы добавить использование Promise из Ratpack.

      2) async/await везде делается на уровне языка, так что этого нет ни в JAVA, ни в Ruby, то и в JRuby ему появится неоткуда. В данном случае, предлагается модель Promise на основе акторов от Netty (JAVA библиотека, не имеет отношения к Ruby), без GIL, как в MRI реализации.

      3) Зачем DSL? Еще раз, статья о том как использовать высокопроизводительный сервер из мира JAVA с Ruby.

      Подобное работает, в продакшене, быстрое, юзабельное и приносит реальные деньги.

      Как эксперимент пойдет, как продакшен решение — не катит и требует изменений API

      И еще раз повторю, статья о том, как использовать хорошие, быстрые, производительные решения на JAVA и не отказываться от имеющейся кодовой базы полностью. Отсюда и выглядит это все немного не Ruby like, а как JAVA.


      1. crmMaster
        14.11.2016 00:19

        1. И теряете все преимущества rack как унифицированного интерфейса запуска ruby сервера и управления его конфигурацией, вместо цепочки вызовов.
        А делов то — перенести конфигурацию в self.run(app, options = {}) и обозвать экспандер по-другому.

        2. > async/await везде делается на уровне языка.
        Согласен. Только я имел ввиду не нативный async/await, а «систему async/await», т.е. не ParallelBatch.of(tasks).yieldAll.flatMap
        а, например,
        tasks.async_each

        3. Зачем DSL?
        Чтобы у новичков шаблон не рвало, например. Тех кто десятый год в хайлоаде ничем не испугаешь, но простая адаптация и DSL, особенно который является стандартом для подобного рода задач, значительно сокращают время адаптации нового сотрудника. Опять же, переписать — минимум — chain почти все умеет.

        Бесспорно «В проде работает, не трогай» — это отлиная отмазка для своей лени. Но друг мой, вы творите дичь. Дичь по меркам руби сообщества. Вы не режете углы там, где могли бы, вы не следуете идеологии руби.

        Не надо так. Очень прошу, почитайте доктрину rails http://rubyonrails.org/doctrine/


        1. fuCtor
          14.11.2016 07:41

          1) Ratpack пишут ребята из Gradle, и пишут его для JAVA мира, привязаться к Rack можно, написав свои адаптеры и тд. Пару месяцев назад даже делал такую прослойку, чтобы перенести имеющийся прод на Ratpack, но из-за лишних этапов (в самом Rack и потом Sinatra), которые по большей части дублируют функциональность, разница в скорости была не существенная. Когда выпилил их, и всю логику перенес на JAVA (те самые Chain), оставив лишь прямые вызовы бизнес логики, то прирост уже был ощутим.

          Если и делать такую обертку, то над чистым Netty, чтобы задачу роутинга, формирования ответа и тд переложить на Rack и выше.

          2) В JAVA нельзя расширять существующие классы, в Ruby можно конечно, да и интерфейс авторы сделали более менее привычный, совместим с тем, что есть в RxJava и других пободных бибилотеках и решениях. Многие как раз ругают Rails, за то что расширяет классы безспроса, что потом уже путают что есть в Ruby, что из Rails пришло.

          3) Написать DSL поверх этого не проблема, но не вижу смысла, как говорится ИМХО. В данном случае, я за то чтобы делать минимум лишних действий между JAVA кодом и Ruby. А скорость адаптации все же не от DSL зависит, а от качества когда.

          Это достаточно специфичное решение. Если нужно что-то каноническое, то это Puma, но на ней не сделаешь сотню паралелельных HTTP запросов, не заблокировав потоки. Можно конечно поколдовать над Ruby::Concurrency или Celluloid, но архитектура у Puma такая, что прям навязывают линейное выполнение кода. Не зря же делают альтернативные решения для выноса ActionCable из основного приложения (anycable).

          В проде оно очень даже хорошо себя показывает, высокий concurrency, низкие задержки, узкие места перенесены на JAVA. В итоге время ответа (сложная логика) составляет 2-3мс. И весь I/O полностью асинхронный.

          В конечном итоге я не предлагаю коробочное решения, а лишь показываю что если не устраивает то что есть в Ruby экосистеме, но есть на JVM, можно использовать это достаточно легко и просто. Да это будет не ruby way, да это будет идти местами в разрез с идеологией, но все это лишь инструмент и рекомендации, а не догма.