-
Notifications
You must be signed in to change notification settings - Fork 197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[RFC - Server] Refining the service builder API #1859
Conversation
A new generated diff is ready to view.
A new doc preview is ready to view. |
- com.aws.example#CheckHealth | ||
|
||
Use the dedicated methods on `PokemonServiceBuilder` to register the missing handlers: | ||
- PokemonServiceBuilder::check_health |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Beginners to rust may not know that this is telling them to use builder.check_health()
, maybe we should clarify that.
```rust | ||
pub struct PokemonServiceBuilder< | ||
Op1, | ||
Op2, | ||
Op3, | ||
Op4, | ||
Op5, | ||
Op6, | ||
Exts1 = (), | ||
Exts2 = (), | ||
Exts3 = (), | ||
Exts4 = (), | ||
Exts5 = (), | ||
Exts6 = (), | ||
Pl = aws_smithy_http_server::plugin::IdentityPlugin, | ||
> { | ||
check_health: Op1, | ||
do_nothing: Op2, | ||
get_pokemon_species: Op3, | ||
get_server_statistics: Op4, | ||
capture_pokemon: Op5, | ||
get_storage: Op6, | ||
#[allow(unused_parens)] | ||
_exts: std::marker::PhantomData<(Exts1, Exts2, Exts3, Exts4, Exts5, Exts6)>, | ||
plugin: Pl, | ||
} | ||
``` | ||
|
||
to: | ||
|
||
```rust | ||
pub struct PokemonServiceBuilder< | ||
Op1, | ||
Op2, | ||
Op3, | ||
Op4, | ||
Op5, | ||
Op6, | ||
Exts1 = (), | ||
Exts2 = (), | ||
Exts3 = (), | ||
Exts4 = (), | ||
Exts5 = (), | ||
Exts6 = (), | ||
Pl = aws_smithy_http_server::plugin::IdentityPlugin, | ||
> { | ||
check_health: Option<Op1>, | ||
do_nothing: Option<Op2>, | ||
get_pokemon_species: Option<Op3>, | ||
get_server_statistics: Option<Op4>, | ||
capture_pokemon: Option<Op5>, | ||
get_storage: Option<Op6>, | ||
#[allow(unused_parens)] | ||
_exts: std::marker::PhantomData<(Exts1, Exts2, Exts3, Exts4, Exts5, Exts6)>, | ||
plugin: Pl, | ||
} | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a diff view would make the distinction clearer here (tho you lose syntax highlighting AFAIK).
pub struct PokemonServiceBuilder<
Op1,
Op2,
Op3,
Op4,
Op5,
Op6,
Exts1 = (),
Exts2 = (),
Exts3 = (),
Exts4 = (),
Exts5 = (),
Exts6 = (),
Pl = aws_smithy_http_server::plugin::IdentityPlugin,
> {
- check_health: Op1,
+ check_health: Option<Op1>,
...
#[allow(unused_parens)]
_exts: std::marker::PhantomData<(Exts1, Exts2, Exts3, Exts4, Exts5, Exts6)>,
plugin: Pl,
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good so far.
I think we should include a section on what happens to FailOnMissingOperation
etc. There are various use cases where we want to opt out of the normal Operation<S, L>
upgrade procedure.
|
||
Trait objects are another common approach to achieve type erasure: instead of storing the raw handler in `PokemonServiceBuilder`, we box it and cast it to a `dyn Upgradable<...>`. | ||
Using trait objects would allow us to delay upgrading to `Route` until `build` is called, mimicking the behaviour of the current API and allowing developers to register plugins at any point before calling `build`. | ||
Using trait objects would unfortunately have a non-zero runtime impact: we would be introducing an extra layer of indirection (double-boxing the handler), affecting the performance of our application at runtime. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this is the case here - we could Box<Box<Box<dyn Upgradable<...>>>>
but the actual upgrade procedure would still result in a singly nested handler (Route::new
). So the boxed trait objects here would affect startup but not application runtime.
If we were to type erase via Route::new
we would get double-boxed handlers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this is the case here - we could Box<Box<Box<dyn Upgradable<...>>>> but the actual upgrade procedure would still result in a singly nested handler (Route::new)
Is this the case though?
Route::new
desugars to a BoxCloneService::new
call, which introduces one layer of vtable indirection.
The argument to BoxCloneService::new
is the parameter passed to Route::new
- if that were to be behind multiple layers of boxing, I am under the impression that we would have to resolve those indirections at runtime when invoking the service.
It's a different situation when we are talking about boxed Plugin
s - the call to .map
would suffer from the indirection, but that only happens at startup time.
Have I got this right or am I missing something (more than likely, tbh)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently, Upgradable
looks like this:
/// Provides an interface to convert a representation of an operation to a HTTP [`Service`](tower::Service) with
/// canonical associated types.
pub trait Upgradable<Protocol, Operation, Exts, B, Plugin> {
type Service: Service<http::Request<B>, Response = http::Response<BoxBody>>;
/// Performs an upgrade from a representation of an operation to a HTTP [`Service`](tower::Service).
fn upgrade(self, plugin: &Plugin) -> Self::Service;
}
If we wanted to make the type uniform we'd have to remove the associated type which would vary across each operation (else we'd have to specify it in the dyn Upgradable<Service = ....>
). So we'd have to move the Route::new
into the Ugpradable
, like this:
pub trait Upgradable<Protocol, Operation, Exts, B, Plugin> {
fn upgrade(self, plugin: &Plugin) -> Route<B>;
}
I think is acceptable tweak - all the gnarly Send + Clone + ...
bounds would be pushed down into the Upgradable for Operation
impl too, rather than hanging around on the service builder which might be worth while anyway (I recall you mentioning this before).
The .boxed()
method on the service builder would then take the OpX
and turn it into a Box<dyn Upgradable<...>>
. The Box
here would box the dyn Upgradable
and if we call .boxed()
multiple times it'd become Box<Box<Box<dyn Upgradable>>>
but this is Box
ing the actual dyn Upgrade
, rather than creating a Box<dyn Service>
? The Route::new
is still only called once and hence the indirection at runtime only happens once as far as I can tell? The way I'm imagining it is that Box<Box<Box<dyn FnOnce() -> Box<T>>>>
when called returns a Box<T>
whose access requires 1 indirection rather than 3.
```rust | ||
// Currently you'd have to go for `PluginStack::new(IdentityPlugin, IdentityPlugin)`, | ||
// but that can be smoothed out even if this RFC isn't approved. | ||
let plugin_stack = PluginStack::default() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's an alternative approach we could also take here which gives us some different dynamics.
Currently, because the "upgrade" is performed in the build
method, all applied Plugin
s are used in build
. This means that
.get_pokemon_species(/* handler */)
.get_storage(/* handler */)
.print()
.build()
is equivalent to
.get_pokemon_species(/* handler */)
.print()
.get_storage(/* handler */)
.build()
If we are eagerly Route::new
ing in the setters we could have the current Plugin
be used. Which means that the two snippets above would not be equivalent - the latter would not apply the print
plugin to the get_storage
handler.
This would allow the customer to "scope" plugins in a coarse way - I've been asked if this is the current behavior. I'm not convinced this is a good approach - it might cause confusion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I should have called this out in the RFC, but I am indeed not convinced this is the right way forward for the same reason you mentioned - it can be confusing and lead to surprising runtime behaviour.
Especially considering how many existing web frameworks in Rust ignore at what point a middleware is registered and apply it to all routes at the same scope/level.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the intention is that the plugin would only affect the operations registered so far, I think it's ok.
We can also consider providing a way to apply plugins (i.e. layers to the operation before it's HTTP-upgraded) more fine-grained, to specific operations via a method on Operation
, instead of the current way in #1837. But this is a one-way door, we can add this functionality later if there's user interest.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can also consider providing a way to apply plugins (i.e. layers to the operation before it's HTTP-upgraded) more fine-grained, to specific operations via a method on Operation, instead of the current way in #1837.
I think just adding a blanket impl
impl<P, Op, Pl, S, L> Pluggable<Pl> for Operation<S, L> where Pl: Plugin<P, Op, S, L> {
type Output = Operation<Pl::Service, Pl::Layer>;
fn apply(self, new_plugin: Pl) -> Self::Output {
new_plugin.map(self)
}
}
to the aws-smithy-http-server
would allow you to
op.print()
where op: Operation
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have captured both as an alternative in 90ae7aa
I've added as well a sketch of an API for attaching plugins to groups of routes using a nested/scoped builder.
It supports fine-grained configuration at multiple levels (per-handler middlewares, router middlewares, plugins) while trying to prevent some misconfiguration issues at compile-time (i.e. missing operation handlers). | ||
There is consensus that the new API is an improvement over the pre-existing `OperationRegistryBuilder`/`OperationRegistry`, which is now on its way to deprecation in one of the next releases. | ||
|
||
This RFC builds on top of [RFC 20] to explore an alternative API design prior to its stabilisation. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it'd be nice to link to the exact section which we aim to revert here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't linked a specific section because this is not technically a "revert" - we had those generic parameters in the OperationRegistryBuilder
version as well, so reverting the compile-safety piece does not lead us to a less generic API per se.
|
||
### Alternatives: boxed trait objects | ||
|
||
Trait objects are another common approach to achieve type erasure: instead of storing the raw handler in `PokemonServiceBuilder`, we box it and cast it to a `dyn Upgradable<...>`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps a little example of using the type erasure method e.g. boxed
with a conditional branch?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could note a downside here that we cannot type erase MissingOperation
etc this way, so the branches would still have the MissingOperation
stuck in their signature.
```rust | ||
impl PokemonService<()> { | ||
/// Constructs a builder for [`PokemonService`]. | ||
pub fn builder<Body, Plugin>(plugin: Plugin) -> PokemonServiceBuilder<Body, Plugin> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could also do a two-phase builder which keeps the user in the method chain, something like:
let app = PokemonService::builder() // : PokemonServicePluginBuilder<IdentityPlugin>
.print()
/* ... */
.finalize_plugins() // PokemonServiceBuilder<B, PluginStack<IdentityPlugin, ..., PrintPlugin>>
.get_pokemon_species(/* handler */)
...
.build();
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this could work as well, with a PokemonService::builder_without_plugins()
for the case where the developer doesn't want to register any plugin and PokemonService::builder().finalize_plugins()
would be ugly.
```rust | ||
impl PokemonService<()> { | ||
/// Constructs a builder for [`PokemonService`]. | ||
pub fn builder<Body, Plugin>(plugin: Plugin) -> PokemonServiceBuilder<Body, Plugin> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's worth noting that now Plugin
might be a complicated type given that it must be fully specified up front. Do we need to type erase it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do expect that it should be possible to simply have an unconstrained Plugin
generic parameter in most functions where you pass the builder around, but we should have a .boxed()
method to type-erase the plugin if necessary.
It feels like it won't be very amenable to an impl Trait
approach in function signatures.
```rust | ||
// Currently you'd have to go for `PluginStack::new(IdentityPlugin, IdentityPlugin)`, | ||
// but that can be smoothed out even if this RFC isn't approved. | ||
let plugin_stack = PluginStack::default() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it'd also be a good idea to make a PluginBuilder
here - PluginStack
is kinda confusing term to be a primary API.
This would mirror how tower
has a ServiceBuilder
and Stack
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Captured this in a9aab96 👍🏻
``` | ||
|
||
This pattern needs to be revisited if we want to move forward with this RFC, since new plugins cannot be registered after the builder has been instantiated. | ||
My recommendation would be to implement `Pluggable` for `PluginStack`, providing the same pattern ahead of the creation of the builder: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a great bit of luck that this migrates so cleanly. There's no harm in doing this kind of impl even without this RFC being accepted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think the missing handler error message is persuasive; there are sharper edges elsewhere. The other two identified shortcomings (branching and partial_setup
) are very compelling and I agree we should do something to remediate them.
Let's try to register an operation handler now: | ||
|
||
```rust | ||
fn partial_setup<Op1, Op2, Op3, Op4, Op5, Op6>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Far from ideal, but for simple use cases, you could get away with a macro that performs this initialization.
error[E0277]: the trait bound `MissingOperation: Upgradable<AwsRestJson1, CheckHealth, (), _, IdentityPlugin>` is not satisfied | ||
--> pokemon-service/src/bin/pokemon-service.rs:38:10 | ||
| | ||
38 | .build(); | ||
| ^^^^^ the trait `Upgradable<AwsRestJson1, CheckHealth, (), _, IdentityPlugin>` is not implemented for `MissingOperation` | ||
| | ||
= help: the following other types implement trait `Upgradable<Protocol, Operation, Exts, B, Plugin>`: | ||
FailOnMissingOperation | ||
Operation<S, L> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually think this is one of the nicer error messages you can get. It has enough of the right words MissingOperation
, CheckHealth
for it to likely to not cause much confusion.
The error messages you get when passing in a handler that does not satisfy the type signature (not async
, wrong input / output types) are worse, although those (and perhaps this one too?) can be palliated with a debug macro à la axum
's debug_handler
, which I think we direly need anyway.
I expect this error to not be frequent. Users will likely start off from a template; the new service builder needs to generate Rust docs with an out-of-the-box compilable program, like in #1498 (comment).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error messages you get when passing in a handler that does not satisfy the type signature (not async, wrong input / output types) are worse, although those (and perhaps this one too?) can be palliated with a debug macro à la axum's debug_handler, which I think we direly need anyway.
I don't disagree that those errors are worse, but I don't think that the builder case can be fixed using a debug macro. Or, at least, not in the general case nor easily.
The macro would need to wrap the entire scope where the builder is being used, therefore ruling out refactorings that break the startup logic across multiple functions. We would also have to understand branches in the code we are wrapping to make sure that all paths lead to a correctly instantiated builder - it gets nasty quite quickly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah it's not feasible.
We could improve the error messages if we named the type params OpUnset
, OpSet
etc https://github.com/estebank/makeit#error-messages
|
||
Trait objects are another common approach to achieve type erasure: instead of storing the raw handler in `PokemonServiceBuilder`, we box it and cast it to a `dyn Upgradable<...>`. | ||
Using trait objects would allow us to delay upgrading to `Route` until `build` is called, mimicking the behaviour of the current API and allowing developers to register plugins at any point before calling `build`. | ||
Using trait objects would unfortunately have a non-zero runtime impact: we would be introducing an extra layer of indirection (double-boxing the handler), affecting the performance of our application at runtime. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is worth exploring. Following a vtable has negligible performance impact for our use cases. If you can flatten the type that the router ends up holding to just a few boxes, it's likely ok.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've significantly expanded this section exploring two different approaches for lazy type erasure.
I don't believe it's actually practical moving forward with either of them - they do improve slightly on the current status quo but they carry a significant complexity budget in terms of implementation and ongoing maintenance.
We spent some time we @hlbarber to play around with ideas and they get quite complex quite quickly.
A new generated diff is ready to view.
A new doc preview is ready to view. |
A new generated diff is ready to view.
A new doc preview is ready to view. |
A new generated diff is ready to view.
A new doc preview is ready to view. |
A new generated diff is ready to view.
A new doc preview is ready to view. |
A new generated diff is ready to view.
A new doc preview is ready to view. |
A new generated diff is ready to view.
A new doc preview is ready to view. |
A new generated diff is ready to view.
A new doc preview is ready to view. |
Having to specify all plugins upfront is unlikely to have a negative impact on developers currently using `smithy-rs`. | ||
We have seen how cumbersome it is to break the startup logic into different functions using the current service builder API. Developers are most likely specifying all plugins and routes in the same function even if the current API allows them to intersperse route registrations and plugin registrations: they would simply have to re-order their registration statements to adopt the API proposed in this RFC. | ||
|
||
### Alternatives: allow new plugins to be registered after builder creation |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should add an additional alternative here:
- Don't solve the function signature problem at all, the customer just has to
PokemonService::builder()....build()
within one function. - Solve the conditional branch problem with
Either<A, B>
types (e.g.A: Upgradable<...>
andB: Upgradable<...>
impliesEither<A, B>: Upgradable
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't solve the function signature problem at all, the customer just has to
PokemonService::builder()....build()
within one function.
I think the RFC should mention using macros instead of functions to workaround this limitation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Macros that we provide or macros written on an ad-hoc basis by the customer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Written by the user. The problem here is the user having to type the complicated function signature that does partial initialization. If they just plop the function body into a macro, they don't have to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd argue that writing a macro is a skill outside the comfort zone of most Rust users, who primarily consume them. Sketching a very simple macro_rules!
is somewhat more accessible, but not much.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The problems described here are very real. The solution space is very subtle and the RFC is a great exploration of it. Here's a summary of my thoughts:
Clear wins in this exploration are:
PluginBuilder
API.Upgradable
associated type removal.
We should implement these regardless.
The main proposal provides the customer with a super ergonomic solution to both the conditional branching and error message problem. This is given to the customer by default - no intervention is required on their part.
The function signature problem seems more difficult to solve generally. The main proposal prevents the customer from having to work with the O(n)
type parameters but the Plugin
itself is now required to be known up front. The customer now only has to deal with 1 nasty type parameter when passing a builder through a function signature, but is it still too much of an obstruction? With that said, alternative approach 1 and 2 are strictly less ergonomic and the Either
proposal doesn't even attempt to solve it.
Fundamentally, if we want Plugin<P, Op, S, L>
to be applied to operations we cannot do it after type erasure of P
, Op
, S
, L
. Any solution which preserves the current Plugin
architecture and type erases any of these parameters will prevent Plugin
s being applied afterward. You can see this with the main proposal and the alternative approach 2.
The only feasible solution which solves the conditional branching problem, provides an infallible builder, and prevents eager Plugin
is to provide Either<A, B>: Upgradable
in my opinion. This does not solve the function signature problem or the error problem and we're still stuck with O(n)
type parameters.
In conclusion:
- Both lazy type erasure via
Box<dyn Upgradable>
andRoute::new
can be used to hold onto the compile time safety ofbuild
but are far worse ergonomics for the customer and still requirePlugin
s to be known up front - this probably not a good trade-off. - If we submit to the inevitability of the function signature problem then I think the
Either
proposal is the primary contestant to the main proposal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the plan for solving the same set of problems when building a plugin stack (conditionally applying a plugin and partial initialization of a plugin stack in a function)?
This constraint guarantees that all operation handlers are upgraded to a `Route` using the same set of plugins. | ||
|
||
Having to specify all plugins upfront is unlikely to have a negative impact on developers currently using `smithy-rs`. | ||
We have seen how cumbersome it is to break the startup logic into different functions using the current service builder API. Developers are most likely specifying all plugins and routes in the same function even if the current API allows them to intersperse route registrations and plugin registrations: they would simply have to re-order their registration statements to adopt the API proposed in this RFC. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Developers are most likely specifying all plugins and routes in the same function
This seems to go counter to the Refactoring into smaller functions -> Prepare for some type juggling! section that motivates the proposal for reducing type parameters.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I should have worded this better.
What I meant: since it's so complicated to refactor into smaller functions (given the problems shown in the sections you linked) we can be fairly confident that all the code in the wild is currently sticking to having everything in a single function to avoid the pain (even if they don't necessarily want to).
Based on our standup discussion from a few days ago:
I'll merge this in and get cracking. |
A new generated diff is ready to view.
A new doc preview is ready to view. |
Motivation and Context
This PR adds an RFC that explores how we could evolve the new service builder API to improve developer experience, in particular for Rust beginners.
Rendered RFC
The API proposed in this RFC has been manually implemented for the Pokemon service. You can find the code here.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.