aws-powertools / powertools-lambda-java

Powertools is a developer toolkit to implement Serverless best practices and increase developer velocity.
https://docs.powertools.aws.dev/lambda/java/
MIT No Attribution
286 stars 91 forks source link

RFC: support for multi-endpoint HTTP handler Lambda functions #1612

Open dnnrly opened 6 months ago

dnnrly commented 6 months ago

Key information

Summary

One paragraph explanation of the feature.

This RFC proposes a new module that will allow developers to easily implement lightweight, multi-endpoint ALB and API Gateway lambdas in Java with minimal (or no) additional dependencies outside of the Powertools and the JRE. This approach provides an alternative to using more heavy weight frameworks based around Spring or JaxRS, reducing GC and classloader burden to allow maximum cold start performance.

Motivation

Why are we doing this? What use cases does it support? What is the expected outcome?

Here's the value statement I think we should work towards:

As a developer

I would like to implement my HTTP microservice in Java on AWS Lambda

So that I can take advantage of the serverless platform without having to adopt an entirely new technology stack

We have been using a lightweight framework to achieve this goal rather successfully for the past couple of years. We have gone all-in on AWS lambda and have been able to solve many of the problems associated with implementing Java lambdas as we gained experience. We would like to contribute some of the technology that has enabled to do this so successfully.

To be more specific about what problem this solves, this RFC proposes a solution for lambdas that:

Many of the techniques and approaches used in this proposal come from this article: https://medium.com/capital-one-tech/aws-lambda-java-tutorial-best-practices-to-lower-cold-starts-capital-one-dc1d8806118

This proposal does not make Java more performant than using other runtimes for your lambda, but it does prevent the framework being a performance burden.

Proposal

This is the bulk of the RFC.

Explain the design in enough detail for somebody familiar with Powertools for AWS Lambda (Java) to understand it, and for somebody familiar with the implementation to implement it.

This should get into specifics and corner-cases, and include examples of how the feature is used. Any new terminology should be defined here.

The design of this proposal is significantly different from the approach taken in Spring and JaxRS. It borrows concepts and idioms from other languages, Go in particular. Despite this it is still possible to use patterns familiar to Java developers such as 3-tier or Domain Driven Design. Examples of this will be shown

API examples

Basic route binding

A simple example of how URL path and HTTP method can be used to bind to a specific handler ```java public class ExampleRestfulController { private final Router router; { /* This router demonstrates some of the features you would expect to use when creating a RESTful resource. They are all using the same root but differentiated by looking at the HTTP method and the `id` path parameter. These individual routes are also named so that you can filter log events based on that rather than having to use a regular expression on the path in combination with method. */ final Router resourcesRouter = new Router( Route.bind("/", methodOf("POST")).to(this::createResource), Route.bind("/(?.+)", methodOf("GET")).to(this::getResource), Route.bind("/(?.+)", methodOf("DELETE")).to(this::deleteResource), Route.fallback().to(this::invalidMethodFallthrough)); router = new Router( Route.HEALTH, Route.bind("/resources(?/.*)?") .to(subRouteHandler("sub", resourcesRouter))); } private AlbResponse createResource(Request request) { return AlbResponse.builder().withStatus(Status.CREATED).withBody("created something").build(); } private AlbResponse getResource(Request request) { return AlbResponse.builder() .withStatus(Status.OK) .withBody("got resource with ID " + request.getPathParameters().get("id")) .build(); } private AlbResponse deleteResource(Request request) { return AlbResponse.builder() .withStatusCode(204) .withBody("deleted resource with ID " + request.getPathParameters().get("id")) .build(); } private AlbResponse invalidMethodFallthrough(Request request) { return AlbResponse.builder().withStatus(Status.METHOD_NOT_ALLOWED).build(); } } ```

Filters

In this section, we will describe how we can add filters that allow us to perform operations on the request object before it is passed to the handler. I've called them filters here but we can decide if there's a more appropriate name for this.

A simple example of how we can add filters to a binding. ```java Filter aSingleFilter = new YourFilter(); List manyFilters = asList(new AnotherFilter(), new FilterWithParameters(42)); Router router = new Router(Route.HEALTH, Route.bind("/path") .to(aSingleFilter) .to(manyFilters) .to((handler, request) -> { // Do something inline return handler.apply(request); }) .thenHandler(this::mainHandler)); ```
Here is an example of a filter you might want perform some common logging. ```java public class RequestLogger implements Filter { private final YourStructuredLogger log; public RequestLogger(Logger log) { this.log = log; log.info("isColdStart", "true"); } @Override public AlbResponse apply(RouteHandler handler, Request request) { log.info("path", request.getPath()); String method = request.getHttpMethod(); if (!Objects.equals(method, "")) { log.info("http_method", method); } logHeaderIfPresent(request, "content-type"); logHeaderIfPresent(request, "accept"); try {      AlbResponse response = handler.apply(request); log.info("status_code", String.valueOf(response.getStatusCode())); return response; } catch (Exception ex) { log.error("Unhandled exception", ex); throw ex; } } ```
Here is an example of how you could do request validation inside of a filter. ```java Filter validator = (next, request) -> { if (request.getBody() != "") { return next.apply(request); } else { return AlbResponse.builder().withStatusCode(400).withBody("you must have a request body").build(); } }; ```
Here is how you might implement a simple (or complex) exception mapper using filters. ```java public class ExceptionMapping { private final Router router = new Router( Route.HEALTH, Route.bind("/resources/123", methodOf("GET")) .to(this::mapException) .then(this::accessSubResource), Route.bind("/resources/(?.+)", methodOf("GET")) .to(this::mapException) .then(this::accessMissingResource), Route.bind("/resources", methodOf("POST")) .to(this::mapException) .then(this::processRequestObject), Route.bind("/some-gateway", methodOf("GET")) .to(this::mapException) .then(this::callDownstream)); /** This is a really simple example of how exception mapping can be done using forwarders. */ private AlbResponse mapException(RouteHandler handler, Request request) { AlbResponse response; try { response = handler.apply(request); } catch (RequestValidationException ex) { response = AlbResponse.builder().withStatus(Status.BAD_REQUEST).build(); } catch (NotFoundException ex) { response = AlbResponse.builder().withStatus(Status.NOT_FOUND).build(); } catch (BadDownstreamDependencyException ex) { response = AlbResponse.builder().withStatus(Status.BAD_GATEWAY).build(); } catch (Exception e) { response = AlbResponse.builder().withStatus(Status.INTERNAL_SERVER_ERROR).build(); } return response; } private AlbResponse callDownstream(Request request) { throw new BadDownstreamDependencyException(); } private AlbResponse processRequestObject(Request request) { throw new RequestValidationException(); } private AlbResponse accessMissingResource(Request request) { throw new NotFoundException(); } private AlbResponse accessSubResource(Request request) { return AlbResponse.builder().withStatus(Status.OK).withBody("Hello!").build(); } private static class BadDownstreamDependencyException extends RuntimeException {} private static class RequestValidationException extends RuntimeException {} private static class NotFoundException extends RuntimeException {} } ```

Dependency Injection

Dependency injection is outside of the scope of this RFC - but this approach is compatible with compile-time DI solutions like Dagger2. Our experience has shown that you retain a high degree of control over what code is executed and the number classes being managed by the class loader, allowing you to manage your cold start phase very well.

Drawbacks

Why should we not do this?

Do we need additional dependencies? Impact performance/package size?

The style of API used here will not be familiar to a lot of Java developers. This may add some cognitive burden to those that would like to adopt this module in their solution.

Rationale and alternatives

Unresolved questions

Optional, stash area for topics that need further development e.g. TBD

scottgerring commented 6 months ago

Morning @dnnrly ! Thanks heaps for this - it is clear you have spent time thinking about this. There is prior art here too - powertools for python supports something similar .

Full disclosure - we are focusing at the moment on getting V2 #1522 into a good state, targeting H1 this year. This is certainly something we'd be interested in in the future, though, and would have a natural home in V2, as we're reluctant to add net new features to V1.

dnnrly commented 6 months ago

Sure, that makes sense. I'm in no rush to include this - happy to contribute whenever it makes the most sense. It probably offers this RFC a chance to breathe a little bit and gives us time to get the right kind of feedback.

scottgerring commented 6 months ago

absolutely - it would be great to get other views on this pop up in the coming weeks !

jeromevdl commented 6 months ago

Thank you @dnnrly for this very well detailed RFC. As Scott mentioned, there is an equivalent in python, and this is a feature we were thinking about when we looked at the feature parity with Python. To be transparent, it was definitely not our number one priority as it's a complex one and also very competitive (vs Spring & other fwks). But I like the idea to have something lighter and the first thoughts you put in this RFC.

About this, can we have pre-filters and post-filters (for example to add CORS headers or other data transformation) ?

Also, it would be great to see what has been done in Python, not to replicate but to get inspiration and maybe complete the feature scope.

dnnrly commented 6 months ago

The filters in this implementation aren't pre- or post-, you can write the code around the call to next(...) however it makes sense. The internal implementation we have includes a library of filters that we can use in our routers. Some do validation, others logging, and even some mutate the request before it gets to the handler. A lot of the behaviour implemented in the Python version could easily be implemented as filters that you just add to your chain.

As for the comparison with other frameworks, our experience with Spring and JaxRS performance was very poor when we tried it. The cold-start times were quite high - beyond our budget for the APIs that we have implemented. It's not really meant to compete with those technologies, just give you options if you want to host your API using lambdas.

I totally understand that it's not a priority. I raised this RFC as we have had a stable implementation for a couple of years now and it has become an important part of how we deliver our APIs and it seemed a shame not to share our experience with the wider development community. 😄

jeromevdl commented 6 months ago

Thank you very much for your contribution here. Let's sleep on this a bit. v2 is around the corner, then it makes sense to have another look at it.

scottgerring commented 6 months ago

Thinking out loud @dnnrly - what would be the downside of providing a powertools implementation of JAX-RS? I don't think it should be inherently slow in and of itself (see e.g. Quarkus). On the other hand from my own perspective I quite like this style of API modelling you've done here - it feels more "modern" to me, and lines up with the way we're using builder-style interfaces in V2 e.g.

batch V2 API https://github.com/aws-powertools/powertools-lambda-java/blob/adbb7bfc0a2b32ba5e83b377d91897c89281c2ef/docs/utilities/batch.md?plain=1#L533-L545
jeromevdl commented 6 months ago

@scottgerring remember our conversation on jax-rs: https://github.com/aws-powertools/powertools-lambda-java/issues/1103

scottgerring commented 6 months ago

@scottgerring remember our conversation on jax-rs: #1103

In retrospect I think my suggestion to use quarkus or spring-boot is a bit heavy-handed. I see the value in providing something lightweight like both yourself and @dnnrly are suggesting! I am not sure about the performance impact of it; it feels like quarkus with resteasy-reactive mitigates this somehow (compile time weaving ? 🤷 ).

Ultimately I am hoping to prompt new discussion on options !