Skip to content
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

Merged
merged 6 commits into from
Oct 26, 2018
Merged

fix: optimize serving static files #1848

merged 6 commits into from
Oct 26, 2018

Conversation

hacksparrow
Copy link
Contributor

@hacksparrow hacksparrow commented Oct 15, 2018

Optimize serving static files

See #1784 and #1785

Checklist

  • npm test passes on your machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • API Documentation in code was updated
  • Documentation in /docs/site was updated
  • Affected artifact templates in packages/cli were updated
  • Affected example projects in examples/* were updated

@hacksparrow hacksparrow self-assigned this Oct 15, 2018
@hacksparrow hacksparrow force-pushed the option1 branch 2 times, most recently from 6feb7d9 to 99d95f6 Compare October 15, 2018 07:10
@@ -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?
Copy link
Member

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

// 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?
Copy link
Member

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";
    }
  };

@hacksparrow hacksparrow changed the title [DO NOT MERGE] fix: optimize serving static files [WIP] fix: optimize serving static files Oct 15, 2018
@hacksparrow hacksparrow force-pushed the option1 branch 2 times, most recently from 2c9ec52 to acb0089 Compare October 16, 2018 03:47
Copy link
Member

@bajtos bajtos left a 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);
Copy link
Member

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);
Copy link
Member

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.

return reject(err);
}
// Express router called next, which means no route was matched
return reject(new HttpErrors.NotFound());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

},
);

this.httpHandler.registerRoute(staticAssetsRouter);
Copy link
Member

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 {
Copy link
Member

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).

@hacksparrow
Copy link
Contributor Author

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 === '*') {
Copy link
Contributor

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.

@bajtos
Copy link
Member

bajtos commented Oct 18, 2018

@hacksparrow please let me know when this pull request is ready for another review. Also the title says [WIP], is it still true?

@hacksparrow
Copy link
Contributor Author

@bajtos I will let you now. There is API spec generation side-effect, please consider it WIP till I remove it.

@bajtos
Copy link
Member

bajtos commented Oct 18, 2018

@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.

The last missing piece I see is how to exclude a route from the generated API spec. Personally, I'd introduce an extension property allowing developers to mark any route as not documented. For example:

const spec: OperationObject = {
  'x-internal': true,

  description: 'return all customers',
  parameters: [
    // ...
  ],
  responses: {
    // ...
  },
};

Few alternatives to x-internal: true

  • x-documented: false (in LB 3.x, remoting metadata has documented: false)
  • x-private: true

@hacksparrow
Copy link
Contributor Author

@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.

Hage Yaapa added 2 commits October 24, 2018 11:20
Optimize serving static files
review 1
@@ -150,6 +160,9 @@ export class RoutingTable {

validateApiPath(route.path);
this._router.add(route);
if (route.path === '/*') {
Copy link
Contributor Author

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?

Copy link
Contributor

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 /*.

Copy link
Contributor Author

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?

Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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?

Copy link
Contributor

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);.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it now.

@hacksparrow hacksparrow changed the title [WIP] fix: optimize serving static files fix: optimize serving static files Oct 24, 2018
@@ -58,6 +58,10 @@ export interface HttpServerLike {
requestHandler: HttpRequestListener;
}

interface ExpressRouter extends express.Router {
Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor

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

Copy link
Member

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;
}

@@ -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;
Copy link
Contributor

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.

Copy link
Member

@bajtos bajtos left a 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.

@@ -58,6 +58,10 @@ export interface HttpServerLike {
requestHandler: HttpRequestListener;
}

interface ExpressRouter extends express.Router {
Copy link
Member

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;
}

@@ -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;
Copy link
Member

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)

Copy link
Contributor Author

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?

Copy link
Member

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.

Copy link
Contributor Author

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) {
Copy link
Member

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.

Copy link
Contributor Author

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?

Copy link
Member

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(
Copy link
Member

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);
Copy link
Member

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 () => {
Copy link
Member

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')

Hage Yaapa and others added 2 commits October 25, 2018 16:28
@bajtos
Copy link
Member

bajtos commented Oct 26, 2018

@hacksparrow I pushed a new commit f516b31 to further clean up the implementation, PTAL.

@bajtos
Copy link
Member

bajtos commented Oct 26, 2018

One more improvement: 79b268c

Copy link
Member

@bajtos bajtos left a 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.

@bajtos
Copy link
Member

bajtos commented Oct 26, 2018

@hacksparrow hmm, the code coverage data suggests that we are missing the following test:

  • Create an app with static assets configured.
  • Request an URL that does not exist.
  • Verify that 404 was returned.

(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?

@hacksparrow
Copy link
Contributor Author

Will do.

Tests for RestApplication
@raymondfeng raymondfeng merged commit 9c86e14 into master Oct 26, 2018
@raymondfeng raymondfeng deleted the option1 branch October 26, 2018 18:59
@raymondfeng
Copy link
Contributor

@hacksparrow I merged the PR so that we can do a release soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants