- Hands-On Microservices with Kubernetes
- Gigi Sayfan
- 809字
- 2021-06-24 13:46:46
Implementing the support functions
As you may recall, the social graph implementation in the pkg/social_graph_manager package is completely transport agnostic. It implements the SocialGraphManager interface in terms of Go and couldn't care less whether the payload is JSON or protobuf and coming over the wire through HTTP, gRPC, Thrift, or any other method. The service is responsible for translation, encoding, and decoding. These support functions are implemented in the transport.go file.
For each endpoint, there are three functions, which are the input to the HTTP transport's NewServer() function of Go kit:
- The Endpoint factory function
- The request decoder
- The response encoder
Let's start with the Endpoint factory function, which is the most interesting. Let's use the GetFollowing() operation as an example. The makeGetFollowingEndpoint() function takes a SocialGraphManager interface as input (as you saw earlier, in practice, it will be the implementation in pkg/social_graph_manager). It returns a generic endpoint.Endpoint function, which a function that takes a Context and a generic request and returns a generic response and error:
type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)
The job of the makeGetFollowingEndpoint() method is to return a function that complies with this signature. It returns such a function that, in its implementation, takes the generic request (the empty interface) and type before asserting it to a concrete request, that is, getByUsernameRequest:
req := request.(getByUsernameRequest)
This is a key concept. We cross the boundary from a generic object, which could be from anything to a strongly typed struct. This ensures that, even though the Go kit endpoints operate in terms of empty interfaces, the implementation of our microservice is type checked. If the request doesn't contain the right fields, it panics. I could also check whether it's possible to do the type assert and return an error instead of panicking, which might be more appropriate in some contexts:
req, ok := request.(getByUsernameRequest)
if !ok {
...
}
Let's take a look at the request itself. It is simply a struct with a single string field called Username. It has the JSON struct tag, which is optional in this case because the JSON package can automatically work with field names that differ from the actual JSON just by case like (Username versus username):
type getByUsernameRequest struct {
Username string `json:"username"`
}
Note that the request type is getByUsernameRequest and not getFollowingRequest, as you may expect in order to be consistent with the operation it is supporting. The reason for this is that I actually use the same request for multiple endpoints. The GetFollowers() operation also requires a username, and getByUsernameRequest serves both GetFollowing() and GetFollowers().
At this point, we have the username from the request and we can invoke the GetFollowing() method of the underlying implementation:
followingMap, err := svc.GetFollowing(req.Username)
The result is a map of the users that the requested user is following and the standard error. However, this is an HTTP endpoint, so the next step is to package this information into the getFollowingResponse struct:
type getFollowingResponse struct {
Following map[string]bool `json:"following"`
Err string `json:"err"`
}
The following map can be translated into a JSON map of string->bool. However, there is no direct equivalent to the Go error interface. The solution is to encode the error as a string (via err.Error()), where an empty string represents no error:
res := getFollowingResponse{Following: followingMap}
if err != nil {
res.Err = err.Error()
}
Here is the entire function:
func makeGetFollowingEndpoint(svc om.SocialGraphManager) endpoint.Endpoint {
return func(_ context.Context, request interface{}) (interface{}, error) {
req := request.(getByUsernameRequest)
followingMap, err := svc.GetFollowing(req.Username)
res := getFollowingResponse{Following: followingMap}
if err != nil {
res.Err = err.Error()
}
return res, nil
}
}
Now, let's take a look at the decodeGetFollowingRequest() function. It accepts the standard http.Request object. It needs to extract the username from the request and return a getByUsernameRequest struct that the endpoint can use later. At the HTTP request level, the username will be a part of the request path. The function will parse the path, extract the username, prepare the request, and return it or an error if anything goes wrong (for example, no username is provided):
func decodeGetFollowingRequest(_ context.Context, r *http.Request) (interface{}, error) {
parts := strings.Split(r.URL.Path, "/")
username := parts[len(parts)-1]
if username == "" || username == "following" {
return nil, errors.New("user name must not be empty")
}
request := getByUsernameRequest{Username: username}
return request, nil
The last support function is the encodeResonse() function. In theory, each endpoint can have its own custom response encoding function. However, in this case, I am using a single generic function that knows how to encode all the responses into JSON:
func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
return json.NewEncoder(w).Encode(response)
}
This requires all the response structs to be JSON serializable, which was taken care of by translating the Go error interface into a string by the endpoint implementation.