A blow to the soul from jackson: Can ControllerAdvice be foolproof?

ControllerAdvice global exception capture

Here is a brief introduction, friends who dislike long-winded words can skip it directly and skip to Part 2.

In the Spring family, global exception handling can be enabled by annotating @ControllerAdvice or @RestControllerAdvice. Using this annotation indicates that the capture of global exceptions is enabled. We only need to use the @ExceptionHandler annotation in a custom method and define the type of exception to be caught. These caught exceptions are handled uniformly.

As long as the exception can finally reach the controller layer and match the exception type defined by @ExceptionHandler, it can be caught.

@RestControllerAdvice
public class GlobalExceptionHandler {

    Logger logger = LoggerFactory. getLogger(GlobalExceptionHandler. class);

    @ExceptionHandler(value = Exception. class)
    public Result exceptionHandler(Exception e){
        logger. error(e. getMessage(), e);
        return Result. error(e. getMessage());
    }

    @ExceptionHandler(value = RuntimeException. class)
    public Result exceptionHandlerRuntimeException(Exception e){
        logger. error(e. getMessage(), e);
        return Result. error(e. getMessage());
    }

    // or other custom exceptions
}

Then define a unified interface to return the object:

Click to view the code


Uniform status code:

Click to view the code


Define another test object:

@Getter
@Setter
//@ToString
//@AllArgsConstructor
//@NoArgsConstructor
public class Person {
    private String name;
    private Integer age;
    private Person father;

    public Person(String name, Integer age) {
        this.name = name;
        this. age = age;
    }
}

Write a test interface, simulate circularly dependent objects, and use fastjson to serialize and return.

public Result test2 (){
        List<Person> list = new ArrayList<>();
        Person obj1 = new Person("Zhang San", 48);
        Person obj2 = new Person("Lisi", 23);
        obj1. setFather(obj2);
        obj2. setFather(obj1);

        list.add(obj1);
        list.add(obj2);

        Person obj3 = new Person("Wang Mazi", 17);
        list. add(obj3);

        List<Person> young = list. stream(). filter(e -> e. getAge() <= 45). collect(Collectors. toList());
        List<Person> children = list. stream(). filter(e -> e. getAge()< 18). collect(Collectors. toList());

        HashMap map = new HashMap();
        map. put("young", young);
        map. put("children", children);
        return Result. success(map);
    }

Enable fastjson’s SerializerFeature.DisableCircularReferenceDetect to disable circular dependency detection and make it throw an exception.
Access the test interface and print logs in the background

ERROR 21360 [http-nio-8657-exec-1] [com.nyp.test.config.GlobalExceptionHandler] : Handler dispatch failed; nested exception is java.lang.StackOverflowError

Interface returns

{
"code": "500",
"data": null,
"msg": "Handler dispatch failed; nested exception is java.lang.StackOverflowError",
"success": false
}

Proof that the exception is successfully caught at the global exception catch. And a 500 status code is returned, which proves that an exception has occurred on the server.

jackson’s question

We now replace fastjson and use springboot’s own jackson for serialization. The same is the code above.
The log is printed in the background:

[2023-04-01 15:27:42.230] ERROR 17156 [http-nio-8657-exec-2] [com.nyp.test.config.GlobalExceptionHandler] : Could not write JSON: Infinite recursion (StackOverflowError) ; nested exception is com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError) (through reference chain: com.nyp.test.model.Person["father"]->com.nyp.test.model.Person[ "father"]....

The log information is slightly different, which is the difference between two different serialization frameworks. In short, the global exception capture is also successful.

Look at the returned results as follows:

This is obviously wrong. The background has thrown an exception and successfully caught the exception. Why did the front end receive the 200 status code? And there are circularly nested data in data!

The returned message is very long. Carefully observe the last part and find that the 500 status code and exception information are also returned at the same time.

To make a long story short, Jackson is quite used. By default, for circular object references, when global exception handling is added, the interface returns two opposite messages at the same time:

{
"code": "200",
"data":{"young":[{"name":"Li Si","age":23,"father":{"name":"Zhang San","age":48}]}"
"success": true
}
{
"code": "500",
"data": null,
"msg": "Handler dispatch failed; nested exception is java.lang.StackOverflowError",
"success": false
}

Do you have a lot of question marks, kid? ?

Is this phenomenon caused by throwing an exception after return?

This is a bit interesting.
The reason for this phenomenon, I initially suspected that it was caused by throwing an exception after the method return returned.

My suspicion is not without reason. For details, please refer to my other article. When transcational meets synchronized, it is mentioned,
Spring uses dynamic proxy plus AOP to implement transaction management. Then a method with annotated transactions actually needs to be simplified into at least 3 steps:

void begin();

@Transactional
public synchronized void test(){
    //
}

void commit();
// void rollback();

If under the transaction isolation level of read committed and above, the test method is executed and the data is updated, but the commit transaction has not been reached yet, but the lock has been released, and another transaction comes in to read the old data.

Similarly, the test method here is actually the same. Jackson is doing the serialization operation before return, so will the return return 200 once, and return a 500 status code after throwing an exception after return?

Then use TransactionSynchronization to simulate an exception after return to see what information is returned to the front end.

@Transactional
    @RequestMapping( "/clone")
    public Result test2 (){
        List<Person> list = new ArrayList<>();
        Person obj1 = new Person("Zhang San", 48);
        Person obj2 = new Person("Lisi", 23);
        obj1. setFather(obj2);
        obj2. setFather(obj1);

        list.add(obj1);
        list.add(obj2);

        Person obj3 = new Person("Wang Mazi", 17);
        list. add(obj3);

        List<Person> young = list. stream(). filter(e -> e. getAge() <= 45). collect(Collectors. toList());
        List<Person> children = list. stream(). filter(e -> e. getAge()< 18). collect(Collectors. toList());

        HashMap map = new HashMap();
        map. put("young", young);
        map. put("children", children);

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                if (1 == 1) {
                    throw new HttpMessageNotWritableException("test exception after return");
                }
                TransactionSynchronization. super. afterCommit();
            }
        });
        return Result. success(map);
    }

Restart and call the test interface, and print the log in the background

[http-nio-8657-exec-1] [com.nyp.test.config.GlobalExceptionHandler] : test exception after return

Return client information:

{"code":"500","success":false,"data":null,"msg":"test exception after return"}

Tests have shown that this is not the cause.

At this point, careful friends may have discovered it. For the previous conjecture, about jackson is doing serialization before return, so will the return return 200 once, and then return after throwing an exception after the return A 500 status code? It is actually unreasonable.
When we first came into contact with java web development, we must first learn servlet, then learn spring, springmvc, springboot frameworks, and now go back to the original beauty, think about how servlet returns data to the client?

Obtain an output stream through HttpServletResponse, whether it is OutputStream or PrintWriter, and output our manually serialized json string to the client.

@WebServlet(urlPatterns = "/testServlet")
public class TestServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        // Create an output stream via PrintWriter or OutputStream
        // OutputStream outputStream = response. getOutputStream();
        PrintWriter out = response. getWriter();
        try {
            // Simulate getting a return object
            Person person = new Person("Zhang San", 23);
            out. println("start!");
            // Manually serialize and output to the client
            Gson gson = new Gson();
            out.println(Result.success(gson.toJson(person)));
            // outputStream.write();
            out. println("end");
        } finally {
            out.println("Success!");
            out. close();
        }
        super.doGet(request, response);
    }
}

I haven’t seen the source code of springmvc, so I think it’s the same logic, right?
After the invoke in dispatchServlet is completed, the target controller obtains the returned object, then calls the serialization framework jackson or fastjson to obtain a JSON object, and then outputs the front end through the output stream. The last step may be in the servlet or directly in the serialization framework. operate.
In short, no matter which step it is in, it is a bit unreasonable. If it is during serialization, the serialization framework is directly abnormal, and it should not output two segments of messages of 200 and 500.

In any case, here is also a verification of whether @ControlerAdvice can catch the exception thrown by the target controller method after Return, the answer is yes.

Now we can take a look at why Fastjson does not output 200 and 500 messages when an exception occurs during serialization after return.

Why is there no problem with fastjson

We know from the previous article that under the same circumstances, fastjson serialization can normally return a 500 exception message to the client.

We are now switching the serialization framework of springmvc to fastjson. Go through the source code through breakpoints. Observe why fastjson can throw exceptions normally.

Through the call stack information, we can clearly observe the distpatchServlet that we are very familiar with, and then the handleReturnValue call completes the target controller to get the return object, now to the AbstractMessageConverterMethodProcessor .writeWithMessageConverters, and finally reach GenericHttpMessageConverter.write() Through comments, even if it is the method name and parameter name, we also know that here is to start calling the specific serialization framework to rewrite this method to output and return The text has arrived at the client.

Then start to make a breakpoint here, this is an interface method, it has many implementation classes, the breakpoint here will directly enter the method of the specific implementation class.
Finally came to FastJsonHttpMessageConverter.writeInternal()

Here comes the important point, as shown in the figure above, when line 314 is executed, an exception is thrown at the place marked 1, and then it goes to finally, skips line 337, that is, the 2 places actually execute the write output to The operation of the client.
We don’t need to worry about the serialization specific operation inside the method called at line 314, we just need to know that it directly fails in the serialization preparation phase, and does not actually execute the write operation to the client.

Then the exception is finally caught by @RestControllerAdvice and output to the client 500.

jackson’s output process

Now as a comparison, let’s go back and see how jackson completes the above operations.

Hit the same breakpoint as fastjson in the previous section, and finally enter the serialization method of jackson. Through the inline watches on the right, you can see that the value to be serialized has changed from a circular reference of an object to a specific one. There are several layers of nested loops.

Breakpoint all the way, come to UTF8JsonGenerator, it can be observed that jackson does not serialize the entire return value value together, but serializes one object and one field sequentially.

These values will temporarily enter a buffer buffer, and when it is greater than outputend=8000, it will be flushed and output directly to the client.

The _outputstream here is the java.io.OutputStream object.

Summary

A summary can be made here.

Why does Jackson output two messages of 200 and 500 to the client at the same time when the object is circularly referenced?

Because the serialization of jackson is carried out in stages, it uses a mechanism similar to fail-safe, which delays the failure until later, and before the failure, the message with the 200 status code has been output to the client.

Why can fastjson only output 500 packets normally?

Because the serialization of Fastjson has a fail-fast mechanism, it can directly throw an exception when it judges that there is an object circular reference, and then it is processed by the global exception, and finally only outputs a 500 status code report to the client arts.

@ControllerAdvice failure scenario

Through annotations, we know that @ControllerAdvice acts on all controller class methods by default. You can also set the package manually.

@RestControllerAdvice("com.nyp.test.controller")
or
@RestControllerAdvice(basePackages = "com.nyp.test.controller")

Then the scenario to make it fail is
1. The exception cannot reach the controller layer, such as swallowing the exception through try-catch in the service layer. Another example is that it is also thrown when it reaches the controller layer, but it is processed by try-catch in other AOP aspect notifications.
2. Or do not point to the controller layer or part of the controller layer, such as through @RestControllerAdvice(basePackages = “com.nyp.test.else”)

etc.

Others, as long as the above situation is not touched, and the configuration is correct, even if an exception is thrown after return, it can be handled correctly.
Specific to the situation of jackson in this article, strictly speaking, @ControllerAdvice also plays a role. It’s just a problem with jackson itself in the process of serialization.

Summary

  1. Is @ControllerAdvice completely safe?
    As long as it is configured correctly, it is completely safe. This article belongs to the special case of jackson, and the abnormal situation it causes is not the problem of @ControllerAdvice.

2. What is the reason for returning 200 and 500 messages at the same time?

Because the serialization of jackson is carried out in stages, it uses a mechanism similar to fail-safe, which delays the failure until later, and before the failure, outputs the 200 status code message to After the client fails, it outputs a message with a 500 status code to the client.
The serialization of Fastjson has a fail-fast mechanism, it can directly throw an exception when it judges that there is an object circular reference, and then it is processed by the global exception, and finally only outputs a 500 status code to the client message.

3. How to solve this problem?

This is essentially a jackson circular dependency issue. via annotation
@JsonBackReference
@JsonManagedReference
@JsonIgnore
@JsonIdentityInfo
It can be partially resolved.

for example:

@JsonIdentityInfo(generator= ObjectIdGenerators.IntSequenceGenerator.class, property="name")
private Person father;

return:

{
"code": "200",
"success": true,
"data": {
"young": [{
"name": "Li Si",
"age": 23,
"father": {
"name": 1,
"name": "Zhang San",
"age": 48,
"father": {
"name": 2,
"name": "Li Si",
"age": 23,
"father": 1
}
}
}, {
"name": "Wang Mazi",
"age": 17,
"father": null
}],
"children": [{
"name": "Wang Mazi",
"age": 17,
"father": null
}]
},
"msg": "success"
}

At the same time, for the situation of object circular reference, it should be avoided as much as possible in the code.
Just like the situation where spring handles dependency injection, @lazy annotation is used to solve it at first, and then spring officially solves it through three-layer cache, and then springboot officially does not support dependency injection by default. If there is dependency injection, it will report an error by default.

In a word, what this article is about is that when spring mvc & amp; spring boot uses jackson for serialization output, if the problem of circular dependency is not handled well, then the front end cannot correctly perceive the problem of server exception.

However, circular dependencies are not common, and there are solutions when they are encountered, so it seems that this article is not useful.

However, no one stipulates that it must be solved. When I was a novice, I did not solve the circular dependency, and at the same time, when the front end did not receive the correct server exception, there was always doubt.

And if it is expanded, jackson may fail in the middle of serialization, and this may happen.

From this perspective, is it a problem with Jackson?

Anyway, I hope this article can inspire you.