En DoorDash, la mayor parte de nuestro backend se basa actualmente en Django y Python. La base de código Django de nuestros primeros días ha evolucionado y madurado a lo largo de los años a medida que nuestro negocio ha crecido. Para seguir creciendo, también hemos empezado a migrar nuestra aplicación monolítica hacia una arquitectura de microservicios. Hemos aprendido mucho sobre lo que funciona bien y lo que no con Django, y esperamos poder compartir algunos consejos útiles sobre cómo trabajar con este popular framework web.
Cuidado con las "aplicaciones
Django tiene este concepto de "aplicaciones", que se describen vagamente en la documentación como "un paquete de Python que proporciona un conjunto de características". Si bien tienen sentido como bibliotecas reutilizables que se pueden conectar a diferentes proyectos, su utilidad en cuanto a la organización de su código de aplicación principal es menos clara.
Hay algunas implicaciones de cómo defines tus "aplicaciones" que debes tener en cuenta. La más importante es que Django hace un seguimiento de las migraciones de modelos por separado para cada aplicación. Si tienes ForeignKey's enlazando modelos a través de diferentes apps, el sistema de migración de Django intentará inferir un gráfico de dependencias para que las migraciones se ejecuten en el orden correcto. Desafortunadamente, este cálculo no es perfecto y puede llevar a algunos errores o incluso dependencias circulares complejas, especialmente si tienes muchas apps.
Originalmente organizamos nuestro código en un montón de "aplicaciones" separadas para organizar diferentes funcionalidades, pero teníamos un montón de ForeignKey's entre aplicaciones. Las migraciones que habíamos comprobado terminaban ocasionalmente en un estado en el que se ejecutaban bien en producción, pero no en desarrollo. En el peor de los casos, ni siquiera se reproducirían sobre una base de datos en blanco. Cada sistema puede tener una permutación diferente de los estados de migración para diferentes aplicaciones, y la ejecución de manage.py migrate
puede no funcionar con todas ellas. Al final, nos dimos cuenta de que tener todas estas aplicaciones por separado generaba una complejidad y unos dolores de cabeza innecesarios.
Rápidamente descubrimos que si teníamos estas ForeignKey cruzando diferentes aplicaciones, entonces quizás no eran realmente aplicaciones separadas para empezar. De hecho, sólo teníamos una "aplicación" que podía organizarse en diferentes paquetes. Para reflejar mejor esto, desechamos nuestras migraciones y migramos todo a una única "app". Este proceso no fue la tarea más fácil de llevar a cabo (Django también utiliza nombres de aplicaciones para rellenar ContentType's y en el nombramiento de tablas de base de datos - más sobre esto más adelante), pero nos alegramos de haberlo hecho. También significó que todas nuestras migraciones tuvieron que ser linealizadas, y aunque eso tuvo sus desventajas, encontramos que fueron superadas por el beneficio de tener un sistema de migración predecible y estable.
En resumen, estas son nuestras sugerencias para cualquier desarrollador que comience un proyecto Django:
- Si realmente no entiendes el sentido de las aplicaciones, ignóralas y quédate con una única aplicación para tu backend. Puedes organizar una base de código en crecimiento sin usar aplicaciones separadas.
- Si quieres crear aplicaciones separadas, deberás tener muy en cuenta cómo definirlas. Sé muy explícito y minimiza las dependencias entre las diferentes aplicaciones. (Si estás planeando migrar a microservicios más adelante, puedo imaginar que "apps" podría ser una construcción útil para definir precursores de un futuro microservicio).
Organice sus aplicaciones dentro de un paquete
Ya que estamos hablando de aplicaciones, hablemos un poco de la organización de paquetes. Si sigues el tutorial "Getting started" de Django, el paquete manage.py startapp
creará una "app" en el nivel superior del directorio del proyecto. Por ejemplo, una aplicación llamada foo sería accesible como import foo.models…
. Le recomendamos encarecidamente que ponga sus aplicaciones (y todo su código Python) en un paquete Python, es decir, el paquete que se crea con django-admin startproject
.
En el ejemplo del tutorial de Django, en lugar de:
mysite/
mysite/
__init__.py
polls/
__init__.py
Te lo sugerimos:
mysite/
mysite/
__init__.py
polls/
__init__.py
Se trata de un cambio pequeño y sutil, pero evita conflictos de espacio de nombres entre tu aplicación y las bibliotecas Python de terceros. En Python, los módulos de nivel superior van a un espacio de nombres global y necesitan tener un nombre único. Como ejemplo, la biblioteca Python de un proveedor que utilizamos, Segment, se llama en realidad analytics
. Si tuviéramos un analytics
definida como módulo de nivel superior, no habría forma de distinguir entre los dos paquetes en el código.
Nombre explícitamente las tablas de su base de datos
Tu base de datos es más importante, más duradera y más difícil de cambiar a posteriori que tu aplicación. Sabiendo eso, tiene sentido que usted debe ser muy intencional acerca de cómo está diseñando su esquema de base de datos, en lugar de permitir que un marco web para tomar esas decisiones por usted.
Aunque en gran medida controlas el esquema de la base de datos en Django, hay algunas cosas que maneja por defecto que deberías conocer. Por ejemplo, Django genera automáticamente un nombre de tabla para tus modelos, con el patrón de <app_name>_<model_name_lowercased>
. En lugar de confiar en estos nombres autogenerados, debería considerar definir su propia convención de nomenclatura y nombrar todas sus tablas manualmente, utilizando Meta.db_table
.
class Foo(Model):
class Meta:
db_table = 'foo'
La otra cosa a tener en cuenta es ManyToManyFields
. Django facilita la generación de relaciones hombre a hombre utilizando este campo y creará la tabla de unión con nombres de tabla y columna generados automáticamente. En lugar de hacer esto, le recomendamos encarecidamente que cree y nombre las tablas de unión manualmente (utilizando la función through
). Así será mucho más fácil acceder directamente a la tabla y, francamente, nos hemos dado cuenta de que es molesto tener tablas ocultas..
Estos pueden parecer detalles menores, pero desacoplar el nombre de tu base de datos de los detalles de implementación de Django es una buena idea porque va a haber otras cosas que toquen tus datos además del ORM de Django, como los almacenes de datos. Esto también te permite renombrar tus clases modelo más tarde si cambias de opinión. Finalmente, simplificará cosas como dividir las tablas en servicios separados o la transición a un framework web diferente.
Evitar GenericForeignKey
Si puede evitarlo, evite utilizar GenericForeignKey's. Perderá funciones de consulta de bases de datos como las uniones (select_related
) y funciones de integridad de datos como restricciones de clave externa y eliminaciones en cascada. Utilizar tablas separadas suele ser una solución mejor, y puedes aprovechar los modelos base abstractos si buscas reutilizar el código.
Dicho esto, hay situaciones en las que todavía puede ser útil tener una tabla que puede apuntar a diferentes tablas. Si es así, sería mejor que hicieras tu propia implementación y no es tan difícil (sólo necesitas dos columnas, una para el ID del objeto, la otra para definir el tipo). Una cosa que no nos gusta de GenericForeignKey es que depende del framework ContentTypes de Django, que almacena los identificadores de las tablas en una tabla de mapeo llamada django_contenttypes
.
Esa tabla no es muy divertida de manejar. Para empezar, utiliza el nombre de tu aplicación (app_label
) y la clase modelo de Python (model
) como columnas para asignar un modelo Django a un id entero, que luego se almacena dentro de la tabla con el GFK. Si alguna vez mueves modelos entre aplicaciones o renombras tus aplicaciones, vas a tener que hacer algunos parches manuales en esta tabla. Y lo que es más importante, tener una tabla común que contenga estas asignaciones GFK complicará mucho las cosas si alguna vez quieres mover tus tablas a servicios y bases de datos independientes. Al igual que en la sección anterior sobre la asignación explícita de nombres a las tablas, también debe poseer y definir sus propios identificadores de tabla. Si desea utilizar un número entero, una cadena o cualquier otra cosa para hacer esto, cualquiera de ellos es mejor que confiar en un ID arbitrario definido en alguna tabla aleatoria.
Migraciones seguras
Si estás usando Django 1.7 o posterior y estás usando una base de datos relacional, probablemente estés usando el sistema de migración de Django para gestionar y migrar tu esquema. A medida que empieces a funcionar a escala, hay algunos matices importantes a tener en cuenta sobre el uso de las migraciones de Django.
En primer lugar, tendrá que asegurarse de que sus migraciones son seguras, es decir, que no causarán tiempos de inactividad cuando se apliquen. Supongamos que su proceso de despliegue implica llamar a manage.py migrate
automáticamente antes de desplegar el último código en sus servidores de aplicaciones. Una operación como añadir una nueva columna será segura. Pero no debería ser demasiado sorprendente que eliminar una columna rompa las cosas, ya que el código existente todavía estaría haciendo referencia a la columna inexistente. Incluso si no hay líneas de código que hagan referencia al campo eliminado, cuando Django obtiene un objeto (por ejemplo Model.objects.get(..)
bajo el capó realiza una SELECT
en cada columna definida en el modelo. Como resultado, prácticamente cualquier acceso de Django ORM a esa tabla lanzará una excepción.
Puedes evitar este problema asegurándote de ejecutar las migraciones después de desplegar el código, pero esto significa que los despliegues tienen que ser un poco más manuales. Puede resultar complicado si los desarrolladores han realizado varias migraciones antes de una implementación. Otra solución es convertir estas y otras migraciones peligrosas en migraciones "no-op", haciendo que las migraciones sean puramente operaciones de "estado". En ese caso, tendrá que realizar la operación DROP
operaciones tras el despliegue.
class Migration(migrations.Migration):
state_operations = [ORIGINAL_MIGRATIONS]
operations = migrations.SeparateDatabaseAndState(
state_operations=state_operations
)
Por supuesto, la eliminación de columnas y tablas no es la única operación con la que hay que tener cuidado. Si tienes una gran base de datos de producción, hay muchas operaciones inseguras que pueden bloquear tu base de datos o tablas y provocar tiempos de inactividad. Los tipos específicos de operaciones dependerán de la variante de SQL que esté utilizando. Por ejemplo, con PostgreSQL, añadir columnas con un índice o que no sean anulables a una tabla grande puede ser peligroso. Aquí hay un artículo bastante bueno de BrainTree que resume algunas de las migraciones peligrosas en PostgreSQL.
Aplastar las migraciones
A medida que tu proyecto evoluciona y acumula más y más migraciones, éstas tardarán más y más en ejecutarse. Por diseño, Django necesita reproducir incrementalmente cada migración empezando por la primera para construir su estado interno del esquema de la base de datos. Esto no sólo ralentizará los despliegues de producción, los desarrolladores también tendrán que esperar cuando configuren inicialmente su base de datos de desarrollo local. Si tienes múltiples bases de datos, este proceso llevará aún más tiempo, porque Django reproducirá todas las migraciones en cada base de datos, independientemente de si la migración afecta a esa base de datos.
A falta de evitar las migraciones de Django por completo, la mejor solución que se nos ha ocurrido es hacer una limpieza de primavera periódica y "aplastar" las migraciones. Una opción es probar la función de aplastamiento incorporada en Django. Otra opción, que ha funcionado bien para nosotros, es hacer esto manualmente. Suelta todo en la carpeta django_migrations
eliminar los archivos de migración existentes y ejecutar manage.py makemigrations
para crear migraciones nuevas y consolidadas.
Reducir la fricción de la migración
Si muchas docenas de desarrolladores están trabajando en la misma base de código Django, puedes encontrarte frecuentemente con condiciones de carrera con la fusión en migraciones de bases de datos. Por ejemplo, considere un escenario en el que el historial de migración actual en master se parece:
0001_a
0002_b
Ahora supongamos que el ingeniero A genera migración 0003_c
en su rama local, pero antes de que pueda fusionarlo, el ingeniero B llega primero y comprueba la migración 0003_d
. Si el ingeniero A fusiona ahora su rama, cualquiera que intente ejecutar migraciones después de extraer el último código se encontrará con el error "Migraciones en conflicto detectadas; múltiples nodos hoja en el gráfico de migración: (0003_c, 0003_d)".
Como mínimo, esto hace que las migraciones se tengan que linealizar manualmente o crear una migración merge, lo que provoca fricciones en el proceso de desarrollo del equipo. El equipo de ingenieros de Zenefits analiza este problema con más detalle en una entrada de blog, de la que extrajimos inspiración para mejorarlo.
En menos de una docena de líneas de código, fuimos capaces de resolver una forma más general de este problema en el caso de que tengamos múltiples Django aplicaciones. Para ello, anulamos la función handle()
de nuestro makemigrations
para generar un manifiesto de migración de varias aplicaciones:
Aplicando esto al ejemplo anterior, el archivo de manifiesto tendría una entrada doordash: 0002_b
para nuestra aplicación. Si generamos un nuevo archivo de migración 0003_c
fuera de HEAD, el diff en el archivo de manifiesto se aplicará limpiamente y se puede fusionar tal cual:
- doordash: 0002_b
+ doordash: 0003_c
Sin embargo, si las migraciones son obsoletas, como si un ingeniero sólo tiene 0001_a
localmente y genera una nueva migración 0002_d
el archivo de manifiesto diff no se aplicará limpiamente y por lo tanto Github declararía que hay conflictos de fusión:
- doordash: 0001_a
+ doordash: 0002_d
El ingeniero sería entonces responsable de resolver el conflicto antes de que Github permita la fusión del pull request. Si tienes pruebas de integración en las que las fusiones de código están bloqueadas (lo que cualquier empresa de ese tamaño debería hacer), esta es también otra motivación para mantener el conjunto de pruebas rápido.
Evitar modelos gordas
Django promueve un patrón de "modelo gordo" donde pones la mayor parte de tu lógica de negocio dentro de los métodos del modelo. Si bien esto es lo que usamos inicialmente, e incluso puede ser bastante conveniente, nos dimos cuenta de que no escala muy bien. Con el tiempo, las clases modelo se hinchan de métodos y se hacen extremadamente largas y difíciles de leer. Los mixins son una forma de mitigar un poco la complejidad, pero no parecen la solución ideal.
Este patrón puede ser un poco incómodo si tienes alguna lógica que realmente no necesita operar en una instancia de modelo completa obtenida de la base de datos, sino que sólo necesita la clave primaria o una representación simplificada almacenada en la caché. Además, si alguna vez quieres dejar el ORM de Django, acoplar tu lógica a los modelos va a complicar ese esfuerzo.
Dicho esto, la verdadera intención detrás de este patrón es mantener la API/vista/controlador ligeros y libres de lógica excesiva, que es algo que recomendamos encarecidamente. Tener lógica dentro de los métodos del modelo es un mal menor, pero puede que quieras considerar mantener los modelos ligeros y centrados en la capa de datos. Para que esto funcione, necesitarás idear un nuevo patrón y poner tu lógica de negocio en alguna capa que esté entre la capa de datos y la capa API/presentacional.
Cuidado con las señales
El framework de señales de Django puede ser útil para desacoplar eventos de acciones, pero un caso de uso que puede ser problemático son pre/post_save
señales. Pueden ser útiles para cosas pequeñas (por ejemplo, comprobar cuándo invalidar una caché), pero poner demasiada lógica en las señales puede hacer que el flujo del programa sea difícil de rastrear y leer. Pasar argumentos personalizados o información a través de una señal no es realmente posible. También es muy difícil, sin el uso de algunos hacks, desactivar una señal de disparo en determinadas condiciones (por ejemplo, si desea actualizar en masa algunos modelos sin desencadenar señales costosas).
Nuestro consejo es que limites el uso de estas señales y, si las usas, evites poner en ellas algo que no sea lógica simple y barata. También debes mantener estas señales organizadas en un lugar predecible y consistente (por ejemplo, cerca de donde se definen los modelos), para que tu código sea fácil de leer.
Evite utilizar el ORM como interfaz principal para sus datos
Si estás creando y actualizando directamente objetos de base de datos desde muchas partes de tu código base con llamadas a la interfaz ORM de Django (Model.objects.create()
o Model.save()
), es posible que desee revisar este enfoque. Hemos descubierto que utilizar el ORM como interfaz principal para modificar datos tiene algunos inconvenientes.
El principal problema es que no existe una forma limpia de realizar acciones comunes cuando se crea o actualiza un modelo. Supongamos que cada vez que se crea el ModeloA, se quiere crear también una instancia del ModeloB. O quieres detectar cuando un determinado campo ha cambiado de su valor anterior. Aparte de las señales, su única solución es sobrecargar un montón de lógica en Model.save()
que puede resultar muy difícil de manejar e incómodo.
Una solución para esto es establecer un patrón en el que enrutes todas las operaciones importantes de la base de datos (crear/actualizar/borrar) a través de algún tipo de interfaz simple que envuelva la capa ORM. Esto te da puntos de entrada limpios para añadir lógica adicional antes o después de los eventos de la base de datos. Además, desacoplar un poco el código de tu aplicación de la interfaz del modelo te dará la flexibilidad de moverte fuera del ORM de Django en el futuro.
No almacenar en caché los modelos de Django
Si estás trabajando en escalar tu aplicación, probablemente estés aprovechando una solución de caché como Memcached o Redis para reducir las consultas a la base de datos. Aunque puede ser tentador almacenar en caché instancias de modelos de Django, o incluso los resultados de Querysets enteros, hay algunas advertencias que debes tener en cuenta.
Si migras tu esquema (añades/cambias/eliminas campos de tu modelo), Django en realidad no maneja esto con mucha gracia cuando se trata de instancias en caché. Si Django intenta leer una instancia de modelo que fue escrita en la caché desde una versión anterior del esquema, prácticamente morirá. Bajo el capó, se está deserializando un objeto de la caché, pero ese objeto será incompatible con el código más reciente. Esto es más un desafortunado detalle de implementación de Django que otra cosa.
Puedes aceptar que tendrás algunas excepciones después de un despliegue con una migración de modelo, y limitar el daño estableciendo TTL's de caché razonablemente cortos. Mejor aún, evitar el almacenamiento en caché de modelos como una regla. En su lugar, almacene en caché sólo las claves primarias y busque los objetos en la base de datos. (Normalmente, las búsquedas de claves primarias son bastante baratas. Son las consultas SELECT para encontrar esos IDs las que son caras).
Llevando esto un paso más allá para evitar por completo las visitas a la base de datos, puedes almacenar en caché los modelos de Django de forma segura si sólo mantienes una copia en caché de una instancia del modelo. Entonces, es bastante trivial invalidar esa caché ante cambios en el esquema del modelo. Nuestra solución fue crear un hash único de los campos conocidos y añadirlo a nuestra clave de caché (p.ej. Foo:96f8148eb2b7:123
). Cada vez que se añade, renombra o elimina un campo, los cambios en el hash invalidan la caché.
Conclusión
Django es definitivamente un framework potente y lleno de características para empezar con tu servicio backend, pero hay sutilezas a tener en cuenta que pueden ahorrarte dolores de cabeza más adelante. Definir las aplicaciones Django cuidadosamente e implementar una buena organización del código desde el principio te ayudará a evitar trabajo de refactorización innecesario más adelante. Mientras tanto, tomando el control total sobre tu esquema de base de datos y siendo deliberado sobre cómo usas las características de Django como GenericForeignKey's y el ORM, puedes asegurarte de que no estás demasiado acoplado al framework y migrar a otras tecnologías o arquitecturas en el futuro.
Pensando en estas cosas, puedes mantener la flexibilidad para evolucionar y escalar tu backend en el futuro. Esperamos que algunas de las cosas que hemos aprendido sobre el uso de Django te ayuden a crear tus propias aplicaciones.