-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
fix: optimize serving static files #1848
Conversation
6feb7d9
to
99d95f6
Compare
@@ -290,7 +290,7 @@ export class Route extends BaseRoute { | |||
verb: string, | |||
path: string, | |||
public readonly spec: OperationObject, | |||
protected readonly _handler: Function, | |||
protected readonly _handler: Function, // <-- doesn't this Function have a signature? |
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 handler function accepts a list of parameters as described in its OpenAPI specification.
See invokeHandler
below and our docs: https://loopback.io/doc/en/lb4/Routes.html#using-partial-openapi-spec-fragments
packages/rest/src/rest.server.ts
Outdated
// LB4's static assets serving router | ||
const staticAssetsRouter = new Route('get', '*', {responses: {}}, () => { | ||
console.log('>> staticAssetsRouter HANDLER'); | ||
// @bajtos How do we access req and res 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.
You cannot access the request/response from a handler route (a Route
instance). In my comment #1784 (comment), I was showing how to create a new kind of RouteEntry
(a new class implementing RouteEntry
interface). RouteEntry's method invokeHandler
does have access to the request/response objects.
// somewhere in the code setting up RoutingTable
// the anonymous RouteEntry object should be refactored into a class
this.routingTable.catchAllRoute = <RouteEntry>{
verb: 'get',
path: '*',
spec: { /* TODO: this route should be excluded from the spec */ },
updateBindings(requestContext: Context) {
// no-op
},
invokeHandler(
{request, response}: Context,
args: OperationArgs,
): Promise<OperationRetval> {
return this._handleStaticAssetRequest(request, response);
}
describe(): string {
return "final route to handle static assets";
}
};
2c9ec52
to
acb0089
Compare
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 like the fact that in the current design, the implementation of StaticAsset routing is scattered across multiple files and layers of abstraction (server, router, routing table, StaticRoute). Let's try to find a way how to keep most of the implementation in one or two places please - see my comments below.
args: OperationArgs, | ||
): Promise<OperationRetval> { | ||
const req = await requestContext.get(RestBindings.Http.REQUEST); | ||
const res = await requestContext.get(RestBindings.Http.RESPONSE); |
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.
Let's use context.request
and context.response
, it should be faster than obtaining the values via Context bindings.
): Promise<OperationRetval> { | ||
const req = await requestContext.get(RestBindings.Http.REQUEST); | ||
const res = await requestContext.get(RestBindings.Http.RESPONSE); | ||
return this._handler(req, res); |
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 am proposing to simplify the design here. Since there will be always at most one StaticRoute instance registered with a server, there is no need to make the handler configurable. Let's move the code dealing with _routerForStaticAssets
here into invokeHandler
.
Ideally, I'd like to see _routerForStaticAssets
to be a member property of StaticRoute
.
packages/rest/src/rest.server.ts
Outdated
return reject(err); | ||
} | ||
// Express router called next, which means no route was matched | ||
return reject(new HttpErrors.NotFound()); |
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.
Let's be more verbose here. See how we deal with 404 errors now:
packages/rest/src/rest.server.ts
Outdated
}, | ||
); | ||
|
||
this.httpHandler.registerRoute(staticAssetsRouter); |
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.
IIUC, this is relying on the fact that the code paths invoked here is bypassing path validations and that our routing table will pass the path *
to Express router without modifications.
I find that rather brittle, let's find a different solution please.
I am thinking about the following implementation of RoutingTable#find
:
class RoutingTable {
// ...
find(request: Request): ResolvedRoute {
debug('Finding route %s for %s %s', request.method, request.path);
const found = this._router.find(request);
if (found) {
debug('Route matched: %j', found);
return found;
}
debug('No API route found for %s %s, trying to find a static assets', request.method, request.path);
return this._staticAssetsRoute;
}
}
@@ -238,6 +240,31 @@ export interface ResolvedRoute extends RouteEntry { | |||
readonly schemas: SchemasObject; | |||
} | |||
|
|||
export class StaticRoute implements RouteEntry { |
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.
Let's call this StaticAssetsRoute
. The name "static route" can be also interpreted as a route that's static (e.g. does not accept any path parameters).
Thanks @bajtos, I will be updating the implementation based on your feedback. |
@@ -150,6 +157,9 @@ export class RoutingTable { | |||
|
|||
validateApiPath(route.path); | |||
this._router.add(route); | |||
if (route.path === '*') { |
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.
This is a bit hacky to test path === '*'
. The special route is also added to the underlying router, which might cause unexpected results.
@hacksparrow please let me know when this pull request is ready for another review. Also the title says |
@bajtos I will let you now. There is API spec generation side-effect, please consider it WIP till I remove it. |
@hacksparrow are you referring to the following part of #1784 (comment)? It would be best to open a new pull request for that, to make the review easier and get the new flag lander faster.
const spec: OperationObject = {
'x-internal': true,
description: 'return all customers',
parameters: [
// ...
],
responses: {
// ...
},
};
|
@bajtos yes that's what I am referring too. Let me add that feature first then. Breakpoints jumping locations and disappearing is making debugging really hard. |
Optimize serving static files
review 1
@@ -150,6 +160,9 @@ export class RoutingTable { | |||
|
|||
validateApiPath(route.path); | |||
this._router.add(route); | |||
if (route.path === '/*') { |
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.
@raymondfeng you had some reservations about this.
This is a bit hacky to test path === '*'. The special route is also added to the underlying router, which might cause unexpected results.
Can you elaborate the concerns, so that we can start a discussion?
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.
On line 162, the special route
is added to the router
implementation, which probably doesn't know /*
.
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 am still not able to see what the potential problem is. We run validateApiPath(route.path)
and /*
is a valid key to the static assets router. What am I missing?
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.
It's not helpful to have /*
in the router which won't match any requests.
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.
In your implementation, the static asset route is a special one outside of the Router
responsibility. It serves as the last matching candidate.
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.
So do we use a non-path key, like path = 'catch-all-router'
? We'll have to skip path validation in that case. What are your suggestions?
@bajtos what are your thoughts on this?
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'm fine to have /*
as the path value, but don't call this._router.add(route);
.
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.
Got it now.
packages/rest/src/rest.server.ts
Outdated
@@ -58,6 +58,10 @@ export interface HttpServerLike { | |||
requestHandler: HttpRequestListener; | |||
} | |||
|
|||
interface ExpressRouter extends express.Router { |
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.
ExpressRouter
is used in rest.server.ts
and routing-table.ts
, and defined in both the files. What is a good location to export it from to avoid duplication?
Having to extend express.Router
because its definition file does not include the handle
method for it. I wonder why.
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.
Please note that express.Router
itself is the handle
function.
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.
src/types.ts
is a good place to define common interfaces
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.
As @raymondfeng said, express.Router
is a callable function. It extends IRouter
which extends RequestHandler
which is defined as follows:
export interface RequestHandler {
// tslint:disable-next-line callable-types (This is extended from and can't extend from a type alias in ts<2.2
(req: Request, res: Response, next: NextFunction): any;
}
packages/rest/src/rest.server.ts
Outdated
@@ -242,8 +246,13 @@ export class RestServer extends Context implements Server, HttpServerLike { | |||
*/ | |||
protected _setupRouterForStaticAssets() { | |||
if (!this._routerForStaticAssets) { | |||
this._routerForStaticAssets = express.Router(); | |||
this._expressApp.use(this._routerForStaticAssets); | |||
this._routerForStaticAssets = express.Router() as ExpressRouter; |
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 a valid cast as express.Router()
returns a function which is a handler.
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 implementation looks mostly reasonable, let's improve the way how StaticAssetsRoute is integrated with server/handler/router parts.
packages/rest/src/rest.server.ts
Outdated
@@ -58,6 +58,10 @@ export interface HttpServerLike { | |||
requestHandler: HttpRequestListener; | |||
} | |||
|
|||
interface ExpressRouter extends express.Router { |
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.
As @raymondfeng said, express.Router
is a callable function. It extends IRouter
which extends RequestHandler
which is defined as follows:
export interface RequestHandler {
// tslint:disable-next-line callable-types (This is extended from and can't extend from a type alias in ts<2.2
(req: Request, res: Response, next: NextFunction): any;
}
packages/rest/src/rest.server.ts
Outdated
@@ -127,7 +131,7 @@ export class RestServer extends Context implements Server, HttpServerLike { | |||
protected _httpServer: HttpServer | undefined; | |||
|
|||
protected _expressApp: express.Application; | |||
protected _routerForStaticAssets: express.Router; | |||
protected _routerForStaticAssets: ExpressRouter; |
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.
Let's move the implementation details of dealing with static assets down to StaticAssetsRoute
please.
Ideally, I'd like to see RestServer#static
implemented as a thin wrapper delegating the registration of static-asset middleware to StaticAssetsRoute and/or our HttpHandler
class.
See also my earlier #1848 (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.
@bajtos Calling app.static
after the server has started should still set up the static assets route successfully. I tried to understand what you are suggesting and moved some pieces around, but resulted in app.static
losing functionality after the app started.
Can you elaborate more on your suggestion, please? What does the outline of RestServer#static
look like?
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.
Let's wait until you have addressed the other comments and then I can try to shuffle the code around myself, I think it will be the fastest option.
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 had addressed all others, except this one. Please refresh to see the changes.
const onFinished = () => resolve(); | ||
response.once('finish', onFinished); | ||
this._routerForStaticAssets.handle(request, response, (err: Error) => { | ||
if (err) { |
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.
When the router called us back, then please remove onFinished
listener to avoid resolving/rejecting the promise twice and also to avoid a potential (memory) leak.
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.
It's a once
, do we still need to remove 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.
Yes, because when the static asset cannot be found or a ReadStream cannot be created, then the finish
event is not fired within the timeframe of this async method.
I guess one could argue that the finish
method will be eventually fired because the HTTP response will be finished by other parts of the framework eventually, but I find it very brittle and hacky to rely on some other part of our codebase to trigger "finish". See also Action at a distance anti-pattern.
updateBindings(requestContext: Context): void { | ||
// no-op | ||
} | ||
async invokeHandler( |
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.
No need to use async
if you are not calling await
.
args: OperationArgs, | ||
): Promise<OperationRetval> { | ||
const {request, response} = requestContext; | ||
return this._handler(request, response); |
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.
Have you considered inlining this._handler
inside invokeHandler
?
class StaticAssetsRoute /*...*/ {
//...
invokeHandler(
{request, response}: RequestContext,
args: OperationArgs,
): Promise<OperationRetval> {
return new Promise((resolve, reject) => {
const onFinished = () => resolve();
response.once('finish', onFinished);
// ...
});
}
//...
}
@@ -164,7 +147,7 @@ describe('RestServer (integration)', () => { | |||
.expect(200, 'Hello'); | |||
}); | |||
|
|||
it('serve static assets if matches before other routes', async () => { | |||
it('does not serve static assets even if matches before other routes', async () => { |
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 am proposing to rephrase this test name as follows:
it('gives precedence to API routes over static assets')
review 2
@hacksparrow I pushed a new commit f516b31 to further clean up the implementation, PTAL. |
One more improvement: 79b268c |
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.
LGTM. Please make sure to clean up the git history before landing.
It would be great if you could get @raymondfeng's approval too.
@hacksparrow hmm, the code coverage data suggests that we are missing the following test:
(This triggers a different code path than for a pure API server with no static assets.) Could you please add it as part of this pull request? |
Will do. |
Tests for RestApplication
@hacksparrow I merged the PR so that we can do a release soon. |
Optimize serving static files
See #1784 and #1785
Checklist
npm test
passes on your machinepackages/cli
were updatedexamples/*
were updated