Inversion of dependencies in the frontend world. Yandex lecture

    Patterns of control inversion (dependency inversion, DI) have been known for a long time, but have not yet found wide distribution in the world of front-end. This report answers the question of how to build a robust architecture based on a DI container due to JS capabilities. The author of the report is Eugene ftdebugger Shpilevsky, head of the interface development group at Yandex.Collections.


    - As far as I know, dependency inversion, DI containers and other patterns invented back in the 70s did not enter the frontend development world very tightly. There is probably a reason for this. Part of the fact is that many people do not understand why they are needed at all.


    I will try to explain what DI is, what dependency inversion is, how it can help you in the project, and what nice bonuses you can get if you start using it.

    For starters, the most basic concept. What is dependency inversion? When we are designing any feature, we want to decompose it to such a small state that a particular separate class performs exactly one function and nothing more.

    In the example on the slide, there are User and UserSettings. Everything related to user settings was moved to a separate class. How can I do that? There are two approaches: create an instance of such a class internally or accept it externally. This is the main principle of dependency inversion. If we create some instances from the outside and then pass inward, we get some advantage.




    In fact, there is only one reason - we no longer rely on a specific implementation, but begin to rely solely on interfaces. When we say that some small decomposed function is placed in a separate class, it does not matter to us how it was implemented. We just can use it, and it doesn’t matter which instance of which class will be slipped if the interfaces match. And since there is no interface in JS, the method is called from a larger one as well.


    The UserSettings example is a bit out of context, it is hardly a code that you write every day.

    A slightly more mundane code, close to the reality and realities of JS, is synchronous. If we want to create a data model, we need to get this data from somewhere. One of the methods, the most common, is to use Ajax to go to the server, receive data synchronously, and create them.

    If we start writing this code in the style of dependency inversion, we get something like this. Not only is this code not written in the most optimal way, it is also quite monstrous. We kind of wanted just one component that would list our user’s pictures, and we needed to write so much code for this.

    In a real project, the component count goes to tens, and such an assembly even on a page will be quite monster-like.



    What can be done? Make the code worse. We can make our first DI container, the most primitive, in the forehead. We will take everything that we wrote before and pack it into methods. We put them in hashes, which we will call DI, and we will consider this a DI container. Then we get the first step towards making our future a little better.

    The bottom line is that at any time when you need a user, settings, pictures, or any of the hundreds of other methods that could be described here, you can take the DI, call it, and it will not matter to you how was designed.

    All the code that is responsible for building your models, classes, components will be isolated in one container. Naturally, writing code in this way will be difficult; this file will quickly become large. It already has problems. Anyone who learns the code a bit will find that at least the user is loading twice. This is bad, this must be avoided.

    In addition, all the code is boilerplate. We can replace it using some functions. And we can write our own container that will solve all our problems, it will be cool, fast. Anything you want.

    That's what I wanted from him.


    He must look for classes himself. They need to be imported from somewhere, then created, used. I don’t want this, let the container do it.

    I want him to create instances asynchronously. If we initially put such a requirement on the container, we will have no problems with how we will create instances in the future, whether they will go to Ajax, whether time will be spent on it or not, or whether they will go synchronously. If the creation is asynchronous, everything is already provided.

    Reuse. It is very important. If we start writing a large container and do not reuse instances in it, we run the risk of getting many useless, unnecessary requests to the server. I want to avoid this.

    The last point. I am pretty sure that the imperative regular code that I demonstrated on the previous slide did not please anyone. Instead, I want to write a regular declarative JSON in which all of this would be described, and everything would work for me.


    We learn step by step how to solve each problem.

    How can we teach dynamically finding classes? You can use webpack, it has a dynamic import function. Such code, which seems a little strange, will work for itself after bundling Webpack. Moreover: all classes that fall under these conditions will automatically become separate bundles and start loading asynchronously. And all of our class loading code will look like this. We just synchronously ask for a class and get it. The getClass function may look exactly the way you want it to. If you want to load some dependencies statically - you can write them here. Want a smarter bundling - you can describe it here. All this, by and large, up to you.


    There are two ways to create instances. You can come up with a terrible configuration of how this will happen, or introduce some kind of convention. I like the way with the convention, because you don’t need to code, you just need to remember something and then always follow these standards.

    In this case, I introduce the following convention: any class must have a static factory method. He will be responsible for how this class will be built, which dependencies will be thrown into it. He is responsible for everything.

    CreateInstance turned out to be very simple, the factory can be both synchronous and asynchronous. Well, the code to create a trivial user has become different, but still ugly.


    Reuse instances. To achieve this, we introduce a new concept. Any instance that is created as part of a DI container will be assigned an identifier. We will come up with these identifiers, they will describe some entities from our system. In this case, on the last line we describe the current user. We will somehow get the class of the previously written function, create an instance from it and put it in the cache.

    In this example, a couple of bugs are allowed. A full implementation of the CreateInstance cache-based method takes approximately 100 lines. Who cares, can then read it .


    The last is dependencies. We will describe a regular hash, where the keys are identifiers from a DI container, and the values ​​are the configuration with which we can create all of the above. Take and create the UserSettings class. In currentUser, we take the user class, pop it as dependencies in UserSettings. What is UserSettings? What we previously announced.

    Having described such a structure, we can develop a simple algorithm that will go through the whole tree with the dependencies that are formed. In fact, there a graph is formed on this tree. Such an algorithm will collect everything we need.

    To reduce the amount of noise on the slide, I will introduce another convention.


    Why not write in JSON, but in anything, and why not describe everything in a simpler form? If you need a class - just take a string, if you want a class and dependencies - use an array. No matter which format you choose. The main thing is that you enjoy it and you understand what is happening here. This is the same slide, only rewritten.


    As a result, if we have implemented all this, assembled, we will get automatic bundling. Here you will get such an interesting option that if you request the current user, then your DI container will be able to simultaneously load the bundle containing this class and already load the dependencies it needs asynchronously. The fact is that now he has information about where the class is located - possibly in some kind of bundle - and what dependencies he will need. Landing example: if we want to make a component that will display a list of pictures, then JS, where the code that draws these components with pictures lies, we will only be loaded, and at that moment a request to the server for the data can go. When both of them are finally fulfilled, we will get it.

    This can be obtained simply by using a DI container, there is no need for anything else. We have ease of dependency delivery. When you just start using the DI container to the fullest, everything from your world begins to appear there: all, all common utilities, components, data models. And if at some point you need to get something, you can simply describe one line of dependencies, and do not care about how it should be created, configured, describe a whole complex process that must go through all stages. You just get it from the container as a dependency.

    Reusing code. If we start writing in such a way that in no particular class we explicitly create instances of other classes, then we cease to be tied to implementation. We can slip any kind of instance into the class as dependencies. Within the same component of pictures, we can load any kind of pictures and palm off them from anywhere. Within the container, all this will differ simply by a line in the configuration. You just take another addiction and that's it, it will be very simple for you.


    As soon as the container is fixed in your project in a very important place, you begin to use it simply as the basis of everything. I want to demonstrate how you can make an ordinary multi-page SPA using a DI container.

    We will take some kind of Router. Its implementation is not important. It is important that when it gets caught on some url, it will give this page some name. In this case, perhaps home and profile.

    Take our container and describe there as the keys home and profile. We will describe everything that we do not want to get there. And we want to get some kind of component that we will take and insert into the body. In this case, this is some kind of Layout. Layout is used both there and there, just different dependencies are enclosed in it. What to do next? What components will go deeper, already at this stage it does not matter, because they already somehow work, someone set them up. Only the level of abstraction that we are working on right now is important to us.

    That's all, we can request a dependency from a DI container by key, by page name. In this case, this is a sign on Layout, and this Layout will already contain all the necessary data, all components, everything that we want to do. It remains only to add it to the body.


    What about the testability of all this? As soon as we start using it, the circumstance arises that classes are not directly dependent on the container. You are nowhere and will never use the container directly in the sense that I need such a dependency, I will take it and get it. No, rather, it will lie at the very bottom of the architecture, in the bootstrap of your application, as demonstrated in the previous slide.

    In fact, your classes do not depend on it in any way, you can take them from anywhere and anywhere to port.

    All dependencies are passed during creation, and if we are talking about testing, in this place we can easily attach moki, fixture data and anything else - simply because it already works that way. The DI container made us write code in such a way that everything worked just that way.


    A few examples. When creating, when we are engaged in testing and want to get wet the user settings so that they do exactly what we want, and not what they have written, we can create a user, slip test data under him. We can use the container for this - the dependency tree is already formed, we can redefine some of them. In the future, simply by their logic of working with DI, everyone who ever wanted to get UserSettings will receive them, wherever they are. We can use it for testing.


    There is another interesting example. If we assume that all data models that go somewhere to the server for data will use some ajaxAdapter written for this purpose, then during the tests we can replace it with our own class TestAjaxAdapter, which can implement logic. This is how it is implemented, for example, in Sinon, if someone tried to get wet with it.


    Or we can go even deeper. We implemented this logic in this adapter so that when it was first used in the tests, it would start recording requests and responses from a real server, and when it was restarted, it would simply play it from the cache. We add this cache to the repository to our test data. And when we want to do testing on fixtures and are afraid that they will change over time due to the fact that the logic of communication with the server is already implemented here, we replace TestAjaxAdapter. A cache is formed in the repository, which will then be reused.


    How can this be used even more interesting? Gemini testing has already been mentioned here.. This is a type of visual regression testing. Who doesn’t know, Gemini is a testing method in which we take a screenshot of some of our block on the finished page, put it to the test data in the repository, and when we want to do the reverse test, we restart, re-take the screenshot and compare it pixel by pixel . If somewhere the pixels did not match, the test fell. This is a very simple and effective type of testing, checking visual regressions. We work with CSS, it has a peculiarity: it constantly breaks. Gemini helps us get rid of these breakdowns.

    What have we done in this place? Since everything was implemented through a DI container, we specially prepared a server to which identifiers from the DI container could be passed as parameters. He simply formed it, painted on the page alone this component that we wanted. In this case, there is something connected with the recipes, some kind of card, real data on which the test runs were, a real screenshot.

    After running the test, ajaxAdapters were replaced, and a cache was formed related to how the server communicated. We have this data - constantly reproduced over time, and the tests become stable.

    This approach applies to all types of tests. If you want to log into your Selenium browser component-by-click and click, nothing bothers you, because you get a fully working piece of functionality that you want to commit to. And you can even make several blocks at the same time, just display them on the page and click on them. Between themselves, the blocks have some kind of event connection or something else. Even if the blocks do not match this site, you can test some logic this way.

    I read a quick talk about what DI is. I hope someone is interested. If you need more details, I can access the links: mail , GitHub , Telegram , Twitter .

    Here are the links where you can find new information about what was here. For example, the fully implemented DI container that I talked about, the inversify DI container is a very cool thing for TypeScript. There are some more links here to understand how to put everything together.


    Thanks.

    Also popular now: