One thing I always liked about Common Lisp is its mighty condition system. Conditions provide what Exceptions do in Ruby, and more.
Usually, when an Exception
is raised in Ruby, it may be rescued by
providing an error handler (possibly even a generic one that displays
an error page in a web application, for example), or ignored, which
will end the program. One thing you cannot do in Ruby is to use
Exceptions to emit warnings, as there is no way to resume the
erroneous code when you are already in the handler.
Not till now, at least. :-) I quickly coded an Common Lispish system to restart exceptions. It’s best shown by developing a simple application, so I’ll port the piece of code that explains Conditions in Practical Common Lisp.
This is an interactive Ruby description. Fire up your irb
and paste
the very last piece of code. Now, please copy and paste the code as
you are reading to try the examples live. Happy hacking.
First, here is the code without any error handling:
class LogAnalyzer
def initialize(logs)
@logs = logs
end
def analyze
@logs.each { |log|
analyze_log log
}
end
def analyze_log(log)
parse_log(log).each { |line|
p line
}
end
def parse_log(log, &block)
entries = []
log.split("\n").each { |line|
entries << parse_log_entry(line)
}
entries
end
def parse_log_entry(line)
line[/^\d+ (\w+)/, 1]
end
end
Let’s call it with three example logs:
def test
LogAnalyzer.new([<<LOGA, <<LOGB, <<LOGC]).analyze
1 foo
2 bar
3 quux
LOGA
"?$%
&)%$
-,$"&
4 good_but_to_late
LOGB
1 foo
bar
3 quux
LOGC
end
test
You now should see this output:
"foo"
"bar"
"quux"
nil
nil
nil
"good_but_to_late"
"foo"
nil
"quux"
All those nil
s are mistakes in our log files! Eww. We should raise
an error if there is an erroneous line in our logs, and then ignore it
so our program will not totally stop:
class LogAnalyzer
class MalformedLogEntryError < StandardError; end
def parse_log(log, &block)
entries = []
log.split("\n").each { |line|
begin
entries << parse_log_entry(line)
rescue MalformedLogEntryError
# Skip
end
}
entries
end
def parse_log_entry(line)
line[/^\d+ (\w+)/, 1] or raise MalformedLogEntryError
end
end
This will result in (rerun test
):
"foo"
"bar"
"quux"
"good_but_to_late"
"foo"
"quux"
Now, this looks much better, but we obviously should tell the user we
had to skip some entries. Now, this is where the trouble starts.
Usually you would not want to put that notification code into
parse_log
, because parse_log
is really our backend… it should go
to analyze
. Here, restartable expections become a need:
class LogAnalyzer
def analyze
@logs.each { |log|
begin
analyze_log log
rescue MalformedLogEntryError => e
warn "Skipped invalid line: #{e.data}"
e.restart :skip_line
end
}
end
def parse_log(log, &block)
entries = []
log.split("\n").each { |line|
begin
entries << parse_log_entry(line)
rescue MalformedLogEntryError => e
e.data = line
e.restart_case(:skip_line) {
# Skip
}
e.handle_restarts
end
}
entries
end
end
Now we get this output:
"foo"
"bar"
"quux"
Skipped invalid line: "?$%
Skipped invalid line: &)%$
Skipped invalid line: -,$"&
"good_but_to_late"
Skipped invalid line: bar
"foo"
"quux"
Ignoring invalid entries is obviously not the only way to handle
invalid logfiles (one could say it’s a rather bad way). We may want
to replace single lines with something different. Let’s implement
this approach, called use_value
just like in Common Lisp:
class LogAnalyzer
def analyze
@logs.each { |log|
begin
analyze_log log
rescue MalformedLogEntryError => e
e.restart :use_value, "xyzzy"
end
}
end
def parse_log(log, &block)
entries = []
log.split("\n").each { |line|
begin
entries << parse_log_entry(line)
rescue MalformedLogEntryError => e
e.data = line
e.restart_case(:skip_line) {
# Skip
}
e.restart_case(:use_value) { |replacement|
entries << replacement
}
e.handle_restarts
end
}
entries
end
end
Works beautifully, now all invalid lines get xyzzy
:
"foo"
"bar"
"quux"
"xyzzy"
"xyzzy"
"xyzzy"
"good_but_to_late"
"foo"
"xyzzy"
"quux"
To make you see what complex situations you can handle with this
simple code, have a look at this example. We silently ignore wrong
entries, but when the log has more than 2 errors, we skip the whole
thing. Also, lines with bar
in it for some reason or another
unfortunately got broken, so let’s restore them on the fly:
class LogAnalyzer
def analyze
@logs.each { |log|
invalid_entries = 0
begin
analyze_log log
rescue MalformedLogEntryError => e
if invalid_entries >= 2
next
elsif e.data =~ /bar/
e.restart :use_value, "BAR"
else
invalid_entries += 1
e.restart :skip_line
end
end
}
end
def parse_log(log, &block)
entries = []
log.split("\n").each { |line|
begin
entries << parse_log_entry(line)
rescue MalformedLogEntryError => e
e.data = line
e.restart_case(:skip_line) {
# Skip
}
e.restart_case(:use_value) { |replacement|
entries << replacement
}
e.handle_restarts
end
}
entries
end
end
Now, look how the very wrong log totally got skipped:
"foo"
"bar"
"quux"
"foo"
"BAR"
"quux"
Finally, here are the extensions to Exception
I did to make this
possible (I think I should make that a library):
class Exception
attr_accessor :data
def restart_case(strategy, &block)
@restarts ||= {}
@restarts[strategy] = block
self
end
def handle_restarts
callcc { |@continuation| raise self }
end
def restart(strategy, *params)
@continuation.call(@restarts[strategy].call(*params))
end
end
NP: Bob Dylan—Sara