@hyurl/grpc-boot v1.1.0
gRPC Boot
Make it easy to create standalone and elegant gRPC based applications.
NOTE: this package uses @hyurl/grpc-async to make life with gRPC easier.
NOTE: The NPM package only contains the minimal file base, go to GitHub for this Doc and the related files. By combining these files, this project itself serves as an example of using gRPC Boot in real world.
Install
npm i @hyurl/grpc-bootFirst Impression
Take a look at the following config file (grpc-boot.json):
{
"$schema": "./node_modules/@hyurl/grpc-boot/grpc-boot.schema.json",
"package": "services",
"protoDirs": [
"./services"
],
"protoOptions": {
"longs": "String",
"defaults": true,
"oneofs": true
},
"apps": [
{
"name": "example-server",
"uri": "grpc://localhost:4000",
"serve": true,
"services": [
"services.ExampleService"
],
"stdout": "./out.log"
},
{
"name": "user-server",
"uri": "grpc://localhost:4001",
"serve": false,
"services": [
"services.UserService"
]
},
{
"name": "post-server",
"uri": "grpc://localhost:4002",
"serve": true,
"services": [
"services.PostService"
],
"stdout": "./out.log"
}
]
}Now, start the apps like this:
npx tsc && npx grpc-boot startIt's just that simple.
Explanation
packageThis is the package name in the.protofiles.namespaceThis is the directory that stores the service class (.ts) files, and is the root namespace of the services. Normally, this option is omitted or set to the same value aspackage, but it can be set to a different value to achieve aliasing.entryThe entry file that is used to spawn apps. Normally, this property is not required because the CLI command will use the default entry file for us.If a custom entry file is provided, it's spawned with the arguments
appName [config], we can useprocess.argv[2]to get the app's name andprocess.argv[3]to get the config filename (if provided). Please take a look at the example main.ts.importRootWhere to begin searching for TypeScript / JavaScript files, the default is.. If given, we need to set this property the same value as theoutDircompiler option in thetsconfig.jsonfile.protoDirsThese directories stores the.protofiles. Normally,.protofiles reside with the service class (.ts) files, but they can be placed in a different place, say aprotofolder, just set this option to the correct directory so the program can find and load them.protoOptionsThese options are used when loading the.protofiles.appsThis property configures the apps that this project serves and connects.nameThe name of the app.uriThe URI of the gRPC server, supported schemes aregrpc:,grpcs:,http:,https:orxds:(make sure package @grpc/grpc-js-xds is installed).serveIf this app is served by the gRPC Boot app server. If this property isfalse, that means the underlying services are served by another program. As we can see from the above example, theuser-serversets this property tofalse, because it's served in agolangprogram. If we take a look at the services.UserService, we will just see a very simple TypeScript declaration file.servicesThe services served by this app. if we take a look at the services.ExampleService and the services.PostService, we will see that they're very simple TypeScript class files.stdoutLog file used for stdout.More Options
caThe CA filename when using TLS/SSL.certThe certificate filename when using TLS/SSL.keyThe private key filename when using TLS/SSL. NOTE: We only need a pair of certificates for both the server and the client, since they are inside one project, using different certificates makes no sense.connectTimeoutConnection timeout in milliseconds, the default value is5_000ms.optionsChannel options, see https://www.npmjs.com/package/@grpc/grpc-js for more details.stderrLog file used for stderr.envThe environment variables passed to theentryfile.
With these simple configurations, we can write our gRPC application straightforwardly in a .proto
file and a .ts file in the services directory, without any headache of how to start the server
or connect to the services, all is properly handled internally by the gRPC Boot framework.
CLI Commands
init [options] [package]initiate a new gRPC projectoptions-c, --config <filename>create a custom config file
packageThe package name / root namespace of the services, default 'services'
start [options] [app]start an app or all apps (exclude non-served ones)options-c, --config <filename>use a custom config file
appthe app name in the config file
restart [options] [app]restart an app or all apps (exclude non-served ones)options-c, --config <filename>use a custom config file
appthe app name in the config file
reload [options] [app]reload an app or all appsoptions-c, --config <filename>use a custom config file
appthe app name in the config file
stop [options] [app]stop an app or all appsoptions-c, --config <filename>use a custom config file
appthe app name in the config file
list [options]list all apps (exclude non-served ones)options-c, --config <filename>use a custom config file
Hot-Reloading
After we've modified our source code (or recompiled), the .proto files, or the config file, we can
use the reload command to hot-reload our apps without restarting the process.
When the command is issued, the application will scan the imported service files and their
dependencies (exclude the ones in node_modules), and reload them all at once. Since this procedure
doesn't restart the process, all context stores in the global scope are still available.
Hot-reloading is much faster than restarting the whole program, the clients will experience
0-downtime of our services.
It's important to point out, though, that the hot-reloading model this package uses only supports services and their dependencies, any other code, for example, the change of the entry file, will not join the reloading process and cannot be hot-reloaded, if such changes are made, a full restart is needed for the new code to run.
Why not auto-reload when the file is changed?
gRPC uses the .proto file for definition and the .ts file for implementation, it's hard to keep
track on both files at the same time. If reload immediately after a file is changed, there may be
inconsistency between the two files and causing program failure. So this package provides the
reload command that allows us to manually reload the app when we're done with our changes.
About Process Management
A server app may be automatically respawned if it is crashed, but this behavior requires at least one app is still running. If our project only have one app, this feature will not function.
On the other hand, the CLI tool only works for the app instance, if the process contain other
objects that prevent the process to exit, the stop command won't be able to terminate the process.
It's recommended to use external process management tool such as PM2 in production, which gives
us more control of our program and provides more features such as monitoring. And while using PM2
(or others), we can still use the reload command to hot-reload our app after deployed new updates.
Programmatic API
App.boot(app?: string, config?: string): Promise<void>
Starts the app programmatically.
appThe app's name that should be started as a server. If not provided, the app only connects to other servers but not serves as one.configUse a custom config file.
Example
import App from "@hyurl/grpc-boot";
(async () => {
// This app starts a gRPC server named 'example-server' and connects to all services.
const serverApp1 = await App.boot("example-server");
// This app starts a gRPC server with a custom config file.
const serverApp2 = await App.boot("example-server", "my.config.json");
})();
(async () => {
// This app won't start a gRPC server, but connects to all services.
const clientApp1 = await App.boot();
// This app connects to all services with a custom config file.
const clientApp2 = await App.boot(null, "my.config.json");
})();app.stop(): Promise<void>
Stops the app programmatically.
Example
import App from "@hyurl/grpc-boot";
App.boot("example-server").then(app => {
process.on("exit", (code) => {
// Stop the app when the program is issued to exit.
app.stop().then(() => {
process.exit(code);
});
});
});app.reload(): Promise<void>
Reloads the app programmatically.
This function is rarely used explicitly, prefer to use the CLI reload command or
App.sendCommand("reload") instead.
app.onReload(callback: () => void): void
Registers a callback to run after the app is reloaded.
Example
import App from "@hyurl/grpc-boot";
App.boot("example-server").then(app => {
app.onReload(() => {
// Log the reload event.
console.info("The app has been reloaded");
});
});app.onStop(callback: () => void): void
Registers a callback to run after the app is stopped.
Example
import App from "@hyurl/grpc-boot";
App.boot("example-server").then(app => {
app.onStop(() => {
// Terminate the process when the app is stopped.
process.exit(0);
});
});App.loadConfig(config?: string): Promise<Config>
Loads the configurations.
configUse a custom config file.
App.loadConfigForPM2(config?: string): Promise<{ apps: any[] }>
Loads the configurations and reorganize them so that the same configuration can be used in PM2's configuration file.
configUse a custom config file.
App.sendCommand(cmd: "reload" | "stop" | "list", app?: string, config?: string): Promise<void>
Sends control command to the apps. This function is mainly used in the CLI tool.
cmdappThe app's name that should received the command. If not provided, the command is sent to all apps.configUse a custom config file.
App.runSnippet(fn: () => void | Promise<void>, config?: string): Promise<void>
Runs a snippet inside the apps context.
This function is for temporary scripting usage, it starts a temporary pure-clients app so we can use
the services as we normally do in our program, and after the main fn function is run, the app is
automatically stopped.
fnThe function to be run.configUse a custom config file.
Example
import App from "@hyurl/grpc-boot";
App.runSnippet(async () => {
const post = await services.PostService.getPost({ id: 1 });
console.log(post);
});Good Practices
The package name of the
.protofile for services is the same namespace in the.tsservice class files.For example:
// the .proto file
syntax = "proto3";
package services;// the .ts file
declare global {
namespace services {
}
}NOTE: this rule doesn't apply to the situation when multiple projects are
sharing the same .proto files.
The package name / namespace is the directory name that store the
.protofiles and.tsfiles. For example, package nameservicesuses./servicesdirectory, andservices.subuses./services/sub. This is required for discovering and importing files.The base name of the
.protofile (without extension) should have a correspondent.tsfile (or.d.tsfile). For example,ExampleService.protomaps to theExampleService.tsfile.The
.protofile should contain only one service and its name is the same name as the file, respectively, the correspondent.tsfile export the default class with the same name.For example
// the ExampleService.proto file
syntax = "proto3";
package services;
service ExampleService {
// ...
}// the ExampleService.ts file
import { ServiceClient } from "@hyurl/grpc-boot"
declare global {
namespace services {
const ExampleService: ServiceClient<ExampleService>;
}
}
export default class ExampleService {
}Lifecycle Support
The service class served by gRPC Boot application supports lifecycle functions, to use this feature,
simply implement the LifecycleSupportInterface for the service class, for example:
import { LifecycleSupportInterface } from "@hyurl/grpc-boot";
export default class ExampleService implements LifecycleSupportInterface {
async init(): Promise<void> {
// When the service is loaded (or reloaded), the `init()` method will be automatically
// called, we can add some async logic inside it, for example, establishing database
// connection, which is normally not possible in the default `constructor()` method
// since it doesn't support asynchronous codes.
}
async destroy(): Promise<void> {
// When the app is about to stop, or the service is about to be reloaded, the `destroy()`
// method will be called, which gives the ability to clean up and release resource.
}
}Routing According to the Message
If a service is served in multiple apps, gRPC Boot uses a client-side load balancer to connect to it, the load balancer is configured with a custom routing resolver which allows us redirect traffic according to the message we sent when calling RPC functions.
To use this feature, define the request message that extends / augments the interface
RoutableMessageStruct, it contains a route key that can be used in the internal client load
balancer. When the client sending a request which implements this interface, the program will
automatically route the traffic to a certain server evaluated by the route key, which can be set
in the following forms:
- a URI or address that corresponds to the ones that set in the config file;
- an app's name that corresponds to the ones that set in the config file;
- if none of the above matches, use the hash algorithm on the
routevalue; - if
routevalue is not set, then the default round-robin algorithm is used for routing.
For Example:
// the .proto file
message RequestMessage = {
string route = 1;
// other fields
};// the .ts file
import { RoutableMessageStruct } from "@hyurl/grpc-boot";
export interface RequestMessage extends RoutableMessageStruct {
// other fields
}Running the Program in TS-Node
The CLI tools starts the program either with node or ts-node according to the entry file. If the
entry file's extension is .js, it spawn the process via node, which means the source code (in
TypeScript) needs to be transpiled into JavaScript first in order to be run (the default behavior).
If the filename ends with .ts, it load the program via ts-node, which allow TypeScript code run
directly in the program.
By default, gRPC Boot app uses a default entry file compiled in JavaScript, which means our code
needs to be transpiled as well. To use ts-node running TypeScript, we need to provide a custom
entry file, just like this.
{
"package": "services",
"entry": "./main.ts",
// ...
}Moreover, instead of giving the extension name, we can omit it (for example ./main) and allow the
CLI tool to determine whether to use node or ts-node according the file presented. If main.js
is presented, node is used, otherwise, ts-node is used.
Multi-Config Project
It's possible to define a project with multiple gRPC Boot configurations, just pass the custom
config filename everywhere we need. This suits the scenario that the gRPC servers from other
projects uses other package names that is different from ours (commonly services).
For example, another project uses the package name helloworld, and we use the GreeterService
from it. To do this, we can create a custom config file helloworld.grpc-boot.json like this:
{
"package": "helloworld",
"protoDirs": ["helloworld"],
"protoOptions": {
"longs": "String",
"defaults": true,
"oneofs": true
},
"apps": [
{
"name": "greeter-server",
"uri": "grpc://greeter-server:4000",
"services": [
"helloworld.GreeterService"
]
},
]
}Then create a folder named helloworld, inside it, we create a TypeScript declaration file
GreeterService.d.ts:
import { ServiceClient } from "@hyurl/grpc-boot";
declare global {
namespace helloworld {
const GreeterService: ServiceClient<GreeterService>;
}
}
// declare message types ...
export default class GreeterService {
// declare methods ...
}Then in our entry file, add the following code:
await App.boot(null, "helloworld.grpc-boot.json");Sharing .proto Files Across Projects
Multiple projects can use each other's services to form a bigger architecture. To do this, one
project needs to download and import the .proto files from the other project. During the process,
we need to prevent naming conflict. For example, both project A and project B uses services as
their namespaces and the folder to place service class files, and they both have their own
services.UserService. It's impossible for us to store two .proto files with the same name in one
place, and impossible to have two UserService under the same namespace.
In order to prevent this and have a better coding experience, we need a better practice, to name our
packages in the .proto files, as well as the service namespaces in the .ts files. We can do this
by settings different names for the package option and the namespace option in the config file
(and the corresponding .proto and .ts files).
For Example, in project A:
// grpc-boot.json
{
"package": "hyurl.grpcBoot.services",
"namespace": "services",
// ...
"apps": [
{
// ...
"services": [
"services.UserService"
]
}
]
}This tells that our .proto files uses the package name hyurl.grpcBoot.services, while the .ts
files uses namespace services.
When project B uses the .proto files from this project, in its
config file (a custom one), we set this:
// extra-grpc-boot.json
{
"package": "hyurl.grpcBoot.services",
"namespace": "projectA",
// ...
"apps": [
{
// ...
"services": [
"projectA.UserService"
]
}
]
}Then in project A we are comfortably using services.UserService while in project B we use
projectA.UserService to refer to the same service.
Naming the package with prefixed domains gives a clear indication of where the services and messages
are coming from. The namespace, on the other hand, creates an alias of the package in the .ts
files and for auto-loading. It practically just gives a shorter name of the package (as well as a
shallower path of the directory structure). However this is optional, if we'd prefer, we can create
all the subfolders for the package name and leave namespace unset (and reference the services
respectively).
0-Services App
An app can be configured with serve: true but no services, such an app does not actually start the
gRPC server neither consume the port. But such an app can be used, say, to start a web server, which
connects to the gRPC services and uses the facility this package provides, such as the CLI tool and
the reloading hook.
For example:
// grpc-boot.json
{
// ...
"entry": "main", // need a custom entry file
"apps": [
{
"name": "web-server",
"uri": "http://localhost:4000",
"serve": true,
"services": [] // leave this blank
},
// ...
]
}// main.ts
import App from "@hyurl/grpc-boot";
import * as http from "http";
import * as https from "https";
import * as fs from "fs/promises";
if (require.main?.filename === __filename) {
(async () => {
const appName = process.argv[2];
const config = process.argv[3];
const app = await App.boot(appName, config);
let httpServer: http.Server;
let httpsServer: https.Server;
if (appName === "web-server") {
const conf = await App.loadConfig(config);
const _app = conf.apps.find(app => app.name === appName);
let { protocol, port } = new URL(_app.uri);
if (protocol === "https:") {
port ||= "443";
httpsServer = https.createServer({
cert: await fs.readFile(_app.cert),
key: await fs.readFile(_app.key),
}, (req, res) => {
// ...
}).listen(port);
} else if (protocol === "http:") {
port ||= "80";
httpServer = http.createServer((req, res) => {
// ...
}).listen(port);
}
}
app.onReload(() => {
// do some logic to reload the HTTP(S) server
});
app.onStop(() => {
httpServer?.close();
httpsServer?.close();
});
process.send("ready");
})().catch(err => {
console.error(err);
process.exit(1);
});
}