神刀安全网

Replacing inclusion with injection

Have you ever come across duplication in your code across classes? I’m guessing that you probably have. How did you approach the removal of the duplication? Take a moment to think about this. As a caring and considerate programmer it is important that you keep your code clean and DRY. You never know, the next person who comes along to maintain your beautifully-written, well-crafted code might be an axe-weilding maniac with access to your address. At the same time, it is important that you choose the right abstraction. Otherwise you are only going to end up in more pain. A lot of times, the first tool we reach for is the “mixin”, but this just adds more responsibility to your class. Why not try injecting the responsibility as part of the class’s construction?

By injecting a dependency into your existing class, you are removing responsibilities from your existing class into another more sharable class. At the same time you are also giving yourself the opportunity to easily change the behaviour of the class upon its construction.

To start off with, you might have a wrapper around an API endpoint that looks something like the following.

module MyGem   class Order     class Api       def create(options)         connection.post("orders", options)       end        private        def config         @config ||= Configuration.instance       end        def connection         @connection ||= Faraday.new(url: url) do |faraday|           faraday.request :url_encoded           faraday.response :json           faraday.adapter Faraday.default_adapter         end       end        def domain         config.domain       end        def url         "#{domain}/path/to/endpoint"       end     end   end end 

This is perfectly acceptable at this stage, if this is the only instance of a class that is connecting to this API. We don’t want to be accused of prematurely optimising our code now do we? But what happens if you want to introduce the following class.

module MyGem   class Payment     class Api       def index(options)         connection.get("payments", options)       end        private        def config         @config ||= Configuration.instance       end        def connection         @connection ||= Faraday.new(url: url) do |faraday|           faraday.request :url_encoded           faraday.response :json           faraday.adapter Faraday.default_adapter         end       end        def domain         config.domain       end        def url         "#{domain}/path/to/endpoint"       end     end   end end 

At this point, you are probably noticing a whole lot of duplication most of which lives in the classes’ private methods. Seeing this, you might be inspired to create a module of shared functionality similar to the following.

module MyGem   module Connectable     private      def config       @config ||= Configuration.instance     end      def connection       @connection ||= Faraday.new(url: url) do |faraday|         faraday.request :url_encoded         faraday.response :json         faraday.adapter Faraday.default_adapter       end     end      def domain       config.domain     end      def url       "#{domain}/path/to/endpoint"     end   end end 

That seems nice enough and your classes will now be much smaller.

# lib/my_gem/order/api.rb module MyGem   class Order     class Api       include Connectable        def create(options)         connection.post("order", options)       end     end   end end  # lib/my_gem/payment/api.rb module MyGem   class Payment     class Api       include Connectable        def index(options)         connection.get("payments", options)       end     end   end end 

Are these classes really any smaller? If you were to look at the tests then you would see that it is still the responsibility of both of these classes to know how to connect to the API endpoint.

module MyGem   class Payment     describe Api do       describe '#index' do         let(:adapter) { :net_http }         let(:config) { double("Configuration") }         let(:connection) { double("Faraday::Connection") }         let(:domain) { "https://example.com" }         let(:options) { { order_id: 1 } }         let(:response) { double("Faraday::Response") }         let(:url) { "#{domain}/path/to/endpoint" }          before do           allow(Configuration).to receive(:instance).and_return(config)           allow(Faraday).to receive(:default_adapter).and_return(adapter)           allow(Faraday).to receive(:new).with(url: url).and_yield(connection)           allow(connection).to receive(:request).with(:url_encoded).             and_return(connection)           allow(connection).to receive(:response).with(:json).             and_return(connection)           allow(connection).to receive(:adapter).with(:adapter).             and_return(connection)           allow(connection).to receive(:get).with("payments", options).             and_return(response)         end          it "returns the `get` response from the connection" do           expect(Api.new.get(options)).to be(response)           expect(connection).to have_received(:request).with(:url_encoded)           expect(connection).to have_received(:response).with(:json)           expect(connection).to have_received(:adapter).with(adapter)         end       end     end   end end 

You will notice that the size of your test hasn’t changed. This is a sign that something might not be right. Surely, connecting to the API should be the responsibility of one class and not shared across the codebase. How about if we changed the Connectable module into a Connector class?

module MyGem   class Connector     def create(path, params)       connection.create(path, params)     end      def get(path, params)       connection.get(path, params)     end      private      def config       @config ||= Configuration.instance     end      def connection       @connection ||= Faraday.new(url: url) do |faraday|         faraday.request :url_encoded         faraday.response :json         faraday.adapter Faraday.default_adapter       end     end      def domain       config.domain     end      def url       "#{domain}/path/to/endpoint"     end   end end 

Here you have a class who’s only responsibility is knowning how to interact with a singular HTTP APi. All the configuration is wrapped up nicely in one place. Getting your classes to interact with the Connector is as simple as the following.

# lib/my_gem/order/api.rb module MyGem   class Order     class Api       def new(connector: Connector.new)         @connector = connector       end        def create(options)         @connector.post("order", options)       end     end   end end  # lib/my_gem/payment/api.rb module MyGem   class Payment     class Api       def new(connector: Connector.new)         @connector = connector       end        def index(options)         @connector.get("payments", options)       end     end   end end 

These two classes might not look any smaller than the two we had before, but if you were to look at the classes’ unit tests (and you’d stubbed out dependencies appropriately) then you would see that both of these classes now have a much smaller set of responsibilities.

module MyGem   class Payment     describe Api do       describe '#index' do         let(:options) { { order_id: 1 } }          before do           allow(Connector).to receive(:new).and_return(connector)           allow(connector).to receive(:get).with("payments", options).             and_return(response)         end          it "returns the `get` response from the connector" do           expect(Api.new.get(options)).to be(response)         end       end     end   end end 

As well as storing all of the connection code in one place, we are now reliant on an abstraction instead of a concrete class. This means that if we want to change the underlying code or replace the abstraction with another very minimal changes are required to the consuming class (and sometimes none at all!)

For example, let’s say that we move the logic and data for all orders into its own microservice along with its own HTTP API along with a different set of interactions.

module MyGem   class PaymentsConnector     def get(path, params)       connection.get(path, params)     end      private      def connection       @connection ||= FuturisticHTTPGem.new(url)     end      def domain       "https://example.com"     end      def url       "#{domain}/path/to/endpoint"     end   end end 

If we wanted to plug this into our existing Payment::Api class, we’d just do something as simple as the following

api = Payment::Api.new(connector: PaymentsConnector.new) 

We have completely changed the way we interact with that API endpoint and we haven’t had to change any of the code in the Api class. It’s kind of like magic. Think about how much hassle you have saved yourself or some axe-wielding maniac in the future.

Next time you are looking to extract some duplication, take a second, and ask yourself, “is this the right abstraction?”

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » Replacing inclusion with injection

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址