📜 ⬆️ ⬇️

Elixir: making code extensible with Behavior


So, let's define the disposition ... You wrote a piece of code that you want to use with a lot of different "things" - it sounds not very scientific, but still. These different things are united by some common property, through which they achieve the same result at a high level of abstraction; only here the ways of achieving the result can be completely different.


Often your code should only use one such thing at a time, but you don’t want to make your code so narrow? This is just disgusting. Isn't it great when other people can create new “things” and expand your code while you don't even touch the keyboard?


But can't I select a specific implementation and use it? I don't need anything else ....

Of course you can. But what happens if you change your mind about the “thing” you are using. Suddenly your sweetie without a wrapper will turn out to be not what it seems? And suddenly even worse - your marvelous "little thing" will cease to support? In such terrible conditions it would be cool if you could quickly change one for the other without changing everything we wrote at all. Pralno?


Stop pounding the water in a mortar ...


If you read up here, I think that you understand what I mean. “Things” represent millions of variations, but let's move on to some adequate examples from the real world:



All of these scenarios describe a serious problem - since we want to work with all these little things, but the differences in them constitute a formidable barrier from a heap of repetitive code. A bunch of programming languages ​​have solutions to this problem, and Elixir is no exception: meet Behaviour .


Behavior in Elixir


Tip in the title. To interact with several things as if they were one and the same: we must define their common behavior as an abstraction. And this is exactly what Behaviour does in Elixir: the definition of such an abstraction. Every Behavior exists as a specification or instruction, allowing other modules to follow these instructions and thus support Behaviour . This allows the rest of the code to care only about the common interface. Want to change services? On health, the calling code will not notice anything.


But how does all this look? Behavior is defined as a normal module, within which you can designate a group of specifications for functions that should be implemented in a module that supports this Behavior . Each such specification is defined using the callback directive and typespec signatures, which allows you to specify what exactly each function accepts and gives. Looks like that:


defmodule Parser do @callback parse(String.t) :: any @callback extensions() :: [String.t] end 

All modules that want to support this Behaviour should:



The use of Behaviour is explicit, so all modules that support Behaviour should confirm this using the @behaviour SomeModule attribute. This is very convenient - you can rely on the compiler, it will check that your modules do not meet the specifications. Therefore, if you update Behavior , then you can be sure that the compiler is on your side - it will make sure that all modules supporting it should be updated too.


We dive deeper


If you still do not quite understand what I mean - you can help the example of other languages. If you are a fan of Python , then this post is a good explanation of the pattern as a whole. If you are from Ruby - read it too - the philosophy is basically the same (inherit the basic adapter and hope for the best, hehe). Well, for Go lovers, there is a lot in common with this interface .


I must say that it is much easier to explain how Behaviour can help write extensible code, on a live example, so we’ll go deeper with an example about email . Consider the Swoosh library, which uses Behavior to define a stack of methods for delivering letters, and can use one more method and use it.


Definition of a public contract


We have been discussing why for so long, so let's immediately look at the library, namely at Swoosh.Adapter


 defmodule Swoosh.Adapter do @moduledoc ~S""" Specification of the email delivery adapter. """ @type t :: module @type email :: Swoosh.Email.t @typep config :: Keyword.t @doc """ Delivers an email with the given config. """ @callback deliver(email, config) :: {:ok, term} | {:error, term} end 

As you can see, the example is a little longer than the documentation, because Swoosh defines all the types used for additional readability and transparency of the code ( config is used as a link to Keyword.t , simply because it is more understandable). But we generally don't care about types, we only care about the callback directive, which defines the rules for one and only function in this abstraction: deliver the letter. The definition of deliver/2 tells us that:



We support Behavior


It's time to determine doing something . We will look at and look at the two adapters that support behavior , and are included in the "batteries" to Swoosh . First, let's take a look at the simple - Local client, which delivers letters straight to memory.


 defmodule Swoosh.Adapters.Local do @behaviour Swoosh.Adapter def deliver(%Swoosh.Email{} = email, _config) do %Swoosh.Email{headers: %{"Message-ID" => id}} = Swoosh.InMemoryMailbox.push(email) {:ok, %{id: id}} end end 

Here, in general, there is nothing to discuss. For starters, the adapter clearly indicates that it supports Swoosh.Adapter Behavior . Then, the deliver/2 function is defined, which has exactly the signature that is defined in the contract. This explicit definition allows the compiler to do all the dirty work for us. If the guys who make Swoosh decide to add another function to the specification, then all supporting modules will also have to be changed, otherwise the application will simply not compile. awesome security!


Another client who sends letters via Sendgrid is too big to copy the source code here, but you can watch it on GitHub . You will notice that the module is much more complicated, and defines a large number of functions other than the one that should be required: deliver/2 . This is because for Behavior it does not matter how many extra functions there are - they should not be 1: 1. This allows more complex modules to invoke other specific functions in the same ones defined in the contract, which improves readability and cleanliness of the code.


Add a pinch of flexibility to the code


We have already learned how to define the "contract" of Behavior , and even how to support it in modules, but how will this help us when we want to use them in the calling code? There are several ways to implement this idea, everyone can vary in complexity. Let's start with the simple.


Dependency injection using function header


Let's return to our example Parser straight from the docks:


 defmodule Document do @default_parser XMLParser defstruct body: "" def parse(%Document{} = document, opts // []) do {parser, opts} = Keyword.pop(opts, :parser, @default_parser) parser.parse(document.body) end end 

Here we use the Document module which simply defines a functional wrapper for our Behaviour , so we can easily switch between different parsers. Let's try to run ...


 Document.parse(document) 

In the code above, we pass only one argument — no options . This leads to the fact that data access with the key :parser in the Keyword.pop call Keyword.pop not work, and returns us a simple @default_parser , which was defined in the module attribute. After this, the parse/1 function will simply call the same method on our parser, passing the body string there.


Great, what about the XMLParser ? Voila!


 Document.parse(document, parser: JSONParser) 

Since both XMLParser and JSONParser support Parser Behaviour , they both have an implementation of the parse/1 function. And by calling this function in a wrapper, we can very quickly and simply do dependency injection of the parser we need.


This kind of dependency management is very powerful. It even allows different parts of the application to use, for example, different parsers. However, there are downsides. When using this method, you must trust the user to know how and where dependency injection occurs, which will require more extensive documentation. Moreover, what if the user wants to use different dependencies in different environments? Your code will have to decide in runtime which module to use and when. Isn't it better to ask it in advance and forget about it?


Dependency injection using mix config


Thanks to Mix is not even a problem. Let's look at the Parser example again:


 defmodule Document do @default_parser XMLParser defstruct body: "" def parse(%Document{} = document) do config = Application.get_env(:parser_app, __MODULE__, []) parser = config[:parser] || @default_parser parser.parse(document.body) end end 

In this example, parse/1 no longer accepts any options. But the type of the parser is calculated directly from the configuration of the OTP Application .


For example, the configuration file might look like this:


 # config/config.exs config :parser_app, Document, parser: JSONParser 

our Document.parse wrapper will know to use JSONParser for parsing. All this serves for us an excellent service, since the choice of the adapter is no longer tied to the calling code, and therefore can be changed in the Mix config , or the config that depends on the environment can be chosen by our parser in the future. Again, this approach has its drawbacks: the configuration is strongly tied to the Document module, because it uses MODULE (module name) in the config. This means that we are moving away from the possibility of using several parsers, only because everywhere in the code we use the hardcoded Document module. Of course, in most cases, one adapter is enough for the entire project, but if you suddenly need more? For example, one part of the code will send letters via Sendgrid , and the other part of it will require the support of an outdated SMTP server. Well, back to Swoosh ...


We achieve the advantages of both approaches


Fortunately for, Swoosh repeats Ecto 's approach to this problem. You, as a programmer, must define your own module somewhere in the code that Swoosh will then use via use Swoosh.Mailer . Your calling code will then use this module as a wrapper for the imported Swoosh.Mailer . Unfortunately, the details of how macros work are beyond the scope of the article. But for the simple reason: the use macro causes Elixir to call a macro called using in the imported module. You can see how this macro Swoosh.Mailer is executed. using directly in turnips .


In essence, this means that the Swoosh configuration is in two places at once:


 # In your config/config.exs file config :sample, Sample.Mailer, adapter: Swoosh.Adapters.Sendgrid, api_key: "SG.xx" # In your application code defmodule Sample.Mailer do use Swoosh.Mailer, otp_app: :sample end 

By structuring the code in this way, we can achieve that each module has its own section of settings in Mix config . Each individual module must use Swoosh.Mailer in order for them to be configured differently.


... and everything! Now you know how to create publicly extendable code. But before you finish, a couple more words ...


More examples


Reading the existing code will help you learn the knowledge gained in the article. You can start with:



A couple of things to see


Summing up: the advantages of this approach is that it allows you to write loosely coupled code that is subject to any public contracts. Thanks to this, developers can extend existing functionality simply by explicitly defining extensions that appear in the course of work. The fact that a contract is explicitly defined makes it much easier to test, without "mocking as a verb" there .


As already mentioned, this approach does not work for all situations. Defining a standard set of relationships between all the plugins, you kind of force the plugins to have similar functionality, but this is not always applicable: there are situations when the code is so different that you cannot put a common divider under it. For further reading, I would advise the code for the Ecto project, especially those areas where the basis for access to various databases, such as the DDL transaction and how they are made in Behaviour , is summarized.


Epilogue


Post suddenly turned out just huge. In any case, thanks for getting here, and maybe even clicking on a couple of links. In any case, feel free to email me, or subscribe to my Twitter , or in any other way spread the plague of love for Elixir !


Thanks to Baris for reading and checking.


From translator


The desire to translate an article appeared after I had to do something similar in a small library of my own production. The code can be viewed here for another example. I hope this article will help to understand the work of the Elixir language even deeper, as well as interest those who so far do not distinguish Phoenix from Elixir . If you have any questions, write to the community .


')

Source: https://habr.com/ru/post/312442/


All Articles