Whose pan is this anyway
In normal business development, we often encounter a lot of complex logical decisions, and the general framework may be like the following:
public void request(a) {
if (conditionA()) {
// ...
return;
}
if (conditionB()) {
throw new RuntimeException("xxx");
}
if (conditionC()) {
// do other things}}private boolean conditionC(a) {
return false;
}
private boolean conditionB(a) {
return false;
}
private boolean conditionA(a) {
return true;
}
Copy the code
If it is a simple, constant business, it is not a big problem. But in a core, complex business where the code of the same system is maintained by multiple people, it would be very difficult to insert a piece of logic into the above code. When implementing, you may encounter these problems:
- Before implementation: I need to consider the logical branch, where should I add to meet the requirements? Could it be blocked by preconditions?
- Implementation: The logic branch uses some function parameters, will these parameters have an impact on the subsequent logic? And will the parameters be modified by any of the previous parameters? So I have to understand all the logic before and after in order to get the function I want right, or I have to take a chance that it’s not changed…
- After implementation: After adding, how do I test? It seems that we have to construct conditions for all of the previous judgments to pass… Again, understand the logic of the preconditions
If you put it into a real business scenario, you might encounter more problems. I just want to add another branch of logic! Why is it so hard?!
What is the solution to these problems? Obviously, when implementing a feature, there are too many details to know for the principle of a single responsibility. Both new logic and modified logic are highly intrusive and inconsistent with the open and closed principle. I’m not responsible for the logical details of my back and forth, and I need to throw the pot out, and to do a better job of throwing the pot, then this is the chain of responsibility mode.
The trick of flipping the pan
The chain of responsibility, as the name suggests, is a chain with many nodes in it. When mapped to a data structure, it is an ordered queue with many elements, each of which processes its own logic independently and, when done, passes the process to the next node. So, in this pattern, two roles can be abstracted: the chain and the node. The chain handles requests and assembles nodes, while each node handles its own business logic, regardless of what happens upstream and downstream of the node.
Therefore, from the use-case perspective, the following use-case diagram can be obtained:
So, can the chain of responsibility solve the above problems? The above problems actually correspond to the following questions:
- Where should requirements be implemented to meet them?
- Will implementing this requirement affect other modules, and how will other modules affect the logic I implement?
- How should the requirements be tested once they are implemented?
It is clear from the role division in the chain of responsibility:
- For the first question, the role of the chain should be concerned with arranging where the nodes are implemented from a business perspective.
- For the second problem, the implementation of the requirements is the responsibility of the node. For input parameters in the responsibility chain, only the read method is provided, but no write method is provided. In this way, the risk of one node secretly tampering with parameters is well avoided. For other nodes, there is no need to worry about the modification of input parameters by other nodes. The responsibility between each node is distinct, and the influence between modules is small because of the structure of the responsibility chain itself.
- For the third question, after the node logic is realized, only the node logic itself is tested. As for whether the logic can be executed on this node, the node sequence set by the chain is guaranteed. When testing, you just need to make sure the order is correct. There is no need to start at the beginning of the request, and construct a bunch of conditions for the code to execute on its own logic.
All the problems mentioned above can be solved by using the responsibility chain model. So how do you implement the chain of responsibility model?
It’s time to show some real flipping skills
As defined in the use-case diagram above, the chain is responsible for managing the node, which is the entry point to the request, and the node is one of the links in the chain. So this is an aggregative relationship. The class diagram is as follows:
Shake the pan a secret skill
If we develop on top of the Spring framework, we can easily implement a simple chain of responsibilities pattern:
@Component
@RequiredArgsConstructor
public class PolicyChain1 {
private final List<Policy<ContextParams, String>> policies;
public void filter(ContextParams contextParams) { policies.forEach(policy -> { policy.filter(contextParams); }); }}Copy the code
Spring’s auto-injection mechanism implements the addPolicy method without the tedious process of adding nodes, as long as the Policy implementation class is also marked as Component.
However, there is a serious problem. How do you control the order between each Policy? At this point you might want to use the @order annotation to solve this problem. But suppose there are dozens of policies, and if you need to insert a Policy in the 10th and 11th policies, do you want to reorder all the policies starting from the 11th? It’s a hassle to think about it. Therefore, this method can only be used when there is no requirement on the sequence. For example, when the permission is verified, each verification condition is unrelated to each other and there is no restriction on the sequence. Therefore, this method can be implemented with strong scalability and simple implementation.
Trick two
But the requirements must be in order, so how to do? The above analysis of the way to specify Order is not desirable, what else?
In fact, the above method is very similar to the operation of inserting an array. When you insert an element in the middle of an array, the element after the insertion position is moved one place back. The Order value of the corresponding Policy is increased by 1. So similarly, arrays are inefficient to insert, so wouldn’t a more efficient way be a linked list? Each Policy can hold a reference to the next Policy to be processed. When this Policy is processed, call the filter method of the next Policy and modify the reference of the previous Policy.
Let’s draw a class diagram
After this organization, PolicyA needs to hold a reference to PolicyB, and PolicyB needs to hold a reference to PolicyC. When I need to insert a D between B and C, THEN I need to point the reference in B to D, and then D to C.
However, after such organization, I do not know the whole picture of the chain, which nodes there are in the chain and what the order is, and I cannot infer it all at once. In addition, this is not consistent with the use case diagram inferred above. In the use case, the chain is responsible for the assembly of the nodes, which is now left to each node. This is a clear violation of the single responsibility principle.
In this case, I still put the node assembly in the chain implementation, nodes only implement logic, but at the time of assembly, can let the user specify the order explicitly, so not good?
The approximate implementation looks like this:
@Component
@AllArgsConstructor
public class PolicyChain2 {
private SessionJoinDeniedPolicyHandler sessionJoinDeniedPolicyHandler;
private SessionLockPolicyHandler sessionLockPolicyHandler;
private SessionPasswordPolicyHandler sessionPasswordPolicyHandler;
@PostConstruct
public void init(a) {
sessionLockPolicyHandler.setNextHandler(sessionJoinDeniedPolicyHandler);
sessionJoinDeniedPolicyHandler.setNextHandler(sessionPasswordPolicyHandler);
}
public void filter(ContextParams params) { sessionLockPolicyHandler.filter(params); }}Copy the code
So the chain itself needs to know what the nodes are so that it can assemble the different nodes together.
// Policy abstract class
public abstract class PolicyHandler<T> {
private PolicyHandler<T> nextHandler;
void setNextHandler(PolicyHandler<T> handler) {
nextHandler = handler;
}
public void filter(T context) {
doFilter(context);
if(nextHandler ! =null) { nextHandler.filter(context); }}protected abstract void doFilter(T context);
}
// Policy implementation class
@Component
public class SessionPasswordPolicyHandler extends PolicyHandler<ContextParams> {
@Override
public void doFilter(ContextParams context) {
String requestParam = context.getRequestParam();
if (Objects.equals(requestParam, "ok")) {
return;
}
throw new RuntimeException("session password throw exception"); }}Copy the code
As for the node itself, you only need to pay attention to the business logic that it processes. Users simply call the Filter method of PolicyChain, and the following logic is automatically completed in order.
It seems that this implementation is more or less sufficient! Until… When I implemented a counter using this trick…
To reduce the strain on the database, I annotated a method to join a room and implemented a counter to check if the number of people joining exceeded the room limit, which reduced the number of queries to the database. The implementation code is roughly as follows:
@ValidatePolicy
public void filter(a) {
join();
}
// The interceptor method corresponding to the validate annotation, which omits the relevant code of the section class and shows only the core content
public void validate(a) {
strategyRouter.applyStrategy(ContextParams.builder()
.isJoinDenied(false)
.isLocked(false)
.password("123")
.build());
}
Copy the code
The validate() method passed the check happily and set its counter to 3. However, when the join method was executed, an exception was thrown, and the third person who should have joined did not join. Then the fourth person joined the room. Since there were only two people in the room, the fourth person should have succeeded in joining the room, but since the counter was already set to 3, the fourth person threw an exception directly during the verification phase.
So, on the basis of Trick 2, catch an exception when you perform the latter method, and then correct the counter! However, this implementation is impossible, because each node is focused on handling its own logic when it successfully intercepts and ignores what happens when the subsequent logic fails. Thus, secret technique 2 can handle sequential nodes and can be used for stateless pre-check, but it cannot support the subsequent logic failure, the node itself needs to handle the rollback operation.
Three tips to shake the pot
Based on the above problem, I needed to find an implementation that could support rollback. At this time, I referred to the implementation of Filter in Spring Cloud Gateway and found several characteristics:
- Each node depends on the chain itself, and when the processing logic of the next node is to be executed, it is simply called
chain.filter()
Method can. - The definition of node order is separated from the creation of nodes to avoid the dependence of the chain on specific nodes. The creation of nodes can be realized through the factory mode, which enhances scalability.
The general class diagram is as follows:
First let’s look at how to support ordering. In FilterRouter, there is a loadFilterDefinitions method that subclasses can override to define which nodes are present in the chain of responsibilities. The chain itself becomes indifferent to the order of nodes and delegates the processing of node order to another object. In addition to explicitly defining node orders in FilterRouter, you can override loadFilterDefinitions to specify node orders from different sources, such as configuration files, external systems, etc., making the order definitions more flexible and extensible.
@RequiredArgsConstructor
public abstract class FilterRouter<T.R> {
private final Map<String, FilterFactory<T, R>> filterFactories;
public List<Filter<T, R>> getFilters(T filterChainContext) {
final List<FilterDefinition> filterDefinitions = new ArrayList<>();
loadFilterDefinitions(filterChainContext, filterDefinitions);
List<Filter<T, R>> filters = filterDefinitions.stream().map(filterDefinition -> {
FilterFactory<T, R> filterFactory = filterFactories.get(filterDefinition.getName());
return filterFactory.apply();
}).collect(Collectors.toList());
filterDefinitions.clear();
return filters;
}
protected abstract void loadFilterDefinitions(T filterChainContext, List<FilterDefinition> filterDefinitions);
}
@Component
public class DefaultFilterRouter extends FilterRouter<String.String> {
public DefaultFilterRouter(Map<String, FilterFactory<String, String>> filterFactories) {
super(filterFactories);
}
@Override
protected void loadFilterDefinitions(String filterChainContext, List<FilterDefinition> filterDefinitions) {
filterDefinitions.add(newFilterDefinition(PasswordFilterFactory.KEY)); }}Copy the code
Let’s take a look at how node operations support rollback. By implementing the FilterFactory interface, you can perform your own validation logic in the Apply method and catch exceptions for subsequent processing, and when an exception is caught, the rollback exception is handled in the exception handling block. In addition, by using the Spring framework’s automatic injection to declare the Factory as Component, FilterRouter avoids cumbersome add methods when collecting Filter implementations.
@Component
public class PasswordFilterFactory implements FilterFactory<String.String> {
public static final String KEY = "passwordFilterFactory";
@Override
public Filter<String, String> apply(a) {
return (filterChainContext, filterChain) -> {
// validate
try {
return filterChain.filter(filterChainContext);
} catch (Exception e) {
// rollback
}
return ""; }; }}Copy the code
As for DefaultFilterChain, all it does is receive a request that will generate a Filter list from FilterRouter’s FilterFactory. The code is as follows:
public class DefaultFilterChain<T.R> implements FilterChain<T.R> {
private final T filterChainContext;
private int index = 0;
private final List<Filter<T, R>> filters = new ArrayList<>();
public DefaultFilterChain(FilterRouter<T, R> filterRouter, T filterChainContext) {
this.filterChainContext = filterChainContext;
filters.addAll(filterRouter.getFilters(filterChainContext));
}
public R filter(a) throws Throwable {
return filter(filterChainContext);
}
@Override
public R filter(T filterChainContext) throws Throwable {
int size = filters.size();
if (this.index < size) {
Filter<T, R> filter = filters.get(this.index);
index++;
return filter.filter(filterChainContext, this);
}
return null;
}
public void addLastFilter(Filter<T, R> filter) { filters.add(filter); }}Copy the code
Code for use:
@Component
@RequiredArgsConstructor
public class Client {
private final DefaultFilterRouter defaultFilterRouter;
public void filter(String param) throws Throwable {
DefaultFilterChain<String, String> filterChain = newDefaultFilterChain<>(defaultFilterRouter, param); filterChain.filter(param); }}Copy the code
So far, the last implementation method can not only meet the requirements of sequential nodes, but also support the post-processing of nodes when the subsequent logic fails. At the same time, it also has good scalability, which can realize the sequence of loading nodes from different sources, and can realize different filters through FilterFactory. Then encapsulate the third trick as a component so that the business can gracefully dump it when it comes to access.
Pan summary rejection
The three realization modes of responsibility chain mode are listed above, which can deal with three scenarios respectively:
- There is no requirement for the node order, which can be achieved in a simple way
- If the sequence of nodes is required and all nodes are stateless and do not need post-processing, secret technique 2 can be used
- If the sequence of nodes is required and the processing of one node is stateful and post-processing is required, secret technique 3 can be used
In the classic design patterns book, Design Patterns: The Foundations of Reusable Object-oriented Software, there is a phrase that says, “Find change, Encapsulate change.” This is the underlying logic of design patterns.
Reviewing the whole process, we can see:
- What changes is that, from code like a running list, to trick one, a new piece of insert logic is added, and the final effect of encapsulation is that the insert logic becomes the processing logic of one of the nodes.
- The change from Trick one to Trick two is the need to support node order, and the final effect of encapsulation is to condenses the definition of the order inside the chain, enabling custom order.
- From secret technique 2 to secret technique 3, what changes is that nodes need to support rollback and post-processing, and the result of encapsulation is that the logic of subsequent processing is exposed to nodes, but nodes rely on the chain itself, and the subsequent processing logic is shielded, and nodes still focus on their own processing logic.
It can be seen that procedural code and the evolution of design pattern are not invented out of thin air, but start from the problem, find the core change point, and encapsulate and abstract the change point, and slowly form the final ideal result.