Hace ya un tiempo, nuestro querido amigo Joan López de la Franca, escribía un artículo en el que hablaba del patrón Context para concurrencia, el cual vimos que es realmente útil, pero es que el contexto en Go tiene otros usos, y hoy vamos a explicar un patrón muy extendido.
Seguramente a medida que habéis ido profundizando en Go y utilizado distintos de librerías o visto proyectos de ejemplo, habréis visto una proliferación de funciones/métodos con una firma similar a la siguiente, func foo(ctx contex.Context, arg string)
, sin ir más lejos lo llegamos a ver la nueva librería de MongoDB.
¿Qué es el context.Context?
Desde la versión de Go 1.7, tenemos disponible en la librería oficial el paquete context. No es muy complicado pensar con ese nombre, sobre qué va el paquete context
, y es de dotar de un contexto a nuestras funciones o métodos.
El Context
que nos proporciona el paquete context
, es entre otras muchas cosas, un método para mover información entre una cadena de llamadas. Y os preguntaréis, ¿qué información es apropiada almacenar en un context
? Para responder correctamente a esa pregunta, primero deberemos entender cómo funciona internamente el context.Context
.
El context.Context
no deja de ser al final un map[interface{}]interface{}
, esto quiere decir que los parámetros que pasemos a través de nuestro context
son completamente inseguros, y el compilador no podrá validarlos de ninguna manera. ¿Qué pasaría si os dijera que tenéis una función func foo(args map[interface{}]interface{})
? Seguramente me llamaríais hereje y que habría que ver otra forma de declarar dicha función; bueno pues con el context
igual, siempre que podáis evitar usarlo para pasar argumentos mejor, no porque vuestra firma de función sea extremadamente grande, vais a usar el context
para ahorraros algunos parámetros.
Pero, obviamente tenemos información que se presta a ser traspasada por un context
, uno de los métodos más extendido es para recoger información de una request
, ya que es información que solo sucede una vez empieza la request
y que no tenemos disponible de otro modo, y que queremos propagar a capas inferiores para poder, logar o tracear, ejemplos de estos datos, podrían ser, tokens
, user IDs
, session IDs
, extraídos por ejemplo del header.
Una regla adicional que podéis usar, que no está exenta de que te vaya a salvar la vida, es pasar siempre valores en tu context
y evitar las referencias.
Al lío, dando contexto a mi código
Desde la inclusión del paquete context
, la Request
fue dotada de un context
propio, al cual podemos acceder con su método Context()
.
Para este ejemplo vamos a usar nuestra archifamosísima API, GopherAPI, y vamos a adaptarla para utilizar el context
, antes de entrar en detalles, comentemos los cambios que hemos tenido que hacer para poder tener el contexto de la request
a cualquier nivel.
Un consejo que os doy, es que no paséis el contexto a aquellas capas que realmente no lo necesitéis, y este consejo usadlo en general en vuestros proyectos, no vayáis pensando en el “y si…", si ese día que se necesita llega, pues hacéis el refactor, dejar todo preparado para algo que puede no llegar a suceder sólo nos traerá problemas. En nuestro caso, primero por motivos formativos y segundo porque sabemos qué lo vamos a necesitar para un próximo artículo sobre observabilidad vamos a pasar el context
hasta el repositorio.
Si vamos desde dentro hacia afuera, primeramente modificaremos nuestras interfaces de dominio:
//Repository provides access to the gopher storage
type Repository interface {
// CreateGopher saves a given gopher
CreateGopher(ctx context.Context, g *Gopher) error
// FetchGophers return all gophers saved in storage
FetchGophers(ctx context.Context) ([]Gopher, error)
// DeleteGopher remove gopher with given ID
DeleteGopher(ctx context.Context, ID string) error
// UpdateGopher modify gopher with given ID and given new data
UpdateGopher(ctx context.Context, ID string, g Gopher) error
// FetchGopherByID returns the gopher with given ID
FetchGopherByID(ctx context.Context, ID string) (*Gopher, error)
}
Como veis hemos dotado a todas de un primer parámetro context.Context
, algo que es común en el patrón context
, es que dicho parámetro sea el del context
.
Obviamente no hay que decir que al modificar nuestra interfaz, tendremos que modificar su implementación, la cual de momento se limitará a esperar dicho context
pero no haremos nada con él, pero como os decía en un próximo artículo lo usaremos.
Ahora iremos a nuestros servicios de aplicación, los cuales se dividen en cuatro paquetes, adding
, fetching
, modifying
y removing
. Nos centraremos en el paquete de fetching
aunque en todos funcionaran de la misma manera.
Primero modificamos la interfaz:
// Service provides fetching operations.
type Service interface {
FetchGophers(ctx context.Context) ([]gopher.Gopher, error)
FetchGopherByID(ctx context.Context, ID string) *gopher.Gopher
}
Nada nuevo en el horizonte, hemos añadido el primer parámetro context.Context
a todos nuestros métodos.
La siguiente modificación será pasar ese nuevo parámetro al repositorio:
// FetchGopherByID returns a gopher
func (s *service) FetchGopherByID(ctx context.Context, ID string) (*gopher.Gopher, error) {
return s.repository.FetchGopherByID(ctx, ID)
}
Y ya sólo nos queda modificar nuestro handler
para pasar el contexto de la request
, recordemos que todos los handlers los teníamos dentro del fichero pkg/server/api.go
el cual vamos a renombrar por coherencia a pkg/server/server.go
al igual que su struct
correspondiente.
Una vez hecho el renaming, vamos a modificar nuestro handler
, FetchGopher
// FetchGopher return a gopher by ID
func (s *server) FetchGopher(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
gopher, err := s.fetching.FetchGopherByID(r.Context(), vars["ID"])
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusNotFound) // We use not found for simplicity
json.NewEncoder(w).Encode("Gopher Not found")
return
}
json.NewEncoder(w).Encode(gopher)
}
Como podéis ver cuando llamamos a nuestro servicio, hemos pasado el context
de la Request
, gopher, err := s.fetching.FetchGopherByID(r.Context(), vars["ID"])
, con esto ya hemos propagado el contexto a todas nuestras capas, pero aún no hemos terminado, ni mucho menos.
Ahora ya tenemos propagado el contexto de la request
por todas nuestras capas, pero actualmente no lo estamos utilizando, así que vamos a ver para que nos puede ser servir.
Utilizando el contexto para nuestro logging
Para que nos sea más sencillo de seguir, os voy a proporcionar un caso de uso e iremos adaptando el código de GopherAPI hasta cubrir la totalidad del caso de uso.
Ahora mismo en nuestra aplicación no tenemos sistema de logging, con lo cual si tuviéramos algún error no quedaría registrado de ninguna manera. Queremos hacer una prueba de concepto de integrar un sistema de logging, así que de momento nos centraremos en el handler
, FetchGopher
, el cuál actualmente luce así:
// FetchGopher return a gopher by ID
func (s *server) FetchGopher(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
gopher, err := s.fetching.FetchGopherByID(r.Context(), vars["ID"])
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusNotFound) // We use not found for simplicity
json.NewEncoder(w).Encode("Gopher Not found")
return
}
json.NewEncoder(w).Encode(gopher)
}
Lo que queremos hacer es sólo devolver un error 404, si el gopher
no existe, es decir si es nil, si sucede cualquier otro error provocado por el repositorio, deberá ser capturado y registrado en el propio servicio de aplicación.
Además en el log queremos informar por defecto los siguientes parámetros:
serverID
: identificador de la instancia de la aplicación, la cual vendrá por variable de entorno o bien por flag al levantar la aplicación.X-Forwarded-For
: sólo si viene indicado vía heder.X-Forwarded-Proto
: sólo si viene indicado vía header.Endpoint
: url sobre la que hacemos la peticiónIP
: ip desde la cual recibimos la petición.
Así que al final tendríamos un log similar a este:
ERRO[0043] Unexpected error: Error has ocurred while finding gopher 01D3XZ7CN92AKS9HAPSZ4D5DP9 clientip=127.0.0.1 endpoint=/gophers/01D3XZ7CN92AKS9HAPSZ4D5DP9 logid=01DK2XFX9PQ85ZPZ5CP68P108Y serverid=GOPERAPI-01 xforwardedfor=127.0.0.01 xforwardedproto=http
Vale, ahora que ya tenemos el caso de uso claro, veamos como implementar todo esto, como sabéis en Friends of Go nos gusta respetar las buenas prácticas siempre que podemos y que tiene sentido, y ya que GopherAPI está siendo un proyecto evolutivo, así será.
Patrón contextKey
No creo que os pille por sorpresa si os digo que vamos a valernos del context
para poder dotar a nuestro log de toda esta información de manera “mágica”. ¿Pero cómo lo hacemos? hasta ahora solo hemos visto como propagar el contexto, pero en ningún momento hemos visto cómo asignarle valores.
Pues bien, os diré que el contexto sólo acepta un valor, es decir si tuviéramos valores diferentes serían contextos diferentes, ¿es decir todo lo que hemos hecho hasta ahora no sirve para absolutamente nada? No, claro que sirve, no perdáis los nervios.
El paquete context
nos ofrece la función, func WithValue(parent Context, key, val interface{}) Context
, la cual nos permite pasar un context, padre y asignar una clave y un valor, después para poder recuperar dicha información nos valdremos del método Value(key interface{}) interface{}
, el cual se recorrerá todos los contextos en busca de la clave que le hayamos pasado. Ahora todo tiene más sentido ¿no?
Bien, ahora es cuando nos toca asignar toda esa información que nos vendrá dada en su mayoría en la request
a nuestro context
. Obviamente una manera de hacer esto sería crearnos un método helper e ir poniéndolo en cada una de nuestras request
.
// FetchGopher return a gopher by ID
func (s *server) FetchGopher(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
ctx := createRequestContext(s.serverID, r.Context())
gopher, err := s.fetching.FetchGopherByID(ctx, vars["ID"])
...
}
func createRequestContext(serverID string, ctx context.Context) context.Context {
var (
xForwardedFor = req.Header.Get("X-FORWARDED-FOR")
xForwardedProto = req.Header.Get("X-FORWARDED-PROTO")
)
if xForwardedFor != "" {
ctx = context.WithValue(ctx, "contextkey_xforwardedfor", xForwardedFor)
}
if xForwardedProto != "" {
ctx = context.WithValue(ctx, "contextkey_xforwardedproto", xForwardedProto)
}
ip, _, _ := net.SplitHostPort(req.RemoteAddr)
ctx = context.WithValue(ctx, "contextkey_clientip", ip)
ctx = context.WithValue(ctx, "contextkey_endpoint", req.URL.RequestURI())
ctx = context.WithValue(ctx, "contextkey_serverid", serverID)
}
El
serverID
se establecerá una vez hagamos elNew
, el cual vendrá informado desde elmain.go
por variable de entorno o bien como flag como habíamos comentado, así que hay que arrastrarlo al contexto.
En el ejemplo anterior, vemos que ahora tenemos una función helper que nos ayuda a establecer ese contexto, pero creo que estaréis conmigo en que tiene algunas fallas. Primero, tendremos que acordarnos siempre que creamos un nuevo handler
incluir dicha función, segundo, si vemos esa función veremos que tenemos las claves a hardcode, cuando queramos hacer el get
de los valores en el log, tendremos que acordarnos de que clave pusimos, y cuando las modifiquemos recordar modificarla en ambos lados, vamos un percal.
Vamos a solucionar todo esto, por partes, primero hagamos que las claves y la obtención de los valores del contexto, dejen de ser un problema, podríamos ofrecer una serie de constantes dentro del paquete server para las claves; pero además tenemos un problema adicional, si recordáis cuando obtenemos un valor de un contexto, el método correspondiente nos devolverá una interfaz vacía, es decir tendremos que castear su valor al correspondiente, con lo cual tenemos el mismo problema que con las claves, tendremos que mantener el valor de los parámetros en lugares diferentes.
Un ejemplo, imaginemos que la ip
, dejamos de recibirla como string
para recibirla como int
, sería raro pero imaginarlo, ya tendríamos que recordar este cambio para que a la hora de consumir dicho recurso hagamos el casteo correspondiente, ya que el compilador no se quejará y nos petará en tiempo de ejecución.
Por ello existe un patrón muy extendido que es el patrón contextkey, muy fácil de implementar y realmente útil.
Este patrón consiste en tener un fichero por paquete (siempre que sea necesario) con el manejo de las key
y value
de nuestro contexto, por ejemplo nosotros hemos creado el fichero pkg/server/context.go
package server
import (
"context"
)
var (
contextKeyServerID = contextKey("id")
contextKeyXForwardedFor = contextKey("xForwardedFor")
contextKeyXForwardedProto = contextKey("xForwardedProto")
contextKeyEndpoint = contextKey("endpoint")
contextKeyClientIP = contextKey("clientIP")
)
type contextKey string
func (c contextKey) String() string {
return "server" + string(c)
}
// ID gets the name server from context
func ID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(contextKeyServerID).(string)
return id, ok
}
// XForwardedFor gets the http address server from context
func XForwardedFor(ctx context.Context) (string, bool) {
xForwardedFor, ok := ctx.Value(contextKeyXForwardedFor).(string)
return xForwardedFor, ok
}
// XForwardedProto gets the http address server from context
func XForwardedProto(ctx context.Context) (string, bool) {
xForwardedProto, ok := ctx.Value(contextKeyXForwardedProto).(string)
return xForwardedProto, ok
}
// Endpoint gets the http address server from context
func Endpoint(ctx context.Context) (string, bool) {
endpoint, ok := ctx.Value(contextKeyEndpoint).(string)
return endpoint, ok
}
// ClientIP gets the http address server from context
func ClientIP(ctx context.Context) (string, bool) {
clientIP, ok := ctx.Value(contextKeyClientIP).(string)
return clientIP, ok
}
Lo que haremos es crearnos un tipo que implementará la interfaz Stringer
type contextKey string
func (c contextKey) String() string {
return "server" + string(c)
}
Todas nuestras claves, ahora tendrán el formato que nosotros queramos cuando sean invocadas, y podremos cambiar dicho formato sin afectar a nuestro código. Por otro lado nuestras claves son privadas, permitiendo así que sólo se puedan hacer asignaciones y lecturas desde el propio paquete.
Por otro lado publicaremos una serie de Getters
, los cuales se encargaran de leer con la clave que toca dentro del contexto y publicar su valor e información de si no existe en la cadena de contextos.
Así pues podremos refactorizar nuestra función anterior utilizando las claves, nuevas pero seguimos con el problema, de que tendremos que repetir la llamada en cada request
. Esto lo solucionaremos con un Middleware
, un middleware
, no deja de ser un handler que se ejecutará siempre antes de nuestra request
.
// pkg/server/middleware.go
package server
import (
"context"
"net"
"net/http"
)
type handler struct {
serverID string
next http.Handler
}
func newServerMiddleware(serverID string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
h := &handler{
serverID: serverID,
next: next,
}
return h
}
}
// ServeHTTP implements http.Handler.
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := h.createRequestContext(r)
h.next.ServeHTTP(w, r.WithContext(ctx))
}
func (h handler) createRequestContext(req *http.Request) context.Context {
ctx := req.Context()
var (
xForwardedFor = req.Header.Get("X-FORWARDED-FOR")
xForwardedProto = req.Header.Get("X-FORWARDED-PROTO")
)
if xForwardedFor != "" {
ctx = context.WithValue(ctx, contextKeyXForwardedFor, xForwardedFor)
}
if xForwardedProto != "" {
ctx = context.WithValue(ctx, contextKeyXForwardedProto, xForwardedProto)
}
ip, _, _ := net.SplitHostPort(req.RemoteAddr)
ctx = context.WithValue(ctx, contextKeyClientIP, ip)
ctx = context.WithValue(ctx, contextKeyEndpoint, req.URL.RequestURI())
ctx = context.WithValue(ctx, contextKeyServerID, h.serverID)
return ctx
}
Luego en nuestro router, lo asignamos
func router(s *server) {
r := mux.NewRouter()
r.Use(newServerMiddleware(s.serverID))
r.HandleFunc("/gophers", s.FetchGophers).Methods(http.MethodGet)
r.HandleFunc("/gophers/{ID:[a-zA-Z0-9_]+}", s.FetchGopher).Methods(http.MethodGet)
r.HandleFunc("/gophers", s.AddGopher).Methods(http.MethodPost)
r.HandleFunc("/gophers/{ID:[a-zA-Z0-9_]+}", s.ModifyGopher).Methods(http.MethodPut)
r.HandleFunc("/gophers/{ID:[a-zA-Z0-9_]+}", s.RemoveGopher).Methods(http.MethodDelete)
s.router = r
}
Ahora ya hemos dotado a nuestro contexto de toda la información necesaria, simplemente tenemos que hacer que el logger
recoja dicha información y la imprima.
Para ello hemos modificado un poco el servicio, quedando así:
// FetchGopherByID returns a gopher
func (s *service) FetchGopherByID(ctx context.Context, ID string) *gopher.Gopher {
g, err := s.repository.FetchGopherByID(ctx, ID)
// This error can be any error type of our repository
if err != nil {
s.logger.UnexpectedError(ctx, err)
return nil
}
return g
}
Como veis nuestro service
ahora tiene una propiedad logger
, la cual se inicializará en el New
, en la que no entraremos en detalle pues no es proposito de este artículo, con esta propiedad podrá llamar a un método UnexpectedError(ctx context.Context, err error)
, el cual espera un context.Context
y un error
.
Tal como pedimos ahora no devolveremos el error, sino que simplemente lo logaremos, así que vamos a ver cual es la implementación del método UnexpectedError
, para la cual adelantamos que nos hemos servido de la librería logrus verdaderamente útil para temas de log en Go.
func (l *logger) UnexpectedError(ctx context.Context, err error) {
l.WithDefaultFields(ctx).WithField("logid", unexpectedErrorMessage.id).
Errorf(unexpectedErrorMessage.message, err)
}
func (l *logger) WithDefaultFields(ctx context.Context) *logrus.Entry {
serverID, _ := server.ID(ctx)
endpoint, _ := server.Endpoint(ctx)
clientIP, _ := server.ClientIP(ctx)
fields := logrus.Fields{
"serverid": serverID,
"endpoint": endpoint,
"clientip": clientIP,
}
if xForwardedFor, ok := server.XForwardedFor(ctx); ok {
fields["xforwardedfor"] = xForwardedFor
}
if xForwardedProto, ok := server.XForwardedProto(ctx); ok {
fields["xforwardedproto"] = xForwardedProto
}
return l.WithFields(fields)
}
El método aquí importante es, WithDefaultFields(ctx context.Context) *logrus.Entry
el cual se encargará de añadir a nuestro log, los datos que necesitamos desde el contexto, estos datos serán obtenidos utilizando los helpers
que creamos anteriormente.
Al final tendremos como resultado un log como el que comentamos al principio. En otro artículo entraremos en más detalle en como organizar, centralizar e implementar nuestros logs ya que es un tema apasionante y muy útil.
Conclusión
Sé que ha sido un artículo muy denso, y que tendréis muchas cosas a digerir, pero podéis encontrar el código entero en nuestro repositorio, en la versión 3.2 de GopherAPI. A partir de ahora podréis nutrir vuestros logs, con mucha más información sin tener que hacer diversas chapuzas o complicaros excesivamente la vida, creedme, hasta que no llegué a conocer dicho uso del context
, las pasaba canutas para poder sacar la simple información que os he propuesto en este caso de uso.
Ya sabéis que si tenéis cualquier duda o comentario podéis dejarlo en los comentarios o en nuestro Twitter @FriendsofGoTech.