- 2.1 The Entire App Inside a Component
- 2.2 ActiveRecord and Handling Migrations within Components
- 2.3 Handling Dependencies within Components
2.3 Handling Dependencies within Components
With Sportsball now having the ability to store teams and games, we can turn to the question of how to predict the outcome of games based on past performance. To this end, we would like to add a page that will allow us to pick two teams. Click a button labeled something like “Predict the winner!”, and see the application’s prediction of who is more likely to win as a result.
2.3.1 Using path Blocks for Specifying CBRA Dependencies
When we added our component, we used the following format for stating this dependency in the app’s Gemfile as follows:
Sample Gemfile reference using path option
1 gem 'app_component', path: 'components/app_component'
There is another way of stating this dependency using a block syntax, like so:
Same Gemfile reference using path block
1 path "components" do 2 gem "app_component" 3 end
The first visible difference is that there will be less code to write when the list of dependencies grows. That is, of course, only if we put future components into the same components folder. Additional components are simply added to the block:
Gemfile reference with multiple gems in path block
1 path "components" do 2 gem "app_component" 3 gem "component_a" 4 gem "component_b" 5 end
There is another difference between the path option and the block syntax. As Enrico Teotti reports (http://teotti.com/gemfiles-hierarchy-in-ruby-on-rails-component-based-architecture/), the block syntax will use a feature in bundler that ensures that transitive dependencies of AppComponent are looked up in the stated path folder. That means that it will not be necessary to state every transitive CBRA dependency explicitly in the Gemfile. Instead, only the direct dependencies need to be listed.
For example, imagine that in the previous Gemfile, component_a depends on component_c. Without path block syntax, we would need to add gem "component_c", path: "components/component_c" to our Gemfile. With path block syntax, we don’t have to. We get this for free since we already stated that the direct dependency component_a is listed.
Because of this, when using cobradeps to generate component diagrams, it is no longer necessary to specify a special group for direct dependencies; cobradeps simply assumes that all stated dependencies are direct dependencies.
2.3.2 Adding a Regular Gem: slim—Different Templating
Before we get to the part where we calculate a likely outcome, we need to add the new page. I find ERB unnecessarily verbose and avoid it when possible. Luckily, there are plenty of alternatives out there, and we can add the first dependency to our component that is not Rails.
I like slim (http://slim-lang.com/), as it greatly reduces the amount of code I have to write in comparison to ERB. Particularly, the number of chevrons (the “<” and “>” symbols so common in HTML) is greatly reduced, which I like a lot. Instead of adding the slim (https://rubygems.org/gems/slim) gem, we will add slim-rails (https://rubygems.org/gems/slim-rails), which in turn will require slim, but in addition adds Rails generators that can create views in slim syntax.
./components/app_component/app_component.gemspec - Add slim dependency
1 s.add_dependency "slim-rails"
This line should be added to AppComponent’s gemspec file to require slim-rails. Running bundle in the main app’s root folder, we should see slim-rails and slim being installed. Take a note of the exact version of slim-rails that is installed. At the time of writing, it is 3.1.3.
To make use of our new gem, let us use the current welcome page as an example and translate it into slim. In fact, the current welcome page still contains that default auto-generated text. We will use the opportunity to give the page a bit more meaningful content. So, let’s delete ./components/app_component/app/views/app_component/welcome/index.html.erb and create an index.html.slim in the same folder instead. The new page links to the admin pages of Team and Game.
./components/app_component/app/views/app_component/welcome/index.html.slim
1 h1 Welcome to Sportsball! 2 p Predicting the outcome of matches since 2015. 3 4 = link_to "Manage Teams", teams_path 5 | | 6 = link_to "Manage Games", games_path
When we fire up the server, however, and try to load the new homepage of our app, instead of a page, we get this “Template is missing” error depicted in Figure 2.5.
Figure 2.5. “Template missing” error after switching to slim
The reason for this is that, unlike Rails applications, which automatically require all the gems they are directly dependent upon, Rails engines do not. Check out Jonathan Rochkind’s blog post on the issue (https://bibwild.wordpress.com/2013/02/27/gem-depends-on-rails-engine-gem-gotcha-need-explicit-require/). We never require slim in our engine and it shows, because Rails reports only the template handlers that come standard: :handlers=>[:erb, :builder, :raw, :ruby], but not :slim as we would expect.
To fix the issue, we must explicitly require slim-rails in our AppComponent component, as follows. Note that I moved require "app_component/engine" into the scope of the AppComponent module. There is no programmatic need for that, but I like for gems to indicate this way which requires are local (i.e., within the gem) versus external (i.e., external gem dependencies).
./components/app_component/lib/app_component.rb - Require slim
1 require "slim-rails" 2 3 module AppComponent 4 require "app_component/engine" 5 end
We restart Rails to make it pick up the newly required gem and when we reload the homepage, we get the desired outcome, shown in Figure 2.6.
Figure 2.6. New welcome page written in slim
2.3.3 Locking Down Gem Versions
Let us take another closer look at the runtime dependencies now present in our AppComponent gemspec.
./components/app_component/app_component.gemspec - Production dependencies
1 s.add_dependency "rails", "~> 5.1.4" 2 s.add_dependency "slim-rails"
The Rails dependency was generated as ~> 5.1.4, allowing all versions of Rails 5.1.* (where * is 4 or greater).1 We added slim-rails without any version restrictions.
Commonly, when developing gems, authors strive to keep the range of acceptable versions of needed gems as broad as possible. This is to exclude the fewest number of developers who might be in different situations and on different update paths from using a gem. Only for incompatible differences, which would prevent the gem from working properly, would a restriction typically be added.
Contrary to this, in Rails applications, Gemfile.lock is added to source control to lock down the versions of all dependencies. This ensures that when code is run in different environments or by different people, it will behave the same.
So. We are building an app, but are using gems. Which strategy should we take? Should we have a loose or a tight version policy? Well, I lock down all runtime dependencies in components to exact versions, like the following:
./components/app_component/app_component.gemspec - Production dependencies locked down
1 s.add_dependency "rails", "5.1.4" 2 s.add_dependency "slim-rails", "3.1.3"
The reason for the version lockdown has to do with the testing of the component and is based on a couple of assumptions. I assume that you write:
Automated tests for your code
Different kinds of tests, like unit, functional, integration, and feature
Tests at the lowest possible level
If these assumptions are true for you, you will attempt to verify all of the internals of the component within the component itself. That also means you will not be testing the internals outside of the component, that is, in the context of the completed Rails application. What would happen if, in this situation, the versions of dependencies of the component somehow drifted from the ones used in the Rails app? That would be an untested dependency in production: The functioning of the component would be verified against a version of its dependencies that are not used in production. The version lockdown enforces that all components bound together by the Rails app run with and are tested against the same version of a dependency. Testing components is the topic of the next section, Section 3.1.
For this section, suffice it to say that there is merit to keeping the versions of dependencies in sync among all parts of the app. In the running app, only one version of every dependency is going to be loaded; we might as well try not to be surprised by how it works.
2.3.4 Adding the Development Version of a Gem: Trueskill—A Rating Calculation Library
We can now turn to the prediction of the outcome of future games. If we do not want to be in the business of figuring out how to do that, we better find a gem that will do such a calculation for us. Luckily, there is a lot of theory we could potentially draw from, such as ranking algorithms, rating algorithms, or Bayesian networks (https://www.cs.ubc.ca/~murphyk/Bayes/bnintro.html). I started my search for a fitting gem with the FIFA World Rankings page on Wikipedia, which, while not explaining how the official rankings are calculated, mentions an alternative, the Elo rating system (The rating of chess players, past and present by Aspad Elo, 1978). Elo was created for use in chess but is now used in many competitor-versus-competitor games. An improvement to Elo is the Glicko rating system (http://www.glicko.net/glicko.html), which in turn was extended by Microsoft to TrueSkill (http://trueskill.org/), a system that works for multiplayer games. For all of these—Elo, Glicko, and Trueskill—we can find corresponding gems on rubygems (https://rubygems.org). For the following, we are going to work with the trueskill gem (https://rubygems.org/gems/trueskill). Not only does the idea of assessing a team’s strength while taking into account the players’ strengths sound appealing, but the gem also poses a nice little problem: It is totally outdated. At the time of writing, the last version of the gem was published in 2011. However, code has been contributed to forks of the original project until late 2014.
The version of the code we would like to use for trueskill is commit e404f45af5 (https://github.com/benjaminleesmith/trueskill/tree/e404f45af5b3fb86982881ce064a9c764cc6a901) on the benjaminleesmith fork.2 The problem is that we can only specify gems to depend on published versions of other gems. There is no way for us to set a restriction based on a commit SHA. For gems that are intended to be published, this makes sense: They should not depend on code that was not also published and distributed as a gem.
To work around this problem, we have to employ the gem’s gemspec and its Gemfile at the same time.
./components/app_component/Gemfile
1 source "https://rubygems.org" 2 3 gemspec 4 5 gem "trueskill", 6 git: "https://github.com/benjaminleesmith/trueskill", 7 ref: "e404f45af5b3fb86982881ce064a9c764cc6a901"
./components/app_component/app_component.gemspec – Dependencies
1 s.add_dependency "rails", "5.1.4" 2 s.add_dependency "slim-rails", "3.1.3" 3 s.add_dependency "trueskill"
The Gemfile in a gem’s directory is used during development of the gem, just like the Gemfile in a Rails app. When bundle is called in this directory, it will install all the gem dependencies listed there. The special line gemspec tells bundler to look for a gemspec file in the current directory and add all dependencies specified there to the current bundle. In our case, the gemspec states that AppComponent has a runtime dependency on trueskill and the Gemfile restricts this to be from the specified git URL at the given SHA.
Bundle AppComponent with trueskill (some results omitted). Execute in ./components/app_component
$ bundle The latest bundler is 1.16.0.pre.3, but you are currently running 1.15.4. To update, run `gem install bundler --pre` Fetching https://github.com/benjaminleesmith/trueskill Fetching gem metadata from https://rubygems.org/.......... Fetching version metadata from https://rubygems.org/.. Fetching dependency metadata from https://rubygems.org/. Resolving dependencies... . . . Using trueskill 1.0.0 from https://github.com/benjaminleesmith/\ trueskill (at e404f45@e404f45) . . . Bundle complete! 3 Gemfile dependencies, 46 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed.
When bundling the component, we see git checking out the repository specified and using the correct SHA. However, bundling the main app will reveal that it does not take into account the restriction posed by the Gemfile. That is because the Gemfile of any gem is ignored by other gems or apps depending on it (again, due to fact that the common expectation is for a gem to be published).
To work around this, there is no other way than to ensure that the version of the dependency is enforced by the app itself. That leads to an exact duplicate of the trueskill line from AppComponent’s Gemfile in the main app’s Gemfile.
New lines in ./Gemfile
1 gem "trueskill", 2 git: "https://github.com/benjaminleesmith/trueskill", 3 ref: "e404f45af5b3fb86982881ce064a9c764cc6a901"
And just like with slim-rails, we need to explicitly require the trueskill gem in AppComponent to make sure it is loaded.
./components/app_component/lib/app_component.rb - Requiring the trueskill dependency
1 require "slim-rails" 2 require "saulabs/trueskill" 3 4 module AppComponent 5 require "app_component/engine" 6 end
2.3.5 Adding Predictions to the App
With models, scaffolds for administration, and the rating calculation library in place, we can turn to implementing the first iteration of game prediction.
Let’s create a cursory sketch of how our models might interact to generate a prediction. A predictor object might get a collection of all the games it should consider. As we are using an external library, we don’t really know what is going on. The best way we can describe it is that the predictor learns (about the teams or the games). Because of this, we will make learn the first method of the public interface of the class.
After the predictor has learned the strengths of teams it can, given two teams, predict the outcome of their next match. predict becomes the second method of the public interface.
./components/app_component/app/models/app_component/predictor.rb
1 module AppComponent 2 class Predictor 3 def initialize(teams) 4 @teams_lookup = teams.inject({}) do |memo, team| 5 memo[team.id] = { 6 team: team, 7 rating: [Saulabs::TrueSkill::Rating.new( 8 1500.0, 1000.0, 1.0)] 9 } 10 memo 11 end 12 end 13 14 def learn(games) 15 games.each do |game| 16 first_team_rating = 17 @teams_lookup[game.first_team_id][:rating] 18 second_team_rating = 19 @teams_lookup[game.second_team_id][:rating] 20 game_result = game.winning_team == 1 ? 21 [first_team_rating, second_team_rating] : 22 [second_team_rating, first_team_rating] 23 Saulabs::TrueSkill::FactorGraph.new( 24 game_result, [1, 2]).update_skills 25 end 26 end 27 28 def predict(first_team, second_team) 29 team1 = @teams_lookup[first_team.id][:team] 30 team2 = @teams_lookup[second_team.id][:team] 31 winner = higher_mean_team(first_team, second_team) ? 32 team1 : team2 33 AppComponent::Prediction.new(team1, team2, winner) 34 end 35 36 def higher_mean_team(first_team, second_team) 37 @teams_lookup[first_team.id][:rating].first.mean > 38 @teams_lookup[second_team.id][:rating].first.mean 39 end 40 end 41 end
To start, initialize creates a lookup hash from all the teams it is handed that allows the Predictor class to efficiently access teams and their ratings by a team’s id.
Inside of learn, the predictor loops over all the games that were given. It looks up the ratings of the two teams playing each game. The teams’ ratings are passed into an object from trueskill called FactorGraph in the order “winner first, loser second” so that the update_skills method can update the ratings of both teams.
predict simply compares the mean rating values of the two teams and “predicts” that the stronger team will win. It returns a Prediction object, which we will look at next.
There is not much going on in the Prediction class. It is simply a data object that holds on to the teams participating in the prediction, as well as the winning team.
./components/app_component/app/models/app_component/prediction.rb
1 module AppComponent 2 class Prediction 3 attr_reader :first_team, :second_team, :winner 4 5 def initialize(first_team, second_team, winner) 6 @first_team = first_team 7 @second_team = second_team 8 @winner = winner 9 end 10 end 11 end
The PredictionsController has two actions: new and create. The first, new, loads all teams so they are available for the selection of the game to be predicted. create creates a new Predictor and then calls learn and predict in sequence to generate a prediction.
./components/app_component/app/controllers/app_component/predictions_controller.rb
1 require_dependency "app_component/application_controller" 2 module AppComponent 3 class PredictionsController < ApplicationController 4 def new 5 @teams = AppComponent::Team.all 6 end 7 8 def create 9 predictor = Predictor.new(AppComponent::Team.all) 10 predictor.learn(AppComponent::Game.all) 11 @prediction = predictor.predict( 12 AppComponent::Team.find(params["first_team"]["id"]), 13 AppComponent::Team.find(params["second_team"]["id"])) 14 end 15 end 16 end
For completeness, we list the two views of the prediction interface as well as a helper that is used to generate the prediction result that will be displayed as a result.
./components/app_component/app/views/app_component/predictions/ new.html.slim
1 h1 Predictions 2 3 = form_tag prediction_path, method: "post" do |f| 4 .field 5 = label_tag :first_team_id 6 = collection_select(:first_team, :id, @teams, :id, :name) 7 8 .field 9 = label_tag :second_team_id 10 = collection_select(:second_team, :id, @teams, :id, :name) 11 .actions = submit_tag "What is it going to be?", class: "button"
./components/app_component/app/views/app_component/predictions/create.html.slim
1 h1 Prediction 2 3 =prediction_text @prediction.first_team, @prediction.second_team, @prediction.winner 4 5 .actions 6 = link_to "Try again!", new_prediction_path, class: "button"
./components/app_component/app/helpers/app_component/predictions_helper.rb
1 module AppComponent 2 module PredictionsHelper 3 def prediction_text(team1, team2, winner) 4 "In the game between #{team1.name} and #{team2.name} " + 5 "the winner will be #{winner.name}" 6 end 7 end 8 end
Finally, we can add a link to the prediction to the homepage to complete this feature.
./components/app_component/app/views/app_component/welcome/ index.html.slim
1 h1 Welcome to Sportsball! 2 p Predicting the outcome of matches since 2015. 3 4 = link_to "Manage Teams", teams_path 5 | | 6 = link_to "Manage Games", games_path 7 | | 8 = link_to "Predict an outcome!", new_prediction_path
With the changes from this section in place, we can navigate to http://localhost:3000/ to see a new homepage (see Figure 2.7) from which we can navigate to our new prediction section. Figure 2.8 shows how we can request a new prediction. Finally, in Figure 2.9, we see the result of a successful prediction.
Figure 2.7. Sportsball homepage with link to predictions
Figure 2.8. Requesting the prediction of a game
Figure 2.9. Showing the prediction result
With the conclusion of this chapter, Sportsball is fully functional! Well, if you can call it functional at this point. In any case, the current state of the application will allow us to discuss and analyze many aspects of componentization. We will start in Chapter 3 by testing our component and application.