Knit brings GraphQL-like capabilities to RPCs. Knit has type-safe and declarative queries that shape the response, batching support to eliminate the N+1 problem, and first-class support for error handling with partial responses. It is built on top of Protobuf and Connect.
Knit is currently in alpha (α), and looking for feedback. Learn more about it at the Knit repo, and learn how to use it with the Tutorial.
This repo is an implementation in Go of the server-side of the Knit protocol. The result is a gateway that processes these declarative queries, dispatches the relevant RPCs, and then merges the results together. The actual service interface is defined in the BSR: buf.build/bufbuild/knit.
For more information on the core concepts of Knit, read the documentation in the repo that defines the protocol.
This repo contains two key components:
- The runtime library used by a Knit gateway: Go package
"github.com/bufbuild/knit-go"
. - A standalone server program that can be used as a Knit gateway and configured via YAML file:
"github.com/bufbuild/knit-go/cmd/knitgateway
.
The Knit gateway is a Go server that implements the Knit service.
The process of handling a Knit query consists of the following steps:
-
Request Validation and Schema Computation
The first step is to validate each requested entry point method and its associated mask. Validating the mask is done at the same time as producing the response schema, both of which involve a traversal of the mask, comparing requested field names against the RPC's response schema and the gateway's set of known relations.
-
Issuing Entry-Point RPCs
Once the request is validated, all indicated methods are invoked. These requests are sent concurrently (up to a configurable parallelism limit). When the gateway is configured, a route is associated with each RPC service, so this dispatch step could end up sending multiple requests to the same backend or scattering requests to many backends (depending on which methods were in the request and their configured routes).
-
Response Masking
Once an entry-point RPC completes, the response data is filtered according to the mask in the request. If the mask indicated any relations that must be resolved, those are accumulated in a set of "patches". A patch indicates a piece of data that must first be computed by a resolver and then inserted into the response structure.
-
Stitching
Stitching is the iterative process of resolving patches and adding them to the response structure. Stitching is complete when there are no patches to resolve.
So if any patches were identified in the above step, they are aggregated into batches and sent to resolvers. Resolvers are functions that know how to compute the values of relation fields. To avoid the N+1 problem, resolvers always accept batches. All batches are resolved concurrently (up to the same configurable parallelism limit used for dispatching entry-point RPCs).
After a resolver provides results, we go back to step 3: the result data is filtered according to the mask in the request and inserted into the response structure. If the mask for the resolved data includes more relations, a subsequent set of patches is computed, and then the gateway performs another round of stitching.
At the end of this step, the gateway has aggregated the results of all RPCs and can send a response to the client.
This process occurs for all Knit operations: Fetch
, Do
, and Listen
. That last one is
a server-stream, where the above steps are executed for each response message in the stream.
When services are registered, if any of the service's methods are annotated as relation resolvers, then the gateway will use that RPC method to resolve relations that appear in incoming queries.
This repo contains a stand-alone Knit gateway server that can get you up and going by just writing a YAML config file.
The server is a single statically-linked binary that can be downloaded from the Releases page for this repo.
You can also use the Go tool to build and install the server from source:
go install github.com/bufbuild/knit-go/cmd/knitgateway@latest
This builds a binary named knitgateway
from the latest release.
Running the binary will start the server, which will by default expect a
config file named knitgateway.yaml
to exist in the current working directory.
The binary accepts the following command-line options:
--conf <filename>
: Overrides the name and path of the config file to use.--log-format <format>
: Configures the log output format. The default is "console" format, which emits logs in a simple human-readable line-oriented text form. The other option is "json" format, which emits structured data formatted as JSON.--version
: Prints the version of the gateway program and then immediately exits.
In order to configure the server, you need to provide a YAML config file. There
is a an example in the root of this repo named
knitgateway.example.yaml
.
The example file shows all the properties that can be configured. The example
also is a working example if you also run the
swapi-server
demo server as the backend.
The YAML config format is documented in its entirety in a separate page.
You may want a custom gateway if you need the gateway to do something that the standalone gateway program does not do. This could range from custom observability or alternate logging, additional endpoints that the environment expects, add features not present in the standalone gateway (supporting other encodings, compression algorithms, protocols [e.g. HTTP/3], etc), or even embedding the gateway into the same process as a Connect or gRPC backend.
Creating a custom gateway involves writing a Go HTTP server. This server will install a handler
for the Knit service, which is provided by the "github.com/bufbuild/knit-go"
package in this
repo.
The main steps to use this package all involve configuring the handler.
First we have to create a gateway. Note that none of the attributes are required, so it can be as simple as this:
gateway := &knit.Gateway{}
This returns a gateway that will:
- Use
http.DefaultClient
as the transport for outbound RPCs. - Use Connect as the protocol (vs. gRPC or gRPC-Web) and use the Protobuf binary format as the message encoding.
- Have no limit on parallelism for outbound RPCs
- Use
protoregistry.GlobalTypes
for resolving extension names in requests and for resolving message names ingoogle.protobuf.Any
messages. - Require that registered services include routing information (so the gateway knows where to send outbound RPCs).
You can customize the above behavior by setting various fields on the gateway:
Client
: The transport to use for outbound RPCs. (This can also be overridden on a per-service basis, if some services require different middleware, such as auth, than others).ClientOptions
: The Connect client options to use for outbound RPCs. This allows customizing things like interceptors and protocols. If some backends only support gRPC, you can configure that with a client option.MaxParallelismPerRequest
: The concurrency limit for handling a single Knit request. Note that this controls the parallelism of issuing entry-point RPCs and the parallelism of invoking resolvers. This setting cannot be enforced inside of resolver implementations: if a resolver implementation starts other goroutines to operate with additional parallelism, this limit may be exceeded.Route
: This is a default route. If you have one application that will receive most (or all) of the Connect/gRPC traffic, configure it here. Then you only need to include routing information when registering services that should be routed elsewhere.TypeResolver
: This is an advanced option that is usually only useful or necessary when using dynamic RPC schemas. This resolver provides descriptors for extensions and messages, in case any requests or responses include extensions orgoogle.protobuf.Any
messages.
NOTE: If you want to configure a custom codec for outbound RPCs, to customize content encoding, you must use
knit.WithCodec
instead ofconnect.WithCodec
when creating the Connect client option.
Once the gateway is created with basic configuration, we register RPC services whose methods can be used as entry points for Knit operations.
The simplest way is to register services is to import the Connect generated code for these services. This generated code includes a constant for the service name and will also ensure that the relevant service descriptors are linked into your program.
package main
// This is the generated package for the Connect demo service: Eliza
import (
"net/url"
"buf.build/gen/go/bufbuild/eliza/bufbuild/connect-go/buf/connect/demo/eliza/v1/elizav1connect"
"github.com/bufbuild/knit-go"
)
func main() {
gateway := &knit.Gateway{
Route: &url.URL{
Scheme: "https",
Host: "my.backend.service:8443",
},
MaxParallelismPerRequest: 10,
}
// Refer to generated constant for service name
err := gateway.AddServiceByName(elizav1connect.ElizaServiceName)
// ... more configuration ...
// ... start server ...
}
When you register a service, requests for that service will be routed to the
gateway.Route
URL. If that field is not set (i.e. there is no route for the
service), the call to AddServiceByName
will return an error.
You can supply the route (or override the default one in gateway.Route
) with
an option. There are other options that allow you to provide a different
HTTP client and different Connect client options. These can be used if your
backends are not homogenous: for example, some are Connect and some are gRPC,
some support "h2c" and some do not,
etc.
err := gateway.AddServiceByName(
elizav1connect.ElizaServiceName,
knit.WithRoute(elizaBackendURL),
knit.WithClient(h2cClient),
knit.WithClientOptions(connect.WithGRPC()),
)
The Knit protocol is a Protobuf service, so it can be exposed over HTTP using the Connect framework, like any other such service.
So now that our gateway is fully configured, we just wire it up as an HTTP handler:
package main
import (
"net"
"net/http"
"github.com/bufbuild/knit-go"
)
// Example function for starting an HTTP server that exposes a
// configured Knit gateway.
func serveHTTP(bindAddress string, gateway *knit.Gateway) error {
listener, err := net.Listen("tcp", bindAddress)
if err != nil {
return err
}
mux := http.NewServeMux()
mux.Handle(path, gateway.AsHandler())
// This returns when the server is stopped
return http.Serve(listener, mux)
}
Now a Knit client can send requests to the HTTP server we just started.
Knit is undergoing initial development and is not yet stable.
Offered under the Apache 2 license.