Refactoring with the Strategy Pattern
About a year ago, as part of the technical book club on the labs team at Flatiron School, we read the infamous Design Patterns - Elements of Reusable Object-Oriented Software by the Gang of Four. Perhaps tried to read is more accurate. As a beginner and Rubyist, I found that much of the original book went over my head, so I instead the updated, Ruby version of the patterns, Design Patterns in Ruby, by Russ Olsen.
Although a lot of that went over my head too, as a group, we decided that most of the design patterns boil down to the strategy pattern, so that's the one that I've tried to focus on and incorporate into my applications as much as possible.
Strategy Pattern
According to OO Design.com, the intent of the strategy pattern is to "define a family of algorithms, encapsulate each one, and make them interchangeable." The way I've interpreted this is that when a client (whatever's going to be calling your code) needs to perform an operation that varies slightly based on the situation, whether that's in response to input or based on different conditions that can be detected, you can use the strategy pattern to simplify the interface.
Strategy Pattern in Practice (A Use Case)
Recently, I was working on updating a script to batch update a bunch of
curriculum used on Learn.co. Already in existence was a
Learn Writer Script,
which a colleague of mine wrote to open source our curriculum. This script
essentially added the three files needed to make valid content for Learn,
the .learn
, CONTRIBUTING.md
, and LICENSE.md
. That was all well and
good when that's all we needed to do, but a new use case, the need to
reformat a bunch of readmes that were incorrectly created as labs.
This to me sounded like a perfect use case for the strategy pattern because I wanted the client to be able to vary the algorithm such that it could still open source curriculum and also toggle labs to readmes. The easy patch, of course, would be to just add some janky code that would handle that, but I felt this would be an excellent excuse to make the script more flexible and enable additional strategies to be incorporated in the future.
Using the Strategy Pattern
After inspecting the existing code, I noticed that there was a class called
RepoWriter
that handled writing the three required files if they didn't
exist. This class both configured the GitHub client (using Octokit) and
handled checking the GitHub repo for the files and writing them if they
didn't already exist. (We store our curriculum in GitHub repositories.)
In order to make this more flexible, I decided to turn this class into a
strategy, OpenSource
. Then I defined a Base Strategy
, that would
abstract away all the shared behavior among all strategies. Finally, I
defined a new class, ToggleToReadme
that would remove files such as
index.html
, index.js
, and package.json
, and recursively remove
everything in a spec
or test
directory.
Prior to my refactor, the bin/run
file would take an array of repos, and
pass them into the RepoWriter
, then call the method write_to_repo
on it.
# /bin/run
#!/usr/bin/env ruby
require_relative "../config/environment"
# get the secret octo_token
secrets = YAML::load(File.open(Dir.pwd + '/config/application.yml'))
# take array of repo urls, collect names
repos = ["https://github.com/SophieDeBenedetto/test-writer", "https://github.com/SophieDeBenedetto/test-writer-2"]
repos = repos.map { |repo| Repo.new(repo) }
# write content for each repo
repos.each do |repo|
RepoWriter.new(repo, secrets).write_to_repo
end
The RepoWriter
class was defined as such:
#
class RepoWriter
VALID_LICENSE = File.open(File.expand_path(File.dirname(__FILE__)) + '/fixtures/LICENSE.md')
VALID_CONTRIBUTING = File.open(File.expand_path(File.dirname(__FILE__)) + '/fixtures/CONTRIBUTING.md')
VALID_DOT_LEARN = File.open(File.expand_path(File.dirname(__FILE__)) + '/fixtures/VALID_DOT_LEARN.yml')
APPLICATION_TOKENS_PATH = File.open(Dir.pwd + '/config/application.yml')
attr_accessor :client, :repo_content, :repo_name, :owner_name, :secrets
def initialize(repo, secrets)
@secrets = secrets
configure_client
@repo_name = repo.name
@owner_name = repo.owner
@repo_content = {contributing: {sha: " ", present: false}, license: {sha: " ", present: false}, dot_learn: {sha: " ", present: false}}
end
def configure_client
@client ||= Octokit::Client.new(:access_token => self.secrets["octo_token"])
end
def write_license
license_content = File.read(VALID_LICENSE)
client.create_contents("#{owner_name}/#{repo_name}", "LICENSE.md", "create license", license_content)
end
def write_contributing
contributing_content = File.read(VALID_CONTRIBUTING)
client.create_contents("#{owner_name}/#{repo_name}", "CONTRIBUTING.md", "create contributing", contributing_content)
end
def write_dot_learn
dot_learn_content = File.read(VALID_DOT_LEARN)
client.create_contents("#{owner_name}/#{repo_name}", ".learn", "create dot_learn", dot_learn_content)
end
def check_for_file_presence
files = client.contents("#{owner_name}/#{repo_name}", path: "")
files.each do |file|
if file[:name] == "CONTRIBUTING.md"
self.repo_content[:contributing][:sha] = file[:sha]
self.repo_content[:contributing][:present] = true
elsif file[:name] == "LICENSE.md"
self.repo_content[:license][:sha] = file[:sha]
self.repo_content[:license][:present] = true
elsif file[:name] == ".learn"
self.repo_content[:dot_learn][:sha] = file[:sha]
self.repo_content[:dot_learn][:present] = true
end
end
end
def find_or_create(type)
unless self.repo_content[type.to_sym][:present]
send("write_#{type}")
end
end
def write_to_repo
check_for_file_presence
["license", "dot_learn", "contributing"].each do |type|
find_or_create(type)
end
end
end
After a bunch of refactoring, I decided that I'd rewrite the bin/run
as follows:
# bin/run
#!/usr/bin/env ruby
require_relative "../config/environment"
# get the secret octo_token
secrets = YAML::load(File.open(Dir.pwd + '/config/application.yml'))
#parse strategy from command line
strategy = ARGV.first
raise "Strategy undefined" unless BaseStrategy::VALID_STRATEGIES.include?(strategy)
# parse CSV of repo urls
csv_path = ARGV[1]
raise "Must provide path to CSV" if csv_path.empty?
repos = []
CSV.foreach(csv_path) do |row|
repos << Repo.new(row.first)
end
repos.each do |repo|
Object.const_get(strategy).new(repo, secrets).execute
end
So here, I'm allowing the user to pass in a strategy from the command line. That strategy is then validated. I also did a little refactor to allow the program to accept a path to a CSV, which I would then parse.
What's really interesting for me is turning the strategy passed from the command
line a class, creating an instance of that class, and then calling execute
on it, service-object style.
This bit of metaprogramming allows us to dynamically execute the correct strategy based
on the user's input.
Then, as I mentioned before, I encapsulated each "algorithm" into a separate class. Within
a lib/strategies
directory, I placed the BaseStrategy
, OpenSource
, and ToggleToReadme
classes.
In the new paradigm, BaseStrategy
, handled configuring the client and generating a hash of relevant files.
# lib/strategies/base_strategy.rb
class BaseStrategy
VALID_STRATEGIES = ["OpenSource", "ToggleToReadme"]
def initialize(repo, secrets)
@secrets = secrets
configure_client
@repo_name = repo.name
@owner_name = repo.owner
@repo_content = generate_repo_content_hash
end
def configure_client
@client ||= Octokit::Client.new(:access_token => self.secrets["octo_token"])
end
def generate_repo_content_hash
relevant_files.each_with_object({}) do |file, hash|
hash[filename_to_sym(file)] = {sha: " ", present: false}
end
end
def filename_to_sym(filename)
filename.gsub(".", "_").to_sym
end
end
OpenSource
and ToggleToReadme
both inherit from BaseStrategy
, but are specialized to handle their specific operations.
# lib/strategies/open_source.rb
class OpenSource < BaseStrategy
VALID_LICENSE = File.open(File.expand_path(File.dirname(File.dirname(__FILE__))) + '/fixtures/LICENSE.md')
VALID_CONTRIBUTING = File.open(File.expand_path(File.dirname(File.dirname(__FILE__))) + '/fixtures/CONTRIBUTING.md')
VALID_DOT_LEARN = File.open(File.expand_path(File.dirname(File.dirname(__FILE__))) + '/fixtures/VALID_DOT_LEARN.yml')
APPLICATION_TOKENS_PATH = File.open(Dir.pwd + '/config/application.yml')
attr_accessor :client, :repo_content, :repo_name, :owner_name, :secrets
def execute
check_for_file_presence
write_to_repo
end
private
def relevant_files
["LICENSE.md", "CONTRIBUTING.md", ".learn"]
end
def write_licensemd
license_content = File.read(VALID_LICENSE)
client.create_contents("#{owner_name}/#{repo_name}", "LICENSE.md", "create license", license_content)
end
def write_contributingmd
contributing_content = File.read(VALID_CONTRIBUTING)
client.create_contents("#{owner_name}/#{repo_name}", "CONTRIBUTING.md", "create contributing", contributing_content)
end
def write_learn
dot_learn_content = File.read(VALID_DOT_LEARN)
client.create_contents("#{owner_name}/#{repo_name}", ".learn", "create dot_learn", dot_learn_content)
end
def check_for_file_presence
files = client.contents("#{owner_name}/#{repo_name}", path: "")
files.each do |file|
if relevant_files.include?(file[:name])
self.repo_content[filename_to_sym(file[:name])][:sha] = file[:sha]
self.repo_content[filename_to_sym(file[:name])][:present] = true
end
end
end
def find_or_create(file)
unless self.repo_content[filename_to_sym(file)][:present]
send("write_#{file.gsub(".", "").downcase}")
end
end
def write_to_repo
relevant_files.each do |file|
find_or_create(file)
end
end
end
And finally, ToggleToReadme
uses Octokit
slightly differently so that it can recursively remove the test
or spec
directory.
# lib/strategies/toggle_to_readme.rb
class ToggleToReadme < BaseStrategy
attr_reader :client, :repo_name, :owner_name, :secrets, :repo_content, :files, :spec_files
def initialize(repo, secrets)
super(repo, secrets)
@spec_files = []
end
def execute
get_files
check_for_file_presence
delete_files
check_for_spec_directory
delete_specs
end
private
def relevant_files
[ "index.html", "index.js", "package.json"]
end
def delete_files
relevant_files.each do |file|
file_hash = repo_content[filename_to_sym(file)]
if file_hash[:present]
delete(file, file_hash[:sha])
end
end
end
def delete_specs
spec_files.each do |file|
delete(file[:path], file[:sha])
end
end
def delete(filename, sha)
unless filename == "spec" || filename == "test"
client.delete_contents("#{owner_name}/#{repo_name}", "#{filename}", "deleting #{filename}", sha)
end
end
def get_files
sha = client.ref("#{owner_name}/#{repo_name}", "heads/master").object.sha
sha_base_tree = client.commit("#{owner_name}/#{repo_name}", sha).commit.tree.sha
@files = client.tree("#{owner_name}/#{repo_name}", sha_base_tree, :recursive => true).tree
end
def check_for_file_presence
files.each do |file|
if relevant_files.include?(file[:path])
self.repo_content[filename_to_sym(file[:path])][:sha] = file[:sha]
self.repo_content[filename_to_sym(file[:path])][:present] = true
end
end
end
def check_for_spec_directory
files.each do |file|
if file.path.include?("test") || file.path.include?("spec")
spec_files << { path: file.path, sha: file.sha }
end
end
end
end
There's of course still room to improve this script, but refactoring has been super fun!
Now, if we ever need to batch update curriculum again in a slightly different way, we
could just define a new strategy in lib/strategies
, whitelist it within BaseStrategy
,
and then just run the script. Whoot! Hooray for the strategy pattern.