Gokit: HTTP transport

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. Endpoints 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 Middlewares 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:

  1. Get Context for the *http.Request
  2. If a finalizer exists
  3. Wrap the http.ResponseWriter in an intercepting writer
  4. Defer updating context with the headers and response size and calling the finalizer
  5. Run Context and *http.Request through all before functions, update the Context
  6. Decode request using decoder; in case of error, log, call error encoder, and return
  7. Call the endpoint with the decoded request and Context; handle possible error (log, error encoder, return)
  8. Run Context and http.ResponseWriter through all after functions, update the Context
  9. 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.

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.

comments powered by Disqus