简体   繁体   中英

Using monads in Springboot application to catch exceptions

So our project back-end is a Java 8 Springboot application, springboot allows you to do some stuff really easily. ex, request validation:

class ProjectRequestDto {
    @NotNull(message = "{NotNull.DotProjectRequest.id}")
    @NotEmpty(message = "{NotEmpty.DotProjectRequest.id}")
    private String id;
}

When this constraint is not meet, spring (springboot?) actually throws a validation exception, as such, we catch it somewhere in the application and construct a 404 (Bad Request) response for our application.

Now, given this fact, we kinda followed the same philosophy throughout our application, that is, on a deeper layer of the application we might have something like:

class ProjectService throws NotFoundException {
  DbProject getProject(String id) {
      DbProject p = ... // some hibernate code
      if(p == null) {
          Throw new NotFoundException();
      }

      return p;
  }
}

And again we catch this exception on a higher level, and construct another 404 for the client.

Now, this is causing a few problems:

  1. The most important one: Our error tracing stops being useful, we cannot differentiate (easily) when the exception is important, because they happen ALL the time, so if the service suddenly starts throwing errors we would not notice until it is too late.

  2. Big amount of useless logging, on login requests for example, user might mistyped his password, and we log this and as a minor point: our analytics cannot help us determine what we are actually doing wrong, we see a lot of 4xx's but that is what we expect.

  3. Exceptions are costly, gathering the stack trace is a resource intensive task, minor point at this moment, as the service scales up with would become more of a problem.

I think the solution is quite clear, we need to make an architectural change to not make exceptions part of our normal data flow, however this is a big change and we are short on time, so we plan to migrate over time, yet the problem remains for the short term.

Now, to my actual question: when I asked one of our architects, he suggested the use of monads (as a temporal solution ofc), so we don't modify our architecture, but tackle the most contaminating endpoints (ex. wrong login) in the short term, however I'm struggling with the monad paradigm overall and even more in java, I really have no idea on how to apply it to our project, could you help me with this? some code snippets would be really good.

TL:DR: If you take a generic spring boot application that throws errors as a part of its data flow, how can you apply the monad pattern to avoid login unnecessary amount of data and temporarily fix this Error as part of data flow architecture.

The standard monadic approach to exception handling is essentially to wrap your result in a type that is either a successful result or an error. It's similar to the Optional type, though here you have an error value instead of an empty value.

In Java the simplest possible implementation is something like the following:

public interface Try<T> {

    <U> Try<U> flatMap(Function<T, Try<U>> f);

    class Success<T> implements Try<T> {
        public final T value;

        public Success(T value) {
            this.value = value;
        }

        @Override
        public <U> Try<U> flatMap(Function<T, Try<U>> f) {
            return f.apply(value);
        }
    }

    class Fail<T> implements Try<T> {
        // Alternatively use Exception or Throwable instead of String.
        public final String error;

        public Fail(String error) {
            this.error = error;
        }

        @Override
        public <U> Try<U> flatMap(Function<T, Try<U>> f) {
            return (Try<U>)this;
        }
    }
}

(with obvious implementations for equals, hashCode, toString)

Where you previously had operations that would either return a result of type T or throw an exception, they would return a result of Try<T> (which would either be a Success<T> or a Fail<T> ), and would not throw, eg:

class Test {
    public static void main(String[] args) {
        Try<String> r = ratio(2.0, 3.0).flatMap(Test::asString);
    }

    static Try<Double> ratio(double a, double b) {
        if (b == 0) {
            return new Try.Fail<Double>("Divide by zero");
        } else {
            return new Try.Success<Double>(a / b);
        }
    }

    static Try<String> asString(double d) {
        if (Double.isNaN(d)) {
            return new Try.Fail<String>("NaN");
        } else {
            return new Try.Success<String>(Double.toString(d));
        }
    }
}

Ie instead of throwing an exception you return a Fail<T> value which wraps the error. You can then compose operations which might fail using the flatMap method. It should be clear that once an error occurs it will short-circuit any subsequent operations - in the above example if ratio returns a Fail then asString doesn't get called and the error propagates directly through to the final result r .

Taking your example, under this approach it would look like this:

class ProjectService throws NotFoundException {
  Try<DbProject> getProject(String id) {
      DbProject p = ... // some hibernate code
      if(p == null) {
          return new Try.Fail<DbProject>("Failed to create DbProject");
      }

      return new Try.Succeed<DbProject>(p);
  }
}

The advantage over raw exceptions is it's a bit more composable and allows, for example, for you to map (eg Stream.map) a fail-able function over a collection of values and end up with a collection of Fails and Successes. If you were using exceptions then the first exception would fail the entire operation and you would lose all results.

One downside is that you have to use Try return types all the way down your call stack (somewhat like checked exceptions). Another is that since Java doesn't have built-in monad support (al la Haskell & Scala) then the flatMap'ing can get slightly verbose. For example something like:

try {
    A a = f(x);
    B b = g(a);
    C c = h(b);
} catch (...

where f, g, h might throw, becomes instead:

Try<C> c = f(x).flatMap(a -> g(a))
               .flatMap(b -> h(b));

You can generalise the above implementation by making the error type an generic parameter E (instead of String), so it then becomes Try<T, E> . whether this is useful depends on your requirements - I've never needed it.

I have a more fully-implemented version here , alternatively the Javaslang and FunctionalJava libraries offer their own variants.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM