LabsEPS parte 1: el modelo

Muy de vez en cuando me encuentro vagando por los pasillos de la tercera planta de la escuela en busca de un laboratorio libre. Muchas veces, el laboratorio que está libre es el último que se me ocurre comprobar. Este es el primer post de una serie sobre el desarrollo de una aplicación que permite consultar qué laboratorios están libres.

El desarrollo de esta aplicación tiene lugar en GitHub: https://github.com/knifecake/labseps

La escuela publica los horarios en su web. Además también hay horarios de cada laboratorio en las puertas de estos y a veces funciona una pantalla que está en la puerta de la tercera planta. La idea es conseguir estas tablas pero por desgracia en la web solo están colgados los horarios por grupos y cuatrimestres. Aún así, es posible que con un poco de web scrapping sea posible obtener estas tablas. Es más, una vez tengamos los datos podremos obtener respuestas a preguntas como ¿qué laboratorios hay libres mañana por la mañana? o ¿qué laboratorios están libres el resto de la tarde? En esta serie (que es posible que tenga una duración de una sola entrada) voy a hablar del proceso de desarrollo de tal aplicación.

Concretemos lo que queremos

El objetivo más básico es tener una aplicación que nos diga qué laboratorios libres hay en este momento. Además también estaría bien que pudiera decir qué laboratorios va a haber libres en las próximas horas o días. Nuestro punto de partida son los datos disponibles en la página web de la Escuela. Una vez los tengamos en un formato que podamos tratar solo tenemos que extraer la información que necesitamos de ellos.

Para que la aplicación sea de utilidad tendrá que cumplir una serie de requisitos. Lo más importante es que la información sea correcta y precisa. La Escuela publica solamente los horarios de los tres grados que oferta pero en los laboratorios ocurren muchas más cosas que las prácticas de los estudiantes de grado: hay másteres, recuperaciones de clases y algún concurso. Además, es posible que los datos que hay disponibles en la web no estén enteramente actualizados o no sean completos. Por desgracia no podemos hacer mucho para mitigar estos problemas ya que esta información solo la tiene la Escuela. Como máximo podremos corregir o añadir información al conjunto de datos que obtengamos. Esto es un proceso manual muy costoso pero teniendo en cuenta que los horarios se actualizan una vez al año no es una tarea imposible.

A parte de esto la aplicación tiene que ser fácil de usar, tiene que responder muy rápido y ser confiable &emdash; no como el Moodle, que se cae siempre antes de un examen. Tiene que estar optimizada para móviles ya que la gente la utilizará de camino a los laboratorios. Hoy en día hay cientos de aplicaciones en cada móvil y los usuarios suelen mostrar reticencia a la hora de instalar otra más. Además nuestra aplicación será muy ligera y se utilizará durante periodos cortos de tiempo, no necesitará acceder a las APIs nativas de los sistemas operativos móviles ni ocupará mucho espacio. Por estas razones me parece que lo más lógico es diseñar una aplicación web. Si el tamaño es suficientemente pequeño nadie se preocupará del tráfico consumido a la hora de cargar la aplicación. Además, al no tener que instalarla será mucho más fácil de compartir. Pero lo más importante es que no habrá que desarrollar una aplicación para cada sistema operativo móvil ni habrá que esperar a que Apple apruebe la aplicación o las actualizaciones para poder lanzarla en el App Store. El coste de alojamiento se puede reducir a cero si la base de datos que extraemos de la página web de la escuela es suficientemente pequeña. Basta con incrustarla en la propia página web, implementar la lógica en JavaScript y servir todos los archivos desde GitHub Pages.

Pero bueno, antes de pensar en cómo vamos a lanzar la aplicación, empecemos por cómo vamos a obtener los datos y cómo los vamos a tratar una vez los hayamos adquirido.

Estructura de la aplicación

Hay dos partes claras en esta aplicación. Primero, un web scrapper que navegará a la página de la Escuela e irá leyendo los datos de horarios. Por otro lado tendremos un servidor web que accederá a estos datos y los presentará a los usuarios de forma útil. El web scrapper se ejecutará poco (igual solo una vez al año) mientras que el servidor web se ejecutará continuamente. Visto esto, lo más fundamental es planificar de qué manera podrán compartir la información estas dos partes, es decir, cual va a ser el modelo que utilizaremos para los datos. Decidirse por un modelo al principio del desarrollo es muy beneficioso porque permite que varios equipos puedan trabajar en distintas partes de la aplicación y además ayuda a que los requisitos de la aplicación sean cada vez más claros. En nuestro caso el modelo es simple: tiene que reflejar los datos disponibles en la página de la Escuela y además tiene que permitir el acceso a los mismos de manera suficientemente flexible para poder contestar a las preguntas que planteábamos antes.

El modelo

En la página de la Escuela, al pinchar sobre una clase en un horario aparece otra ventana en la que se encuentran el lugar donde se celebrará la clase entre otros datos. Como mínimo necesitamos quedarnos con un lugar, una hora de comienzo y una hora de finalización por cada clase. Ya que vamos a invertir bastante tiempo en desarrollar el web scrapper, aprovecharemos para quedarnos con todos los atributos que podamos. Estos serán nombre, abreviatura, código, grupo, lugar, profesor, horas de inicio/finalización, día de la semana y cuatrimestre. Como tendremos muchas clases y todas con los mismos atributos parece ser que un modelo relacional sería apropiado. Además el uso de bases de datos relacionales nos permitirá utilizar SQL con lo que nos ahorraremos la implementación de las búsquedas. Además, aunque las entidades centrales en los horarios son las clases, en nuestra aplicación son los lugares así que parece lógico separar la información sobre el lugar donde se celebran las clases de los demás datos asociados a estas. Así, propongo el siguiente modelo relacional, con una tabla para las clases:

Field name Field Type Source
short_name string Content of the td element in the main table
semester integer Content of the h1 element in the main table
code string onclick handler first argument
starts_at string onclick handler second argument
ends_at string onclick handler third arguement
day_of_week integer onclick handler fourth argument
course_name string Asignatura in time slot detail
group string Grupo in time slot detail
professors string Profesores in time slot detail
room_id integer Aula in time slot detail

y otra para los lugares:

Field name Field Type Source
id integer Auto increment
name string Aula in time slot detail

Además, he añadido los tipos de datos de las distintas columnas de las tablas de la base de datos. Aunque puedan parecer simplones, utilizar tipo string para las horas y los días es nos da flexibilidad y además, como en las tablas que publica la Escuela las horas están en formato 24h no necesitamos hacer nada especial para la ordenación. Para los días de la semana y el cuatrimestre hemos ido un poco más alla y decidimos codificarlos con números para evitar repetir tantas veces la misma información. Aun así, la tasa de duplicidad es muy alta en campos como profesores nombre o grupo. Este es el aspecto más reprochable de este modelo pero elegimos hacerlo de esta manera porque evita tener que implementar excesivas relaciones en el código (luego vemos cómo llevamos este modelo a código) y porque el tamaño de los datos es pequeño (la base de datos acaba ocupando solo 188kB).

La tercera columna indica cual va a ser la fuente canónica de cada atributo. En ocasiones es posible obtener esta información de varios lugares por lo que definimos cual va a ser la fuente canónica por si hubiera alguna incoherencia en el futuro.

Persistencia

El acceso a la base de datos será únicamente de lectura una vez se hayan obtenido los datos. Los datos ya están disponibles online así que no pasa nada si distribuimos el conjunto de datos completo a todos los usuarios en lugar de ofrecer únicamente la posibilidad de hacer consultas. De hecho, esto puede ser de utilidad para alguien que quiera mejorar la aplicación o acceder a los datos para cualquier otro proposito. Es por esto que lo mejor es elegir una solución abierta y ampliamente disponible. La mejor en este caso es probablemente SQLite. SQLite se ejecuta en más ordenadores del mundo que Linux1 (son muchos), es completamente abierto y además de guardar los datos permite hacer consultas sobre ellos en SQL, con lo que nos ahorramos implementarlas nosotros a mano. Además, se ejecuta de manera nativa en los navegadores con lo que podremos distribuir la base de datos como un recurso de la aplicación web y así evitar tener que configurar un servidor.2

Representación del modelo en código

A la hora de desarrollar tanto el web scrapper como el servidor web tendremos que comunicarnos con la base de datos SQLite. Esto es sencillo ya que hay librerías para casi todos los lenguajes de programación. Nosotros elegimos Python básicamente porque me apetece pero no es una mejor elección que Ruby o que Javascript por ejemplo. Al ser un lenguaje orientado a objetos, la opción más natural es representar cada entidad del modelo con una clase. Además para interactuar con la base de datos definiremos otra clase, una clase repositorio, que implementará métodos para cada una de las acciones que necesitemos llevar a cabo al hacer consultas. Esto es lo que se conoce como el patrón repositorio y bien implementado nos puede dar una manera cómoda de utilizar la base de datos.

Admito que quizá es un poco innecesario montar todo este aparato ya que si finalmente implementamos la lógica de las consultas en JavaScript para que lo ejecute el navegador todo este código no nos valdrá. Aún así decido hacerlo por las siguientes razones:

  • En cualquier caso necesitamos escribir código para comunicarnos con la base de datos. Qué menos que esté organizado.
  • Otra posibilidad hubiera sido utilizar una librería como SQLAlchemy o algún otro ORM pero me apetecía explorar este patrón de diseño (es más fácil de implementar que un ORM y a la larga acaba funcionando mejor, aunque no llegaremos a tales extremos en esta aplicación).
  • El hecho de no utilizar una librería en este caso me ahorra tiempo ya que al no haber utilizado ninguna previamente en Python tendría que aprenderla (admito también que esta es una razón pobre).

Vistas estas consideraciones generales pasamos al plan de acción. La idea es implementar dos clases base o abstractas: Entity y EntityRepository. La primera se corresponde con la clase de la que heredaran todas las que representen a entidades de nuestro modelo, a saber: Lesson y Room. La segunda, EntityRepository, es la clase de la que heredarán todas las clases que representen a los repositorios. En este caso habrá dos repositorios: LessonRepository y EntityRepository.

Entity-attributes+__setattr__()+__getattr__()+__str__()EntityRepository-table_name-primary_key-column_dictionary+find(id: primary_key) : Entity+insert(e: Entity): primary_key+update(...)+delete(...)+create_table()+drop_table()+entity_factory()RoomLessonLessonRepositoryRoomRepository

Las clases de entidad

Los métodos disponibles en las clases de entidad son mínimos en este ejemplo ya que en estas clases normalmente incorporaríamos los elementos de la lógica del problema que estamos resolviendo como validación, atributos derivados y más. En este caso nuestro problema es tan simple que las clases de entidad son meros envoltorios de diccionarios o hashes. Aprovechamos que python nos permite definir los métodos __getitem__ y __setitem__ para que podamos acceder a los atributos de las entidades utilizando la notación propia de los diccionarios. Es decir, que implementados estos métodos podremos acceder al atributo a de la entidad e con la sintaxis e['a']. La implementación de estos métodos consiste básicamente en crear aliases a los métodos propios del diccionario de atributos que tendrá cada clase entidad. Así, la clase Entity queda de la siguiente manera:

class Entity:
    def __init__(self, attrs=None):
        if attrs is None:
            self.attrs = {}
        else: self.attrs = attrs

    def __getitem__(self, k):
        return self.attrs.get(k, None)

    def __setitem__(self, k, v):
        self.attrs[k] = v

    def __str__(self):
        return "<%s (%s): %s>" % (type(self).__name__, str(id(self)), str(self.attrs))

Hemos añadido también un método __str__ que nos ha sido de ayuda al tener que depurar. Observemos que en la definición del constructor __init__ inicializamos el parámetro attrs a None. Esto es porque si lo inicializáramos a un diccionario vacío con def __init__(self, attrs={}) entraría en juego un comportamiento algo raro de Python que consiste en que el diccionario se inicializaría vacío solo la primera vez y en sucesivas llamadas tendríamos referencias al mismo diccionario que se inicializó. Para más detalles podemos consultar este excelente artículo. La definición de las clases concretas Lesson y Room se reduce a marcar la herencia:

class Lesson(Entity):
    pass

class Room(Entity):
    pass

Las clases repositorio

Estas clases son realmente el centro del patrón repositorio. Abstraen la conexión a la base de datos. Si el día de mañana cambiáramos el sistema de gestión de bases de datos que utilizamos, solo sería necesario actualizar estas clases para que la aplicación siguiera funcionando. Además, en este caso en concreto, añadimos métodos para crear la base de datos porque nos resulta más cómodo. En una aplicación más grande sería apropiado utilizar migraciones para esto. No voy a entrar en detalles de implementación porque en muchos sitios se explica cómo implementar el patrón repositorio pero sí merece la pena comentar dos aspectos de la implementación que hacemos nosotros.

Para implementar la clase abstracta EntityRepository necesitamos dejar algunos detalles de implementación a las clases hijas que heredarán de esta. Estos son datos como el nombre de la tabla en la base de datos, las columnas que tiene (la estructura o schema) y cuál es la clave primaria. En Python no hay sintaxis para marcar una clase como abstracta pero conseguimos el mismo resultado definiendo métodos para cada uno de estos datos (en otros lenguajes podríamos dejarlos como atributos) pero sustituyendo el cuerpo de los mismo por una sentencia que lanza la excepción NotImplementedError.

class EntityRepository:
    @abstractmethod
    def table_name(self):
        """Returns the name of the table this repository mirrors."""
        raise NotImplementedError

Otro aspecto que necesitamos dejar por definir es las entidades concretas que deben devolver los métodos de lectura del repositorio, esto es, qué tipo (o de qué clase) deben ser los objetos que devuelva find dada una clave primaria. La manera en la que hacemos esto es definiendo un factory method (ver Factory Method design pattern) en la clase abstracta que debe retornar un objeto vacío del tipo de entidad con el que tratamos.

class EntityRepository:
    @abstractmethod
    def entity_factory(self, attrs):
        """Returns an entity objec. Optionally, it can be initailized with the given attributes."""
        raise NotImplementedError

Así, las clases hijas quedarían de la siguiente manera:

class RoomRepository(EntityRepository):
    def __init__(self, connection_string):
        super(RoomRepository, self).__init__(connection_string)

    def table_name(self):
        return 'rooms'

    def column_dict(self):
        return {'id': 'INTEGER PRIMARY KEY', 'name': 'TEXT'}

    def primary_key(self):
        return 'id'

    def entity_factory(self, attrs):
        return Room(attrs)

El otro aspecto que merece la pena señalar está relacionado con la seguridad. Aunque en nuestro caso la aplicación se ejecutará enteramente dentro del navegador, no está mal tener en cuenta posibles ataques de SQL injection sobre todo de cara a la parte de web scrapping. Un ataque de este tipo consiste en manipular las partes de las consultas que dependen de entrada del usuario de manera que cuando se ejecuten en el gestor de bases de datos tengan un efecto no deseado. Por ejemplo, si quisiéramos buscar una persona por nombre utilizaríamos la siguiente consulta: SELECT * FROM people WHERE name='datos proporcionados por el usuario';. Un usuario malicioso podría darnos el nombre '; DROP TABLE people;-- y de esta manera conseguir que la consulta que realmente se ejecutase fuera la siguiente: SELECT * FROM people WHERE name=''; DROP TABLE people;--. En realidad el usuario ha conseguido que se ejecuten dos consultas. La primera no le ha servido para nada pero con la segunda ha conseguido que perdamos los datos que teníamos. Este problema ya lleva décadas resuelto en cualquier librería de bases de datos pero no es algo que debamos dar por sentado. En nuestro caso debemos utilizar la sustitución de parámetros que pone a nuestra disposición el paquete sqlite3 de Python.

Como anécdota, en el lenguaje de programación PHP se implementó una función encargada de sanear los parámetros proporcionados por el usuario llamada mysql_escape_string. Tristemente esta función no tenía el efecto desado y se implementó otra, mysql_real_escape_string, que sí que saneaba apropiadamente los datos. En más de un caso los desarrolladores no actualizaron su código y vimos efectos catastróficos.

Conclusión (por ahora)

Tenemos ya una estructura base para poder comenzar a desarrollar el web scrapper y además es suficientemente robusta para poder acomodar posibles futuras extensiones. En la próxima entrada de la serie hablaré sobre cómo enfocar el web scrapping de manera responsable y eficiente.

  1. Linux se ejecuta en innúmerables servidores y es la base de sistemas operativos como MacOS o Android pero SQLite está presente no solo en los ordenadores que llevan Linux sino que también lo está en Windows y en ocasiones estáticamente enlazado en algunas aplicaciones. 

  2. Mi intención es hablar sobre el despliegue de la aplicación en una futura entrada. Si las entregas lo permiten.