LabsEPS parte 1: el modelo
by Elias Hernandis • Published Jan. 24, 2019 • Tagged data models, programming
Muy a menudo 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 argument |
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 allá 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
.
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 object. Optionally, it can be initialized 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.
-
Linux se ejecuta en innumerables 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. ↩
-
Mi intención es hablar sobre el despliegue de la aplicación en una futura entrada. Si las entregas lo permiten. ↩