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

docs(lb4): Creating components - custom servers #523

Merged
merged 1 commit into from
Nov 13, 2017

Conversation

kjdelisle
Copy link

@kjdelisle kjdelisle commented Nov 9, 2017

@raymondfeng
Copy link
Contributor

Good stuff. I wonder why you don't use jsonrpc-2.0? The spec is close to what you illustrate.


## Creating your own servers

LoopBack 4 has the concept of a Server, which you can use to create your own
Copy link
Contributor

Choose a reason for hiding this comment

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

which can be used might sound better

Copy link
Author

Choose a reason for hiding this comment

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

@crandmck Do we want to avoid addressing the user? I can switch to passive voice if we want.

Copy link
Author

Choose a reason for hiding this comment

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

Actually, we already have plenty of examples where we're addressing the user:
https://loopback.io/doc/en/lb4/Schemas.html


### A Basic Example
The concept of servers in LoopBack doesn't actually require listening on ports;
you can use this pattern to create workers that process data, write logs, or
Copy link
Contributor

Choose a reason for hiding this comment

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

or do whatever you'd like.

$gt: window,
},
});
if (results && results.length > 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

results.length > 0 isn't needed. forEach won't throw on an empty array.

Copy link
Author

Choose a reason for hiding this comment

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

Why call it when I can check if it's got elements?
Also, it could end up being defined, but not a collection with a length property.
Mostly just defensive coding, really.

Copy link
Author

Choose a reason for hiding this comment

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

Additional point-of-note: The advanced example is real code that I've tested, but this one was largely a thought experiment. It's definitely not robust (there are plenty of holes in the way it looks for reports to process, for example), and it might not even run at all.

Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to make this code snippet something users can copy and paste and run? It'd be cool to get them started, especially since this is our basic example of a server.

Copy link
Author

Choose a reason for hiding this comment

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

If we want this to be copy-pastable, I'd have to expand it to include all of the same boilerplate snippets contained in the advanced example (index.ts, /src/application.ts, etc)

Copy link
Member

Choose a reason for hiding this comment

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

it might not even run at all

Please, let's avoid code that looks like it should run but that does not actually run. If you only want to outline the implementation, then use some sort of a pseudo code.

You can also stick a big fat warning saying the file does not compile, but that would look pretty lame, at least to me. We are writing these code examples to be read by many people, I think the time needed to verify that the code examples actually work is very well worth it!

and then update the database.

### Using your server
To use your server, simply bind it to your application's context.
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to call using methods like .controller, .server, etc. as bindings or does it make sense to say To use your server, register it with your application via .server()


### Trying it out
Let's start our server up and run a few REST requests. Feel free to use
whatever REST client you'd prefer (this example will use `curl`).
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't it be in this example

@kjdelisle
Copy link
Author

Funny, @bajtos said the same thing when I showed him this earlier. This came from an example I made on-the-fly with the team.

Wouldn't making it a jsonrpc-2.0 server complicate the Router portion quite a bit?
You'd need to compile a list of all the methods available on the controller definitions to determine which one is appropriate handler, whereas my contrived example lets you skip that part.

@kjdelisle
Copy link
Author

@jonathan-casarrubias PTAL

I want to know if this answers some of the questions you have/may have had while working on the gRPC server, as well as what you might want to see more of in this document.

@raymondfeng
Copy link
Contributor

Wouldn't making it a jsonrpc-2.0 server complicate the Router portion quite a bit?
You'd need to compile a list of all the methods available on the controller definitions to determine which one is appropriate handler, whereas my contrived example lets you skip that part.

We can use ControllerName.methodName as the method for json-rpc 2.0.

@crandmck
Copy link
Contributor

crandmck commented Nov 9, 2017

@kjdelisle I just made some copy edits directly to the branch, rather than via comments. Hope that's OK.

@bajtos
Copy link
Member

bajtos commented Nov 9, 2017

I want to know if this answers some of the questions you have/may have had while working on the gRPC server, as well as what you might want to see more of in this document.

Adding @akashjarad to the loop, he volunteered to implement MQTT support - see loopbackio/loopback-next#710 and loopbackio/loopback-next#647 (comment)

@kjdelisle
Copy link
Author

Rebased, squashed @crandmck 's changes into the first commit

@kjdelisle
Copy link
Author

We can use ControllerName.methodName as the method for json-rpc 2.0.

What if there are two controllers with the same method name?
Though, I guess you could just document that as a limitation of the server...

Hmm. Maybe I should just write a JsonRpcServer, and document the process? :P

// Currently, there is a limitation with core node libraries
// that prevents the easy promisification of some functions, like
// http.Server's listen method.
this._server = this.expressServer.listen(
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we wrap listen() call with try/catch block?

Copy link
Author

Choose a reason for hiding this comment

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

It's meant to be demo code. I don't want to impact its readability with loads of boilerplate safeguarding and error handling.

It's the same reason it's not riddled with debug statements. :P

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 actually quite concerned about this too. People look up to our docs for examples they will be copy-n-pasting into their code and which may eventually get into production.

I agree we should keep this readable, but not at the cost of showing bad practices!

Here in particular we should use p-event to ensure error event reported by the server is correctly handled.

this._server = this.expressServer.listen(...);
return await pEvent(this._server, 'listen');

Copy link
Member

Choose a reason for hiding this comment

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

It is also important to wait for listen event before returning from start(), otherwise our app can print "Server is running on port XYZ" before the server has actually started to listen 😱

What's worse, if there are tests calling app.start() and making HTTP requests immediately after start returns, then they can sporadically fail when the timing is wrong and listen takes more ticks of event-loop/promise micro-queue than usually and the request is started before the server started listening.

Copy link
Author

Choose a reason for hiding this comment

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

Including pEvent seems a bit odd. I didn't even know it was a thing (I think you mentioned it in your presentation, but I missed the start!). Not everyone will know what pEvent is/does, and I think it detracts from the example, which is why I stuck the comment up there instead.

It is also important to wait for listen event before returning from start(), otherwise our app can print "Server is running on port XYZ" before the server has actually started to listen 😱

I had a version that handled this using old-school promises:

return new Promise((resolve, reject) => {
  // using this ugly form to call resolve or reject on the event.
});

I wasn't a fan because it detracted from the core logic. I think I'll just use p-event.

return Promise.resolve();
}
async stop(): Promise<void> {
this._server.close();
Copy link
Contributor

Choose a reason for hiding this comment

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

ditto

Copy link
Author

Choose a reason for hiding this comment

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

see above for response

Copy link
Member

Choose a reason for hiding this comment

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

There is actually no need to wrap this in a try/catch block here. If close() throws, the error will be converted into promise rejection and the promise returned by the async stop() function will be rejected with that.

However, it's probably a good practice to wait until the server is closed, and maybe return an error when it cannot be closed (although I am not sure what the caller is expected to do in such case, as there is little to do when the server cannot be closed).

this._server.close();
return await pEvent(this._server, 'close');

@b-admike
Copy link
Contributor

b-admike commented Nov 9, 2017

Okay LGTM

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 reviewed about two thirds of the patch up to "Defining our router and server", PTAL at my comment. I'll take a look at the remaining content later, hopefully still today.

async start() {
// Run the report processor every 2 minutes.
let window = Date.now();
interval = setInterval(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 think this created a global interval property, did you mean this.interval?

Copy link
Member

Choose a reason for hiding this comment

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

Also note that errors (promise rejections) returned by this function are ignored and trigger process.on('unhandledRejection'), which is IMO not the right way for handling errors in background processing. You should catch the errors, see e.g. the code snippet in my comment above.

@inject('database') db: DefaultCrudRepository<Report, int>;
interval: NodeJS.Timer;
async start() {
// Run the report processor every 2 minutes.
Copy link
Member

Choose a reason for hiding this comment

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

Uh oh, I find this example rather complex and difficult to understand. Can we come up with a simpler example? If not, then please at least refactor the code and extract small named functions that will make it easier to comprehend what's going on. For example:

export class ReportProcessingServer implements Server {
  // ...
  async start() {
    // Run the report processor every 2 minutes.
    this.interval = setInterval(
      () => this.processReports().catch(err => console.warn('Report processing failed:', err)),
      1000 * 120);
  }

  async processReports() {
    const results = await this.getRecentlyAddedRecords();
    if (!results || !results.length) return;
    await Promise.all(results.map(rep => this.processResult(rep)));
  }

  // ...
}

interval: NodeJS.Timer;
async start() {
// Run the report processor every 2 minutes.
let window = Date.now();
Copy link
Member

Choose a reason for hiding this comment

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

I see two problems here:

  • window can be confused with the global variable provided by browsers
  • In my experience, window usually refers to an interval of time (how many seconds do we want to go into past), not a point (what is the first timestamp we want to check).

I am proposing to use a better name, e.g. timeOfLastRun.

});
if (results && results.length > 0) {
results.forEach(async (rep) => {
await db.updateById(rep.id, await processReport(rep));
Copy link
Member

Choose a reason for hiding this comment

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

This is horrible in the sense that it does not wait for the update operations to finish and completely ignore any errors that may happen. Please consider using Promise.all and .map instead, see one of my earlier comments above.

await db.updateById(rep.id, await processReport(rep));
});
}
window = Date.now();
Copy link
Member

Choose a reason for hiding this comment

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

const results = await db.find({
        createdDate: {
         $gt: window,
       },
     });
// ...
window = Date.now();

This creates a race condition. What if some records have been added after the database finished the query but before we have received all the matching records (and processed them)? Those will be silently ignored.

I understand you want to keep this example simple, but I don't think that's a valid excuse for teaching people to write subtly broken code.

Assuming processReport is idempotent (produces the same result when executed multiple times on the same record), the problem can be fixed by storing the current time before querying the database.

class {
  // ...
  async processReports() {
    const since = this.window; // please, use a better name than "window"
    this.window = Date.now();

    const results = await db.find({
      createdDate: {
        $gt: since,
      },
    });
    // etc. (no changes there)
  }
}

```ts
export class GreetController {
basicHello(input: Person) {
return `Hello, ${input && input.name || 'World'}!`;
Copy link
Member

Choose a reason for hiding this comment

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

It's a bad practice to mix business logic (what's the name to print) with views/templates. Please split this line into two:

const name = input && input.name || 'World';
return `Hello, ${name}!`;

It's not that bad here, but hobbyHello below is much more difficult to read in the current form. Here is what I am proposing for hobbyHello:

const hobby = input && input.hobby || 'underwater basket weaving';
return `${this.basicHello(input)} I heard you like ${hobby}.`;

(I think this will also make the code easier to debug, I am not sure if V8/DevTools Inspector can step through template parts.)

bajtos
bajtos previously requested changes Nov 10, 2017
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.

Few more comments regarding the code snippets.

At high level, I am missing any mention of Sequences. I think it's important to explain why we use the Sequence concept in REST, why extension developers should use the Sequence concept for their own transports too, and also show an example how to implement a new Sequence with new sequence actions for a custom transport.

Thoughts?

this.routing = express.Router();
const jsonParser = parser.json();
this.routing.post('*', jsonParser, async (request, response) => {
console.log(JSON.stringify(request.body));
Copy link
Member

Choose a reason for hiding this comment

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

What if this line throws an error? The top-level async function will return a rejected promise, which will be ignored because express does not report async middleware/route functions.

Please consider cleaning up this code and making it more robust.

class {
  constructor(...) {
    this.routing.post('*', jsonParser, (request, response) => {
      this.handleRequest(request, response)
        .catch(err => this.handleError(request, response, error));
    });
  }

  async handleRequest(request, response) {
    console.log(JSON.stringify(request.body));
    // etc.
  }

  handleError(request, response, err) {
     console.error('Cannot handle %s %s:', request.method, request.path, err);
     if (response.headersSent) return;
     response.statusCode = 500;
     response.end();
  });
}

const controller = await this.server.get(`controllers.${ctrl}`);
if (!controller) {
response.statusCode = 400;
response.send(`No controller was found with name "${ctrl}".`);
Copy link
Member

Choose a reason for hiding this comment

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

I'd personally throw new HttpErrors.BadRequest. However, since the controller was not found, it's probably better to send 404? Also, if this RPC is JSON based, shouldn't we return a JSON formatted error, possibly with 200 status code?

response.statusCode = 400;
response.send(
`No method was found on controller "${ctrl}" with name "${method}".`
);
Copy link
Member

Choose a reason for hiding this comment

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

Ditto.

}
});
// Use our router!
this.server.expressServer.use(this.routing);
Copy link
Member

Choose a reason for hiding this comment

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

Why is this custom router a class when all it does is to create an express router and mount it on the server? I find this design confusing. Also why are you creating a new Router instance, when there is only a single route set up?

Here is a possibly simpler solution:

export function mountRpcRoute(server) {
  server.expressServer.post('*', jsonParser, async (request, response) => {
    handleRequest(server, request, response)
        .catch(err => this.handleError(server, request, response, error));
  });
}

Copy link
Author

Choose a reason for hiding this comment

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

It was mostly to draw attention to the concept by isolating it as a class.

Copy link
Author

Choose a reason for hiding this comment

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

Additionally, it makes it way simpler to mock pieces of the code if it's a class instead of a function.

this._server = this.expressServer.listen(
(this.config && this.config.port) || 3000
);
return Promise.resolve();
Copy link
Member

Choose a reason for hiding this comment

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

This line is not needed at all. When an async function does not return anything, the runtime will convert it into a promise resolving to undefined under the hood.

Please remove the other usages of return Promise.resolve() too.

// Currently, there is a limitation with core node libraries
// that prevents the easy promisification of some functions, like
// http.Server's listen method.
this._server = this.expressServer.listen(
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 actually quite concerned about this too. People look up to our docs for examples they will be copy-n-pasting into their code and which may eventually get into production.

I agree we should keep this readable, but not at the cost of showing bad practices!

Here in particular we should use p-event to ensure error event reported by the server is correctly handled.

this._server = this.expressServer.listen(...);
return await pEvent(this._server, 'listen');

return Promise.resolve();
}
async stop(): Promise<void> {
this._server.close();
Copy link
Member

Choose a reason for hiding this comment

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

There is actually no need to wrap this in a try/catch block here. If close() throws, the error will be converted into promise rejection and the promise returned by the async stop() function will be rejected with that.

However, it's probably a good practice to wait until the server is closed, and maybe return an error when it cannot be closed (although I am not sure what the caller is expected to do in such case, as there is little to do when the server cannot be closed).

this._server.close();
return await pEvent(this._server, 'close');

// Currently, there is a limitation with core node libraries
// that prevents the easy promisification of some functions, like
// http.Server's listen method.
this._server = this.expressServer.listen(
Copy link
Member

Choose a reason for hiding this comment

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

It is also important to wait for listen event before returning from start(), otherwise our app can print "Server is running on port XYZ" before the server has actually started to listen 😱

What's worse, if there are tests calling app.start() and making HTTP requests immediately after start returns, then they can sporadically fail when the timing is wrong and listen takes more ticks of event-loop/promise micro-queue than usually and the request is started before the server started listening.

await app.stop();
process.exit(0);
}
// Event handlers for when we are killed; these aren't *required*.
Copy link
Member

Choose a reason for hiding this comment

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

Well, if we want to keep our examples in this page short and readable, then I think this section should be removed, since it's not really needed? If you want to show how to use stop, then perhaps use an end-to-end test as the example and call start/stop from before/after hooks.

@raymondfeng
Copy link
Contributor

It might be better off if we create a github repo to host the sample code and README. The doc site can just make a reference to the README.

@kjdelisle
Copy link
Author

At high level, I am missing any mention of Sequences. I think it's important to explain why we use the Sequence concept in REST, why extension developers should use the Sequence concept for their own transports too, and also show an example how to implement a new Sequence with new sequence actions for a custom transport.

My thoughts are that Sequence is not demonstrably a best practice, and that it would only serve to complicate a developer's implementation if they originally intended to use middleware chaining or some other pattern.

In our case, a router is a natural consequence of our framework's opinion regarding the use of controllers but the same can't be said for something like sequence.

@kjdelisle
Copy link
Author

@raymondfeng I'm starting to think so as well. I actually have the code handy, so I might just create a new repository for it and edit the PR to point there.

@kjdelisle
Copy link
Author

@bajtos I've moved the code to a different repo, PTAL

@kjdelisle kjdelisle dismissed bajtos’s stale review November 13, 2017 23:15

Comments regarding code no longer relevant, migrated to new repository.

@kjdelisle kjdelisle merged commit 8842d4e into gh-pages Nov 13, 2017
@kjdelisle kjdelisle deleted the lb4/custom-server-components branch November 13, 2017 23:15
@kjdelisle kjdelisle removed the review label Nov 13, 2017
@bschrammIBM
Copy link
Contributor

Do you still me to review this? There seems to be a long history.

@kjdelisle
Copy link
Author

@bschrammIBM It's already landed, but if there are concerns or comments you'd like addressed, you can still perform a review after the fact and I can try to address them in a follow-up PR.

@jonathan-casarrubias
Copy link

jonathan-casarrubias commented Nov 15, 2017

@kjdelisle sorry for the late response...

Anyway I think this is a good start but my first impression is that still is too basic for more complex extensions, I'm not sure how to improve the docs without creating a tutorial instead, but the Server example is not quite the same as the REST and gRPC components are actually built...

Somehow the Server examples demonstrate how to extend an Application but for me it tastes more like an application developer level, rather than an extension developer level example

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

Successfully merging this pull request may close these issues.

9 participants