Rate limiting de la API con Traefik, Docker, Go y Caching

Limitar el uso de la API basándose en una regla avanzada de limitación de velocidad no es tan fácil. Para lograr esto detrás de la API NLP Cloud, estamos utilizando una combinación de Traefik (como proxy inverso) y el almacenamiento en caché local dentro de un script Go. Cuando se hace correctamente, se puede mejorar considerablemente el rendimiento de la limitación de la tasa y acelerar adecuadamente las solicitudes de la API sin sacrificar la velocidad de las solicitudes.

En este ejemplo mostramos cómo delegar la limitación de velocidad de cada petición de la API a un microservicio dedicado gracias a Traefik y Docker. Luego, en este microservicio dedicado, contaremos el número de peticiones realizadas recientemente para autorizar o no la nueva petición.

Traefik como reverse proxy

Para montar una pasarela API, Traefik y Docker son una muy buena combinación.

Traefik

La idea es que todas las solicitudes de la API se dirijan primero a un contenedor Docker que contenga una instancia de Traefik. Esta instancia de Traefik actúa como un proxy inverso, por lo que hará cosas como la autenticación, el filtrado, el reintento, … y finalmente el enrutamiento de la solicitud del usuario al contenedor correcto.

Por ejemplo, si estás haciendo una solicitud de resumen de texto en NLP Cloud, primero pasarás por la puerta de enlace de la API que se encargará de autenticar tu solicitud y, si se autentica con éxito, tu solicitud se dirigirá a un modelo de aprendizaje automático de resumen de texto contenido en un contenedor Docker dedicado alojado en un servidor específico.

Tanto Traefik como Docker son fáciles de usar, y hacen que su programa sea bastante fácil de mantener.

¿Por qué usar Go?

Un script de limitación de velocidad tendrá que manejar necesariamente un gran volumen de peticiones concurrentes.

Go es un buen candidato para este tipo de aplicaciones, ya que procesa sus peticiones muy rápidamente, y sin consumir demasiada CPU y RAM.

Tanto Traefik como Docker fueron escritos en Go, lo que no debe ser una coincidencia…

Una implementación ingenua sería utilizar la base de datos para almacenar el uso de la API, contar las solicitudes pasadas de los usuarios, y limitar las solicitudes en base a eso. Rápidamente surgirán problemas de rendimiento, ya que hacer una petición a la base de datos cada vez que se quiera comprobar una petición saturará la base de datos y creará toneladas de accesos a la red innecesarios. La mejor solución es gestionar esto localmente en la memoria. La otra cara de la moneda, por supuesto, es que los contadores en memoria no son persistentes: si reinicias tu aplicación de limitación de velocidad, perderás todos tus contadores en curso. En teoría, no debería ser un gran problema para una aplicación de limitación de velocidad.

Delegación de la limitación de la tasa de la API a un microservicio dedicado gracias a Traefik y Docker

Traefik tiene muchas características interesantes. Una de ellas es la posibilidad de reenviar la autenticación a un servicio dedicado.

Traefik Auth Forwarding

Básicamente, cada solicitud de API entrante se reenvía primero a un servicio dedicado. Si este servicio devuelve un código 2XX, entonces la solicitud se enruta al servicio adecuado, de lo contrario se rechaza.

En el siguiente ejemplo, utilizaremos un archivo Docker Compose para un clúster Docker Swarm. Si estás usando otro orquestador de contenedores como Kubernetes, Traefik también funcionará muy bien.

Primero, crea un archivo Docker Compose para tu punto final de la API y habilita Traefik:

version: "3.8"

services:
  traefik:
    image: "traefik"
    command:
      - --providers.docker.swarmmode
  api_endpoint:
    image: path_to_api_endpoint_image
    deploy:
      labels:
        - traefik.http.routers.api_endpoint.entrypoints=http
        - traefik.http.services.api_endpoint.loadbalancer.server.port=80
        - traefik.http.routers.api_endpoint.rule=Host(`example.com`) && PathPrefix(`/api-endpoint`)

A continuación, añada un nuevo servicio dedicado a la limitación de velocidad y pida a Traefik que le reenvíe todas las peticiones (codificaremos este servicio de limitación de velocidad Go un poco más tarde):

version: "3.8"

services:
  traefik:
    image: traefik
    command:
      - --providers.docker.swarmmode
  api_endpoint:
    image: path_to_your_api_endpoint_image
    deploy:
      labels:
        - traefik.http.routers.api_endpoint.entrypoints=http
        - traefik.http.services.api_endpoint.loadbalancer.server.port=80
        - traefik.http.routers.api_endpoint.rule=Host(`example.com`) && PathPrefix(`/api-endpoint`)
        - traefik.http.middlewares.forward_auth_api_endpoint.forwardauth.address=http://rate_limiting:8080
        - traefik.http.routers.api_endpoint.middlewares=forward_auth_api_endpoint
  rate_limiting:
    image: path_to_your_rate_limiting_image
    deploy:
      labels:
        - traefik.http.routers.rate_limiting.entrypoints=http
        - traefik.http.services.rate_limiting.loadbalancer.server.port=8080

Ahora tenemos una configuración completa de Docker Swarm + Traefik que primero reenvía las solicitudes a un servicio de limitación de velocidad antes de enrutar finalmente la solicitud al punto final de la API. Puedes poner lo anterior en un archivo production.yml e iniciar la aplicación con el siguiente comando:

docker stack deploy --with-registry-auth -c production.yml application_name

Tenga en cuenta que sólo se reenvían las cabeceras de las peticiones, no el contenido de las mismas. Esto es por razones de rendimiento. Así que si quieres autenticar una solicitud basándote en el cuerpo de la misma, tendrás que idear otra estrategia.

Gestión de la limitación de la velocidad con Go y el almacenamiento en caché

Las configuraciones de Traefik y Docker están listas. Ahora tenemos que codificar el microservicio Go que se encargará de limitar la velocidad de las peticiones: los usuarios sólo tienen derecho a 10 peticiones por minuto. Por encima de 10 peticiones por minuto, cada petición será rechazada con un código HTTP 429.

package main

import (
  "fmt"
  "time"
  "log"
  "net/http"

  "github.com/gorilla/mux"
  "github.com/patrickmn/go-cache"
)

var c *cache.Cache

// updateUsage increments the API calls in local cache.
func updateUsage(token) {
  // We first try to increment the counter for this user.
  // If there is no existing counter, an error is returned, and in that
  // case we create a new counter with a 3 minute expiry (we don't want
  // old counters to stay in memory forever).
  _, err := c.IncrementInt(fmt.Sprintf("%v/%v", token, time.Now().Minute()), 1)
  if err != nil {
  c.Set(fmt.Sprintf("%v/%v", token, time.Now().Minute()), 1, 3*time.Minute)
  }
}

func RateLimitingHandler(w http.ResponseWriter, r *http.Request) {
  // Retrieve user API token from request headers.
  // Not implemented here for the sake of simplicity.
  apiToken := retrieveAPIToken(r)
  
  var count int

  if x, found := c.Get(fmt.Sprintf("%v/%v", apiToken, time.Now().Minute())); found {
    count = x.(int)
  }

  if count >= 10 {
    w.WriteHeader(http.StatusTooManyRequests)
    return
  }

  updateUsage(apiToken)

  w.WriteHeader(http.StatusOK)
}

func main() {
 r := mux.NewRouter()
 r.HandleFunc("/", RateLimitingHandler)

 log.Println("API is ready and listening on 8080.")

 log.Fatal(http.ListenAndServe(":8080", r))
}

Como puedes ver, estamos usando el toolkit Gorilla para crear una pequeña API, escuchando en el puerto 8080, que recibirá la petición enviada por Traefik.

Una vez recibida la petición, extraemos el token de usuario de la API de la petición (no se implementa aquí por simplicidad), y comprobamos el número de peticiones realizadas por el usuario asociado a este token de la API durante el último minuto.

El contador de peticiones se almacena en memoria gracias a la librería go-cache. Go-cache es una librería de caché minimalista para Go que es muy similar a Redis. Maneja automáticamente cosas importantes como la expiración de la caché. Almacenar los contadores de la API en memoria es crucial ya que es la solución más rápida, y queremos que este código sea lo más rápido posible para no ralentizar demasiado las peticiones de la API.

Si el usuario ha realizado más de 10 peticiones durante el minuto actual, la petición es rechazada con un código de error HTTP 429. Traefik verá que este error 429 no es un código 2XX, por lo que no permitirá que la solicitud del usuario llegue al punto final de la API, y propagará el error 429 al usuario.

Si la solicitud no está limitada por la tasa, automáticamente incrementamos el contador para este usuario.

Te recomiendo que despliegues esta aplicación Go dentro de un simple contenedor “scratch” (FROM scratch): es la forma más ligera de desplegar binarios Go con Docker.

Conclusión

Como puedes ver, implementar una pasarela de limitación de velocidad para tu API no es tan difícil, gracias a Traefik, Docker y Go.

Por supuesto, la limitación de la tasa basada en un número de solicitudes por minuto es sólo un primer paso. Puede que quieras hacer cosas más avanzadas como:

  • Limitar la tasa por minuto, por hora, por día y por mes.
  • Limitar la tasa por punto final de la API
  • Tener un límite de tarifa variable por usuario dependiendo del plan al que esté suscrito
  • Comprobar la concurrencia

¡Hay tantas cosas interesantes que no podemos mencionar en este artículo!

Si tiene preguntas, no dude en ponerse en contacto conmigo.

Also available in English | Existe aussi en français
Análisis de la API con series temporales gracias a TimescaleDB

El seguimiento del uso de la API puede ser todo un reto técnico debido a la alta velocidad y el volumen de solicitudes. Sin embargo, tener un análisis preciso de su API es crucial, especialmente si depende de ella para facturar a sus clientes. Es posible ser rápido y preciso con una base de datos de series temporales llamada TimescaleDB. De hecho, esta es la solución que implementamos detrás de NLP Cloud.

¿Qué es el análisis de la API y por qué es difícil?

El análisis de la API consiste en recuperar diversas métricas relacionadas con el uso de su API.

Por ejemplo, detrás de la API NLP Cloud queremos saber lo siguiente:

  • Cuántas solicitudes se hicieron durante el último minuto, la última hora, el último día, el último mes y el último año
  • Cuántas solicitudes se hicieron por punto final de la API y por usuario
  • Cuántas palabras fueron generadas por nuestros modelos NLP de generación de texto (como GPT-J)
  • Cuántos caracteres fueron utilizados por nuestro complemento de NLP multilingüe

Todas estas métricas son importantes para entender mejor cómo utilizan nuestra API nuestros clientes. Sin estos datos, no podemos saber qué modelos de PNL son los más utilizados, quiénes son nuestros clientes más importantes, etc.

Pero lo más importante es que algunas de estas métricas se utilizan para la facturación. Por ejemplo, a los clientes suscritos a un plan de “pago por uso” se les cobra en función del número de palabras que han generado con nuestra API.

Un volumen muy elevado de datos pasa por nuestra pasarela API, lo que supone un reto en términos de rendimiento. Es muy fácil que la API se ralentice o que se pierdan algunos datos.

Por lo tanto, es crucial que dicho sistema de análisis de la API sea rápido y fiable.

TimescaleDB al rescate

TimescaleDB es una base de datos PostgreSQL optimizada para series temporales.

TimescaleDB

Básicamente, Timescale está optimizado para un alto volumen de escrituras atómicas. Es perfecto para un caso de uso en el que se escriben toneladas de datos de forma muy regular, pero casi nunca se alteran estos datos, y sólo se leen los datos ocasionalmente.

Timescale viene con interesantes herramientas que facilitan las series temporales. Por ejemplo, tienen los llamados “agregados continuos”. Estos agregados son una forma de “muestrear” automáticamente sus datos de forma regular. El muestreo descendente significa que se eliminan los datos antiguos después de un tiempo y sólo se conservan algunos agregados de estos datos (basados en sumas, recuentos, promedios, etc.). Es crucial por dos razones:

  • Las series temporales pueden crecer muy rápidamente, por lo que es una muy buena forma de ahorrar espacio en disco
  • La lectura de una tabla repleta de datos puede ser dolorosamente lenta. Es mucho más fácil leer los datos de una tabla agregada que contiene menos datos.

A diferencia de otras soluciones como InfluxDB, TimescaleDB es una solución puramente SQL, por lo que la curva de aprendizaje es bastante baja, y hará la integración mucho más fácil. Por ejemplo, en NLP Cloud estamos interactuando con TimescaleDB tanto en aplicaciones Python como Go y podemos utilizar nuestras librerías PostgreSQL habituales.

Instalación

Puedes instalar TimescaleDB como un paquete de sistema, pero es más sencillo instalarlo como un contenedor Docker.

En primer lugar, extraiga la imagen Docker:

docker pull timescale/timescaledb:latest-pg14

A continuación, inicie su contenedor y pase una contraseña para su DB:

docker run -d --name timescaledb -p 5432:5432 -e POSTGRES_PASSWORD=password timescale/timescaledb:latest-pg14

Estructura de datos en TimescaleDB

En este ejemplo, queremos almacenar las peticiones de la API. Queremos que cada solicitud contenga lo siguiente:

  • La hora de la solicitud
  • El ID del usuario que hizo la solicitud
  • El punto final de la API utilizado durante la solicitud

La primera vez que lances TimescaleDB, tendrás que crear varias cosas.

En primer lugar, iniciar la extensión de TimescaleDB.

CREATE EXTENSION IF NOT EXISTS timescaledb;

Crear la tabla que almacenará las peticiones de la API, como haríamos en cualquier BD de PostgreSQL:

CREATE TABLE IF NOT EXISTS api_calls (
  time TIMESTAMPTZ NOT NULL,
  user_id TEXT NOT NULL,
  endpoint TEXT NOT NULL
);

A continuación, creamos una llamada “hipertabla” a partir de ella:

SELECT create_hypertable('api_calls', 'time', if_not_exists => TRUE);

Las hipertablas son el corazón de TimescaleDB. Añaden automáticamente muchas cosas inteligentes con el fin de gestionar sus datos de manera eficiente.

Ahora creamos una vista específica de su tabla api_calls llamada api_calls_per_hour. Es una vista que almacenará los datos agregados procedentes de api_calls. Cada hora, el número de peticiones de la API en api_calls será contado y puesto en api_calls_per_hour. La vista será mucho más rápida de consultar ya que contiene muchos menos datos que la tabla inicial api_calls.

CREATE MATERIALIZED VIEW IF NOT EXISTS api_calls_per_hour
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', time) as bucket, user_id, endpoint,
COUNT(time)
FROM api_calls
GROUP BY bucket, user_id, endpoint;

Por último, creamos una política de agregación continua y una política de retención. Ambas serán gestionadas por trabajadores en segundo plano. La mayoría de las veces todo funciona bien, pero si empiezas a tener muchas políticas puedes quedarte sin trabajadores en segundo plano y verás algunos mensajes de error en tus registros. En ese caso, el truco es aumentar el número de trabajadores en segundo plano en /var/lib/postgresql/data/postgresql.conf.

La política de agregación continua se encargará de muestrear regularmente los datos de api_calls y ponerlos en api_calls_per_hour. La política de retención se encargará de borrar los datos antiguos de api_calls para que nunca te quedes sin espacio en el disco:

SELECT add_continuous_aggregate_policy('api_calls_per_hour',
  start_offset => INTERVAL '1 day',
  end_offset => INTERVAL '1 hour',
  schedule_interval => INTERVAL '1 minute',
  if_not_exists => TRUE);

SELECT add_retention_policy('api_calls', INTERVAL '90 days', if_not_exists => TRUE);

Como puedes ver, no era demasiado complejo.

Inserción de datos

En su aplicación, ahora puede conectarse a su base de datos de Timescale e insertar solicitudes.

Por ejemplo, así es como lo harías en Python:

import psycopg2

conn = psycopg2.connect(
  "host=timescaledb dbname={} user={} password={}".format("name", "user", "password"))
cur = conn.cursor()
cur.execute("INSERT INTO api_calls (time, user_id, endpoint) VALUES (%s, %s, %s)",
  (datetime.now(), "1584586", "/v1/gpu/bart-large-cnn/summarization"))
conn.commit()
cur.close()
conn.close()

Y ahora en Go:

import (
  "github.com/jackc/pgx/v4"
  "github.com/jackc/pgx/v4/pgxpool"
)

func main(){
timescaledbURL := fmt.Sprintf("postgres://%s:%s@timescaledb:5432/%s", "user", "password", "name")
timescaledbDatabase, err := pgxpool.Connect(context.Background(), timescaledbURL)
if err != nil {
  log.Fatalf("Cannot connect to TimescaleDB database: %v. Stopping here.", err)
}

query := `INSERT into api_calls (time, user_id, endpoint) VALUES ($1, $2, $3)`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  defer cancel()

  _, err := timescaledbDatabase.Exec(ctx, query, time.Now(), "1584586", "/v1/gpu/bart-large-cnn/summarization")
  if err != nil {
    log.Printf("Cannot insert metric in TimescaleDB: %v", err)
  }
}

Punto importante: lo más probable es que no quieras ralentizar las peticiones de la API del usuario debido a un procesamiento potencialmente lento en el lado de TimescaleDB. La solución es insertar sus datos de forma asíncrona, de modo que la respuesta de la API del usuario regrese incluso si los datos no están insertados en su DB todavía. Pero esto está más allá del alcance de este artículo.

Para mejorar el rendimiento, también puedes insertar varias solicitudes de la API a la vez. La idea es que primero se necesita almacenar en caché algunas solicitudes en la memoria, y luego guardar muchas de ellas en la DB a la vez después de algún tiempo.

Visualización de datos

Existen muchas herramientas de visualización de datos. A mí me gusta Grafana porque es fácil conectarla a TimescaleDB, y las capacidades de los gráficos son innumerables.

Aquí hay un buen tutorial sobre cómo configurar TimescaleDB con Grafana: verlo aquí.

Grafana

Conclusión

TimescaleDB es una poderosa herramienta para las series de tiempo, y es una gran solución si quieres analizar adecuadamente el uso de tu API.

Como puedes ver, configurar y utilizar TimescaleDB es bastante fácil. Sin embargo, tenga cuidado: TimescaleDB puede utilizar rápidamente mucha memoria RAM, así que asegúrate de tenerlo en cuenta antes de aprovisionar tu instancia de servidor.

Si tienes alguna pregunta, no dudes en preguntar.

Also available in English | Existe aussi en français
CTOs, desarrolladores: ¿cómo elegir una buena API externa?

Hoy en día, encontrar una API externa que nos permita mejorar la calidad de nuestro servicio es muy fácil. Cada día mas empresas ponen a disposición APIs. Problema: numerosos desarrolladores/CTOs empiezan la integración, ¡mientras que debería de ser la ultima etapa! Antes de eso usted tiene que determinar si la calidad de la API basta. Aquí esta como hago yo. Espero que todo ello ayudara a otros CTOs y desarrolladores.

Calidad de los datos

Muchas APIs proveen datos que le permiten enriquecer su propio sistema (no es el caso de todas las APIs por supuesto, Stripe no es una API de enriquecimiento por ejemplo). Es indispensable que se asegure de la calidad de estos datos. Va a tomarle mucho tiempo, ¡y ya sé que no le gustan las pruebas! Yo tampoco, pero no puede evitar la creación de un escenario de prueba riguroso aquí. Si se da cuenta de que la calidad de sus datos no esta bastante buena, solo dos semanas después de haber terminado su integración, se arrepentirá…

Documentación

Recientemente me encontré una API que proveía datos de alta calidad (mucho mejor que lo que la competencia proponía para mí), pero su documentación era… ¡una pesadilla! En realidad no había documentación. Además la API no respetaba todas la convenciones REST. ¿Cómo puede lograrlo integrar una API externa si los códigos de error no están correctamente documentados? Entonces la única solución que queda es probar mucho la API para entender su funcionamiento. La ingeniería inversa puede ser graciosa pero necesita mucho tiempo. Recuerde que en lo que concierne una API no tiene repositorio GitHub a explorar ya que el código fuente no esta disponible… Una mala documentación le hace perder mucho tiempo al dev y trae sorpresas desagradables a medio plazo.

Bibliotecas

¿Es posible integrar la API gracias a una biblioteca disponible en su lenguaje favorito? Como desarrollador Python y Go siempre estoy encantado cuando me encuentro una API que ofrece una lib Python (sé que puedo olvidar Go por el momento). Le puede hacer ganar mucho tiempo, pero ante todo asegúrese de que la biblioteca esté madura y que cubra todas la funcionalidades de la API (no siempre es el caso).

Notoriedad de la empresa

La notoriedad puede ayudarle a aclarar su elección y evitar las sorpresas desagradables con su API en el futuro. Por “sorpresa desagradable” entiendo interrupción de servicio, regresión, o incluso la suspensión definitiva del servicio… Puede en parte evitar estas trampas preguntándose lo siguiente:

  • ¿Esta API es popular en Internet (en general si encuentra poca información, huya)? ¿Encuentra muchos artículos/tutoriales que hablan de la API? Estos artículos son elogiosos?
  • ¿Empresas populares la utilizan?
  • Si la empresa ha desarrollado bibliotecas dedicadas, ¿estan evaluadas de forma positiva en GitHub? ¿Los problemas reportados en GitHub están tratados con regularidad?
  • ¿Hubieron actualizaciones recientes de la API o la ultima actualización tuvo lugar hace mucho tiempo?

Soporte técnico

Asegúrese de que alguien responda a sus preguntas rápidamente por email cuando usted encuentra un problema y que la respuesta es relevante. Si vive en Europa y la API esta proveída por una empresa americana, asegúrese de que el desfase horario no sea un problema.

Respeto de las convenciones

Para mi, las APIs serias hoy tienen que ser RESTful. Si la API que le gusta no respeta la convenciones REST, pues desconfíe de esta API. Sin embargo tenga presente que el estándar REST no es perfectamente claro y que cada API puede tener sur propia variante (códigos HTTP, codificación de las consultas POST, …). A pesar de todo, tiene que leer la documentación atentamente y asegurarse de que no nota cosas demasiado originales. Originalidad le ralentizara…

Precio

Claro el precio es muy importante. Pero cuidase, la tarificación de una API no siempre es fácil de entender. ¿Va a pagar cada mes por un numero ilimitado de consultas? ¿Pagar por cada consulta? En este segundo caso, ¿va a pagar dos veces por dos consultas idénticas (caso de una API de enriquecimiento)?, ¿o la segunda consulta será gratis? ¿Va a pagar por una consulta que no retorna ningún resultado (HTTP 404)? Asegúrese de que lo entiende bien todo.

Calidad de servicio (QoS)

La calidad de servicio importa mucho. Su meta es trabajar con una API la mas rápida posible y con pocas interrupciones. Lamentablemente no se trata de desempeños fáciles de probar. En efecto la calidad de servicio cambia mucho con el tiempo, y numerosas APIs ofrecen dos niveles de QoS diferentes según utiliza la versión gratis o pagada… A veces incluso puede elegir diferentes suscripciones con diferentes tiempos de respuesta.

Soporte de las consultas paralelas

Según la manera de integrar la API, quizás tenga ganas de acelerar las cosas consultando la API con varias consultas simultaneas en lugar de la configuración secuencial clásica. Yo utilizo Golang con ese fin. Pero cuídese: muchas APIs no soportan las consultas paralelas y cuando las soportan imponen sistemáticamente una limite. En este caso asegúrese de pedir que es esta limite (no siempre esta mencionado en la doc) y adapte su programa.

Este articulo sera un buen memo para mi, ¡espero que para usted también!

Also available in English | Existe aussi en français
Utilización de una API REST: Go vs Python

Se encuentran API en todas partes hoy en día. Imagine: quiere recoger información sobre sus clientes potenciales gracias a sus emails. Bueno hay una API para hacer esto. ¿Necesita geocodar una dirección? También existe una API para hacer esto. Como desarrollador, integro regularmente nuevas API en mi sistema, en Python o en Go. Los dos métodos son bastante diferentes. Comparemolos trabajando sobre un caso “borderline”: enviar datos JSON en el body de una consulta POST.

Un ejemplo real

Recientemente utilicé la API NameAPI.org para separar un nombre entero en nombre y apellido, y conocer el género de la persona.

Su API espera datos JSON puestos en el body de una consulta POST. Ademas, el Content-Type de la consulta tiene que ser application/json y no multipart/form-data. Se trata de un caso especial porque en general se envían los datos POST a través del header de la consulta, y si se quiere enviarlos en el body de la consulta (en el caso de datos JSON complejos por ejemplo) el Content-Type común es multipart/form-data.

Aquí esta el JSON que se quiere enviar:

{
  "inputPerson" : {
    "type" : "NaturalInputPerson",
    "personName" : {
      "nameFields" : [ {
        "string" : "Petra",
        "fieldType" : "GIVENNAME"
      }, {
        "string" : "Meyer",
        "fieldType" : "SURNAME"
      } ]
    },
    "gender" : "UNKNOWN"
  }
}

Se puede hacerlo fácilmente con cURL:

curl -H "Content-Type: application/json" \
-X POST \
-d '{"inputPerson":{"type":"NaturalInputPerson","personName":{"nameFields":[{"string":"Petra Meyer","fieldType":"FULLNAME"}]}}}' \
http://rc50-api.nameapi.org/rest/v5.0/parser/personnameparser?apiKey=<API-KEY>

Y aquí esta la respuesta JSON de NameAPI.org:

{
"matches" : [ {
  "parsedPerson" : {
    "personType" : "NATURAL",
    "personRole" : "PRIMARY",
    "mailingPersonRoles" : [ "ADDRESSEE" ],
    "gender" : {
      "gender" : "MALE",
      "confidence" : 0.9111111111111111
    },
    "addressingGivenName" : "Petra",
    "addressingSurname" : "Meyer",
    "outputPersonName" : {
      "terms" : [ {
        "string" : "Petra",
        "termType" : "GIVENNAME"
      },{
        "string" : "Meyer",
        "termType" : "SURNAME"
      } ]
    }
  },
  "parserDisputes" : [ ],
  "likeliness" : 0.926699401733102,
  "confidence" : 0.7536487758945387
}

¡Ahora veamos como hacer esto en Go y en Python!

Realización en Go

Código

/*
Fetch the NameAPI.org REST API and turn JSON response into a Go struct.

Sent data have to be JSON data encoded into request body.
Send request headers must be set to 'application/json'.
*/

package main

import (
    "encoding/json"
    "io/ioutil"
    "log"
    "net/http"
    "strings"
)

// url of the NameAPI.org endpoint:
const (
    url = "http://rc50-api.nameapi.org/rest/v5.0/parser/personnameparser?" +
        "apiKey=<API-KEY>"
)

func main() {

    // JSON string to be sent to NameAPI.org:
    jsonString := `{
        "inputPerson": {
            "type": "NaturalInputPerson",
            "personName": {
                "nameFields": [
                    {
                        "string": "Petra",
                        "fieldType": "GIVENNAME"
                    }, {
                        "string": "Meyer",
                        "fieldType": "SURNAME"
                    }
                ]
            },
            "gender": "UNKNOWN"
        }
    }`
    // Convert JSON string to NewReader (expected by NewRequest)
    jsonBody := strings.NewReader(jsonString)

    // Need to create a client in order to modify headers
    // and set content-type to 'application/json':
    client := &http.Client{}
    req, err := http.NewRequest("POST", url, jsonBody)
    if err != nil {
        log.Println(err)
    }
    req.Header.Add("Content-Type", "application/json")
    resp, err := client.Do(req)

    // Proceed only if no error:
    switch {
    default:
        // Create a struct dedicated to receiving the fetched
        // JSON content:
        type Level5 struct {
            String   string `json:"string"`
            TermType string `json:"termType"`
        }
        type Level41 struct {
            Gender     string  `json:"gender"`
            Confidence float64 `json:"confidence"`
        }
        type Level42 struct {
            Terms []Level5 `json:"terms"`
        }
        type Level3 struct {
            Gender           Level41 `json:"gender"`
            OutputPersonName Level42 `json:"outputPersonName"`
        }
        type Level2 struct {
            ParsedPerson Level3 `json:"parsedPerson"`
        }
        type RespContent struct {
            Matches []Level2 `json:"matches"`
        }

        // Decode fetched JSON and put it into respContent:
        respContentBytes, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            log.Println(err)
        }
        var respContent RespContent
        err = json.Unmarshal(respContentBytes, &respContent)
        if err != nil {
            log.Println(err)
        }
        log.Println(respContent)
    case err != nil:
        log.Println("Network error:", err)
    case resp.StatusCode != 200:
        log.Println("Bad HTTP status code:", err)
    }

}

Explicaciones

Nos enfrentamos a 2 problemas:

  • tenemos que utilizar http.Client, NewRequest(), client.Do(req), y req.Header.Add("Content-Type", "application/json") par poner datos en el body y cambiar el Content-Type. Son muchas etapas.
  • recibir el JSON de NameAPI en Go es difícil porque tenemos que crear un struct que tenga la misma estructura que el JSON.

Realización en Python

Código

"""
Fetch the NameAPI.org REST API and turn JSON response into Python dict.

Sent data have to be JSON data encoded into request body.
Send request headers must be set to 'application/json'.
"""

import requests

# url of the NameAPI.org endpoint:
url = (
    "http://rc50-api.nameapi.org/rest/v5.0/parser/personnameparser?"
    "apiKey=<API-KEY>"
)

# Dict of data to be sent to NameAPI.org:
payload = {
    "inputPerson": {
        "type": "NaturalInputPerson",
        "personName": {
            "nameFields": [
                {
                    "string": "Petra",
                    "fieldType": "GIVENNAME"
                }, {
                    "string": "Meyer",
                    "fieldType": "SURNAME"
                }
            ]
        },
        "gender": "UNKNOWN"
    }
}

# Proceed, only if no error:
try:
    # Send request to NameAPI.org by doing the following:
    # - make a POST HTTP request
    # - encode the Python payload dict to JSON
    # - pass the JSON to request body
    # - set header's 'Content-Type' to 'application/json' instead of
    #   default 'multipart/form-data'
    resp = requests.post(url, json=payload)
    resp.raise_for_status()
    # Decode JSON response into a Python dict:
    resp_dict = resp.json()
    print(resp_dict)
except requests.exceptions.HTTPError as e:
    print("Bad HTTP status code:", e)
except requests.exceptions.RequestException as e:
    print("Network error:", e)

Explicaciones

¡La biblioteca Request lo hace casi todo en una sola linea: resp = requests.post(url, json=payload)!

Recibir el JSON retornado por NameAPI se hace en una linea también: resp_dict = resp.json().

Conclusión

Python es el ganador. La simplicidad de Python y su cantidad de bibliotecas disponibles nos ayudan mucho.

Aquí no hablamos del desempeño. Si el desempeño de la integracion que hace es importante para usted, Go puede ser una muy buena elección. Pero simplicidad y desempeño no están compatibles…

Also available in English | Existe aussi en français
¿Cómo acelerar el web scraping con Go (Golang) y concurrencia?

Desarrollo web scrapers en Python desde varios años. La simplicidad de Python permite realizar prototipos rápidos y sus numerosas bibliotecas son muy útiles para el scraping y el parsing de los resultados (Requests, Beautiful Soup, Scrapy, …). Sin embargo, cuando se empieza a interesarse al desempeño de su scraper, Python tiene limites y Go nos ayuda mucho.

¿Porqué Go?

Cuando se trata de acelerar la recuperación de información desde el web (para scraping HTML, como para fetching de API), dos posibilidades de optimización principales existen:

  • acelerar la recuperación del recurso web (p. ej. descargar la pagina http://example.com/hello.html)
  • acelerar el parsing del información recuperado (p. ej. recuperar todas las url contenidas en hello.html)

Se puede mejorar el desempeño del parsing mejorando su código, utilizando un parser mas rápido como lxml, o le asignando más recursos maquina a nuestro scraper. A pesar de todo, se da cuenta de que a menudo la optimización del parsing es insignificante y que el cuello de botella es el acceso red (es decir el descargamiento de la pagina web).

Pues la solución es paralelizar el descargamiento de las paginas web. ¡Por eso Go está una bueno elección!

La programación concurrente es un ámbito muy complicado y Go puede hacerlo bastante fácil. Go es un lenguaje moderno que fue pensado para la concurrencia desde el principio. Al contrario, Python es un lenguaje más antiguo que, a pesar de numerosos esfuerzos recientes, es más complejo cuando se quiere programar un scraper concurrente.

¡Hay otras ventajas utilizar Go, pero vamos a hablar de esto en otro momento!

Instale Go

Ya realicé un pequeño tuto sobre la instalación de Go en Ubuntu.

Si quiere instalar Go en otra plataforma, puede utilizar la doc oficial.

Un scraper concurrente simple

Nuestro scraper se contenta de abrir una lista de paginas web que se le da primero. Después averigua que obtiene un código HTTP 200 (significa que el servidor retorno la pagina HTML sin error). No hay parsing HTML aquí porque la meta está focalizarse sobre el desempeño del acceso a la red. ¡A usted le toca escribir más!

Código final


/*
Open a series of urls.

Check status code for each url and store urls I could not
open in a dedicated array.
Fetch urls concurrently using goroutines.
*/

package main

import (
    "fmt"
    "net/http"
)

// -------------------------------------

// Custom user agent.
const (
    userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) " +
        "AppleWebKit/537.36 (KHTML, like Gecko) " +
        "Chrome/53.0.2785.143 " +
        "Safari/537.36"
)

// -------------------------------------

// fetchUrl opens a url with GET method and sets a custom user agent.
// If url cannot be opened, then log it to a dedicated channel.
func fetchUrl(url string, chFailedUrls chan string, chIsFinished chan bool) {

    // Open url.
    // Need to use http.Client in order to set a custom user agent:
    client := &http.Client{}
    req, _ := http.NewRequest("GET", url, nil)
    req.Header.Set("User-Agent", userAgent)
    resp, err := client.Do(req)

    // Inform the channel chIsFinished that url fetching is done (no
    // matter whether successful or not). Defer triggers only once
    // we leave fetchUrl():
    defer func() {
        chIsFinished <- true
    }()

    // If url could not be opened, we inform the channel chFailedUrls:
    if err != nil || resp.StatusCode != 200 {
        chFailedUrls <- url
        return
    }

}

func main() {

    // Create a random urls list just as an example:
    urlsList := [10]string{
        "http://example1.com",
        "http://example2.com",
        "http://example3.com",
        "http://example4.com",
        "http://example5.com",
        "http://example10.com",
        "http://example20.com",
        "http://example30.com",
        "http://example40.com",
        "http://example50.com",
    }

    // Create 2 channels, 1 to track urls we could not open
    // and 1 to inform url fetching is done:
    chFailedUrls := make(chan string)
    chIsFinished := make(chan bool)

    // Open all urls concurrently using the 'go' keyword:
    for _, url := range urlsList {
        go fetchUrl(url, chFailedUrls, chIsFinished)
    }

    // Receive messages from every concurrent goroutine. If
    // an url fails, we log it to failedUrls array:
    failedUrls := make([]string, 0)
    for i := 0; i < len(urlsList); {
        select {
        case url := <-chFailedUrls:
            failedUrls = append(failedUrls, url)
        case <-chIsFinished:
            i++
        }
    }

    // Print all urls we could not open:
    fmt.Println("Could not fetch these urls: ", failedUrls)

}


Explicaciones

Este código está un poco más largo que lo que podría hacer con un lenguaje como Python, pero es muy razonable. Go es un lenguaje estático, pues declarar las variables toma un poquito más tiempo. ¡Pero mida el tiempo de ejecución de este programa, y va a ver la recompensa!

Tomamos 10 url al azar para el ejemplo.

Aquí las palabras claves están go, chan, y select:

  • go permite crear una nueva goroutine, es decir que fetchUrl sera ejecutado cada vez en una nueva goroutine concurrente.
  • chan es el tipo que representa un channel. Se utiliza los channels para comunicar entre goroutines (main también es una goroutine).
  • select ... case es un switch ... case dedicado a recibir los mensajes enviados por los channels. El programa continua solo cuando todas las goroutines han enviado un mensaje (sea para decir que el fetching de la url esta terminado, o sea para decir que el fetching fracasó).

Se habría podido crear ningún channel para este scraper, es decir solo crear goroutines y nos esperar información a cambio (por ejemplo si cada goroutine termina almacenando el resultado en base de datos). En este caso es posible que nuestra goroutine principal se termine mientras que otras goroutines todavía trabajan (no es necesariamente un problema ya que Go ejecuta todas la goroutines enteramente incluso si la main paró). Pero en realidad, es casi siempre necesario utilizar los channels para hacer comunicar nuestras goroutines.

¡Piense en limitar la velocidad

Aquí la velocidad máximum es lo que se busca, en particular porque se hace scraping de urls todas diferentes. Sin embargo, si necesita descargar varias veces la misma url (en el caso del fetching de una API externa por ejemplo), tendrá que no superar un numero máximum de consultas concurrentes por segundo. En este caso se necesita implementar un contador (¡quizás en un próximo articulo!).

¡Enjoy el scraping!

Also available in English | Existe aussi en français