Seleccionar página

Uno de los temas a tener en cuenta cuando creamos una Single Page Application es discutir el mejor modo para trabajar con las variables de entorno que tenemos en el servidor y que podemos cambiar sin tener que hacer otro despliegue de la web.

Para empezar, esto conlleva que nuestra SPA va a tener que ser servida por un backend, es un tema relevante, ya que ello nos lleva a cambiar un poco la estrucutura de nuestros archivos y probablemente parte de la estructura de tareas de automatización.

Para explicarlo vamos a imaginar una web con las siguientes características:

  • Una SPA hecha en Angular

  • Un backend hecho en Node (Cualquier otro sería válido también)

  • La web se va a alojar en Azure (No habría demasiadas diferencias conceptuales si utilizamos cualquier otro proveedor)

Caso simple

El caso simple va a ser crear una web sin backend, esto nos dará dos resultados:

  • Podríamos pasar variables de entorno en el momento del despliegue de la web.

  • No podríamos cambiar variables de entorno en nuestro servidor y que cambie su valor en nuestra web.

Nos quedaría una estructura del tipo:

Destacando los siguientes archivos:

  • src/app/app.module.ts
    Configuramos las dependencias de nuestra aplicación Angular, más adelante veremos cómo nos ayuda.

  • src/environments
    En estos archivos podemos definir variables y modificarlas en tiempo de compilación, cuando creamos la release de nuestra web.

  • web.config
    Aunque en este caso no tenemos backend, al publicar en Azure podemos añadir un web.config que nuestro servidor interpretará, aquí podríamos tener variables y modificarlas en el momento de desplegar la web, para luego leerlas cargando el archivo vía ajax.

Es un caso que no me sirve, lo que busco es poder definir variables en mi servidor y que sus valores se utilicen desde la web, en estos casos requeriría siempre compilar o desplegar de nuevo la web.

Caso completo

Para el caso más completo hay dos modos típicos de resolverlo, uno de ellos sería tener las variables en el servidor, y al servir mi web desde el backend, modificar el index.html escribiendo ahí las variables ya sea mediante un attr en componente de mi html o mediante el tag script.

Este primer caso es bastante sencillo de hacer y tiene un rendimiento muy correcto, de hecho es más inmediato que el segundo caso que voy a comentar, pero es precisamente este segundo caso el que me parece una mejor práctica de cara a la programación aunque el rendimiento de mi site vaya a ser un poco (y digo un poco) peor.

¿Pero en qué consiste? Vamos a ello.
En resumen, vamos a tener un servidor que nos va a servir la SPA, además de tener una API que nos devolverá las variables de entorno (en este caso de una web en Azure) y finalmente utilizaremos APP_INITIALIZER de Angular para antes de que se cree nuestra aplicación hacer una llamada ajax y guardar todas estas variables en memoria, listas para ser usadas.

Creando el servidor

Hay mil ejemplos de servidores y librerías de Node, para este caso concreto he buscado lo más simple posible y he decidido usar restify

var restify = require('restify');

var server = restify.createServer();

server.listen(process.env.PORT || 8080, function() {
    console.log('%s listening at %s', server.name, server.url);
});

Perfecto, ya tenemos un servidor funcionando, también podemos ver que en process.env tenemos las variables de entorno del servidor, en este caso el puerto.

Para servir todos los archivos de la spa vamos a añadir lo siguiente:

server.get(/.*/, restify.serveStatic({
    directory: __dirname,
    default: 'index.html'
}));
Y finalmente el método de la api que nos devuelve las variables de entorno:
server.get('/api/config', (req, res) => {
    let config = {
        name:  process.env.name || 'Quique'
    };
    res.send(config);
    next();
});

Si lo unimos todo obtenemos el siguiente código para nuestro archivo server.js

var restify = require('restify');

var server = restify.createServer();

server.get('/api/config', (req, res) => {
    let config = {
        name: process.env.name || 'Quique'
    };
    res.send(config);
    next();
});

server.get(/.*/, restify.serveStatic({
    directory: __dirname,
    default: 'index.html'
}));

server.listen(process.env.PORT || 8080, function() {
    console.log('%s listening at %s', server.name, server.url);
});

En un ejemplo real, las buenas prácticas nos recomiendan tener router, controller y un servicio, pero al ser un caso tan simple podemos ver con facilidad un pequeño diccionario clave – valor en el que podemos ir poniendo las variables de entorno que queramos por ejemplo name.

Añadiendo nuevas variables al servidor

En el caso de Azure, una vez hemos creado la web y está desplegada, podemos añadir nuevas variables de una manera super simple, vamos a la configuración de nuestra web y añadimos las variables en la lista.

Obteniendo las variables desde nuestra aplicación Angular

Primero de todo vamos a crear un servicio que va a hacer una llamada a nuestra api y va a guardar las variables en memoria, en este caso le he llamado config.service.ts y tiene el siguiente aspecto:

import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Observable } from "rxjs/Observable";
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/toPromise';

import { ISettings } from './config.model';

@Injectable()
export class ConfigService {
    public settings: ISettings;

    constructor(private http: Http) { }

    public load(): Promise<ISettings> {
        return this.http.get('/api/config')
            .map(res=> this.extractData(res))
            .catch(this.handleError)
            .toPromise();
    }

    private extractData(res: Response) {
        let body = res.json();
        this.settings = body;
        return this.settings;
    }

    private handleError(error: Response) {
        return Observable.throw(error);
    }
}

Esta clase tiene un método público llamado load que va a hacer la petición a nuestra api y devolver una promise y una propiedad settings también pública a la que vamos a acceder para obtener nuestras variables de entorno desde cualquier punto de la aplicación.

Además, podemos crear una interfaz que tenga la estructura esperada de nuestras variables, de modo que nos ayude mientras programamos, en este caso está en config.model.ts

export interface ISettings {
    name: string;
}

Ahora, vamos a app.module.ts veremos un código parecido a este:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule, Http, RequestOptions } from '@angular/http';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule
  ],
  providers: [
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Vamos a importar el servicio para que se pueda utilizar, quedando tal que así:

...
import { ConfigService } from './config.service';

export function loadConfig(config: ConfigService) {
  return () => config.load();
}

@NgModule({
  ...
  providers: [
    ConfigService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

La clave ahora es añadir un provider para la key -> APP_INITIALIZER esto lo que hará es antes de cargar nuestra app, ejecutar la función deseada y esperar una Promise de la misma, es decir podemos hacer una operación asíncrona antes de cargar la app ¡PERFECTO!

...
export function loadConfig(config: ConfigService) {
  return () => config.load();
}

...
providers: [
    ConfigService,
    { provide: APP_INITIALIZER,
    useFactory: loadConfig,
    deps: [ConfigService],
    multi: true }
  ],

Nota: El parámetro multi, en principio nos permite no bloquear esa key y permitir que desde otros puntos se pueda añadir más funciones que se ejecutarán, es decir que nuestra función no será la única para esa key, es algo que se suele dejar activado para este tipo de providers especiales.

Nos quedaría este código:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule, Http, RequestOptions } from '@angular/http';

import { AppComponent } from './app.component';
import { ConfigService } from './config.service';

export function loadConfig(config: ConfigService) {
  return () => config.load();
}

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule
  ],
  providers: [
    ConfigService,
    { provide: APP_INITIALIZER,
    useFactory: loadConfig,
    deps: [ConfigService],
    multi: true }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Ahora podemos usar en cualquier lado nuestras settings y cambiarlas de nuevo en el servidor sin tener que volver a compilar o desplegar nuestra web.

Un ejemplo sería en app.component.ts y su correspondiente vista app.component.html:

export class AppComponent {
  public title = 'app works!';
  public name: string;

  constructor(private configService: ConfigService) { }

  ngOnInit() {
    this.name = this.configService.settings.name;
  }
}

Añadimos nuestras settings y enseñamos la variable:

<h1>
  {{title}} - {{name}}
</h1>

Obteniendo un resultado parecido a este en local:

Y a este en nuestro servidor:

¿Qué te parece? ¿Tú también has tenido mil discusiones buscando la mejor manera de hacer esto?
De momento estoy bastante contento con el resultado, por supuesto esto no es un ejemplo real, pero se acerca mucho.

Tienes el código de este artículo en mi Github, tanto el del ejemplo simple, cómo el ejemplo completo.

+1