Hace ya más de quince meses desde que escribimos nuestro primer artículo sobre cómo crear una API REST en Go, en el que dimos vida a uno de nuestros proyectos más populares en GitHub: friendsofgo/gopherapi. Desde entonces, han sido ya varias las implementaciones (MySQL, CockroachDB, in-memory…) que hemos hecho del repositorio principal de dicha aplicación. Sin embargo, hay un tipo de persistencia bastante habitual en el ecosistema Go (y en general en el mundo del desarrollo) del que aún no habíamos hablado: Redis. Y eso vamos a hacer hoy, extender nuestra API para añadirle soporte para Redis.
De nuevo, tal y como hicimos en el artículo de MySQL en Go, vamos a hacer una implementación del repositorio que podemos ver definido aquí, además veremos cómo hacerlo con un 100% de cobertura, no solo a nivel de tests unitarios sino que también veremos como podríamos hacer tests de integración. Para ello vamos a usar los siguientes paquetes:
Implementando el repositorio
Tras las presentaciones, ha llegado el momento de hacer la implementación de nuestro repositorio. Sin embargo, es importante recordar (especialmente para aquellas personas que nunca hayan trabajado con Redis) que Redis no es un sistema de persistencia tradicional (ya sea SQL o NoSQL), sino que está basado en un tipo de persistencia clave-valor. Esto tendrá algunas implicaciones que veremos a continuación.
Para dicha implementación, lo primero que vamos a hacer será ver cómo funciona la librería redigo.
Esta nos proporciona un tipo redis.Conn
sobre el que podremos ejecutar las consultas que queramos hacer contra Redis mediante el método
Do(commandName string, args ...interface{}) (reply interface{}, err error)
. Como podéis ver, el retorno (reply
)
de esta función es de tipo interface{}
, pero no os tenéis que asustar, pues la propia librería nos proporciona los
métodos necesarios para convertir/castear esa respuesta al tipo esperado, con métodos como los siguientes:
func String(reply interface{}, err error) (string, error)
func Bytes(reply interface{}, err error) ([]byte, error)
Sin embargo, hay que tener en cuenta que el tipo redis.Conn
no tiene soporte para concurrencia, así que lo que vamos
a hacer será trabajar con el tipo redis.Pool,
que básicamente nos proporcionará un pool de conexiones, cuya forma de
obtener una conexión será similar a:
conn, err := r.pool.GetContext(ctx)
if err != nil {
return err
}
Dicho todo esto, antes de pasar a la acción solo nos faltaría una última consideración que ya introducimos anteriormente. Redis es un tipo de persistencia basada en un sistema clave-valor, dónde ambos elementos suelen ser representados como una cadena de carácteres (string). Así que, lo que vamos a hacer será serializar y deserializar nuestros objetos en JSON para poder hacer uso de dicho almacenamiento. En otras palabras, el valor de cada uno de los elementos guardados será el string correspondiente al JSON del elemento guardado.
package redis
import (
"context"
"encoding/json"
"errors"
gopherapi "github.com/friendsofgo/gopherapi/pkg"
"github.com/gomodule/redigo/redis"
_ "github.com/lib/pq"
)
type gopherRepository struct {
pool *redis.Pool
}
// NewRepository instances a Redis implementation of the gopherapi.Repository
func NewRepository(pool *redis.Pool) gopherapi.Repository {
return gopherRepository{
pool: pool,
}
}
// CreateGopher satisfies the gopherapi.Repository interface
func (r gopherRepository) CreateGopher(ctx context.Context, gopher *gopherapi.Gopher) error {
bytes, err := json.Marshal(gopher)
if err != nil {
return err
}
conn, err := r.pool.GetContext(ctx)
if err != nil {
return err
}
_, err = conn.Do("SET", gopher.ID, string(bytes))
return err
}
Como podemos ver, con todas las consideraciones comentadas anteriormente, hacer la implementación del método CreateGopher
es realmente sencillo. De hecho, solo nos hace falta saber que el comando de Redis necesario para dicha operación es el
SET.
Un código muy similar podríamos hacer para los métodos de recuperación (FetchGopherByID
), actualización (UpdateGopher
)
y eliminación (DeleteGopher
) de los datos, mediante los comandos GET,
SET y DEL respectivamente,
como podéis ver aquí.
Sin embargo, el caso de recuperar todos los datos (FetchGophers
) es un poco más complejo, debido a como funciona
Redis internamente. En esta ocasión, lo que vamos a hacer será usar el comando KEYS
para recuperar todas las claves y luego usar el comando MGET.
Par ello, vamos a tener que considerar algunas cosas, como por ejemplo:
-
El método
KEYS
recibe como argumento una expresión regular que será usada para recuperar todas las claves, de forma que, si usamos la misma instancia de Redis para diferentes datos, deberemos hacer uso de dicha expresión para recuperar solo las claves que nos interesan (por ejemplo, en lugar deKEYS *
usarKEYS gophers.*
). -
El coste temporal del método
KEYS
es O(N), lo que significa que en realidad lo que está haciendo es recorrer todas las claves de nuestro Redis y ver si hacen match con la expresión regular pasada. Y que, por lo tanto, contra más claves tengamos, más lenta será esta consulta, proporcionalmente. -
El método
MGET
también tiene un coste temporal de O(N), con lo que debemos tener las mismas consideraciones que en el punto anterior, ya que en realidad dicha operación lo que nos permite es ahorrarnos N peticiones de red, pero, a nivel de Redis, será como hacer N veces unGET
.
Y, pese a que después de dichas consideraciones puede que hayamos llegado a la conclusión de que Redis no es un tipo de persistencia indicado para este caso de uso, podemos ver que, en efecto, sí es posible hacer una implementación completamente funcional:
func (r gopherRepository) FetchGophers(ctx context.Context) ([]gopherapi.Gopher, error) {
conn, err := r.pool.GetContext(ctx)
if err != nil {
return nil, err
}
keys, err := redis.Strings(conn.Do("KEYS", "*"))
if err != nil {
return nil, err
}
if len(keys) == 0 {
return []gopherapi.Gopher{}, nil
}
args := make([]interface{}, 0, len(keys))
for _, key := range keys {
args = append(args, key)
}
results, err := redis.Strings(conn.Do("MGET", args...))
if err != nil {
return nil, err
}
gophers := make([]gopherapi.Gopher, 0, len(results))
for _, result := range results {
gopher := gopherapi.Gopher{}
err := json.Unmarshal([]byte(result), &gopher)
if err != nil {
return nil, err
}
gophers = append(gophers, gopher)
}
return gophers, nil
}
Como podéis ver, las operaciones que realizamos para implementar dicho método son:
- Recuperar todas las claves (
KEYS *
) de los objetos que queremos recuperar. - Recuperar todos los valores (
MGET keys...
) de los objetos que queremos recuperar. - Deserializar todos los datos recuperados.
NOTA: Si bien es cierto que Redis proporciona mecanismos para optimizar las consultas de datos similares a FetchGophers
,
en este caso nos hemos centrado en mostrar como hacer dicho proceso de forma conceptualmente sencilla, pues la finalidad
de este artículo no es profundizar en Redis sino en como usar el cliente de Go y cómo testearlo.
Testeando nuestra implementación del repositorio
Ahora ya tenemos nuestra implementación así que ha llegado la hora de testearla (aunque idealmente deberíamos poder
hacerlo en el orden inverso). Para ello vamos a hacer uso de la librería redigomock,
que básicamente nos va a permitir abstraer el código de nuestra implementación de lo que es la librería redigo
en sí para,
de este modo, poder testear (unitariamente) nuestra lógica.
El uso de dicha librería es muy sencillo, pues básicamente lo que tendremos que hacer es definir qué llamada (parámetros)
esperamos y que resultado queremos que nos devuelva el mock, tanto el valor principal (Expect
) como el error (ExpectError
).
Además, como nuestro repositorio va a esperar un pool de conexiones, lo que vamos a hacer es un pequeño helper que nos permita encapsular dicho mock en un pool:
func wrapRedisConn(conn redis.Conn) *redis.Pool {
return &redis.Pool{
MaxIdle: 3,
IdleTimeout: 240 * time.Second,
Dial: func() (redis.Conn, error) { return conn, nil },
}
}
Entonces, ahora ya seríamos capaces de desarrollar nuestros tests, veamos un ejemplo:
func Test_GopherRepository_FetchGopherByID_Succeeded(t *testing.T) {
gopherID := "123ABC"
expectedGopher := buildGopher(gopherID)
conn := redigomock.NewConn()
conn.Command("GET", gopherID).Expect(gopherToJSONString(expectedGopher))
repo := NewRepository(wrapRedisConn(conn))
gopher, err := repo.FetchGopherByID(context.Background(), gopherID)
assert.NoError(t, err)
assert.NoError(t, conn.ExpectationsWereMet())
assert.Equal(t, &expectedGopher, gopher)
}
Esta librería (el mock en sí), incluso nos va a permitir simular situaciones en las que la conexión con el Redis falle o en las que los datos almacenados en el mismo estén corruptos. De este modo nos será muy fácil lograr un 100% de cobertura con nuestros tests y, por lo tanto, tenerlo todo controlado para que nada nos sorprenda. Aquí podéis ver el ejemplo completo con todos los tests de nuestro repositorio.
Miniredis
Estéis más o menos acostumbrados a escribir tests unitarios de código de infraestructura (bases de datos, controladores HTTP, etc),
probablemente algunos de vosotros estáis pensando: Vale, pero ¿cómo sé que todo esto funciona? Si bien, lo lógico es confiar en nuestros
tests, en este caso nosotros hemos definido qué nos tenía que devolver cada uno de los mocks definidos sin realmente saber qué
es lo que nos devuelve la implementación “real” de la librería redigo
.
Así que, aún nos queda una alternativa más para realmente garantizar que nuestra implementación funciona correctamente. Esta alternativa es usar la librería miniredis, que básicamente es una implementación en memoria (in-memory) compatible con Redis. Si bien no tiene soporte para absolutamente todos los comandos de Redis, sí que tiene cubierto un porcentaje muy alto de ellos, así que, para esta ocasión, nos será más que suficiente.
Para ello solo necesitamos una instancia de miniredis
y hacer uso del
NewConn
del propio repositorio:
s, err := miniredis.Run()
if err != nil {
panic(err)
}
defer s.Close()
repo := NewRepository(NewConn(s.Addr()))
Y ahora sí, ya estamos listos para probar nuestro repositorio contra una “implementación real” de Redis:
func Test_GopherRepository_Example(t *testing.T) {
// GIVEN a miniredis instance and a Redis implementation of result.Repository
s, err := miniredis.Run()
if err != nil {
panic(err)
}
defer s.Close()
repo := NewRepository(NewConn(s.Addr()))
// WHEN two gophers are created
gopherA, gopherB := buildGopher("123ABC"), buildGopher("ABC123")
err = repo.CreateGopher(context.Background(), &gopherA)
assert.NoError(t, err)
err = repo.CreateGopher(context.Background(), &gopherB)
assert.NoError(t, err)
// THEN they can be fetched by ID
result, err := repo.FetchGopherByID(context.Background(), gopherA.ID)
assert.NoError(t, err)
assert.Equal(t, gopherA, *result)
result, err = repo.FetchGopherByID(context.Background(), gopherB.ID)
assert.NoError(t, err)
assert.Equal(t, gopherB, *result)
// AND they can be fetched in batch
results, err := repo.FetchGophers(context.Background())
assert.NoError(t, err)
assert.Equal(t, []gopher.Gopher{gopherA, gopherB}, results)
}
Y como siempre, estaremos muy agradecidos de que nos dejéis vuestras experiencias personales, dudas o sugerencias en los comentarios o en nuestra cuenta de Twitter.