Hermosas API de nodo

Esta publicación trata sobre cómo crear hermosas API en Node.js. Genial, ¿y qué es una API? La definición dice Interfaz de programación de aplicaciones, pero ¿qué significa? Podría significar una de las pocas cosas según el contexto:

  • Puntos finales de un servicio Arquitectura orientada a servicios (SOA)
  • Firma de función
  • Atributo de clase y métodos

La idea principal es que una API es una forma de contrato entre dos o más entidades (objetos, clases, preocupaciones, etc.). Su principal objetivo como ingeniero de nodos es crear una API hermosa para que los desarrolladores que consumen su módulo/clase/servicio no maldigan ni le envíen mensajes instantáneos y correos electrónicos de odio. El resto de su código puede ser feo, pero las partes que son públicas (para uso de otros programas y desarrolladores) deben ser convencionales, extensibles, fáciles de usar y comprender, y consistentes.

Veamos cómo crear hermosas API para las que puede asegurarse de que otro desarrollador

Hermosos puntos finales en Node:domar a la bestia REST

Lo más probable es que no esté utilizando el nodo central http módulo directamente, pero un marco como Express o Hapi. Si no es así, considere seriamente usar un marco. Vendrá con regalos como análisis y organización de rutas. Usaré Express para mis ejemplos.

Aquí está nuestro servidor API con CRUD para el /accounts recurso enumerado con un método HTTP y el patrón de URL (`{} significa que es una variable):

  • OBTENER /accounts :Obtener una lista de cuentas
  • POST /accounts :Crear una nueva cuenta
  • OBTENER /accounts/{ID} :Obtener una cuenta por ID
  • PONGA /accounts/{ID} :Actualización parcial de una cuenta por ID
  • ELIMINAR /accounts/{ID} :Eliminar una cuenta por ID

Puede notar inmediatamente que necesitamos enviar el ID del recurso (cuenta) en la URL para los últimos tres puntos finales. Al hacerlo, logramos los objetivos de tener una distinción clara entre la colección de recursos y el recurso individual. Esto, a su vez, ayuda a evitar errores por parte del cliente. Por ejemplo, es más fácil confundir DELETE /accounts con ID en el cuerpo de la solicitud para la eliminación de todas las cuentas, lo que puede hacer que lo despidan fácilmente si este error alguna vez entra en producción y realmente provoca la eliminación de todas las cuentas.

Se pueden derivar beneficios adicionales del almacenamiento en caché por URL. Si usa o planea usar Varnish, almacena en caché las respuestas y al tener /accounts/{ID} obtendrá mejores resultados de almacenamiento en caché.
¿Aún no está convencido? Déjeme decirle que Express simplemente ignorará la carga útil (cuerpo de solicitud) para solicitudes como ELIMINAR, por lo que la única forma de obtener esa ID es a través de una URL.

Express es muy elegante al definir los puntos finales. Para el ID que se llama parámetro de URL, hay un req.params objeto que se completará con las propiedades y valores siempre que defina el parámetro de URL (o varios) en el patrón de URL, por ejemplo, con :id .

app.get('/accounts', (req, res, next) => {
  // Query DB for accounts
  res.send(accounts)
})

app.put('/accounts/:id', (req, res, next) => {
  const accountId = req.params.id
  // Query DB to update the account by ID
  res.send('ok')
})

Ahora, algunas palabras sobre PUT. Se usa mucho mal porque, según la especificación, PUT es para una actualización completa, es decir, el reemplazo de toda la entidad, no la actualización parcial. Sin embargo, muchas API, incluso de empresas grandes y de buena reputación, usan PUT como una actualización parcial. ¿Ya te confundí? ¡Es solo el comienzo de la publicación! Bien, déjame ilustrar la diferencia entre parcial y completo.

Si actualiza con {a: 1} un objeto {b: 2} , el resultado es {a: 1, b: 2} cuando la actualización es parcial y {a: 1} cuando es un reemplazo completo.

Volvamos a los puntos finales y los métodos HTTP. Una forma más adecuada es usar PATCH para actualizaciones parciales, no PUT. Sin embargo, las especificaciones de PATCH carecen de implementación. Tal vez esa sea la razón por la que muchos desarrolladores eligen PUT como una actualización parcial en lugar de PATCH.

Bien, estamos usando PUT porque se convirtió en el nuevo PATCH. Entonces, ¿cómo obtenemos el JSON real? Hay body-parser que puede darnos un objeto Node/JavaScript a partir de una cadena.

const bodyParser = require('body-parser')
// ...
app.use(bodyParser.json())
app.post('/accounts', (req, res, next) => {
  const data = req.body
  // Validate data
  // Query DB to create an account
  res.send(account._id)
})

app.put('/accounts/:id', (req, res, next) => {
  const accountId = req.params.id
  const data = req.body
  // Validate data
  // Query DB to update the account by ID
  res.send('ok')
})

Siempre, siempre, siempre valida los datos entrantes (y también salientes). Hay módulos como joi y express-validator para ayudarlo a desinfectar los datos con elegancia.

En el fragmento anterior, es posible que haya notado que estoy devolviendo el ID de una cuenta recién creada. Esta es la mejor práctica porque los clientes necesitarán saber cómo hacer referencia al nuevo recurso. Otra práctica recomendada es enviar códigos de estado HTTP adecuados, como 200, 401, 500, etc. Se dividen en categorías:

[Nota al margen]

Leer publicaciones de blog es bueno, pero ver cursos en video es aún mejor porque son más atractivos.

Muchos desarrolladores se quejaron de la falta de material de video de calidad asequible en Node. Es una distracción ver videos de YouTube y una locura pagar $ 500 por un curso de video de Node.

Visite Node University, que tiene cursos de video GRATUITOS en Node:node.university.

[Fin de la nota al margen]

  • 20x:Todo está bien
  • 30x:redireccionamientos
  • 40x:errores del cliente
  • 50x:errores del servidor

Al proporcionar un mensaje de error válido, puede ayudar a los desarrolladores del lado del cliente drásticamente , porque pueden saber si la falla de la solicitud es culpa suya (40x) o falla del servidor (500). En la categoría 40x, debe distinguir al menos entre autorización, carga útil deficiente y no encontrado.

En Express, los códigos de estado se encadenan antes del send() . Por ejemplo, para POST /accounts / estamos enviando 201 creado junto con el ID:

 res.status(201).send(account._id)

La respuesta para PUT y DELETE no tiene que contener la ID porque sabemos que el cliente conoce la ID. Usaron en la URL después de todo. Todavía es una buena idea enviar un mensaje de aprobación diciendo que todo está bien cuando se solicita. La respuesta puede ser tan simple como {"msg": "ok"} o tan avanzado como

{ 
  "status": "success",
  "affectedCount": 3,
  "affectedIDs": [
   1,
   2, 
   3
  ]
}

¿Qué pasa con las cadenas de consulta? Se pueden usar para obtener información adicional, como una consulta de búsqueda, filtros, claves API, opciones, etc. Recomiendo usar datos de cadena de consulta para GET cuando necesite pasar información adicional. Por ejemplo, así es como puede implementar la paginación (no queremos obtener todas las 1000000 cuentas para la página que muestra solo 10 de ellas). La página variable es el número de página y el límite variable es cuántos elementos se necesitan para una página.

app.get('/accounts', (req, res, next) => {
  const {query, page, limit} = req.query
  // Query DB for accounts 
  res.status(200).send(accounts)
})

Basta de puntos finales, veamos cómo trabajar en un nivel inferior con funciones.

Hermosas funciones:aceptar la naturaleza funcional de Node

Node y JavaScript son muy (pero no completamente) funcionales, lo que significa que podemos lograr mucho con las funciones. Podemos crear objetos con funciones. Una regla general es que al mantener puras las funciones se pueden evitar problemas futuros. ¿Qué es una función pura? Es una función que NO tiene efectos secundarios. ¿No te encantan los sabelotodos que definen un término oscuro con otro aún más oscuro? Un efecto secundario es cuando una función "toca" algo externo, generalmente un estado (como una variable o un objeto). La definición adecuada es más compleja, pero si recuerda tener una función que solo modifique su argumento, estará mejor que la mayoría (la mayoría es solo el 51 %, y de todos modos es mi humilde estimación).

Esta es una hermosa función pura:

let randomNumber = null
const generateRandomNumber = (limit) => {
  let number = null  
  number = Math.round(Math.random()*limit)
  return number
}
randomNumber = generateRandomNumber(7)
console.log(randomNumber)

Esta es una función muy impura porque está cambiando randomNumber fuera de su alcance. Accediendo a limit fuera del alcance también es un problema porque esto introduce una interdependencia adicional (acoplamiento estrecho):

let randomNumber = null
let limit = 7
const generateRandomNumber = () => {
  randomNumber = Math.floor(Math.random()*limit)
}
generateRandomNumber()
console.log(randomNumber)

El segundo fragmento funcionará bien, pero solo hasta cierto punto en el futuro, siempre que pueda recordar los efectos secundarios limit y randomNumber .

Hay algunas cosas específicas para el nodo y la función solo . Existen porque Node es asíncrono y no teníamos las promesas hipster o async/await en 201x cuando el núcleo de Node se estaba formando y creciendo rápidamente. En resumen, para el código asíncrono necesitamos una forma de programar la ejecución futura de algún código. Necesitamos poder pasar una devolución de llamada. El mejor enfoque es pasarlo como el último argumento. Si tiene un número variable de argumentos (digamos que un segundo argumento es opcional), mantenga la devolución de llamada como última. Puedes usar aridad (arguments ) para implementarlo.

Por ejemplo, podemos reescribir nuestra función anterior de ejecución síncrona a asíncrona usando la devolución de llamada como último patrón de argumento. Dejé intencionalmente randomNumber = pero será undefined ya que ahora el valor estará en la devolución de llamada en algún momento posterior.

let randomNumber = null
const generateRandomNumber = (limit, callback) => {
  let number = null  
  // Now we are using super slow but super random process, hence it's async
  slowButGoodRandomGenerator(limit, (number) => {
    callback(number)
  })
  // number is null but will be defined later in callback 
}

randomNumber = generateRandomNumber(7, (number)=>{
  console.log(number)
})
// Guess what, randomNumber is undefined, but number in the callback will be defined later

El siguiente patrón que está estrechamente relacionado con el código asíncrono es el manejo de errores. Cada vez que configuramos una devolución de llamada, será manejada por un bucle de eventos en algún momento futuro. Cuando se ejecuta el código de devolución de llamada, ya no tenemos una referencia al código original, solo a la variable en el alcance. Por lo tanto, no podemos usar try/catch y no podemos arrojar errores como sé que a algunos de ustedes les encanta hacer en Java y otros lenguajes síncronos.

Por esta razón, para propagar un error desde un código anidado (función, módulo, llamada, etc.), podemos simplemente pasarlo como argumento… a la devolución de llamada junto con los datos (number ). Puede verificar sus reglas personalizadas en el camino. Usa return para terminar la ejecución posterior del código una vez que se encuentra un error. Mientras usa null como un valor de error cuando no hay errores presentes (heredados o personalizados).

const generateRandomNumber = (limit, callback) => {
  if (!limit) return callback(new Error('Limit not provided'))
  slowButGoodRandomGenerator(limit, (error, number) => {
    if (number > limit) {
      callback(new Error('Oooops, something went wrong. Number is higher than the limit. Check slow function.'), null)
    } else {    
      if (error) return callback(error, number)
      return callback(null, number)
    }
  })
}

generateRandomNumber(7, (error, number) => {
  if (error) {
    console.error(error)
  } else {
    console.log(number)
  }
})

Una vez que tenga su función asíncrona pura con manejo de errores, muévala a un módulo. Tienes tres opciones:

  • Archivo:la forma más fácil es crear un archivo e importarlo con require()
  • Módulo:puede crear una carpeta con index.js y muévalo a node_modules . De esta forma, no tienes que preocuparte por los molestos __dirname y path.sep ). Establecer private: true para evitar la publicación.
  • Módulo npm:Lleve su módulo un paso más allá al publicarlo en el registro npm

En cualquier caso, usaría la sintaxis de CommonJS/Node para los módulos, ya que la importación de ES6 no está cerca de TC39 o de la hoja de ruta de Node Foundation (a partir de diciembre de 2016 y una charla del colaborador principal que escuché en Node Interactive 2016). La regla general al crear un módulo es lo que exportas es lo que importas . En nuestro caso, su función es así:

module.exports = (limit, callback) => {
  //...
}

Y en el archivo principal, importas con require . Simplemente no use mayúsculas ni guiones bajos para los nombres de archivo. De verdad, no los uses:

const generateRandomNumber = require('./generate-random-number.js')
generateRandomNumber(7, (error, number) => {
  if (error) {
    console.error(error)
  } else {
    console.log(number)
  }
})

¿No te alegra que generateRandomNumber es puro? :-) Apuesto a que te hubiera llevado más tiempo modularizar una función impura, debido al estrecho acoplamiento.

Para resumir, para una función hermosa, normalmente haría que fuera asíncrono, tendría datos como el primer argumento, opciones como el segundo y devolución de llamada como el último. Además, haga que las opciones sean un argumento opcional y, por lo tanto, la devolución de llamada puede ser un segundo o tercer argumento. Por último, la devolución de llamada pasará el error como primero evento de argumento si es simplemente nulo (sin errores) y data como el último (segundo) argumento.

Hermosas clases en Node:sumergirse en programación orientada a objetos con clases

No soy un gran admirador de las clases ES6/ES2015. Uso fábricas de funciones (también conocido como patrón de herencia funcional) tanto como puedo. Sin embargo, espero que más personas comiencen a codificar en Node que provienen del front-end o de Java. Para ellos, echemos un vistazo a la forma OOP de heredar en Node:

class Auto {
  constructor({make, year, speed}) {
    this.make = make || 'Tesla'
    this.year = year || 2015
    this.speed = 0
  }
  start(speed) {
    this.speed = speed
  }
}
let auto = new Auto({})
auto.start(10)
console.log(auto.speed)

La forma en que se inicializa la clase (new Auto({}) ) es similar a una llamada de función en la sección anterior, pero aquí pasamos un objeto en lugar de tres argumentos. Pasar un objeto (puedes llamarlo options ) es un patrón mejor y más hermoso ya que es más versátil.

Curiosamente, al igual que con las funciones, podemos crear funciones con nombre (ejemplo anterior), así como clases anónimas almacenándolas en variables (código a continuación):

const Auto = class {
  ...
}

Los métodos como el llamado start en el fragmento con Auto se denominan prototipo o método de instancia. Al igual que con otros lenguajes OOP, podemos crear un método estático. Son útiles cuando los métodos no necesitan acceso a una instancia. Digamos que eres un programador hambriento en una startup. Ahorraste $15,000 de tus escasos ingresos al comer fideos ramen. Puede verificar si eso es suficiente para llamar a un método estático Auto.canBuy y todavía no hay coche (ninguna instancia).

class Auto {
  static canBuy(moneySaved) {
    return (this.price<moneySaved)
  }
}
Auto.price = 68000

Auto.canBuy(15000)

Por supuesto, todo hubiera sido demasiado fácil si TC39 incluyera el estándar para atributos de clase estáticos como Auto.price entonces podemos definirlos en el cuerpo de la clase en lugar de afuera, pero no. No incluyeron atributo de clase en ES6/ES2015. Tal vez lo consigamos el próximo año.

Para extender una clase, digamos que nuestro automóvil es un Model S Tesla, hay extends operando Debemos llamar al super() si sobreescribimos constructor() . En otras palabras, si extiende una clase y define su propio constructor/inicializador, invoque super para obtener todas las cosas del padre (Auto en este caso).

class Auto {
}
class TeslaS extends Auto {
  constructor(options) {
    super(options)
   }
}

Para hacerlo hermoso, defina una interfaz, es decir, métodos públicos y atributos/propiedades de una clase. De esta manera, el resto del código puede permanecer feo y/o cambiar más a menudo sin causar frustración o enojo a los desarrolladores que usaron la API privada (los desarrolladores privados de sueño y café tienden a ser los más enojados; tenga un refrigerio a mano en su mochila para ellos) en caso de ataque).

Dado que Node/JavaScript está escrito de forma flexible. Debe esforzarse más en la documentación de lo que normalmente haría al crear clases en otro idioma con escritura fuerte. Un buen nombre es parte de la documentación. Por ejemplo, podemos usar _ para marcar un método privado:

class Auto {
  constructor({speed}) {
    this.speed = this._getSpeedKm(0)
  }
  _getSpeedKm(miles) {    
    return miles*1.60934
  }
  start(speed) {
    this.speed = this._getSpeedKm(speed)
  }
}
let auto = new Auto({})
auto.start(10)
console.log(auto.speed)

Todo lo relacionado con la modularización descrito en la sección sobre funciones se aplica a las clases. Cuanto más granular y acoplado libremente sea el código, mejor.

Bueno. Esto es suficiente por ahora. Si su mente anhela más de estas cosas ES6/ES2015, consulte mi hoja de trucos y publicación de blog.

Quizás se pregunte cuándo usar una función y cuándo una clase. Es más un arte que una ciencia. También depende de tus antecedentes. Si pasó 15 años como arquitecto de Java, le resultará más natural crear clases. Puede usar Flow o TypeScript para agregar escritura. Si eres más un programador funcional de Lisp/Clojure/Elixir, entonces te inclinarás por las funciones.

Resumen

Ese fue un ensayo muy largo, pero el tema no es trivial en absoluto. Su bienestar podría depender de ello, es decir, cuánto mantenimiento requerirá el código. Suponga que todo el código está escrito para ser cambiado. Separe las cosas que cambian con más frecuencia (privadas) de otras cosas. Exponga solo las interfaces (públicas) y hágalas resistentes a los cambios tanto como sea posible.

Por último, tenga pruebas unitarias. Servirán como documentación y también harán que su código sea más robusto. Podrá cambiar el código con más confianza una vez que tenga una buena cobertura de prueba (preferiblemente automatizada como GitHub+CI, por ejemplo, CircleCI o Travis).

¡Y sigue asintiendo!