lunes, 30 de diciembre de 2019

Sitio estático multi-lenguaje con webpack

Recientemente, he tenido que configurar una plantilla de sitio web estático (digo plantilla porque supuestamente servirá como scaffolding a futuros proyectos, pero seguro quedará obsoleta en 2 días y habrá que modificarla, veremos a posteriori si sirvió a más de un proyecto). El sitio web no es una SPA, es una MPA (Multiple-Page Application) y, además, era un requisito indispensable la traducción a múltiples lenguajes y que fuera en tiempo de compilación, por SEO. Siendo así, y después de no encontrar nada en google ya hecho y que me gustara, pensé que sería fácil y rápido hacerlo con webpack, pero craso error, me ha llevado más tiempo de lo que suponía y he acabado (no sé si con razón o sin razón), buceando en exceso en la documentación, y probando con el método ensayo-error el resultado de la compilación. La conclusión que saco de todo esto es que en el front me siento como un conductor novel que está configurando un vehículo con un excesivo y apabullante número de extras disponibles. De hecho, y por casualidad, hace unas semanas contesté a esta encuesta https://stateofjs.com/ y viendo ahora los resultados más del 50% contestamos que sí, que "La creación de aplicaciones JavaScript es demasiado compleja en este momento" https://2019.stateofjs.com/opinions/#building_js_apps_overly_complex, aunque claro, más del 50% eramos full-stack https://2019.stateofjs.com/demographics/#jobTitle así que lo mismo el problema no es javascript, si no gente que le da a todo y así no se puede (yo me incluyo por si no ha quedado claro).

En este post, me quiero centrar en las decisiones que he tomado en relación con la MPA y a la traducción. Para el resto es más fácil y seguro leer la documentación de webpack.

El repositorio con la plantilla está en https://github.com/panicoenlaxbox/webpack-static-site-template

Si tenemos una MPA, tendremos varios entries y por cada uno de ellos podremos decidir que bundles queremos incluir, la idea está sacada de https://webpack.js.org/guides/entry-advanced/

entry: {
index: [
    "./src/index.js",
    "./src/styles/index.scss",
    "selectric/public/selectric.css"
],
about: ["./src/about.js", "./src/styles/about.scss"]
},
plugins: [
new HtmlWebpackPlugin({
    filename: path.join(translation.dist, "index.html"),
    template: "src/index.html",
    chunks: ["index", "vendor"]
}),
new HtmlWebpackPlugin({
    filename: path.join(translation.dist, "about.html"),
    template: "src/about.html",
    chunks: ["about", "vendor"]
}),

Fijarse que cada entry especifica los estilos, .scss por lo que no usa require como dependencia en el .js. Además, cada nueva página debería ir acompañada de una nueva entry y una nueva instancia de HtmlWebpackPluginhttps://github.com/jantimon/html-webpack-plugin#generating-multiple-html-files.

Por otro lado, ya ha aparecido el objeto translation. Aunque se usa i18n-webpack-plugin para traducir las claves de los ficheros .js, también hay claves de traducción en ficheros .html y ahí el reemplazo lo he resuelto con este otro plugin html-string-replace-webpack-plugin-webpack-4.

new HtmlStringReplace({
    patterns: [
    {
        match: /__(.+?)__/g,
        replacement: (match, $1) => translation.translation[$1]
    }
    ]
}),

Como que el sitio es estático, quería obtener en la raíz de dist/ la versión del lenguaje neutro y luego una carpeta / por cada lenguaje soportado. Para ello, he usado una compilación mútiple (la idea está sacada de https://survivejs.com/webpack/techniques/i18n/) y luego unas tareas extras para ajustarlo todo.

Que webpack devuelve una función en vez de un objeto, se puede ver en https://webpack.js.org/configuration/configuration-types/. Esto da mucho juego y abre distintas posibilidades.

module.exports = (env, argv) => {
  const isProduction = argv.mode === "production";
  return translations.map(translation => {
    return {
      entry: {

translations es un objeto que lee los mismos ficheros que usa … y agrega algunas propiedaes útil para la compilación.

[ { language: 'en',
    translation:
     { language: 'en',
       title: 'My static site template',
       message: 'My message' },
    default: true,
    dist: 'C:\\Temp\\webpack-static-site-template\\dist' },
  { language: 'es',
    translation:
     { language: 'es',
       title: 'Mi plantilla de sitio estático',
       message: 'Mi mensaje' },
    default: false,
    dist: 'C:\\Temp\\webpack-static-site-template\\dist\\es' } ]

Ahora cada vez que webpack emite un bundle lo hace teniendo en cuenta el lenguaje:

output: {
  path: translation.dist,
  filename: `[name].${translation.language}${
      isProduction ? ".[contenthash]" : ""
  }.js`
}

Como estamos compilando lo mismo varias veces (con la única diferencia del lenguaje), acabaremos por tener en dist/ algo válido pero un poco feo y repetitivo, se puede mejorar haciendo algunos reemplazos (que por otro lado no me gusta hacer, es como hackear el sistema, de largo es lo que más oscuro me parece).

new HtmlStringReplace({
    patterns: [
        {
            match: /(<img src=")(?!(\/\/|https?:\/\/|data:image))/gi,
            replacement: (match, $1) => `${$1}/`
        }
    ]
}),
new HtmlStringReplace({
    enable: !translation.default,
    patterns: [
        {
            match: /(<link href=")(?!(\/\/|https?:\/\/))/gi,
            replacement: (match, $1) => `${$1}../`
        },
        {
            match: /(<script type="text\/javascript" src=".*?)(?=vendor\.)/gi,
            replacement: (match, $1) => {
            return $1.substring(0, $1.lastIndexOf('"') + 1) + "/";
            }
        }
    ]
}),

Y borrando por último, lo que no queremos en un hook del ciclo de compilación:

new EventHooksPlugin({
    done: () => {
        if (!translation.default) {
            exec(`rimraf \"dist/${translation.language}/!(*.html|*.js)\"`);
            exec(`rimraf \"dist/${translation.language}/vendor*.js"`);
        }
    }
})

Después de esto, acabaremos con una estructura como la siguiente:

│   about.css
│   about.en.js
│   about.html
│   index.css
│   index.en.js
│   index.html
│   vendor.css
│   vendor.js
└───es
        about.es.js
        about.html
        index.es.js
        index.html

Un saludo!

viernes, 6 de diciembre de 2019

High-order Observables (Angular) y ASP.NET Core

ReactiveX es una API para la programación asíncrona con Observables. Su implementación en Javascript es RxJS y en Angular (no sé en otros frameworks), se usa y se usa mucho, no puedes pasarlo por alto. Este post (al que luego volveré a hacer mención) empieza con un párrafo bastante lapidario "Like it or not, rxjs is a critical component of modern Angular development. Although it is perfectly possible to use Angular 2+ without using observables, you lose out on an enormous amount of functionality. The reactive pattern is extremely powerful, and once you get over the, admittedly rather high, learning curve the grass is definitely greener on the other side.". Pues así me siento yo, en mi opinión, Angular es un framework con una curva de aprendizaje medio-alta, si a eso le sumamos Redux, en su sabor Angular vía NgRx, la cosa se complica un poco/bastante más, pero todo tiene un denominador común... RxJS. De hecho, el lema de NgRx es "Reactive State for Angular", y Reactive es sinónimo de RxJS. Queda claro que RxJS debe ser importante.

En este sentido, hay un concepto de RxJS que me parece especialmente importante y son los high-order Observables. Normalmente, trabajamos con Observables que emiten valores de tipos básicos (strings, numbers, tipos de usuario, etc.), a estos se les llama first-order Observables, pero si un Observable emite, a su vez, Observables, estamos hablando de high-order Observables (es igual que cuando tenemos una función que recibe o devuelve otra función y hablamos de high-order functions, de ahí habrán sacado el nombre, digo yo).

Llegar a encontrarse en el código high-order Observables no es excepcional, bastaría un ejemplo sencillo como el siguiente, donde en la suscripción recibimos un Observable y no un number ¿Qué hago yo ahora? Yo quería recibir un number.

import { Observable, of } from "rxjs";
import { map } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n))
  )
  .subscribe((n: Observable<number>) => console.log(n));

// Observable { _isScalar: true, _subscribe: [Function], value: 1 }
// Observable { _isScalar: true, _subscribe: [Function], value: 4 }
// Observable { _isScalar: true, _subscribe: [Function], value: 9 }

Podemos resolverlo creando una suscripción anidada, pero es un anti-pattern. Lo es porque perdemos el control de cuando cancelar la suscripción anidada y además recuerda (sospechosamente) al famoso call-back hell. Si te encuentras dos susbcribe anidados, tienes un problema. Y no lo digo yo, lo dice Deborah Kurata, que sabe bastante más que yo.

import { Observable, of } from "rxjs";
import { map } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n))
  )
  .subscribe((n: Observable<number>) =>
    n.subscribe((i: number) => {
      console.log(i);
    })
  );

// 1
// 4
// 9

Y ¡ojo!, no vale hace trampas, aunque no tengamos dos subscribe juntos, hay otras formas más enrevesadas de tener nested suscriptions.

import { Observable, of } from "rxjs";
import { map, tap } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n)),
    tap<Observable<number>>((o: Observable<number>) => {
      o.subscribe((i: number) => {
        console.log(`inner ${i}`);
      });
    })
  )
  .subscribe((n: Observable<number>) => console.log(`outer ${n}`));

// inner 1
// outer [object Object]
// inner 4
// outer [object Object]
// inner 9
// outer [object Object]

He aprovechado este último ejemplo para introducir dos nuevas palabras que es importante tener en cuenta, source u outer Observable e inner Observable. El Observable al que nos suscribimos se llama outer Observable y, a los Observables que emite (por eso un high-order Observable) se les llama inner Observable.

También quiero aprovechar a hacer el disclaimer, de que en este post seré muy verbose con la firma de los métodos porque es una manía que tengo cuando estoy aprendiendo, es decir, lo anterior es equivalente a este otro código:

of(1, 2, 3)
  .pipe(
    map(n => of(n * n)),
    tap(o => {
      o.subscribe((i: number) => {
        console.log(`inner ${i}`);
      });
    })
  )
  .subscribe(n => console.log(`outer ${n}`));

Volviendo al problema original, ¿cómo trabajar entonces con high-order Observables? Pues hay que convertir un high-order Observable en un first-order Observable. Eso se hace con flattenig (aplastamiento, su traducción más o menos acertada al español). Estos operadores permitirán que consumamos los inner Observable como tipos básicos y, además, y esto es muy importante, gestionarán de forma automática la suscripción y cancelación al inner Observable.

Por ejemplo, con concatAll podemos resolver el problema inicial.

import { Observable, of } from "rxjs";
import { map, concatAll } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n)),
    concatAll<number>(),
  )
  .subscribe((n: number) => console.log(n));

// 1
// 4
// 9

Y podemos hacerlo un poco mejor, si usamos el operador concatMap, que es la suma de map y concatAll (al igual que sucede por ejemplo en vanilla Javascript con map y flat, que se combinan en flatMap)

import { Observable, of } from "rxjs";
import { map, concatAll, concatMap } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    concatMap<number, number>((n: number) => of<number>(n * n))
  )
  .subscribe((n: number) => console.log(n));

// 1
// 4
// 9

Amigo de concatMap, tenemos también a switchMap (un clásico), mergeMap y exhaustMap.

Lo más importante de entender es que todos estos operadores responden a la misma pregunta ¿Qué hacer si el outer Observable vuelve a emitir y el inner Observable todavía está trabajando? Es decir, ¿Qué hacer si se solapan emisiones del outer Observable con el inner Observable? Si pasa el suficiente tiempo entre emisiones del outer Observable como para que la suscripción y cancelación del inner Observable ya haya acabado, podemos poner lo que queramos, da igual, se va a comportar de la misma forma. Sin embargo, si el inner Observable todavía esta trabajando ¿qué hacer con el trabajo actual del inner? Aquí es donde el post que mencionaba al principio http://alanpryorjr.com/2019-05-15-rxjs-flattening-operators/ me parece genial porque (sin código, importante y felicito por ello al autor) explica con una analogía de jefe-empleado como se comportará para operador de los mencionados en el hipotético caso de que un jefe (outer Observable) manda tareas (emite) a un empleado (flatennig operator) que todavía no ha acabado la anterior (inner Observable).

Si lo llevamos a un escenario más concreto, como llamar a una API si el usuario hace click en un botón (y asumiendo no hemos tenido a bien, deshabilitar el botón después de un click, que sería lo suyo), tenemos lo siguiente:

  • switchMap. Cancela petición en curso y vuelve a llamar a la API.
  • concatMap. Cuando acabe la petición en curso, hará otra llamada. Es una cola. Ademas, se respetará el orden y no llamada hasta no acabar con la anterior (esto es importante porque queremos garantizar que en el back se procesen en orden, que se hagan las peticiones desde cliente en orden no garantiza que en el back se procesen en el mismo orden, por eso se espera a que termine una petición para lanzar la siguiente).
  • mergeMap. A la vez que la petición en curso, se lanzará una nueva llamada, en paralelo.
  • exhaustMap. No hará nada si hay una petición en curso.

En este ejemplo https://stackblitz.com/edit/angular-nkgfyr está recogido lo anterior, y usando https://www.mocky.io/ podemos simular un delay para forzar a que el inner Observable esté trabajando cuando volvamos a hacer click en el botón. Es muy importante tener abierta la pestaña network de las developer tools para ver como se cancelan las peticiones en curso en función del operador elegido. En el ejemplo, se usan switchMapTo, concatMapTo y mergeMapTo, que son iguales a sus versiones sin "To", sólo que no necesitan un parámetro de entrada.

Llegando al final y, puesto que hemos cancelado peticiones, ¿Qué podemos hacer en el back para aprovecharnos de este comportamiento? Pues usar CancellationToken y propagarlo en todos los métodos asíncronos que lo permitan (por ejemplo, EF, Dapper, MediatR, etc.). Porque es muy bonito que el cliente cancele una petición (le honra), pero si en back seguimos procesando las peticiones, sólo uno estará haciendo lo correcto, el otro seguirá sin percatarse de que está trabajando para nada y que ya a nadie le importa el resultado (triste pero cierto).

En ASP.NET Core (y haciendo una API identica a la que hemos consumido anteriormente), simplemente añadiendo el parámetro CancellationToken al método de acción y pasándoselo a Task.Delay hará la magia de lanzar una excepción del tipo System.Threading.Tasks.TaskCanceledException si el cliente cancela la petición.

[HttpGet]
public async Task<ActionResult<IEnumerable<User>>> Get(CancellationToken cancellationToken, int delay = 0)
{
    await Task.Delay(delay, cancellationToken);
    return new[]
    {
            new User() {Id = 1, Name = "Sergio", Email = "panicoenlaxbox@gmail.com"},
            new User() {Id = 2, Name = "Carmen", Email = "panicoenel20@gmail.com"}
        };
}

Un saludo!