Natsy
The natsy gem allows you to listen for (and reply to) NATS messages asynchronously in a Ruby application.
TODO
- docs
- tests
- "controller"-style classes for reply organization
- runtime subscription additions
- multiple queues
- config options for URL/host/port/etc.
- config for restart behavior (default is to restart listening on any
StandardError) -
on_errorhandler so you can send a response (what's standard?) - support lifecycle callbacks (like
on_connect,on_disconnect, etc.) provided by thenatsgem - ability to request (not just reply)
Installation
Locally (to your application)
Add the gem to your application's Gemfile:
gem 'natsy'...and then run:
bundle installGlobally (to your system)
Alternatively, install it globally:
gem install natsyNATS server (important!)
This gem also requires a NATS server to be installed and running before use. See the NATS documentation for more details.
Usage
You'll need to start a NATS server before running your Ruby application. If you installed it via Docker, you might start it like so:
docker run -p 4222:4222 -p 8222:8222 -p 6222:6222 -ti nats:latestNOTE: You may need to run that command with
sudoon some systems, depending on the permissions of your Docker installation.
NOTE: For other methods of running a NATS server, see the NATS documentation.
Configuration
Use Natsy::Config::set to set configuration options. These options can either be set via a Hash/keyword arguments passed to the ::set method, or set by invoking the method with a block and assigning your options to the yielded Natsy::Config::Options instance.
This README will use the following two syntaxes interchangably; remember that they do exactly the same thing:
Natsy::Config.set(
urls: ["nats://foo.bar:4567", "nats://foo.bar:5678"],
default_queue: "foobar",
logger: Rails.logger,
)Natsy::Config.set do |options|
options.urls = ["nats://foo.bar:4567", "nats://foo.bar:5678"]
options.default_queue = "foobar"
options.logger = Rails.logger
endThe following options are available:
-
url: A single URL string (including protocol, domain, and port) which points to the relevant NATS server (see here for more info) -
urls: An array of URL strings in case you need to listen to multiple NATS servers (see here for more info) -
logger: A logger wherenatsycan write helpful information (see here for more info) -
default_queue: The default queue that your application should fall back to if none is given in a more specific context (see here for more info)
Setting the NATS server URL(s)
Set the URL/URLs at which your NATS server mediates messages.
Natsy::Config.set do |options|
options.url = "nats://foo.bar:4567"
endNatsy::Config.set do |options|
options.urls = ["nats://foo.bar:4567", "nats://foo.bar:5678"]
endNOTE: If no
url/urlsoption is specified,natsywill fall back on the default NATS server URL, which isnats://localhost:4222.
Logging
Attaching a logger
Attach a logger to have natsy write out logs for messages received, responses sent, errors raised, lifecycle events, etc.
require 'natsy'
require 'logger'
Natsy::Config.set do |options|
nats_logger = Logger.new(STDOUT)
nats_logger.level = Logger::INFO
options.logger = nats_logger
endIn a Rails application, you might do this instead:
Natsy::Config.set(logger: Rails.logger)Log levels
The following will be logged at the specified log levels
-
DEBUG: Lifecycle events (starting NATS listeners, stopping NATS, reply registration, etc.), as well as everything underINFO,WARN, andERROR -
INFO: Message activity over NATS (received a message, replied with a message, etc.), as well as everything underWARNandERROR -
WARN: Error handled gracefully (listening restarted due to some exception, etc.), as well as everything underERROR -
ERROR: Some exception was raised in-thread (error in handler, error in subscription, etc.)
Setting a default queue
Set a default queue for subscriptions.
Natsy::Config.set(default_queue: "foobar")Leave the default_queue blank (or assign nil) to use no default queue.
Natsy::Config.set(default_queue: nil)Registering message handlers
Register a message handler with the Natsy::Client::reply_to method. Pass a subject string as the first argument (either a static subject string or a pattern to match more than one subject). Specify a queue (or don't) with the queue: option. If you don't provide the queue: option, it will be set to the value of default_queue, or to nil (no queue) if a default queue hasn't been set.
The result of the given block will be published in reply to the message. The block is passed two arguments when a message matching the subject is received: data and subject. The data argument is the payload of the message (JSON objects/arrays will be parsed into string-keyed Hash objects/Array objects, respectively). The subject argument is the subject of the message received (mostly only useful if a pattern was specified instead of a static subject string).
Natsy::Client.reply_to("some.subject", queue: "foobar") { |data| "Got it! #{data.inspect}" }
Natsy::Client.reply_to("some.*.pattern") { |data, subject| "Got #{data} on #{subject}" }
Natsy::Client.reply_to("other.subject") do |data|
if data["foo"] == "bar"
{ is_bar: "Yep!" }
else
{ is_bar: "No way!" }
end
end
Natsy::Client.reply_to("subject.in.queue", queue: "barbaz") do
"My turn!"
endStarting the listeners
Start listening for messages with the Natsy::Client::start! method. This will spin up a non-blocking thread that subscribes to subjects (as specified by invocation(s) of ::reply_to) and waits for messages to come in. When a message is received, the appropriate ::reply_to block will be used to compute a response, and that response will be published.
Natsy::Client.start!NOTE: If an error is raised in one of the handlers,
Natsy::Clientwill restart automatically.
NOTE: You can invoke
::reply_toto create additional message subscriptions afterNatsy::Client.start!, but be aware that this forces the client to restart. You may see (benign, already-handled) errors in the logs generated when this restart happens. It will force the client to restart and re-subscribe after each additional::reply_toinvoked after::start!. So, if you have a lot of additional::reply_toinvocations, you may want to consider refactoring so that your call toNatsy::Client.start!occurs after those additions.
NOTE: The
::start!method can be safely called multiple times; only the first will be honored, and any subsequent calls to::start!after the client is already started will do nothing (except write a "NATS is already running" log to the logger at theDEBUGlevel).
Basic full working example (in vanilla Ruby)
The following should be enough to start a natsy setup in your Ruby application, using what we've learned so far.
NOTE: For a more organized structure and implementation in a larger app (like a Rails project), see the "controller" section below.
require 'natsy'
require 'logger'
Natsy::Config.set do |options|
nats_logger = Logger.new(STDOUT)
nats_logger.level = Logger::DEBUG
options.logger = nats_logger
options.urls = ["nats://foo.bar:4567", "nats://foo.bar:5678"]
options.default_queue = "foobar"
end
Natsy::Client.reply_to("some.subject") do |data|
"Got it! #{data.inspect}"
end
Natsy::Client.reply_to("some.*.pattern") do |data, subject|
"Got #{data} on #{subject}"
end
Natsy::Client.reply_to("subject.in.queue", queue: "barbaz") do
{
msg: "My turn!",
turn: 5,
}
end
Natsy::Client.start!Creating "controller"-style classes for listener organization
Create controller classes which inherit from Natsy::Controller in order to give your message listeners some structure.
Use the ::default_queue macro to set a default queue string. If omitted, the controller will fall back on the global default queue assigned to Natsy::Config::default_queue (as described here). If no default queue is set in either the controller or globally, then the default queue will be blank. Set the default queue to nil in a controller to fall back to the global default queue.
Use the ::subject macro to create a block for listening to that subject segment. Nested calls to ::subject will append each subsequent subject/pattern string to the last (joined by a periods). There is no limit to the level of nesting.
You can register a response for the built-up subject/pattern string using the ::response macro. Pass a block to ::response which optionally takes two arguments (the same arguments supplied to the block of Natsy::Client::reply_to). The result of that block will be sent as a response to the message received.
class HelloController < Natsy::Controller
default_queue "foobar"
subject "hello" do
subject "jerk" do
response do |data|
# The subject at this point is "hello.jerk"
"Hey #{data['name']}... that's not cool, man."
end
end
subject "and" do
subject "wassup" do
response do |data|
# The subject at this point is "hello.and.wassup"
"Hey, how ya doin', #{data['name']}?"
end
end
subject "goodbye" do
response do |data|
# The subject at this point is "hello.and.goodbye"
"Hi #{data['name']}! But also GOODBYE."
end
end
end
end
subject "hows" do
# The queue at this point is "foobar"
subject "*", queue: "barbaz" do # Override the default queue at any point
# The queue at this point is "barbaz" (!)
subject "doing" do
# The queue at this point is "barbaz"
response queue: "bazbam" do |data, subject|
# The queue at this point is "bazbam" (!)
# The subject at this point is "hows.<wildcard>.doing" (i.e., the
# subjects "hows.jack.doing" and "hows.jill.doing" will both match)
sender_name = data["name"]
other_person_name = subject.split(".")[1]
desc = rand < 0.5 ? "terribly" : "great"
"Well, #{sender_name}, #{other_person_name} is actually doing #{desc}."
end
end
end
end
endNOTE: If you implement controllers like this and you are using code-autoloading machinery (like Zeitwerk in Rails), you will need to make sure these paths are eager-loaded when your app starts. If you don't,
natsywill not register the listeners, and will not respond to messages for the specified subjects.For example: in a Rails project (assuming you have your NATS controllers in a directory called
app/nats/), you may want to put something like the following in an initializer (such asconfig/initializers/nats.rb):Natsy::Config.set(logger: Rails.logger, default_queue: "foobar") # ... Rails.application.config.after_initialize do nats_controller_paths = Dir[Rails.root.join("app", "nats", "**", "*_controller.rb")] nats_controller_paths.each { |file_path| require_dependency(file_path) } Natsy::Client.start! end
Development
Install dependencies
To install the Ruby dependencies, run:
bin/setupThis gem also requires a NATS server to be installed and running. See the NATS documentation for more details.
Open a console
To open a REPL with the gem's code loaded, run:
bin/consoleRun the tests
To run the RSpec test suites, first start the NATS server. Then, run the tests:
bundle exec rake spec...or (if your Ruby setup has good defaults) just this:
rake specRun the linter
bundle exec rubocop...or (if your Ruby setup has good defaults) just this:
rubocopCreate a release
Bump the Natsy::VERSION value in lib/natsy/version.rb, commit, and then run:
bundle exec rake release...or (if your Ruby setup has good defaults) just this:
rake releaseThis will:
- create a git tag for the new version,
- push the commits,
- build the gem, and
- push it to rubygems.org.
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/Openbay/natsy.
License
The gem is available as open source under the terms of the MIT License.