Ionic Framework, PouchDB y Cloudant: la combinación perfecta totalmente expuesta parte 12


Esta es una continuación de la serie sobre desarrollo en móviles que comencé aquí. Les recomiendo comenzar a escribir el código fuente desde la parte 1 ya que no se publica el código para descarga.

En este post vamos a incluir algunos puntos propios de la plataforma Cordova que no han sido tan obvios hasta ahora y que deben tenerse en cuenta para aplicaciones reales, pero antes hay que planificar.

Planificación:

Quedó pendiente en el post anterior la implementación de un método para ver cuáles noticias eran las más leídas. Algo tan simple nos plantea los siguientes retos:

  1. Al leer una noticia se tendría que agregar un documento indicando que un usuario determinado la ha leído.
  2. El documento agregado debe ser sincronizado solamente con el servidor. No debe ser sincronizada con los demás usuarios
  3. En el servidor debe existir un mecanismo que concentre todos los documentos de lectura y haga la suma para poder ordenarlos y así presentarlos en el móvil.

El punto 1 requiere de insertar documentos en la base de datos. Para eso recordemos que no hay un esquema así que tendríamos que definir uno que tenga al menos la siguiente información: usuario, noticia leída, la fecha. Digamos que algo así:

{
_id:’id del documento’,
newsId: ‘id de la noticia leída’,
fecha: ‘fecha cuando fue leída la noticia’,
tipo: ‘read’,
usuario: ‘usuario’
}

Por ahora este documento cumple con lo que nos hace falta. Y para grabarlo a la base de datos el comando es super simple “db.put(doc)”, ya veremos como usarlo.

Para el punto 2 debemos considerar que al sincronizar, debe haber alguna información en el documento para definir si hace falta ser sincronizado o no. Además, también debemos considerar que hay información que tiene que ir a todos los usuarios. Entonces podríamos usar el campo “usuario” de la siguiente manera:

  • Consideraremos que la información común a todos, será asignada al usuario “all”
  • La información particular a un usuario, será asignada al usuario actual

De esta manera, podríamos definir una regla para que se sincronicen todos los registros donde el campo usuario sea igual a “all” o al “usuario actual”. Lo único que debemos asegurar es que tengamos un mecanismo para generar un id único. También podríamos considerar que no se pasen las noticias muy antiguas, para eso podríamos pasar la fecha, lo haremos al final si sobra tiempo. Todas estas reglas se entienden fácil, pero hace falta que le hagamos entender esto a CouchDB en Cloudant para lo que tendremos que usar una característica llamada “filtered replication” o replicación filtrada.

Para el punto 3, tendremos que usar una vista en el servidor que utilice una característica llamada map/reduce, que no es otra cosa que un proceso de dos pasos, que está optimizado para cuando trabajamos con un software distribuido en muchos servidores, tal como es nuestro caso. Para entenderlo fácil, MAP concentra todos los documentos que deben ser manipulados para que luego REDUCE realice cierta operación sobre los mismos. En nuestro caso, lo que haremos será buscar los documentos donde registramos que una noticia ha sido leída, luego las agrupamos por noticia y contamos las ocurrencias para hacer el ordenamiento.

Ejecución:

Empecemos por un punto 2, la replicación filtrada pues no queremos que los datos viajen por la red sin control para eso tenemos que crear un filtro así que vayamos a la sección de documentos de diseño:

alldesigndocs

Encontraremos el documento por diseño por defecto y para ver su contenido vamos a hacer click en el icono de lápiz:

designdoc

El documento de diseño tiene varias secciones, podrán reconocer la sección “views” donde he definido unas vistas de prueba. Lo que necesitamos hacer es crear una sección nueva para definir nuestro filtro, el cual debe ser una función que se evalúa por cada documento que se envía en una replicación. Tengamos en cuenta que para nuestro caso, la función debe ser verdadera para cuando el campo de usuario sea igual a “all” o al usuario actual. Digamos que el campo usuario lo llamamos “owner” entonces nuestra función de filtro sería:

"filters": {
"leidos": "function(doc,req){ if (doc._deleted){ return false; } if (doc.owner){ if (doc.owner==req.query.user){ return true; }; if (doc.owner==’all’){return true;};} else {if (doc._id==’_design/news’){return true;} else {return false;}}}"
}

Hemos agregado una condición al inicio y se refiere a los documentos que no hayan sido borrados. Por diseño, Couchdb guarda incluso los documentos que borras y los sincroniza también, así que es mejor filtrarlos para que la comunicación sea más fluida y de pasadita ahorramos algo de espacio.

La replicación filtrada nos permite asegurar que los datos que se replican son los necesarios y esto es super importante por dos razones: seguridad y uso de ancho de banda. Es muy común tener apps donde los usuarios tienen información particular que les es exclusiva, como por ejemplo, tu lista de compras, tu lista de reservas, tus preferencias y así. Con este mecanismo aseguramos que cada usuario tiene lo que realmente necesita y de pasadita al dejar de enviar un montón de documentos inútiles ahorramos espacio y ancho de banda.

Y bien, agregado el filtro a nuestro documento de diseño, éste quedaría así:

{
"_id": "_design/news",
"_rev": "41-667b5e312f96dfec37e4480d2c34479d",
"views": {
"topic": {
"map": "function (doc) {\n if (doc.tipo ==\"news\") {\n emit([doc.topic], {_id:doc._id});\n }\n}"
}
},
"filters": {
"leidos": "function(doc,req){ if (doc._deleted){ return false; } if (doc.owner){ if (doc.owner==req.query.user){ return true; }; if (doc.owner==’all’){return true;};} else {if (doc._id==’_design/news’){return true;} else {return false;}}}"
}

Tengan cuidado de poner todos los valores en una sola línea y de no utilizar la comila (“) dentro de cada valor.

Y listo, ahora vamos al código de replicación y pongamos la llamada a la replicación filtrada paso a paso:

Creamos una función para obtener un id único. Para eso, nuevamente usamos ng-Cordova para usar el plugin Device, esta vez en el archivo app.js ya que tenemos que agregar un concepto propio de la plataforma Cordova:

angular.module('starter', ['ionic','controller','service','ngCordova'])
.run(function($ionicPlatform,$cordovaDevice,$cordovaSplashscreen,$state,db) {
 $ionicPlatform.ready(function() {
 // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard
 // for form inputs)
 if(window.cordova && window.cordova.plugins.Keyboard) {
 cordova.plugins.Keyboard.hideKeyboardAccessoryBar(true);
 }
 if(window.StatusBar) {
 StatusBar.styleDefault();
 };
 var id='1231232132';
 if (ionic.Platform.platform()!="win32"){
 id = $cordovaDevice.getUUID();
 };
 db.setUser(id);
 $state.go('app.news',{status:'loading'});
 });
})

Como verán, hemos incluido el módulo ngCordova como una dependencia y llamamos a $cordovaDevice.getUUID() que genera un código único por equipo móvil. Para ser exactos no es tan único, o al menos no tenemos garantía de eso, pero para nuestro ejemplo es suficiente. Quizá lo mas interesante aquí es como y cuando llamamos a esta función.

Si se dan cuenta en la línea 3, llamamos a la función $ionicPlatform.ready. Como les dije en algún momento, ionic está basado en Apache Cordova y pues Apache Cordova se toma un tiempo en estar listo para contestarnos por lo que usamos la línea 3 para “esperar” a que cargue Cordova y en ese momento, ya podemos utilizar los plugins que nos dan acceso a funciones nativas, en nuestro caso $cordovaDevice. Ahora bien, el valor del id del equipo es crítico, al punto que tendremos que hacer que el app espere a que tengamos este valor disponible y eso es lo que se hace en las líneas siguientes:

  • En la línea 16, pasamos el valor del id del equipo al servicio porque nos va a servir para sincronizar la base de datos
  • En la línea 17, cuando ya tenemos todo listo, recién pasamos al estado que muestra las noticias.

Ahora bien, ¿Que hacemos mientras Cordova termina de cargar? pues lo mejor es mostrar un Splash Screen o el logo de nuestra aplicación y para eso tenemos que agregar un nuevo estado


$stateProvider
.state('splash',{
url:"/splash:status",
cache:false,
views:{
"home":{
templateUrl:"templates/splash.html",
controller:"splashController as splash"
}
},
resolve:{
status: function($stateParams){
return $stateParams.status;
}
}
})

Obviamente, tendremos que agregar una vista y un controlador. Aquí va la vista:

<ion-view view-title="">
<ion-content>
<div class="row">
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
</div>
<div class="row row-center">
<div class="col">
</div>
<div class="col">
<img src="img/ionic.png">
</div>
<div class="col">
</div>
</div>
<div class="row-bottom">
<div class="col"></div>
<div class="col"></div>
<div class="col"></div>
</div>
</ion-content>
</ion-view>

y aquí va el controlador:


.controller('splashController',
['$state',
'status',SplashController])
function SplashController($state,status){
if (status!='loading'){
$state.go('app.news');
}
};

Y verán que hay código de más. El tema es el siguiente: si el splash screen es un estado quiere decir que cuando se muestren las noticias, el usuario podría “regresar” al splash screen con el botón Back en Android o Windows Phone, cosa que nadie quiere. Para eso, en la línea 17 del archivo app.js al ir al estado de noticias, pasamos un parámetro para indicar que llamamos al estado desde el flujo de inicio del programa. Si vamos al estado de noticias mediante el botón Back, llegará sin éste parámetro y haremos que se vaya automáticamente al estado news nuevamente.

Lo bueno de todo esto es que con este mecanismo implementado, estamos 100% seguros que Cordova está listo y todas las llamadas a plugins funcionarán correctamente.

Ahora vamos hasta la método replicate y le tendremos que agregar el usuario como un parámetro, así que también tenemos que actualizar la línea donde llamamos a la replicación en método init. Mostremos todo el archivo services.js porque hay mas cosas que explicar

angular.module('service',[])
.factory('db',['$q','$rootScope',DbService]);
 function DbService($q,$rootScope){
 var key = 'xxxxxxxx';
 var pass = 'xxxxxxxxxx';
 var remote = 'https://'+key+':'+pass+'@server.cloudant.com/news';
 var db;
 var user;
 var initiated=false; 
 function newNews(change){
 if (!change.deleted){
 if (change.doc.tipo=="news")
 $rootScope.$broadcast('db:newNews',
 {newsId:change.id,
 newsTitle:change.doc.titular});
 }
 };
 function put(doc){
 db.put(doc);
 console.log('documento grabado');
 };
 function changed(change){
 console.log('cambios en la base de datos');
 if (!initiated)
 {
 initiated=true;
 db.changes({live: true, since: 'now', include_docs: true})
 .on('change', newNews);
 };
 $rootScope.$broadcast('db:changed',change);
 };
 return {
 setUser:function(userId){
 user = userId;
 },
 init: function(){
 if (!db) db = new PouchDB('news',{adapter:'websql'});
 this.replicate();
 return true;
 },
 replicate: function(){
 if (!db) this.init();
 db.replicate.to(remote,{live:true,
 retry:true,
 filter:'news/leidos',
 query_params:{'user':user}})
 .on('change',function(info){
 console.log('Evento Chage:'+JSON.stringify(info));
 })
 .on('paused',function(){
 console.log('Evento Paused:');
 })
 .on('complete',function(info){
 console.log('Evento Complete:'+JSON.stringify(info));
 })
 .on('active',function(){
 console.log('Evento Active:');
 })
 .on('error',function(err){
 console.log('Evento error:'+JSON.stringify(err));
 })
 .on('denied',function(info){
 console.log('Evento error:'+JSON.stringify(info));
 });
 db.replicate.from(remote,{live:true,
 retry:true,
 filter:'news/leidos',
 query_params:{'user':user}})
 .on('paused',changed);
 },
 get: function(id){
 if (!db) this.init();
 return db.get(id);
 },
 getCats: function(){
 if (!db) this.init();
 return db.allDocs(
 {startkey:'cat_',
 endkey:'cat_\uffff',
 include_docs:true});
 },
 getNews: function(catId){
 if (!db) this.init();
 if (catId)
 return db.query('news/topic',
 {key:[catId],
 include_docs:true,
 descending:true});
 else return db.allDocs({startkey:'news_\uffff',
 endkey:'news_',
 descending: true,
 include_docs:true});
 },
 putRead: function(pnewsId){
 var doc = {
 _id :'read_'+user+'_'+pnewsId,
 newsId: pnewsId,
 fecha:new Date().toString(),
 tipo:'read',
 owner:user 
 };
 put(doc);
 }
 }
 };

Veamos, vayamos en orden:

  • Primero, tenemos que agregar una función para recoger el dato de usuario, para eso hemos agregado una variable interna en la línea 8 y un método público en la línea 33.
  • Seguidamente, necesitamos grabar registros y para eso tenemos un método privado bastante genérico en la línea 18. Pero como necesitamos agregar un documento por lo que también agregamos un método público en la línea 94 donde verán que usamos una plantilla para crear el documento JSON que describimos al comienzo donde solamente cambiamos los valores de los campos para luego llamar al método interno genérico.
  • Finalmente, agregamos un comando de replicación. Los atentos se habrán dado cuenta que el comando de replicación que hemos venido usando es db.replicate.from lo cual significa que la dirección de los datos es del servidor al móvil, así que faltaría agregar la dirección del móvil al servidor. Para hacer un poco más gráfico el proceso de debug, hemos agregado todos los eventos del proceso. NOTA: Hay un comando db.sync que replica en ambos sentidos, pero prefiero separar las dos direcciones porque quiero capturar solamente los eventos solamente cuando “llegan” nuevas noticias del servidor.

Tanto lío y la pregunta que se cae de madura: ¿Ya podemos grabar y sincronizar? Aún no. Falta que la llave que usamos para conectarnos a la base de datos en Cloudant tenga permisos para escribir. Para eso vamos a Cloudant y hacemos click en el icono de candado al costado del nombre de nuestra base de datos:

Security

permissions

En el segundo gráfico podrán verificar que el key que están utilizando tenga el privilegio Writer.

Ahora, agreguemos la grabación cuando una noticia es leída en el controlador:


function DetailController(db,$scope,$state,noticia){
$scope.event= noticia;
db.putRead(noticia._id);
$scope.$on('db:changed',
function(event,changed){
$state.go('.', null, { reload: true });
console.log('Recargando Noticia');
});
};

En la línea 34, hemos agregado la dependencia al servicio db y en la línea 36 hemos agregado la bendita línea para grabar que hemos leído una noticia.

Y con eso ya podemos ver como nuestra aplicación hace lo siguiente:

  • Graba un documento del tipo ‘read’ cada vez que alguien lee una noticia. Como la clave del documento es única, si entramos a una noticia ya leída, la inserción simplemente fallará y eso esta bien porque asegura que sólo tendremos un documento tipo ‘read’ por noticia.
  • Los documentos serán sincronizados con la base de datos en Cloudant de forma automática.
  • Finalmente, manejamos el evento Ready para verificar que Cordova ya está listo y podemos usar plugins que nos den acceso a servicios nativos.

El Post ya se hizo largo, así que en uno próximo haremos un reporte de las noticias más leídas y ya que el código se ha hecho algo grande, vamos a ponerlo en un repositorio Github.

Por mientras, traten de actualizar su código para que tengan la funcionalidad desarrollada hasta el momento. Y sobre todo revisen los eventos que genera la replicación móvil – Servidor.


  1. 1 Ionic Framework, PouchDB y Cloudant: la combinación perfecta totalmente expuesta | Víctor Pease

    […] Part 12 […]




Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s



%d bloggers like this: