Many Ruby developers know how things are going with asynchronous code execution on existing servers. Either you use something on EventMachine, or conjure with Ruby :: Concurrent, Celluloid.
In any case, this doesn't work very efficiently because of GIL (we wait, hope and believe in Ruby 3).
But there are implementations free from this problem, one of them on top of the JVM is JRuby, where the same libraries will feel much more comfortable.
Many will not paint, I think everyone at least heard about him. The main feature of this implementation is easy integration with any library on the JVM. This opens up a lot of room in choosing libraries and off-the-shelf tools.
So in the Java world there is a library that saves us from using the standard competitive Java model on the Executor, implementing it on actors. Call the library Netty. Later on its basis were developed by others, for example Ratpack.
Ratpack is an asynchronous web server, Netty is under the hood, therefore it works quite efficiently with connections and, in general, with IO, contains everything you need to build a productive server.
Therefore, using the features of Ratpack, the flexibility and simplicity of Ruby (JRuby), we will create a simple service that will expand us to short links. There are a number of examples on the Internet, but they end on how to run it all and get a simple answer.
There is another example with the connection of metrics (link at the end), the method from the documentation is absolutely not suitable for JRuby, as it is given only for Groovy.
In this example, consider:
Connecting libraries
Every Ruby programmer uses a bundler, life without him was sad and full of dancing, rakes and other adventures.
In the Java world, there are various assemblers that pull these dependencies and build an application, but this is not a Ruby way.
This is how jbundler appeared. Performs the same function as the bundler, but for Java libraries, after which, when loaded, they are available from JRuby. Beauty!
And so, we need to connect Ratpack to our application. It will be enough just core, the rest we do not use yet.
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'
In the console we execute
bundle install bundle exec jbundle install
In the future we will add a couple of libraries, but for now let's stop on this.
Having downloaded all the dependencies, we create a basic server, check that everything works. Since we do not have Rack, we will do the routing using standard tools.
To get started, import the necessary Java classes.
require 'java' java_import 'ratpack.server.RatpackServer' java_import 'ratpack.server.ServerConfig' java_import 'java.net.InetAddress'
And declare our server class:
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 was created for our service, it will allow to check if the server is alive in principle.
The handlers method takes a block in which the Chain interface is transmitted, which determines the routing. To declare status, we use the get method, which is equivalent to the HTTP method.
The second argument is the object that implements the Handler interface. In our case, this is the module in which the method is declared, which accepts the current context. As you can see everything is quite simple and clear. No three-story factories, or something like that.
Actually the handler itself, just answer that everything is OK:
module UrlExpander module Handler class Status def self.handle(ctx) ctx.render 'OK' end end end end
Ratpack also has its own health check implementation, but for our example it is redundant.
We can now monitor the status of our service, but it would be good to know what is inside with it, response time, number of requests and other indicators.
For this we need metrics. Ratpack has integration with Dropwizard, for this you need to add a couple of packages to our Jarfile and install them
jar 'io.ratpack:ratpack-guice', '1.4.2' jar 'io.ratpack:ratpack-dropwizard-metrics', '1.4.2'
Next, connect it to our server. This is done quite simply, it is enough just to modify a few sections.
java_import 'ratpack.guice.Guice' java_import 'ratpack.dropwizard.metrics.DropwizardMetricsConfig' java_import 'ratpack.dropwizard.metrics.DropwizardMetricsModule' java_import 'ratpack.dropwizard.metrics.MetricsWebsocketBroadcastHandler'
Register the module in our Registry:
s.serverConfig(config) s.registry(Guice.registry { |g| g.module(DropwizardMetricsModule.new) })
And load its configuration:
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
And we want to receive our metrics via WebSocket, add a handler for this:
s.handlers do |chain| chain.get 'status', Handler::Status chain.get 'metrics-report', MetricsWebsocketBroadcastHandler.new chain.all Handler::Default end
Done, you can also connect the unloading of metrics in the console or in StatsD. Since we now have a WebSocket for output, we will add a page for display.
The scheme is standard, the public folder, containing all the statics. To return it, we will prescribe an additional route, specifying the name of the folder and the index of the new file:
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
Our server starts, listens to the specified port and responds to requests. Next, add an endpoint that will return us all the url through which our short link passes. The algorithm is the simplest, at each redirect we save a new Location into an array, and then we return it.
s.handlers do |chain| chain.get 'status', Handler::Status chain.path 'expand', Handler::Expander chain.all Handler::Default end
The added endpoint will accept both POST and GET requests.
If we only had a blocking API, each request was processed in its own stream, how it was processed, 90% of the time it would wait for a response from the server, since useful calculations we have a minimum. But to our happiness, Ratpack is an asynchronous server and provides a complete set of components, including an asynchronous http client and Promise, which is asynchronous without them.
And so, we create for each initial reference Promise, which, if successful, will return the Location array to us.
Inside, run GET at our URL and hang up a callback to receive a new Location from the server.
Thus, we place in our array the target URL and all intermediate ones.
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) : {}
We create HttpClient, which will collect our links
httpClient = ctx.get HttpClient.java_class
We collect all the URLs transmitted to us and if there is nothing, then we immediately return Promise with an empty map.
urls = [*data['urls'], *data['url'], *params['url']].compact unless urls.present? return Promise.value({}) end
We create parallel requests for all links submitted:
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
Waiting for them to complete, collect the result and return it:
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
As a result, we got a chain from Promise, which asynchronously executes our code.
It is time to test what you wrote. We will test through the good old rspec, but with nuances. Since we use Ratpack + Promise, then testing in isolation from the library will not work, somehow these Promise should be performed, i.e. need a working eventloop. For this we will connect an additional JAR library from the bundle:
jar 'io.ratpack:ratpack-test', '1.4.2'
This library allows you to organize how to test requests (creating a test server), and just perform the Promise. For the latter, the ExecHarness class is used , it is described in detail in the documentation and the examples are easily transferred to JRuby.
We will test how our GET request is executed and use EmbeddedApp , which allows you to run a test server. There are various static methods to simplify the creation
under certain cases. We will only test our handler, regardless of the path, so we will create it as follows:
describe UrlExpander::Handler::Expander do let(:server) do EmbeddedApp.fromHandlers do |chain| chain.all(described_class) end end #... end
And let's check that everything works as it should:
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
The test method starts the execution and passes to the block an instance of TestHTTPClient, with which the request is executed. Further we check the received answer. As you can see everything is quite simple.
Unlike ExecHarness, EmbeddedApp re-creates a server for every check, while ExecHarness only runs EventLoop once.
Therefore, it is better to separate the code from working with the Ratpack Context as much as possible so that it can be independently tested.
After everything is ready, run our project on heroku. This procedure is practically no different from running a normal ruby service.
The only difference is that you need to install the JAR library, and heroku does not perform this operation automatically.
For this, a small hack is made. In principle, it is described everywhere, but for integrity, I will repeat it here. During the build process, static builds are performed, so we will use this and add the following 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
Everything, now at the assembly, also the libraries specified in Jarfile will be installed.
As you can see, using Ratpack in conjunction with JRuby is not so difficult, at the same time it gives you access to all the features of JVM and Netty in particular. Based on it, you can build a high-performance asynchronous server. All this is production ready, testing on Hello World shows up to 25k rps on EC2 c4.large in a docker container after warming up. About 30K requests were executed for warming up, at the start time floats, but by the end it is stable. At the same time, even with a fairly complex logic, the query execution time is a few milliseconds. This of course depends on the tasks, but even just replacing Puma with Ratpack (tested for time evaluation) gave a significant increase. After a complete refactoring and rethinking of the code and dense optimization on the JVM, the time was reduced by orders of magnitude. So who is looking for Java performance and flexibility, Ruby development speed, while there is a lot of work, I recommend looking at this pair.
Source: https://habr.com/ru/post/314726/
All Articles