Large-scale web front-end architecture design-introduction to abstract programming

Author: svenzeng, Tencent PCG front-end development engineer

Abstract-oriented programming is a very important reference principle for building a large-scale system. But for many front-end students, the understanding of abstract programming is not very deep. The habit of most students is to start writing the UI interface after getting the demand list and the design draft, which buttons in the UI need to be adjusted which methods, and then writing these methods, seldom consider reusability. When the requirements change one day, it is found that the current code is difficult to adapt to these changes and can only be rewritten. Day after day, so cycle.

When you first see the phrase "separate abstraction from concrete realization", it may be difficult to understand what it means. What is abstraction, and what is concrete realization? In order to understand this passage, let's be patient, let's look at a small hypothetical example first, and recall what is specific implementation-oriented programming.

Suppose we are developing a program similar to "The Sims" and created Xiao Ming. In order to let him have a regular life every day, we set the following logic in his core program:

1、8点起床
2、9点吃面包
3、17点打篮球

After a month, Xiao Ming was tired of the constant repetition of life. After getting up one morning, he suddenly wanted to eat potato chips instead of bread. In the evening, he wanted to play football instead of basketball, so we had to modify the source code:

 1、8点起床
 2、9点吃面包 -> 9点吃薯片
 3、17点打篮球 -> 17点踢足球

After a while, Xiao Ming hopes to play football on the 3rd and 5th of the week and badminton on Sunday. At this time, in order to meet the demand, many if and else statements may be added to our program.

In order to meet the change of demand, it is very similar to the real world, we need to go deep into the core source code and make a lot of changes. Now think about your own code, are there many familiar scenes?

This is an example of specific implementation-oriented programming. Here, the actions of eating bread, eating potato chips, playing basketball, and playing football belong to specific implementations. Mapped to the program, they are a module, a class, or a function, including With some specific code, to be responsible for a specific thing.

Once we want to change these implementations in the code, we must be forced to go deep and modify the core source code. When requirements change, on the one hand, if there are a large number of specific implementations in the core code, the workload of rewriting all of these specific implementations is huge. On the other hand, modifying the code will always bring unknowns. When the connection between modules is inextricably linked, you must be careful to modify any module, otherwise it is very likely that one bug will be corrected and three more bugs will occur.

Extract common features

Abstract means: to extract common and essential characteristics from some things.

If we always write code for specific implementations, like the example above, we either write hard to eat bread at 9 o'clock, or write hard to eat potato chips at 9 o'clock. As a result, in the process of business development and system iteration, the system will become rigid and difficult to modify. Product requirements are always changing. We need to keep the core source code stable and not modified in a changing environment.

The method is to extract the general characteristics of "eat bread at 9 o'clock" and "eat potato chips at 9 o'clock". Here, "eat breakfast at 9 o'clock" can be used to represent this general characteristic. In the same way, we extracted the general characteristics of "playing basketball at 17:00" and "playing football at 17:00" and replaced them with "playing at 17 o'clock". Then let this core source code rely on these "abstracted general features" instead of relying on the "concrete realization" of "eating bread" or "eating breakfast".

We write this code as:

  1、 8点起床
  2、 9点吃早餐
  3、17点做运动

In this way, this core source code has become relatively stable. No matter what Xiao Ming wants to eat in the morning, there is no need to change this code. As long as the outer program will "eat breakfast" or "eat potatoes" in the later stage, "Piece" can be injected in.

Real example

Just now it was a virtual example. Now look at a piece of real code. This code is still very simple, but it can well illustrate the benefits of abstraction.

In a certain core business code, localstorge needs to be used to store some user operation information. The code is quickly written:

import ‘localstorge’ from 'localstorge';

class User{
    save(){
        localstorge.save('xxx');
    }
}

const user = new User();
user.save();

This code originally worked well, but one day, we found that the amount of user information related data was too large, exceeding the storage capacity of localstorge. At this time we thought of indexdb, it seems that it would be more reasonable to use indexdb for storage.

Now we need to replace localstorge with indexdb, so we have to go deep into the User class and change the place where localstorge is called to call indexdb. It seems that we have returned to the familiar scene. We found that in the program, in the depth of many core business logic, not only one, but hundreds of thousands of places called localstorge, this simple modification has become a disaster.

Therefore, we still need to extract the common abstract part of localstorge and indexdb. Obviously, the common abstract part of localstorge and indexdb is to provide its consumers with a save method. As its consumer, the core logic code in the business, it does not care whether it is localstorge or indexdb. This matter can be determined by other outer codes after the program is later.

We can declare an interface with a save method:

interface DB{
   save(): void;
}

Then let the core business module User only rely on this interface:

import DB from 'DB';

class User{
    constructor(
        private db: DB
   ){

    }
    save(){
        this.db.save('xxx');
    }
}

Then let Localstorge and Indexdb implement the DB interface respectively:

class Localstorge implements DB{
    save(str:string){
        ...//do something
    }
}

class Indexdb implements DB{
    save(str:string){
        ...//do something
    }
}

const user = new User( new Localstorge() );
//or
const user = new User( new Indexdb() );

userInfo.save();

In this way, the User module has changed from relying on the specific implementations of Localstorge or Indexdb to relying on the DB interface. The User module has become a stable module. No matter whether we use Localstorge or Indexdb in the future, the User module will not be forced to follow. To make changes.

Keep modifications away from the core source code

Some students may have questions. Although we no longer need to modify the User module, we still need to choose whether to use Localstorge or Indexdb. We have to change the code somewhere. What is the difference between this and the code of the User module? ?

In fact, what we are talking about abstract-oriented programming is usually for core business modules. The User module belongs to our core business logic, and we hope it is as stable as possible. Don't want to change the User module just because you choose to use Localstorge or Indexdb. Because once the core business logic of the User module is accidentally changed, it will affect thousands of outer modules that depend on it.

If the User module now relies on the DB interface, it is much less likely to be modified. Regardless of the future development of local storage, as long as they still provide external save functions, the User module will not change due to changes in local storage.

Relative to the specific behavior, the interface is always relatively stable, because once the interface is modified, it means that the specific implementation must be modified accordingly. On the contrary, when the specific behavior is modified, the interface usually does not need to be modified.

As for the choice whether to use Localstorge or Indexdb, there are many ways to do it. Usually we put it in a place that is easier to be modified, that is, the outer module away from the core business logic, to name a few An example:

* 在main函数或者其他外层模块中生成Localstorge或者Indexdb对象,在User对象被创建时作为参数传给User
* 用工厂方法创建Localstorge或者Indexdb
* 用依赖注入的容器来绑定DB接口和它具体实现之间的映射

Inner, outer, and one-way dependencies

Layering the system is like an architect divides a building into many layers, each with its own unique design and function, which is the basis for building a large-scale system architecture. In addition to the outdated mvc layered architecture, the currently commonly used layering methods include onion architecture (tidy architecture), DDD (domain-driven design) architecture, hexagonal architecture (port-adapter architecture), etc. We will not introduce each A layered model, but whether it is an onion architecture, a DDD architecture, or a hexagonal architecture, their layers will be relatively and dynamically divided into outer and inner layers.

We have also mentioned the concepts of inner and outer layers several times (referred to as high-level and low-level in most books). In actual business, which modules correspond to the inner layer and which modules should be placed on the outer layer. What rule is it to decide?

First observe the next nature. The earth revolves around the sun. We think that the sun is the inner layer and the earth is the outer layer. After the eyes receive light, the brain is imaged. We think that the brain is the inner layer and the eyes are the outer layer. Of course, the inner and outer layers here are not determined by the physical location, but based on the stability of the module, that is, modules that are more stable and difficult to modify should be placed in the inner layer, and modules that are more volatile and more likely to be modified should Is placed on the outer layer. Just like when building a house with building blocks, we need to put the strongest building blocks underneath.

Such a rule setting is very meaningful, because a mature layered system will strictly abide by one-way dependencies.

Let's look at the following picture:

mark

Assuming that the system is divided into four layers: A, B, C, and D, then A is the relatively innermost layer, and the outer layer is B, C, and D in sequence. In a strictly one-way dependency system, the dependency relationship can always only point from the outer layer to the inner layer.

This is because if the innermost A module is modified, the B, C, and D modules that depend on the A module will all be implicated. In a statically typed language, these modules have to be recompiled because of changes in module A, and if they reference a variable in module A or call a method in module A, then they are likely to be modified by module A It needs to be modified accordingly. So we hope that the A module is the most stable, it is best to never change.

But what if the outer module is modified? For example, after module D is modified, because it is in the outermost layer and no other modules depend on it, it only affects itself. Modules A, B, and C do not need to worry about any impact on them, so when the outer layer When the module is modified, the damage to the system is relatively small.

If the modules that are easy to change and often change with product requirements are placed close to the inner layer from the beginning, it means that we often have to adjust or test other modules in the system that depend on it because of changes in these modules.

It can be imagined that the Creator may also set up the universe and nature based on the principle of one-way dependence. For example, planets depend on stars. Without the earth, the sun would not have much impact, and if the sun was lost, the earth would naturally not exist. Eyes depend on the brain. If the brain is broken, the eyes naturally lose their function, but the other functions of the brain can still be used if the eyes are broken. It seems that the earth is just a plug-in of the sun, and the eyes are just a plug-in of the brain.

Back to specific business development, the core business logic is generally relatively stable, and the closer to the user input and output (the closer to the product manager and designer, such as UI interface), the more unstable. For example, when developing a stock trading software, the core rules of stock trading rarely change, but the interface of the system can easily change. So we usually put the core business logic in the inner layer, and put the modules close to user input and output in the outer layer.

In Tencent's document business, the core business logic refers to calculating user input data through certain rules and converting it into document data. These conversion rules and specific calculation processes are the core business logic of Tencent Docs. They are very stable. From Microsoft Office to Google Docs to Tencent Docs, there have not been too many changes in more than 30 years. They should be placed in the inner layer of the system. On the other hand, whether these core business logic runs on the browser, terminal or node side, they should not change. The network layer, storage layer, offline layer, and user interface are changeable. In the terminal environment, the implementation of the terminal user interface layer and the web layer are completely different. On the node side, the storage layer may be directly removed from the system, because on the node side, we only need to use the core business logic module to perform some calculations on the function. In the same way, in unit testing or integration testing, the offline layer and storage layer may not be needed. In these volatile situations, we need to put non-core business logic on the outer layer so that they can be modified or replaced at any time.

Therefore, following the one-way dependency principle can greatly improve the stability of the system and reduce the damage to the system when the requirements change. When we design each module, we spend a lot of time on the design level, the division of modules, and the dependencies between levels and modules. We often say "divide and conquer". "Division" refers to the level and module. How to divide, class, etc., "governance" refers to how to reasonably connect the divided levels, modules, and classes. These designs are more important than specific coding details.

Dependence reversal principle

The core idea of ​​the dependency inversion principle is: inner modules should not depend on outer modules, they should all depend on abstraction.

Although we will spend a lot of time considering which modules are placed in the inner and outer layers, try to ensure that they are in a one-way dependency. However, in actual development, there are always many scenarios where inner modules need to rely on outer modules.

For example, in the examples of Localstorge and Indexdb, the User module, as the core business logic of the inner layer, relies on the volatile Localstorge and Indexdb modules on the outer layer, which causes the User module to become unstable.

import ‘localstorge’ from 'localstorge';

class User{
    save(){
        localstorge.save('xxx');
    }
}

const user = new User();
user.save();

Missing picture

In order to solve the stability problem of the User module, we introduced the DB abstract interface, which is relatively stable. The User module changed to rely on the DB abstract interface, so that the User became a stable module.

Interface DB{
   save(): void;
}

Then let the core business module User only rely on this interface:

import DB from 'DB';

class User{
    constructor(
        private db: DB
   ){

    }
    save(){
        this.db.save('xxx');
    }
}

Then let Localstorge and Indexdb implement the DB interface respectively:

class Localstorge implements DB{
    save(str:string){
        ...//do something
    }
}

Dependency becomes: missing picture

User -> DB <- Localstorge

From Figure 1 and Figure 2, the User module no longer explicitly depends on Localstorge, but on the stable DB interface. What is DB? Localstorge or Indexdb will be injected by other external modules later in the program. Here The dependency relationship seems to be reversed. This approach is called "dependency inversion".

Find changes, abstract and encapsulate them

Our theme "abstract-oriented programming", in many cases actually refers to "interface-oriented programming". Abstract-oriented programming stands from a more macro perspective of system design and guides us how to build a loosely coupled system, while interface-oriented programming tells us Our concrete realization method. The principle of dependency inversion tells us how to use "interface-oriented programming" so that dependencies are always from the outside to the inside, pointing to more stable modules in the system.

It is easy to know and difficult to do. Although abstract programming is not difficult to understand conceptually, it is not always easy in actual implementation. Which modules should be abstracted, which dependencies should be reversed, and how many abstraction layers are reasonable to introduce into the system, there are no standard answers to these questions.

When we receive a requirement and perform modular design on it, we must first analyze whether this module may be replaced with changes in the requirements, or be modified and reconstructed on a large scale? When we find that there may be changes, we need to encapsulate these changes and let the modules that depend on it rely on these abstractions.

For example, Localstorge and indexdb in the above example, experienced programs will easily think that they may need to be replaced with each other, so they are best designed to be abstract from the beginning.

In the same way, our database may also change. Maybe we are using mysql today, but it may be replaced by oracle next year. Then our applications should not rely on mysql or oracle, but let them rely on mysql and oracle. Public abstraction.

For another example, we often use ajax in our programs to transmit user input data, but one day we may want to replace ajax with websocket requests, so the core business logic should also rely on the common abstraction of ajax and websocket.

Packaging changes and design patterns

In fact, the 23 common design modules are summarized from the perspective of package changes. Take the creation mode as an example. To create an object is an abstract behavior, and the specific object created can be changed. The purpose of the creation mode is to encapsulate the changes of the created object. The structural model encapsulates the combination of objects. Behavioral patterns encapsulate the behavioral changes of objects.

For example, the factory model, by encapsulating the changes of the created object in the factory, so that the core business does not need to rely on specific implementation classes, and does not need to understand too many implementation details. When the created object changes, we only need to change the implementation of the factory, without affecting the core business logic.

For example, the module method pattern encapsulates the execution process sequence. The subclass inherits the template function of the parent class and executes it according to the process rules set by the parent class. The specific function implementation details are implemented by the subclass itself.

By encapsulating changes, the stable and unchanging parts of the system can be isolated from the easily changing parts. In the evolution of the system, only those parts that are easy to change need to be replaced or modified. If these parts are already encapsulated, they are relatively easy to replace. This can maximize the stability of the program.

Avoid excessive abstraction

Although abstraction improves the scalability and flexibility of the program, abstraction also introduces an additional layer of indirection, which brings additional complexity. Originally, a module depends on another module. This dependency is the simplest and most direct, but every time we add an abstraction layer in the middle, it means that we need to always pay attention to and maintain this abstraction layer. The addition of these abstract layers to the system will inevitably increase the level and complexity of the system.

If we judge that some modules are relatively stable and will not change for a long time, then there is no need to make them abstract in the first place.

For example, the String class in java is very stable, so there is no abstraction of String.

For example, some tool methods, similar to utils.getCookie(), I can hardly imagine what will replace cookies in 5 years, so I prefer to write getCookie directly.

For example, the data model of Tencent document excel, which belongs to the core in the core, like the bones and meridians in the entire body, has been integrated into each application logic. The possibility of it being replaced is very small, and the difficulty is also very high. Write a Tencent document excel, so there is no need to over abstract the model.

Conclusion

There are two biggest benefits of abstract programming.

On the one hand, abstract-oriented programming can encapsulate the frequently changing parts of the system in abstraction to keep the core module stable.

On the other hand, abstract-oriented programming can free core module developers from the implementation details of non-core modules, and leave the implementation details of these non-core modules in the later stage or leave it to others.

The actual discussion in this article mainly focuses on the first point, namely package changes. Packaging changes are the key to building a loosely coupled system.

This article, as an introduction to abstract programming, hopes to help some students realize the benefits of abstract programming and master some basic abstract programming methods.

Guess you like

Origin blog.csdn.net/Tencent_TEG/article/details/112210677