Gokit: HTTP transport
Jul 23, 2017 · 9 minute read · Commentsgolang
In our services written in Go, we quite heavily rely on Go kit. You can build anything with just Go’s standard library. It has very little opinions about how to do things and gives you a lot of latitude when architecting your application. That’s great for trying things out or even if you have a single application, but when you develop multiple services in a team, it’s useful to have a common baseline for routine tasks and Go kit does exactly that.
We have experience writing RESTful services in Go, but it took some effort to fully grasp the concepts offered by Go kit. There is a set of examples but for some reason they didn’t get us fully there, so I feel it’s worth wharing what we learned. For examples, I’ll lean on the profilesvc, a CRUD service storing information about user profiles.
Service
Let’s start with a quick overview before we dig deeper. The core concept is a service. Most likely it will take a form of a struct with a few fields (e.g. database connection pool) and a set of methods — these methods are the real reason for the existence of the service, they provide the business value. How the service works is up to you.
type Service interface {
// ...
PostProfile(ctx context.Context, p Profile) error
GetProfile(ctx context.Context, id string) (Profile, error)
// ...
}
Endpoint
The next concept is an Endpoint. It converts the specifics of a service method into a general pattern of
type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)
There is interface{}
both in the arguments and in the return values, what is going on? In order to work with multiple endpoints in a library without tying yourself to specifics, the Endpoint
’s definition has to be a bit vague, individual endpoint then type casts the request argument to the desired type. Of course, as a user of the library, you need to make sure that it gets called with a request of the correct type. Endpoint
s tend to be very simple, they are a glue binding the flexible (and general) concepts of Go kit to your specific service.
func MakeGetProfileEndpoint(s Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (response interface{}, err error) {
req := request.(getProfileRequest)
p, e := s.GetProfile(ctx, req.ID)
return getProfileResponse{Profile: p, Err: e}, nil
}
}
Cast request, call the service method, maybe handle the error, return. That’s the usual anatomy of an Endpoint
.
If you think about your service as a unique object, wrapping it in an Endpoint
is like putting in a shipping container: it’s got standard dimensions and can be handled easily.
Middlewares
So far, we have our service method wrapped in a function making it a general Endpoint
. The next step is bit tricky and would benefit from more distinctive term definitions, because we’re hitting against middlewares.
Middleware can mean a lot of things just in the IT industry. Apart from the business IT definition described on Wikipedia, in the web server world it is closer to the decorator pattern. Unfortunately (from the naming perspective) and luckily (from the usefulness perspective) Go kit uses two types of middlewares (well actually three, but we’ll get to that later).
Endpoint-middlewares
The first type decorates an endpoint, the second decorates the service itself. For clarity, let’s call them endpoint-middlewares and service-middlewares respectively. Endpoint-middlewares have their own type Middleware
in Go kit (type Middleware func(Endpoint) Endpoint
). The example given in hte stringsvc example is for transport logging:
func loggingMiddleware(logger log.Logger) Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
logger.Log("msg", "calling endpoint")
defer logger.Log("msg", "called endpoint")
return next(ctx, request)
}
}
}
You take an Endpoint
, any Endpoint
, do something before, after, or around it, and let it run. This is useful for any work that doesn’t need to know anything about the underlying service, e.g. some forms of logging, rate limiting, or circuit breaking. In our container analogy, this would be like adding cooling to your container — it’s still a container and behaves like one but it also has an extra functionality which doesn’t need to know about the actual contents (just that it needs cooling).
It’s common to have multiple Middleware
s you need to apply to your Endpoint
. because of their type, you can easily nest them wrap one around the other, but you need to figure out the correct order. Sometimes, it doesn’t matter, but when it does, you need to remember that:
endpoint := makeEndpoint(service)
endpoint = middlewareA(endpoint)
endpoint = middlewareB(endpoint)
endpoint = middlewareC(endpoint)
will mean that middlewareC
is executed first, then middlewareB
, then middlewareA
, finally endpoint itself, and then up again in reverse order. If you want the middlewares to be executed in the opposite order, you can use Chain
which makes the declaration order match the execution order.
Service-middlewares
Service-middleware is tied to the service. In Go terms, it’s a type which implements the same interface as the service itself but decorates it with extra functionality. The stringsvc example show two uses: application level logging (logging arguments and return values of the service) and metrics instrumentation. In terms of the analogy, this would be like adding temporary structural reinforcements to your unique cargo so that it survives the transport.
Transport
In order to make your service wrapped in an Endpoint
useful to the world, you need to define how the world can communicate with it. Go kit currently provides support for HTTP and gRPC transports. Both implement a Client
and a Server
which let you work with the Endpoint
both as a consumer and a publisher. I’ll limit this article to HTTP Server
.
Server
Server
is a mechanism to publish your Endpoint
. The name is a bit confusing, because it implements http.Handler
— it’s a handler for one path, not a whole server, the general Client
/Server
naming contrast has probably won over accuracy in the specific case. (Go kit doesn’t dictate which HTTP router you have to use, you can pass Server
to any of them.)
Server
has a constructor which takes a bunch of arguments to configure it. To understand them, let’s look at the main method ServeHTTP
:
- Get
Context
for the*http.Request
- If a finalizer exists
- Wrap the
http.ResponseWriter
in an intercepting writer - Defer updating context with the headers and response size and calling the finalizer
- Run
Context
and*http.Request
through allbefore
functions, update theContext
- Decode request using decoder; in case of error, log, call error encoder, and return
- Call the endpoint with the decoded request and
Context
; handle possible error (log, error encoder, return) - Run
Context
andhttp.ResponseWriter
through allafter
functions, update theContext
- Encode response from the endpoint using encoder; handle error the same way
This shows us all the options in action. We have talked about endpoints before. In the context of Server
endpoint may already be wrapped in both in service- and endpoint-middlewares. Now to the other options.
Request decoder
Request decoder converts Context
and *http.Request
into a request and an error. The request is typed as interface{}
and you need to make sure that the actual type matches the type expected by your Endpoint
. Typically, decoder will decode JSON (or some other exchange format) and/or extract values from URL and headers. The second part might not be obvious, so let’s repeat it: decoder extract arguments from URL and headers as well. This pattern is shown in examples and makes sense if you realize that separation of arguments between the request body and URL and headers is totally irrelevant from the perspective of the Endpoint
, it’s an implementation detail of the HTTP transport and others might be different. The request type should therefore be agnostic and represent the whole request.
Response encoder
Response encoder is a mirror image of the request decoder. It takes the response type and converts it into a response body and headers as necessary. In most cases, this will call an encoder (e.g. JSON, XML), write it to the http.ResponseWriter
, and set some headers.
Logger
Server can take a Logger
and use it to log errors in ServeHTTP
. Haven’t we talked about logging middleware before? Why logger here? Well, yeah, this is a bit confusing. Go kit provides this logger as a fallback for you, but this logger is in no way related to any request-scoped loggers you may have. The issue has been raised before and decided against: if you want smarter logging, you have to do it yourself either in a middleware or in a custom error encoder.
Error encoder
Error encoder takes the Context
of the current request, an error and an http.ResponseWriter
to presumably write a suitable error response. As noted above, it’s called in case when request decoding, Endpoint
itself, or response encoding fail. Go kit provides a basic implementation but you’ll probably want to implement your own. It has access to the request Context
so it can be very powerful.
In most cases, this will type switch on the error to detect custom error types and possibly individual values in order to convert it to different HTTP status codes and body values.
Before functions
As described above, before functions take Context
and http.Request
and update the Context
. It can’t do anything else, it can’t error out (sure, it can panic). The only responsibility should be to extract and sanitize data from the http.Request
and put it into the Context
.
As an example, Go kit provides a function which copies commonly used headers. Other possible use it to add request-scoped logger or extract JWT token. The latter one shows a common pattern — before function extracts data and stores them in a context and then an endpoint-middleware uses that value. This forces you to split responsibilities into two functions leading to smaller simpler functions but placing the responsibility to handle missing input on the middleware.
After functions
They mirror before functions. I haven’t found much use for them but they are there if you find a use.
Finalizers
Finalizers haven’t been a part of Go kit from the beginning. They have been added later and only to the HTTP transport. It’s continued existence is dubious in any case and there is talk about removing it altogether. I haven’t seen a real use for a finalizer so far except for suggestions to use it for recovering request-scoped panics. This has however been discouraged in favour of a server-middleware wrapping the whole Server
(= http.Handler
) in it’s own handler. (And that’s the third type of middleware.)
Where do I put it?
OK, let’s review what we’ve learned and what goes where.
- Business functionality belongs to your service
- Application-level logging, metrics instrumentation, and other tasks which need to know the type of your service are service-middleware, you compose them by wrapping them in each other, order matters
- Concerns that don’t need to know about the service itself are endpoint-middleware, this includes rate limiting, circuit breaking, or authorization
- Extracting data from
*http.Request
intoContext
is a responsibility of before functions - Conversion of
*http.Request
into anEndpoint
specific request type belongs to request decoder - Response encoder encodes endpoint specific response into a transport specific response
- Error encoder does the same for errors and is typically unique for an application
Server
’s logger is a fallback and while useful, you probably want better logging elsewhere, probably in the error encoder- Panic recovery belongs to service-middleware
This has been dense but I hope it helped to grasp Go kit’s API and understand how to make use of it.
We're looking for developers to help us save energy
If you're interested in what we do and you would like to help us save energy, drop us a line at jobs@enectiva.cz.