Should Service layer exceptions be thrown to the Controller layer for processing or handled directly?

0 Preface

Generally, when beginners learn coding and [error handling], they first know that the [programming language] has a form or convention for handling errors (such as Java throwing exceptions), and then start using these tools. But it ignores the essence of the problem: handling errors is to write correct programs . But

1 What does “correct” mean?

Determined by the problem being solved. The problems are different, the solutions are different.

For example, a web interface accepts user requests, the parameter age, and maybe the business requirement field is an integer between 0 and 150. If you enter a string or a negative number, it will definitely not be accepted. Generally, the input validity check is done somewhere in the backend, but an exception is thrown.

But ultimately the “correct” solution to this problem always involves prompting the user in some form. To prompt the user for some kind of front-end work, it depends on whether the interface is an app, H5+AJAX, or a server-generated interface similar to [jsp]. No matter what, you have to "design a process for fixing errors" based on your needs. For example, a common process requires the backend to throw an exception, and then go all the way to a centralized error processing code, convert it into an HTTP error (business error code) and provide it to the front end, and the front end then maps it to a "prompt". If a user enters an illegal request, logically the backend cannot fix it by itself. This is a "correct" strategy.

2 I quoted 500!

For example, if a user uploads an avatar and the backend sends the image to [Cloud Storage], the cloud storage reports 500. What should I do? You may want to try again as it may just be [network jitter] and retrying will work fine. However, if retrying multiple times fails, if some kind of hot backup solution is designed, it may be sent to another server instead. "Retry" and "Use backup dependencies" are both "process immediately".

But if the retry is invalid and all [backup services] are invalid, you may be able to throw the error to the front end as above, prompting the user that "the server is deserted". It is easy to see from this plan that where you want to throw the error is because the catch place is the most convenient place to deal with the problem. The solution to a problem may require a combination of several different error handlers.

3 NPE!

Your program throws an NPE. This is usually a programmer's bug:

  • Or the programmer wanted to express "no" of something, but forgot to determine whether it was null during subsequent processing.
  • Or when I was writing the code, I felt that it was 100% impossible for null to appear.

No matter what kind of error, users will always see a very vague error message, which is far from enough. The "correct" approach is for the programmer to discover it as quickly as possible and fix it as quickly as possible. To achieve this, the [monitoring system] needs to continuously crawl logs and report problems to the police. Instead of waiting for users to complain to customer service.

4OOM!

For example, your [backend program] suddenly OOM hangs. A hung program cannot recover itself. To get it "right", consider this in a container outside of the service.

If your service runs on [k8s], they will monitor the status of your program, then restart a new service instance to make up for the failed service, and adjust the traffic to switch the traffic to the downed service to the new instance. Because this recovery is cross-system, it cannot be achieved only with exceptions, but the principle is the same.

But is restarting alone “correct”? If the service is completely stateless, this is not a big problem. But if there is state, some user data may be messed up by half-executed requests. Therefore, when restarting, pay attention to "restoring the data to a legal state" first. This goes back to knowing what the “right” thing to do is. This matter cannot be solved mindlessly by relying solely on simple grammatical functions.

5. Improve the dimension

  • The "outer container" of a worker thread is the "master" that manages the worker thread
  • The "external container" of a network request is a Web Server
  • The "external container" of a user process is [operating system]
  • Erlang integrates this supervisor-worker mechanism into the design of the language

Web programs can throw exceptions to the top level to a large extent because:

  • The request comes from the front end. For problems caused by incorrect user requests (data legality, permissions, user context status), the user can basically only be informed in the end. Therefore, it is reasonable to throw exceptions to a centralized error processing place and convert the exception into a certain business error code.
  • Backend services are generally stateless. This is also a general principle of software system design. Statelessness means that you can safely restart anytime and anywhere. User data will not cause problems because of the next article
  • The backend's modification of data relies on DB transactions. Therefore, a half-modified, uncommitted transaction will not cause side effects.

But these three conditions are not always true. You will always encounter:

  • Some processing logic is not stateless
  • Not all data modifications can be protected by a transaction

Pay special attention to calls to [microservices]. Modifications to memory status are not protected by transactions . If you are not careful, user data will be messed up. For example, the following code snippet

6 Difficult to troubleshoot code snippets

 try {
   int res1 = doStep1();
   this.status1 += res1;
   int res2 = doStep2();
   this.status2 += res2;
   // 抛个异常
   int res3 = doStep3();
   this.status3 = status1 + status2 + res3;
} catch ( ...) { 
   // ...
}

Assume first that some invariant constraints (invariants) need to be maintained between status1, status2, and status3. Then when executing this code, if an exception is thrown in doStep3, the following assignment to status3 will not be executed. At this time, if the modification of status1 and status2 cannot be rolled back, it will cause data violation problem.

It is difficult for programmers to find that this data has been modified. Bad data may also cause logic errors in other codes that rely on this data (such as points that should have been awarded but were not). This kind of error is generally difficult to troubleshoot, and it is extremely difficult to find the incorrect short paragraph from a large amount of data.

7 more difficult code snippets

// controller
void controllerMethod(/* 参数 */) {
  try {
    return svc.doWorkAndGetResult(/* 参数 */);
  } catch (Exception e) {
    return ErrorJsonObject.of(e);
  }
}

// svc
void doWorkAndGetResult(/* some params*/) {
    int res1 = otherSvc1.doStep1(/* some params */);
    this.status1 += res1;
    int res2 = otherSvc2.doStep2(/* some params */);
    this.status2 += res2;
    int res3 = otherSvc3.doStep3(/* some params */);
    this.status3 = status1 + status2 + res3;
    return SomeResult.of(this.status1, this.status2, this.status3);
}

The difficulty is that when you write, you may think that things like doStep1~3 can be caught in the Controller even if an exception is thrown.

There is no need to handle any exceptions at the svc layer, so it is natural not to write [try...catch] . But in fact, any exception thrown by doStep1, doStep2, and doStep3 will cause the data status of svc to be inconsistent. You can even confirm from the beginning through documentation or other communication that doStep1, doStep2, and doStep3 are bound to succeed at the beginning and will not throw errors, so the code you write is correct from the beginning.

But you may not be able to control their implementation (for example, they are provided by [jar] developed by another team), and their implementation may be changed to throw errors. Your code may go from "not going to go wrong" to "may go wrong" without even realizing it... Even more frightening is similar code that doesn't work correctly:

void doWorkAndGetResult(/* some params*/) {
    try {
       int res1 = otherSvc1.doStep1(/* some params */);
       this.status1 += res1;
       int res2 = otherSvc2.doStep2(/* some params */);
       this.status2 += res2;
       int res3 = otherSvc3.doStep3(/* some params */);
       this.status3 = status1 + status2 + res3;
       return SomeResult.of(this.status1, this.status2, this.status3);
   } catch (Exception e) {
     // do rollback
   }
}

You think this will handle the data rollback, and you even think this code is elegant . But in fact, errors are thrown in every place in doStep1~3, and the rollback code is different.

It has to be written like this

void doWorkAndGetResult(/* some params*/) {
    int res1, res2, res3;
    try {
       res1 = otherSvc1.doStep1(/* some params */);
       this.status1 += res1;
    } catch (Exception e) {
       throw e;
    }

    try {
      res2 = otherSvc2.doStep2(/* some params */);
      this.status2 += res2;
    } catch (Exception e) {
      // rollback status1
      this.status1 -= res1;
      throw e;
    }

    try {
      res3 = otherSvc3.doStep3(/* some params */);
      this.status3 = status1 + status2 + res3;
    } catch (Exception e) {
      // rollback status1 & status2
      this.status1 -= res1;
      this.status2 -= res2;
      throw e;
   } 
}

This is the code that gets the correct results and maintains data consistency wherever errors occur. Elegant?

It looks ugly. It's uglier than go's if err != nil. But when it comes to choosing between correctness and elegance, I definitely choose the former without hesitation. As a programmer, you cannot directly think that throwing exceptions can solve any problem. You must learn to write programs with correct logic, even if it is difficult and looks ugly.

In order to achieve high accuracy, you cannot always focus most of your attention on the "everything is OK process", but regard errors as work that can be dealt with casually or simply believe that exceptions can automatically solve everything.

8 Summary

Be respectful of error handling:

  • Java has to avoid using Checked Exception due to design issues
  • Uncaughted Exception is really weak and cannot provide programmers with better help.

Therefore, programmers must reflect on themselves every time they throw an error or handle an error:

  • Is this error handling correct?
  • What will users see?
  • Will it mess up the data?

Don't think that you can throw an exception and that's it. When [the compiler] can't help much, write UT well to protect the poor correctness of the code.

Please write more correct code !

This article is published by OpenWrite, a blog that publishes multiple articles !

Guess you like

Origin blog.csdn.net/qq_33589510/article/details/133000935