DEV Community

Fred Heath
Fred Heath

Posted on

The Proxy pattern revisited.

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 utilising 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:

  1. 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.
  2. 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)
  3. 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 a method_missing implementation. This will happen for every single method call.
  4. 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)

prepending_proxy_to_class

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:

prepending_proxy_to_singleton_class

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 proxy-ing has a number of advantages:

  1. 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.
  2. It's performant. Some basic benchmarking has indicated a 3-4x performance gain against a method_missing proxy.
  3. 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

Top comments (3)

Collapse
 
luispcosta profile image
Luís Costa

Awesome post Fred! I've personally never felt the need to use a Proxy in my Ruby code, probably because I've never run into and/or requirements that called this necessity. Can you describe a real world use case where you've used this?

Collapse
 
redfred7 profile image
Fred Heath • Edited

Thanks Luis! Yes, I often have to use proxies. A recent use-case I had was this: Our client was using an external CRM API in their server code. We had built a new, better API and the client wanted to gradually phase out the old API and use ours instead. They had a class along these lines:

class oldAPIFacade
  def updateUser
    #call oldAPI with @user
  end
end 

What I did was to give them a proxy:

Module APIProxy
  def self.prepended
    ...
    Proxy.class_eval do
        define_method(m) do |*args|
           if @user.region == 'EMEA'
             # call our new API
           else # call old API  
             super(*args)
           end
        end
  end 
end

Which they prepended at the end of the oldAPIFacade class.

I then kept removing conditions from the proxy until eventually all API calls were routed to our new API. It was a seamless, phased transition :)

Collapse
 
luispcosta profile image
Luís Costa

That's awesome! Thank you for provide a real world example, made the blog post /concept much easier to understand. You should include it in the post itself