Ruby Chips - Part 2
In the first installment of Ruby Chips, I showed you a very basic class to log debug messages to a text file. To take our logging capabilities to the next step, today we are going to concentrate on adding the ability to log messages to multiple locations, such as email or a database, not just a text file.
The goal is to keep a simple syntax for the end user (the developer) and hide the complexity of multiple logging destinations. So a call to “log” won't just execute the log method in the SimpleFileLogger, it will also execute the log method of other, as of yet unwritten logging classes, such as a DatabaseLogger or EmailLogger.
Rather than dwell on the implementation details of how a database or email logging class would work, I'll just define the following skeleton classes, using the #{expression} syntax for string substitution.
class MyDatabaseLogger
def MyDatabaseLogger.log(message)
puts "Logged #{message} to database."
end
end
class MyEmailLogger
def MyEmailLogger.log(message)
puts "Emailed #{message}."
end
end
Now the next step is stringing together these 3 logging classes, so that one logging call will feed into all of them.
To do this we'll create one master or central logging class which we'll route all of our logging through:
class MyLogDistributor
def MyLogDistributor.log(message)
MyEmailLogger.log(message)
MyDatabaseLogger.log(message)
MySimpleFileLogger.log(message)
end
end
No matter what language you are used to programming in, seeing something like this should start to make you feel nervous.
Why? This implementation does have the advantage of simplicity and if you really knew you'd always want to log to these 3 sources, then maybe it would be fine. More likely, depending on the application, or even the situation, you will want to be able to dynamically determine which sources to log to. In addition, while class methods eliminate the need to keep track of any object instances, they are also less obviously configurable. Do you always want to log to the same filename? To the same email address, etc?
We will address these various criticisms as we go, with the first task being to make our central logging component a little more configurable.
The following code listing shows all 3 logging classes (with minor style cleanup) and the new LogDistributor class, along with the updated test harness.
class MySimpleFileLogger
def log(msg)
File.open("log.txt","a") do |file| #opens file in append mode and closes it after write
file.puts msg
puts "Logged #{msg} to file."
end
end
end
class MyDatabaseLogger
def log(msg)
puts "Logged #{msg} to database."
end
end
class MyEmailLogger
def log(msg)
puts "Logged #{msg} to email."
end
end
class MyLogDistributor
def initialize
@logSinks = Array.new
end
def add_sink(sink)
@logSinks << sink
end
def remove_sink(sink)
@logSinks.delete(sink)
end
def log(msg)
@logSinks.each do |sink|
sink.log(msg)
end
end
end
myLogger = MyLogDistributor.new
myLogger.add_sink(MySimpleFileLogger.new)
myLogger.add_sink(MyDatabaseLogger.new)
myLogger.add_sink(MyEmailLogger.new)
100.times do |number|
Thread.new(number) do |number|
myLogger.log("Message #" + number.to_s)
end
end
Thread.list.each do |t|
if (t != Thread.current) then
t.join
end
end
The new LogDistributor keeps an array of all the loggers (sinks) that it needs to log a message to. When a message is sent, via the log method, each sink's log method is called.
As a dynamically or loosely typed language, you can express this code in the compact form I've shown above. You don't need to setup an inheritance hierarchy or cast anything. Things kind of just work like you would expect.
Loose typing does open the door to some runtime errors if you were to add a sink that did not respond to the log method. Assuming you tested your code you would quickly find this error, however, if you really wanted to be careful, you could use the following defensive code style in the add_sink method:
def add_sink(sink)
if (sink.respond_to?(:log))
@logSinks << sink
else
raise "A sink must respond to the log method"
end
end
The method “respond_to?” is a method that all objects in Ruby can use to test if they have the given method defined. “Raise” is the equivalent of the throw keyword in C#.
Next time, we'll continue to improve on our logging component and add some routing, to determine which messages go where.