Intro
The Proxy is one of the most popular design patterns in most programming languages. A Proxy is simply an object that sits between some client code and a service object. The client code deals directly with the Proxy, instead of the service object. 1 This means that the Proxy can be used to mask the service object's physical location (a Remote proxy), manage access to the service object (a Security proxy), or just lazily initialize the service object (a Virtual proxy).
The old way
Whatever the use-case, the idiomatic Ruby way to implement a proxy has been by utilizing the method_missing
method, as described in Russ Olsen's seminal book Design Patterns in Ruby.
# the service object
class Account
def initialize(balance)
@balance = balance
end
def deposit(amount)
@balance += amount
end
def withdraw(amount)
@balance -= amount
end
def self.interest_rate_for(a_balance)
a_balance > 10_000 ? '3.2%' : '5.5%'
end
end
# the proxy object
class AccountProxy
require 'etc'
def initialize(real_account)
@real_account = real_account
end
def method_missing(name, *args)
raise "Unauthorised access!" unless Etc.getlogin == 'fred'
@real_account.send(name, *args)
end
end
## client code
acc = Account.new(100)
proxy = AccountProxy.new(acc)
proxy.deposit(10)
proxy.withdraw(50)
In this simple proxy example, we intercept all method calls to AccountProxy
, do a basic security check and then delegate the method to the real Account
object to do the actual work.
This is a concise and effective way to create a proxy in Ruby, but it has some weaknesses:
The client code needs to call a separate Object, e.g. AccountProxy instead of Account. It's pretty obvious we're not dealing with the 'real' service object, which is not only a nuisance if you have to re-write existing client code, but may also lead malicious actors to try to by pass the proxy.
method_missing
is a catch-all trap. It will catch every method call coming our way and we need to think long and hard about which methods we want to delegate to the 'real' object and which ones to handle in our proxy (#to_s
, for instance)Using
method_missing
has a performance hit. When calling a method on an object, the Ruby interpreter will first look all the way up the object hierarchy trying to find the method and -when it can't- will go back down the hierarchy tree and start looking for amethod_missing
implementation. This will happen for every single method call.Our proxy doesn't work with class methods. To do that we'd need a separate proxy object for
Account
's singleton class.
The new way
A few years after Russ's book came out, Ruby 2.0.0 was released and introduced Module#prepend. The way Module#prepend
works is by inserting the prepended Module between the calling code (a.k.a the 'receiver' object) and the module or class that does the prepending.
Can you see the connection already? The prepended Module sits between the receiver and a service object. Sounds familiar? Oh yes, this is exactly what a Proxy is meant to be doing!
Knowing this we can use the #prepended
hook method to dynamically implement all methods of the prepending module in our proxy, making sure to add our extra security check, before calling the original method implementation.
module Proxy
require 'etc'
# Dynamically re-creates receiver's class methods, intercepts calls
# to them and checks user before invoking parent code
#
# @param receiver the class or module which has prepended this module
def self.prepended(receiver)
obj_mthds = receiver.instance_methods - receiver.superclass.instance_methods
obj_mthds.each do |m|
args = receiver.instance_method(m).parameters # => [[:req, :x], [:req, :y]]
args.map! {|x| x[1].to_s}
Proxy.class_eval do
define_method(m) do |*args|
puts "*** intercepting method: #{m}, args: #{args}"
raise "Unauthorised access!" unless Etc.getlogin == 'fred'
super(*args)
end
end
end
end
end #module
class Account
def initialize(balance)
@balance = balance
end
def deposit(amount)
@balance += amount
end
def withdraw(amount)
@balance -= amount
end
def self.interest_rate_for(a_balance)
a_balance > 10_000 ? '3.2%' : '5.5%'
end
prepend Proxy
end
All we're doing is taking advantage of the way #prepend
affects the Ruby Object Model in order to find out which methods the intercepted object is defining and then implementing them in our proxy while adding our own code. To call the original implementation, we once again leverage the fact that the intercepted object is the parent of our proxy module, so all we need to do is call #super
(no more ugly #send
calls)
Let's write some client code to exercise our account:
acc = Account.new(100)
puts acc.deposit(10)
puts acc.withdraw(50)
puts Account.interest_rate_for(2000)
which outputs:
*** intercepting method: deposit, args: [10]
110
*** intercepting method: withdraw, args: [50]
60
5.5%
That's pretty cool, but the beauty of it doesn't end here. We can use the very same mechanism to intercept class method calls. All we need to do is prepend the Proxy to the Account's singleton class:
class Account
def initialize(balance)
@balance = balance
end
def deposit(amount)
@balance += amount
end
def withdraw(amount)
@balance -= amount
end
def self.interest_rate_for(a_balance)
a_balance > 10_000 ? '3.2%' : '5.5%'
end
class << self
prepend Proxy
end
prepend Proxy
end
Once again, the Object Model is manipulated to our favour:
And now by running our client code again, we see that both instance and class methods of the Account class are caught by the proxy:
acc = Account.new(100)
puts acc.deposit(10)
puts acc.withdraw(50)
puts Account.interest_rate_for(2000)
which outputs:
*** intercepting method: deposit, args: [10]
110
*** intercepting method: withdraw, args: [50]
60
*** intercepting method: interest_rate_for, args: [2000]
5.5%
This way of writing proxies has a number of advantages:
It's transparent. Client code doesn't need to change, it doesn't even need to know that a proxy is being used, we just need to prepend the proxy module to the service object class.
It's performant. Some basic bench-marking has indicated a 3-4x performance gain against a method_missing proxy.
It works for both instance and class methods.
So there you have it: Another way of building proxies. Ruby keeps evolving and so do we. Happy 25th birthday!
1: a Ruby object that encapsulates some business logic