JRuby + Ratpack = ❤️

JRuby + Ratpack = Love
Багато розробники на 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)[https://ratpack.io/manual/current/api/ratpack/test/exec/ExecHarness.html]у документації він докладно описаний і приклади легко переносяться на JRuby.
Ми ж будемо тестувати як виконується наш GET запит і скористаємося (EmbeddedApp)[https://ratpack.io/manual/current/api/ratpack/test/embed/EmbeddedApp.html], який дозволяє запустити тестовий сервер. Є різні статичні методи, для спрощення створення
під певні випадки. Ми будемо тестувати тільки наш обробник, незалежно від шляху, тому створимо його наступним чином:
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
do it
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, при цьому є напрацьований код рекомендую подивитися на цю пару.
Посилання
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.