Today I will guide you through a class refactoring done in one of the projects I work on. Ideally we should be doing this test driven, but - as this is for fun - I won’t today.
class VirusScanner
attr_reader :file_path
def initialize(file_path)
@file_path = file_path
end
def infected?
!Settings['virusscan_off'] && scan != 'OK'
end
private
def scan
return @scan if @scan
command = Cocaine::CommandLine.new('clamscan', "--no-summary :file").command(file: file_path)
_, stdout, stderr = ::Open3.popen3(command)
@scan = parse_clamscan_output(stdout.gets, stderr.gets)
end
def parse_clamscan_output(stdout, stderr)
stderr = stderr.to_s.split("\n").reject{|line| line =~ /\ALibClamAV Warning:/ }.join("\n")
raise RuntimeError, "The 'clamscan' command line was unable to execute. #{stderr}" if !stderr.blank?
stdout.to_s.split("\n").last.split(":")[1].strip
end
end
This is a simple interface around the clamscan scanner. This code has several smells. 😷
Our primary goal is to keep backward compatibility with the rest of the code base, while cleaning up the code.
By doing this separation we can focus on the the VirusScanner without needing to worry about clamscan.
class VirusScanner
attr_reader :file_path
def initialize(file_path)
@file_path = file_path
end
def infected?
return false if Settings['virusscan_off']
ClamScan.new(scanner).infected?
end
end
class ClamScan
attr_reader :file_path
def initialize(file_path)
@file_path = file_path
end
def infected?
scan != 'OK'
end
private
def scan
#...
end
def parse_clamscan_output(stdout, stderr)
#...
end
end
Cool. 😎
I want to get rid of the app specific ‘virusscan_off’ setting. Honestly I’m ashamed I ever wrote that 😳
The first step is to make the behavior of the class configurable.
class VirusScanner
attr_reader :file_path
def initialize(file_path, options = {})
@file_path = file_path
@scanner = options.fetch(:scanner) {
->(file_path) { ClamScan.new(scanner).infected? }
}
end
def infected?
return false if Settings['virusscan_off']
@scanner.call(file_path)
end
end
We created an easy way to understand the interface for VirusScanner’s scanner. It’s now also very easy to extract the ‘virusscan_off’ setting.
scanner_with_off_switch = ->(*args) {
Settings['virusscan_off'] ? false : ClamScan.new(scanner).infected?
}
This works! However! This breaks one of our earlier requirements: “keep backward compatibility”. This solution also requires the developer to know what the default scanner is, and that a new smell; a smell I don’t like. 😡
Let’s do better!
class VirusScanner
def self.default_scanner
@default_scanner ||= -> (file_path) { ClamScan.new(scanner).infected? }
end
def self.default_scanner=(scanner)
@default_scanner = scanner
end
attr_reader :file_path
def initialize(file_path, options = {})
@file_path = file_path
@scanner = options.fetch(:scanner) { self.class.default_scanner }
end
def infected?
@scanner.call(file_path)
end
end
old_scanner = VirusScanner.default_scanner
VirusScanner.default_scanner = ->(*args) {
Settings['virusscan_off'] ? false : old_scanner.call(*args)
}
Ok. I’m pretty happy with that. 😌
Now we turn to the ClamScan
class.
Cocaine has a lot of powerful features, has tones of great use cases, and I had a phase where I was totally addicted to it. 😇
Here Cocaine is only used to safely use a file name in a shell command. 🐌 Shellwords - a standard lib - can deal with this easily.
class ClamScan
#...
private
def scan
return @scan if @scan
command = Shellwords.join(['clamscan', '--no-summary', file_path])
_, stdout, stderr = ::Open3.popen3(command)
@scan = parse_clamscan_output(stdout.gets, stderr.gets)
end
def parse_clamscan_output(stdout, stderr)
stderr = stderr.to_s.split("\n").reject{|line| line =~ /\ALibClamAV Warning:/ }.join("\n")
raise RuntimeError, "The 'clamscan' command line was unable to execute. #{stderr}" if !stderr.blank?
stdout.to_s.split("\n").last.split(":")[1].strip
end
end
Open3.popen3 opens a stdin pipe, we don’t need that at all. A better choice would be Open3.capture3.
class ClamScan
#...
private
def scan
return @scan if @scan
command = Shellwords.join(['clamscan', '--no-summary', file_path])
stdout, stderr, _ = Open3.capture3(command)
@scan = parse_clamscan_output(stdout, stderr)
end
def parse_clamscan_output(stdout, stderr)
stderr = stderr.split("\n").reject{|line| line =~ /\ALibClamAV Warning:/ }.join("\n")
raise RuntimeError, "The 'clamscan' command line was unable to execute. #{stderr}" if !stderr.blank?
stdout.split("\n").last.split(":")[1].strip
end
end
Let us also clean the code… 🚿
class ClamScan
attr_reader :file_path
def initialize(file_path)
@file_path = file_path
end
def infected?
@infected ||= begin
stdout, stderr, status = Open3.capture3(command)
!clamscan_result_safe?(stdout, stderr, status)
end
end
private
def command
Shellwords.join(['clamscan', '--no-summary', file_path])
end
def clamscan_result_safe?(stdout, stderr, status)
raise_on_stderr(stderr)
stdout.lines.last.split(':', 2).last.strip == 'OK'
end
def raise_on_stderr(stderr)
clean_stderr = stderr.lines.reject { |line| line =~ /\ALibClamAV Warning:/ }
return if clean_stderr.empty?
fail RuntimeError, "The 'clamscan' command line was unable to execute. #{clean_stderr.join("\n")}"
end
end
Doesn’t that look a lot better? We did quite a bunch of changes here.
fail
over raise
for original errorsOnly one more line to go. There is still a train wreck in the clamscan_result_safe?
method.
def clamscan_result_safe?(stdout, stderr, status)
raise_on_stderr(stderr)
result_line = stdout.lines.last.strip
scanned_file, result = result_line.split(':' 2)
result == 'OK'
end
That’s a lot easier to understand. 🎓
This improvement also hints at an other improvement I could add: By making sure the scanned_file
actually matches the @file_path
given, we would be even more sure about the behavior of this class.
There is still room for improvement here.
Let me know if you like more posts like this. Also, let me know if you are interested to seem me do this same refactoring using TDD. Or, if you have a piece of code you’d like me to refactor; send it to me! 📩