Logic for reading data is handled a little differently than the logic for writing data. Remem- ber that when reading data, the ideal is to run requests in parallel. This ensures that calls can be made to multiple services in the minimum amount of time. Enabling this kind of behavior requires that the objects that hold data (in a way similar to ActiveRecord models) should never actually make requests themselves. They can build request objects, but it is the responsibility of a controller outside the data object to manage those requests. This is very different from ActiveRecord, which makes connections within the data object.
A detailed example shows the issues you need to consider when performing data reads. This example considers a user’s reading list from the social feed reader application.
ptg The example assumes that there is a service that stores specific blog entries, another serv-
ice that stores ratings data for each entry, and another service that returns a reading list for a specific user.
In the beginning of this chapter, you set up a gem for each of these services. The full code for these libraries is available at http://github.com/pauldix/service-oriented- design-with-ruby/tree/master/chapter_08/. The following sections show only the most important parts of it.
First, it’s worth talking about how to handle joins. In this example, there are three separate gems (PauldixEntries, PauldixRatings, and PauldixReadingList). An
entry can have many ratings and a ratings total (aggregate statistics). A rating and a ratings total belong to an entry. A reading list has many entries. These joins represent a fairly complex relationship to model across gem boundaries. If each one referenced the other, it would lead to circular gem dependencies, where the entries gem requires the ratings gem and vice versa.
To avoid circular dependencies, it’s a good idea to have at most one library that joins the gems together. This could happen in the Rails or Ruby application that ref- erences these gems, or it could occur in one gem that ties them all together. For this example, the PauldixReadingList gem performs the joins. Thus, it includes the requires for the PauldixEntries and PauldixRatings gems.
The reading list gem should have an object that stores configuration. This is nec- essary for setting up development, testing, and production environments:
class PauldixReadingList::Config
class << self; attr_accessor :host, :hydra; end end
The config object has class accessors to store a host object and a hydra object.
Remember from the previous chapter that a hydra object is the connection manager
object that Typhoeus uses to queue and run HTTP requests. This will be used later to queue up requests while in the ReadingList class. The host object is simply the host
for the HTTP service of the reading list.
Before looking at ReadingList, it’s worth taking a look at how it is used. The fol-
lowing example contains three sections, each of which would be placed in different parts of a Rails application:
# this would go in a service initializer in config/initializers/ HYDRA = Typhoeus::Hydra.new
ptg
PauldixEntries::Config.hydra = HYDRA PauldixRatings::Config.hydra = HYDRA PauldixReadingList::Config.hydra = HYDRA
# this would go in config/environments/development.rb host = "localhost:3000"
PauldixEntries::Config.host = host PauldixRatings::Config.host = host PauldixReadingList::Config.host = host
# code in a controller or presenter or non-ActiveRecord model reading_list = nil
PauldixReadingList::ReadingList.for_user("paul", :include => [:entry, :rating_total]) do |list|
reading_list = list end
HYDRA.run
# now the data can be used
entry = reading_list.entries.first entry.title
entry.body entry.author
entry.published_date
The first section of this example initializes the configuration objects for the three gems with the same hydra object. This means that any requests that the three libraries
queue up will be run by the same connection manager. The second section sets the host that each service can be found on. This is usually an environment-specific setting that depends on whether it is being run in development, testing, staging, or production.
The final section shows how the reading list can be used to get a list of entries for a specific user. The call to get the list for a user takes two arguments: the user name and the include options. This specific call tells the reading list to include the entries
and the rating totals for those entries. Finally, the call to get a list for a user requires a block to be passed in. This is because of the evented programming style of Typhoeus. The block is called when the request has completed.
ptg Finally, a call is made to hydra to run all queued requests. The important thing
to note about the example so far is that the request is not run by the reading list gem. The request is queued up by the reading list gem, but it is run outside this gem. This ensures that the hydra manager is able to run requests in parallel. A close look at the ReadingList class shows how this works:
class PauldixReadingList::ReadingList
attr_accessor :entry_ids, :previous_page, :next_page def initialize(json, options = {})
json = Yajl::Parser.parse(json) @next_page = json["next_page"] @entry_ids = json["entry_ids"] @previous_page = json["previous_page"] @includes = options[:include] end
def self.for_user(user_id, options = {}, &block) includes = options[:include] || []
request = Typhoeus::Request.new(get_by_id_uri (user_id))
request.on_complete do |response| list = new(response.body, options)
list.request_entries if includes.include?(:entry) list.request_rating_totals if includes.include? (:rating_total) block.call(list) end PauldixReadingList::Config.hydra.queue(request) end def self.get_by_id_uri(user_id)
ptg "http://#{PauldixReadingList::Config.host}/api/v1/ reading_list/users/#{user_id}" end def request_entries PauldixEntries::Entry.get_ids(entry_ids) do |entries| @entries = entries end end def request_rating_totals PauldixRatings::RatingTotal.get_ids(entry_ids) do |ratings| @rating_totals = ratings end end end
The reading list contains three pieces of data: a URI for the previous page in the list, the next page, and an array of the entry IDs of the current page. This previous- and next-page functionality is exactly like the design of the vote service in Chapter 5, “Implementing Services.” The initializer contains logic to parse a JSON string and assign the values.
The for_user method contains the first interesting bit of code. First, a Typhoeus Request object is created. It is a simple GET request. Another small method call is
made to construct the URI for the email. This will call to the config object for the
host name. The request object’s on_complete handler is then set. This isn’t called right
away. It will be called when the request has completed.
Inside the on_complete handler is where the reading list is instantiated and the
joins for entries and ratings occur. The instantiation parses the JSON response from the ReadingList service. The joins are made only if the includes have been specified
on the call to for_user. The joins will be addressed in a moment. For now, look at
the remainder of the for_user method. The last line in the on_complete block calls
the block that was passed in and gives it the newly created list, so these block calls occur only after the requests have completed.
Now it’s time to look at the joins. Both are identical, so we’ll look only at the join to the entry service. The method shown earlier in the reading list contains a single call to a method on Entry called get_ids. It takes an array of entry IDs and a block. Just
ptg like the reading list request for_user, the block is run only after the request has been
completed. In the completion, the entries instance variable is assigned the variable
that was yielded to the block.
Finally, here is the associated code in the Entry class in PauldixEntries that
shows the logic for getting and parsing entries:
def self.get_ids(ids, &block)
request = Typhoeus::Request.new(get_ids_uri(ids)) request.on_complete do |response|
json = Yajl::Parser.parse(response.body) entries = ids.map do |id|
new(json[id].merge("id" => id)) end block.call(entries) end PauldixEntries::Config.hydra.queue(request) end def self.get_ids_uri(ids) "http://#{PauldixEntries::Config.host}/api/v1/ entries?ids=#{ids.join(",")}" end
The get_ids method takes an array of entry IDs and a block. It creates a request
object. Just like the ReadingList class, it has a method for generating the URI for
those IDs. The URI that is generated points to the entry service with a comma- separated list of IDs as a query parameter. The on_complete block is then assigned to
the request. It parses the response body and creates ID objects. This multi-get call works as the multi-get for ratings works on the vote service created in Chapter 5, “Implementing Services.” It is a hash with the entry ID as a key, and the value is the entry JSON. The last line in the on_complete block calls the passed-in block with the
ptg The reading list has now set up all the request objects for getting the reading list
and running the requests for the specific ratings totals and entries in parallel. A single call to hydra.run runs all the requests. The final bit of logic is to combine the results
into a single collection of entry objects. The following code is in the ReadingList class: def entries
return @entries if @includes_run
include_rating_total = @includes.include?(:rating_total) if @includes.include?(:rating_total)
@entries.each_with_index do |entry, index| entry.rating_total = @rating_totals[index] end end @includes_run = true @entries end
The entries method assumes that all the requests have already been run. It loops
through the entries and assigns them their associated ratings totals. The important thing to take away from this rather long example is that these classes (ReadingList, Entry,
and Rating) never make requests. They only create the request objects and queue them
up. The responsibility for running the requests lies outside these classes. That way, if other requests need to be made for other services, they can be queued up as well.